DpExchange.Webull (DpExchangeWebull v0.4.167)

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 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 documents no aggregate volume: not on bars, not on the snapshot's Quote.volume. That field is nil, never 0, on a crypto quote whose response carries none — zero would claim a genuinely flat interval. A response that does carry a volume is delivered as a :running_total (a documented field list is not a measurement); a crypto bar's volume is likewise nil unless the row states 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 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 split the same way: the stock, option, futures and event bar rows document a volume and it is carried through, while the crypto bar row documents none and its volume is nil.

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, eleven declared

capabilities/0's historical_timeframes names eleven canonical widths — Rest.wide_timeframes/0: Rest's eight plus 1w, 1M and 1y — because the equity, option and futures bars serve three the crypto and event-contract bars do not. 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. (1y was withheld until dp_exchange_core 0.1.57 widened Timeframe.nameable/0; it is declared now.) get_historical_prices/4 still refuses 1w/1M/1y per-call on a crypto or event-contract category rather than degrading to the nearest width it does serve.

Options are checked, not ignored

A function given an option it does not read answers {:error, {:unknown_option, key}}, and a non-keyword opts is {:error, {:invalid_options, opts}}: a misspelt limt: must not return an answer to a question the caller did not ask. The check runs before anything is sent and before the facade adds its own :limiter, after the positional arguments (symbol, order id, watchlist id, request) and before the credentials. The keys each function reads are written once, in DpExchange.Webull.Options; coverage/1, wanted/1, coverage_by_kind/1, streaming?/1 and live?/1, which have no error channel, raise ArgumentError instead. A function that answers {:error, :not_supported} does so whatever it is given.

Supervision

children = [{DpExchange.Webull, []}]

{:ok, quote} = DpExchange.Webull.get_price("BTC-USD", credentials: my_credentials())

Summary

Functions

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.add_payment_method/2.

Adds instruments to a watchlist.

Whether bars of timeframe, on the equity/ETF tape, are forward-adjusted.

The asset classes this package serves.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.cancel_all_orders/2.

Cancels one order by its client order id. This acts on a live account.

What this package claims about Webull, per endpoint and with the evidence in measured_against.

Checks a token's status — the call that tells PENDING from EXPIRED from INVALID.

The supervisor child spec for this venue's tree, with :credentials wrapped so they never appear in a crash report.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.close_position/3.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.commit_conversion/2.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.convert/4.

Which symbols are arriving on the stream, as %{symbol => :stream} read from the running feed.

What is arriving, per symbol, split by which kind of data it is.

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.create_account/1.

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.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.estimate_withdrawal_fee/4.

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

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_contract_stats/2.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_conversion/2.

Dividends and earnings dates for one issuer. opts[:symbol] is required.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_deposit_address/3.

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.

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_funding/2.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_fx_rate/3.

Candles for one symbol at one of the eleven declared widths.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_market_overview/1.

News about the symbols asked for: the venue's own items (GET /market-data/news/stock-news/list), each with a headline, summary, link, publisher, time and the venue's related symbols. Until 2026-10-18 this was the venue's LLM-generated summary endpoint, which returned one paraphrase and no headline, link or time; the vendor now publishes itemised news, and that is what this reads.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_notional_balances/3.

The option chain for an underlying — expiry × strike, both sides.

The expiries listed on an underlying.

Not served by this venue: answers {:error, :not_supported}. Webull publishes no greeks. See DpExchange.Core.Venue.get_option_greeks/2.

One order by its client order id.

The order book for an equity, ETF or future.

Orders on one account.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_payment_method/3.

Open positions on one account. Requires opts[:account_id].

The last price for one symbol, from the venue's signed snapshot endpoint.

Last prices for many crypto symbols at once, 20 per request: one result per symbol asked for, each in get_price/2's own shape.

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_roles/1.

A venue screener by the venue's own identifier — screeners/0 lists them.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_balances/1.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_history/1.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_rates/1.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_rewards/1.

The tradable symbols, read from the venue's paginated instrument catalogue.

The best bid and ask for one symbol, from the signed REST API.

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_trade_history/2.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_trade_volume/2.

Tick-by-tick public trades for an equity, ETF, option or future — 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.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_approved_addresses/1.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_custody_fees/2.

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.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_fee_promos/1.

Futures contracts by symbol or by product code.

The futures product classification groups.

Every US_CRYPTO listing — base, quote, instrument type and trading status — from the same paginated catalogue get_symbols/1 reads.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_networks/2.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_payment_methods/2.

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_portfolios/1.

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 one order. This moves funds.

Places several orders in one request. This moves funds.

Prices an order without placing it. Requires opts[:account_id].

