DpExchange.Webull.Rest (DpExchangeWebull v0.4.167)

Copy Markdown View Source

Webull's OpenAPI REST surface — internal.

Every call is signed, including the public-looking ones

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

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

Bars nest one level down, and assuming otherwise returns nothing

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

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

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

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

Volume is real on stocks, absent on crypto

Webull's crypto OpenAPI documents no trade volume on the bars or the REST snapshot. volume is nil rather than 0 on a crypto quote from this module, because zero is a volume and this venue is not reporting one.

The MQTT stream is not covered by that sentence any more. It used to be, but that was never measured. The stream's Snapshot schema carries volume (field 8) for every category, and Socket now passes it through as a :running_total, or nil when the frame leaves it empty (dp_exchange_webull issue #6). Whether crypto snapshots fill it in is for a live run to show, not for this doc to assume.

The stock snapshot is different: get_price/2 with category: "US_STOCK" or "US_ETF" carries a real volume, the day's aggregate. capabilities/0 therefore declares reports_trade_volume: true — the venue does report one, on a real, active path — with measured_against carrying the crypto/equity split so a caller does not read the boolean as "every quote has a number."

Bars are the same split, not a blanket "no volume." Stock, option, futures and event bars each document a required volume on their own "result" row (historical-bars.md:269,298, option-historical-bars.md, futures-historical-bars.md:214, event-bars.md:236); only the crypto bar schema (crypto-bars.md) carries none. decode_bar/4 previously hard-coded nil for every caller, which understated the four documented cases the same way the old reports_trade_volume: false understated the venue as a whole. get_volume_profile/4 remains the separate equity endpoint that splits traded volume by price and side — this note is about the bars' own volume column, not that endpoint.

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

Summary

Functions

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

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

Cancels an order by its client order id.

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

The calendars get_corporate_events/2 reads for opts[:kind]: both when it is absent, one when it is :dividend or :earnings, and {:error, {:unsupported_event_kind, kind}} for anything else. For DpExchange.Webull.Fake, which answers one event per calendar read.

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

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

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

The fundamentals kinds this package reaches, as atoms. Not every fundamentals endpoint the venue publishes: as of 2026-10-18 its index also lists insider holdings and transactions, institutions and their 13F holdings and changes, and the economic calendar, none of which is reached here (docs/reference/webull/endpoint-pages.txt).

The options a get_fundamental/4 of kind reads: :category, and the query keys that kind's own page documents. An endpoint's page names no count or type unless it does, so those are not options of a kind that does not take them.

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

The auction order imbalance — snapshot or published series.

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

Dividends and earnings dates — Types.CorporateEvent.

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

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

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

Regulatory filings this venue indexes — Types.Filing.

Financial statements for an issuer — Types.FinancialStatement.

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

OHLC bars for a symbol and canonical timeframe.

News about the symbols asked for — GET /market-data/news/stock-news/list, one request per symbol.

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

The expiries listed on an underlying, without the strikes.

One order by its client order id.

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

Open orders, or historical ones with history: true.

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

Last price for one symbol.

Last prices for many crypto symbols, up to 20 per request.

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

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

Every crypto symbol the venue lists, canonical.

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

Tick-by-tick public trades — /market-data/stocks/ticks/list for US_STOCK (the default) and US_ETF, /market-data/options/ticks/list for US_OPTION and /market-data/futures/ticks/list for US_FUTURES. Crypto is refused ({:unsupported_book_category, category}) and US_EVENT answers {:use_get_event_trades, "US_EVENT"}. opts[:sessions] reaches the stock tape only.

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

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

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

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

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

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

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

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

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

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

Every US_CRYPTO listing as a Core.Instrument — base, quote, instrument type and trading status — from the same paginated /trading/instruments/crypto/profiles/list endpoint get_symbols/2 already walks.

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

What get_news/2 asks for beside symbol: {filters, limit}. filters is the query category (default US_STOCK) and sentiment when given, each checked against the page's enum; limit is opts[:limit], a positive integer, or nil. The second check get_news/2 makes, after the symbols, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

The symbols get_news/2 asks about: opts[:symbols] as a list, or one symbol as a string; {:error, :symbols_required} otherwise. The first check get_news/2 makes, for DpExchange.Webull.Fake to accept and refuse exactly what the real path does.

A number a request carries as the Decimal an Order holds: a Decimal, an integer, a float or a numeric string; nil for anything else (and for NaN, Infinity and an absurd exponent). The decoder place_order/3 and the venue's read-backs use, for DpExchange.Webull.Fake to amend an order exactly as the real read-back reads it.

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

The Order place_order/3 answers for request under id, built by the same code.

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

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

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

Places an order. request[:instrument_type] picks the instrument (:crypto when absent, which is what this package served first; see order_instrument_types/0), and each instrument has its own type and time-in-force matrix. The crypto matrix is the short one, described first.

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

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

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

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

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

The options a get_screener/3 of name reads: the query keys that screener's own page documents, and :category unless the page documents none (hot_stocks).

The screeners this package reaches, by the identifier get_screener/3 takes. The venue also lists a fear-and-greed index and a per-stock unusual-activity feed, which are not rankings and are not reached here (docs/reference/webull/endpoint-pages.txt, 2026-10-18).

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

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

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

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

Whether an {:error, reason} from a signed request was decided before anything was sent: missing or unusable credentials (Auth.headers/2), Core.HttpClient's own limiter refusing ("Throttled by our own rate limiter ..." / "Rate limiter unavailable" / :rate_limit_timeout from a blocking limiter, bare or inside {:exchange_error, provider, message}), or the venue's own 429 ("Rate limited by the venue - retry after Ns"): the venue documents it as "request rejected with no partial processing" (rest-rate-limits.md), and Core's place_order/3 contract makes it the one 4xx that is a plain {:error, reason}.

unsent?/1 for a request sent under opts: a refusal by our own limiter proves nothing was sent only when the request had a single attempt.

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

The account every account and order call is made against: opts[:account_id], which must be non-empty text.

