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
@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.
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.
@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.
@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}}).
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.
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.
@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.
@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}.
@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.
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.
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.
@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/listThe 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}}.
@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.
@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.
@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.
@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.
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.
@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.
@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.
@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.
@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_atplus the width is after now, this machine's clock) unlessinclude_partial: true. Core'sCandlehas no flag, so withinclude_partial: truethe last bar may still change.real_time:is gone (BREAKING) and is{:error, {:unsupported_option, :real_time}}; a non-boolean:include_partialis{:error, {:invalid_option, :include_partial, v}}. :startinclusive,:endexclusive, onopened_at. A crypto or stock read whose page is full while its oldest bar is still after:startis{:error, {:range_too_wide, count}}(was{:range_exceeds_count, count}), never a short answer. Stock bars sendcount(the:limit, else the maximum when a:startis 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,1hand 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 calltime"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}}.
@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.
@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.
@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.
@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.
@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.
@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.
@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.
@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.
@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.
@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}}.
@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.
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.
@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).
@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.
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.
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.
@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.
@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.
@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.
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.
@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.
@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.
@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.
@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.
@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.
@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.
@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.
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.
@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.
@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.
@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.
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.
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.
@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 onlyThere 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.
@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.
@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.
@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.
@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.
@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 onlyKeyed 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}.
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.
@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).
@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.
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.
Exposed for the same reason as order_type_name/1.
@spec timeframes() :: [String.t()]
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}.
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?/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).
@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.
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.
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}}).
Shared by get_historical_prices/5, get_stock_bars/5 and DpExchange.Webull.Fake.
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.
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.
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}}.
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.
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.
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.
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.
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}}.
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}}.
A non-DateTime bound used to raise in DateTime.compare/2 after the request had been
made and charged against the endpoint's bucket.
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.
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.
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.
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.
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}}.
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.
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.
@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.
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.