The venue's display name, "Webull". See DpExchange.Core.Venue.provider_name/0.

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

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.quote_conversion/4.

The quote currencies the symbol mapping recognises when splitting a native symbol.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.remove_approved_address/3.

Removes instruments from a watchlist, by symbol and category.

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.rename_account/3.

Amends a working order in place, keyed on its client order id.

The venue's runtime id, :webull. See DpExchange.Core.Venue.runtime_id/0.

The screener identifiers get_screener/2 takes.

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

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.stake/3.

Starts this venue's supervision tree (DpExchange.Webull.Supervisor.start_link/1).

Whether this environment carries a live stream.

Subscribes opts[:to] (default self()) to the symbols over the venue's MQTT stream.

Subscribes opts[:to] (default self()) to the feed's notices.

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.test_connection/2.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.transfer_internal/4.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.unstake/3.

Stops delivery for the named symbols; a stopped feed answers :ok.

Replaces the live subscription's symbol set; a stopped feed answers {:error, :feed_not_started}.

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.

The symbols the consumer asked this package to stream, sorted, as opposed to coverage/1, which is what is observed arriving.

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.withdraw/5.

Functions

add_payment_method(details, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.add_payment_method/2.

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.

cancel_all_orders(credentials, opts \\ [])

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.cancel_all_orders/2.

cancel_order(credentials, client_order_id, opts \\ [])

Cancels one order by its client order id. This acts on a live account.

Requires opts[:account_id] and the credentials. See DpExchange.Core.Venue.cancel_order/3.

capabilities()

What this package claims about Webull, per endpoint and with the evidence in measured_against.

Every callback not in venue_does_not_serve/0 or the not-ported list is :experimental; the rest are :unsupported. See DpExchange.Core.Venue.capabilities/0.

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.

child_spec(opts)

The supervisor child spec for this venue's tree, with :credentials wrapped so they never appear in a crash report.

Supervise it as {DpExchange.Webull, opts}; nothing starts until you do. The child is a :supervisor and opts[:name] becomes its id. See DpExchange.Core.Venue.child_spec/1.

close_position(credentials, symbol, opts \\ [])

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.close_position/3.

commit_conversion(id, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.commit_conversion/2.

convert(from, to, amount, opts \\ [])

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.convert/4.

coverage(opts \\ [])

Which symbols are arriving on the stream, as %{symbol => :stream} read from the running feed.

Answers %{} when no feed is running or it does not answer. Reads opts[:feed] to name a feed other than the default. See DpExchange.Core.Venue.coverage/1.

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 has two independent declared kinds, not two 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 (one print, delivered but not a declared kind, see below) — 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 the reverse.

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.

:trades never appears here, although TICK is requested and a Trade that arrives is delivered. streamable does not declare :trades (the venue has published no tick for us-crypto in either measurement), and Core's contract is that every key here is a declared kind and that coverage/1 is exactly their union. A tick is therefore delivered and left out of both until a run shows ticks arriving and the declaration moves with it.

create_account(opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.create_account/1.

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.

estimate_withdrawal_fee(asset, network, amount, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.estimate_withdrawal_fee/4.

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.

Reads opts[:auction] and opts[:history]; any other key is {:error, {:unknown_option, key}}.

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_contract_stats(symbol, opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_contract_stats/2.

get_conversion(id, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_conversion/2.

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_deposit_address(asset, network, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_deposit_address/3.

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, 1–4 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_funding(symbol, opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_funding/2.

get_fx_rate(pair, at, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_fx_rate/3.

get_historical_prices(symbol, timeframe, range \\ [], opts \\ [])

Candles for one symbol at one of the eleven declared widths.

A width the symbol's category does not serve (1w, 1M, 1y on crypto and event contracts) is refused, not rounded to a neighbour. Reads opts[:credentials] (required), opts[:category], opts[:limit], opts[:include_partial] and opts[:sessions]; any other key is {:error, {:unknown_option, key}}. See DpExchange.Core.Venue.get_historical_prices/4.

get_market_overview(opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_market_overview/1.

get_news(opts \\ [])

News about the symbols asked for: the venue's own items (GET /market-data/news/stock-news/list), each with a headline, summary, link, publisher, time and the venue's related symbols. Until 2026-10-18 this was the venue's LLM-generated summary endpoint, which returned one paraphrase and no headline, link or time; the vendor now publishes itemised news, and that is what this reads.

opts[:symbols] is required; opts[:category] is US_STOCK (default) or HK_STOCK; opts[:sentiment] filters on the venue's POSITIVE/NEGATIVE/NEUTRAL label. opts[:limit] is items wanted per symbol; the venue's cursor is followed until that many arrive or the feed ends, and a cap reached first is {:error, :history_truncated}, not a short list. See DpExchange.Webull.Rest.get_news/2.

get_notional_balances(credentials, currency, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_notional_balances/3.

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_option_greeks(symbol, opts)

Not served by this venue: answers {:error, :not_supported}. Webull publishes no greeks. See DpExchange.Core.Venue.get_option_greeks/2.

get_order(credentials, client_order_id, opts \\ [])

One order by its client order id.

Requires opts[:account_id] and the credentials. See DpExchange.Core.Venue.get_order/3.

get_order_book(symbol, opts \\ [])

The order book for an equity, ETF or future.

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.

Reads opts[:category], opts[:depth], opts[:overnight] and opts[:extended_hours]; opts[:limit] is {:error, {:unsupported_option, :limit}} (levels per side are :depth) and any other key is {:error, {:unknown_option, key}}.

get_orders(credentials, opts \\ [])

Orders on one account.

Requires opts[:account_id] and the credentials. See DpExchange.Core.Venue.get_orders/2.

get_payment_method(credentials, id, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_payment_method/3.

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, and why a multi-leg option strategy or an event contract on the account answers {:error, {:unsupported_position, symbol, reason}} rather than a Position that drops the legs or the yes/no outcome.

get_price(symbol, opts \\ [])

The last price for one symbol, from the venue's signed snapshot endpoint.

Reads opts[:credentials] (required: every Webull call is signed), opts[:category], opts[:extended_hours], opts[:overnight] and opts[:environment]. Any other key is {:error, {:unknown_option, key}}, see DpExchange.Webull.Options. See DpExchange.Core.Venue.get_price/2.

get_prices(symbols, opts \\ [])

@spec get_prices([String.t()], keyword()) ::
  {:ok,
   %{
     required(String.t()) =>
       {:ok, DpExchange.Core.Types.Quote.t()}
       | {:error, term()}
       | {:refused, term()}
   }}
  | {:error, term()}

Last prices for many crypto symbols at once, 20 per request: one result per symbol asked for, each in get_price/2's own shape.

For a consumer covering many pairs from REST, get_price/2 per symbol spends one request of the endpoint's 1/s budget on each, where this spends one on twenty. See DpExchange.Webull.Rest.get_prices/3 (issue #9).

get_rate_limit_status(credentials, opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_rate_limit_status/2.

get_roles(opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_roles/1.

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_staking_balances(opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_balances/1.

get_staking_history(opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_history/1.

get_staking_rates(opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_rates/1.

get_staking_rewards(opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_staking_rewards/1.

get_symbols(opts \\ [])

The tradable symbols, read from the venue's paginated instrument catalogue.

Reads opts[:credentials] (required) and opts[:category]; any other key is {:error, {:unknown_option, key}}. See DpExchange.Core.Venue.get_symbols/1.

get_top_of_book(symbol, opts \\ [])

The best bid and ask for one symbol, from the signed REST API.

Reads opts[:credentials] (required), opts[:category], opts[:extended_hours], opts[:overnight] and opts[:environment]; any other key is {:error, {:unknown_option, key}}. See DpExchange.Core.Venue.get_top_of_book/2.

get_trade_history(credentials, opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_trade_history/2.

get_trade_volume(credentials, opts \\ [])

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.get_trade_volume/2.

get_trades(symbol, opts \\ [])

Tick-by-tick public trades for an equity, ETF, option or future — the tape.

See DpExchange.Webull.Rest.get_trades/3, including why every trade's side is nil (the venue's B/S/G/L/N codes are defined nowhere it publishes).

Reads opts[:category], opts[:limit] and opts[:sessions]; opts[:since] is {:error, {:unsupported_option, :since}} and any other key is {:error, {:unknown_option, key}}.

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.

Reads opts[:category], opts[:session] and opts[:count]; any other key is {:error, {:unknown_option, key}}.

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_approved_addresses(opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_approved_addresses/1.

list_custody_fees(credentials, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_custody_fees/2.

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_fee_promos(opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_fee_promos/1.

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_instruments(opts \\ [])

Every US_CRYPTO listing — base, quote, instrument type and trading status — from the same paginated catalogue get_symbols/1 reads.

US_STOCK/US_ETF refuse by name ({:error, {:unsupported_instrument_category, category}}) rather than being served: see DpExchange.Webull.Rest.list_instruments/2 for why the stock profile schema has nothing honest to put in base/quote/ instrument for either. Each instrument's own status field tells a symbol this venue is currently refusing new orders on (:unknown) apart from one it will accept (:tradable) — get_symbols/1 keeps listing both, unfiltered, per Core's contract.

See DpExchange.Webull.Rest.list_instruments/2 for the full base/quote/status derivation and why each choice is the honest one rather than the flattering one.

Reads opts[:category]; any other key is {:error, {:unknown_option, key}}.

list_networks(asset, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_networks/2.

list_payment_methods(credentials, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_payment_methods/2.

list_portfolios(opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.list_portfolios/1.

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 list-economic-calendars page that appeared 2026-10-18 is a calendar of macroeconomic releases — CPI, payrolls, Fed events — not of trading hours or holidays, so it answers a different question.) 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/api.sandbox.webull.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.

A grant is sent once, and one whose answer was lost (a transport error or timeout, a 5xx or 408, a 2xx that is no token) is {:error, {:write_unconfirmed, nil, reason}}: the code may be spent or the refresh token rotated, so do not retry the same one.

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

Places one order. This moves funds.

Requires opts[:account_id] and the credentials; the request is checked against the venue's order-type and time-in-force matrix before anything is sent. See DpExchange.Core.Venue.place_order/3.

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.

preview_replace(credentials, id, changes, opts \\ [])

Not served by this venue: answers {:error, :not_supported}. Use preview_order/3 and replace_order/4. See DpExchange.Core.Venue.preview_replace/4.

provider_name()

The venue's display name, "Webull". See DpExchange.Core.Venue.provider_name/0.

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.

quote_conversion(from, to, amount, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.quote_conversion/4.

quotes()

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

The quote currencies the symbol mapping recognises when splitting a native symbol.

This is the normaliser's list, not a statement about what trades: capabilities/0 declares supported_quotes: ["USD"], because every pair measured was quoted in USD.

remove_approved_address(network, address, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.remove_approved_address/3.

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.

rename_account(id, name, opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.rename_account/3.

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.

request_approved_address(asset, network, address, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.request_approved_address/4.

runtime_id()

The venue's runtime id, :webull. See DpExchange.Core.Venue.runtime_id/0.

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.

stake(asset, amount, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.stake/3.

start_link(opts)

Starts this venue's supervision tree (DpExchange.Webull.Supervisor.start_link/1).

Prefer supervising child_spec/1. Calling this directly wraps the credentials too, but a child spec you build yourself does not. See DpExchange.Core.Venue.start_link/1.

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.

subscribe(symbols, opts \\ [])

Subscribes opts[:to] (default self()) to the symbols over the venue's MQTT stream.

Symbols are upper-cased, the caller's arguments are validated before the feed is reached, and a stopped feed answers {:error, :feed_not_started}. Under environment: :uat there is no stream: {:error, {:streaming_unavailable, :uat}}. Credentials are required.

opts[:sub_types] names the venue's data types to ask for ("SNAPSHOT", "QUOTE", "TICK"; default all three). It must be a non-empty list of strings, else {:error, {:invalid_sub_types, value}} before the feed is reached. Once given it is remembered by the feed and used for every later request it makes for you: the post-reconnect replay, the 60-second blind resubscribe and later calls that name none. See DpExchange.Core.Venue.subscribe/2.

subscribe_notices(opts \\ [])

Subscribes opts[:to] (default self()) to the feed's notices.

A stopped feed answers {:error, :feed_not_started}. See DpExchange.Core.Venue.subscribe_notices/1.

test_connection(credentials, opts)

Not ported: answers {:error, :not_supported}. See DpExchange.Core.Venue.test_connection/2.

transfer_internal(asset, amount, opts, request_opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.transfer_internal/4.

unstake(asset, amount, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.unstake/3.

unsubscribe(symbols, opts \\ [])

Stops delivery for the named symbols; a stopped feed answers :ok.

Symbols are upper-cased and validated before the feed is reached. See DpExchange.Core.Venue.unsubscribe/2.

update_symbols(symbols, opts \\ [])

Replaces the live subscription's symbol set; a stopped feed answers {:error, :feed_not_started}.

Symbols are upper-cased and validated before the feed is reached. See DpExchange.Core.Venue.update_symbols/2.

update_watchlist(id, opts \\ [])

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.

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.

wanted(opts \\ [])

@spec wanted(keyword()) :: [DpExchange.Core.Venue.symbol()]

The symbols the consumer asked this package to stream, sorted, as opposed to coverage/1, which is what is observed arriving.

A symbol stays here for as long as it is wanted, whether or not anything has arrived for it (a rejected or dark symbol is wanted and not covered). Answers [] when no feed is running or it does not answer. Reads opts[:feed] to name a feed other than the default.

withdraw(asset, network, amount, address, opts)

Not served by this venue: answers {:error, :not_supported}. See DpExchange.Core.Venue.withdraw/5.