Whether opts[:start]/opts[:end] is a range the cash-activity endpoint accepts (both in one calendar year, compared in UTC) and opts[:activity_types] is something it can send (a non-empty list of text, or the venue's own comma-joined string; absent is fine). The checks get_transfers/2 and get_transactions/2 make after the account and before anything is signed, for DpExchange.Webull.Fake to refuse exactly as the real path does. A list with a non-text member, or a number, is {:error, {:invalid_activity_types, value}}: it raised out of Enum.join/2 or went to the wire as whatever to_string/1 made of it.

The bar options every bars path checks first: :real_time was renamed :include_partial (the family's name for "give me the bar that is still forming") and is refused {:error, {:unsupported_option, :real_time}} rather than ignored - silently ignoring it would hand a caller who asked for the forming bar a series without it. :include_partial must be a boolean ({:error, {:invalid_option, :include_partial, value}}).

Whether a bars request names a category, width, range and :limit that the endpoint for that category serves: the checks get_historical_prices/5 makes on every one of its five paths before anything is signed, in the same order, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

Whether requests is a batch place_orders/3 would send: its size and every entry against the batch endpoint's own schema, refused by index. The same checks place_orders/3 makes before it signs anything, for DpExchange.Webull.Fake to answer exactly as the real path does — a fake that accepted a STOP_LOSS or price-less LIMIT entry would let a consumer ship a batch the venue path refuses.

Whether category and opts[:depth] are an order-book request get_order_book/3 sends: the category (US_STOCK, US_ETF or US_FUTURES) and a depth in the range its page states. The checks it makes before anything is signed, for DpExchange.Webull.Fake.

Whether a crypto bars request names a width and a :limit the endpoint serves: the two checks get_historical_prices/5 makes on the crypto path before anything is signed, in the same order, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

The checks get_event_order_book/3 makes before it builds a request: the :depth, then the symbol. For DpExchange.Webull.Fake.

The checks list_event_events/2 makes before it builds a request: :series_symbol is present ({:error, :series_symbol_required}), then the option types. For DpExchange.Webull.Fake.

The checks list_event_markets/2 makes before it builds a request: the scalar options, then :symbols. For DpExchange.Webull.Fake.

The checks list_event_series/2 makes before it builds a request: the scalar options, then :symbols. For DpExchange.Webull.Fake.

The checks get_event_trades/3 makes before it builds a request: the :limit (at most 1200), then the symbol. For DpExchange.Webull.Fake.

Whether a footprint request names a category, width and session the endpoint serves: the three checks get_volume_profile/4 makes before anything is signed, in the same order, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

The checks get_fundamental/4 makes before it builds a request, in its order: the kind ({:error, {:unknown_fundamental, kind}}), the option types, the statement count, the symbol. For DpExchange.Webull.Fake, which refuses exactly what the real path refuses.

The checks list_futures_contracts/2 makes before it builds a request, in its order: the option types, then that :symbols or :code names something. For DpExchange.Webull.Fake, which refuses exactly what the real path refuses.

Whether opts[:history] is absent or a boolean: the check get_orders/2 and get_auction_imbalance/3 make before choosing between their open/snapshot and history/bars endpoints, for DpExchange.Webull.Fake.

Whether opts[:extended_hours] and opts[:overnight] are each absent or a boolean: the check the snapshot, top-of-book and order-book reads make before the request is built, for DpExchange.Webull.Fake. Anything else is {:error, {:invalid_option, key, value}}.

The checks oauth_token/3 makes before it sends anything, in its order: client_id is text, client_secret is text, exactly one of opts[:code] / opts[:refresh_token] is given, and the OAuth host is https (or loopback http). For DpExchange.Webull.Fake.

Whether client_order_id names an order a call can address: text, and not empty.

Whether request is an order this package would send: the venue's combination matrix, sizing, and the prices its type requires. The same checks place_order/3 makes before it signs anything, for DpExchange.Webull.Fake to answer exactly as the real path does.

Whether opts[:since]/opts[:until] are times get_orders/2 can send: absent, or a DateTime. For DpExchange.Webull.Fake, which refuses exactly as the real path does.

Whether request is an order preview_order/3 would price: every check validate_order_request/1 makes FIRST, so a request place_order/3 refuses is refused with the identical tuple (a non-map is {:invalid_order_request, r}, never a raise), and then an instrument the venue can preview (crypto is {:preview_not_supported, :crypto}). The same steps preview_order/3 takes, for DpExchange.Webull.Fake.

Whether range is a keyword list whose :start and :end, where present, are DateTimes: the check get_historical_prices/5 makes before anything is signed, for DpExchange.Webull.Fake. Anything else is {:error, {:invalid_range, range}}.

Every check replace_order/4 makes before the account option and the credentials, in its order: the order id ({:invalid_order_id, v}), changes being a map ({:invalid_order_request, changes}), the two options that say WHICH order is being edited (opts[:instrument_type] and opts[:order_type], each {:missing_option, key} when absent), crypto ({:preview_not_supported, :crypto}), and the per-type edit table.

The two positional checks replace_order/4 makes first: the order id ({:error, {:invalid_order_id, id}}), then changes being a map ({:error, {:invalid_order_request, changes}}). For DpExchange.Webull.Options, which runs them before it looks at the options.

Whether name and opts are a screener request get_screener/3 would send: a known identifier, and the rank_type week52_high_low requires. The checks it makes before anything is signed, for DpExchange.Webull.Fake to refuse exactly what the real path does.

Whether category is one get_price/3 can ask a snapshot endpoint about: the check it makes before a request, for DpExchange.Webull.Fake. Anything else is {:error, {:unsupported_snapshot_category, category}}.

Whether opts[:count] is within the 20 the statement endpoints state as their maximum, for a statement kind (get_financials/4's own vocabulary). The check get_financials/4 makes after the kind and before anything is signed, for DpExchange.Webull.Fake. A query option (:category, :type, :count, ...) that is a map, a tuple or a list is {:error, {:invalid_option, key, value}}, checked first.

Whether kind is a statement kind get_financials/4 serves. The check it makes before a request is built, for DpExchange.Webull.Fake to refuse a kind (:company_profile, a typo) exactly as the real path does rather than answer with a statement of it.

Whether symbol is something a single-symbol read can ask the venue about: a string with something in it after trimming. Anything else - nil, an atom, a number, "", " " - is {:error, {:invalid_symbol, symbol}}, decided locally before credentials and before any request.

Whether symbols is a proper list: anything else (a bare string, nil, an improper list) is {:error, {:invalid_symbols, symbols}}. Its members are not judged here - get_prices/3 answers a member that is not a usable symbol under its own key.

The category checks get_top_of_book/3 makes before a request: a snapshot category, and not US_EVENT, which answers {:error, {:use_get_event_order_book, "US_EVENT"}}. For DpExchange.Webull.Fake, in the real order.

Whether opts[:category] (default US_STOCK) and opts[:limit] are a tape request get_trades/3 sends: a category with a tape endpoint, and a count within that endpoint's stated maximum. The checks it makes before anything is signed, for DpExchange.Webull.Fake.

Whether opts[:category] (absent means US_STOCK) is the one value the watchlist instrument endpoints document: their category enum is US_STOCK alone (add-watchlist-instruments.md and the remove and update pages). Anything else is {:error, {:invalid_category, category}} before a request, rather than a 417 from the venue that DpExchange.Webull.Fake could not model. The check create_watchlist/4 and the three instrument writes make after their members, for Fake.

The symbols check add_watchlist_instruments/4 and remove_watchlist_instruments/4 make first: validate_watchlist_symbols/1, and then at least one symbol ({:error, :symbols_required}). For DpExchange.Webull.Fake.

Whether sort is something a watchlist create or update can send: absent, or a whole number. sort is a JSON integer on the watchlist pages (create-watchlist.md:149-154); text or a float there was sent as text, and a value the venue would read some other way. Anything else is {:error, {:invalid_sort, sort}}. For DpExchange.Webull.Fake.

The opts[:sorts] check sort_watchlist_instruments/3 makes first: a non-empty map of symbol (non-empty text) to position (a whole number). Missing or empty is {:error, :sorts_required}; anything else wrong is {:error, {:invalid_sorts, sorts}}. For DpExchange.Webull.Fake.

The members a watchlist write names, as a list: a list of non-empty text, one text, or nil/[] for none. Anything else is {:error, {:invalid_symbols, value}}.

Whether id and name are something a watchlist call can address: an id is non-empty text, a name is text. Anything else is {:error, {:invalid_watchlist_id, id}} / {:error, {:invalid_watchlist_name, name}}; these were FunctionClauseErrors in the caller's process. The check get_watchlist/3, update_watchlist/3, delete_watchlist/3 and create_watchlist/4 make before anything else, for DpExchange.Webull.Fake.

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

Re-tags the outcome of a watchlist write that may have reached the venue.

Functions

add_watchlist_instruments(watchlist_id, symbols, credentials, opts)

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

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

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

A write that may have reached the venue and is not proven is {:error, {:write_unconfirmed, nil, reason}} (write_outcome/1); read the watchlist before retrying.

adjusted?(timeframe)

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

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

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

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

cancel_order(credentials, client_order_id, opts)

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

Cancels an order by its client order id.

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

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

check_token(token, credentials, opts)

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

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

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

The status comes back as the venue's own string. Nothing is mapped to a boolean, because "not usable" covers three different problems with three different remedies. A response with no status, or one outside the venue's four, is refused rather than returned as a verdict ({:error, {:missing_required_field, :status}} / {:error, {:unknown_token_status, status}}).

corporate_event_kinds_for(kind)

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

The calendars get_corporate_events/2 reads for opts[:kind]: both when it is absent, one when it is :dividend or :earnings, and {:error, {:unsupported_event_kind, kind}} for anything else. For DpExchange.Webull.Fake, which answers one event per calendar read.

create_token(credentials, opts)

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

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

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

status travels unmapped for that reason: PENDING, NORMAL, INVALID and EXPIRED are the venue's four, and only the second is a token that works. A response with no token, or a status outside the four, is refused rather than handed back as a token - as the reason of the :write_unconfirmed below ({:missing_required_field, field} / {:unknown_token_status, status}).

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

A create that may have reached the venue and is not proven (a transport error or timeout, a 5xx, a 2xx whose body is unreadable, names no token or states a status outside the four) is {:error, {:write_unconfirmed, nil, reason}}: a token may now exist (nil: the request carries no idempotency key), and the venue's SMS verification may be waiting. Failures decided before sending (missing credentials, our own limiter refusing a single attempt) stay plain, and a 4xx is {:refused, reason}. Sent once.

create_watchlist(name, symbols, credentials, opts)

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

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

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

If the add itself is unproven (a timeout or 5xx: it may have landed) the answer is {:error, {:watchlist_created_members_unconfirmed, id, reason}} - the watchlist exists and its membership is UNKNOWN, so read it with get_watchlist/3 before adding again. Only a definite failure is {:watchlist_created_without_members, id, reason}.

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

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

symbols is checked before the watchlist is created: a member that is not non-empty text is {:error, {:invalid_symbols, symbols}}, and opts[:sort] must be a whole number ({:error, {:invalid_sort, sort}}), so a mistake knowable from the arguments does not leave an empty watchlist behind.

A create that may have reached the venue and is not proven (a transport error or timeout, a 5xx, a 2xx whose body is unreadable or names no id) is {:error, {:write_unconfirmed, nil, reason}}: the watchlist may exist, so list before retrying. Failures decided before sending stay plain.

delete_watchlist(watchlist_id, credentials, opts)

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

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

Irreversible, in the venue's own words. Returns :ok rather than the venue's %{"success" => true}, because the contract's delete_watchlist/2 is documented as returning :ok — and a false in that field is an error here rather than a successful call that deleted nothing. A delete that may have reached the venue and is not proven is {:error, {:write_unconfirmed, nil, reason}} (re-listing shows whether it took); an unknown id is {:refused, :not_found}.

fundamental_kinds()

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

The fundamentals kinds this package reaches, as atoms. Not every fundamentals endpoint the venue publishes: as of 2026-10-18 its index also lists insider holdings and transactions, institutions and their 13F holdings and changes, and the economic calendar, none of which is reached here (docs/reference/webull/endpoint-pages.txt).

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

fundamental_option_keys(kind)

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

The options a get_fundamental/4 of kind reads: :category, and the query keys that kind's own page documents. An endpoint's page names no count or type unless it does, so those are not options of a kind that does not take them.

A kind this venue does not publish is {:error, {:unknown_fundamental, kind}}, the same answer get_fundamental/4 gives. For DpExchange.Webull.Options.

get_accounts(credentials, opts)

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

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

Takes no parameters: the credential decides what it sees.

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

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

get_auction_imbalance(symbol, credentials, opts)

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

The auction order imbalance — snapshot or published series.

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

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

Two endpoints, and the series carries less than the snapshot

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

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

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

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

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

auction on each result is the one the row names in its own imbalance_action_type (PRE_OPEN is :opening, PRE_CLOSE is :closing), not the one requested: outside the publication windows the venue returns history, and a row for the other auction must not be labelled with the requested one. A row that names none takes the requested auction; one that names any other value is {:error, {:unsupported_auction, value}}.

get_balances(credentials, opts)

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

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

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

What this deliberately does not fill in

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

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

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

get_corporate_events(credentials, opts)

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

Dividends and earnings dates — Types.CorporateEvent.

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

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

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

A dividend's amount is set only for a CASH_DIVIDEND (or a row that names no div_type). The venue's other types - stock, scrip, return of capital, and so on - leave it nil, because a cash amount in currency is a claim the page does not make for them; the venue's whole row, div_type included, is in details.

get_event_order_book(symbol, credentials, opts)

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

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

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

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

Returned as %{yes_bids:, yes_asks:, no_bids:, no_asks:, quote_time:} with the venue's own level maps and its quote_time in milliseconds. A side that is present and not a list is {:error, :unexpected_response_shape} (an absent one is []), and a row naming another market than the one asked for is {:error, :not_in_response}. opts[:depth] must be a whole number of levels, one or more, or {:error, {:invalid_option, :depth, depth}} before any request.

get_event_trades(symbol, credentials, opts)

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

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

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

The venue's default count here is 30, the same as its other tapes (event-tick.md, tick.md); the maximum is 1200.

get_fees(credentials, opts)

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

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

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

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

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

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

get_filings(symbol, credentials, opts)

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

Regulatory filings this venue indexes — Types.Filing.

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

The list is under filings, not data

filings.md:176 documents GET /market-data/fundamentals/filings/list as returning a single object — {symbol, category, filings: [{title, url, publish_date}]} — with the array under its own key, filings, not the data key get_fundamental/4's generic rows/1 unwraps. Before this, rows/1 saw no data key and fell back to its bare-object clause, treating the WHOLE envelope as one row; every field this decoder reads off an individual filing (title, url, publish_date) is absent on that envelope, so get_filings/2 returned a single all-nil filing rather than the venue's actual list. filings_from_envelope/1 below reaches into the right key instead.

get_financials(symbol, kind, credentials, opts)

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

Financial statements for an issuer — Types.FinancialStatement.

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

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

:indicators answers one statement per fiscal period, not one per call. The venue returns a single object whose metrics each list their own {fiscal_year, fiscal_period, value} readings; those are regrouped by period, oldest first, with line_items holding that period's metrics beside fiscal_year, the raw fiscal_period code and currency. period_end is nil for them (the page publishes no end date). A response not in that shape is {:error, :unexpected_response_shape}.

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

get_fundamental(kind, symbol, credentials, opts)

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

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

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

opts[:type] (ANNUAL or QUARTERLY) and opts[:count] are accepted only on the endpoints that document them, and are dropped elsewhere rather than sent — a parameter an endpoint does not know is at best ignored and at worst a refusal, and neither tells the caller which happened. fund_files, fund_holdings and fund_splits are three such endpoints for :count specifically — none of their own pages define it.

:fund_dividends is the one kind (besides :filings, which get_filings/2 reaches through its own envelope) whose page documents pagination_key (fund-dividends.md:60-73), and this follows it bounded rather than returning one page.

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

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

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

OHLC bars for a symbol and canonical timeframe.

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

Family conventions (all categories)

  • The bar still forming is dropped (opened_at plus the width is after now, this machine's clock) unless include_partial: true. Core's Candle has no flag, so with include_partial: true the last bar may still change. real_time: is gone (BREAKING) and is {:error, {:unsupported_option, :real_time}}; a non-boolean :include_partial is {:error, {:invalid_option, :include_partial, v}}.
  • :start inclusive, :end exclusive, on opened_at. A crypto or stock read whose page is full while its oldest bar is still after :start is {:error, {:range_too_wide, count}} (was {:range_exceeds_count, count}), never a short answer. Stock bars send count (the :limit, else the maximum when a :start is given) so the venue's default of 200 does not cut a range short.
  • A bar must sit on its width's grid, else the reply is {:error, {:unaligned_candle, time}}. Crypto uses the strict UTC grid (DpExchange.Core.Timeframe.aligned?/2). Stock, option, futures and event bars are session-anchored: widths of 30 minutes and under keep the UTC grid, 1h and wider intraday widths need only a 30-minute alignment (a 09:30 New York open is 13:30Z or 14:30Z), and a daily, weekly, monthly or yearly bar stamped at 00:00Z or at New York midnight (04:00Z EDT, 05:00Z EST) is mapped to 00:00 UTC of that date. Unmeasured: the pages call time "Bar UTC time" without saying it is the open.
  • An open, high, low or close of zero or less is {:error, {:non_positive_price, field, raw}}.

get_news(credentials, opts)

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

News about the symbols asked for — GET /market-data/news/stock-news/list, one request per symbol.

These are the venue's own news items, not a generated summary. Until 2026-10-18 this read POST /market-data/news/summaries/get, whose vendor description is "Invokes LLM to generate news summaries": one Server-Sent-Events reply, a model's paraphrase with no headline, no link, no publisher and no time. The vendor's reference now publishes list-stock-news (openapi/list-stock-news.md), which returns itemised articles with news_id, title, summary, source, url, publish_time, sentiment and related_symbols, so the summaries endpoint is no longer called. The two are different products: a caller who relied on the old single paraphrase gets articles now.

opts[:symbols] is required, a list or one symbol as a string. opts[:category] is US_STOCK (the default) or HK_STOCK, the page's enum; opts[:sentiment] is POSITIVE, NEGATIVE or NEUTRAL and filters on the venue's classification. A value outside those is {:error, {:invalid_option, key, value}} before anything is sent. sentiment is a filter only: Core.Types.NewsItem has no field for the venue's label.

The cursor is followed, never dropped. Core.Venue.get_news/1 names no count or window, and the page documents a pagination_key and no page size. opts[:limit] (a positive integer, this package's option) is how many items per symbol the caller wants: pages are read until that many have arrived and the list is cut to exactly that number. Without :limit the feed is read to its end (an absent or empty cursor). A cursor that repeats the one just sent is {:error, :pagination_key_did_not_advance}, one that is not text is {:error, :unexpected_pagination_key}, and @max_pages pages without either the limit or the end is {:error, :history_truncated} (family convention) rather than the items so far, so a busy symbol read without :limit may be refused: pass one. An item the venue returned for two of the symbols appears once, with both symbols in :symbols.

:symbols is the symbol the item was returned for plus the venue's own related_symbols: the venue's tagging, not a judgement made here. :published_at is the venue's publish_time, converted to UTC. An item with no news_id, an unreadable publish_time or an unreadable related_symbols refuses the whole read, as every unreadable row in this package does, rather than being dropped from the list.

The page states no rate limit of its own; the per-endpoint and global buckets that govern every market-data read apply.

get_option_chain(underlying, credentials, opts)

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

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

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

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

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

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

Two different contracts at one expiry, strike and right are refused, not overwritten. The grid holds one contract per cell, and an index whose standard and weekly roots share a date (SPX and SPXW on the third Friday) can list two. The second used to replace the first silently, so a caller trading the cell got whichever row the venue sent last. That is now {:error, {:ambiguous_option_contract, expiry, strike, right, [venue_symbol]}}; narrow the request with opts[:expiry] / opts[:strike] or read the flat contract list. The same contract listed twice is one contract, and is kept once.

The grid is keyed on the strike's value, not the venue's spelling of it: a call listed at "150.0" and a put at "150" share the cell at Decimal.new("150"). The contract's own :strike keeps the spelling it was sent with.

get_option_expirations(underlying, credentials, opts)

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

The expiries listed on an underlying, without the strikes.

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

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

get_order(credentials, client_order_id, opts)

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

One order by its client order id.

Requires opts[:account_id].

The response is a combo GROUP, not a flat order row

/trading/orders/get returns {client_order_id, combo_order_id, combo_type, orders: [...]} (order-detail.md:163,182) — the group's OWN fields (side, status, quantity, price, …) live on each entry of orders, not on the envelope. Reading them off the group itself, which is what this did before, finds nothing at every key and returns an Order every field of which is nil except provider — a response that looks like an order and answers no question about one. See to_order/1.

get_order_book(symbol, credentials, opts)

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

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

The crypto and option books are refused. The crypto snapshot endpoint publishes a top of book and no depth, and the vendor states US_OPTION is not supported here. So a crypto or option category is refused before the request ({:error, {:unsupported_book_category, category}}) rather than sent and rejected.

opts[:category] picks US_STOCK (the default), US_ETF or US_FUTURES; opts[:depth] is the venue's own level count. opts[:overnight] includes overnight trading data on the stock endpoint, which requires the parameter, so false is sent explicitly rather than omitted; the futures endpoint does not take it.

depth defaults to 10

The stock depths page (docs/reference/webull/openapi/quotes.md) marks depth required and describes it as "L1-1 level, L2-default 10 levels", so 10 is the page's own L2 figure. On US_FUTURES the venue's page states depth is 1–10, required with no default (docs/reference/webull/futures-and-event-contracts.md), so 10 there is this package filling a required parameter with a value known to be in range, not a venue default being honoured.

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

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

The row is the requested symbol's. A row naming another symbol is {:error, :not_in_response}, never that instrument's book under this one's name; a response whose rows name no symbol is read as its first row.

venue_time is the venue's quote_time. A book the venue did not stamp is delivered with venue_time: nil, never the local clock: observed_at states when this package read it, and refusing would discard real levels over an optional field.

get_orders(credentials, opts)

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

Open orders, or historical ones with history: true.

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

Requires opts[:account_id]. Follows pagination_key, bounded — order-open.md:46,554 and order-history.md:66 both document it on the request and the response, which the venue's own page_size never was: nothing in either page names a page_size parameter, so the page_size this sent before was undefined on both endpoints. opts[:limit] is no longer sent for that reason.

history: true also accepts opts[:since]/opts[:until] (DateTimes), sent as the documented start_time/end_time in the venue's own yyyy-MM-dd'T'HH:mm:ss.SSS'Z' format (order-history.md:47,58). Omitted, the venue defaults to the last 7 days — its own words, not a default this package invented — so a caller reconciling a longer history must pass both.

get_positions(credentials, opts)

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

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

Requires opts[:account_id].

This venue states no side. Every documented field is a quantity, a price or a P&L, and account-position.md describes quantity only as "Quantity of the position" - it documents no sign convention. Direction is therefore read from the sign of quantity through Position.from_signed_quantity/1 (a negative is :short), an assumption that has not been measured against a live short position and needs a credential this repository never holds. A package that assumed :long because equities are usually long would produce a short position that is exactly backwards with every number in it still plausible.

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

Two documented row shapes cannot be a Position and are refused, with the whole list: an options strategy other than SINGLE (one row for several contracts, which sit under legs) and any EVENT row (its event_outcome, yes or no, is the opposite exposure to the same symbol and Position has nowhere to say which). Both answer {:error, {:unsupported_position, symbol, reason}}; the alternative was a position that read as a plain holding in the underlying, or a no holding reported as a yes one. A SINGLE option row's symbol is passed on as the venue sent it. The page's own example shows the underlying (AAPL) there while describing the field as the "option symbol code for derivatives", and this was not probed live, so it is not asserted to be the contract symbol.

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

A row with no readable quantity refuses the whole list ({:error, {:missing_required_field, :quantity}}, or {:invalid_decimal, :quantity, value} for text that is not a number): the page lists it as required, and Core.Types.Position has no nil quantity or side to put in its place.

get_price(symbol, credentials, opts)

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

Last price for one symbol.

Each asset class is its own endpoint, chosen by opts[:category] — US_CRYPTO (the default), US_STOCK, US_ETF, US_OPTION, US_FUTURES or US_EVENT. Any other category is {:error, {:unsupported_snapshot_category, category}} before a request is made.

The row must be the symbol asked for. The response is a list, and the first row used to be taken whatever it named. A row that names a different symbol than the one requested is {:error, :not_in_response}: a price for another instrument is the substitute this package refuses to hand back, and it would look entirely plausible. A response whose rows name no symbol at all is read as before.

An event contract's price is the venue's own unlabelled number (event-snapshot.md says only "the current market price for buying or selling an event contract", beside separate yes_* and no_* quotes). It is returned as sent; which outcome it prices is not stated by the venue, and nothing here claims one.

Volume is read wherever the response carries it

The crypto snapshot documents no volume, and a response without one gives nil. One that carries a volume delivers it as a :running_total, because a documented field list is not a measurement. Where crypto gives none, its quote's volume is nil — never zero, which would claim a genuinely flat interval. The stock snapshot does publish volume, and it is the day's aggregate rather than the last trade's size; the venue names no per-trade size on this endpoint, so that is what a caller gets and the field carries the venue's own meaning.

get_prices(symbols, credentials, opts)

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

Last prices for many crypto symbols, up to 20 per request.

The crypto snapshot endpoint takes a list: "Supports querying up to 20 symbols per request" (docs/reference/webull/openapi/crypto-snapshot.md). get_price/3 sends one, so a consumer filling a stale stream from REST got 60 symbols a minute from a per-endpoint limit of one request per second, where the venue serves 1,200 (dp_exchange_webull issue #9). Each chunk of 20 is one request through the same endpoint bucket.

Answers {:ok, %{symbol => result}} with a result for every symbol asked for, in the shape get_price/3 gives it: {:ok, Quote.t()}, {:refused, :no_quote} for the venue's "null" price, or {:error, reason}. A symbol the response carries no row for is {:error, :not_in_response}, never silently absent. A chunk the venue rejects for its content (a 4xx other than 401/403/429, such as one INVALID_SYMBOL member) is halved and retried down to single symbols, so only the rejected symbol keeps the rejection. A chunk whose request fails any other way gives each of its symbols that failure, so one chunk's trouble never costs another's quotes. A member that is not a usable symbol (nil, a number, blank text) is answered {:error, {:invalid_symbol, member}} under its own key, with no request, and does not cost the other members anything (so such a key is not always text); a symbols that is not a proper list is {:error, {:invalid_symbols, symbols}}. US_CRYPTO only. Other categories answer {:error, {:unsupported_category, category}}, because their snapshot limits are not read here.

get_screener(name, credentials, opts)

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

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

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

Each screener takes different parameters and this sends only the ones its own page documents. gainers_losers requires rank_type and sort_by; both default to the venue's own documented defaults rather than being omitted, because the venue marks them required and an omitted required parameter is a refusal a caller cannot read. top_actives defaults rank_type to the venue's own documented VOLUME (get-top-active.md:51-57). week52_high_low's own page documents four rank_type values and no default (get-week-52-high-low.md:44) — opts[:rank_type] is required here for that reason, {:error, :rank_type_required} when it is missing, rather than a guess at one of the four.

market_sectors and market_sector both document pagination_key (get-market-sectors.md:96) and this follows it to the end, bounded — see paginate/1.

hot_stocks ranks stocks by community activity, not by price or volume (list-hot-tickers.md). Its page documents no query parameter, so none is sent; each row's metrics carries the venue's own rank and category.

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

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

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

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

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

Daily and above are adjusted; minute bars are not

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

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

The bar that is still forming is dropped unless include_partial: true

Every bars path in this package (crypto, stock, option, futures, event) removes a bar whose opened_at plus its width is after now (this machine's clock - the venue sends no clock on this endpoint) unless the caller passes include_partial: true. The request asks the venue for real_time_required: true (its default, "Y: The returned data includes the latest market data") so that finished bars are not cut off at "the nearest whole hour", and the one still forming is removed here. The option was real_time: and is now include_partial: (BREAKING); real_time: is refused {:error, {:unsupported_option, :real_time}}. Core.Types.Candle has no partial flag, so with include_partial: true the last bar may still change after it is returned.

The range

:start is inclusive and :end exclusive, on opened_at. The venue returns the latest count bars (default 200 on its side) up to end_time, so a range is walked the one way there is: count is sent as the caller's :limit, or as the endpoint's maximum (1200; 1650 for 1m) when a :start is given without one, and a full page whose oldest bar is still after :start is {:error, {:range_too_wide, count}} - older bars exist that this read did not fetch, and returning the page would present a truncated window as the whole range. Whether the venue returns the latest or the oldest count bars of a range is unmeasured (the page does not say); only the oldest-bar check above is made.

A bar's time must sit on its width

The page says only "Bar UTC time" (historical-bars.md), not that it is the open. A bar whose time is off its grid refuses the whole reply {:error, {:unaligned_candle, time}}. The grid is the session's, not the UTC epoch's: widths of 30 minutes and under keep the strict UTC grid (they divide the 09:30 New York open); 1h and wider intraday widths need only minute 0 or 30 with no seconds (13:30Z is accepted, 13:45Z refused); a 1d, 1w, 1M or 1y bar stamped at 00:00Z or at New York midnight (04:00Z in EDT, 05:00Z in EST) is mapped to 00:00 UTC of that date, as DpExchange.Schwab does, and a 1d bar at any other time of day is refused (weeks, months and years have no rule in Core, so another time is left as sent). Unmeasured against a live venue: this is where a session bar can open, not an observed stamp.

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

get_symbols(credentials, opts)

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

Every crypto symbol the venue lists, canonical.

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

This endpoint paginates, and the old one did not

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

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

get_top_of_book(symbol, credentials, opts)

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

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

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

US_EVENT is refused here on purpose, the same way tick_path/1 refuses it for get_trades/3. The event snapshot has no single bid/ask — it publishes yes_bid/yes_ask/no_bid/no_ask and their sizes (event-snapshot.md:170-181,221-256), because a binary market has two instruments' worth of quotes, not one. Picking the YES side to be "the" bid and ask would answer about only one of the two contracts and, per get_event_order_book/3's own moduledoc, the numbers would still look right — a YES ask of 0.13 is real and is not "the" ask the way a stock's is. Use get_event_order_book/3 instead, which returns both sides under their own names.

As in get_price/3, a row naming a different symbol than the one asked for is {:error, :not_in_response}. bid, ask and the sizes are nil where the row does not carry a number for them: a price of 0 (or the literal "null"; an empty string is an absent field) is no resting order on that side, so that side and its size are nil, while a negative price or text that is not a number is {:error, {:non_positive_price, side, raw}} / {:error, {:invalid_decimal, side, raw}}. venue_time is the quote's own time (quote_time) and nil when the row has none - the stock snapshot states only last_trade_time, a different event, and it is not used (family convention MD7).

get_trades(symbol, credentials, opts)

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

Tick-by-tick public trades — /market-data/stocks/ticks/list for US_STOCK (the default) and US_ETF, /market-data/options/ticks/list for US_OPTION and /market-data/futures/ticks/list for US_FUTURES. Crypto is refused ({:unsupported_book_category, category}) and US_EVENT answers {:use_get_event_trades, "US_EVENT"}. opts[:sessions] reaches the stock tape only.

The tape, not get_trade_history/2. That returns the credential's own fills; this returns everyone's executions. The venue sends them newest first; they are returned oldest first by the venue's own time, as Core.Venue asks.

opts[:limit] is the venue's count (default 30; max 1000 on the stock tape, 1200 on the option and futures tapes). opts[:sessions] reaches the stock tape only and takes the venue's own list — PRE, RTH, ATH, OVN, comma-joined — defaulting to RTH. The venue marks trading_sessions required on the stock tape, so one is always sent there; asking for regular hours by default is a choice, and it is the one that matches what get_price/3 returns. The option and futures tapes take no such parameter and none is sent.

side is always nil: the venue's five codes are defined nowhere

The venue documents the field as "Such as: B S G L N" and defines none of them - not whose side B is (the taker's, the buyer's, the resting order's). Core.Types.Trade says an undocumented code is nil, not a mapping nobody has checked, so every tick's side is nil (BREAKING: B was :buy and S was :sell). Unmeasured: a live read against a known aggressor would settle the meaning; until then the mapping is not claimed. Guessing would put volume on the wrong side of a delta, which is the number a caller reads a tape for. A price of zero or less refuses the reply ({:non_positive_price, :price, raw}).

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

get_transactions(credentials, opts)

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

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

Requires opts[:account_id].

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

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

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

The venue's two constraints hold here as they do there: without a time range it answers the last 7 days, and a range spanning two calendar years is refused up front rather than silently truncated. Pagination follows pagination_key, bounded — see get_transfers/2's own note; this endpoint is the same one.

get_transfers(credentials, opts)

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

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

Requires opts[:account_id].

This endpoint is much wider than transfers, and that matters

It lists every cash activity: TRADE, FEES, DIVIDENDS, TAX, INTERESTS, CORPORATE_ACTION, OPTION_EA, JOURNAL and EC_SETTLEMENT (the page's twelve-value enum; OTHER is not one) alongside DEPOSIT, WITHDRAW and TRANSFER. The contract asks get_transfers/2 for deposit and withdrawal history, because that is what a cost basis for transferred-in assets needs.

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

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

The venue's two constraints, enforced rather than discovered

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

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

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

Pagination follows pagination_key, bounded — not page_size/last_activity_id

trade-cash-activity-by-type.md:90 names account_id, activity_types, start_time, end_time and pagination_key; there is no page_size and no last_activity_id. Both were sent before this fix and neither is a parameter the venue reads, so opts[:limit] and opts[:after] did nothing — the whole first page always came back, cursor or not. This now walks pagination_key to the end (or @max_pages, fail closed) instead.

get_volume_profile(symbol, timeframe, credentials, opts)

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

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

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

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

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

real_time_required is sent as false

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

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

get_watchlist(watchlist_id, credentials, opts)

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

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

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

The id is the one the venue's reply names. A reply that names none is {:error, {:missing_required_field, :id}}; it is not filled in from the id asked for.

list_event_categories(credentials, opts)

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

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

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

list_event_events(credentials, opts)

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

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

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

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

list_event_markets(credentials, opts)

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

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

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

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

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

list_event_series(credentials, opts)

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

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

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

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

list_futures_contracts(credentials, opts)

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

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

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

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

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

list_futures_product_classes(credentials, opts)

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

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

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

list_instruments(credentials, opts)

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

Every US_CRYPTO listing as a Core.Instrument — base, quote, instrument type and trading status — from the same paginated /trading/instruments/crypto/profiles/list endpoint get_symbols/2 already walks.

Category is fixed to US_CRYPTO, not read from opts[:category] the way

get_symbols/2's pagination helper does

Core.Instrument.instrument_type/0 is :spot | :perp | :unknown — no :equity member — and instrument-list.md's stock row documents a currency field that names the LISTING currency, not one half of a traded pair the way crypto's currency does. A US_STOCK/US_ETF row has no honest quote to put there and no honest instrument type to claim, so this callback refuses both by name rather than reaching for the stock endpoint and inventing either: {:error, {:unsupported_instrument_category, category}}.

base/quote come from the row's own currency field, never a parsed suffix list

Core.Instrument's own moduledoc names the hazard directly: splitting a symbol by scanning a list of known quote codes got Gemini's quote distribution wrong, because BTCUSDCPERP cannot be split without the venue's own fields. This does not scan SymbolFormat's quote list for the base:/quote: fields — it reads currency off the SAME row (crypto-instrument-list.md:266-270) and strips it from symbol (:221-225) only when that strip is exact and leaves a non-empty remainder ("BTCUSD" minus "USD" is "BTC"). symbol: itself still goes through SymbolFormat.to_canonical_symbol/1, same as get_symbols/2 — the two only need to agree for currency values SymbolFormat.quotes/0 also lists, which is every currency this venue has been seen to publish.

A row whose symbol does not literally end in its own currency is DROPPED, not guessed at and not made to fail the whole reply — dp_exchange_robinhood's Rest.list_instruments/2 (row_symbol/1/has_symbol?/1) is the precedent for dropping one unreadable row from an otherwise-honest catalogue rather than refusing the lot for it.

status: OC is :tradable; CO and NT are both :unknown

Core.Instrument.status_from/1's own doc calls limit_only tradable "because the venue still matches orders". Webull's CO ("Liquidate only") is not that — only a CLOSING order matches; an order that would open or grow a position is refused — so :tradable would tell a consumer it can open a position here when the venue will refuse it. NT ("Non-Tradable") is not :delisted either: the row is still LISTED, only not currently tradable, and :delisted claims the venue removed it, which NT does not say. Core.Instrument.status/0 offers exactly :tradable | :delisted | :unknown, so both land on :unknown — a consumer filtering on status == :tradable sees only OC rows; neither CO nor NT is mistaken for a listing the venue dropped. An absent or unrecognised status string is :unknown for the same reason: an unseen word must not manufacture a delisting nothing said.

This is how a consumer tells the symbols get_symbols/2 must still list (Core's contract: "every symbol the venue lists", not filtered here either) apart from ones the venue will reject at subscribe time — RENDER-USD, SYN-USD, XDC-USD, TOSHI-USD and the rest of dp_crypto_management's INVALID_SYMBOL rejections are exactly this: listed, but not OC.

list_watchlists(credentials, opts)

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

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

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

The venue caps an account at 20 watchlists.

A row with no text watchlist_id refuses the whole reply as {:error, {:missing_required_field, :id}}; it is never dropped.

news_filters(opts)

@spec news_filters(keyword()) ::
  {:ok, {%{required(String.t()) => String.t()}, pos_integer() | nil}}
  | {:error, term()}

What get_news/2 asks for beside symbol: {filters, limit}. filters is the query category (default US_STOCK) and sentiment when given, each checked against the page's enum; limit is opts[:limit], a positive integer, or nil. The second check get_news/2 makes, after the symbols, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

news_symbols(opts)

@spec news_symbols(keyword()) :: {:ok, [String.t()]} | {:error, :symbols_required}

The symbols get_news/2 asks about: opts[:symbols] as a list, or one symbol as a string; {:error, :symbols_required} otherwise. The first check get_news/2 makes, for DpExchange.Webull.Fake to accept and refuse exactly what the real path does.

number_to_decimal(value)

@spec number_to_decimal(term()) :: Decimal.t() | nil

A number a request carries as the Decimal an Order holds: a Decimal, an integer, a float or a numeric string; nil for anything else (and for NaN, Infinity and an absurd exponent). The decoder place_order/3 and the venue's read-backs use, for DpExchange.Webull.Fake to amend an order exactly as the real read-back reads it.

oauth_token(client_id, client_secret, opts)

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

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

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

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

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

A 2xx answer with no access_token is {:error, {:missing_required_field, :access_token}}, not a token response: this package has no vendor schema for this body (the page is not retrievable), but a grant that returned no token has not succeeded whatever else it said.

A grant that may have been honoured and is not proven is {:error, {:write_unconfirmed, nil, reason}}, never a bare error: a transport error or timeout, a 5xx or 408, and a 2xx that is undecodable, is not an object or carries no access_token. The code may be spent or the refresh token rotated with the answer lost, so retrying the same credential is not the remedy; start the consent flow again. A 4xx but 408 and 429 is {:refused, reason}, and a failure decided before sending (our own limiter refusing) stays plain. Sent once.

order_from_request(id, request)

@spec order_from_request(String.t(), map()) :: DpExchange.Core.Types.Order.t()

The Order place_order/3 answers for request under id, built by the same code.

For DpExchange.Webull.Fake, which used to echo the request as typed. The request must already have passed validate_order_request/1, which requires the type and the time in force: neither is defaulted.

order_instrument_types()

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

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

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

order_type_atom(name)

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

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

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

order_type_name(order_type)

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

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

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

place_order(credentials, request, opts)

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

Places an order. request[:instrument_type] picks the instrument (:crypto when absent, which is what this package served first; see order_instrument_types/0), and each instrument has its own type and time-in-force matrix. The crypto matrix is the short one, described first.

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

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

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

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

account_id is required and is never inferred

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

Only NORMAL combo orders

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

An accepted response that names no order is :order_unconfirmed

A 2xx whose row carries no client_order_id, or no row at all ({} with an empty or unreadable data), is {:error, {:order_unconfirmed, client_order_id, :unexpected_response_shape}} - the same shape a timeout gets, with the id this package sent, because the order may be live. Use get_order/3 with that id to find out.

Only an outcome that may have reached the venue is wrapped. Missing credentials, our own rate limiter refusing and a signing failure are decided before anything is sent and are the plain {:error, reason} ({:missing_credentials, :webull}, ...): there is no order to reconcile.

The request is checked, in this order, before anything is signed

A map ({:invalid_order_request, r}); no key the builder does not read ({:unsupported_order_field, key}); :symbol ({:invalid_symbol, v}); :side, :buy or :sell as an atom or text in any case ({:missing_field, :side} when absent, {:invalid_side, v} otherwise - :short is refused, capabilities/0 says supports_short_selling: false); :order_type and :time_in_force, both required and never defaulted ({:missing_field, key}; a type the instrument does not use is {:unsupported_order_type, type}, a duration it does not serve for the type {:unsupported_order_combination, type, tif}); with the duration, a GTD order's :expire_date ({:missing_field, :expire_date}; on any other duration {:unexpected_field, :expire_date}); the size ({:missing_field, :quantity} when neither :quantity nor :amount is given, {:unexpected_field, :amount} when both are or when a stop-limit order is sized in cash, {:invalid_quantity, v}, {:invalid_amount, v} - a float is refused, it has lost digits); the price fields ({:unexpected_field, key} for one the type does not use, {:missing_field, key} for one it needs, {:invalid_price, v}, {:invalid_stop_price, v}, {:invalid_trailing_stop_step, v}); :client_order_id ({:invalid_client_order_id, v}, 32 characters at most); then the account option ({:missing_option, :account_id}) and the credentials.

place_orders(credentials, requests, opts)

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

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

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

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

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

A missing :symbol or :side, or a LIMIT order with no :price, is refused by index as {:batch_order_rejected, index, {:missing_field, field}} (an absent :symbol is {:invalid_symbol, nil}, a bad one {:invalid_symbol, v}; an absent :side is {:missing_field, :side}, a bad one {:invalid_side, v}) before anything is sent. Neither :quantity nor :amount is {:missing_field, :quantity}, and :amount at all is {:unexpected_field, :amount} (the batch endpoint takes QTY only). A key the batch builder does not read is {:unsupported_order_field, key}, and a :price on a market entry is {:unexpected_field, :price}, each by index.

A batch that may have been placed - a timeout, a 5xx, or a 2xx with no readable rows - is {:error, {:order_unconfirmed, ids, reason}}, ids being every client_order_id sent, in order (generated ones included). Decided-before-sending failures (unsent?/2) stay plain.

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

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

preview_order(credentials, request, opts)

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

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

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

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

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

A figure the venue sent that cannot be read as a number is {:error, {:invalid_decimal, :estimated_cost | :estimated_fee, value}}, not a nil; an absent estimated_cost is {:error, :unexpected_response_shape}, and an absent fee is nil.

quantization(symbol, credentials, opts)

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

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

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

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

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

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

The row must be the symbol's own: a response whose rows all name other symbols is {:error, :not_in_response}, never another instrument's increments.

remove_watchlist_instruments(watchlist_id, symbols, credentials, opts)

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

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

Removal is by symbol and category, not by the instrument id the listing returns — the venue's own asymmetry. An unproven write is {:error, {:write_unconfirmed, nil, reason}}, as for add_watchlist_instruments/4.

replace_order(credentials, client_order_id, changes, opts)

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

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

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

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

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

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

The body is account_id plus a modify_orders array, not a flat object

common-order-replace.md:141,150 marks both account_id and modify_orders required on the outer body, and client_order_id required on each entry of that array — there is no top-level client_order_id. A flat body with client_order_id beside account_id, which is what this sent before, is not this endpoint's shape at all; the venue's schema has no field there to receive it.

order_type is sent too, when the caller's changes include it: the @amendable table below already let :order_type through its check for limit, stop and stop_limit — a caller changing a limit order to a market one, which the venue's own futures rules describe as the one destination each amendable type may switch to — but the field never reached the wire, so that change was silently dropped from every replace this package sent while the check that was supposed to gate it kept passing.

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

A failed read-back is not a failed replace. The venue has accepted the amendment by the time the order is read, so a read that fails (a timeout, a refusal, another order's answer) is {:error, {:replace_accepted_unread, client_order_id, reason}} - the id goes back as place_order/3 returns {:order_unconfirmed, id, reason} - and get_order/3 says what the order is now. Retrying the replace is not the remedy.

Which order is being edited is not guessed

opts[:instrument_type] and opts[:order_type] (the type of the order being amended - its edit table is chosen from it) are required: absent, each is {:error, {:missing_option, key}}. They used to default to an equity limit order, where place_order/3 defaults to crypto. changes that is empty or not a map (%{}, nil, text, a keyword list) is {:error, {:invalid_order_request, changes}}, checked before those two options, and an order id that is nil, not text or blank is {:error, {:invalid_order_id, id}}, checked first.

A transport error or 5xx on the replace itself (it may have landed) is {:error, {:order_unconfirmed, client_order_id, reason}}; no credentials or our own limiter refusing, decided before anything is sent, stay the plain {:error, reason}.

screener_option_keys(name)

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

The options a get_screener/3 of name reads: the query keys that screener's own page documents, and :category unless the page documents none (hot_stocks).

An identifier this venue does not publish is {:error, {:unknown_screener, name}}, the same answer get_screener/3 gives. For DpExchange.Webull.Options.

screeners()

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

The screeners this package reaches, by the identifier get_screener/3 takes. The venue also lists a fear-and-greed index and a per-stock unusual-activity feed, which are not rankings and are not reached here (docs/reference/webull/endpoint-pages.txt, 2026-10-18).

sort_watchlist_instruments(watchlist_id, credentials, opts)

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

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

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

tif_atom(name)

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

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

tif_name(tif)

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

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

Exposed for the same reason as order_type_name/1.

timeframes()

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

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

unsent?(arg1)

@spec unsent?(term()) :: boolean()

Whether an {:error, reason} from a signed request was decided before anything was sent: missing or unusable credentials (Auth.headers/2), Core.HttpClient's own limiter refusing ("Throttled by our own rate limiter ..." / "Rate limiter unavailable" / :rate_limit_timeout from a blocking limiter, bare or inside {:exchange_error, provider, message}), or the venue's own 429 ("Rate limited by the venue - retry after Ns"): the venue documents it as "request rejected with no partial processing" (rest-rate-limits.md), and Core's place_order/3 contract makes it the one 4xx that is a plain {:error, reason}.

Such an outcome cannot have placed or changed anything, so place_order/3 does not wrap it as :order_unconfirmed. Anything else (a transport error, a timeout, a 5xx, an undecodable body) may have reached the venue.

unsent?(reason, opts)

@spec unsent?(term(), keyword()) :: boolean()

unsent?/1 for a request sent under opts: a refusal by our own limiter proves nothing was sent only when the request had a single attempt.

Core.HttpClient asks the limiter again before every retry. With retry_attempts: 3 a limiter refusal on the second attempt follows a first attempt that was sent and may have landed, so reporting it as the plain {:error, reason} would send the caller to place the order again. Rest sends every write once (retry_attempts: 1, always), so a limiter refusal is always a refusal before the first attempt. Missing or unusable credentials are decided before the first attempt whatever retry_attempts is. A :retry_attempts that is not an integer is treated as retrying (the conservative answer).

update_watchlist(watchlist_id, credentials, opts)

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

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

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

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

A write that may have reached the venue and is not proven is {:error, {:write_unconfirmed, nil, reason}}; failures decided before sending stay plain.

validate_account(opts)

@spec validate_account(keyword()) ::
  {:ok, String.t()} | {:error, {:missing_option, :account_id}}

The account every account and order call is made against: opts[:account_id], which must be non-empty text.

Anything else (absent, nil, false, a number, "", whitespace only) is {:error, {:missing_option, :account_id}} - the family's answer for a missing account option, replacing the bare atom :account_id_required. A number went into a JSON body as a number and a query as its to_string/1, where the venue types the account id as a string. The check each account call makes, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

validate_activity_range(opts)

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

Whether opts[:start]/opts[:end] is a range the cash-activity endpoint accepts (both in one calendar year, compared in UTC) and opts[:activity_types] is something it can send (a non-empty list of text, or the venue's own comma-joined string; absent is fine). The checks get_transfers/2 and get_transactions/2 make after the account and before anything is signed, for DpExchange.Webull.Fake to refuse exactly as the real path does. A list with a non-text member, or a number, is {:error, {:invalid_activity_types, value}}: it raised out of Enum.join/2 or went to the wire as whatever to_string/1 made of it.

validate_bar_options(opts)

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

The bar options every bars path checks first: :real_time was renamed :include_partial (the family's name for "give me the bar that is still forming") and is refused {:error, {:unsupported_option, :real_time}} rather than ignored - silently ignoring it would hand a caller who asked for the forming bar a series without it. :include_partial must be a boolean ({:error, {:invalid_option, :include_partial, value}}).

Shared by get_historical_prices/5, get_stock_bars/5 and DpExchange.Webull.Fake.

validate_bars_request(timeframe, range, opts)

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

Whether a bars request names a category, width, range and :limit that the endpoint for that category serves: the checks get_historical_prices/5 makes on every one of its five paths before anything is signed, in the same order, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

opts[:category] defaults to US_CRYPTO, as it does there. Crypto checks the width and :limit; options, futures and event contracts refuse a range and check the width and :limit; any other category is the equity path, which checks the category, the width (the three it adds to the crypto eight) and the :limit for that width.

validate_batch_orders(requests)

@spec validate_batch_orders([map()] | term()) :: :ok | {:error, term()}

Whether requests is a batch place_orders/3 would send: its size and every entry against the batch endpoint's own schema, refused by index. The same checks place_orders/3 makes before it signs anything, for DpExchange.Webull.Fake to answer exactly as the real path does — a fake that accepted a STOP_LOSS or price-less LIMIT entry would let a consumer ship a batch the venue path refuses.

validate_book_request(category, opts)

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

Whether category and opts[:depth] are an order-book request get_order_book/3 sends: the category (US_STOCK, US_ETF or US_FUTURES) and a depth in the range its page states. The checks it makes before anything is signed, for DpExchange.Webull.Fake.

validate_crypto_bars_request(timeframe, opts)

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

Whether a crypto bars request names a width and a :limit the endpoint serves: the two checks get_historical_prices/5 makes on the crypto path before anything is signed, in the same order, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

validate_event_book_request(symbol, opts)

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

The checks get_event_order_book/3 makes before it builds a request: the :depth, then the symbol. For DpExchange.Webull.Fake.

validate_event_events_request(opts)

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

The checks list_event_events/2 makes before it builds a request: :series_symbol is present ({:error, :series_symbol_required}), then the option types. For DpExchange.Webull.Fake.

validate_event_markets_request(opts)

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

The checks list_event_markets/2 makes before it builds a request: the scalar options, then :symbols. For DpExchange.Webull.Fake.

validate_event_series_request(opts)

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

The checks list_event_series/2 makes before it builds a request: the scalar options, then :symbols. For DpExchange.Webull.Fake.

validate_event_trades_request(symbol, opts)

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

The checks get_event_trades/3 makes before it builds a request: the :limit (at most 1200), then the symbol. For DpExchange.Webull.Fake.

validate_footprint_request(timeframe, opts)

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

Whether a footprint request names a category, width and session the endpoint serves: the three checks get_volume_profile/4 makes before anything is signed, in the same order, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

validate_fundamental_request(kind, symbol, opts)

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

The checks get_fundamental/4 makes before it builds a request, in its order: the kind ({:error, {:unknown_fundamental, kind}}), the option types, the statement count, the symbol. For DpExchange.Webull.Fake, which refuses exactly what the real path refuses.

validate_futures_contracts_request(opts)

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

The checks list_futures_contracts/2 makes before it builds a request, in its order: the option types, then that :symbols or :code names something. For DpExchange.Webull.Fake, which refuses exactly what the real path refuses.

validate_history_flag(opts)

@spec validate_history_flag(keyword()) ::
  :ok | {:error, {:invalid_option, :history, term()}}

Whether opts[:history] is absent or a boolean: the check get_orders/2 and get_auction_imbalance/3 make before choosing between their open/snapshot and history/bars endpoints, for DpExchange.Webull.Fake.

Any other value ("false", 0, :no) used to be truthy to if, so a caller who wrote history: "false" was silently answered from the history endpoint. Refused {:error, {:invalid_option, :history, value}}.

validate_hours_flags(opts)

@spec validate_hours_flags(keyword()) ::
  :ok | {:error, {:invalid_option, atom(), term()}}

Whether opts[:extended_hours] and opts[:overnight] are each absent or a boolean: the check the snapshot, top-of-book and order-book reads make before the request is built, for DpExchange.Webull.Fake. Anything else is {:error, {:invalid_option, key, value}}.

The two are sent to the venue as "true" / "false" through to_string/1, which raised Protocol.UndefinedError in the caller for a map or tuple and sent "yes" or "1" as typed for text and numbers: values the venue's boolean parameters do not define.

validate_oauth_request(client_id, client_secret, opts)

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

The checks oauth_token/3 makes before it sends anything, in its order: client_id is text, client_secret is text, exactly one of opts[:code] / opts[:refresh_token] is given, and the OAuth host is https (or loopback http). For DpExchange.Webull.Fake.

validate_order_id(id)

@spec validate_order_id(term()) :: :ok | {:error, {:invalid_order_id, term()}}

Whether client_order_id names an order a call can address: text, and not empty.

The check cancel_order/3, get_order/3 and replace_order/4 make FIRST - before the account option and before anything is signed - for DpExchange.Webull.Fake to refuse what the real path refuses. A nil, an empty or a whitespace-only id (" " was sent as it stood) is {:error, {:invalid_order_id, id}}. Length is not checked here: the 32-character cap is the venue's for an id the CALLER chose (place_order/3 enforces it, as {:invalid_client_order_id, id}), and an id the venue issued is not this package's to refuse.

validate_order_request(request)

@spec validate_order_request(term()) :: :ok | {:error, term()}

Whether request is an order this package would send: the venue's combination matrix, sizing, and the prices its type requires. The same checks place_order/3 makes before it signs anything, for DpExchange.Webull.Fake to answer exactly as the real path does.

validate_orders_window(opts)

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

Whether opts[:since]/opts[:until] are times get_orders/2 can send: absent, or a DateTime. For DpExchange.Webull.Fake, which refuses exactly as the real path does.

Only the history listing reads them (the open-orders page names no time parameters), so a window asked of an open listing is {:error, {:unsupported_option, key}} rather than ignored. On a history read a bound that is not a DateTime is {:error, {:invalid_time, key, value}}, and :since after :until is {:error, {:invalid_option, :since, value}}.

validate_preview_request(request)

@spec validate_preview_request(term()) :: :ok | {:error, term()}

Whether request is an order preview_order/3 would price: every check validate_order_request/1 makes FIRST, so a request place_order/3 refuses is refused with the identical tuple (a non-map is {:invalid_order_request, r}, never a raise), and then an instrument the venue can preview (crypto is {:preview_not_supported, :crypto}). The same steps preview_order/3 takes, for DpExchange.Webull.Fake.

validate_range(range)

@spec validate_range(term()) :: :ok | {:error, {:invalid_range, term()}}

Whether range is a keyword list whose :start and :end, where present, are DateTimes: the check get_historical_prices/5 makes before anything is signed, for DpExchange.Webull.Fake. Anything else is {:error, {:invalid_range, range}}.

A non-DateTime bound used to raise in DateTime.compare/2 after the request had been made and charged against the endpoint's bucket.

validate_replace(client_order_id, changes, opts)

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

Every check replace_order/4 makes before the account option and the credentials, in its order: the order id ({:invalid_order_id, v}), changes being a map ({:invalid_order_request, changes}), the two options that say WHICH order is being edited (opts[:instrument_type] and opts[:order_type], each {:missing_option, key} when absent), crypto ({:preview_not_supported, :crypto}), and the per-type edit table.

Neither option is defaulted. replace_order/4 used to assume an equity limit order where place_order/3 assumes crypto, so a caller who forgot to say was checked against, and edited under, the table of an order it never placed.

validate_replace_arguments(client_order_id, changes)

@spec validate_replace_arguments(term(), term()) :: :ok | {:error, term()}

The two positional checks replace_order/4 makes first: the order id ({:error, {:invalid_order_id, id}}), then changes being a map ({:error, {:invalid_order_request, changes}}). For DpExchange.Webull.Options, which runs them before it looks at the options.

validate_screener(name, opts)

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

Whether name and opts are a screener request get_screener/3 would send: a known identifier, and the rank_type week52_high_low requires. The checks it makes before anything is signed, for DpExchange.Webull.Fake to refuse exactly what the real path does.

validate_snapshot_category(category)

@spec validate_snapshot_category(term()) :: :ok | {:error, term()}

Whether category is one get_price/3 can ask a snapshot endpoint about: the check it makes before a request, for DpExchange.Webull.Fake. Anything else is {:error, {:unsupported_snapshot_category, category}}.

validate_statement_count(kind, opts)

@spec validate_statement_count(atom(), keyword()) :: :ok | {:error, term()}

Whether opts[:count] is within the 20 the statement endpoints state as their maximum, for a statement kind (get_financials/4's own vocabulary). The check get_financials/4 makes after the kind and before anything is signed, for DpExchange.Webull.Fake. A query option (:category, :type, :count, ...) that is a map, a tuple or a list is {:error, {:invalid_option, key, value}}, checked first.

validate_statement_kind(kind)

@spec validate_statement_kind(term()) :: :ok | {:error, term()}

Whether kind is a statement kind get_financials/4 serves. The check it makes before a request is built, for DpExchange.Webull.Fake to refuse a kind (:company_profile, a typo) exactly as the real path does rather than answer with a statement of it.

validate_symbol(symbol)

@spec validate_symbol(term()) :: :ok | {:error, {:invalid_symbol, term()}}

Whether symbol is something a single-symbol read can ask the venue about: a string with something in it after trimming. Anything else - nil, an atom, a number, "", " " - is {:error, {:invalid_symbol, symbol}}, decided locally before credentials and before any request.

These used to raise FunctionClauseError in the symbol mapper (crypto) or go out as symbols= for the venue to refuse (everything else). Shared with DpExchange.Webull.Fake, so both answer a malformed symbol identically.

validate_symbols(symbols)

@spec validate_symbols(term()) :: :ok | {:error, {:invalid_symbols, term()}}

Whether symbols is a proper list: anything else (a bare string, nil, an improper list) is {:error, {:invalid_symbols, symbols}}. Its members are not judged here - get_prices/3 answers a member that is not a usable symbol under its own key.

The positional check get_prices/3 makes first, for DpExchange.Webull.Options to run before it looks at the options.

validate_top_of_book_category(category)

@spec validate_top_of_book_category(term()) :: :ok | {:error, term()}

The category checks get_top_of_book/3 makes before a request: a snapshot category, and not US_EVENT, which answers {:error, {:use_get_event_order_book, "US_EVENT"}}. For DpExchange.Webull.Fake, in the real order.

validate_trades_request(opts)

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

Whether opts[:category] (default US_STOCK) and opts[:limit] are a tape request get_trades/3 sends: a category with a tape endpoint, and a count within that endpoint's stated maximum. The checks it makes before anything is signed, for DpExchange.Webull.Fake.

validate_watchlist_category(opts)

@spec validate_watchlist_category(keyword()) ::
  :ok | {:error, {:invalid_category, term()}}

Whether opts[:category] (absent means US_STOCK) is the one value the watchlist instrument endpoints document: their category enum is US_STOCK alone (add-watchlist-instruments.md and the remove and update pages). Anything else is {:error, {:invalid_category, category}} before a request, rather than a 417 from the venue that DpExchange.Webull.Fake could not model. The check create_watchlist/4 and the three instrument writes make after their members, for Fake.

validate_watchlist_membership(symbols)

@spec validate_watchlist_membership(term()) :: {:ok, [String.t()]} | {:error, term()}

The symbols check add_watchlist_instruments/4 and remove_watchlist_instruments/4 make first: validate_watchlist_symbols/1, and then at least one symbol ({:error, :symbols_required}). For DpExchange.Webull.Fake.

validate_watchlist_sort(sort)

@spec validate_watchlist_sort(term()) :: :ok | {:error, {:invalid_sort, term()}}

Whether sort is something a watchlist create or update can send: absent, or a whole number. sort is a JSON integer on the watchlist pages (create-watchlist.md:149-154); text or a float there was sent as text, and a value the venue would read some other way. Anything else is {:error, {:invalid_sort, sort}}. For DpExchange.Webull.Fake.

validate_watchlist_sorts(opts)

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

The opts[:sorts] check sort_watchlist_instruments/3 makes first: a non-empty map of symbol (non-empty text) to position (a whole number). Missing or empty is {:error, :sorts_required}; anything else wrong is {:error, {:invalid_sorts, sorts}}. For DpExchange.Webull.Fake.

validate_watchlist_symbols(symbols)

@spec validate_watchlist_symbols(term()) :: {:ok, [String.t()]} | {:error, term()}

The members a watchlist write names, as a list: a list of non-empty text, one text, or nil/[] for none. Anything else is {:error, {:invalid_symbols, value}}.

The check create_watchlist/4 makes before it creates anything, and add_watchlist_instruments/4 / remove_watchlist_instruments/4 before they write, for DpExchange.Webull.Fake to refuse exactly what the real path refuses.

validate_watchlist_target(atom, id)

@spec validate_watchlist_target(:id | :name, term()) :: :ok | {:error, term()}

Whether id and name are something a watchlist call can address: an id is non-empty text, a name is text. Anything else is {:error, {:invalid_watchlist_id, id}} / {:error, {:invalid_watchlist_name, name}}; these were FunctionClauseErrors in the caller's process. The check get_watchlist/3, update_watchlist/3, delete_watchlist/3 and create_watchlist/4 make before anything else, for DpExchange.Webull.Fake.

wide_timeframes()

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

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

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

write_outcome(result, opts \\ [])

@spec write_outcome(term(), keyword()) :: term()

Re-tags the outcome of a watchlist write that may have reached the venue.

An {:error, reason} that was NOT decided before sending (unsent?/1: missing credentials, our own limiter refusing) - a transport error or timeout, a 5xx, a 2xx whose body cannot be read or that lacks the id or success the write needs - does not say whether the write happened, so it is {:error, {:write_unconfirmed, nil, reason}} (nil: this venue takes no idempotency key for a watchlist write; reason bounded). A caller that read a bare {:error, _} as "worth retrying" would repeat a create or a delete. Pre-send failures, {:refused, _} and :ok/{:ok, _} pass through unchanged. opts are the options the request was sent under (unsent?/2): a limiter refusal on a RETRY of a write that was already sent is not proof that nothing was sent. For DpExchange.Webull.Fake.