Webull, behind the DpExchange facade.
⚠️ EXPERIMENTAL
This package has not run in production. While it is 0.x the API may change without a
major version — pin all three segments. Maturity is declared per endpoint through
capabilities/0; do not read this banner as your check.
This module is the entire public API of this package. Transport, signing, session handling and supervision are internal, and nothing here returns them.
Credentials are required for market data, which is unusual
Every Webull OpenAPI call is signed — including the ones that look public. There is no
anonymous quote endpoint. capabilities/0 declares credential_benefit: :required, the
first venue in this family to do so, and get_price/2 takes credentials that the same
call on other venues does not need.
The host still owns authentication: it holds the App Key and secret and passes them in. This package signs one request with them and keeps nothing.
Two transports, and you see neither
Market data arrives over MQTT 3.1.1 carried on a WebSocket; subscriptions are
HTTP calls; the two are joined by a session identifier this package generates. You
call subscribe/2 with symbols.
That matters beyond tidiness. The venue does not restore subscriptions after a reconnect — this package replays them. A consumer doing that itself would have to notice reconnects, which is exactly what the facade exists to hide.
The UAT environment has REST but no stream
environment: :uat gives authenticated REST against test data. It has no broker —
mqtt-uat.webullbroker.com does not resolve — so subscribe/2 there returns
{:error, {:streaming_unavailable, :uat}} rather than quietly falling back to
production. A consumer testing against UAT that received production prices would be
reading real market data believing it was fake.
Trade volume is real on stocks, absent on crypto
Webull's crypto OpenAPI reports no aggregate volume: not on bars, not on the
snapshot's Quote.volume. That field is nil, never 0 on a crypto quote — zero
would claim a genuinely flat interval.
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 says
reports_trade_volume: true because that path is real and active; measured_against
carries the crypto/equity split so a caller does not read the boolean as "every quote
on every category has a number." Bars carry no volume on any category.
This is a different claim from a Trade.quantity — the streamed tape (subscribe/2)
and get_trades/2 both report one print's own size, which the venue does publish.
A sum of individual sizes is not the same statement as the venue's own aggregate
figure, and this package does not compute one from the other.
Historical widths: eleven reachable, ten declared — Core cannot yet name the eleventh
capabilities/0's historical_timeframes names ten canonical widths — Rest's eight
plus 1w and 1M — because the equity, option and futures bars serve three the crypto
and event-contract bars do not: 1w, 1M and 1y. Core.Capabilities has one
flat list for the whole package, with no room to say "these three only on these asset
classes," so the declaration is the union of what any active path serves and Core can
name.
1y is the one exception, and it is a Core gap rather than an under-declaration
here. dp_exchange_core 0.1.48's Timeframe.nameable/0 admits 1w and 1M beyond
what it can bucket, but not 1y — declaring it raises Capabilities.new/1's own
vocabulary check. This package serves 1y on the equity, option and futures bars
(Rest.get_stock_bars/5, tested) and cannot say so in this struct until Core's
vocabulary widens by one more width. get_historical_prices/5 still refuses
1w/1M/1y per-call on a crypto or event-contract category rather than degrading to
the nearest width it does serve.
Supervision
children = [{DpExchange.Webull, []}]
{:ok, quote} = DpExchange.Webull.get_price("BTC-USD", credentials: my_credentials())
Summary
Functions
Adds instruments to a watchlist.
Whether bars of timeframe, on the equity/ETF tape, are forward-adjusted.
The asset classes this package serves.
Checks a token's status — the call that tells PENDING from EXPIRED from INVALID.
What is arriving, per symbol, split by which kind of data it is.
Creates a server-to-server token. The token comes back PENDING and is not usable yet.
Creates a watchlist and adds symbols to it.
Deletes a watchlist and everything in it. Irreversible.
The fundamentals kinds get_fundamental/3 reaches.
Every account this credential can reach, as the venue records them.
The auction order imbalance. opts[:auction] is required — :opening or :closing.
Balances for one account. Requires opts[:account_id].
Dividends and earnings dates for one issuer. opts[:symbol] is required.
The order book for one event-contract market — four books, not two.
The tape for one event-contract market.
Webull's crypto fee — see DpExchange.Webull.Rest.get_fees/2 for the source and why
credentials is unused: the venue publishes one flat rate, not a tier a credential
selects among.
Regulatory filings this venue indexes. Points at them; never fetches them.
Financial statements for an issuer.
One fundamentals endpoint by kind — the venue's own rows, unnormalised.
News summaries. Generated, not reported — the venue's own description is "invokes LLM
to generate news summaries", so each summary is a model's paraphrase.
The option chain for an underlying — expiry × strike, both sides.
The expiries listed on an underlying.
The order book for an equity or ETF.
Open positions on one account. Requires opts[:account_id].
A venue screener by the venue's own identifier — screeners/0 lists them.
Tick-by-tick public trades for an equity or ETF — the tape.
Every cash activity on one account — wider than get_transfers/2 and wider than fills.
Money into and out of one account. Requires opts[:account_id].
Traded volume split by price and by side, for an equity.
One watchlist including its membership.
Every event-contract category — the root of category → series → event → market.
The events under one series. opts[:series_symbol] is required.
The tradable markets under a series or an event, paged.
Event-contract series, paged.
Futures contracts by symbol or by product code.
The futures product classification groups.
The watchlists held at the venue.
Whether the environment opts resolves to moves real money.
The OAuth code exchange and refresh — one endpoint, two operations.
Places several orders in one request. This moves funds.
Prices an order without placing it. Requires opts[:account_id].
Rounds a price and quantity to what the venue will actually accept.
The quote currencies this venue settles in.
Removes instruments from a watchlist, by symbol and category.
Amends a working order in place, keyed on its client order id.
The screener identifiers get_screener/2 takes.
Reorders instruments within a watchlist. opts[:sorts] maps symbol to position.
Whether this environment carries a live stream.
Renames a watchlist or changes its sort order. Does not change membership.
Endpoints the venue does not serve, as distinct from ones this package has not ported.
Functions
@spec add_watchlist_instruments(String.t(), [String.t()], keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Adds instruments to a watchlist.
Venue-specific: the contract's update_watchlist/2 does not reach membership on this
venue. See DpExchange.Webull.Rest.add_watchlist_instruments/4.
Whether bars of timeframe, on the equity/ETF tape, are forward-adjusted.
See DpExchange.Webull.Rest.adjusted?/1 for the venue's rule: daily and above are
forward-adjusted, minute bars are not. Exposed here because that answer is for a
caller of get_historical_prices/4, not for anything inside this package — nothing
here stitches two widths together, and Core.Types.Candle has no field to carry the
answer on the bar itself. A caller doing that stitching across a split needs to know the
two widths are not the same series before it joins them, not after.
DpExchange.Webull.adjusted?("1d") #=> true
DpExchange.Webull.adjusted?("1m") #=> false
The asset classes this package serves.
:equity is here because the order builder serves it, not because the venue does.
Webull publishes stocks, options, futures and event contracts alongside crypto; this
package's order path now builds all five instrument types, its preview and replace
endpoints work on the four non-crypto ones, and get_accounts/2 already returns the
CRYPTO, FUTURES and EVENTS_CASH account classes the credential reaches.
The coarse vocabulary is :crypto | :equity | :option | :future | :event_contract; the
finer statement is supported_instrument_types below. A declaration that outran the code
would be the worse error — a consumer routes on this.
Options joined on 2026-09-01: the contract list, snapshot, bars and tape are live, and
US_OPTION reaches its own endpoints rather than being refused on the stock ones — which
is what this package claimed until that date, and was a false negative about the venue.
Futures and event contracts joined on the same day: futures snapshots, bars, ticks,
depths and footprints route by US_FUTURES, and the event-contract hierarchy — category,
series, event, market — plus its snapshots, bars, ticks and depths are reachable. Each
class is declared per endpoint through capabilities/0's endpoints map, which is why
that map exists rather than one flag per package.
Two event-contract endpoints are deliberately not behind the contract's callbacks. An
event tick has a yes price, a no price and a side of yes/no; an event book has four
sides. Types.Trade and Types.OrderBook have room for neither, so get_event_trades/2
and get_event_order_book/2 return the venue's own rows and get_trades/2 and
get_order_book/2 refuse US_EVENT rather than filing a print against the other
instrument of a two-instrument market.
Checks a token's status — the call that tells PENDING from EXPIRED from INVALID.
See DpExchange.Webull.Rest.check_token/3. All three fail the same way at the next
request, and only this endpoint says which.
@spec coverage_by_kind(keyword()) :: %{ required(DpExchange.Core.Capabilities.data_kind()) => %{ required(DpExchange.Core.Venue.symbol()) => DpExchange.Core.Venue.route() } }
What is arriving, per symbol, split by which kind of data it is.
coverage/1 answers "is anything arriving for this symbol" with one boolean per
symbol — truthful, but it folds every kind of payload into that single answer. A venue
streaming more than one kind can have one kind healthy and another dark for the same
symbol, and coverage/1 alone cannot say which: it marks a symbol :stream the moment
any payload for it arrives, regardless of what kind that payload was. A Coinbase venue
in this family once reported full coverage for hundreds of symbols this way while one of
its two streamed kinds had gone dark for nearly all of them — the discrepancy hid behind
the single boolean across two issues before anyone noticed.
This venue genuinely has three independent kinds, not one adopted only for cross-venue
uniformity: every subscribe asks the venue for SNAPSHOT, QUOTE and TICK, and this
package's socket decodes them on separate topics into three different structs — a
snapshot becomes DpExchange.Core.Types.Quote (kind :quotes, a traded price), a quote
becomes DpExchange.Core.Types.TopOfBook (kind :top_of_book, bid/ask), a tick becomes
DpExchange.Core.Types.Trade (kind :trades, one print) — and any one topic can go
quiet without the others doing the same. So the same failure this callback exists to
catch on Coinbase can genuinely happen here too: a symbol present under :quotes while
absent under :top_of_book, or any other combination.
The kind reported is derived from the struct type that actually arrived, never assumed
from capabilities/0's own streamable list — so a further kind reaching the feed
without this function being updated for it is caught rather than silently folded into
an existing kind.
Creates a server-to-server token. The token comes back PENDING and is not usable yet.
Venue-specific. See DpExchange.Webull.Rest.create_token/2 — verification happens through
an SMS code in the Webull app, which needs a person.
Creates a watchlist and adds symbols to it.
See DpExchange.Webull.Rest.create_watchlist/4. Two requests, and where the second
fails the watchlist exists and is empty — the error carries its id.
Deletes a watchlist and everything in it. Irreversible.
@spec fundamental_kinds() :: [atom()]
The fundamentals kinds get_fundamental/3 reaches.
Every account this credential can reach, as the venue records them.
See DpExchange.Webull.Rest.get_accounts/2, including what account_class reveals about
this venue's breadth.
The auction order imbalance. opts[:auction] is required — :opening or :closing.
See DpExchange.Webull.Rest.get_auction_imbalance/3, including why the venue's own time
matters as much as the numbers outside an auction window.
Balances for one account. Requires opts[:account_id].
See DpExchange.Webull.Rest.get_balances/2 — in particular why available_balance is
nil on this venue.
Dividends and earnings dates for one issuer. opts[:symbol] is required.
See DpExchange.Webull.Rest.get_corporate_events/2. Without opts[:kind] both calendars
are read, which is two requests.
@spec get_event_order_book( String.t(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
The order book for one event-contract market — four books, not two.
See DpExchange.Webull.Rest.get_event_order_book/3. Types.OrderBook has one bid side and
one ask side; a binary market quotes YES and NO separately and neither is the other.
@spec get_event_trades( String.t(), keyword() ) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
The tape for one event-contract market.
Not get_trades/2. See DpExchange.Webull.Rest.get_event_trades/3: an event tick has
a yes price, a no price and a side of yes/no, and Types.Trade has room for one price
and a side of :buy/:sell.
Webull's crypto fee — see DpExchange.Webull.Rest.get_fees/2 for the source and why
credentials is unused: the venue publishes one flat rate, not a tier a credential
selects among.
Regulatory filings this venue indexes. Points at them; never fetches them.
Financial statements for an issuer.
See DpExchange.Webull.Rest.get_financials/4. Line items keep the venue's own names, and
fiscal_period keeps its integer code — 0 for the full year, 1–4 for quarters.
@spec get_fundamental(atom(), String.t(), keyword()) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
One fundamentals endpoint by kind — the venue's own rows, unnormalised.
Venue-specific: twenty-three endpoints under one shape, of which the contract has types
for four. fundamental_kinds/0 lists them; see
DpExchange.Webull.Rest.get_fundamental/4.
News summaries. Generated, not reported — the venue's own description is "invokes LLM
to generate news summaries", so each summary is a model's paraphrase.
See DpExchange.Webull.Rest.get_news/2. opts[:symbols] is required.
The option chain for an underlying — expiry × strike, both sides.
See DpExchange.Webull.Rest.get_option_chain/3. The venue publishes a flat contract list
and the grid is rebuilt here; a contract this package cannot address is refused rather
than dropped, so a chain never comes back with a hole in it that looks complete.
The expiries listed on an underlying.
See DpExchange.Webull.Rest.get_option_expirations/3. Webull publishes no expiry-only
endpoint, so these are the distinct expiries of a real contract list — a narrowing of a
response the venue sent, not a substitute for one it did not.
The order book for an equity or ETF.
See DpExchange.Webull.Rest.get_order_book/3 — including why a crypto symbol is refused
here, and what per-participant attribution this contract has no place for.
Open positions on one account. Requires opts[:account_id].
See DpExchange.Webull.Rest.get_positions/2, including why the side comes from the sign
of the quantity rather than an assumption.
A venue screener by the venue's own identifier — screeners/0 lists them.
See DpExchange.Webull.Rest.get_screener/3. Nothing is merged or re-ranked: the rank is
the position the venue returned the row in.
Tick-by-tick public trades for an equity or ETF — the tape.
See DpExchange.Webull.Rest.get_trades/3, including why three of the venue's five side
codes map to nil.
Every cash activity on one account — wider than get_transfers/2 and wider than fills.
See DpExchange.Webull.Rest.get_transactions/2. The two are not interchangeable: a
dividend and a deposit both credit cash and neither is the other, so a caller computing
contributions uses get_transfers/2 and one reconciling a balance uses this.
Money into and out of one account. Requires opts[:account_id].
See DpExchange.Webull.Rest.get_transfers/2 — in particular why the endpoint's wider
activity types are excluded unless asked for, and what the venue's 7-day default means
for an empty answer.
Traded volume split by price and by side, for an equity.
See DpExchange.Webull.Rest.get_volume_profile/4 — including the five widths this
endpoint serves and why real_time_required is sent as false.
One watchlist including its membership.
See DpExchange.Webull.Rest.get_watchlist/3. name is nil here: the membership
endpoint does not return it.
Every event-contract category — the root of category → series → event → market.
The events under one series. opts[:series_symbol] is required.
@spec list_event_markets(keyword()) :: {:ok, %{rows: [map()], pagination_key: String.t() | nil}} | {:error, term()} | {:refused, term()}
The tradable markets under a series or an event, paged.
See DpExchange.Webull.Rest.list_event_markets/2. status and tradable_status are two
different fields and both survive.
@spec list_event_series(keyword()) :: {:ok, %{rows: [map()], pagination_key: String.t() | nil}} | {:error, term()} | {:refused, term()}
Event-contract series, paged.
See DpExchange.Webull.Rest.list_event_series/2. The returned :pagination_key is nil
on the last page, which is how the end is told from a truncation.
Futures contracts by symbol or by product code.
Venue-specific: the contract has no callback for a futures chain. See
DpExchange.Webull.Rest.list_futures_contracts/2 — one of opts[:symbols] or
opts[:code] is required.
@spec list_futures_product_classes(keyword()) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}
The futures product classification groups.
The watchlists held at the venue.
See DpExchange.Webull.Rest.list_watchlists/2. symbols is nil on every row — this
endpoint names watchlists and does not list membership, and nil is "not asked" where
[] would be "empty".
Whether the environment opts resolves to moves real money.
Resolves opts[:environment] through the same precedence as every call this package
makes — an explicit option, then DpExchange.Core.Config, then :production — and
answers with DpExchange.Webull.Environment.live?/1. Meant as a check a caller makes of
itself before a money-moving call such as place_order/3, in the same spirit as
capabilities/0's own declaration: the default is production and that is the direction
where a wrong guess costs money, so a caller that wants to be certain asks rather than
assumes.
if DpExchange.Webull.live?(opts) do
# confirm with the human before placing this order
end
@spec oauth_token(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
The OAuth code exchange and refresh — one endpoint, two operations.
See DpExchange.Webull.Rest.oauth_token/3. opts[:code] exchanges, opts[:refresh_token]
refreshes, and exactly one of them is required. Two expiries come back and they are not
the same clock: rt_expires_in is the one that ends the session.
Places several orders in one request. This moves funds.
See DpExchange.Webull.Rest.place_orders/3. Fifty at most and equities only, both the
venue's own limits and both enforced before the request; the vendor also notes the endpoint
is not available to every client.
Prices an order without placing it. Requires opts[:account_id].
See DpExchange.Webull.Rest.preview_order/3 — including why crypto is refused, and what
estimated_cost means on a futures order.
Rounds a price and quantity to what the venue will actually accept.
See DpExchange.Webull.Rest.quantization/3 for the source and the crypto/stock field
split. This venue signs every request (credential_benefit: :required), and
quantization/1's contract carries no opts — there is nowhere to put a credential in
a call shaped that way. Accepting opts here, as get_symbols/1 already does, is the
only way a caller on this venue can pass one; a caller that does not still gets the
arity the behaviour promises, and an honest signing failure rather than a blanket
refusal that would say this is unbuilt when it is not.
@spec quotes() :: [String.t()]
The quote currencies this venue settles in.
@spec remove_watchlist_instruments(String.t(), [String.t()], keyword()) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Removes instruments from a watchlist, by symbol and category.
Amends a working order in place, keyed on its client order id.
See DpExchange.Webull.Rest.replace_order/4 — including which fields each order type
allows, and why the order is read back rather than reported from the request.
@spec screeners() :: [String.t()]
The screener identifiers get_screener/2 takes.
@spec sort_watchlist_instruments( String.t(), keyword() ) :: {:ok, map()} | {:error, term()} | {:refused, term()}
Reorders instruments within a watchlist. opts[:sorts] maps symbol to position.
Whether this environment carries a live stream.
Exposed because the answer differs by environment on this venue and a consumer should be able to ask before it commits, rather than learning from a subscription that never delivers.
Renames a watchlist or changes its sort order. Does not change membership.
See DpExchange.Webull.Rest.update_watchlist/3 — opts[:symbols] is refused rather than
silently skipped, and add_watchlist_instruments/3 is the membership write.
Endpoints the venue does not serve, as distinct from ones this package has not ported.
Both answer {:error, :not_supported}, and a caller acts the same way on either — but
they mean different things to anyone deciding what to build next, so they are told apart
here rather than flattened into one list.
Every entry is recorded with its source and the date consulted in
docs/reference/webull/negative-claims.md.