DpExchange.Webull (DpExchangeWebull v0.4.53)

Copy Markdown View Source

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 brokermqtt-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.

Not supported. This package cannot answer market status honestly for this venue.

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

add_watchlist_instruments(id, symbols, opts \\ [])

@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.

adjusted?(timeframe)

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

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

asset_classes()

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.

check_token(token, opts \\ [])

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

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.

coverage_by_kind(opts \\ [])

@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.

create_token(opts \\ [])

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

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.

create_watchlist(name, symbols, opts \\ [])

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.

delete_watchlist(id, opts \\ [])

Deletes a watchlist and everything in it. Irreversible.

See DpExchange.Webull.Rest.delete_watchlist/3.

fundamental_kinds()

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

The fundamentals kinds get_fundamental/3 reaches.

get_accounts(credentials, opts)

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.

get_auction_imbalance(symbol, opts \\ [])

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.

get_balances(credentials, opts)

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.

get_corporate_events(opts \\ [])

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.

get_event_order_book(symbol, opts \\ [])

@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.

get_event_trades(symbol, opts \\ [])

@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.

get_fees(credentials, opts)

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.

get_filings(symbol, opts \\ [])

Regulatory filings this venue indexes. Points at them; never fetches them.

See DpExchange.Webull.Rest.get_filings/3.

get_financials(symbol, kind, opts \\ [])

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, 14 for quarters.

get_fundamental(kind, symbol, opts \\ [])

@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.

get_news(opts \\ [])

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.

get_option_chain(underlying, opts \\ [])

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.

get_option_expirations(underlying, opts \\ [])

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.

get_order_book(symbol, opts \\ [])

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.

get_positions(opts)

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.

get_screener(name, opts \\ [])

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.

get_trades(symbol, opts \\ [])

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.

get_transactions(credentials, opts)

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.

get_transfers(credentials, opts)

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.

get_volume_profile(symbol, timeframe, opts \\ [])

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.

get_watchlist(id, opts \\ [])

One watchlist including its membership.

See DpExchange.Webull.Rest.get_watchlist/3. name is nil here: the membership endpoint does not return it.

list_event_categories(opts \\ [])

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

Every event-contract category — the root of category → series → event → market.

See DpExchange.Webull.Rest.list_event_categories/2.

list_event_events(opts \\ [])

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

The events under one series. opts[:series_symbol] is required.

See DpExchange.Webull.Rest.list_event_events/2.

list_event_markets(opts \\ [])

@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.

list_event_series(opts \\ [])

@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.

list_futures_contracts(opts \\ [])

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

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.

list_futures_product_classes(opts \\ [])

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

The futures product classification groups.

See DpExchange.Webull.Rest.list_futures_product_classes/2.

list_watchlists(opts \\ [])

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".

live?(opts \\ [])

@spec live?(keyword()) :: boolean()

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

market_status(opts)

Not supported. This package cannot answer market status honestly for this venue.

Webull is not crypto-only — asset_classes/0 is [:crypto, :equity, :option, :future, :event_contract], and equities, options and futures all trade on real, exchange-set hours. market_status/1 takes no symbol or asset-class argument at all: it answers ONE status for the WHOLE venue, so even a working implementation could not honestly report a single open/closed value across a venue that spans a 24/7 asset class and four that are not.

That question is moot here anyway: the venue publishes nothing this package can reach. Webull's OpenAPI (docs/reference/webull/endpoint-inventory.md) documents 85 endpoints and none of them is a market-status or trading-calendar call. The one trading-calendar endpoint Webull publishes anywhere, GET /broker/master-data/trading-calendars/list, belongs to a different product entirely — the Broker API, served from broker-api.webull.com rather than this package's api.webull.com/us-openapi-alb.uat.webullbroker.com, and reachable only with its own broker-tier credential, obtained through a separate business relationship ("contact our business team" — developer.webull.com/apis/docs/broker-api/about-broker- api.md). This package's credentials() is an OpenAPI App Key/App Secret pair; it does not model that credential and this package has no relationship that could supply one. Calling that endpoint with an OpenAPI credential would not honestly serve this venue's callers — it would 401 regardless of what any caller here supplies.

Previously {:ok, :open} unconditionally, found by dp_exchange_core 0.1.66's widened assertion 17: this venue declares credential_benefit: :required and that literal answered {:ok, _} with no credential — true by construction (nothing here ever read one), and a false claim for four of the five asset classes this package serves. Verified against the vendor's own documentation, not assumed — see docs/reference/webull/negative-claims.md.

oauth_token(client_id, client_secret, opts \\ [])

@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.

place_orders(credentials, requests, opts)

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.

preview_order(credentials, request, opts \\ [])

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.

quantization(symbol, opts \\ [])

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.

quotes()

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

The quote currencies this venue settles in.

remove_watchlist_instruments(id, symbols, opts \\ [])

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

Removes instruments from a watchlist, by symbol and category.

See DpExchange.Webull.Rest.remove_watchlist_instruments/4.

replace_order(credentials, client_order_id, changes, opts \\ [])

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.

screeners()

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

The screener identifiers get_screener/2 takes.

sort_watchlist_instruments(id, opts \\ [])

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

Reorders instruments within a watchlist. opts[:sorts] maps symbol to position.

See DpExchange.Webull.Rest.sort_watchlist_instruments/3.

streaming?(opts \\ [])

@spec streaming?(keyword()) :: boolean()

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.

update_watchlist(id, opts \\ [])

Renames a watchlist or changes its sort order. Does not change membership.

See DpExchange.Webull.Rest.update_watchlist/3opts[:symbols] is refused rather than silently skipped, and add_watchlist_instruments/3 is the membership write.

venue_does_not_serve()

@spec venue_does_not_serve() :: [{atom(), arity()}]

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.