All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Status: EXPERIMENTAL
Stated here rather than only per-release, because a reader arriving at a specific version needs it as much as one reading the top.
This package has not run in production. While it is 0.x the API may change without a
major version. Coverage is uneven by design: the fake and the stubbed HTTP transport are
well covered, order placement and authenticated flows are not. Nothing here runs against
the live venue — every Webull call is signed, so there is no public endpoint a
credential-less repository could test against.
Whenever an endpoint moves to :proven, the entry that does it states the evidence —
what was run against the live venue, and when. "Marked proven" with no evidence is not an
acceptable changelog line.
[Unreleased]
[0.4.167] - 2026-10-20
Dependencies
- Requires
dp_exchange_core ~> 0.3.116(was~> 0.3.114). Its conformance groups now have teeth this package passes: group 39 counts a success only if it holds something to inspect, group 37 judges a bar's timeframe label, group 42 requires the sentclient_order_idon:order_unconfirmed, and group 19 checks all 37 secret names.
Fixed (tick AP)
A caller that joined a connecting shard was answered
{:connack_timeout, ms}when an EARLIER caller's deadline arrived. Each parked caller armed a{:connack_timeout, index, session_id}timer, and the first to fire answered every waiter on the shard, so a call that arrived nine seconds into another's ten-second wait was told the shard had not connected after one second. The timer now carries the caller it was armed for ({:connack_timeout, index, session_id, from}) and answers that caller only; the three-element form still answers every waiter. Test:feed_test.exs, "a per-caller timeout answers that caller only".A tick's
timewas read by magnitude although the page documents milliseconds.get_trades/2took a ten-digit number for seconds, so a seconds-valued tick time dated the trade 2026 for a fieldtick.mdtypes as epoch milliseconds. It is read as milliseconds only now; a value at or below 100,000,000,000 (a seconds-sized number,0) is{:error, :invalid_unix_time}. Bars and the stream (which state no unit) keep the documented seconds-or-milliseconds reading. Test:tick_ap_test.exs.The shard reopen ladders had a cap and no jitter.
reopen_with_backoff/4(a crashed socket or INVALID_SESSION) andretry_shard_open/7(a shard that failed to open) retried every shard torn down by one venue fault on the same 1s, 2s, 4s ... schedule. Each wait is now shortened by a random amount of up to a quarter (Socket.jittered_delay_ms/1, never lengthened, so the 60s cap holds). Test:shard_reopen_backoff_test.exs.Snapshot, quote, depth and imbalance times were read by magnitude although the pages state milliseconds. The crypto, option and futures snapshots (
last_trade_time,quote_time), the futures depthquote_timeand the NOIIimbalance_timeare documented as epoch milliseconds; a seconds-sized number there was read as seconds and dated a quote 2026. They are read as milliseconds only (parse_time_ms/1): a seconds-sized or non-positive number is no time, sovenue_timeisnilon a quote, a top of book, a depth or an imbalance. The stock snapshot, the stock depth, the bars and the event pages state no unit and keep the seconds-or-milliseconds reading.capabilities/0'smeasured_againstnow says the rule is documented, not measured. Tests:rest_test.exs,microstructure_test.exs,futures_and_events_test.exs,defensive_branches_test.exs.Feeddecided delivery staleness, shard rotation and quiet-shard reopens on the wall clock.now_ms/0wasSystem.system_time/1, so a clock step (NTP, a VM resume) made a shard look freshly delivered, or silent for hours, and the rotation or the reopen fired early or never. Every interval it decides is onSystem.monotonic_time/1now, through the samenow_ms/0the tests inject; "never delivered" is a minimum-integer sentinel (the monotonic clock is negative on the BEAM, so0is not "never"). The delivery and rotation records are never shown to a consumer. Tests:tick_ap_test.exs("an arrival is stamped on the monotonic clock", "a shard that never delivered is quiet and is re-asserted") and the existing feed tests, which inject monotonic instants.Auth.timestamp/1read the clock with no seam. It takes the instant fromConfig.get(:dp_exchange_webull, :webull_clock, nil)(a zero-arity function, process-scoped like every other override) when set, elseDateTime.utc_now/0. Test:tick_ap_test.exs("a retry carries a later x-timestamp and a different x-signature-nonce").
Docs
DpExchange.Webull.coverage_by_kind/1called:tradesone of "three independent kinds" and, three paragraphs later, said:tradesnever appears. The venue streams three payload topics and declares two kinds; the text says so.usage-rules.mdstates the REST time-unit rule per page.
Tests
refute_receivewindows that guard a negative were 0, 50, 100, 200 or 300 ms, or the 100 ms ExUnit default, shorter than the task and HTTP-stub hops they wait out. They are 500 ms now, andtest_helper.exssetsrefute_receive_timeout: 500(its comment already said the option existed and left it at the default). Arefute_receivedafter a call that returns before an asynchronous reconcile task has run (the plug sends from the task) is now arefute_receivewith a window.- Assertions that could not fail were tightened: a
flunk/1inside a plug that runs in a reconcile task (the test passed whether or not the request was made) reports to the test process and is refuted instead; an{:error, _reason}or{:ok, _}that named no reason or value now pins it (the dial that failed, the HTTP 500 carried in:exchange_error, a write that is:write_unconfirmed, the missing-credentials refusal, the facade's delegated answers); aSocket.disconnect/2timeout is the{:exit, {:timeout, _}}it returns.
Audited, no change
x-timestampisYYYY-MM-DDThh:mm:ssZ(second precision, UTC) as every reference page states, and is minted per attempt AFTER the rate limiter has cleared (Core signs again once a blocking wait is over);x-signature-nonceis 128 random bits per attempt.- The MQTT keep-alive and PINGRESP-silence timers carry a per-connection reference and are
cancelled on disconnect; the CONNACK deadline matches its shard by session id; the socket
reconnect backoff is capped at 30s and jittered. The package holds no access token, so there
is no expiry check to skew:
expires_at/expires_inare passed through as the venue sent them. Fakereturns canned structs and never parses a wire time, so there is no seconds-valued tick for it to refuse; its REST-shaped time fields (the event book'squote_time, a tick'stime) are milliseconds, as the real path now requires.quote_proto.exandmqtt_packet.exhold no clock and no timer (the keep-alive is a CONNECT field and the PINGREQ schedule lives inSocket);webull.exholds no token state.
[0.4.166] - 2026-10-20
Dependencies
- Requires
dp_exchange_core ~> 0.3.114(was~> 0.3.111). The conformance suite now runs group 51 against this package: its supervisor, feed, ledger and socket are started with canary credentials (test/support/process_probe.ex) and:sys.get_status/2of each must not print one. 0.3.114 also refuses an improper-listoptsinstead of raising.
Changed (BREAKING for a caller who started a named tree and addressed it by :feed alone)
- A tree started with its own
name:now names its feed, rate limiter and ledger from it (<name>.Feed,<name>.RateLimiter,<name>.Ledger); an explicit:feed,:limiteror:ledgerstill wins, and a tree with noname:keeps today's names exactly. Two trees given only distinct names used to collide on the feed and the limiter. Facade calls take:name(listed inDpExchange.Webull.Optionswherever the limiter or the feed is read; not onget_fees/2,streaming?/1,live?/1) and resolve the same names. A call that names the tree's:feedbut neither its:namenor its:limiternow meters in the default limiter, which a named tree does not start ("Rate limiter unavailable"); passname:.
Fixed (tick AO)
A
Feedcrash lost everything it had been asked to do. It restarted from its static start options: no wanted symbols, no subscribers, emptycoverage/1, and nobody told; the sockets died with it. NewDpExchange.Webull.Ledger, started first under the tree and not restarted with the feed, holds the feed's durable intent (wanted symbols, data and notice subscribers, replay options with the credentials insideCredentials). The feed writes it when a change is accepted (a caller answered after any refusal was undone, a notice subscriber added, a subscriber dropped), never for a refused one. A restarted feed reads it inhandle_continue/2, monitors the live subscribers again, plans its shards again and sends every subscriber a:coverage_changenotice,details.reason: :feed_restarted. The tree now has three children.A WebSocket frame, or a fragmented message, of any size was buffered for as long as the peer kept sending. The vendored
websockexread into one growing binary until a frame was whole (each TCP segment copying all of it again): a header announcing a 64-bit payload length, or continuation frames that each stayed small, grew memory without limit and made the work quadratic. The MQTT layer's own 1 MiB packet bound (MqttPacket) never saw it, because it only runs once a frame is whole. The fork now closes the connection past 4 MiB (a frame announcing more:{:error, %WebSockex.FrameError{reason: :frame_too_large}}and close code 1011; a message that adds up to more: close code 1009), and the ordinary:link_down, reconnect and resubscribe path takes over. 4 MiB is chosen, not measured.GenServer.stop(feed)(reason:normal) left every shard's socket running.WebSockexdoes not trap exits, and a process that is not trapping ignores a:normalexit signal, so the link did nothing.Feed.terminate/2had said DISCONNECT, the socket'shandle_disconnect/2then reconnected as it does for any drop, and it went on doing so, delivering to a dead pid, holding one of the venue's five connections per App Key for as long as the node lived. A:normalstop now stops each shard's socket after the DISCONNECT. A supervisor's:shutdownand a crash already killed them through the link and are unchanged. The comment inSocket.wait_to_reconnect/2that saidWebSockextraps exits was wrong and is corrected.A protobuf length prefix is checked against the buffer by comparison. A varint may announce up to 2^70; the decoder now refuses a length beyond what is left before it is used as a
binary-size, so "ends the walk, never raises" no longer rests on how the runtime treats an absurd size. No change for any real frame.The
ArgumentErrorfromcoverage/1,wanted/1,coverage_by_kind/1,streaming?/1andlive?/1no longer echoes the options it was given when they are not a keyword list. A caller that builtoptsas a map has usually put:credentialsin it, and an exception message is what reaches a log. It says what kind of term it got.
Reviewed, not changed (tick AO)
- The stream path creates no atom from wire text (topics, field numbers and symbols are binaries
or fixed lookups);
test/dp_exchange/webull/tick_ao_test.exsproves an unknown topic does not become one. Protobuf decoding is linear and bounded by the 1 MiB packet bound. - The MQTT
CONNECTcarries the App Key as its username and the constant"x"as its password (the venue authenticates on the signed HTTP subscribe), so it holds no signature or token. The socket's state holds the key wrapped inCredentials, and[:websockex, :frame, :sent]telemetry fires only for frames sent throughsend_frame/3(theDISCONNECT), never for theCONNECT. - Two instances of this package in one VM need
:name,:feedand:limiterpassed explicitly (the defaults derive from the environment only); a second default instance fails to start with{:error, {:already_started, pid}}rather than sharing a feed or a rate-limit bucket.
[0.4.165] - 2026-10-19
Dependencies
- Requires
dp_exchange_core ~> 0.3.111(was~> 0.3.109). That release refuses a GET with a body before sending (Req 0.7.0-0.7.2, which the Core floor admits, sent it as a retried POST) and refuses URL userinfo over plain http.
Fixed (tick AN)
Feedreplayed with credentials a refused or unusable call had left behind, which is what answered{:error, {:missing_required_field, :app_key}}on a feed that had been streaming. The error is this package's own (Feed.open_socket/2), never the venue's: a live run recorded it "while a shard was open" and it was never explained.subscribe/3andupdate_symbols/3merged the call'scredentials:over the stored ones before resharding, whatever it held.credentials: nil,%{}or a map with a blank key or secret replaced the working credentials; the call itself was refused at signing (or, changing nothing, answered:ok), and every later reconnect replay, blind resubscribe, staggered open and crash orINVALID_SESSIONreopen then ran from the emptied credentials. Two changes: a credentialAuth.present?/1would not sign with is never stored, and a REFUSED call puts back the options it replaced (credentials, environment,:sub_types, the rest of what a replay forwards), like it already did forwanted. A blank (""or whitespace):app_keyis also the missing key it is, where it opened a socket whose CONNECT named an empty key. Traced and found sound: everySubscriptionrequest is signed per attempt throughAuth.headers/2, which refuses before sending without both halves of the credential, and always carriesx-app-key;Sockettakes its key fromopen_socket/2only.oauth_token/3answered a bare{:error, _}for an outcome that may have followed a honoured grant. An authorization code is single-use and a refresh token may rotate, so a transport error or timeout, a 5xx or 408, and a 2xx that is undecodable, not an object or has noaccess_tokencan all follow a grant the venue accepted: a caller reading the bare error as "worth retrying" retried with a credential that no longer worked. They are now{:error, {:write_unconfirmed, nil, reason}}, ascreate_token/2answers (Core's W1); a 4xx but 408 and 429 stays{:refused, {:venue_error, status, reason}}and our own limiter refusing stays plain.
Changed (documentation)
usage-rules.md: the credential-replay rule above;oauth_token/3as a write; the order book's unreadable price refuses the book (it said the level was dropped);get_screener/2's unnamed row refuses the read (two paragraphs still said it was dropped); a refused CONNACK emits no:downtelemetry and says which notice it does emit; the option table was split by a paragraph and rendered as text; the 4xx statement names which calls it covers.README.md: 89 callbacks, 48 declared:experimental(it said 88 and 47; Core 0.3.109 has 89 and the facade implements all 89).QuoteProtomoduledoc: values go toDecimal.parse/1(guarded), notDecimal.new/1.
[0.4.164] - 2026-10-19
Changed (BREAKING: order-write discipline, Core group 50)
replace_order/4given an EMPTY change map is{:error, {:invalid_order_request, %{}}}(it was{:error, :no_order_changes}), likenil, text and a keyword list, and it is checked before the:instrument_type/:order_typeoptions are asked for. Facade,RestandFakealike.- A present
nilin a change map is judged asplace_order/3judges that field, not read as "no change":%{price: nil}is{:invalid_price, nil},%{quantity: nil}is{:invalid_quantity, nil},%{order_type: nil}is{:missing_field, :order_type}, the first inplace_order/3's check order (DpExchange.Core.Venue.replace_order/4: "each field that is present is judged asplace_order/3judges it ..., a presentnilincluded"). The bare{:error, :no_order_changes}no longer exists: an all-nilmap is answered by its firstnilfield, and an empty map by{:invalid_order_request, %{}}. - A refused
subscribe/2,unsubscribe/2orupdate_symbols/2changes no state.Feedchangedwantedbefore resharding and reverted only the shard's list on failure, so a call answered with an error left its symbols wanted: later resubscribed, and counted as coverage the consumer believed it had been refused. Whatever answers the call (a failed reconcile, a refused or timed-out shard, a crash) now undoes what the call changed inwanted(an unsubscribe's removals return, an update's previous set returns, a capacity overflow undoes only the symbols it names) and re-plans the shards from it. A call answered:okkeeps its change. A caller answeredreconcile_pendingat its deadline is settled when its reconcile concludes, not at the deadline. An oversubscribed rejection on a shard's own CONNACK therefore refuses the parked caller and undoes its symbols too, instead of rebalancing them in the background. preview_order/3judges its request exactly asplace_order/3does, first: a request place refuses is refused with place's identical tuple whatever its instrument (a crypto request that lacks a side is{:missing_field, :side}, not{:preview_not_supported, :crypto}). Only a valid order is then asked whether the venue can price it.Fakealike.- Refusal text is read from the documented error body only (
error_code,message; see the alias entry below), so a{:refused, {:venue_error, status, words}}carries the words of those two fields and no others.
Changed (BREAKING for a caller whose responses used a name the vendor does not publish)
- Decoders read the vendor's field names and nothing else. An alias that no page in
docs/reference/webull/and no captured response shows is a substitute source (a different field of that name read as this one), so every such alias is gone fromRest, each remaining key citing the page that shows it: camel-case spellings of every snake-case field (bidSize,lastPrice,strikePrice,clientOrderId,pairedShares,imbalanceTime, ...);bidPrice/bid_price/askPrice/ask_priceforbid/ask;lastPrice/last_trade_priceforprice;disSymbolandnamefor an instrument'ssymbol;paginationKey/next_page_key;payment_dateanddividendon a dividend row;direction,strike,instrument_id(as the contract symbol) anduniton an option contract, and the single-letter rightsCandP;msg,codeanderror_descriptionas the words of a refusal (error_codeandmessageare the documented pair). - Times are read from the field the page defines for that row, not from the first of a list.
A bar, tick or footprint row is dated by
timeonly; a snapshot'svenue_timeis itslast_trade_timeonly; a top of book and an order book are dated byquote_timeonly.ts,timestamp,tradeTimeandtrade_timeare gone, and a bar can no longer be dated by a quote's time. - A filing carries
title,urlandfiled_atonly (filings.md:182-192):id,form_typeandperiod_endarenil(the aliasesid,filing_id,form_type,type,name,link,period_endandend_dateappear in no page). An option contract'slast_trading_dayisnil: the page defines no such field. - A screener row is named by its
symbol; only themarket_sectorslisting is named by its sector'sname.sector_nameis not read, and a stock's companynameno longer stands in for a missingsymbol. Fake.get_filings/2fixtures follow (id: nil, ...).
Fixed (tick AM)
Fake.subscribe/2,unsubscribe/2andupdate_symbols/2need credentials where the real feed does. A call that changes the wanted set touches a shard. With none open the realFeedneeds the App Key to open the socket and answers the call itself,{:error, {:missing_required_field, :app_key}}; with one open the HTTP request is signed from the call's own credentials,{:error, {:missing_credentials, :webull}}. A call that changes nothing (an empty list, symbols already wanted) needs none. The fake answered:okwithout them and a consumer's test went green on a call the venue path refuses. A refused call changes no state, as the real feed's reverted delta does not. Core's conformance suite already gives these callscredentials:througharg_value/2.subscribe/2's:sub_typeswas honoured for the one request the call made and then lost: the post-CONNACK replay, the 60-second blind resubscribe, a reopened shard's replay and any later call naming none all sent the defaultSNAPSHOT,QUOTE,TICK, so a consumer who asked for fewer types silently got all three back at the first tick or reconnect.Feednow remembers the last:sub_typesasubscribe/2orupdate_symbols/2named (with the credentials and the other replayed options) and uses it for every request it makes for you.A
:sub_typesthat is not a non-empty list of strings is refused at the boundary,{:error, {:invalid_sub_types, value}}, bysubscribe/2,unsubscribe/2andupdate_symbols/2and by theirFaketwins, after the symbols and the unknown-option check and before the subscriber and the feed. It was found only when the request was about to be sent, after a shard had been opened for the call. NewSubscription.check_sub_types/1.Fake.get_corporate_events/1andFake.get_filings/2now make the fundamentals checks the real path makes before it signs (Rest.validate_fundamental_request/3): a blank:symbolis{:error, {:invalid_symbol, symbol}}and a:categorythat cannot be a query value is{:error, {:invalid_option, :category, value}}, as there. Both were answered with fixture rows.Fake.get_transactions/2rows carry all eight fieldstrade-cash-activity-by-type.mdmarks required (account_id,activity_sub_type,activity_type,biz_time,currency,id,net_amount,trade_date), asFake.get_transfers/2rows already did; they had four.Docs:
capabilities/0'smeasured_againstandrest-rate-limits.mdno longer say the 60/60s ceiling is the venue's figure for "every market-data endpoint". It is the Display Solution tier as read 2026-09-09; the same page read 2026-10-18 lists a Non-Display Solution tier at 300/60s production (read through a page fetch, not saved verbatim). Which tier an app key is on was never measured and cannot be read from here, so the declared figure is the stricter one and a Non-Display key is under-used, not over-used.usage-rules.mdsays the same.
[0.4.163] - 2026-10-18
Changed (BREAKING: tick AL)
- Reads too: a 4xx other than 408 and 429 (417, 409, 422, 400, 418, ...) is
{:refused, {:venue_error, status, reason}}, not a retryable{:error, {:exchange_error, :webull, "HTTP 4xx: ..."}}, and is not retried (404 stays{:refused, :not_found}, a single-symbolINVALID_SYMBOLstays{:refused, :not_listed}).get_prices/3still halves a chunk down to the rejected symbol on any non-credential refusal; that symbol now carries the refusal.Subscriptionis unchanged. - A watchlist write's
:categoryis validated against the documented enum (US_STOCK):{:error, {:invalid_category, v}}before a request, on the real path and the Fake. - A futures
replace_order/4changes:order_typeone way only, per the vendor page: limit or stop to:market, stop-limit to:limit; else{:error, {:unsupported_order_type_change, from, to}}before a request, in the Fake too. - Docs:
Authrecords thatx-versionis pinned tov2while the vendor default isv3, unmeasured; the cash-activity moduledocs no longer listOTHER, which is not in the enum. - A write that is always sent exactly once no longer reads
:retry_attemptsor:retry_delay:place_order/3,place_orders/3,cancel_order/3,replace_order/4,create_watchlist/3,update_watchlist/2,delete_watchlist/2,add_watchlist_instruments/3,remove_watchlist_instruments/3,sort_watchlist_instruments/2,create_token/1andoauth_token/3answer{:error, {:unknown_option, key}}for either, whatever the value, before anything is sent, on the facade and the Fake.Restused to accept the key and overwrite it with 1. Reads and the subscribe family keep both keys. - A trailing stop (
order_type: :trailing_stop) now requires:trailing_type(:amountor:percentage, or"AMOUNT"/"PERCENTAGE") and sends it astrailing_type:common-order-place.mddocuments it besidetrailing_stop_stepand its trailing example sends both, and this package sent the step alone, leaving the venue to choose between a dollar distance and a fraction of the price. Missing is{:missing_field, :trailing_type}, another value{:invalid_trailing_type, v}, on any other order type{:unexpected_field, :trailing_type}, and a:percentagestep above 1 (the page: "can not exceed 1, 0.01 means 1%") is{:invalid_trailing_stop_step, v}. - A refusal that carries the venue's
error_codekeeps it:{:venue_error, 417, "OPENAPI_NO_NIGHT_TRADING_TIME: The current period does not support..."}(the reason was the message alone). A body with anerror_codeand no message is that code. get_fundamental(:capital_flows, ...)refuses a:countoutside 1..5 ({:limit_out_of_range, n, max: 5});capital-flow.mdstates "range 1~5" and the check was left to the venue.
Fixed
- A 4xx answered to a write is a refusal, not an unconfirmed write. The venue documents
417(its business response,{"error_code", "message"}) on the order, watchlist and token writes, and a 417, 409, 422 or any other 4xx but 408 and 429 came back as{:error, {:exchange_error, ...}}, whichplace_order/3,place_orders/3,replace_order/4andcancel_order/3wrapped as{:order_unconfirmed, id, ...}and the watchlist writes as{:write_unconfirmed, nil, ...}: an order the venue had declined was reported as possibly live. They are{:refused, {:venue_error, status, reason}}now (Core's DV6). 408 and 5xx stay unconfirmed.oauth_token/3reads a 4xx the same way. - The venue's own 429 ("Rate limited by the venue - retry after Ns") on a write is a plain
{:error, reason}, not:order_unconfirmed/:write_unconfirmed: the page says the request is "rejected with no partial processing" and Core'splace_order/3contract makes it the one 4xx that stays a plain error.Rest.unsent?/1,2knows it. get_news/2collected pages withacc ++ itemsandlength/1per page; it keeps a running count and flattens once.
[0.4.162] - 2026-10-18
Depends on dp_exchange_core 0.3.105 (conformance group 46: a call that sends no request takes no transport key).
Changed (BREAKING: shared transport keys are accepted only where read)
- A call that sends no request (
get_fees/2,coverage/1,wanted/1,coverage_by_kind/1,subscribe_notices/1) now refuses every HTTP key:timeout: 5_000is{:error, {:unknown_option, :timeout}}(a raise on the plain reads), on the facade and the Fake.:credentialsstays allowed.streaming?/1andlive?/1take only:credentialsand:environment. oauth_token/3refuses:credentials: the client id and secret are arguments.- Fixed:
:adapter,:weight,:retry_delay,:max_retry_after_sand:max_body_byteswere accepted by the key table and dropped byRestandSubscription's forwarding lists, somax_body_bytes: 1_024bounded nothing andweight: 7metered 1. They reachCore.HttpClientnow (and:adapteris carried into a feed replay with:plug). - Fixed: a
subscribe/2orupdate_symbols/2made before the shard connected acceptedtimeout,retry_attempts,log_requests,weight,retry_delay,max_retry_after_sandmax_body_bytesand then dropped them from the stored replay options, so the deferred subscription request ran on Core's defaults. The replay now carries every keySubscriptionforwards. - Tests prove
weight: 7reaches the limiter andmax_body_bytes:refuses an oversized body.
[0.4.161] - 2026-10-18
Changed (BREAKING for callers of get_news/1)
- Depends on dp_exchange_core 0.3.103 (conformance groups 44, fake export parity, and 45, notice shapes).
get_news/1follows the venue'spagination_keyinstead of stopping at the first page (which would have been a silent truncation). New:limitoption: items wanted per symbol, honoured exactly (pages are read until it is met, then the list is cut to it); without it the feed is read to its end. A cursor that repeats the one just sent is{:error, :pagination_key_did_not_advance}, a cursor that is not text is{:error, :unexpected_pagination_key}, an empty cursor ends the feed, and@max_pages(50) reached first is{:error, :history_truncated}rather than a short list, so a busy symbol read without:limitcan be refused: pass one. A:limitthat is not a positive integer is{:error, {:invalid_option, :limit, value}}, before a request, on the Fake as well.Core.Venue.get_news/1names no count or window, so:limitis this package's option.- BREAKING:
:base_urlis refused on every function ({:error, {:unknown_option, :base_url}}) and:environmentonget_fees/2,coverage/1,wanted/1,coverage_by_kind/1andsubscribe_notices/1. Core lists both as shared transport keys, but no call read:base_url(the hosts come from:environment;oauth_token/3has:oauth_url), and those five read no:environment— abase_url:that did nothing let a caller believe a request had been redirected. Same pattern as Schwab and Robinhood. - Every
:data_qualitynotice from the socket now carries an atomdetails.reason, with its text indetails.message.report_drop/3(an unrecognised topic, an unhandled packet type, a QoS 2 window overflow, an undecodable or unreadable snapshot, tick or quote) emitteddetails.droppedonly, so a consumer matching onreasonsaw nothing. The reasons are:unrecognised_topic,:unhandled_packet,:qos2_window_full,:undecodable,:no_readable_level, and the decode error's own tag (for example:invalid_decimal).details.droppedkeeps the full key. The otherNotice.newsites were checked: no other:data_qualitylacks a reason and the one:refusalcarries its symbols. - BREAKING:
get_news/1reads the venue's itemised news,GET /market-data/news/stock-news/list, instead of the LLM-generatedPOST /market-data/news/summaries/get. The vendor's reference gainedlist-stock-news(found by the weekly index diff on 2026-10-18; page captured asdocs/reference/webull/openapi/list-stock-news.md). EachNewsItemnow has the venue'snews_idas:id, and a:headline,:summary,:url,:source(the publisher),:published_at(the venue'spublish_timein UTC) and:symbols(the symbol asked for plus the venue'srelated_symbols) where the old single paraphrase hadnilfor all but the summary andsource: "webull". An item returned for two requested symbols appears once. An item with no textnews_id, an unparseablepublish_timeor arelated_symbolsthat is not a list of tickers refuses the whole read ({:missing_required_field, :id},{:unparseable_venue_timestamp, text},:unexpected_response_shape); a body without the page'sdataarray is:unexpected_response_shape; an unlisted symbol (404) is{:refused, :not_listed}. - BREAKING:
get_news/1option table is now:symbols,:category,:sentiment.:langis gone (the new page has no such parameter, so it was a key the function never read).:categoryisUS_STOCK(default) orHK_STOCKand:sentimentisPOSITIVE,NEGATIVEorNEUTRAL; anything else is{:error, {:invalid_option, key, value}}before a request, on the facade and the Fake alike (Rest.news_filters/1). - Removed the Server-Sent-Events reader that only the summaries call used (
post_sse/4and its decoder);Fake.get_news/1now returns an item with a headline, link and time. - Docs:
usage-rules.md,Rest.fundamental_kinds/0,Rest.screeners/0and the facade no longer call this package's fundamentals and screeners lists "the ones the venue publishes": the index also lists insider holdings and transactions, institutions and their 13F holdings and changes, the economic calendar, a fear-and-greed index and a stock unusual-activities feed, none of which is reached here and none of which falsifies acapabilities/0declaration (reasoning per page inendpoint-pages.txt). The economic calendar is macro releases, not trading hours, somarket_status/1stays:unsupported. rest-rate-limits.mdrecords drift: the vendor's live table now splits Display (60/60s production) from Non-Display (300/60s production); the file's "every market-data endpoint is 60/60s" no longer holds. The package's 60/60s meter is unchanged, deliberately.
[0.4.160] - 2026-10-17
Changed (BREAKING for callers that passed an option the venue cannot honour)
BREAKING: every
DpExchange.Webullfunction that takesopts, and itsDpExchange.Webull.Faketwin, refuses a key it does not read:{:error, {:unknown_option, key}}, and a non-keywordoptsis{:error, {:invalid_options, opts}}. A stray key was silently ignored, so a misspeltlimt:answered a question the caller did not ask. The check runs throughDpExchange.Core.Options.check/2at the facade boundary, before anything is sent and before the facade adds:limiter, after the positional arguments and before the credentials. Each function's own keys are written once, inDpExchange.Webull.Options, and listed inusage-rules.md; the transport keys come fromDpExchange.Core.Options.transport_options/0. Requiresdp_exchange_core ~> 0.3.101(conformance group 43). Callers that shared oneoptslist across calls will see it: an:account_idonget_accounts/2, a:toonupdate_symbols/2/unsubscribe/2, a:limitonget_orders/2and the like are errors now. Keys the code reads only to refuse them keep their more specific answers (:limitonget_order_book/2,:sinceonget_trades/2,:real_timeon the bars,:since/:untilon the open-orders listing,:symbolsonupdate_watchlist/2).get_fundamental/3andget_screener/2take the keys their own endpoint documents, so acount:on a kind whose page defines none is refused instead of dropped, andhot_stockstakes nocategory:.coverage/1,wanted/1,coverage_by_kind/1,streaming?/1andlive?/1, which return a map, a list or a boolean, raiseArgumentErrorinstead. A function that answers{:error, :not_supported}does so whatever it is given.The Fake checks positional arguments (symbol, order request, order id, watchlist id, fundamentals kind, screener name, statement kind) before it consults an injected outcome, as the facade does, so an injection test needs arguments that pass those checks.
BREAKING:
get_symbols/2refuses the whole listing,{:error, :unexpected_response_shape}, when any catalogue row names no readable symbol (missing, non-text, or a non-object row). It skipped such rows, leaving a hole so that a symbol the venue lists read as unlisted.BREAKING, same rule for every other listing decoder that skipped a row it could not read; each now refuses the read with
{:error, :unexpected_response_shape}:list_instruments/2: a row whose symbol does not end in its owncurrency, has nocurrency, or is not an object.get_watchlist/3: a member instrument with no textsymbol.get_screener/3: a row that names nothing (no textsymbol,sector_nameorname).get_news/2: adata:line that is not valid JSON, or atextchunk whosemessageis not text. No spec line documents a frame to skip.
get_order_book/2withlimit:is{:error, {:unsupported_option, :limit}}(levels per side aredepth:); it was dropped and the venue's default ten levels came back.get_trades/2withsince:is{:error, {:unsupported_option, :since}}: the tick endpoints take acountand no start time, so the option was silently ignored.get_orders/2withsince:/until:on the open listing (nohistory: true) is{:error, {:unsupported_option, key}}; the open-orders endpoint names no time parameters and the window was ignored. The Fake refuses identically.A range with
:startafter:endis refused rather than answered with nothing:{:error, {:invalid_range, range}}for candles,{:error, {:invalid_option, :start, v}}for the cash-activity reads (get_transfers/2,get_transactions/2) and{:error, {:invalid_option, :since, v}}for an order-history window.start == endis still a valid (empty) window.
Fixed
- An ISO-8601 venue time at or before the epoch (
1970-01-01T00:00:00Z,0001-01-01T00:00:00Z) is{:error, :invalid_unix_time}, as an epoch of0always was; it parsed as a real instant. oauth_token/3and the Fake treat a blankcodeorrefresh_tokenas absent (:code_or_refresh_token_required) instead of sending an empty grant.- Test windows that guard a negative on an asynchronous Feed path are 500 ms.
[0.4.159] - 2026-10-17
Dependencies
- Requires
dp_exchange_core ~> 0.3.99: its tightened conformance groups 12 and 38 run against this package's fake and pass.
Fixed
Fakelacked 22 public facade functions (list_futures_contracts/1,list_futures_product_classes/1,list_event_categories/1,list_event_series/1,list_event_events/1,list_event_markets/1,get_event_trades/2,get_event_order_book/2,add_watchlist_instruments/3,remove_watchlist_instruments/3,sort_watchlist_instruments/2,get_fundamental/3,fundamental_kinds/0,screeners/0,create_token/1,check_token/2,oauth_token/3,adjusted?/1,venue_does_not_serve/0,streaming?/1,live?/1,quotes/0): a consumer's suite gotUndefinedFunctionErrorwhere production answers. Each now validates with the sameRest.validate_*function the real path calls (new public helpers, which the real functions now use too), in the same order and before credentials, and answers a fixed fixture of the real shape. A test asserts the facade's exports minus the Fake's exports is empty.- Telemetry
link_downwas unpaired. The rule:[:dp_exchange, :link, :down]is emitted only to close alink_up(CONNACK 0), once per lost link. A failed reconnect that never reached CONNACK 0 emittedlink_downfor alink_upthat never fired, once per retry; it no longer does. An unknown CONNACK code (before anylink_up) sends its Notice and no telemetrylink_down. A shard socket that crashed (an:EXIT, sohandle_disconnect/2never ran) and a shard rebuilt on INVALID_SESSION (its socket is stopped, not disconnected) emitted none, leaving a link gauge at "up"; the Feed now emits onelink_down(bounded reason text) for each such shard whose link was up, and never twice for one loss. usage-rules.mdsaid a dead subscriber's symbols "stay wanted" untilunsubscribe/2; the feed (and the family rule) releases them when the last data subscriber dies. The text now states the real behaviour, whichtick_ag_test.exsalready pins.
[0.4.158] - 2026-10-16
Changed
- The contract test supplies
ambiguous_place_order:(Core conformance group 42): the realRest.place_order/3under a 503 on every attempt must answer:order_unconfirmed. Thedp_exchange_corefloor is now~> 0.3.98.
[0.4.157] - 2026-10-16
Changed
- The read-only POSTs (stock bars, order preview, token check, news) and the streaming
subscribe/unsubscribe POST now pass
retry_attempts: 3explicitly (put_new, so a caller can lower it) instead of relying on Core's default, which becomes 1 for non-GET requests. Behaviour is unchanged; every write still sends once. The tick-AG negativerefute_receivewindows are 500 ms.
[0.4.156] - 2026-10-16
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.155] - 2026-10-16
- Lifecycle, bounds and hot-path read (tick AG). The streaming client was read for the
sequences below; each held, and each is now pinned by a test that drives it in message
order with no sleeps (
test/dp_exchange/webull/tick_ag_test.exs): subscribe, unsubscribe, subscribe of one symbol (a frame in the gap is dropped andcoverage/1stays empty until the second subscribe), the same pid subscribing twice (one subscriber, one monitor, one delivery), a dead subscriber (dropped with its monitor; its symbols stay wanted, whichusage-rules.mdnow states), and the answered-call timers (caller_timersandconnack_timersempty once the calls are answered).place_orders/3given an improper list ([order | :tail]) raised in the caller's process on a money write: the size guard'slength/1simply failed to match, so the batch reachedEnum.with_index/1. It is now{:error, :invalid_batch}, as any other non-list, on the real path and inFake.- A streamed frame's
timestamp/timetext is no longer handed toInteger.parse/1when it is longer than 32 bytes (a frame may carry up to 1 MiB in the socket process); it is read as no venue time, as any other unreadable one already was. The chosen bound is not a venue figure. usage-rules.mdnow lists every answersubscribe/2,unsubscribe/2andupdate_symbols/2can give ({:connection_refused, details},{:shard_crashed, reason},{:invalid_session, id},{:reconcile_task_down, reason},{:partial_failure, ...}and the rest), the feed's start options with their defaults, and the options a few reads require (:auction,:series_symbol,:sorts,:event_symbol,:expiring_after,:lang).Fake.place_orders/3's doc said the orders were not remembered; they are.
[0.4.154] - 2026-10-15
- Fake-versus-real validation parity and stale timers (tick AF). Each refusal below is
made by one
Restvalidator that the real path andDpExchange.Webull.Fakeboth call, and each is decided before credentials and before any request.:sessionson the stock tape (get_trades/2) and the stock bars (get_historical_prices/4) must name sessions from the venue'sPRE/RTH/ATH/OVNenum (one, or a list without repeats)."rth","ALL","",[]and["RTH", "RTH"]went to the wire as typed ([]as an empty string); they are now{:error, {:invalid_option, :sessions, value}}, as the footprint read has always refused an unlisted session.:historyonget_orders/2andget_auction_imbalance/2must be a boolean or absent.history: "false"was truthy and was answered from the history endpoint; it is now{:error, {:invalid_option, :history, value}}.get_prices/2now refuses a non-boolean:extended_hours/:overnightfor the whole call, asget_price/2does; the fake used to answer a per-symbol error where the real path ignored the flag.place_orders/3refuses a batch that repeats aclient_order_idas{:error, {:batch_order_rejected, index_of_the_repeat, {:duplicate_client_order_id, id}}}before anything is signed. The second entry used to go to the venue, and in the fake it silently overwrote the first order.- The Fake's
place_orders/3also refuses an entry whoseclient_order_idit already holds (fromplace_order/3or an earlier batch) with the same row-indexed{:batch_order_rejected, index, {:duplicate_client_order_id, id}}, and applies none of the batch; it used to overwrite the stored order silently.place_order/3already refused a reused id as{:refused, {:duplicate_client_order_id, id}}. The venue's own wording for a duplicate is unmeasured. - Timers are cancelled with their subject: a parked caller's CONNACK timeout when the caller is answered, an open-retry chain's pending reopen when the chain ends, a staggered open when the slot is filled another way, and a socket's keep-alive ping when its connection drops. They used to fire later and find nothing.
[0.4.153] - 2026-10-15
dp_exchange_corefloor raised to~> 0.3.95(tick AE). The old floor predatedwanted/1(0.3.93), the conformance group 41 options this package's contract test passes (0.3.94, ignored by an older Core: a silent pass) and the groups that no longer pass vacuously (0.3.95).
Documentation accuracy audit (tick AE). No behaviour change.
usage-rules.md"Streaming calls answer" now sayswanted/1answers[]in the same three cases (no feed, timeout, feed exit) in whichcoverage/1andcoverage_by_kind/1answer an empty map; it was silent, and the code already did this.Supervisor.limits/1's comment no longer says every sandbox figure is the venue's own. Only the per-endpoint half is documented; the sandbox global cap (300/60s) is this package's conservative assumption, since the venue publishes no sandbox figure for it.
[0.4.152] - 2026-10-14
Added
- The conformance suite runs Core's group 41 (family reasons) with
unlisted_symbol:andpadded_credentials:, so an unlisted symbol answering anything but{:refused, :not_listed}or a padded credential answering anything but{:error, {:invalid_credentials, venue}}fails this package's own suite.dp_exchange_core0.3.94.
[0.4.151] - 2026-10-14
Hot-path audit (tick AD). Tests in test/dp_exchange/webull/tick_ad_hotpath_test.exs.
- A reported drop no longer pins the frame it came from.
Socket's:data_qualitynotice for an unreadabletick,snapshotorquotecarried the symbol, price, volume and time as views into the inbound frame, so a consumer holding the notice kept the whole frame (up to 1 MiB) alive, once for each of up to 64 reasons per connection. The fields are copied when the notice is built (once per reason per connection); the per-message decode stays copy-free. - An
INVALID_SESSIONanswer's session id is copied out of the response body. It was a view into the whole body and outlived it in the{:invalid_session, id}errorFeedholds.
Facade consistency (design 2026-10-14_design-facade-consistency, D1/D4/D7/D8/D10). Tests in
test/dp_exchange/webull/tick_ad2_facade_test.exs. Several returned reasons change, which is
a breaking change for a consumer matching on the old ones.
- D1: an unlisted symbol is
{:refused, :not_listed}. On every single-symbol read (get_price,get_top_of_book,get_order_book,get_trades,get_historical_pricesfor every category,quantization,get_volume_profile,get_auction_imbalance,get_financials,get_filings,get_fundamental,get_event_trades,get_event_order_book;get_pricesis multi-symbol and unchanged) a 4xx whose body namesINVALID_SYMBOL(it surfaced as the retryable{:error, {:exchange_error, :webull, "HTTP 417..."}}) and a 404 (it was{:refused, :not_found}) are{:refused, :not_listed}, as the Fake already was. A 404 on an order or watchlist stays:not_found. Existing test updated:defensive_branches_test.exs(a 404 onget_price). - D4: notice shapes. A
noticepayload that did not parse is:data_quality(was:degraded),details.reason: :unparsed_venue_notice, the text indetails.message(details.venue_notice_unparsedis gone).details.reasonon:link_downand:coverage_changeis an atom (:disconnected,:socket_crashed,:invalid_session,:open_failed,:resubscribe_failed) and the free text moved todetails.message. - D7: malformed credentials. A value with surrounding whitespace or a control character is
{:error, {:invalid_credentials, :webull}}before signing, never echoed, real and Fake (blank is still:missing_credentials). - D8: plaintext URLs.
{:insecure_url, :wss_required}and{:insecure_oauth_url, :https_required}are both{:insecure_url, :webull}. - D10:
wanted/1on the facade and the Fake: the sorted symbols asked to stream. - Shard opens enforce their spacing when handled. The background opens are armed at plan
time, so a Feed busy past their deadlines received them back to back and dialled in a burst
(4 microseconds apart under the full gate). The open is now the internal
{:open_staggered, ...}message and one arriving less thanshard_spacing_msafter the last background open is re-armed for the remainder. - Test hardening:
socket_malformed_close_test.exs's server helper waited 5 s and failed{:error, :timeout}under load; now 20 s.
[0.4.150] - 2026-10-14
Decoder robustness against hostile or malformed venue payloads. Each fix has a test in
test/dp_exchange/webull/tick_ac_decoder_test.exs that fails on the old code.
- A negative size is no size, on both transports. A streamed
tickof-5was delivered asTrade.quantity: -5(now dropped with a:data_qualitynotice,{:tick, {:non_positive_size, :quantity}}); a REST tape row of-5likewise (now{:error, {:non_positive_size, :quantity, "-5"}}); a footprint level of-3was stored and summed bypoint_of_control/1(now{:non_positive_size, :buy_detail | :sell_detail, raw}). A negative snapshotvolume(stream and REST) and a negative top-of-bookbid_size/ask_sizearenil("not published"), never a negative running total or depth. A zero-size print is still delivered: nothing measured says it is not a real one. - A price, size or rate written in more than 1,024 characters is unreadable (stream and
REST). The exponent bound did not bound the coefficient:
"1"and two thousand zeros was a finiteDecimalof exponent 0 and was accepted as a price. list_event_series/2andlist_event_markets/2returned a non-textpagination_key(an object, a number) typedString.t() | nil, and a caller passing it on raisedProtocol.UndefinedErrorinto_string/1on the next call. It is{:error, :unexpected_pagination_key}, as the walks that follow a key themselves already answered.- Rows that are not objects were returned as rows by
get_accounts/2,get_transfers/2,get_transactions/2,get_fundamental/4,list_futures_contracts/2,list_futures_product_classes/2,list_event_categories/2,list_event_events/2,list_event_series/2,list_event_markets/2andget_event_trades/3against a[map()]spec; they are{:error, :unexpected_response_shape}. A batch answer holding one is{:order_unconfirmed, ids, :unexpected_response_shape}(the batch may be live). - A crypto bars reply with a group that is not an object was shortened silently
(
_other -> []), so a series with a group missing read as complete; it is:unexpected_response_shape, as the stock, option, futures and event bars already answered. - An option contract at a strike of zero or less was filed in the chain grid; it is
{:unreadable_option_contract, keys}. - A 417 on subscribe whose JSON body arrived as text (no JSON content type) matched none of
the named clauses and became the opaque
:exchange_error- the answer that, forINVALID_SESSION, makesFeedretry a dead session id (dp-exchange-core issue #30). A 417 text body that is a JSON object is decoded first, so:oversubscribed,{:invalid_session, _}and{:invalid_symbols, _}are recognised however the body is typed. extended_hoursandovernightwent throughto_string/1. A map or tuple raisedProtocol.UndefinedErrorin the caller; text or a number ("yes",1) was sent as typed, a value the venue's boolean parameters do not define. Onget_price/3,get_top_of_book/3andget_order_book/3anything buttrue,falseor absent is now{:error, {:invalid_option, key, value}}before the request is built (Rest.validate_hours_flags/1), andDpExchange.Webull.Fakerefuses the same on its price, top-of-book and book reads.place_order/3returned anOrderfor a 2xx row naming a DIFFERENTclient_order_idthan the one sent. The venue echoes the id it is given, andOrder.idis whatcancel_order/3andget_order/3take, so a caller would cancel or read some other order while ours may be live. It is now{:error, {:order_unconfirmed, sent_id, :unexpected_response_shape}}, like an acceptance that names nothing. Existing tests whose canned acceptance named"abc"/"c1"while the request carried a generated or different id pinned the bug; they now send the id they expect back, or echo the one sent.- Query options that a query string cannot hold raised in the caller's process after the
limiter had been asked: a map or tuple for a fundamentals or screener option (
:type,:count,:sort_by,:rank_type, ...), a list of maps for a futures or event:symbols, a non-text:sessionson the tape and stock bars, a map for the event tape:limitor event book:depth(the query was built before the check that refuses it). They are{:error, {:invalid_option, key, value}}before anything is built, andDpExchange.Webull.Fakeanswers the same throughvalidate_screener/2,validate_statement_count/2andvalidate_trades_request/1. A list where one value belongs (type: ["ANNUAL", "QUARTERLY"]) was sent concatenated, as"ANNUALQUARTERLY"; it is refused too.
[0.4.149] - 2026-10-14
- A fast reconnect queued a second MQTT CONNECT, a protocol violation the broker answers by closing; only one is ever queued now.
- An
INVALID_SESSIONanswering a request made on an earlier connection tore down a healthy shard. Answers now carry the connection they were sent on and a stale one is dropped. - Two cleanup unsubscribes for one shard killed each other, leaving the symbol subscribed at the venue; each now runs under its own tag.
- The resubscribe tick launched a second identical subscribe while the post-CONNACK replay was still in flight; a shard is skipped while its replay runs.
- A reopened shard could carry a symbol a sibling had picked up meanwhile, delivering it twice and spending two slots; it no longer does.
- Two callers' subscribe and unsubscribe of one symbol, applied out of order by the venue, leaked a slot or left a wanted symbol dark. The shard now repairs it on the answer.
- Docs:
{:error, :feed_timeout}is documented as "no answer", not "not applied": the request is still applied when the feed reaches it, and the streaming calls are idempotent.
[0.4.148] - 2026-10-13
- Tests: every line the suite never executed was read and either tested, proven dead and
removed (
canonical_case/1,shard_changed?/3's nil arm,kind_for/1's trade clause,iso_millis/1,epoch_ms/1,Socket.decimal/1's catch-all), or kept as a commented defensive clause. No behaviour change.
[0.4.147] - 2026-10-12
- New screener:
"hot_stocks". The vendor published a Community section on 2026-10-12; itslist-hot-tickerspage (GET /market-data/communities/stock-rankings/list) ranks stocks by community activity, which is whatget_screener/2serves. It sends no query parameter (the page documents none, so not evencategory) and each row'smetricsis the venue's{rank, symbol, category}. The page is committed verbatim; the six social-post pages that appeared with it have noVenuecallback and change no declaration. The endpoint inventory is re-dated.
[0.4.146] - 2026-10-12
- Security: OAuth is https only.
oauth_token/3refuses a non-https:oauth_url(plain http only to a loopback host) with{:error, {:insecure_oauth_url, :https_required}}; the client secret, code and refresh token were sent in the clear. - Security: the stream is wss only.
Socket.start_link/1refuses aws://URL unless the host is loopback, with{:error, {:insecure_url, :wss_required}}; the MQTT CONNECT carries the App Key.
[0.4.145] - 2026-10-12
- Docs (
usage-rules.mdchecked line by line against the code): documents the jittered reconnect backoff and itsdelay_mstelemetry,get_order_book/2categories, default depth and{:invalid_option, :depth, value}, the batch refusals (batch_too_large,empty_batch,invalid_batch,unsupported_batch_*),oauth_tokenbeing sent once,support_trading_sessiononplace_order/3, and the full not-ported list.
[0.4.144] - 2026-10-12
- A Socket in its reconnect backoff ignored its parent stopping it. The wait, up to 30s, was
a bare sleep, so a socket that
Feedhad stopped (a stale shard rebuilt) or orphaned (aFeedrestart) slept out the delay and then dialled the venue once more, a stray connection against the five-per-App-Key ceiling. The wait now exits with the parent's reason. - Reconnect backoff is jittered per socket (
Socket.jittered_delay_ms/1: shortened by up to a quarter, never lengthened; an immediate first reconnect stays immediate). Up to five shard sockets dropped by one venue event no longer reconnect on identical schedules. Thelink_reconnect_attempttelemetrydelay_msreports the delay actually waited.
[0.4.143] - 2026-10-12
- Docs: every public facade function is documented in HexDocs (148 added); docs build is warning-free.
[0.4.142] - 2026-10-12
- BREAKING: an order-book
:depthout of range is{:error, {:invalid_option, :depth, value}}. It was{:depth_out_of_range, value, min: 1}(ormax: 10on futures); the family names an option it cannot honour that way on every venue. The ranges are unchanged and documented. - Test suite (tick V, every test file read line by line): tests that could not fail now
can (plug
flunkinside a task, self-built comparisons, empty-list sweeps, a signature test comparing two different bodies); timing windows that decided a race now wait on feed state; untested pagination bounds, T13 facade arities and Fake injection/depth refusals are covered; stale and duplicate tests renamed or removed; leaked processes and a retired fixture path fixed.
[0.4.141] - 2026-10-12
- Ticks sharing one timestamp came back newest first. The tape decoder reversed the venue's
newest-first rows back into venue order before its stable sort, so ties kept the venue's order;
they are now oldest first like the rest of the tape (
Core.Venue's ordering rule).
Fixed
- Test suite (tick U), no behaviour change. The link-telemetry tests in
socket_test.exsnow accept only events emitted by the test process itself (the handler matchedprovider: :webullalone, so a concurrent test's socket could satisfy or break them); the "no DISCONNECT" refutes infeed_terminate_log_test.exscapture at:debug, the level the positive cases use, so they can fail; the busy-feed test blocks the feed for 8s instead of 6s so a descheduled test process cannot shorten the measured wait below its 5s bound; a new test pins that same-millisecond ticks come back reversed from the venue's newest-first order; the rejected-symbol TTL test infeed_test.exsno longer relies on a 20 ms TTL staying unexpired across two HTTP round trips (it moves the expiry into the past instead of sleeping); "the superseded attempt is torn down" inresubscribe_identity_test.exsno longer races real 2 s deadlines against a 1.2 s refute (deadlines are 10 minutes and the stale deadline message is delivered by hand); plug-sideflunk/1in reconcile tasks (which could not fail a test) became messages plus areconciling == %{}check inresubscribe_timer_test.exsand arefute_receivedin the shard-stability test; the notice refutes infeed_test.exsfirst settle the feed with a synchronous call. Aget_top_of_book/3@docline over 98 columns was re-wrapped.
[0.4.140] - 2026-10-12
Fixed
- Docs that contradicted the code, corrected (tick T).
Rest.get_trades/3said the tape came "newest first" (it is returned oldest first) and thattrading_sessionsis always sent (stock tape only);Rest.get_event_trades/3said the venue's default count is 30 "not the 200 its other tapes use" (every tape defaults to 30;tick.md,event-tick.md);Rest.place_order/3was headed "Places a crypto order" though it serves five instrument types;Rest.get_top_of_book/3did not state that an empty bid/ask field is no price;DpExchange.Webull's volume section said a crypto quote or bar has no volume though one the response carries is delivered; several@docs named the wrong arity (get_symbols/1,get_historical_prices/4,get_screener/4,get_price/2,decode_bar/3).usage-rules.mdnow says which bars paths sendreal_time_requiredforinclude_partial: true. place_orders/3/validate_batch_orders/1build the batch list by prepending and reversing once instead ofacc ++ [order]per entry (identical result and order).
[0.4.139] - 2026-10-12
Fixed
The Fake's order book ignored
:depth. It validated the option and then returned its whole canned ladder; it now returns at mostdepthlevels per side, as the real request asks for.The bar grid check no longer refuses legitimate session bars. tick R applied the strict UTC grid to every category, which would refuse US stock
1h/2h/4hbars opening at 09:30 New York (13:30Z/14:30Z) and1dbars stamped at New York midnight. Crypto keeps the strict grid; stock, option, futures and event bars now accept: 30 minutes and under on the UTC grid, wider intraday widths at minute 0 or 30 (13:45Z is still refused), and a1d/1w/1M/1ybar at 00:00Z or New York midnight (04:00Z/05:00Z) mapped to 00:00 UTC of that date, as Schwab does. Anything else is{:unaligned_candle, time}. Unmeasured against a live venue. TheFakemodels crypto only and keeps the strict grid.Crypto bars with a
:startand no:limitsendcount=1200(as stock does), so the range-reached check works past the venue's default of 200.Option bars send
real_time_required=trueforinclude_partial: true. The parameter is optional there (venue defaultfalse), and it was never sent, so a caller who asked for the forming bar was answered by a venue that never included it. Without the option the request is unchanged.The stream reads the venue's
"null"(or an empty) bid/ask price as no resting order on that side, asRest.get_top_of_book/3does. The tick-R change refused the whole frame for it, so the same spelling gave a one-sided book over REST and a dropped frame (with a:data_qualitynotice) on the stream. A side that isNaN, text or negative still refuses the frame, and a frame with neither side priced is still no quote. REST also reads""as no price on a side.
[0.4.138] - 2026-10-12
Family market-data and order conventions (tick R). Nothing here was measured against the live venue; what is unmeasured is said on each line.
Changed
BREAKING:
real_time:is nowinclude_partial:, on every bars path. The bar still forming (opened_atplus its width is after now) is dropped from crypto, stock, option, futures and event bars unlessinclude_partial: true; it was dropped on the stock path only.real_time:is{:error, {:unsupported_option, :real_time}}(it was read by stock and event bars) and a non-boolean:include_partialis{:error, {:invalid_option, :include_partial, v}}, inRestand theFake(Rest.validate_bar_options/1). The event endpoint is asked forreal_time_required=trueonly withinclude_partial: true; crypto keeps sending"false"(the vendor page contradicts itself about which value includes the forming bar - unmeasured).BREAKING:
:endof a bars range is exclusive (a bar opening at:endis no longer returned), inRestand theFake.BREAKING (error value): a range the page cannot cover is
{:error, {:range_too_wide, count}}(crypto's was{:range_exceeds_count, count}), and the stock path now has the check: it sent nocountunless:limitwas given, so the venue's default of 200 could cut a range short without a word. Stock bars now sendcount(the:limit; the endpoint's maximum - 1200, 1650 for1m- when a:startis given without one; nothing when there is no range and no limit) and a full page whose oldest bar is after:startis refused. Whether the venue returns the latest or the oldestcountbars of a range is unmeasured.BREAKING: a bar whose
timeis off its width's grid refuses the whole reply,{:error, {:unaligned_candle, time}}, on every bars path (Core.Timeframe.aligned?/2). The pages calltime"Bar UTC time", not the open, and their own example (09:00:09.945) is on no grid. Unmeasured: whether daily and longer stock bars are stamped at 00:00 UTC of the session date or at the exchange-local midnight; a local stamp is refused until a live read says which, rather than guessing an instant.1w,1Mand1yare not checked (Core has no rule). TheFake's bar is on the grid for its width.BREAKING (error value): a traded or bar price of zero or less refuses the reply -
{:error, {:non_positive_price, field, raw}}for an open/high/low/close, a tick price and a last price (REST), and the frame is dropped and reported once for the stream's snapshot and tick. A REST top-of-book bid or ask of0(or"null", the venue's documented no-price spelling) is no resting order: that side and its size arenil; a negative or non-numeric one refuses the reply.BREAKING: an order book level with no readable price refuses the whole book (REST); it was silently dropped, leaving a hole that read as "nothing at that price". A level priced at zero or less is
{:non_positive_price, :price, raw}; a level of size zero is dropped (no liquidity); a negative size is{:non_positive_size, :size, raw}.BREAKING:
TopOfBook.venue_timeisnilfor a REST row with noquote_time(the stock snapshot states onlylast_trade_time, a different event); it fell back to it.Quote.venue_timeis the last trade's time and nothing else: a REST row with only aquote_timeisnil, and a streamed snapshot with notrade_timeisnilinstead of the message'sBasic.timestamp(when the venue assembled the frame).BREAKING: a trade's
sideisnilon REST and on the stream. The tape'sB/Swere mapped to:buy/:sell, but the pages say only "Such as: B S G L N" and define none of them; whetherBis the taker's, the buyer's or the resting order's side is unmeasured, andCore.Types.Tradesays an undocumented code isnil. TheFake's tape agrees.capabilities/0declaresmax_candles_per_request: 1_200- documented (each bars page'scountmaximum; stock1mis 1650, which one flat number cannot say), never measured against a live response, andmeasured_againstsays so. It wasnil.BREAKING (error value): order validation answers in tagged tuples, in the family's order. An absent side is
{:missing_field, :side}(was{:invalid_side, nil}, also by index in a batch); both:quantityand:amountis{:unexpected_field, :amount}(was:ambiguous_order_size);:amounton a stop-limit order is{:unexpected_field, :amount}(was:cash_sizing_not_supported_for_stop); in a batch entry neither size is{:missing_field, :quantity}(was:missing_order_size),:amountis{:unexpected_field, :amount}(was:cash_sizing_not_supported_for_batch) and no session is{:missing_field, :support_trading_session}(was:support_trading_session_required). A GTD order's:expire_dateis now checked with the time in force, before the size and every price field, inRestand theFake.BREAKING (error value): the page bound of a paginated read is
{:error, :history_truncated}(get_symbols/2's was:too_many_instrument_pages; the other cursor walks' was{:too_many_pages, n}). A repeated cursor stays:pagination_key_did_not_advance.BREAKING (error value):
create_token/2is a write: a create that may have reached the venue and is not proven (timeout, 5xx, an unreadable 2xx, a 2xx naming no token or an unknown status) is{:error, {:write_unconfirmed, nil, reason}}; the old refusals ({:missing_required_field, _},{:unknown_token_status, s}) are now thatreason. Pre-send failures stay plain and a 4xx is{:refused, _}.BREAKING (error value): a candle or trade
:limitabove the page's maximum (or not a positive whole number) is{:error, {:invalid_option, :limit, value}}(MD4), inRestand theFake; it was{:limit_out_of_range, n, max: max}. The:countof the footprint and statement endpoints keeps the old shape.BREAKING: a streamed
TopOfBookside priced0isnilwith no size, and a side that is present but unreadable ("null",NaN, text, negative) refuses that frame's top of book (reported once as a:data_qualitynotice) instead of delivering the other side beside anil(MD6). A frame with neither side priced is still dropped.Socket's trade time still falls back to the frame'sBasic.timestampwhen a tick states no time of its own: deliberate, sinceTrade.timestampis required and MD7 names only the nullableQuoteandTopOfBooktimes. Documented attick_time/1.Credo:
quote_time/1andtrade_time/1no longer use a negatedis_nil/1guard.
Verified
subscribe([])opens nothing:Feedplans no shard for an empty list and answers:okwithout a request (empty_subscribe_test.exs); theFakeagrees.
[0.4.137] - 2026-10-12
Fixed
- BREAKING: a write is sent exactly once whatever
:retry_attemptsthe caller passes.send_once/1wasKeyword.put_new, soretry_attempts: 3made a cancel, a placement, a replace or a watchlist write up to three requests. It is nowKeyword.put(..., 1). This also makes the limiter-on-retry case below unreachable throughRest's writes;unsent?/2stays for callers of the public helper. - BREAKING (error value): an event contract order sized by
:amountis refused unless it is a BUY at FOK, ascommon-order-place.mdstates:{:invalid_side, side}or{:unsupported_order_combination, :amount, time_in_force}, before the request, inRestand theFake. It was sent and left to the venue. - BREAKING (error value):
place_orders/3answers{:error, {:order_unconfirmed, ids, reason}}for a timeout, a 5xx or a 2xx with no readable rows. It was a bare{:error, reason}(or{:ok, []}for an empty body), so the per-orderclient_order_ids this package generated were lost and a retry placed every order again.idslists every id sent, in order. Missing credentials and our own limiter refusing stay plain. - New
Rest.unsent?/2andRest.write_outcome/2take the options a request was sent under: a limiter refusal counts as "never sent" only for a single attempt, becauseCore.HttpClientasks the limiter again before every retry, after attempt one may have landed. Since writes are now always sent once (first entry), noRestwrite reaches that case; the helpers are defensive and behave as before for the single-attempt default. Missing credentials stay plain. usage-rules.mdsaid a price field the order type does not use is "dropped rather than sent"; it is refused as{:unexpected_field, key}. The batch section now saysorder_typeandtime_in_forceare required per row.
[0.4.136] - 2026-10-12
Changed
Family non-order write conventions (W1, W6, W7) for the watchlist endpoints. Nothing here was measured against the live venue.
BREAKING (error value):
{:error, {:invalid_symbols, v}}fromsubscribe,unsubscribeandupdate_symbols(facade,Feed,Fake) carries the caller's whole argument as given. It carried only the offending elements (at most five), and an improper list's tail alone.BREAKING (error shape):
create_watchlist/3,update_watchlist/2anddelete_watchlist/2answer{:error, {:write_unconfirmed, nil, reason}}when the write may have reached the venue and is not proven - a transport error or timeout, a 5xx, a 2xx whose body is undecodable or has nosuccess, a create whose 2xx names nowatchlist_id. Was a bare{:error, reason}, which Core calls retryable: a caller retried its way into a second watchlist or a repeated delete. The key isnil(this venue takes no idempotency key for a watchlist write);reasonis bounded. Pre-send failures (no credentials, our own rate limiter, an invalid id / name / sort / symbols) stay plain, and the venue's own answers stay{:refused, _}(including{:refused, :watchlist_write_rejected}forsuccess: false).Rest.write_outcome/1is the shared classifier.BREAKING (error shape):
add_watchlist_instruments/3,remove_watchlist_instruments/3andsort_watchlist_instruments/2answer{:error, {:write_unconfirmed, nil, reason}}for the same possibly-sent outcomes (was a bare{:error, reason}), throughRest.write_outcome/1.BREAKING (error shape):
create_watchlist/3whose add step is unproven answers{:error, {:watchlist_created_members_unconfirmed, id, reason}}: the watchlist exists and its membership is unknown (read it before adding again). It was{:watchlist_created_without_members, id, reason}, which claimed the members were absent. A definite failure of the add (a refusal,success: false) is still:watchlist_created_without_members.BREAKING:
Fake.delete_watchlist/2of an unknown id is{:refused, :not_found}, as the real path answers a 404. It was{:ok, :ok}. A watchlist already deleted is unknown too.BREAKING:
list_watchlists/1refuses a reply with a row that has no textwatchlist_idas{:error, {:missing_required_field, :id}}. The row was silently dropped, so a list missing a watchlist read as "it does not exist".
[0.4.135] - 2026-10-12
Changed
Family trading conventions (T1-T14), applied to place_order/3, preview_order/3,
replace_order/4, cancel_order/3, get_order/3 and Fake, which answers the same in the
same order from the same functions. Nothing here was measured against the live venue.
- BREAKING (error shape):
place_order/3with a 2xx that has no row at all ({}, an empty or unreadabledata) is{:error, {:order_unconfirmed, client_order_id, :unexpected_response_shape}}, was a bare{:error, :unexpected_response_shape}that contradicted the function's own@docand that a caller retries into a duplicate order. - BREAKING (error shape): errors decided before anything is sent are no longer wrapped as
:order_unconfirmed. No credentials, a blank credential, our own rate limiter refusing ("Throttled by our own rate limiter","Rate limiter unavailable",:rate_limit_timeout) and a signing failure are the plain{:error, reason}-{:error, {:missing_credentials, :webull}}, the Fake's answer - not{:order_unconfirmed, id, reason}, which sent callers looking for an order that was never placed.replace_order/4now wraps a transport error or 5xx on its own send as{:order_unconfirmed, order_id, reason}by the same rule. - BREAKING (error shape): the request is validated, in the family order, by one function
(
Rest.validate_order_request/1, also used by the Fake andpreview_order/3): a map ({:invalid_order_request, r}; a non-map request raisedBadMapError, and a non-map preview raised before any check), then unknown keys, symbol, side, order type, duration, size, price fields, client order id, then the account option, then the credentials.- A key the builder does not read is
{:unsupported_order_field, key}:post_only,reduce_only,end_timewere ignored. :order_typeand:time_in_forceare required ({:missing_field, key}): an absent type became a limit order and an absent duration GTC. A market order with no duration was refused for an unnamed pair.- A type the instrument does not use is
{:unsupported_order_type, type}; a duration it does not serve for that type is{:unsupported_order_combination, type, tif}- three elements, the instrument is gone from the tuple (it was four). - A price field the type does not use is
{:unexpected_field, key}::priceon a market or stop order and:stop_priceon a limit or market one were silently dropped, as was:trailing_stop_stepon any other type. A GTD order with no:expire_dateis{:missing_field, :expire_date}(it was sent without one) and an:expire_dateon any other duration is{:unexpected_field, :expire_date}(it was dropped). - BREAKING: every
place_orders/3row must name:order_typeand:time_in_force({:batch_order_rejected, index, {:missing_field, :order_type | :time_in_force}}). An absent type became a limit order and an absent duration DAY for each of up to fifty orders. The Fake's batch rows read both from the validated request. side: :shortis{:invalid_side, :short}-capabilities/0declaressupports_short_selling: false, yet it was sent on every instrument and returned anOrderwithside: nil. A missing side is{:invalid_side, nil}and a missing or blank symbol{:invalid_symbol, v}(both were{:missing_required_field, field}).- Tags:
{:missing_required_field, field}for an order is{:missing_field, field}(:missing_order_sizeis{:missing_field, :quantity});{:invalid_number, field, v}is{:invalid_quantity, v},{:invalid_amount, v},{:invalid_price, v},{:invalid_stop_price, v}or{:invalid_trailing_stop_step, v}, and a float is refused (it has already lost digits); a blankclient_order_idor symbol is refused. The batch entry checks (place_orders/3) share all of this, by index, and refuse a key the batch builder does not read.
- A key the builder does not read is
- BREAKING (error shape): the order id of
get_order/3,cancel_order/3andreplace_order/4that isnil, not text or blank (" "was sent) is{:error, {:invalid_order_id, v}}, checked before the account option and the credentials (it was{:invalid_client_order_id, v}, checked after the account, and a nil id answered:account_id_requiredfirst).{:invalid_client_order_id, v}now means only theclient_order_idinside a placement (32 characters at most, the venue's format rule). - BREAKING (error shape): a missing account option is
{:error, {:missing_option, :account_id}}, was the bare atom:account_id_required, on every account and order call inRestandFake. Whitespace-only is missing too. - BREAKING (error shape): an HTTP 404 is
{:refused, :not_found}from every REST read and write, was{:error, {:exchange_error, :webull, "HTTP 404 ..."}}(retryable by Core's definition). It now matches whatFakeanswers for an order id it never issued. - BREAKING (options):
replace_order/4requiresopts[:instrument_type]andopts[:order_type]- the order being edited - and answers{:error, {:missing_option, key}}without them. They defaulted to an equity limit order, whereplace_order/3defaults to crypto, so the edit table of an order the caller never named decided the call.changesthat is not a map is{:error, {:invalid_order_request, changes}}(it raised inMap.keys/1). The check order is: order id,changes, the two options, crypto, the edit table, the account option, the credentials.Rest.validate_amendment/2andRest.order_combinations/1(used only by the Fake) are removed;Rest.validate_replace/3,Rest.validate_preview_request/1andRest.unsent?/1are added. Fake.place_order/3refuses a reusedclient_order_idas{:refused, {:duplicate_client_order_id, id}}(permanent), instead of overwriting the stored order. The venue requires the id unique per account; its own wording for a duplicate is unmeasured (the vendor reference is silent), so the shape is the contract. Ids the Fake generates are unique per placement.- Tests:
trading_conventions_test.exspins every answer above inRestandFake(and that both agree on every faulty request, in the same order); existing tests moved to the new tags. The contract suite namesinstrument_type/order_typefor its replace.
[0.4.134] - 2026-10-11
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.133] - 2026-10-11
Changed
- BREAKING (error shape): a single-symbol read of a symbol that is
nil, not a string, or blank is{:error, {:invalid_symbol, value}}, decided locally.get_price/2,get_top_of_book/2,get_order_book/2,get_trades/2andget_historical_prices/4. On the defaultUS_CRYPTOcategoryget_price(nil),get_top_of_book(:btc)and bars raisedFunctionClauseErrorin the symbol mapper; on the others""was sent assymbols=andget_order_book(nil)went to the wire unchecked. The check runs after the category's own refusal and before credentials.Rest.validate_symbol/1is shared with the Fake. The same now holds for every other single-symbol read:get_volume_profile/3,get_auction_imbalance/2,get_fundamental/3,get_financials/3,get_filings/2,get_event_trades/2,get_event_order_book/2,quantization/2,get_option_chain/2,get_option_expirations/2andRest.get_stock_bars/5(which also validates itsrangeitself). The Fake refuses identically, in the same order, for those it models. - BREAKING (error shape):
get_prices/2with a symbol list that is not a proper list is{:error, {:invalid_symbols, value}}(it raisedFunctionClauseError), and a bad member of a good list gets{:error, {:invalid_symbol, member}}as its own result with no request, instead of raising or taking its chunk down. - BREAKING (error shape): a historical
rangethat is not a keyword list ofDateTimebounds is{:error, {:invalid_range, range}}, before any request. A non-DateTime:start/:endraised inDateTime.compare/2after the request had been made and charged; on the option, futures and event paths a non-keyword range raised inKeyword.get/2.Rest.validate_range/1runs first invalidate_bars_request/3, which the Fake uses. - BREAKING (error shape):
subscribe/2,unsubscribe/2,update_symbols/2andsubscribe_notices/1validate their arguments in the caller, before{:error, :feed_not_started}.unsubscribe(nil)used to answer:okwith no feed, and a bad list or subscriber answered:feed_not_started; they are now{:invalid_symbols, _}/{:invalid_subscriber, _}whether or not the feed runs. A validunsubscribewith no feed is still:ok. - BREAKING (error shape):
trueandfalseare refused as a subscriber ({:error, {:invalid_subscriber, to}}), byFeed.check_subscriber/1, the facade and the Fake. They passed as atoms naming a process that does not exist. - BREAKING (error shape): a whitespace-only symbol (
[" "]) in a subscribe list is refused as{:invalid_symbols, [" "]}; only the empty string was refused before.Feed.check_symbols/1is public and is the one validator the facade,Feedand the Fake use. - The streaming callbacks take
opts \\ []on the facade (subscribe/1,unsubscribe/1,update_symbols/1,subscribe_notices/0), as they do on the Fake and the other venues.
Fixed
Socketwithssl_options: nil(or[]) connected without verifying the certificate.websockexreads anilas "none", falling back toverify: :verify_none, andput_newdid not replace thenil.nil,[]and a non-keyword value now mean the verified defaults (verify_peer, the system CA store, hostname check); a keyword list is merged over them, so a caller overriding onlycacertskeeps verification.Feed.start_link/1now wraps:credentials(idempotentCredentials.wrap_opt/1), so aFeedstarted directly, not through the supervisor, no longer hands raw credentials to OTP's start arguments.Fake.subscribe_notices/1no longer sends:link_upon registration. The real feed sends one only when a link comes up; the fake has no link. A test that waited for it should stop.Fakeno longer raises sending to ato:atom that names no process; it skips it, as the realFanoutdoes, and reports no coverage for what nobody received.Fake.update_symbols/2reported coverage for symbols it never pushed. It now delivers the symbols it adds to the existing subscribers and covers only what was delivered; with no subscriber it covers nothing.Fake.coverage_by_kind/1reported only:quotes. The fake now pushes aTopOfBookwith eachQuoteand reports both:quotesand:top_of_book, as the real feed does.Fakechecked credentials before the symbol on the single-symbol reads; the order is now category, range and request, symbol, credentials, as inRest.
[0.4.132] - 2026-10-11
Fixed
- A
pagination_keythat was not text was sent back as the next cursor. The key was taken from the body whatever its type, andstringify/1raises on an object or a list, so a venue answering"pagination_key": {...}raised in the caller. Every cursor walk (get_symbols/2, orders, activities, screeners, option contracts, fundamentals) now refuses it:{:error, :unexpected_pagination_key}. Ending the walk there instead would have returned a prefix as the whole list. - A refusal carried the venue's words and
codeunbounded.{:refused, {:venue_error, status, text}}held a gateway's whole JSON string (up to Core's body limit) and acodethat was an object rode along as a nested term. Text over 2 KiB is cut, copied and marked... [truncated, N bytes in all]; acodeis kept when it is text or a whole number and is otherwise dropped ({:venue_error, status}). - A shard's socket held its
app_keyas a bare string in the state OTP prints on a crash.Credentialsredactsapp_key, andSocketnow holds it wrapped (connect_key/1reads it for the CONNECT).usage-rules.mdno longer says the key shows in a shard's crash report. DpExchange.Webull.start_link/1called directly left the raw credential map in the supervisor's init argument and inFeed's child spec. Onlychild_spec/1wrapped it.DpExchange.Webull.Supervisor.start_link/1now wraps too (idempotent).Credentials.wrap/1passes a value that is not a map through instead of raisingFunctionClauseErrorwith it in the printed arguments.
Reviewed, not changed
- Notices from the venue's
noticetopic are not rate-limited:Feedfans them out unbounded by design (the notice that a consumer is being dropped must not be the first thing dropped), so a venue sending thousands per second would reach a consumer's mailbox at that rate. Nothing measured shows it; recorded, not guessed at. Feedkeeps every wanted symbol, including those past the five-shard ceiling (reported ascapacity_exceeded), so a caller handing it millions of symbols makes every reshard sort them. A caller error rather than a venue behaviour; no cap is invented here.
[0.4.131] - 2026-10-11
Fixed
check_token/2andoauth_token/3raisedFunctionClauseErrorin the caller for a value that was not text. They are refused as{:error, {:invalid_field, :token | :client_id | :client_secret}}, naming the field and never echoing a credential. The Fake'supdate_watchlist/2now checks the id before:symbols, inRest's order.A venue answering
INVALID_SESSIONto every fresh session made a reconnect storm.rebuild_stale_shard/3reopened the shard with an immediate message each time, so a venue that kept refusing the new session cost a websocket and its HTTP round trips per cycle with no pause. It now goes through the policy a crashed socket already used (reopen_with_backoff/4, one shared run counter per index): the first teardown reopens at once, repeats within 60 s climbopen_retry_base_ms * 2^(n-2)capped atopen_retry_max_ms. A delayed reopen carries a token held instate.reopen_timers; a newer teardown of the same index cancels the older timer, and a timer whose token is no longer current is ignored when it fires. The crash path got the same token, which it lacked. Stability guard only; nothing is assumed about the venue's behaviour.A number or object where an order's id belongs was returned as the order's id.
get_order/3answered{:ok, %Order{id: 12345}}for a numericclient_order_id(Order.idis text) andplace_order/3built the order from one; the first is now{:error, {:missing_required_field, :id}}and the second{:error, {:order_unconfirmed, sent_id, :unexpected_response_shape}}, the shape any acknowledgement that names no usable order already has.A book side sent as text, a number or an object was delivered as an empty side.
get_order_book/3read"bids" => "none"as "no bids"; it is now{:error, :unexpected_response_shape}. An absent ornullside is still empty.A watchlist id or name that was not text raised
FunctionClauseErrorin the caller.get_watchlist/3,update_watchlist/3,delete_watchlist/3and the membership writes now answer{:error, {:invalid_watchlist_id, id}}for a non-text or empty id, andcreate_watchlist/4{:error, {:invalid_watchlist_name, name}}, before any request. The Fake refuses the same values in the same position (Rest.validate_watchlist_target/2).A
to:that was not a pid or a registered name crashed the sharedFeedon the first delivery.subscribe/2andsubscribe_notices/1stored any term as the subscriber, andCore.Fanout.resolve/1has clauses for a pid and an atom only, so a string, a{:global, name}tuple or a number raisedFunctionClauseErrorinside the one named process. The restart that followed dropped every other consumer's subscriptions. Both are now refused up front with{:error, {:invalid_subscriber, to}}and nothing is recorded (Feed.check_subscriber/1, shared withDpExchange.Webull.Fake, which refuses the same values in the same order: symbol list first, then subscriber).coverage/1andcoverage_by_kind/1raisedFunctionClauseErrorfor afeed:given as{:global, name},{:via, module, name}or{name, node}. Their specs promise a map. AnyGenServer.server()form is accepted now, and a name that resolves to no local process reads as the empty map.The
DpExchange.Webullmoduledoc namedget_historical_prices/5, an arity the facade does not have (Rest.get_historical_prices/5is the internal one). It says/4.
[0.4.130] - 2026-10-11
Fixed
- An equity, option, future or event symbol was split like a crypto pair.
SymbolFormatcuts a symbol at a trailingUSDT/USDC/USD/BTC/ETH, soget_symbols/2forUS_STOCK, a position, and an order whose row says it is an equity readGBTCasG-BTCandFBTCasF-BTC- names no stock endpoint takes and no holding is known by. The split now applies only to the crypto catalogue and to a position or order row whoseinstrument_typeisCRYPTO(or states none, the undocumented shape, which keeps its old reading). - A volume profile could hold a
nilwhereCore.Types.VolumeProfiletypes aDecimal.buy_detail/sell_detailstoreddecimal(size)under each price, so"null","NaN"or text became anilthatVolumeProfile.point_of_control/1then raised on in the consumer. A level with an unreadable size now refuses the profile ({:error, {:invalid_decimal, :buy_detail | :sell_detail, value}}); a price map that is not an object is{:error, :unexpected_response_shape}. get_top_of_book/3published a size beside a price it did not have. A"null"bid with a realbid_sizecame back asbid: nil, bid_size: 5, depth on an empty side.Socketalready kept each size only with its price; REST now does the same.- An epoch past thirteen digits was read as milliseconds. A microsecond or nanosecond value
became a valid
DateTimein the year 50,000 or later, with no failure anywhere.Restnow answers{:error, :invalid_unix_time}(a bar) ornil(a quote's time), andSockettreats the frame as undated. The streaming schema states no unit fortimestamp, so the seconds/ milliseconds reading there is recorded as unmeasured. - An option contract on another underlying was filed under the one asked for.
get_option_chain/3andget_option_expirations/3now refuse a row whoseunderlying_symbolnames a different underlying ({:error, :not_in_response}). - A screener row whose
symbolwas a number or an object was published as aScreenerResultwith that value as its symbol. Only text names a row; the first text amongsymbol,sector_nameandnameis used, and a row with none is dropped as before. Fake.quantization/2invented increments for a crypto pair it does not list. It now answers{:refused, :not_listed}, as the fake's other reads do.
Added
- An order's
created_atandupdated_at.created_atisplace_time_atandupdated_atisfilled_time_at(both ISO8601 UTC with a stated example,order-detail.md), the latter only for a:filledor:partially_filledorder - after a cancel the last fill is not the last change, and the venue states no time for the cancel.nilwhere absent or not ISO8601. Unmeasured against a live order.
[0.4.129] - 2026-10-11
Fixed
get_order_book/3built a levelCore.Types.OrderBookrefuses.OrderBook.level/0is{Decimal.t(), Decimal.t()}andOrderBook.new/1raises on anything else; a level with a readablepriceand an absent,"null"or"NaN"sizewas passed on as{price, nil}(usage-rules.mdeven called it "a real shape"). The book is now refused:{:error, {:missing_required_field, :size}}or{:error, {:invalid_decimal, :size, value}}. Dropping the level instead would have published a hole in the book as "no liquidity at that price". A level with no readablepriceis dropped as before.expire_dateandevent_outcomeof an order went to the wire as the caller held them.common-order-place.mdtypesexpire_date"yyyy-MM-dd" (UTC) andevent_outcomeyesorno. ADateTime(JSON-encoded as a full timestamp),"20261201", a number,:maybeor"YES"was a refusal about nothing the caller wrote, on an order that moves money. A GTDexpire_datemust now be aDate(written asyyyy-MM-dd) or one realyyyy-MM-ddstring, else{:error, {:invalid_expire_date, value}};event_outcomemust be:yes,:no,"yes"or"no", else{:error, {:invalid_event_outcome, value}}. Both are checked before any request, inplace_order/3,preview_order/3and the fake (one builder; the batch endpoint takes neither field). A missing GTD date is still left missing, as documented.A time bound that was not a
DateTimewas sent as it came.get_transfers/2,get_transactions/2andget_orders/2(history: true) wrote:start,:end,:sinceand:untilthroughiso_millis/1's pass-through clause, so an epoch integer, aNaiveDateTimeor aDatereached the venue in a form the page does not define, and the cross-year guard skipped exactly those values. They are now{:error, {:invalid_time, key, value}}before a request (newRest.validate_orders_window/1;Rest.validate_activity_range/1gains the check;Fakeruns both, in the real path's order).A
noticebody of any size rode indetails.venue_notice. Only the words were bounded (1 KiB), so one frame of up to the 1 MiB packet limit put a megabyte-sized term into a notice a consumer may keep. Past 64 KiB (:erlang.external_size/1)details.venue_noticeis%{"truncated" => true, "approx_bytes" => n}; the bounded message and the severity are still read from the whole body.Subscriptionreturned a refusing body whole.{:error, {:refused, status, body}}carried a gateway's entire page (up to the 16 MiB response limit). Text over 2 KiB is cut (on a character boundary where valid), copied and marked... [truncated, N bytes in all]; a decoded JSON body is unchanged.Feedmeasured "did this shard deliver lately" on the host's clock.state.deliveringandstate.rotated_atwere stamped and compared with:os.system_time/1, the one in-VM duration left on the wall clock after the rejected-symbol TTL moved off it: an NTP step or a resync forward made every delivering shard look silent (feeding the reopen gate) and one backward kept a dead shard looking alive. They now useSystem.system_time/1, the VM's own slewed time (still epoch-based because0is the "never" default they are read with). Tests that plant adeliveringstamp use the same call.
Changed
dp_exchange_corefloor is~> 0.3.74. From thereCore.Types.Order.order_typenames:trailing_stop, whichRest.order_type_atom/1returns forTRAILING_STOP_LOSS; against an older Core anOrderthis package returned was oneOrder.new/1refuses. This closes the gap recorded under "Not changed" in 0.4.128 (the first bullet there is now resolved by Core).- Position side is declared an unmeasured assumption.
account-position.mddescribesquantityonly as "Quantity of the position" and documents no sign convention; reading a negative as:short(Position.from_signed_quantity/1) is now stated inget_positions/2's@docas not measured against a live short position. Behaviour unchanged. - Test waits: every explicit
assert_receive,Task.yield,Task.awaitand polling budget under 5 s that waited on a spawned process or timer is now 5 s (therefute_receivewindows stay short).
[0.4.128] - 2026-10-11
Fixed
Error text quotes a venue body bounded. Six unexpected-status branches in
RestandSubscriptionputinspect(body)/inspect(response)into{:exchange_error, :webull, _}, so a CDN error page became the message. Quoted bounded now (limit: 50,printable_limit: 2_048), as Core does.place_order/3handed back the request as the caller typed it.Order.sideis:buy | :selland its sizes areDecimals, and the answer carriedside: "buy"(or:short) andquantity: 5/price: "180.5"unchanged, inside a structOrder.new/1refuses. The side is now read through the sameBUY/SELLtable a read-back uses (SHORThas no slot, so it isnil, as a read-back of it is) andquantity,priceandstop_priceareDecimals.Fake.place_order/3andFake.place_orders/3build their orders with the same function (Rest.order_from_request/2);Fake.replace_order/4reads amended numbers back asDecimals.A failed read-back after an accepted
replace_order/4looked like a failed replace. The venue had taken the amendment; the read's own{:error, _}/{:refused, _}came back unchanged, so a caller retried a replace that had already taken effect. It is now{:error, {:replace_accepted_unread, client_order_id, reason}}(the id goes back asplace_order/3's{:order_unconfirmed, id, reason}does). Read the order withget_order/3.get_positions/2built a position with no quantity and no side. A row whosequantitywas absent or not a number became%Position{side: nil, quantity: nil}, whichCore.Types.Positionforbids (it enforces both, and states the quantity is always positive); the page listsquantityas required. The whole list is now refused:{:error, {:missing_required_field, :quantity}}, or{:invalid_decimal, :quantity, value}("NaN"included).opts[:account_id]was accepted as any non-nilterm. A number went into a JSON body as a number where the venue types the account id as text, and""andfalsewere accepted byRestand refused byFake(andFake.place_orders/3refused a non-binary thatRestaccepted). One validator,Rest.validate_account/1, now serves both: non-empty text, else{:error, :account_id_required}.get_transfers/2raised onactivity_types: "DEPOSIT,TRANSFER"(Enum.join/2over a string), and a list with a non-text member, or a number, raised or went to the wire asto_string/1made of it. The venue's comma-joined string is sent as it is, and anything but a non-empty list of text or such a string is{:error, {:invalid_activity_types, value}}(get_transactions/2likewise).Fake.get_transfers/2filters by the string as it does by a list.Core text fields received objects and numbers.
FinancialStatement.currency,CorporateEvent.currency,Filing(id,form_type,title,url),OptionContract(venue_symbol,settlement_type,expiration_type),Watchlist.nameand the members ofWatchlist.symbolsareString.t() | nil/[String.t()], and were taken from the venue's JSON as it came. Text is kept, a whole-number filing id becomes its string, anything else isnil(a member that is not text is dropped). A watchlist row whosewatchlist_idis not text is dropped fromlist_watchlists/2and refused bycreate_watchlist/4.A watchlist was created before its arguments were checked.
create_watchlist/4created the list and only then found a member was not text (the add failed), leaving an empty watchlist for a mistake knowable from the arguments; andsortof text went out as a JSON string where the pages type an integer. Members ({:invalid_symbols, symbols}) and sort ({:invalid_sort, sort}) are checked before any request, on create, update and the add/remove writes;sort_watchlist_instruments/3refuses a position that is not a whole number ({:invalid_sorts, sorts}). The fake runs the same validators.extended_hours: nil,overnight: nilandreal_time: nilwere sent as an empty string or JSONnull, wherenilmeans the default everywhere else (DpExchange.Core.Config.opt/3).get_event_order_book/3sent any:depth. A value that is not a whole number of levels is{:error, {:depth_out_of_range, depth, min: 1}}before a request.
Changed
DpExchange.Webull.Fakerefuses and narrows asRestdoes, each rule from a publicRestvalidator rather than a copy:get_price/2andget_top_of_book/2refuse a snapshot category the venue has no endpoint for (andUS_EVENTfor the top of book) before credentials, and answer{:refused, :not_listed}for a category the venue serves but the fake models no symbol on, where they answered a crypto price;get_historical_prices/4checks the category, width, range and:limitof whichever of the five bars endpoints the category names;get_order_book/2servesUS_FUTURESand holds:depthto the venue's range;get_trades/2checks the category and the per-tape:limit;get_financials/3refuses a:countover 20;get_option_chain/2andget_option_expirations/2honour:expiryand:strike;get_news/1has noheadline, as the real stream names none.
Not changed, and why
order_type_atom/1still decodesTRAILING_STOP_LOSSto:trailing_stop, whichCore.Types.Order'sorder_typelist (Order.new/1) does not contain, thoughCore.Capabilitiesdoes andcapabilities/0declares it. That is an inconsistency inside Core, not a venue fact; this package keeps the round trip a previous release chose and records the gap here for Core.- Bars are not checked against the
symboleach group names. Needs a live measurement of how the venue spells a stock symbol in a group (class shares); the vendor's own example pages carry symbols that do not match the request they document.
[0.4.127] - 2026-10-11
Fixed
- An answered call left its
{:caller_due, from}deadline running.Feedarmed one timer persubscribe/unsubscribe/update_symbolscall atconnack_timeout_ms(10s) and never cancelled it, so a busy caller kept ten seconds' worth of timers, each holding itsfrom, and each firing into a no-op. The reference is now kept instate.caller_timersand cancelled by every path that answers the caller (reply_caller/3,reply_all/3). - Every failed shard open started its own retry chain. With the slot still empty, each
reshard's
{:open_shard, ...}that failed scheduled another{:reopen_shard, ...}chain, so an outage of N calls dialled the venue from N chains at once. The slot now holds at most one live chain (state.open_chains, a token per slot); a failure while one is live joins it, and a{:reopen_shard, ..., token}whose token is not the slot's is ignored. Two chains could never open two sockets for one slot (the slot check is in the one process), so this removes load, not a duplicate connection. - A stream that lost MQTT framing was kept open. On a malformed remaining length or an
invalid fixed header
Socketdropped its buffer and carried on, reading the next frame from an unknown offset (it could swallow real packets as the body of a bogus one) and raising a notice for every bad frame. MQTT 3.1.1 section 2.2 says a receiver MUST close on an invalid header. It now raises one:data_qualitynotice (details.dropped: :malformed_frame, withreasonandsession_id; the message says the connection is being closed) and closes, so the ordinary path runs::link_down, backoff, reconnect, CONNECT, resubscribe. Acks owed on the dead connection are discarded. - Background shards of one plan were all opened at the same instant.
@shard_spacing_msis documented as the gap between opening each shard's socket, but every shard a plan opened in the background waited the same five seconds, so three or four sockets were dialled together (a connect burst against a five-connection ceiling). The n-th waits n spacings (:shard_spacing_msinit option, default 5,000). The second shard of a plan is unchanged. Subscriptionkept a compiled regex in a module attribute, which Elixir 1.19 deprecates. It is built by a private function now. Behaviour is unchanged.MqttPacket.connect/4wrote a keep-alive or a string it could not represent as if it could. The keep-alive is a 16-bit field and each string carries a 16-bit length; a value outside that range was truncated modulo 65,536 and the CONNECT went out corrupt, the broker reading the overflow as the next field. A keep-alive outside1..65_535is now aFunctionClauseErrorand a string over 65,535 bytes anArgumentError, where it is built. Nothing in this package passes either (the keep-alive is 60 and the strings are a session id and an App Key), so no behaviour a consumer could reach changes.- A
notice, or the name of an unrecognised topic, pinned the frame it arrived in.String.trim/1,binary_part/3and Jason's default string handling return views into the payload, so a consumer keeping one notice (orSocketkeeping the reported-topic key for the life of the connection, up to 64 of them) kept a frame of up to 1 MiB alive for a 40-byte message. Notice text is now copied, the JSON body is decoded withstrings: :copyand the topic key is copied. Values are unchanged.
[0.4.126] - 2026-10-11
Fixed
- An order's numbers went to the wire as typed.
quantity,amountand the price the order type sends were not checked, so0, a negative,NaN,Infinity,trueor text reached the venue (a map or a list raised out ofto_string/1in the caller's process), andlimit_price,stop_priceandtrailing_stop_stepwent out as a JSON number for an integer and as"1.0e-8"for a float, where the order and replace pages type all of them as strings. They are now{:error, {:invalid_number, field, value}}with no request made (by index in a batch) and are sent as strings in full notation. The fake runs the same check. replace_order/4reported a change it did not send. An:order_typeor:time_in_forcewith no wire name was dropped from the body while the call returned the read-back order; anilchange counted as a non-empty amendment;quantityand the prices of an integer went out as JSON numbers thoughcommon-order-replace.mdtypes them as strings. Now{:unsupported_order_type, v},{:unsupported_time_in_force, v},:no_order_changesand strings respectively, inFaketoo.cancel_order/3,get_order/3andreplace_order/4accepted anilor emptyclient_order_idand sent"client_order_id": null. Now{:error, {:invalid_client_order_id, id}}after the account check and before signing (newRest.validate_order_id/1, used byFakeas well).get_news/2raised on a stream frame it could not read. Ametaframe whoseconvIdorsessionIdwas an object or a list, or atextframe whosemessagewas one, raisedProtocol.UndefinedError; such a value now contributes nothing.symbolsmust all be non-empty text (a tuple or a map in the list reachedJason.encode!/1and raised).get_auction_imbalance/3filed another symbol's row under the requested one. Both NOII pages carrysymbolon each row; a row naming a different symbol is now{:error, :not_in_response}. A row naming none is read as before.get_order/3andget_watchlist/3returned a record other than the one asked for. A reply naming a differentclient_order_idorwatchlist_idis now{:error, :not_in_response}. A watchlist reply naming none is still{:missing_required_field, :id}.get_order_book/3sent anydepth. The futures page states "Valid range: 1 - 10";US_FUTURESnow refuses anything else as{:depth_out_of_range, n, max: 10}, and a stock book needs a positive whole number (min: 1). Absent still sends the documented10.list_futures_contracts/2tooksymbols: []orcode: ""as given and sent an empty parameter; it is now:symbols_or_code_required.get_financials/4,get_corporate_events/1andget_filings/2turned a row that was not an object into a result. A string or number in the list became a statement, event or filing of all-nilfields; the read is now{:error, :unexpected_response_shape}.
[0.4.125] - 2026-10-11
Fixed
- An order's
sideandsymbolwere not checked.place_order/3,preview_order/3,place_orders/3and the fake upper-cased whateversidethey were given and sent it, so:bidor"hold"reached the venue as"BID"or"HOLD"on a money write; asymbolthat was not text raisedFunctionClauseErrorfor crypto. They are now{:error, {:invalid_side, side}}and{:error, {:invalid_symbol, symbol}}with no request made.sideacceptsbuy,sellandshortin any case, atom or string (common-order-place.mdlists BUY, SELL and SHORT, the last "options only"); whether SHORT suits a given instrument is left to the venue, since the page states no rule for the rest. - A
noticeframe's text was carried whole. Up to the 1 MiB packet cap, once per frame, in bothmessageanddetails.venue_notice_unparsed. Text is now cut at 1,024 bytes on a UTF-8 boundary and ends... [truncated, N bytes in all]. Still one notice per frame. The bound is ours, not the venue's. Subscription.subscribe/3andunsubscribe/3raised onsymbolsthat was not a list of strings.nil, a string, an improper list or a non-string entry raised in the caller's process; an empty string would have been sent as a product. They are now{:error, {:invalid_symbols, symbols}}with no request made; the@spectakesterm().An improper symbol list (
["BTC-USD" | :tail]) crashedFeedand the fake. Thecheck_symbolsguard added forniland non-string entries walked the list withEnum, which raises on an improper one, inside the single namedFeedprocess every caller shares. It is now{:error, {:invalid_symbols, [tail]}}inFeedand inFake.Feed's failed-open backoff had no exponent ceiling.retry_shard_open/6computed2 ** (attempt - 1)with an attempt count that grows for the whole outage; it is clamped at 2^30 asreopen_after_crash/3already was. The delay was already capped, so nothing observable changes except the arithmetic.Fake.get_orders/2lost a replaced fixed order. Any stored copy of the fixed order was treated as closed, so afterreplace_order/4(still:pending) it vanished from the open listing and appeared in history. It now goes on the side its own status says.- A venue number with an absurd exponent was admitted as a real figure.
"1E999999999"parses as a finiteDecimal, and the firstadd,compareorto_string(:normal)over it builds a10^999999999integer in the caller's (or the socket's) process.Rest's andSocket'sdecimal/1already refusedNaNandInf; they now also refuse an exponent beyond +-1000, as an unreadable value ({:invalid_decimal, field, value}on REST, a:data_qualitydrop on the stream). The bound is chosen, not measured: the venue documents no numeric range, and no price, size or rate here is near 1E+-1000.
[0.4.124] - 2026-10-11
Fixed
- A count over a maximum the endpoint's page states went to the venue. Refused locally
as
{:error, {:limit_out_of_range, n, max: max}}, the shape crypto bars already used: stock bars 1200 (1650 for1m;historical-bars.md), option, futures and event bars 1200, the option, futures and event tapes 1200 and the stock tape 1000, footprint:count1200, and:count20 on the balance-sheet, income, cash-flow, indicators and fund-net-value endpoints. Endpoints whose page states no maximum are deliberately unchecked (capital-flows, the depth endpoints, the other fundamentals) and the venue decides.Rest.validate_footprint_request/2applies the same rule for the fake. Also:rest.ex's moduledoc namedget_volume_profile/3(it is/4) and the@max_pagescomment had been separated from its attribute by the@timespanscomment. get_volume_profile/4sentcategory=US_ETFto the stock footprint endpoint.footprint.md'scategoryenum isUS_STOCKalone ("Only US_STOCK type queries are supported"), so an ETF caller was sending a value the endpoint does not list. It is now{:error, {:unsupported_footprint_category, "US_ETF"}}before any request.US_FUTURESkeeps its own endpoint.- A caller's
client_order_idover the venue's limit went to the wire.common-order-place.md:175states "Maximum length is 32 characters". An id that is empty, over 32 characters or not text is now{:error, {:invalid_client_order_id, id}}fromplace_order/3,preview_order/3and, by index,place_orders/3, with no request made. The fake enforces the same rule through the shared validator. place_orders/3raised on a batch that was not a list or held a non-map entry. Anilbatch or an entry that was a string reachedEnum.with_index/1orMap.get/3and raised in the caller's process on a money write. They are now{:error, :invalid_batch}and{:error, {:batch_order_rejected, index, :not_an_order}}.Subscription.subscribe/3andunsubscribe/3raised or sent a body the venue rejects on a bad:sub_types. A tuple raised out ofJason.encode!/1; a string,[]or a list of atoms went to the venue as a 417 naming nothing the caller wrote. Anything but a non-empty list of strings is now{:error, {:invalid_sub_types, value}}, with no request made.- The fake's
replace_order/4returned an order nothing could read back. The amended order was built and returned but never remembered, so the id it answered for was refused by the same fake'sget_order/3andcancel_order/3(the round tripdp_exchange_core's assertion 31 extends toreplace_order/4). It is now remembered per process under the same id, built on the order this process placed under that id (so a quantity-only edit keeps the placed symbol and price). An id this process never placed, other than the fixed order, is{:refused, :not_found}as forget_order/3andcancel_order/3; the fake does not invent an order for it. A refused replace remembers nothing.place_orders/3rows were already remembered.close_position/3is:unsupportedhere, so it has no id to round-trip. - The fake could reissue a watchlist id.
create_watchlist/3mintedwl-new-<map size + 1>, which repeats once an id was deleted or never created, overwriting that record. Ids now come fromSystem.unique_integer([:positive, :monotonic]), skipping any id the process already holds a record for. - The fake's
get_watchlist/2answered cannedAAPL/GOOGmembers for any id. Only the fixture's ownwl-1returns them now. An id the process never created is{:refused, :not_found}, the refusal the fake uses for an unknown order id. The real shape for an unknown watchlist id is unmeasured:get-watchlist-instruments.mddocuments only the generic error bodies, so this fails closed rather than copying a guessed answer.
[0.4.123] - 2026-10-11
Fixed
- A watchlist the fake created read back the canned
AAPL/GOOGmembers.create_watchlist/3answeredwl-newevery time andget_watchlist/2returned the same two symbols for any id. The fake now remembers (per process) what it created: a new id per create,get_watchlist/2returns the symbols it was created with,list_watchlists/1names it (and its renames fromupdate_watchlist/2), and afterdelete_watchlist/2it is gone from the listing and reads as no rows,{:error, :unexpected_response_shape}, asRest.get_watchlist/3does.wl-1stays the canned watchlist before anything is created. - The fake issued one order id for every placement, and a batch id nothing accepted
back.
place_order/3with noclient_order_idanswered the literalfake-webull-order-1each time, so two placements in a test shared one order (the second overwrote the first) and the id was not the 32 lower-case hex charactersRestgenerates. Each placement now gets a new 32-hex id, and the fixed orderget_orders/2lists has one too.place_orders/3rows were%{"order_id" => "batch-N", "symbol" => ...}and, for the refused row,%{"code" => ...}: noclient_order_id, which is the idget_order/3andcancel_order/3take, so a batch's order could be neither read nor cancelled. Rows now followorder-batch-place.md: every row names itsclient_order_id, an accepted one adds the 26-character systemorder_idand is remembered forget_order/3andcancel_order/3, and a refused one carrieserror_codeandmessageand is not placed. - A QoS 2
PUBLISHwas delivered again whenever the broker repeated it. The socket acknowledged QoS 2 but kept no record of it, so aPUBLISHretransmitted withDUP, or a second one reusing a packet id before itsPUBREL, reached the consumer twice. The socket now implements MQTT 3.1.1 §4.3.3: it holds the packet ids that were delivered and answered with aPUBRECand are not yet released, re-acknowledges a repeat of one with aPUBRECwithout delivering it, and removes the id onPUBREL, answered withPUBCOMPwhether or not the id was held. The set is bounded by the 16-bit id space (65,535; aPUBLISHneeding one more is neither delivered nor acknowledged, and raises a:data_qualitynotice) and is cleared on disconnect. QoS 1 is deliberately not deduplicated: §4.3.2 makes a repeat of an acknowledged id "a new publication, irrespective of the setting of its DUP flag", so it is acknowledged and delivered again. subscribe,unsubscribeandupdate_symbolscrashed the sharedFeedon a bad symbol list.MapSet.new/1,Map.drop/2andMap.take/2raise on a non-list, so onesubscribe(nil)restarted the process every caller shares and discarded every symbol anyone had subscribed. A non-binary element was admitted towantedand raised inside every reconcile task that tried to send it. A list that is not all non-empty strings, or a value that is not a list, is now refused with{:error, {:invalid_symbols, ...}}before any state moves (the offenders, at most five, or the whole value).Fakeanswers the same way.- A re-plan sent HTTP at a shard that was not connected.
touch_background_shard/4subscribed or unsubscribed against a session that was still connecting, or reconnecting after a drop (up to 30s of backoff). The venue answeredINVALID_SESSION, whichFeedtakes to mean the session is dead, so a socket about to come good was torn down and reopened on a fresh session against the five-connection ceiling. The new list is now only recorded (the link-up replay carries it), and the deferred:reconcile_shardmessage re-checks the session andconnected?when it runs. - An unsubscribe could be undone by a blind resubscribe or link-up replay already in
flight. Those send the shard's list as it was when they were spawned; the venue could
process the older, larger subscribe last and leave the symbol subscribed with nothing
tracking it, holding one of the session's 100 slots. When such an attempt answers
:ok, what it sent and the shard no longer carries is unsubscribed once more. state.rejectedonly grew, and every reconcile left its deadline timer running. ExpiredINVALID_SYMBOLrejections were ignored but never removed; they are now pruned on the resubscribe tick. A reconcile's 60-second:reconcile_timeouttimer (holding the tag, with the caller'sfromand options) was left to fire after the reconcile had answered; it is cancelled when the attempt is forgotten. Blind resubscribe and link-up tasks also no longer capture the wholeFeedstate in their closures.- A caller could outlive its own
GenServer.callafter an:oversubscribedorINVALID_SYMBOLretry. The reply-due timer was armed per primary reconcile and matched on the shard index, so a retry that moved the primary to another index, or parked the caller on a connecting shard, left the first timer matching nothing and the retry's own timers fell after the 15s call timeout; the caller exited and took its process down. Each call now arms one{:caller_due, from}deadline when it arrives, and it finds the caller wherever it is:{:error, {:reconcile_pending, ms}}if it rides a reconcile,{:error, {:connack_timeout, ms}}if it is parked (and it is removed fromreply_to).
Documentation
Feed's comment onrebuild_stale_shard/3said no reply is sent to parked callers; the code answers them, correctly, and the comment now says why.- Four comment blocks in
Feedhad drifted away from the function they describe (thecomplete_link_up/3tail note,spawn_reconcile/4,latch_resubscribe_failure/4andcombine_overflow/2) and are back above it; the{:reconcile_timeout, ...}handler's comment no longer ends in another handler's. Moduledoc and comments saidreshard/4; it isreshard/3. - Two 99-character lines in
Feedare reflowed to the 98-column limit.
[0.4.122] - 2026-10-11
Fixed
- A QoS 1 or 2
PUBLISHwas never acknowledged. The MQTT module stated "QoS 0 throughout", but the client never sends aSUBSCRIBE, so the venue's HTTP subscribe sets the QoS and the documentation states none. A broker that publishes above QoS 0 stops delivering when its in-flight window fills with unacknowledged messages, leaving a connection that passes keep-alive and delivers nothing. The socket now answersPUBACK(QoS 1),PUBREC(QoS 2) andPUBCOMP(for thePUBREL), all of a frame's in one reply.MqttPacket.decode/1returns an acknowledgement-bearing four-element{:publish, ...}for those; a QoS 1 or 2 body too short for its packet id is{:unhandled, 3, body}rather than a PUBLISH whose payload is that byte. Delivery is at-least-once. - An unreadable
snapshotorquotewas dropped in silence, though an unreadabletickhas raised a:data_qualitynotice since dp-exchange-core issue #40. Snapshots with no readable price, books with no readable level, either one undecodable, and any MQTT packet type the socket does not handle now raise one notice per reason per connection. - The set of reasons already reported grew without bound. A reason carried the venue's
offending value (
{:tick, {:invalid_decimal, :price, "<value>"}}) or its topic name, so a venue sending something different each message made a notice per message and a set that grew for the life of the connection. The value is out of the reason (it stays indetails) and at most 64 distinct reasons are reported per connection. - A streamed
TopOfBookcarried a size for a side with no readable price. A"null"bid with a real size arrived asbid: nil, bid_size: 5. Each size is kept only with its price. - CONNACK 4 and 5 were reported as a bare
:link_down. They are MQTT's bad-credentials and not-authorised refusals and now raise:credentials_rejected, as 3, 103 and 104 do. Auth.timestamp/1wrote a non-UTCDateTimewith its own offset (...+02:00), a form the venue'sZtimestamp does not accept, and signed it. It is always UTC.QuoteProto.decode_quote/1's documentation stated that the first level of each side is the best as a fact. The schema does not say so; it is now stated as the assumption it is.
[0.4.121] - 2026-10-11
Fixed
get_order_book/3,get_trades/3,get_volume_profile/4andget_event_trades/3read the row naming the requested symbol, asget_price/get_top_of_booknow do. They took the first row whatever it named, so another instrument's book or ticks came back under the requested symbol. A response naming only other symbols is{:error, :not_in_response}.
Found by tracing every spec-example fixture through the decoder that reads it. None of these was visible to a test written to agree with the code; each is a nearby substitute where there should have been an error.
- A snapshot, book or increment for the wrong symbol is no longer delivered under the
requested one.
get_price/3,get_top_of_book/3andquantization/3took the first row of the response whatever symbol it named. A row set that names other symbols than the one asked for is now{:error, :not_in_response}; the matching row is chosen when several are returned; a response whose rows name no symbol is read as before.get_event_order_book/3gets the same check. get_positions/2refuses what aPositioncannot say. A multi-leg option strategy (one row for several contracts,legsdropped) and anyEVENTrow (itsyes/noevent_outcomeis the opposite exposure to the same symbol, and was dropped) used to come back as a plain holding. They now answer{:error, {:unsupported_position, symbol, reason}}for the whole list, as an unreadable order does.instrument_type: "EVENT"no longer reaches the:eventatom through this path.get_auction_imbalance/3labelled every row with the auction that was requested. Outside the publication windows the venue returns history, and each row names its ownimbalance_action_type. The row's auction is now the one reported (PRE_OPENis:opening,PRE_CLOSEis:closing); a row naming neither is{:error, {:unsupported_auction, value}}; one naming none takes the requested auction.get_financials(:indicators)published one statement with no period. The venue sends{currency, values: {metric: [{fiscal_year, fiscal_period, value}]}}, which was read as if it were a per-period row. It is now regrouped into oneFinancialStatementper fiscal period, oldest first, withfiscal_periodlabelled by the venue's legend andperiod_endnil(the page publishes none). A body not in that shape is{:error, :unexpected_response_shape}.- A stock or scrip dividend's figure was published as a cash
amount.get_corporate_events/2now setsamountonly forCASH_DIVIDEND(or a row naming nodiv_type); the other sixdiv_typemembers leave itnil, anddetailsstill holds the row. get_option_chain/3split one strike across two cells by its spelling. A call listed at"150.0"and a put at"150"were differentDecimalmap keys. The grid is keyed on the strike's value (Decimal.new("150")), and the contract keeps the venue's spelling. Anyone indexing the grid with a trailing-zeroDecimalmust use the plain form.preview_order/3returnednilfor a cost it could not read. A present, unparseableestimated_costorestimated_transaction_feeis now{:error, {:invalid_decimal, field, value}}; an absent fee is stillnil.get_watchlist/3filled the watchlist id in from its own argument when the reply named none. It is now{:error, {:missing_required_field, :id}}.- A token response that is not a token was accepted.
create_token/2andcheck_token/3returned any object,%{}included; they now requiretoken(create) and astatusamongPENDING,NORMAL,INVALID,EXPIRED, andoauth_token/3requiresaccess_tokenon a 2xx. Refusals are{:missing_required_field, field}/{:unknown_token_status, status}. get_event_order_book/3read an unreadable side as empty or as a one-entry book (List.wrap/1on a string or an object). A side that is present and not a list is now{:error, :unexpected_response_shape}.fiscal_periodgiven as a numeric string ("0") was published as the label"0"; it now goes through the venue's legend like the integer does.Fake.get_financials/3answers:indicatorsin the shape the real path now does.
Changed
- Spec-example fixtures:
fund_report_brief.jsonno longer carriesmanagers[].is_incumbent(the page gives it no example, so1was invented);quote_event_snapshot.jsonadded, built only fromevent-snapshot.md's own per-property examples.README.mdthere records both. - Spec-example tests now assert the venue times the decoders pick (
quote_timeagainstlast_trade_time), the ticksidecodes, per-period:indicatorsregrouping, the batchedget_prices/3path, the event snapshot, and every refusal above. The preview test feeds the documented object response instead of wrapping it in a list the venue never sends.
[0.4.120] - 2026-10-10
Fixed
- Documentation contradicted the code in several places a consumer reads.
capabilities/0'smeasured_againststill said1ycould not be declared and pointed at a@core_unnameable_widthsthat no longer exists, although1yhas been declared since 2026-09-07; it now says it is declared and read from the equity bar path, not probed live. A comment onpublic_ceilingdescribed one shared 60-burst bucket; the limiter meters two buckets (per endpoint at burst 1, global 600/60s). The README listed:tradesas a streamed kind (it is deliberately undeclared), counted 30 venue absences wherevenue_does_not_serve/0holds 31, showedget_price/2andsubscribe/2without the credentials they require, and pinned~> 0.2.0, which cannot resolve to any current release.Rest.get_order_book/3documented a refusal of an unstamped book (it deliversvenue_time: nil), omittedUS_FUTURES, and claimed the stock depth page was not committed (openapi/quotes.mdis, and givesdepthas L2 default 10);Rest.get_trades/3omitted the option and futures tapes.quotes/0was described as what the venue settles in; it is the symbol mapping's list, andsupported_quotesis["USD"].usage-rules.mdnamedget_order/2forget_order/3.
[0.4.119] - 2026-10-10
Fixed
Two different option contracts at one expiry, strike and right silently overwrote each other.
get_option_chain/3's 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 replaced the first, so a caller trading the cell got whichever row the venue sent last. It is now{:error, {:ambiguous_option_contract, expiry, strike, right, [venue_symbol]}}. The same contract listed twice is still one contract.Fake.get_orders/2never listed an order the process had placed.get_order/3found it andget_orders/2did not, so a place-then-list round trip never saw its own order. Placed orders are now listed as open while working and underhistory: trueonce cancelled.get_top_of_book/3dated the book by the last trade, not the quote. The crypto, option and futures snapshots carry bothlast_trade_timeandquote_time, and the reader took the first of a shared list,last_trade_time. On a thin pair the bid and ask were stamped with a print hours older than the quote, so a staleness check on the book judged the wrong event.venue_timeis nowquote_time; a row with onlylast_trade_time(the stock snapshot) keeps it, and an unparseablequote_timeisnilrather than the trade's time.A forming weekly, monthly or yearly stock bar was returned as complete. Stock reads ask the venue for
real_time_required: trueand drop the bar still forming by adding the width to its open time, butCore.Timeframe.seconds/1is:errorfor1w,1Mand1yand that was read as "keep every bar". The end of those widths is nowopened_atplus a calendar week, month or year.Fakeanswered whereRestrefuses, or refused in a different order. Each now calls the real path's own validator (new publicRest.validate_activity_range/1,validate_crypto_bars_request/2,validate_footprint_request/2,validate_statement_kind/1,corporate_event_kinds_for/1,news_symbols/1,validate_screener/2) instead of a copy:preview_order/3priced an order with no size or a limit with no price;replace_order/4answered:no_order_changesahead of an unknown order type, blanked the price on a quantity-only edit and dropped the quantity;place_order/3echoed a caller-chosenclient_order_idthatget_order/3andcancel_order/3then refused as:not_found(placed orders are now remembered per process, and a cancelled one reads back:cancelled);get_transfers/2andget_transactions/2answered a range across two calendar years, andget_transactions/2ignored:activity_types;get_historical_prices/4accepted a:limitover 1200 and checked credentials before the width;get_volume_profile/3accepted any session butOVNand any category;get_symbols/1answered the crypto list for any:category;get_financials/3answered a statement for akindthat is not one;get_corporate_events/1ignored:kindand always answered a dividend;get_news/1refusedsymbols: "AAPL";get_screener/2checked credentials before the identifier and did not requireweek52_high_low'srank_type;update_watchlist/2checked credentials before refusing:symbols.
The
DpExchange.Webullmoduledoc described a gap that closed. It said ten widths were declared with1ywithheld (capabilities/0declares all eleven since Core 0.1.57) and that bars carry no volume on any category (stock, option, futures and event bars carry it).usage-rules.mdsaid the fake checks credentials before argument validation; it is the other way round, as inRest.
[0.4.118] - 2026-10-10
Fixed
Fake.place_orders/3accepted batchesRest.place_orders/3refuses. Its entry check was a local copy that knew only the equities-only rule, so aSTOP_LOSSentry, a:gtcentry or a price-lessLIMITpassed against the fake and failed against the venue path. The fake now runs the newRest.validate_batch_orders/1, the exact checks the real path makes, and refuses by the same index.QuoteProtohad no bound on a varint's length. A malformed frame of continuation bytes (up to the 1 MiBMqttPacketaccepts) grew the accumulator seven bits per byte, eachbor/2copying the whole integer: quadratic work in the socket process from one bad frame. A varint longer than ten bytes now ends the walk like any other unparseable tail.place_orders/3raisedKeyErrorfor an entry with no:sideor:symbol, and sent aLIMITentry with no:pricewithoutlimit_price. The identity and price checksplace_order/3already made were not applied to batch entries. Both are now refused by index, before anything is sent:{:batch_order_rejected, index, {:missing_required_field, field}}.place_order/3returned{:ok, %Order{id: nil}}for a 2xx whose row named noclient_order_id. An order with no id cannot be cancelled or looked up, and may be live;get_order/3already refused the same shape. It is now{:error, {:order_unconfirmed, client_order_id, :unexpected_response_shape}}, carrying the id that was sent, as a timeout does.Cash-activity and order-history time ranges were not normalised to UTC. A
DateTimein another zone was sent with its+05:00offset, a second-precision one without the documented.SSS, and the same-calendar-year check compared the localyearrather than the UTC one the venue applies. All three now use UTC with millisecond precision.
[0.4.117] - 2026-10-10
Fixed
- A failed subscribe left its shard believing it had subscribed. The shard's symbol list
was set to the new value before the HTTP reconcile answered and never put back on failure
(a transport error,
:reconcile_timeout,:reconcile_pending,INVALID_SYMBOL,:oversubscribed), so a retry diffed empty and returned:okwhile the symbol stayed unsubscribed. A failure now reverts what the attempt changed and marks the shardunsettled, so the next call, or the next 60-second tick, sends it again. A refused batch is no longer "taken back" with an unsubscribe of symbols it never subscribed. - A background reconcile superseded before it answered lost its delta. Its symbols stayed
recorded on the shard while the successor sent only its own delta, so they were believed
subscribed and never re-sent. The superseded attempt is now treated as failed: reverted and
left
unsettled, and the tick's re-plan clears the mark. - A failed unsubscribe cancelled the subscribe beside it.
reconcile_by_session/4stopped at the first error; both halves now run. - A late answer from a replaced session tore down its healthy replacement.
INVALID_SESSIONwas acted on without comparing the session it named with the shard's current one, and:oversubscribedcapped the new shard from the old one's measurement. Answers for a session the shard no longer has are dropped (a venue-levelINVALID_SYMBOLis still recorded). app_secretsat raw inFeed's state and messages. Onlyresubscribe_optswas wrapped. The credentials ofsubscribe/2,unsubscribe/2andupdate_symbols/2are now wrapped by the facade and again on arrival inFeed, so reconcile tags and{:open_shard, ...}messages print them redacted.- A
nullbook was delivered as%TopOfBook{bid: nil, ask: nil}. The guard tested the raw wire strings, so the venue's documented"null"for a delisted pair passed it. Levels are decoded first and a quote with neither readable is dropped. - A shard open applied the symbols of the plan that scheduled it.
{:open_shard, ...}and{:reopen_shard, ...}now re-derive the shard's symbols from what is wanted at open time, so an older timer winning no longer discards a later re-plan. A shard with nothing wanted opens nothing. - The priority shard's empty room could not take overflow. Three priority symbols plus 450
others were refused as
capacity_exceededbelow the 500-symbol ceiling. Ordinary symbols are still planned into shards 1 to 4 first; only overflow now fills shard 0's remaining room. - A connect-then-drop socket reconnected at once, forever. Reaching CONNACK 0 reset the backoff whatever the session's age. It now resets only for a session that lasted 30 seconds; a shorter one counts as another failed attempt.
- A crashing shard was reopened immediately, without limit. The first crash still reopens at once; one within 60 seconds of the previous waits 1 s, doubling to 60 s.
coverage_by_kind/1could report:trades, whichcapabilities/0does not declare.TICKis requested by default, and a tick that arrived was counted. Core requires every reported kind to be declared andcoverage/1to be exactly their union. ATradeis still delivered, and is no longer counted, until a run shows ticks arriving and the declaration moves with it. Not measured: the venue has published no tick for us-crypto in either measurement (2026-09-29 and 2026-10-02), which is why:tradeswas not simply declared.- Docs said
TICKis not requested by default (Feed's moduledoc, a comment incapabilities/0).Subscription's defaultsub_typesincludes it; the docs now match.
[0.4.116] - 2026-10-10
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.115] - 2026-10-10
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.114] - 2026-10-10
Fixed
- A broker that refused the session got an immediate reconnect, forever. Backoff counted only failed transport reconnects. A CONNACK 103/104/105 leaves the WebSocket up, so every next drop was attempt 1 with no delay, and 105 (the connection limit, held about a minute) sustained itself. Consecutive sessions that never reach CONNACK 0 now count toward the delay.
- Futures and event bars ignored the caller's range and returned the latest bars as though they answered it. They now refuse a range, as option bars already did.
- Crypto bars answered a range older than one read reaches with a short list, or
[]. The endpoint takes onlycount. A full page whose oldest bar is still after:startis now{:error, {:range_exceeds_count, count}}, and a:limitpast 1200 is refused. - Stock bars sent
real_time_required: false. The vendor defines that as "only the completed bars ... at the nearest whole hour", so an intraday read at 10:59 stopped at 10:00. They now send the venue's defaulttrueand drop the bar still forming. An explicitreal_time:is still honoured. - Stock bars sent
"trading_sessions": ""when no session was named. It is now omitted. - A timed-out
place_orderlost its generatedclient_order_id. It now returns{:error, {:order_unconfirmed, client_order_id, reason}}, so the possibly live order can be looked up withget_order/3. - A limit order with no price, a stop with no trigger, or an order with no side was sent,
or raised
KeyError. Each is now{:error, {:missing_required_field, field}}. oauth_token/3was retried. An authorization code is single-use and a refresh token may rotate, so it is now sent once.SUBMITTEDread back asnil. It is now:pending(accepted, not terminal, not:open). It is the open-orders page's own example status.- The UAT REST host is now the vendor's documented
api.sandbox.webull.com. It wasus-openapi-alb.uat.webullbroker.com, carried over from the prior adapter and named in none of the committed docs. Checked by DNS only, on 2026-10-10: the documented host resolves to CloudFront, and the old one to an AWScn-northwest-1load balancer. - The fake accepted orders the real path refuses. It now runs the real request checks
(
Rest.validate_order_request/1) and the real amendment table (Rest.validate_amendment/2), and echoes the caller'sclient_order_id.
[0.4.113] - 2026-10-10
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.112] - 2026-10-10
Fixed
priority_symbols:froze the other shards out when shard 0 held no priority symbol. The guard read the option, not shard 0. With none of the priority symbols subscribed, shard 0 is planned as an ordinary shard, yet only it was ever re-asserted. Now the priority branch applies only while shard 0 is connected and holds a priority symbol. While it is down, the other shards are repaired and rotated as usual.- Rotation handed every turn to a shard whose symbols never tick. It ranked quiet shards by last delivery alone, so a shard of never-quoted pairs ranked at zero forever. It took the stream from the streaming sibling each tick and delivered nothing. A turn now counts as activity, and ties go to the lower index.
- A shard torn down mid-resubscribe kept that attempt tracked. After a socket crash or
INVALID_SESSION, the reopened shard sat out rotation until the old attempt's 60 s deadline. The old answer was then latched onto the new connection. The attempt is now stopped and forgotten with the shard. get_prices/2dropped one of two spellings of the same pair."BTC-USD"and"BTCUSD"share a native symbol, and only the last one kept a result. Both now get it, from one request.- A test meant to prove no shard is re-asserted against a streaming priority shard could not
fail. It
flunked inside the reconcile task. It now asserts in the test process.
[0.4.111] - 2026-10-10
Added
priority_symbols:keeps the stream on the pairs you trade (issue #11's follow-up). On 0.4.110 a consumer measured the rotation working as designed: each of three shards streams one contiguous minute in three, never two at once, so the account holds theus-cryptostream on one session at a time. Symbols named inpriority_symbols:(start option, up to one session's 100) are planned into one shard, which is re-asserted the moment it is quiet and never re-asserted against. With one holder, no other shard rotates while the priority shard streams. Those pairs belong to REST cover (get_prices/2). Not a list of strings →ArgumentErrorat start.
[0.4.110] - 2026-10-10
Fixed
- Starved shards get the stream in rotation instead of never (issue #11, measured on
0.4.109). Never re-asserting a quiet shard while a sibling streamed stopped the flapping,
but left two of three shards with no stream at all: about 190 of 252 pairs on REST alone,
with each 1m bar a single flat quote. The consumer reported this as worse for candles than
0.4.107's flapping. The account cannot stream every shard at once, so the permission is
now rotated deliberately. While any shard streams, each tick re-asserts exactly one quiet
shard, the one silent longest, and announces it with an
:info:coverage_changenaming the shard, itssession_idand its symbols. One grab per tick replaces simultaneous grabs. When no shard streams, every quiet shard is re-asserted, as before. 0.4.109's once-per-episode:warningfor a starved shard is gone with the rule it described.
[0.4.109] - 2026-10-10
Fixed
- The shard ping-pong is stopped by a rule that does not depend on the venue's notices
(issue #11, reopened). 0.4.108 skipped re-asserting a quiet shard only if its own session
had received a type-1002 grab notice. Measured 2026-10-10 on 0.4.108, two shards still
alternated minute by minute while a third stayed steady, so the notice does not reliably
reach the session that lost. A quiet shard is now re-asserted only when no sibling
shard is delivering. A shard silent while its siblings stream is left alone and reported
once per episode with a
:coverage_changewarning naming the shard, itssession_idand its symbols. Its symbols leave:streamcoverage for REST cover to take. The cost is that a subscription the venue drops silently on one shard, while the others stream, is no longer repaired by the timer. From outside it cannot be told apart from a starved shard, and treating it as starved is what stops the flapping.
[0.4.108] - 2026-10-09
Fixed
- Two shards no longer trade the
us-cryptopermission back and forth (issue #11). On 0.4.107 the quiet-shard rule from #10 created a ping-pong: a shard silenced by a sibling's grab qualified as quiet, its re-assert grabbed the permission back, and one interval later the sibling did the same. A consumer measured two of three shards each silent 2 minutes of every 4, out of phase. A quiet shard is now left alone when its own session received a type-1002 "Permission grabbed by other session" notice after its last delivery and a sibling shard is still delivering. A quiet shard with no such notice, which is a silently dropped subscription and the timer's purpose, is still re-asserted, and so is any quiet shard once every sibling is quiet. - Venue
notice-topic notices carrysession_idindetails, so a consumer can tell which shard one concerns (asked for in #10). A grab notice arriving mid-subscribe no longer answers a parked caller as a refused session. Only a notice with aconnackcode does.
[0.4.107] - 2026-10-08
Fixed
- A shard that is delivering is no longer re-subscribed every minute (issue #10). Each
completed blind re-subscribe grabs the
us-cryptopermission from a sibling session, and the venue stops delivering to that sibling until its own tick grabs it back. On 2026-10-08 a consumer measured a contiguous block of 45–125 liquid pairs, one shard's worth, going 30 s or more silent just after the grabs every minute, taking silence from a 60–75 pair baseline to ~205 of 272. The timer now re-asserts a shard only when none of its symbols has arrived within the lastresubscribe_interval_ms, which is the case it exists to repair.
[0.4.106] - 2026-10-07
Fixed
- One rejected symbol no longer costs
get_prices/2its whole chunk of twenty. The venue refuses an entire request for one symbol it calls invalid. A consumer that polls its never-ticked pairs first, where those symbols collect, got{:every_symbol_failed, 60}on more than 4,000 consecutive polls on 0.4.104. A chunk the venue rejects for its content (a 4xx other than 401/403/429) is now halved and retried down to single symbols. The rejected symbol keeps the venue's answer, every other symbol gets its quote, and it costs about two extra requests per bad symbol. A credential or budget refusal is not split, because it would fail every half the same way.
[0.4.105] - 2026-10-05
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.104] - 2026-10-04
Added
get_prices/2: crypto quotes twenty per request (issue #9). The crypto snapshot endpoint takes "up to 20 symbols per request", andget_price/2sent one. A consumer covering a starved stream shard from REST got 60 symbols a minute, and its limiter refused the rest (2,367:rate_limit_timeouts in about 3 hours on 2026-10-03), where the venue serves 1,200. It returns a result for every symbol asked for, inget_price/2's shape. A symbol with no row in the response is{:error, :not_in_response}, and a failed chunk costs only its own symbols.US_CRYPTOonly. The fake implements it too.
[0.4.103] - 2026-10-03
Fixed
- Requires dp_exchange_core
~> 0.3.52, which closes the two ways crypto bars still drew 429s on 0.4.102. On 2026-10-03 a consumer's log showed 27 in 31 s. Concurrent non-blocking requests all passed the limiter's check before any of them was counted, so two went out 250 ms apart through the 1/s endpoint bucket. Core now reserves atomically before sending. And the venue'sRetry-After(5 s) held only the request that drew it, so every other symbol's request kept arriving into the penalty. It now holds the endpoint's bucket.
[0.4.102] - 2026-10-03
Fixed
- A
"null"price from the venue is now{:refused, :no_quote}, not a decode error (issue #8). On 2026-10-02, 51 of the ~325 USD pairs the catalogue lists as tradable (MATIC-USD, FTM-USD, FTT-USD, KLAY-USD, …) answered everyget_price/2with the literal string"null". That came back as{:error, {:invalid_decimal, :price, "null"}}, which looked like a broken parser, and as a possibly-transient:errora consumer retried it forever. It is the venue's definite "no quote", so it is now a refusal, which is permanent by contract. Any other unreadable price is still an error.
[0.4.101] - 2026-10-02
Fixed
- Each endpoint is now held to the venue's 1 request per second. Every endpoint page
states "1 request per second per App Key" and a "Market Data Global Limit: 600 requests
per minute". This package metered both as one shared bucket at 60/60s with a burst of 60,
which let a backfill send a minute's budget to one endpoint at once. A consumer's log on
2026-10-02 showed 45
429s withretry_after=5son crypto bars. Now every request clears two buckets: its own endpoint at 60/60s with a burst of 1, and the global 600/60s with a burst of 10. Sandbox is half of each, as before. Endpoints no longer share the same 60 a minute either, which held all of them together to a tenth of the global budget. Needs dp_exchange_core 0.3.51 (rate_limit_per_endpoint), which also stops a blocking request being metered twice. That double count had held this package's feed to 30 requests a minute.
[0.4.100] - 2026-10-02
Fixed
get_price/2reads a crypto snapshot'svolumeinstead of answeringnilwithout looking.crypto-snapshot.mddocuments no volume field, and the code took that as a measurement: the same unmeasured assumption issue #6 found on the stream. A response that carriesvolumenow delivers it asvolume_window: :running_total. One that carries none still givesnil.
Measured
- On 2026-10-02, Webull crypto delivered no traded volume on its stream, on the first
run with
TICKback in the default. The subscribe was accepted and quotes flowed, but the snapshot's field 8 was empty, and about 6 minutes in a consumer's 1-minute BTC-USD and ETH-USD candles held zero volume. Gemini's trades gave the same consumer real volume at the same moment.TICKstays requested and:tradesstays undeclared.usage-rules.mdsays to take volume for Webull pairs from another venue's:trades, as the consumer's own choice.
[0.4.99] - 2026-10-02
Fixed
- The streamed snapshot's volume is delivered instead of discarded (issue #6).
QuoteProtodecodedSnapshot.volume(field 8) off the wire andSocketthen built everyQuotewithvolume: nil, unchanged since the package's first commit and never measured. It now passes it through asvolume_window: :running_total: the session's cumulative total, to difference rather than sum. A frame that leaves the field empty or unreadable still givesnil, with no window. TICKis requested by default again (issue #7). It is the trade-tape route the venue documents for crypto, and the venue accepted the request carrying it in dp-exchange-core issue #40's measurement, so asking costs nothing and any tick that arrives is delivered as aTrade.:tradesstays out ofcapabilities/0until a run shows ticks arriving.
Changed
- Every
Quotethat carries a volume says which quantity it is (volume_window, dp_exchange_core 0.3.50, dp-exchange-core issue #42). The stream's snapshot volume and the stock or ETF REST snapshot's day aggregate are both:running_total.
[0.4.98] - 2026-10-01
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.97] - 2026-09-29
Changed
:tradesis no longer declared streamable, andTICKis no longer requested by default (dp-exchange-core issue #40). The declaration came from the streaming page's topic table alone. Measured on 0.4.96, which reports every way a tick can be dropped: across ~325 us-crypto symbols, noTradearrived and no drop notice was raised. So the venue publishes noTICKforUS_CRYPTO, the only category this package streams.capabilities/0now declaresstreamable: [:quotes, :top_of_book](and the sameauthenticated_streamable), and the defaultsub_typesis["SNAPSHOT", "QUOTE"]. A caller can still ask forTICKwithsub_types:, and a tick that arrives is still decoded or reported.
[0.4.96] - 2026-09-29
Fixed
- A tick that cannot become a
Tradeis reported, not dropped in silence (dp-exchange-core issue #40). A consumer subscribed to:tradessaw noTradefor six hours, and nothing distinguished a venue that sends no crypto ticks from a decoder that discards them. Both the drop paths (undecodable, or no readable time, price or volume) now raise a:data_qualityNoticeonce per reason per connection, with the raw fields. - A message on a topic this package does not decode is reported the same way, once per topic per connection. It was dropped with no trace.
- A tick whose own
timeis present but unreadable falls back to the frame'sBasictimestamp before being refused. The streaming page givestimeno format.
[0.4.95] - 2026-09-29
Added
list_instruments/1forUS_CRYPTO, built from the crypto profilesget_symbols/1already walks. Each instrument carries the venue's own tradability:OCis:tradable;CO(liquidate only) andNT(non-tradable) are:unknown, because neither accepts an opening order and neither is delisted. A consumer can now tell a listed-but-untradable pair from a live one. Pairs the streaming subscribe rejects asINVALID_SYMBOLwere indistinguishable inget_symbols/1, which still lists every symbol, as Core's contract requires.base/quotecome from the row's ownsymbolandcurrency; a row they cannot be read from is dropped. Stock and ETF categories are refused with{:error, {:unsupported_instrument_category, category}}: Core'sInstrumenthas no equity type. The mapping is declared from the vendor page, not measured live.Fakelists a fourth pair,XRP-USD, whichlist_instruments/1marks:unknown, so a consumer's tests meet a listed-but-untradable pair.
[0.4.94] - 2026-09-29
Fixed
get_orders/2refuses a list it cannot read in full, instead of thinning it. A group that cannot be decoded as oneOrder, most often a multi-leg combo (OCO, OTO, bracket), was dropped, so the open-orders list silently lacked working orders and read as "nothing working there". It now answers{:error, {:unreadable_orders, ids}}, naming the groups byclient_order_idwhere the venue gives one.
[0.4.93] - 2026-09-29
Fixed
Found by spec-example tests that drive the vendor's own documented examples through each call.
place_orders/3returns one result per order. It wrapped the whole{total, success, failed, batch_orders}envelope as a single row, so a batch of N orders always answered with one result. It now readsbatch_orders.update_watchlist/3checks the venue'ssuccessflag like every other watchlist write. It reported{:ok, _}for a documented{"success": false}.- A watchlist
sortis sent as a JSON integer, as documented; it was sent as a string. place_order/3andpreview_order/3send a caller'ssupport_trading_session, checked against the venue's enum. It was dropped.- Parameters the vendor documents are no longer dropped:
sort_byon industry comparisons and on thetop_actives,week52_high_lowandhigh_dividend_ranksscreeners, andlast_dateon fund net values.
Added
- Spec-example tests: every REST call, the subscribe endpoint and the protobuf stream
decoders, driven with the vendor's documented examples (or cited schema-built instances),
with requests checked against the documented parameters. 55 further vendor pages are
committed under
docs/reference/webull/openapi/.
[0.4.92] - 2026-09-29
Fixed
Checked against the vendor's own reference pages, fetched 2026-09-29 and now committed under
docs/reference/webull/openapi/.
- Orders carried no size the venue reads.
place_order/3,preview_order/3andplace_orders/3sentqty/amount; the documented fields arequantityandtotal_cash_amount. replace_order/4sends the documentedmodify_orderslist, and sendsorder_typewhen it is changed; it sent a flat body and droppedorder_type.- Orders are decoded from the documented group envelope (
orders: [...]). Symbol, side, type, status and sizes were read off the group and were alwaysnil. A multi-leg combo is refused with{:error, {:unsupported_combo_type, _}}. Statuses map the documented enum. get_orders/2followspagination_key(bounded), stops sending an undefinedpage_size, and sends:since/:untilasstart_time/end_timefor history.place_orders/3validates against the batch endpoint's own narrower rules (MARKET or LIMIT, DAY, quantity only) and requiressupport_trading_session.- Stock and option bars decode the documented
{"result": [...]}envelope, which failed before. Bars carry the documentedvolume. Option bars refuse a range the endpoint cannot honour, rather than filtering the latest bars down to nothing. - The option chain sends
underlying_symbols,start_date/end_dateand the strike bounds, readsexpiration_date,settlement_methodandexpired_cycle, and followspagination_key. Every row failed to decode before. - Transfers, transactions, market sectors and fund dividends follow
pagination_keyand stop sending parameters the endpoints do not define. get_filings/2reads thefilingslist; corporate events readex_div_date,declare_dateandexpected_publish_date;get_news/2decodes the documented Server-Sent Events stream, which failed on every call.- Screener defaults use values from each endpoint's enum;
week52_high_lowrequires arank_type. ETFs are requested assub_category=ETF. get_top_of_book/3refusesUS_EVENT, whose snapshot has two books, instead of returningnilprices.
[0.4.91] - 2026-09-29
Fixed
- A nested
resultorinstrumentslist that is not a list, or a row that is not an object, is refused with{:error, :unexpected_response_shape}.List.wrap/1turned a string into a one-element list and an object into a one-row list of the wrong thing, and a non-object row read as none, soget_trades/3,get_event_trades/3,get_watchlist/3, stock bars and volume profiles answered no data, or a wrapper as data. Absent is still none.
[0.4.90] - 2026-09-29
Fixed
- A reply whose
datais present and not a list, an object ornull, or a body that is not an object or a list, is now{:error, :unexpected_response_shape}everywhere rows are read. Such a reply was read as no rows, soget_orders/2,get_positions/2,get_accounts/2,get_transfers/2and every other list call answered{:ok, []}from a response that said nothing about any of them."data": nullis still no rows. get_balances/2refuses anaccount_currency_assetsthat is present and not a list, or a body that is not an object, instead of answering "no balances". An absent list is still empty, as before.- A watchlist write whose reply names no
successis{:error, :unexpected_response_shape}, not:ok. Every watchlist endpoint answers with that boolean, so a reply without it is no evidence the write happened. - Locked
dp_exchange_core0.3.48.
[0.4.89] - 2026-09-29
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.88] - 2026-09-28
Fixed
- A retried request is signed again, with its own
x-signature-nonce. Every retry replayed the first attempt's nonce, a replay the venue refuses, so a retry of any REST call or subscribe could never succeed. Headers are now a functionCore.HttpClientcalls per attempt. Requiresdp_exchange_core0.3.46. - Because a retry can now take effect, writes whose repeat is not harmless are sent once
unless the caller passes
:retry_attempts:place_order,place_orders,cancel_order,create_token,update_watchlist,delete_watchlistand the watchlist instrument writes, joiningreplace_orderandcreate_watchlist. The venue documents no idempotency key that would make a repeated order safe.
[0.4.87] - 2026-09-28
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.86] - 2026-09-28
Fixed
- The fake upper-cases subscribed symbols, as the real facade has since the previous
release.
Fake.subscribe/2,unsubscribe/2andupdate_symbols/2took symbols exactly as given. So abtc-usdsubscription delivered nothing against the fake while the real package deliveredBTC-USD, and a consumer's tier-1 tests certified silence where the venue delivers.
[0.4.85] - 2026-09-28
Fixed
- A lower-case symbol subscribes under its canonical form again.
subscribe/2,unsubscribe/2andupdate_symbols/2now upper-case their symbols. The previous release madeFeeddrop a payload for a symbol it does not want. The venue delivers the canonical upper-case symbol, so abtc-usdsubscription received nothing, where before it had received data. Measured 2026-09-28:update_symbols(["btc-usd"]), then aBTC-USDquote, andcoverage/1was%{}. Case is the only difference normalised.
[0.4.84] - 2026-09-28
Fixed
- A frame arriving after
unsubscribe/2is no longer delivered or counted. A venue keeps sending for a moment after an unsubscribe. Those frames used to reach the subscribers who had just asked to stop, and they re-entered delivery tracking, whichunsubscribe/2had just pruned. A streaming route has no staleness window, so one late frame leftcoverage/1answering:streamfor an unsubscribed symbol indefinitely.Feednow drops a payload whose symbol it no longer wants.
[0.4.83] - 2026-09-28
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.82] - 2026-09-28
Added
config :dp_exchange_webull, websocket_url: ...overrides the endpoint a socket dials when no:urlis passed. The default is still the venue. It exists because this package's tier-1 test suite had been opening live connections to the venue on every run (measured 2026-09-27), from tests that never meant to reach it.config/test.exsnow points it at loopback, and the suite opens no connection off the machine. It also works for a proxy. SeeDpExchange.Webull.Feed.
[0.4.81] - 2026-09-28
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.80] - 2026-09-28
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.79] - 2026-09-28
Changed
decimalmay now resolve to 3.x (~> 2.0 or ~> 3.0), alongsidedp_exchange_core0.3.41, which widened the same way. Decimal 3.0 makes the mitigations for CVE-2026-32686 the default, so an input such as1e1000000000is rejected rather than materialised. This package's full suite, its REST mutation fuzz and its socket frame fuzz were run on 3.1.1 first, with numbers of more than 34 digits added to the fuzz values, since 3.x refuses to parse those. All pass with no raise and no hang.
[0.4.78] - 2026-09-27
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.77] - 2026-09-27
Fixed
- A streaming facade call answers instead of exiting the caller's process.
subscribe/2,unsubscribe/2,update_symbols/2,subscribe_notices/1,coverage/1andcoverage_by_kind/1called intoFeedwith a bareGenServer.call/3, which exits the caller when noFeedis running (:noproc) or when one is too busy to answer in its call budget (:timeout). Every one of these callbacks is specified to return a value. Measured 2026-09-27: with no Feed started, four of them exited :noproc here. They now answer{:error, :feed_not_started}(:okforunsubscribe/2),{:error, :feed_timeout}, or{:error, {:feed_exited, reason}}, and the coverage calls answer an empty map.
[0.4.76] - 2026-09-27
Security
- The WebSocket now verifies the venue's TLS certificate. websockex defaults to
insecure: true, which isverify: :verify_none, and this package passed no TLS options. So itswss://connection accepted any certificate, from anyone able to sit on the network path, who then received everything sent after the upgrade, credentials included. Measured 2026-09-27: against a local TLS server with a certificate from a CA nothing trusts, the handshake completed and the upgrade request went out. The socket now defaults:ssl_optionstoverify: :verify_peer, the operating system's trust store (:public_key.cacerts_get/0) and HTTPS hostname matching. A caller may pass its own:ssl_options. This venue's live endpoint (data-api.webull.com:8883) was checked the same day and verifies under these options. HTTP requests were never affected, because Mint verifies by default.
[0.4.75] - 2026-09-27
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.74] - 2026-09-27
Fixed
- The WebSocket opening handshake now has a deadline:
socket_connect_timeoutplussocket_recv_timeout. websockex bounds eachrecvof the HTTP upgrade response separately, and every chunk restarts that timer. So a peer that trickled the response held a start or a reconnect open indefinitely. A start blocksFeedinside ahandle_call/3, so every other consumer's call queued behind it. On a reconnect, the shard delivered nothing and its silence check could not run. Measured 2026-09-27 against a local server sending one byte every 200 ms: still connecting at 12 s. The vendoredWebSockexnow ends the attempt at the deadline with%WebSockex.ConnError{original: :timeout}, the error a timed-outrecvalready produced, so the existing start error and reconnect backoff handle it unchanged.
[0.4.73] - 2026-09-27
Fixed
- A REST response carrying a value of the wrong type no longer raises in the caller's
process. A mutation fuzz (2026-09-27) replaced every nested value of real response
bodies with the wrong shape (
nil,true,[],[%{}], a map, a string, out-of-range numbers), one value at a time, across 23 endpoints. 54 of those mutations raised, from two places:- A symbol that is not a string. In
get_symbols/2the row is skipped, the same way an absent symbol already was. Inget_order/3andget_orders/2the symbol isnil. Inget_positions/2the position is refused, as one with no symbol already was. - An auction-imbalance side code that is not a code. In
get_auction_imbalance/3it is nownil. Before,to_string/1raised on it.
- A symbol that is not a string. In
[0.4.72] - 2026-09-27
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.71] - 2026-09-26
Fixed
- A corrupted packet header could leave the stream silently out of step.
MqttPacket.decode/1accepted any fixed header, so a corrupted byte that turned the front of the stream into, for example, a PINGRESP declaring a 26-byte body made it swallow the next 26 bytes of real packets as that body. Everything after was misread, with no notice. A recovery fuzz (after each mutated real packet, do later valid frames still get through or does the socket say it resynced?) found 99 of 1,045 mutants left the stream wedged. MQTT 3.1.1 §2.2 fixes the flags, and for most types the exact length, of every packet a server may send, and says a receiver must close on invalid ones.decode/1now checks the header as soon as its length is known, and a violation is{:error, :malformed_header}, which the socket answers as it does a malformed length: drop the buffer and raise:data_quality. Afterwards 0 of 1,045 mutants wedge the stream within ten following frames. The test that decoded a client-only CONNECT is rewritten to check the encoder and the decoder separately. Break-verified: the new header test fails on the previous code.
[0.4.70] - 2026-09-26
Fixed
- One corrupted length byte could silence a shard indefinitely. A header whose
remaining length decoded as a valid varint but declared far more than any real message,
up to MQTT's 256 MB maximum, made
MqttPacket.decode/1answer:incomplete. The socket waited, and every later valid frame was appended behind the bad header and never parsed: measured at 50 good snapshots, 0 delivered. The connection stayed up and its buffer grew, and the silence check never fired because frames kept arriving. A declared length over 1 MiB (chosen, not measured, and over a thousand times any real message on this stream) is now malformed, so the socket drops the buffer and raises the existing:data_qualitynotice. A byte-level fuzz of real packets, every byte set to several values and every truncation, raised nothing either before or after. Break-verified: the new test fails on the previous code.
[0.4.69] - 2026-09-25
Fixed
- A PINGREQ nobody answered went unnoticed. MQTT requires a PINGRESP to every
PINGREQ, and
Socketsent PINGREQs every 30s without looking for the answers. A half-open connection therefore stayed "connected", delivering nothing, until TCP gave up. Every frame, a PINGRESP included, now counts as being heard from. A ping that falls due after 90s with nothing heard raises a:degradednotice (:silent_connection, with thesession_id) and closes, taking the ordinary reconnect path. Break-verified: both new tests fail on the previous code.
[0.4.68] - 2026-09-25
Fixed
get_trades/3returned ticks in the venue's order. They are now oldest first by:timestamp, keeping the venue's order for equal timestamps, asCore.Venuenow documents. Break-verified: the new test fails on the previous code.
[0.4.67] - 2026-09-25
Fixed
replace_order/4was retried. Theclient_order_idnames the order being changed, not the change, so the venue cannot tell a repeated replace from a second one, and a replace that already took effect comes back refused. It is now sent once unless the caller raises:retry_attempts, ascreate_watchlist/4is. Break-verified: the new test fails on the previous code.
[0.4.66] - 2026-09-25
Fixed
- A slow venue subscribe crashed the caller. On an already-connected shard,
subscribe/2,unsubscribe/2andupdate_symbols/2wait for the HTTP subscribe their call started. That is allowed 60s (HttpClientretries inside it), while the caller'sGenServer.callgives up at 15s, so a slow venue made the caller EXIT. Each such call now arms a reply deadline at two thirds of the call timeout, the same budget a caller parked on a connecting shard gets, and is answered{:error, {:reconcile_pending, ms}}if the subscribe is still running. The subscribe carries on and its result is applied. Its own late reply is dropped bygen, and a test proves it lands in no mailbox.usage-rules.mdgains a section on both pending replies. Break-verified: the new test fails on the previous code.
[0.4.65] - 2026-09-25
Fixed
- A subscribe during a shard's reconnect could crash the caller. A caller whose
symbols landed on a shard that was reconnecting (not being opened for the first time)
was parked with no
:connack_timeout. The socket's reconnect backoff reaches 30s, so the caller waited out its 15s@call_timeoutand EXITED. A shard also held only one parked caller, so a second caller silently replaced the first, who was then never answered.reply_tois now a list, every path that answers a parked caller answers all of them, and every park arms its own timeout. Break-verified: both new tests fail on the previous code.
[0.4.64] - 2026-09-24
Fixed
- Every reconnect left another keep-alive ping chain running, and none ever stopped.
WebSockexreconnects inside the same process, andSocket.handle_connect/2runs on each reconnect. EachCONNECTstarted a bare:pingtimer that re-armed itself forever, so a socket that had reconnected N times sent N+1PINGREQs per keep-alive interval. On a venue measured dropping this socket 117 times in seven minutes, that is aPINGREQevery quarter-second where the protocol asks for one every thirty. A surviving chain's ping, queued during a reconnect, could also reach the new connection before itsCONNECT, and MQTT 3.1.1 requiresCONNECTto be a client's first packet. Each connection's timer now carries its own reference instate.ping. A ping that does not match it is dropped without re-arming, which ends that chain, andhandle_disconnect/2clears the reference. Break-verified: both keep-alive tests fail on the previous code.
[0.4.63] - 2026-09-24
Fixed
An empty response envelope became a phantom order.
rows/1has a clause for endpoints that answer with a bare object,%{} = body -> [body], and it caught envelopes too:{"code": "200", "msg": "ok", "data": null}failed theis_list/1guard and came back as a one-row list whose row WAS the envelope. Measured against that body:get_orders/2answered{:ok, [%Order{id: nil, symbol: nil, side: nil, …}]}— an order that does not exist —get_transfers/2, which returns rows as sent, handed the envelope back as a transfer, andget_positions/2refused it as a malformed position, reporting "no positions" as corrupt data.A
"data"key now decides the shape whatever it holds: a list is the rows, an object is one row (the object, not its wrapper), anything else —nullabove all — is none. All three now answer{:ok, []}.Separately, an order row with no
client_order_idis dropped fromget_orders/2and refused byget_order/3as{:missing_required_field, :id}, rather than filled in from the caller's own argument. A caller cannot cancel, amend or look up an order with no identity; this package's ownto_watchlist/2already holds "a nil key there is worse than one fewer row this cycle".Core.Types.Orderadmitsid: nilfor acknowledgements that carry little else — a list of orders is not that case.
[0.4.62] - 2026-09-23
Fixed
A forwarded
niloption was used as the value instead of meaning "not set". This family forwardsoptsunchanged through every layer, so an option a caller's own caller never set arrives askey: nil, andKeyword.get/3substitutes its default only for an ABSENT key.Core.Config.opt/3exists for exactly this; these call sites predated it and now use it.The one that mattered most was
:to, and its two halves failed in opposite directions. The realFeedputnilin its subscriber set, andCore.Fanout.deliver/4resolvesnilthe way it resolves any name nothing answers to — nobody — sosubscribe/2answered:okand data never arrived. The fake calledsend(nil, _), which raises. A consumer's tier-1 tests could not have shown them the real behaviour even by accident: the fake crashed where the venue went quiet.:categorywas the other consequential one.case Keyword.get(opts, :category, "US_CRYPTO")let a forwardednilreach thecase, where it matched no named category and fell to the_stockclause: a caller who said nothing, on a package whose documented default is crypto, got equity bars for the same ticker. The substitution this family names first — real bars, the wrong asset, and nothing to notice it by.:limitand:depthwere sent as""(to_string(nil)) for the same reason.:namewas deliberately left alone. Therenilis not "unset" — it is OTP's own way to say "do not register this process", and this package's test support starts unnamed feeds with it. The first pass converted it too and several test files went red at once, which is how that was found; it is recorded so it is not tried again.:credentialsis unchanged as well, sinceniland%{}already end in the samemissing_credentialsrefusal.Each changed call site is tested red-before, green-after:
forwarded_nil_test.exsdrivesFeed.subscribe/3andsubscribe_notices/2against a probe standing in for the feed process, so what is measured is exactly the client-side resolution that changed. Core's conformance suite gains assertion 27 for the fake half.
[0.4.61] - 2026-09-23
Fixed
create_watchlist/4retried a create the venue could not tell apart from the first.Core.HttpClientretries a timeout or a 5xx three times by default, and the body — a name and an optional sort — carries nothing the venue could dedupe on. This module's own comment on that function names the result: "the list now exists at the venue and the caller has no handle to add to, read or delete it". A retried create makes exactly that orphan. It is now sent once, viaput_new, so a caller can still ask for retries; the control test proves the harness sees retries at all.The last instance of a sweep across all five packages asking every write the question
dp_exchange_gemini'spost_once/4states — can the venue tell the second attempt from the first? Orders here were already safe:client_order_id/1generates a key when absent, for single and batch orders alike.
[0.4.60] - 2026-09-23
Fixed
Every frame on the
noticetopic was classified:degraded, so a healthy keepalive logged a WARN 184 times an hour (issue #5). The kind was a constant — nothing inspected the frame — and:degradeddefaults to:warning, so a consumer mapping severity to a log level (the mappingCore.Noticedocuments) warned on every frame whatever it said.Measured by the reporter over eight hours on a live host: 336 WARN/hour, 184 of them
%{"drop" => 0, "rtt" => 0, "sent" => 1878, "type" => "1001"}— a keepalive reporting ZERO drops, renderedwebull degraded: (no message)because it carries nocontent. The feed was healthy throughout (4,400-4,600 rows/60s, zero reconnects). Those notices were the #1 and #2 entries in that host's warning log by volume and buried 1,331 genuinely serious warnings underneath them.Severity is now read from the frame. Two things count as adverse and nothing else does: the venue sent words (it only writes
contentwhen it has something to say — the issue #33 frame, "Permission grabbed by other session", is that shape), or it reported a non-zerodrop. Absence of an adverse indicator is not evidence of degradation, so everything else is:info.Core.Noticecalls its default severity "a default, not a rule: a caller with better information overrides it", and the frame is better information.Keyed on evidence, not on the
typecode the report suggested. The vendor publishes no field schema for this topic at all, so a"1001" => :infotable would be one consumer's observation hardcoded as a venue fact, and a renumbering would silently get it wrong.contentanddropare the fields that carry the claim, and they are what the reporter's own evidence discriminates on.The notice is still delivered, and still whole — the fix is the claim it makes, not the dropping of venue telemetry a consumer may want. The kind stays
:degraded, which is a known imperfection rather than an oversight:Core.Notice's kinds are "deliberately a closed set" with no slot for "the venue reported on itself and nothing is wrong", and widening it is a change to the shared contract, not this package's to make unilaterally.script/check_endpoint_inventory.shword-split its own diff output.printf ' %s\n' $addedwas unquoted, so an entry containing a space was reported as several. Latent here — this vendor's page slugs carry no spaces — and live in the sibling checker, where a vanishedGET /v1/feepromoswas reported as two entries,GETand/v1/feepromos, as though the verb had disappeared from the venue. Fixed in both, since the two scripts are the same shape and a slug with a space or a glob character would surface it here too.
Documentation
- Two vendor pages appeared since the 2026-09-13 capture —
list-transfer-activitiesandget-transfer-activity-detail— and both were read before the capture was refreshed. No claim this package makes is falsified, and the reason is recorded indocs/reference/webull/endpoint-pages.txtbecause the obvious reading is the wrong one: they document ACATS and crypto asset transfers, whileget_transfers/2is the contract's "deposit and withdrawal history". Different facts, socash-activitiesremains the right source and a future reader reaching fortransfer-activitiesbecause the name matches would be answering a different question.{:get_transfers, 2}was already:experimental, so there was no false:unsupportedto correct either, and neither page states a rate limit.
[0.4.59] - 2026-09-15
Fixed
Fake.subscribe/2replaced the live subscription instead of adding to it. It was the identical lineupdate_symbols/2carries — the two callbacks were byte-for-byte the same function — so a secondsubscribe/2silently dropped whatever the first had subscribed.The real facade unions (
wanted: MapSet.union(state.wanted, MapSet.new(symbols))inFeed), and so does every other venue package in this family. A consumer's tier-1 tests against this fake therefore certified the opposite of what the venue does: subscribe twice, keep one symbol here and both there. That is precisely the "differently capable" divergence a fake exists not to have.DpExchange.Core.Venue.subscribe/2's own doc does not state which it is, and it does not need to — the contract settles it by shape.DpExchange.Core.Venue.update_symbols/2exists to "change a live subscription's symbol set" andDpExchange.Core.Venue.unsubscribe/2to stop delivery for named symbols; neither has any purpose ifsubscribe/2already replaces, since a caller would just subscribe the new list. Two callbacks the contract distinguishes, implemented as one function, is the tell.Found by asking all five fakes the same three questions in order — subscribe, subscribe again, unsubscribe, replace — and four of the five answered differently from the fifth. Core's conformance suite gains assertion 26 so the next venue cannot reintroduce it.
[0.4.58] - 2026-09-15
Fixed
A wrong wire type on a field the schema uses crashed the socket.
QuoteProto's compatibility handling covered an unknown field at an unknown field NUMBER — the case its tests exercised, at numbers 99, 50, 51 and 52. The other case is the same unknown wire type arriving at a number the schema does use, where the junk and the real value accumulate into one list under one key.scalar/1anddecode_nested/1both filtered that list foris_binary/1;first_repeated/2took[first | _rest]unfiltered and handed the junk to a reader with clauses for a binary and forniland none for an integer.FunctionClauseError, raised inside the socket process, with norescuebetween there andhandle_frame/2. The connection dropped, reconnected, and was handed the same frame again. The module's own moduledoc promises the opposite in as many words: "one malformed frame must not cost the connection."first_repeated/2now finds the first entry that is actually a level, which both stops the crash and keeps the good level rather than stopping at the junk in front of it.accumulate/3could not tell a stepped-over field from an absent one.decode_value/2storesnilas the value of a 64-bit or 32-bit field it walked past, andMap.get/2reads that storednilas "no such key", so the next occurrence of the same field number replaced the placeholder instead of accumulating beside it. Now read withMap.fetch/2.No decoded field changes value because of this, and that was checked rather than assumed — every reader in the module filters for
is_binary/1, so a lostnilis a lost nothing, and the difference is visible only through the publicdecode_message/1. It is fixed as a latent trap: the conflation holds only while every stepped-over wire type decodes tonil, and the first clause to return a meaningful value at a field number the schema also uses would turn it into a silent drop.
Performance
decode_quote/1walked each book level twice and accumulated repeats quadratically.level_price/1andlevel_size/1each randecode_message/1over the whole level binary to read one string out of it — and anAskBidis not a two-field message: the venue's schema gives itrepeated Order order = 3andrepeated Broker broker = 4, so on an MPID-level book that binary carries every market maker at the price and all of it was walked twice. One decode now serves both reads.accumulate/3appended withexisting ++ [value], which is O(n) per entry and O(n²) per message; it now prepends anddecode_message/1reverses once at the end. Measured perdecode_quote/1call on a synthetic book, before and after:levels per side before after 5 2.78us 2.70us 50 12.70us 8.22us 100 33.57us — 200 120.13us 35.40us The reversal is load-bearing rather than tidiness, and is asserted as such:
first_repeated/2reads the FIRST entry on the wire andscalar/1reads the LAST, so a reversal applied to one and not the other would leave each of them reading the far end of the book.
[0.4.57] - 2026-09-15
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.56] - 2026-09-14
Fixed
An empty or blank
app_keyorapp_secretwas signed with rather than refused.is_binary/1was the whole gate and""satisfies it. An empty secret produces the signing key"&", which HMACs as happily as a real one, so nothing local failed: the request went out and came back refused for a reason naming signatures — sending the reader to the six-step signing scheme, which is correct and verified against the venue's own worked example, instead of to the credential, which was never set. That is precisely the outcomeheaders/2's own@docsays it refuses. Reachable by the commonest misconfiguration there is: a.envline readingNAME=with nothing after it.System.get_env/1returns""for that, notnil, so everynil-shaped guard upstream passes it through intact. Blank is now trimmed and treated as absent, which is the answer{:missing_credentials, _}already existed to give. Two venues in this family were already safe from this, and both by accident rather than by check — their credential formats are structured (base64 key material, a trimmed token), so the blank failed a format test rather than a presence test. The fields that are opaque strings had nothing to fail.present?/1had the same hole, and that one propagated: its entire contract is "the shapeheaders/2accepts", it answered:okfor a blank pair whileheaders/2went on to sign with it, and it is whatRest.get_fees/2(no request to hang the real gate on) andDpExchange.Webull.Fakeboth use. Both now agree, asserted together.An order price or size could go onto the wire in scientific notation.
Decimal.to_string/1defaults to:scientific, andto_string/1on a%Decimal{}reaches that same default throughString.Chars— so a value carrying an exponent was serialised as"1.5E+2"or"1E-8". That is not a number this venue reads, and a different order if it read one at all.An exponent is not exotic.
Decimal.normalize/1— the ordinary way to strip trailing zeros — turns150.00into1.5E+2, and anything below a millionth carries one by construction:Decimal.new("0.00000001")is1E-8. A caller normalising a price before placing an order is doing something entirely reasonable.Every money field now goes through a helper that asks for
:normalexplicitly, and the generic map-building helpers refuse a%Decimal{}before it can reachString.Charsat all — so a money field added to a payload later cannot reintroduce it.This was found in four of the five venue packages at once. Each of them already said
:normalsomewhere — in a conversion helper, a withdrawal amount, an option parameter — and none of them had said it on the order path, which is the one that spends money.
[0.4.55] - 2026-09-14
Added
A test pinning that a retried order carries the SAME idempotency key. This venue's order writes retry — safely, because
client_order_idis documented as an idempotency key and is generated here when the caller gives none, so a retry asks the venue to complete the same order rather than place another.That safety rests on the key being generated once, while the body is built, with the retry loop re-sending that body unchanged. Nothing pinned it. Move the generation inside the loop and every attempt becomes a distinct order, with the whole suite still green — the worst failure available here, arrived at by a change that looks like a refactor.
Found while fixing
dp_exchange_schwabanddp_exchange_gemini, whose order writes had no such key and were retrying anyway; they now send once. The rule across the family is not "never retry a write" but "never repeat an action the venue cannot tell apart from the first one", and this test is what makes this venue's half of it verifiable.
[0.4.54] - 2026-09-14
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.53] - 2026-09-14
Fixed
A pagination claim this repository has no evidence for.
get_orders/2's doc said "the venue paginates onclient_order_idas a cursor". The venue's published endpoint inventory documents/trading/orders/open-orders/listand/trading/orders/historical-orders/listand no pagination parameter for either, so that sentence asserted a mechanism nobody here has seen. It is removed.The one-page behaviour is unchanged and is the honest one:
get_symbols/2walks a cursor because the venue documents the parameter for that endpoint, and guessing one here would be worse than not walking — a wrong parameter is silently ignored by most APIs, turning a limit a caller can see into one they cannot.The pagination walks no longer build their result with
acc ++ page.++copies its left operand, so folding each page onto a growing accumulator is quadratic in the number of rows — the one thing a pagination walk is guaranteed to do a lot of. Pages are collected as pages and concatenated once.Measured: 50 pages of 250 rows went from 2.75 ms to 0.55 ms, and 50 pages of 49 rows — Coinbase's own default page size for
/accounts— from 0.20 ms to 0.01 ms.dp_exchange_robinhood'swalk/6already did it this way and records why; the walkers here did not. Ordering is unchanged and is pinned by test.get_symbols/2's successful multi-page walk had no test. Both failure guards were covered — a key that does not advance, and the page bound — and the happy path was not, so a regression that stopped after page one, or dropped a page while collecting, would have passed. It is covered now.usage-rules.mdnow states thatget_orders/2returns one page, why it is not simply fixed, and what to do about it. The limit was recorded only in the module@doc; a consuming agent readsusage-rules.md, and a caller reconciling positions against a prefix of their orders is exactly who needed to be told.
[0.4.52] - 2026-09-14
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.51] - 2026-09-14
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.50] - 2026-09-14
Fixed
create_watchlist/4returned aWatchlistwith anilid when the venue's create response omittedwatchlist_id.:idis inTypes.Watchlist's@enforce_keys, so itsnew/1refuses anilthere — and nothing here callsnew/1, every struct being built literally as across this family, so that check never ran.This is the worst moment for a
nilid: the list now exists at the venue and the caller has no handle to add to, read, or delete it, whilenillooks like a value.to_watchlist/2on the LIST path already refused a row with nowatchlist_id, citingdp_exchange_robinhood's rule that "a nil key there is worse than one fewer row this cycle". The create path was the one that did not.A timestamp of zero or less decoded to 1970 instead of being refused.
from_epoch/1already used the non-raisingDateTime.from_unix/2and already picked seconds or milliseconds by threshold, so the out-of-range hazard never applied here — butfrom_unix/2answers{:ok, ~U[1970-01-01 00:00:00Z]}for0and a 1969 instant for negatives, and those are perfectly validDateTimes. That is the dangerous case rather than the obvious one:0is a common venue sentinel for "unknown", and a row dated 1970 is a real timestamp to every consumer that only checks fornil.The comment there called 1970 "loud". It is not — a consumer computing an age gets fifty-six years and may skip the row, but one that logs or charts the timestamp shows 1970 and calls it data. The family settled this for level timestamps ("an unreadable level timestamp does not become the epoch") and the same answer now holds wherever an epoch is converted, in this package and in the other four.
[0.4.49] - 2026-09-14
Fixed
A shard whose socket failed to OPEN was never retried — issue #4. The failed-open branch logged one warning and returned
{:noreply, state}, so the shard was not merely un-reopened: it was never put instate.shardsat all, andhandle_info(:resubscribe, _)reduces over exactly that map. Its symbols were orphaned for the life of the process.Reported from a real boot under load average 54: three of four shards timed out their handshake and stayed dark for 28 minutes across zero reopen attempts until the node was restarted, with the feed alive and every liveness probe passing. A secondary cost fell on the consumer, whose REST gap-fill saw nothing covered and drew 88 HTTP 429/minute from the venue for the whole 28 minutes.
The delivery gate added for issue #1 could not have caught this, and should not be widened to: it reopens a shard once no symbol of its has arrived for
stale_delivery_ms, and a shard that never opened has never delivered anything to age. "Never worked" is not "stopped working" — two conditions that look alike and want opposite recoveries.A failed open is now retried on its own backoff — one second, doubling, capped at a minute — until it opens or a resubscribe fills the slot by its own route. It is also audible: a
:coverage_changewarning naming the shard fires once when it first fails, and a matching:infoonce when it opens, rather than a WARN line at boot that nothing revisits.open_retry_base_ms:andopen_retry_max_ms:tune it.
[0.4.48] - 2026-09-14
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.47] - 2026-09-14
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.46] - 2026-09-14
Changed
The test suite runs in 10.3s instead of 25.5s, with the same 836 tests. Nothing was weakened, removed or retimed — two groups of tests moved into files of their own.
feed_test.exsalone took 24.7 seconds for 94 tests while this package's other 742 tests took 4.5 seconds between them. ExUnit parallelises across FILES and serialises within one, so roughly eighteen seconds of unavoidable waiting — a tick boundary, a deadline expiring, a call proving it outlastsGenServer.call/2's five-second default — ran end to end in that one file while everything else finished in its shadow.Two coherent groups moved out: the issue #3 tick/deadline/answer identity tests (
resubscribe_identity_test.exs) and the busy-feed timeout test (busy_feed_test.exs). Both carry a moduledoc saying why they are separate, so the next person does not fold them back in.The margins were not touched. Every one of these tests waits on a real timer, and shrinking the waits to buy speed is how a suite becomes flaky under load — which this family has already paid for three times. The saving comes from letting the waiting overlap, not from waiting less.
Measured rather than assumed:
dp_exchange_coinbasehas the same amount of slow waiting (14.4s of a 17.6s suite) and did not need this, because its slow tests already live in three different files.
[0.4.45] - 2026-09-13
Changed
delegation_test.exsclaimed "every facade function reaches the venue" while covering seventeen of them. Five were missing:get_trades/2,quantization/2,get_fees/2,adjusted?/1andlive?/1. The moduledoc now says what it covers, and the five have their own describe so the gap is legible rather than absorbed.The file's own reasoning is why this matters: the facade threads credentials and the rate limiter into each call, and "a function wired to the wrong
Restarity — or wired withoutwith_limiter/1— compiles, type-checks and then either loses the credential or bypasses the limiter".A refusal-shaped assertion cannot catch that, which is worth recording because it was the first thing tried:
get_trades/2andget_price/2both answer{:error, {:missing_credentials, :webull}}before a request is built, so swapping one delegate for the other left the test green. Only a happy path whose result differs pins the target. All three wrong-delegate swaps now fail.Two of the five corrected a wrong assumption rather than the code:
get_fees/2answers without credentials, because this venue's crypto fee is a flat published rate rather than an account query; and the environments are:productionand:uat, not:live/:paper, whichEnvironment.validate!/1refuses outright rather than defaulting.Coverage 91.87% to 92.14%.
[0.4.44] - 2026-09-13
Changed
The weekly endpoint-index check fired for the first time, and its finding is recorded rather than rubber-stamped.
broker-fd-api/order-executionsis a new page in the vendor's sitemap since the 2026-09-09 capture, taking that surface from 79 to 80.No claim in this package changed. The Broker API is a different product tier from the Trading + Market Data API every callback here is built on, and
endpoint-inventory.mdputs this package's coverage of it at 0. The nearest-sounding claim —{:get_trade_volume, 2}declared:unsupported— rests on the venue publishing no account volume report, not on the absence of an executions endpoint: "the venue reports fills, and summing them here would be this package's arithmetic rather than the venue's ledger". A broker-tier executions page does not supply that ledger.The capture is re-dated to 2026-09-13 after that analysis, in the order
check_endpoint_inventory.shasks for — it warns that updating the capture first "turns this check into a rubber stamp", and the reasoning now lives in the file's own header so the next person diffing it can see what was decided and why.Found by running the checker by hand the day before its first scheduled Monday. All fourteen weekly checks across the family had never once executed — they were added on Monday 2026-09-08 at 12:58 UTC, after that day's 09:00 trigger — so the first real run would have been their first run, on jobs that are deliberately non-blocking and therefore fail quietly. Every one was exercised: six dependency-floor checks pass, five doc-source checks resolve all 32 cited URLs, and of the three index checks this was the only one with anything to say.
[0.4.43] - 2026-09-13
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.42] - 2026-09-13
No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.
[0.4.41] - 2026-09-13
Fixed
usage-rules.mdsaidvenue_timeis nevernilon this venue and called anilbranch "dead code". That was true until 0.4.36 and 0.4.37, when the REST quote, the REST order book, and the MQTT quote and book stopped discarding real prices and real levels over a time the venue had not stated — and the documentation did not follow.usage-rules.mdis what a consuming agent reads, so a consumer taking that advice had a latent crash on a field this package had started returning.It now says the opposite, and says what has not changed: the local clock is never substituted into
venue_time. The "Timestamps come from the venue, or the call fails" section is rewritten to split by TYPE rather than assert one rule — aCandle,VolumeProfileorTradeis still refused undated because each enforces its time, while aQuoteorOrderBookis delivered withvenue_time: nilbecause neither does.
Changed
usage-rules.mdnow states the order-book ordering guarantee from 0.4.39 and the identity-drop behaviour from 0.4.38 — including that a shorter screener list is not an error and that a dropped row leaves a gap inrankrather than renumbering the survivors. Both were consumer-visible changes that shipped without a consumer-facing note.
[0.4.39] - 2026-09-13
Fixed
get_order_book/2returned the venue's row order, sohd(bids)was not the best bid.Core.Types.OrderBookmakes this part of the contract and anticipates the mistake by name: "The ordering is part of the contract, not a convenience: a caller readinghd(bids)as the best bid is reading it correctly, and a venue package that returns venue-order without re-sorting has broken the contract even though every value in it is true."A consumer pricing against the top of this book got whatever row the venue happened to send first — a wrong best bid made entirely of real numbers.
dp_exchange_coinbasewas the one package in the family already sorting, so the family had both answers running at once. The sort matches itssorted/2, including{direction, Decimal}rather than term order, becauseDecimalstructs do not compare correctly as plain terms. Thenot is_nil(price)filter this decoder already had is what keeps a nil price from reaching the sort.
[0.4.38] - 2026-09-13
Fixed
A row the venue did not identify was published with an empty id or symbol.
Core.Types.Watchlist,NewsItemandScreenerResulteach enforce their identifying field andnew/1refuses anilthere, but these build the struct literally — as everywhere in this family — so|| ""satisfied the requirement while saying nothing.An empty string is worse than the
nilit replaced. Anilis detectable;""is a value, so a consumer keying coverage by symbol gets a live entry named"", and one deduplicating by id collapses every unidentified row into a single entry.The news fallback was the worst of the three:
value(row, ["id", "news_id"]) || value(row, ["symbol"]) || ""published a ticker symbol as an item id. That looks like an id and collides for every item about the same symbol, so a consumer deduplicating by id silently kept one story per ticker.All three now drop the row — the rule
dp_exchange_robinhoodalready states for the same situation: "a row missingsymbolentirely is dropped rather than published under a fabricated one … a nil key there is worse than one fewer row this cycle". The screener drops afterEnum.with_index/2, so a survivor keeps the position the venue returned it in; closing the gap would re-rank the list, which that field's own comment rules out.get_watchlist/3'svalue(row, ["watchlist_id"]) || watchlist_idis unchanged and is not the same shape: it falls back to the id the caller asked for, which is neither absent nor fabricated.Found by a mechanical audit of every literal struct construction in the family against the fields its Core type requires non-nil — the check
new/1performs and 85 literal constructions bypass.
[0.4.37] - 2026-09-13
Fixed
A REST quote or order book the venue did not date was refused outright.
get_price/3andget_order_book/2gated on{:ok, timestamp} <- venue_time(row), so a row without a readable time discarded a real guarded price, or a whole set of book levels, over a field neither type enforces.Core.Types.Quotelists[:symbol, :price, :observed_at, :provider];Core.Types.OrderBooklists[:symbol, :bids, :asks, :observed_at, :provider]and typesvenue_timeasDateTime.t() | nil.top_of_book_time/1in this same module has always answered the other way forget_top_of_book/3— the sibling call on the same endpoint — so two calls reading the same rows disagreed about whether an undated one was usable.The guarantee those tests were named for ("not stamped with the local clock", "rather than substituting now") is unchanged and is now asserted directly rather than inferred from an
{:error, _}, which could not tell anilapart from a substitution in the first place.Three paths still refuse, and that is the contract deciding, not inconsistency:
get_historical_prices/5builds aCandleandget_volume_profile/4aVolumeProfile, both of which enforce:opened_at— an interval at an invented minute is a different kind of wrong from an unstated timestamp — andget_trades/2builds aTrade, which enforces:timestamp.Completes the sweep that began in
dp_exchange_geminiand reacheddp_exchange_schwab; this was the last venue and the last transport carrying it.
[0.4.36] - 2026-09-13
Fixed
A streamed book or quote the venue did not date was dropped entirely.
emit_top_of_book/2andemit_decoded/2gated onvenue_time/1, whose comment read "on a stream, refusing to substitute means dropping the frame rather than stamping it with our own clock". Refusing to substitute is right; that was not it. Writing our own clock intovenue_timewould be the substitution, and emittingnilthere is its opposite.Core.Types.TopOfBookenforces[:symbol, :observed_at, :provider]andCore.Types.Quote[:symbol, :price, :observed_at, :provider]. Neither enforces the venue's time, andobserved_at— always present, documented as "a different, honest fact" rather than a stand-in — is what states freshness. A real bid, ask or traded price was discarded over a field the contract marks optional.This package's own REST arm has always answered the other way:
Rest.get_top_of_book/2reads the time throughtop_of_book_time/1, which returnsnilwhenvenue_time/1fails. Same venue, same type, opposite answer decided by transport — andRest.venue_time/1already carried its own note that an incomplete key list "silently produced:missing_venue_timestampfor every row until they were added", which is this same failure having happened once before on the other side.emit_trade/2still gates onvenue_time/1, and that is not an inconsistency:Core.Types.Tradelists:timestampin@enforce_keysand types it non-nullable, so a print this package cannot place in time is genuinely not one it can report. The answers differ because the contracts differ.Found by sweeping the family after the identical defect in
dp_exchange_gemini, where the same sentence appeared in the same role.
[0.4.34] - 2026-09-13
Fixed
A streamed
TopOfBookreportedbid_sizeandask_sizeasnilwhile the venue was sending them.Sockethardcoded both tonilunder a comment stating as fact that "the venue's book message carries prices and no sizes". The venue's own protobuf schema, kept verbatim indocs/reference/webull/streaming-api.md, isAskBid { string price = 1; string size = 2; }— andQuoteProtowas already walking field 2 off the wire indecode_message/1beforelevel_price/1threw it away. The test fixtures in this repo have been building levels with sizes all along.Core.Types.TopOfBookis explicit that anilsize "means 'not published', never 'none available'", so a consumer sizing an order against the top was being told the depth was unpublished when it had been published and discarded. Both sizes now reach the consumer. A level that states no size is stillnil; one that states"0"is a zero, because that is what the venue said.Found while sweeping the family after the same class of defect in
dp_exchange_schwab(a Change-delivery delta published as a book). Webull'squotetopic is not a delta stream — its schema carries no sequence number and no operation field, so each message must stand alone — and needs no merge. This was the other half of the same read.
Changed
usage-rules.mdrecords that streamed sizes are now populated, and what anilthere means.
[0.4.32] - 2026-09-13
Fixed
A caller parked on a shard reopened for
INVALID_SESSIONwas never answered. It waited out@call_timeoutand then EXITed, taking the calling process with it — the outcome this module's own comments name as the one to avoid.complete_link_up/3callshandle_subscribe_result/3, then reads the shard back and treats a missing one as a caller already dealt with. That was true whileisolate_crashed_shard/3was the only thing that could remove a shard mid-reply. It stopped being true whenrebuild_stale_shard/3was added — on the line immediately above that read — because it deleted the shard onINVALID_SESSIONwithout answering anyone, and the comment asserting the invariant is what kept it from being noticed.The reachable path is ordinary: a caller subscribes to symbols landing on a shard that is still connecting and is parked in
reply_to; the shard links up; its replay subscribe is answeredINVALID_SESSION.Both teardowns now answer through one funnel,
answer_parked_caller/2, rather than each writing the reply out for itself — the two drifting apart is the whole of what went wrong, since both delete a shard and only one remembered what that means for a caller. The parked reply's capacityoverflowtravels with the failure here exactly as it does everywhere else. A caller on this path now gets{:error, {:invalid_session, session_id}}, or the{:partial_failure, ...}shape when it also had symbols that did not fit.
[0.4.31] - 2026-09-13
Fixed
A superseded reconcile's answer could still settle its successor's tag (issue #3, follow-up). 0.4.29 tore a superseded attempt down, and the comment there claimed that was what stopped its stale result reaching the successor's callers. It is not, and the claim has been corrected in place. An answer already in the mailbox cannot be recalled by killing the process that sent it —
send/2completes before the kill does — so an attempt that answers between its successor's trigger arriving and that trigger being processed has already left its result behind. Routed by tag alone, that stale result forgot the successor, demonitored it and dropped its bookkeeping, and was then handed to the successor's callers as theirs.An attempt now has an identity that its answer carries, not just its deadline: a
{:reconcile_done, tag, attempt, result}is routed only when the tag is still tracking that attempt. The deadline matches on the same token rather than on the monitor ref, so there is one notion of attempt identity in the module instead of two.Also settled by the same check: an answer arriving after its tag's deadline has already fired is now dropped rather than applied. A deadline reported as
:no_venue_responseis not retracted by the answer turning up afterwards — the caller has been answered and the failure counted, and applying the late result would un-count it, making a shard that is reliably too slow indistinguishable from a healthy one. One attempt gets one outcome, and the first thing to conclude it is the one that counts.No change to any error shape a caller sees. The synthesised
{:reconcile_done, tag, result}that the deadline and:DOWNpaths dispatch is unchanged and does not pass through the new check — those have already decided the outcome.
[0.4.29] - 2026-09-13
Fixed
A blind resubscribe could not complete once a shard needed it, because the next tick killed it (issue #3).
@resubscribe_interval_msand@reconcile_timeout_msare both 60,000, andhandle_info(:resubscribe, _)re-arms its own timer before spawning — so at every tick boundary the tick was enqueued ahead of the deadline armed during the previous one. The tick overwrotereconciling[{:resubscribe, index}]with a fresh task, and the previous deadline then found that successor under the tag it had been armed for and killed it milliseconds old.The blind re-issue is the only recovery this venue has for a session it has silently stopped publishing to (see the moduledoc and DpCryptoManagement's issue #17), so once a shard stalled it could not come back except by escalation. The reported boot measured 449 reconcile timeouts producing 12 recoveries; a four-shard, 60-second interval over 2h04m predicts 496 ticks, so very nearly every tick was failing this way.
Two fixes, because this was two defects wearing one symptom:
- A tick whose previous attempt is still in flight is now skipped. A second identical
subscribe for a session the venue has not answered for yet is load, not a safety net —
validate_resubscribe_interval_ms!/1already refuses a configured interval under a second in those words, and an unfinished attempt shortens the effective interval just as surely. Consequence worth knowing: a shard whose attempt is hung re-asserts on the tick after its deadline clears, which — the deadline and the interval being equal — is every other tick. Stated rather than tuned away, because@reconcile_timeout_msis derived from@call_timeoutfor reasons unrelated to this timer. - A deadline now names the attempt rather than the tag it ran under. The timeout
message carries the attempt's monitor ref and is ignored unless the tag still holds it,
so a timer can only ever tear down what it was armed for. This also covers
{:link_up, _}and{:background, _}, where a supersede is legitimate.
Relatedly,
spawn_reconcile/3now tears an attempt down when it supersedes one instead of orphaning it. An orphan's monitor ref belonged to nothing, so its:DOWNreacheddrop_dead_subscriber/2as though a subscriber had died; and if it later answered, the{:reconcile_done, tag, _}clause forgot its successor and handed that successor's callers a stale result.No change to any error shape a caller sees.
- A tick whose previous attempt is still in flight is now skipped. A second identical
subscribe for a session the venue has not answered for yet is load, not a safety net —
Changed
- The test named "the resubscribe timer reschedules itself" now drives the feed's own timer
instead of hand-sending
:resubscribetwice. The hand-sent version tested that the handler can run twice — not what it was named for — and passed only by racing the first attempt's reply back to the mailbox, which is the overlap issue #3 turned out to be about.
[0.4.28] - 2026-09-12
Fixed
A timed-out reconcile no longer puts an ERROR-level OTP report on the host's logger (issue #2). The reconcile task was torn down with
Process.exit(pid, :kill), which reaches itsTask.Supervisoras:killed; OTP skips its child-termination report only for:normal,:shutdownand{:shutdown, _}, and reports every other reason at ERROR. So each timed-out reconcile — one per shard per resubscribe tick — loggedChild :undefined of Supervisor #PID<_> (Task.Supervisor) terminated / ** (exit) killed. The reporting host saw 234 of 239 ERROR lines in a single boot from this alone.Now
:shutdown, which terminates the task identically (no reconcile task traps exits) and is the reason OTP reads as intentional. This is the same choicestop_socket/1already made for the websocket, made there and never carried to the task.Nothing was broken by the noise and no caller-visible behaviour changes: the timeout still answers its caller with
{:error, {:reconcile_timeout, ms, :no_venue_response}}, and the condition is still reported at WARN with the shard named. What changes is that ERROR on a host running this package now carries information again — the OTP report named neither this package nor the shard, and arrived at the level alerting keys off.Measured rather than assumed, on 2026-09-12: a
Task.Supervisorchild was exited both ways withLogger's:logger_translatorprimary filter set tosasl: true(the host setting that lets these through; this package's test env drops them).:killproduced the report above;:shutdownproduced nothing. The regression test asserts the task's exit reason, which is the whole of what OTP branches on.
[0.4.27] - 2026-09-12
Fixed
The escalation added in 0.4.25 made things worse, and is now gated on delivery (issue #1). That version reopened a shard's socket after twelve consecutive blind resubscribe failures. Measured on the reporter's live fleet: three shards hit the limit, reopened, and failed the identical reconcile on the very next tick and every tick after — a fresh session did not change the outcome once — while this venue's coverage fell from 226 distinct symbols to about 160 (−29%), with
dp_exchange_coinbaseanddp_exchange_geminiflat across the same windows. And becauserebuild_stale_shard/3resets the counter, it had become a cycle: fail twelve times, reopen, fail twelve times, reopen.The inference that failed was ours: that a shard which cannot reconcile is a shard whose session is no good. It is not. A reconcile is a subscription-management call, and it can keep failing while the transport underneath streams everything already subscribed to — which is exactly what this package's own log line has always promised, "its N symbol(s) stay on whatever they last delivered until the next resubscribe tick". A reopen breaks that promise.
The reopen now also requires that none of the shard's symbols has arrived for
stale_delivery_ms(five minutes by default, five whole resubscribe cycles). While a shard is still delivering, failures are counted and reported and nothing is torn down: the worst case of doing nothing is a stale subscription set, and the worst case of acting is losing live data. A reopen can only gain something once the shard has gone quiet.Five minutes because a shard carries on the order of a hundred symbols, and the question is not whether one symbol has been quiet — an illiquid pair can go minutes without a print — but whether every symbol on the shard has been quiet at once, which a live shard does not do. Tunable via
stale_delivery_ms:, alongsideresubscribe_failure_limit:.
[0.4.26] - 2026-09-12
Fixed
A position row the venue did not attribute to an instrument was reported as a position.
Core.Types.Positionenforces:symboland itsnew/1refuses anilthere, but this decoder builds the struct literally — as every decoder in this family does — so that check never ran and the symbol came through by key. A position naming no instrument cannot be sized, closed or reconciled by anyone: it is not a weaker claim about what is held, it is not a claim at all, and it sat in a list of real positions looking like one.One unattributable row now refuses the whole reply rather than being dropped. A position list with an entry silently missing reads as "you hold none of that instrument", which is a different and more dangerous claim than "this response could not be read".
A reconnect-timing test failed under load while the behaviour was correct. It asserted the attempt-1 path returns in under 500ms, which is stricter than the claim needs — the claim is "no backoff was applied", and the smallest backoff is a full second, so any time under one proves it. Measured against that boundary now. Same shape as the rate-limiter bucket race and
Core.PollingFeed's poll-interval waits: a timing assertion that holds when the machine is quiet and inverts when it is not.
[0.4.25] - 2026-09-12
Fixed
A shard could retry the identical failing resubscribe forever (issue #1). After a node restart, all four shards failed their blind resubscribe with
{:reconcile_timeout, 60_000}. Three recovered on their own within eleven minutes; the fourth failed the identical reconcile 21 consecutive times over 21 minutes and never recovered. Nothing counted, so nothing could notice —state.resubscribe_failedwas aMapSet, which answers "is this shard failing?" and never "for how long".After twelve consecutive failures a shard now reopens its own socket on a fresh session instead of attempting the same reconcile again. The escalation is not new machinery:
rebuild_stale_shard/3already stops the socket, drops the shard's bookkeeping and reopens it, and is what{:invalid_session, _}has always done. A repeated timeout now reaches the same remedy, because after a dozen identical failures "this shard's session is no good" is a better hypothesis than "the next attempt will differ".Twelve is measured, not picked. In the reported incident the three shards that self-healed did so after 8, 10 and 10 consecutive failures; a lower limit would tear down sockets that were about to recover, and every teardown claims a fresh session against the venue's per-account ceiling — the same contention that caused the incident. Overridable via
resubscribe_failure_limit:, because one incident is one sample. A success resets the count, so only consecutive failures escalate.
Changed
{:reconcile_timeout, ms}is now{:reconcile_timeout, ms, :no_venue_response}(issue #1). The third element is a statement of fact rather than a guess: that clause runs only because the deadline arrived with no answer.It exists because the two causes call for opposite actions — a venue refusing because another session holds the permission must be waited out, since reconnecting claims a further session against the same ceiling, while a wedged socket of ours must be reconnected. A venue that answers with a refusal reaches a caller as that refusal and never as this tuple, so the two are now distinguishable without either being inferred.
A consumer matching the old two-element tuple must widen the match.
[0.4.24] - 2026-09-12
Changed
usage-rules.mddocuments the error shapes this package started returning this week. Several calls that used to answer{:ok, _}with a value a consumer could not act on now refuse instead, and none of those refusals had reached the document a consuming agent actually reads. CLAUDE.md is explicit thatusage-rules.md"is not optional and it is not the README", and shipping the behaviour without it left a consumer to discover the new shapes from a crash.A consumer matching only
{:ok, _}needs no change. One that enumerates error reasons now has them, each with whether retrying is worth anything — which is the part that decides what a caller does next, and the part a bare list of atoms would leave out.
[0.4.23] - 2026-09-12
Fixed
mix.lockmoved todp_exchange_core0.3.10. It carries two conformance-suite consolidations and two test-isolation fixes in Core's own suite; for this package the effect is that every fake call the contract suite makes now builds its arguments through one builder, so this venue'sendpoint_optsandendpoint_symbolsreach all of them rather than only the call sites that happened to be wired.mix.lockis committed and CI honours it, so the pin allowing the newer Core was never the same thing as running it.
[0.4.22] - 2026-09-12
Fixed
Two conformance assertions were inert here, and are not any more. Core's assertions 14 (
get_top_of_book/2returns aTopOfBookwith aDateTimeobserved_atand nopricefield) and 23 (Quote/OrderBookcarry the venue's own time ornil, never an unparsed stand-in) both called this package's fake with no credential — those endpoints are public-SHAPED, so a credential can only travel inopts, and nothing put one there. This package answered{:error, {:missing_credentials, _}}and the assertions' skip-on-refusal clause took that for an answer.Measured: with a fake deliberately returning
observed_at: nil— the exact defect assertion 14 exists to catch — this package's contract suite passed clean against Core 0.3.8. Against 0.3.9 it fails, on exactly the endpointscapabilities/0declares active. Locked forward accordingly;mix.lockis committed and CI honours it, so the pin allowing the newer Core was never the same thing as running it.The order-book assertion could only ever see a refusal here. This venue serves an order book for US stocks and ETFs and refuses one for a crypto pair, and every entry in the contract test's
sample_pairs:is crypto.endpoint_symbols:(new in Core 0.3.9) now names a symbol the endpoint actually serves, so assertion 23 runs against a real book instead of skipping. The crypto refusal is still correct and still covered —order_book_test.exsasserts it directly.
[0.4.21] - 2026-09-12
Fixed
mix.lockmoved todp_exchange_core0.3.8, so assertion 24's refusal check runs here. 0.3.8 stops that assertion accepting a refusal from an endpointcapabilities/0declares active — the clause that let three packages pass an assertion which never executed on them. The pin already allowed the new Core, butmix.lockis committed and CI runsmix deps.get, which honours it: "the pin allows it" and "CI resolves it" are different statements and only the second one decides what actually runs.
[0.4.20] - 2026-09-12
Fixed
This package was passing fake-driven conformance assertions that never ran. Several assertions in
Core.AdapterContractcall an active endpoint through this venue's fake, and every account-scoped one was refused for the missing account before it reached the behaviour under test. The suite took that refusal as a legitimate answer and skipped — green, and proving nothing.Caught by deliberately breaking this package's fake against Core's new assertion 24 (a
Balancemust name the asset it is a balance of): the suite stayed green. It was never confined to 24 — assertion 17, the credential gate, had been passing for the wrong reason, getting its expected failure from the absent account rather than from the stripped credential it exists to test.The contract test now declares
endpoint_opts:, Core 0.3.7's new option, naming the option each of this venue's own endpoints needs. Verified the way the gap was found: with the fake broken on purpose, the suite now fails.
[0.4.19] - 2026-09-11
Fixed
The socket reconnected with no delay, forever, against a venue that would not have it back.
handle_disconnect/2returned{:reconnect, state}unconditionally, andwebsockexsupplies no delay of its own:on_disconnect/5indeps/websockex/lib/websockex.excallsopen_connection/3and, on failure, calls itself withattempt + 1— a synchronous loop with nothing between the turns. Verified in the dependency's source, not assumed.So any refusal that does not fix itself by being retried sooner — a credential the venue has stopped honouring, an IP it has started refusing, a maintenance window, a 503 — became a connect storm at full speed for as long as it lasted. CLAUDE.md's testing tiers already say what a venue does about traffic like that: "a venue that sees a package polling it on a timer will rate-limit or block". A reconnect storm is that, without the timer.
dp_exchange_schwabwas the only package in the family with reconnect backoff, and its moduledoc had described this exact hazard, in these exact terms, the whole time.The attempt counter was in the argument all along. This module used to state that it "keeps no attempt counter", and declined to emit
Core.Telemetry.link_reconnect_attempt/3rather than invent one — refusing to invent was right, and the premise was wrong.attempt_numberis a documented key of theconnection_status_mapthathandle_disconnect/2already pattern-matches on, incremented bywebsockexfor each consecutive failed reconnect and reset each time a live session drops. Backoff and telemetry now both run on it: attempt 1 still reconnects at once, every attempt after doubles from 1s, capped at 30s.The backoff arithmetic crashed during a long storm — the one thing it existed to survive.
min(base * round(:math.pow(2, n)), max)raisesArithmeticErroronce the exponent passes 1023, because the float range ends at ~1.8e308, and clamping the result does not help when the raise happens while computing the argument tomin/2. At the 30-second cap that is roughly 8.5 hours of continuous failure — an ordinary overnight outage — and the crash lands insidehandle_disconnect/2, where it reads as this socket's fault rather than the venue's. NowBitwise.bsl/2with the exponent clamped before the shift: no ceiling, exact, and every clamped value gives the identical answer since the cap is already reached at exponent 5.
[0.4.18] - 2026-09-11
Fixed
A balance the venue did not attribute to an asset was returned as success.
Core.Types.Balance'snew/1refuses anilin:currency, and this decoder never callednew/1— it builds the struct literally, as all five venue packages do, 85 call sites between them — so the check never ran and the field came straight out of the venue's JSON by key. A renamed or absent key produced%Balance{currency: nil}: an amount attributable to no asset, inside{:ok, balances}, which a consumer cannot size, book or reconcile against. It is precisely the renamed-field scenarioCore.Types.Validate's moduledoc was written for, arriving through the one path that bypassed the constructor written to catch it.Such a row now refuses the whole reply rather than being emitted or silently dropped — dropping it would read as "you hold none of that asset", a different and more dangerous claim than "this response could not be read".
:balanceis deliberately not guarded the same way.Core.Types.Balancenow states that it may honestly benilwhile:currencymay not, and the two are not the same kind of required: an unknown quantity is still a balance, an unattributable one is not.
[0.4.17] - 2026-09-11
Fixed
subscribe_notices/2could kill the caller for asking during trouble. It was the one public call inFeedleft onGenServer.call/2's five-second default timeout. Every sibling call —subscribe/3,unsubscribe/2,update_symbols/2,coverage/1— is given@call_timeout, which is this package's own statement of how long the feed may legitimately take to answer on a busy mailbox.A consumer registers for notices at start-up, which is precisely when that mailbox is deepest: the transport is coming up, symbols are being resolved, and the handler itself may replay a notice to the newly-registered subscriber. A
GenServer.call/3timeout exits in the caller, not in the feed — so the channel a host uses to hear that something is wrong was the one call that could take the host's calling process down for asking under exactly the conditions it exists to report.Now uses
@call_timeoutlike every other call. The test blocks the feed's own process for six seconds — past the old default, nowhere near the new timeout — and asserts both that the call succeeds and that it genuinely queued behind the block, so it fails on the unfixed code rather than passing for free.
[0.4.16] - 2026-09-11
Fixed
A
200this package could not decode became an empty object, and then an empty everything.decode/1collapsed any unparseable body to%{}.%{}is a map, so it passed straight through the readers and came out as a well-formed struct with every fieldnil, returned as{:ok, value}.There was already a test for this — "a non-JSON body decodes to nothing rather than crashing" — and it passed, which is why the defect survived: its name described the substitution, and it only held because
get_price/3's reader rejected%{}for carrying no price. Readers whose fields are all optional, like the order and balance ones, accepted the same%{}and answered{:ok, value}.oauth_token/3was the sharpest instance.decode_map/1returned%{}for a body that was undecodable or not an object, so a token exchange that had not returned a token reported{:ok, %{}}— success. The failure then surfaced at the next signed call as an authentication error, with nothing tying it back to the refresh that caused it.The realistic source is not malformed JSON from Webull. It is a
200that never reached Webull: an interstitial, a captive portal or a CDN maintenance page, each of which answers200 text/html.Success bodies now refuse with
{:error, {:undecodable_response, :webull}}, and a token response that decodes but is not an object refuses with{:error, :unexpected_response_shape}. Refusal bodies keep the lenient decode on purpose — a4xxis read for a human-readable reason, the status code has already established the refusal, and{:venue_error, status}stays true whether or not there was a reason in the body.
[0.4.15] - 2026-09-11
Added
script/check_doc_sources.shnow reports the age and provenance of this package's own capability claims.capabilities/0carriesmeasured_atandmeasured_againstbecause CLAUDE.md is explicit — "Declare what you measured, not what you assume. If it was measured, say when and against what." Both were populated and nothing read them: not the weekly checker, not a test, not a line of consumer documentation.That is the same shape as the
MANUALdocumentation rows before they were aged — a claim that quietly gets old while still reading as current. The check reports the age against the sameSTALE_DAYSthreshold, and reports a missingmeasured_againstas its own finding, because half the rule is not the rule.Reported, never enforced. A stale measurement is not a build failure; it is a venue nobody has re-checked, and only a person re-measuring can fix it.
usage-rules.mdtells consumers those fields exist and what to do with them. They ship inside the Hex tarball and are what a consuming agent reads, and none of the five mentioned provenance at all. The distinction worth acting on is not the date butmeasured_against: a figure measured live against the venue's API and one read off a documentation page and never probed are different kinds of claim, and this package's declarations contain both.
Fixed
The first version of that check would have reported
MISSINGon every venue, forever. It grepped formeasured_against: "…"on one line. Every venue in this family states the field as a multi-line<>concatenation, because the honest answer is a paragraph — which documents, which endpoints, measured live or read from a page. A single-line grep matches none of them.Recorded rather than quietly corrected, because the same mistake was made twice in the same hour: the reading that produced the check also concluded
measured_againstwas unset across the whole family, and came within one commit of replacing five accurate provenance statements — including this one's — with a flat "not probed against the live API". Fordp_exchange_geminithat would have been false: its statement records timeframes and candle windows measured live againstapi.gemini.com. A checker that cannot see a value is not evidence the value is absent.The field is now detected by presence and reported as a
file:linepointer rather than quoted. These statements run to a paragraph each and the clause that matters is rarely the first one, so an excerpt in a weekly notice would mislead more than it informs.
[0.4.14] - 2026-09-11
Fixed
A refused MQTT CONNACK stranded the
subscribe/2caller, and a revoked App Key produces one on the very first call.subscribe/2on a shard that is still connecting parks the caller inshard.reply_toand waits foron_link_up/2. A CONNACK carrying 3, 103 or 104 (credentials rejected) or 105 (five concurrent connections per App Key) means thatlink_upis never coming — the socket staysconnected?: false— and nothing answered the caller. It waited out@call_timeoutand then exited, taking the calling process with it. So a consumer with a bad key got a crash instead of{:error, ...}.This is the most reachable stranded caller found in this sweep: it needs no failure at all beyond a wrong credential. Verified by driving it — after a refused CONNACK,
reply_towas still set andconnected?still false, with nothing left that could ever change either.The caller now gets
{:error, {:connection_refused, details}}, carrying the venue's own CONNACK code and, for 105,reason: :connection_limit. A caller has to tell "rotate the key" from "you already hold five sessions" — reporting a limit breach as rejected credentials is the exact confusionSocket's own comment warns about, one layer up.Those refusal notices did not carry a
session_id, so a multi-shardFeedcould not tell which shard the venue had refused.:link_upand:link_downcarry one precisely so it can. The three CONNACK-refusal notices did not, and that omission is why nothing could answer the parked caller. They were the same event class all along; only the successful one had been wired up.A CONNACK that never arrives is now bounded too.
Socket's:socket_connect_timeoutand:socket_recv_timeoutcover the TCP connect and the HTTP upgrade; once the WebSocket is up,websockexsimply waits for frames, so a venue that accepts the connection and then says nothing left the caller waiting with nothing to react to.@connack_timeout_msbounds it — derived, not picked: two thirds of@call_timeout, because a deadline at or past the caller's own gives up after the caller has already gone. Keyed bysession_idas well as shard index, so a timer armed for one attempt cannot answer a caller parked on the shard that replaced it.The socket is left running in both cases.
websockexreconnects on its own schedule and a later CONNACK may well succeed — a rotated key, or a concurrent session that has since expired. What must not persist is the caller's wait, not the connection attempt.
[0.4.13] - 2026-09-11
Fixed
A control-plane reconcile task that died or hung left its caller with no answer at all.
Task.Supervisor.start_child/2gives a child that is neither linked nor monitored, and the reconcile'stagcarries the caller'sfromon the primary path. So a lost task sent no{:reconcile_done, ...}, nothing here learned of it, and that caller waited out@call_timeoutand then exited, taking the calling process with it.The task is now monitored and bounded. A
:DOWNor a timeout is synthesised into the ordinary{:reconcile_done, tag, {:error, _}}shape and re-dispatched, so the caller and every retry ladder are answered exactly as they would be for a reconcile that failed by replying — a task that failed by dying is not a different kind of failure, and giving it its own path is how the two drift.Less severe than the sibling wedges fixed the same day in
dp_exchange_schwabanddp_exchange_coinbase, and the difference is worth stating. This venue has no "already in flight, join the queue" guard on the primary path, so each subscribe spawns its own reconcile and a lost task strands exactly one caller rather than every future one. Bounded — and still a caller that never gets an answer.@reconcile_timeout_msis@call_timeout * 4, deliberately above the caller's own deadline: a reconcile that outlives the caller waiting on it has already lost that caller, and firing sooner would only replace one lost reply with another while the request was still in flight. The task body is an HTTP round trip throughCore.HttpClient, which carries its own per-attempt timeout and retries; this is the outer bound for a task that has stopped answering entirely.One
:DOWNclause now serves both this and the subscriber-pruning added last release, because Elixir takes the first matching clause and there is no falling through — the monitor ref decides which.A racy test in
socket_malformed_close_test.exs, which CI caught and six local runs did not. The fake server closed the TCP connection in the same breath as sending the malformed close frame, so the FIN could reach the client and be observed before WebSockex had parsed the bytes ahead of it — andhandle_disconnect/2then reported%WebSockex.ConnError{original: :closed}rather than theFrameErrorthe test exists to pin.The server now blocks on a read and closes only after the client tears the connection down, which is the event the assertion is actually waiting for. Not closing at all was tried first and is wrong: no disconnect fires, because it is the client's own reaction to the bad frame that ends the connection.
Worth recording because of where it showed: green on every local run, red in CI, where fewer cores and
max_cases: 8change the interleaving. Reproduced locally by passing--max-cases 8, then six clean runs at that concurrency.
[0.4.12] - 2026-09-11
Fixed
A dead subscriber's pid was never removed, and the fan-out walked it on every message for the life of the feed.
Core.Fanout.resolve/1skipped a dead subscriber at send time, so no events accumulated for one — which is what the contract asks for, and it was true. What accumulated was the pid. Nothing monitored a subscriber or pruned one, so a supervised consumer that restarts left its old pid behind on every restart.That is linear cost on the hot path:
deliver/4walks the whole set and callsProcess.alive?/1per entry, per message. Measured indp_exchange_core0.3.3 —dead pids in set µs per fan-out 0 0.095 200 4.301 1000 22.842 — roughly 240× at a thousand accumulated pids, inside the one process every subscriber's data flows through.
Subscribers are now monitored, and a
:DOWNdrops the pid from every set it was in.A registered name is deliberately not pruned. A pid that has died is gone permanently, so removing it is always right. A name is not a process:
subscribe/2accepts one precisely so a consumer can restart under it, and a monitor fires when the current holder dies. Pruning on that would silently unsubscribe a consumer whose supervisor is about to bring it straight back under the same name — data loss with nothing to notice it by, which is worse than the leak. A name cannot leak anyway: the set holds one atom however many restarts happen.
[0.4.11] - 2026-09-11
Added
check_doc_sources.shnow checks whether its own manifest is COMPLETE. Everything it did before verified the sources that were listed; nothing verified that the list covered what this package'sdocs/reference/actually cites. A checker whose coverage nobody audits reports "all sources resolve" while saying nothing about the sources it was never told about.The gap was real:
dp_exchange_geminicited 22 distinct URLs and listed 12, leaving two genuine vendor documentation pages — the WebSocket streams introduction thatwebsocket-api-replacement.mdnames as its source, and one of the four API specificationsendpoint-inventory.mddiffs — unchecked by anything.Two classes, reported separately, because only one can be judged mechanically:
UNLISTED — cited on a host the manifest already names as documentation. Same vendor, same docs site, different page: near-certainly a source that belongs in the manifest.
UNKNOWN — cited on a host the manifest does not name at all. Deliberately not assumed to be documentation, because most are not:
api.gemini.com,api.sandbox.webull.comandapi.schwabapi.comare venue APIs, and adding one here would put a live venue into a weekly scheduled fetch. D7 is explicit that a venue seeing a package poll it on a timer will rate-limit or block. These are listed for a person to classify and never auto-added.Non-blocking, like the rest of the script: it prints and does not change the exit code. An unlisted page is a gap in evidence, not a broken build.
All three of this package's scheduled checkers were run by hand for the first time, and they pass. Every one of them had never executed: they are scheduled weekly for Monday and landed on a Tuesday, so no cron had come around. A checker nobody has watched run is a checker nobody has proved works — and running these found the manifest-coverage gap above, which is not the defect any of them was written to catch.
No vendor drift: every cited documentation source resolves exactly as recorded, and the committed endpoint inventories match the vendors' current indexes.
[0.4.10] - 2026-09-11
Changed
CI runs
mix test --cover --warnings-as-errors.mix compile --warnings-as-errorsalready coveredlib/, but test files are compiled bymix test, which had no such flag — so a compile warning in a test file was permanent and green. Together the two now mean no warning survives anywhere in the build.The argument is not tidiness. A handful of permanent warnings is exactly the noise a genuinely wrong one hides behind. The gap was found by running
script/check_dependency_floor.shby hand — a checker scheduled weekly that had never once executed, because it landed on a Tuesday and its cron is Monday — and reading what scrolled past.dp_exchange_core0.3.2 fixes five such warnings in the shared conformance suite, two of which were real defects that made every venue package noisy.Verified by injecting an unused function into a test file and confirming the run aborts: a gate nobody has watched fail is a gate nobody has proved.
Two dead things in this package's own tests, which were the only compile warnings in the whole family's suites.
feed_test.exscarried a copy ofdead_pid/0with no caller — the test that used it moved tofeed_terminate_log_test.exs, which has its own copy, and the helper stayed behind. Andfeed_terminate_log_test.exs'sconnected_shard/1had a default argument no call in that file ever used, since every one names its shard explicitly.Both are small, and the reason they are worth a changelog line is that they are what the new
--warnings-as-errorsgate had to clear before it could be turned on at all. Noise is only tolerable until it hides something.
[0.4.9] - 2026-09-11
Changed
dp_exchange_corefloor raised to~> 0.3.1. Core 0.3.0 deletedCore.DataProviderandCore.FeedBehaviour— two contracts with zero implementers, one of which was a second, competing definition of the venue interface carrying every shape this family has since fixed (prices as strings, providers as strings, balances with no timestamp, a single quote timestamp,{:error, String.t()}flattening the refusal/error distinction). A venue author who found it first would have built all of those, plausibly, and every one would have compiled.No code changes here: this package referenced neither module. The floor moves because a pin of
~> 0.2.8would not resolve 0.3.x — the pin doing its job, not a problem to route around — and because staying behind would leave this package on a Core that still ships the contradicting contract.Resolved and compiled against before the pin was written, per the rule this file's own dependency comment already records: a floor is only correct once it has been resolved, never once it has been reasoned about.
[0.4.8] - 2026-09-11
Added
This package now emits the
[:dp_exchange, :link, …]telemetry the contract has documented since it was written.Core.Telemetrysaid these are the events "every venue package emits"; there was not one:telemetry.execute/3call anywhere in the family for as long as the spec existed.:telemetry.attach/4against a name nobody emits succeeds — so a consumer wired a dashboard to it, got no error, and saw an empty panel, which reads as a venue with no traffic rather than as an unimplemented spec.:link, :upand:link, :downon the connection transitions, and:link, :eventper frame with its wire size. The request and rate-limit events come free withdp_exchange_core0.2.8, since every venue's REST goes throughCore.HttpClientand every metered call throughCore.DefaultRateLimiter.The metrics channel is alongside the notice channel, never instead of it. A
Core.Noticeis a condition a consumer must ACT on; telemetry is aggregate and lossy by design. A consumer that alarmed on a telemetry gauge would be acting on a channel documented as droppable, and one that graphed notices would be graphing something it is meant to handle.Two details worth stating, because both are places a plausible-looking number would have been wrong:
A frame is counted whether or not it parses. The question the event answers is "is the venue sending", and a frame this package could not read is still a frame the venue sent — counting only what parsed would make a decoder bug here look like a silent venue.
There is no
:link, :reconnect_attemptfrom this package. It reconnects immediately and keeps no attempt counter, so the only number it could report isattempt: 1, every time — which renders a reconnect loop as an endless series of first attempts. That is worse than no event.dp_exchange_schwabtrackslogin_failuresand does emit it.
Changed
dp_exchange_corefloor raised to~> 0.2.8, which is whereCore.Telemetry's emitter functions live. A venue calling:telemetry.execute/3directly would be naming events by hand in five places — five chances to write:link_upinstead of[:dp_exchange, :link, :up], with the drift invisible, since a wrong name emits successfully and simply never reaches a handler — and would be using a transitive dependency it never declared.
[0.4.7] - 2026-09-11
Added
Back-pressure: a slow subscriber no longer gets an unbounded mailbox.
Core.Venue'ssubscribe/2doc promised this from the day the contract was written, and no venue in this family implemented any of it — every one fanned out with a baresend/2and had never looked at a subscriber's mailbox. A consumer that stalled accumulated a mailbox until the node died, with no notice, no log line, andcoverage/1reporting perfect health throughout, because the feed genuinely was delivering.Past a bound (default 10,000 queued messages,
:max_queue_lenat start) this feed stops sending to that subscriber and emits a:degradednotice naming it, the queue length and the bound — and a secondseverity: :infonotice when it catches up. The pair brackets exactly the window a consumer has to reconcile from the pull endpoints.Implemented in
dp_exchange_core0.2.6 asCore.Fanout, shared rather than written five times. Three properties worth stating, because they are what make dropping acceptable at all: another subscriber that is keeping up is unaffected;coverage/1does not change, because it reports what the venue delivered to this package and not what this package forwarded; and notices are never subject to the bound, since the notice saying a subscriber is being dropped must not be the first casualty of that same subscriber being dropped.See
usage-rules.md, "A slow subscriber gets dropped, and told".
Changed
dp_exchange_corefloor raised to~> 0.2.6, and this one is hard.FeedcallsCore.Fanout.max_queue_len!/2atinit/1andCore.Fanout.deliver/4on every payload. Against a lower Core this package does not misbehave, it fails to compile — which is the good outcome.The pid-or-registered-name subscriber resolution moved to
Core.Fanout.resolve/1. All five venues had written it identically since DpCryptoManagement's issue #15; the data path and the notice path now share one definition, so they cannot drift into disagreeing about what counts as a reachable subscriber.
[0.4.6] - 2026-09-10
Fixed
A link drop flipped the shard's
connected?and leftcoverage/1answering:streamfor what that dead connection had been delivering.Socket.handle_disconnect/2returns{:reconnect, …}, so the socket process survives a transport drop and no:EXITever reachesisolate_crashed_shard/3— the one path that cleared delivery records. So between a drop and a successful resubscribe, that shard's symbols reported as arriving when they were arriving from nowhere; and where the reconnect restored the socket while the venue silently failed to restore a symbol, it reported:streamindefinitely.This package had already written down why that is wrong, on the crash path: "
coverage/1/coverage_by_kind/1must not keep answering:streamfor a shard that just crashed … until this,coverage/1itself kept lying in the meantime." The reason does not depend on what killed the link, so the:link_downclause now drops the same records the same way — scoped to that one shard, since the others are on their own sockets. The shard keeps its entry, unlike the crash path: it is reconnecting rather than dead, and its symbols return as frames arrive after the next resubscribe.dp_exchange_core0.2.5 writes the rule intoCore.Venue'scoverage/1doc — observation is scoped to the current transport session — and records why it cannot be carried by a conformance assertion. All four streaming venues in the family had this wrong in the same way and are fixed in the same batch.
[0.4.5] - 2026-09-10
Fixed
A venue-rejected symbol's 24-hour exclusion was timed on the wall clock, so it could outlive its own bound.
state.rejectedstored:os.system_time(:millisecond) + ttland compared against the wall clock again later. That is an in-VM duration — exclude this symbol for 24 hours from now — and the wall clock is not a duration source. An NTP step, a host resync after a bad RTC, or a VM resuming from a snapshot moves it backwards, and every unexpired rejection silently gains exactly that much extra life. The TTL is 24 hours precisely so a "the venue refuses this" belief has a bound; a belief that can outlive its bound by however far the clock jumped is the bound not holding. A forward step is the mirror image — every rejection expires at once and the shard re-subscribes symbols the venue is still refusing.Both sites now use
System.monotonic_time/1, which is whatCore.PollingFeedalready computes its own staleness window with. Nothing in the facade changes; a symbol still returns to shard composition on the first reshard after its TTL, and now does so after the TTL it was given.Found by sweeping every clock read in the family. This was the only in-VM duration still measured on the wall clock. The auth-token expiries and the request nonces sitting beside it are wall-clock on purpose — those instants come from the venue and are compared against the venue's clock, not ours — and were left alone. The
deliveringtimestamps in this and three sibling feeds are written and never compared, so their clock source decides nothing today; that is noted here rather than changed, since changing it would be motion without a defect behind it.
[0.4.4] - 2026-09-10
Fixed
Every venue notice arrived with
message: nilwhile the venue's own words sat indetails.venue_notice["content"](dp-exchange-core issue #33). A consumer renderingnotice.messageprinted nothing while this package held the text — 230 times in 66 minutes on the reporter's node, over "Permission grabbed by other session, category : us-crypto", which is not a line to make look like noise.:messageis now set from the venue's field, with the raw body still in:details.handle_packet(state, {:connack, 105})already did this; thenoticetopic path simply never had."content"is an observed key, not a documented one — the vendor publishes that the topic carries JSON and never publishes its field schema, so this is labelled as such at the code. Reading it is safe rather than a guess: absent or non-string yieldsnil, the raw body is untouched, andnilkeeps meaning the venue sent no text — which is the right answer for the code-only shape the same reporter saw alongside it.A notice that is not JSON at all used to be dropped silently. That is the same defect one level down, so its text is now carried too — unless the payload is not valid UTF-8, in which case there are no words to keep and forcing them into a log line only produces mojibake that reads like a bug in whatever renders it.
Added
:resubscribe_interval_msis a start option, defaulting to the previous hardcoded 60_000 and validated atinit/1.It exists because of the second half of that issue. The reporter asked whether this package's four shards are taking the
us-cryptocategory from each other, and the rate is suggestive: four connected shards re-asserting once per 60 s is 4/min, against their measured 230 in 66 minutes (3.48/min). Arithmetic agreeing is a hypothesis, not a finding, and nothing here can probe a venue that needs a credential this repository must never hold.So what shipped is the knob that lets them settle it: raise it to 300_000 and the notice rate should fall to a fifth if this timer is the trigger, and not move at all if it is not.
dp_exchange_coinbasealready exposed the identical option — this venue keeping it private is what made a live degradation undiagnosable from outside.
[0.4.3] - 2026-09-10
Documentation
Rest.wide_timeframes/0's reasoning is now the family's written rule, not a local inference. It explained why a width reachable on any path belongs in a flathistorical_timeframes— correctly, and before anything said so.dp_exchange_core0.2.4 writes that rule intoCore.Capabilitiesand cites this function as its worked example, so the doc here points at it instead of re-deriving it.The half worth keeping visible is the second one: a union declaration is only honest because
get_historical_prices/5fails closed —{:error, {:unsupported_timeframe, _}}for a crypto or event-contract category — rather than degrading to the nearest width it does serve.
[0.4.2] - 2026-09-10
Fixed
No published version was attributable to a changelog entry (dp-exchange-core issue #32). Every entry in this repository's
CHANGELOG.mdsat under## [Unreleased]— in the published tarball, sinceCHANGELOG.mdships inside it — so a consumer could not tell which version introduced a breaking change, or whether they had already taken one.That mapping is load-bearing here rather than cosmetic. This family signals a breaking change with a minor bump, and those changes are repeatedly a refusal tuple or struct gaining a field: invisible to the compiler, and invisible to a test that pins the old shape. The reporting consumer's written upgrade procedure is "read
CHANGELOG.mdfor a### Changed — BREAKINGsection, then grep for every clause matching the old shape" — which needs version → change. Without it,### Changed — BREAKINGsays that the shape changed and never whether they already have it.They gave two incidents from the same three days, and the difference between them is the whole argument:
dp_exchange_gemini0.1.42's refusal-shape change was found after shipping, by reading a fix comment, whiledp_exchange_webull0.4.0's was caught before — because that entry happened to name the version in its prose.Two halves, because fixing only one would have let it recur immediately:
- Going forward, the release pipeline cuts a
## [x.y.z] - YYYY-MM-DDheading itself, in the publish job and beforemix hex.publish— a heading added after the upload would describe a tarball nobody can read. - Retroactively, the accumulated block now sits under a
## [<version>] and earlierheading. Attributing each of ~1,600 lines to the exact release that carried it is archaeology; this restores the one fact a consumer needs from it — that none of it is pending — which is what the reporter suggested.
The issue measured five packages, from their
deps/.dp_exchange_schwabhas the same defect and is not one of their dependencies, so it could not appear in their table: six instances, all fixed here.- Going forward, the release pipeline cuts a
[0.4.1] and earlier - 2026-09-10
Everything below this line is published. Entries were accumulated under
[Unreleased] from the first release to 0.4.1, so no reader could tell shipped work
from pending — dp-exchange-core issue #32. Attributing each entry to the exact version
that carried it would be archaeology across hundreds of releases; this heading restores
the one fact a consumer actually needs from it, which is that none of it is pending.
Releases from here on cut their own ## [x.y.z] heading at publish time, so this is
the last block that will ever need a range.
Changed — BREAKING
A refusal now carries the venue's HTTP status as well as its words.
{:refused, {:venue_error, 401, "signature mismatch"}}, where it used to be{:refused, {:venue_error, "signature mismatch"}}.This package refuses on
400,401and403, and all three arrived at a caller looking identical — while their remedies are opposite: a400means fix the request, a401means refresh the token and call again, a403means a person must change what the credential is entitled to. The clause raising the refusal even documents the401remedy in a comment, so the code knew which status it had matched and then discarded the only thing that could tell a caller which one it was.An unrecognised body keeps its status too —
{:venue_error, 403}rather than a bare:refused. Thin, but it is the difference between "the venue rejected this and here is which kind" and "something went wrong".oauth_token/3was worse still: it returned the raw body as the reason, with no classification at all, on a call where400(bad grant) and401(bad client credentials) again mean different things.This venue was the outlier.
dp_exchange_coinbase,dp_exchange_robinhoodanddp_exchange_schwaball already used{:venue_error, status, detail}. Found by sweeping the family for the principle a consumer named on dp-exchange-gemini#1 — a known reason is never less informative than an unknown one — generalised to: a caller must never be handed less than this package already had.If you pattern-match refusal reasons, check that clause before upgrading. A caller matching
{:venue_error, message}now falls through to whatever catch-all follows it, silently, with nothing raising. That exact trap cost a consumer a canonical error mapping ondp_exchange_gemini, which is why it is called out here rather than left to be found.
Documentation
usage-rules.mdnow answers the question a consumer actually has after 0.2.0: when isvenue_timenilhere? The migration note said what the fields mean; it did not say what this venue does with them, which is the part a caller writes a branch for.Never, on this venue: every
QuoteandOrderBookparses a time the venue sent and fails closed when it cannot, so anilbranch here is dead code.
Changed — BREAKING
Core.Types.QuoteandCore.Types.OrderBookno longer carry:timestamp. They carry:venue_time(the venue's own,nilwhere the venue publishes none) and:observed_at(when this package read it, always present). Requiresdp_exchange_core ~> 0.2.1; this package's own version takes a minor bump to signal it.:timestampwas documented as the venue's own and "never invented", and two packages in this family could not keep that promise, because the frames they decode carry no venue time at all. With one field their only options were to lie or drop real data, and they lied.This venue always had a venue time to give, on every
QuoteandOrderBookit builds, so:venue_timecarries exactly what:timestampdid and:observed_atis new information rather than a replacement.The full reasoning, the three options weighed and the consumer's own argument for this one are in
dp_exchange_core'sdocs/design/closed/2026-09-09_venue-time-and-observed-time.md, announced and answered as dp-exchange-core issue #31.Trade,Fill,BalanceandOrderBookDeltaare unchanged.
Added
script/check_endpoint_inventory.shanddocs/reference/webull/endpoint-pages.txt— a weekly, non-blocking diff of the vendor's published endpoint reference pages against a committed capture, via.github/workflows/inventory-check.yml. One HTTP request: this vendor publishes no machine-readable specification, but itssitemap.xmllists onedocs/reference/*page per endpoint, so that set is the closest thing to an operation list available here.This venue is why the mechanism exists. The identical sitemap search found
developer.webull.com/apis/docs/rate-limits/— a per-endpoint rate-limit table that had been published since 2026-08-14 while this package declared a REST ceiling five times too permissive, against a venue whose documented penalty for exceeding it is a temporary IP block. Nobody found it for weeks because nobody was comparing indexes. A page appearing here now says so.Baseline: 248 reference pages, captured 2026-09-09, unchanged on the check's first run.
Fixed
- Reads now carry
@call_timeoutexplicitly, exactly as writes already did.coverage/1,coverage_by_kind/1,status/1andwanted/1tookGenServer.call/2's implicit five seconds while every write named a generous one, and that asymmetry is what turned a bounded delay into a dead caller in dp-exchange-core issue #28:coverage/1is the call a consumer's health check makes, so any moment the Feed was legitimately busy for longer than five seconds turned a health check into an exit — and into a dead consumer process, when the read happened inside the consumer's ownhandle_call/3. The blocking is fixed at its sources rather than papered over here; this is the second line of defence. A read that has to queue behind something should wait for it, not die of it.
Fixed
INVALID_SESSIONwas retried as a subscribe failure forever — dp-exchange-core issue #30. All four MQTT shards stopped delivering and the blind resubscribe timer kept firing against sessions the venue had already discarded: 1,479 identical warnings over fourteen hours, 260 of 325 symbols receiving nothing, and a manualFeedrestart as the only recovery.INVALID_SESSIONsays the session a subscribe is addressed to no longer exists, so re-sending that same subscribe is the one action guaranteed not to help — and nothing escalated from "subscribe failed" to "reconnect", so it could not recover in-process.Two causes, both fixed.
Subscriptioncollapsed the venue's answer into the opaque{:exchange_error, :webull, "HTTP 417: ..."}string, whichFeedcould log and could not match on; a417carryingINVALID_SESSIONis now{:error, {:invalid_session, session_id}}, with the dead session id parsed out of the venue's prose ("...not exist for session:<id>") because on a sharded venue three of four sessions may be perfectly fine. A message this parser cannot read still returns:invalid_sessionwith anilid — the recovery does not depend on the id, and refusing to name the failure over a missing detail would restore the fourteen-hour loop.Feednow rebuilds the shard rather than retrying it, through the recovery it already had for a crashed socket: drop the shard and reopen it, which mints a fresh session id inopen_socket/2. It has to be a reopen and not a retry, because the session id is minted when the socket opens — there is no subscribe-level fix available at all. Handled in the sharedhandle_subscribe_result/3funnel, so every subscribe path recovers the same way; the blind resubscribe is merely where it was first observed.Two details that would be easy to get wrong, both recorded at the code: the shard leaves
state.shardsbefore its socket is stopped, so the resulting{:EXIT, ...}finds no shard and cannot drive a second concurrent rebuild throughisolate_crashed_shard/3; and the socket is stopped, not abandoned, because unlike a crash the process is very likely still alive holding a connection the venue has already discarded — this venue allows five concurrent connections per App Key, so leaking one per stale session would turn a recoverable outage into an unrecoverable one.It is reported as a
:link_downnotice namingINVALID_SESSION, rather than latched as a generic resubscribe failure. The reporting consumer's point stands on its own: the only signal before this was a WARN line saying the symbols were stuck, offering no path out. This is the third failure mode of the resubscribe path — #17 added it (symbols going quiet on a live session), #23 fixed it failing throttled, and this is the session underneath it being gone.A note on why it stayed invisible for fourteen hours, worth carrying: the failure is partial by construction. Dead sessions strand their symbols while surviving ones keep delivering, so venue-wide "has this gone quiet" answered no the entire time — the reported
minstream age was 81s while the p50 was 3,497s.
Documentation
Credentials' moduledoc now says that the redaction wrap lives inchild_spec/1, and that bypassingchild_spec/1bypasses it. Requested by the consumer who verified the dp-exchange-core #29 fix and then went looking for their canary in their own supervisor's state — and found it. Their supervision code builds the child spec itself (start: {__MODULE__, :start_feed, [module, opts, pairs]}) for a legitimate reason: aCore.PollingFeed-shaped facade defaultssubscribertoself(), which resolves to the supervisor whenstart_link/1is called frominit/1, so a different delivery target can only be set atstart_linktime. On that pathchild_spec/1never runs, their supervisor stores the raw map, and OTP renders the live key on the next crash exactly as before. Upgrading does not fix it, because nothing from this package is on that path.No code change:
wrap/1andwrap_opt/1were already public, which was all that path needed. What was missing was anyone saying so — the natural assumption, "upgraded, therefore redacted", is wrong there, and assertion 22 cannot see it because it asks aboutchild_spec/1's own rendering.dp_exchange_core'susage-rules/auth.mdcarries the full version, including the reshaping case that bit them: a host mapping its own key names into a venue's and returning a bare map re-introduces the leak in its own code, downstream of anything a package can reach.
Fixed
Credentials were written to the log in cleartext by any crash — dp-exchange-core issue #29. A supervisor stores the
{module, :start_link, [opts]}MFA its child spec names, and OTP writes that argument list throughinspect/1into theStart Call:line of the report it logs on any child termination.:credentialsarrived as a plain map, so every crash printed the live secret in full. It needs no unusual conditions, it lands in ordinary application logs — the artifact most likely to be shipped to an aggregator or attached to a bug report — and it defeats credential hygiene upstream of it: a consumer can hold the key encrypted at rest and still have it written out in the clear. The reporting consumer found live keys this way and nearly pasted them into a GitHub issue while reporting a different bug.child_spec/1now wraps:credentialswithDpExchange.Webull.Credentials.wrap_opt/1, and the placement is the fix: wrapping instart_link/1orinit/1does nothing, because by then the supervisor above has already captured the raw list. Redacting the value rather than setting the:sensitiveprocess flag is deliberate — that flag suppresses the whole report, including the stack trace that made the unrelated bug diagnosable. This keeps the report and removes only the secret.dp_exchange_core's conformance suite gains assertion 22 for exactly this, so it cannot come back here or arrive in a new venue.
Fixed
The REST ceiling was five times too permissive, and the entry below it in this same unreleased section is what made it so.
public_ceiling/authenticated_ceilingare now%{limit: 60, per_ms: 60_000}— 60 requests per 60-second window, the venue's own units — replacing the%{limit: 5, per_ms: 1_000}the next entry describes arriving at. That5came from Webull's Market Data FAQ ("a rate limit of 300 requests per 60 seconds"). The FAQ is real and still says that. It is also contradicted by the venue's own per-endpoint rate-limit table,developer.webull.com/apis/docs/rate-limits/, which caps every market-data endpoint — crypto, stock, option, future, event contract, display and non-display, including the HTTPstreaming/subscribecalls — at60/60sin production and30/60sin sandbox. A second, independently maintained set of pages corroborates it in different units: each endpoint's own reference page states "1 request per second per App Key", which is 60/60s.The venue contradicts itself, so this was decided by rule, not preference: the per-endpoint table is more specific, newer, states its own units, is corroborated, and is the stricter of the two. Fail closed. The asymmetry matters more than usual here — that page also states that repeatedly exceeding a limit "may result in temporary IP-level blocking", so guessing high does not cost latency, it costs the connection.
This number has now been wrong twice in a row, in the same direction, and the second time it carried a citation. That is the part worth keeping: a citation makes a number checkable, it does not make it right. Operationally the tightening costs nothing —
Subscription.subscribe/3batches symbols into one call per shard rather than one per symbol, andget_price/3/get_top_of_book/3are per-symbol calls a consumer paces itself.UAT was metered against production's budget.
Supervisor'slimits/1(waslimits/0, now takingopts) halves the declared ceiling to30/60swhenEnvironment.resolve/1says:uat, which is the venue's own relationship between its two columns rather than a margin invented here. The Supervisor already gave production and UAT separately named limiters precisely so one could not spend the other's budget; handing both the same limits left that separation cosmetic in the direction that bites — UAT pacing itself against an allowance the sandbox refuses, surfacing as a429on a test run with nothing pointing at the cause.capabilities/0still declares the production figure and can declare nothing else: it takes no arguments.limits/1is now public and documented, matching its already-public siblingslimiter_name/1andfeed_name/1, so the derivation is checkable;test/dp_exchange/webull/rate_ceiling_test.exspins both environments and the burst that follows each.A cited vendor page 404s, and that is how all of the above was found.
docs/reference/webull/rest-rate-limits.mdcited.../docs/reference/option-market-data/; the page isoptions-market-data, plural. Three bullets in that file carried per-endpoint figures — "40 requests per 2 seconds" for orders, "600 requests per minute" for futures, "60 requests per minute" for option tick data — each attributed to a reference page. Every reference page on this vendor's site was fetched and searched: none of them contains any rate-limit text of that form. Those bullets are deleted rather than corrected — unsourced numbers wearing a citation are worse than missing ones. The real per-endpoint figures, from the table that does publish them, are now recorded in full.public_ceiling/authenticated_ceilingwere%{limit: 10, per_ms: 1_000}with no comment and no mention anywhere incapabilities/0's ownmeasured_againststring — unlabelled and inherited, and load-bearing:Supervisor's privatelimits/0feeds this straight into the realDefaultRateLimiterthis package starts, so the unexamined number was actually throttling (or failing to throttle) every REST call. Found by a family-wide sweep for the@pairs_per_socket/@shard_spacing_msdefect class indp_exchange_coinbase— an unverified number sitting where a cited one belongs. Webull's own Data API FAQ (developer.webull.com/apis/docs/market-data-api/faq/, read 2026-09-08) states "a rate limit of 300 requests per 60 seconds" —300 / 60 = 5— nowdocs/reference/webull/rest-rate-limits.md. Both ceilings are now5, not10; every endpoint here is signed (credential_benefit: :required), so there is no separate public/authenticated figure to distinguish. This lowers the rate this package will request against the venue — a caller running close to the old, unverified10may now see requests queue that previously went straight through — but the old number rested on nothing, and this one rests on the venue's own stated ceiling. Two smaller citation gaps found in the same sweep, values unchanged:Rest.place_orders/3's 50-orders-equities-only batch limit now citesdocs/reference/webull/batch-orders.md(previously "its page says so" with no committed source);Rest.get_order_book/3'sdepthdefault of10is now labelled as generalised from the event-contracts endpoint's documented default rather than confirmed for the stock/futures endpoints it actually applies to — the stock endpoint's own parameter table has never been captured (JS-rendered, same limitationnegative-claims.mdalready records elsewhere), and the futures endpoint's own page statesdepthis1–10, requiredwith no default, making the prior "L2 defaults to 10" doc comment an overgeneralisation for that case.A malformed WebSocket close frame from Webull crashed a shard's socket before
handle_disconnect/2ever ran — dp-exchange-core issue #27. Webull sometimes closes this venue's MQTT-over-WebSocket connection with a close frame carrying prose where RFC 6455 §5.5.1 requires a 2-byte status code — measured live:<<136, 10, 98, 121, 101, 45, 98, 121, 101, 33, 33, 33>>, i.e."bye-bye!!!". The first two bytes parse as"by"= 25209, outside every valid close-code range (1000–1015, 3000–4999).WebSockex.Frame.parse_frame/1correctly returns{:error, %WebSockex.FrameError{reason: :invalid_close_code}}for this;websocket_loop/3one level up matched only{:ok, frame, buffer}and:incomplete}even thoughparse_frame/1's own@specnames the error tuple as a real return value, so the process raised an uncaughtCaseClauseErrorand died before any callback ran —Socket.handle_disconnect/2never fired. Measured consumer impact: 117 crashes in 7 minutes across three shards, roughly one close every 10 seconds per shard, sustained.Feed's own supervision reopened each shard, so nothing alarmed; the venue simply never reached full coverage (one consumer's fresh-tick count oscillated between 76 and 243 against a 325-symbol scope, all day).websockex 0.5.1 is the latest Hex release and still has this; confirmed the same unguarded
caseis still present, unreleased, ondominicletz/websockex's currentmaster(2026-09-08) — the maintained upstream repo (Azolo/websockexis abandoned). There is no upstream fix to wait for.Fixed by vendoring, not by switching transport. A transport swap (
Mint.WebSocket,:gun,fresh) was evaluated and rejected: this package already owns its transport by design (CLAUDE.md: "Core ships no venue-specific dependency… a venue that speaks WebSocket ships what it needs to speak it"), but websockex already supplies TCP/TLS connection handling, the HTTP upgrade handshake, frame fragmentation reassembly and automatic reconnect — replacing all of that to work around a two-line defect would have meant hand-rebuilding a WebSocket client on a production incident's timeline, a strictly larger and riskier change for the same outcome. Intercepting the buffer beforeWebSockex.Frame.parse_frame/1sees it was considered and ruled out on inspection, not assumption: every byte from the socket reacheswebsockex's own private process loop directly (WebSockex.Conn. controlling_process/2+set_active/2), with no seam in the callback model for a consumer to sit in front of it.A git-dependency fork was rejected because Hex refuses to publish a package that depends on git — shipping a fork this way would mean publishing an entire separate package under a new name on Hex, a maintenance commitment (a new repo, its own CI, its own releases, tracking every future upstream websockex release for anything beyond this one fix) out of proportion to a two-line defect, and one this package's own authority does not extend to unilaterally taking on.
Instead,
lib/vendor/websockex.exvendorswebsockex0.5.1's single process-loop file (not the whole package —WebSockex.Frame,WebSockex.Conn, WebSockex.Utils (hidden from its own docs, so plain text here),WebSockex.Applicationand everyWebSockex.*Errorstruct stay the real, unmodified dependency; none of those carried the bug), private to this package, underDpExchange.Webull.Vendor.WebSockex— never bareWebSockex, so a consumer who also depends on the realwebsockexpackage (directly, or through another venue in this family) is unaffected. Exactly two things changed from upstream, both marked## VENDORED FIXat their call site: (1)websocket_loop/3gains a clause for{:error, %WebSockex.FrameError{}}, routed through the already-existinghandle_close({:error, reason}, ...)pathsync_send/5already used for a send-time framing error — RFC 6455 §7.1.7's specified response to a peer's malformed frame is to close the connection, not crash reading it, and that path already sends this side's own close frame and runs the ordinary disconnect flow, so no new recovery logic was written; (2) WebSockex.Utils.spawn/5 (hidden from its own docs, so plain text here) hardcodes the literal atomWebSockexas its:proc_libentry module, which would have booted every vendored socket into the real, unpatched module regardless of the rename —do_spawn/2is reimplemented locally using__MODULE__to close that one gap. See the vendored module's own moduledoc for the full incident, and an upstream PR was opened againstdominicletz/websockexalongside this fix (it cannot be the fix that ships today, but it is worth landing regardless).mix.exsnow pins{:websockex, "== 0.5.1"}exactly rather than~> 0.5.1: the vendored file callsWebSockex.Conn's andWebSockex.Frame's functions the same private-in-spirit way the original did, an internal API those modules never promised to keep stable across releases the way their own public behaviour is — a~>floor would invite the exact failure this same dependency already caused this family once (thesend_frame/3arity floor, this file's own earlier entry).lib/vendor/sits outsidetest/dp_exchange/webull_contract_test.exs's (narrowed)package_root,.credo.exs's scanned paths andmix.exs's coverageignore_modules— all for the same reason: this is third-party code carried for a two-line necessity, not code written to this family's own conventions, and none of those checks can see the OTP dynamic dispatch (:proc_lib,:sys) that keeps several of its exports legitimately public with no caller:xrefcan find.Proven by
test/dp_exchange/webull/socket_malformed_close_test.exs: a realSocket.start_link/1against a real local TCP server that completes the WebSocket handshake and then sends the exact 12-byte frame from the incident. Confirmed to fail with the pre-fixCaseClauseErrorwhen the fix's one clause is removed, and to pass with it restored —handle_disconnect/2runs (:link_downnotice received, naming theWebSockex.FrameErrorand:invalid_close_codereason), and the socket process is still alive afterward rather than crashed.Why the venue closes the connection at all — investigated, not fully resolved. Fixing the crash turns each of these into a clean reconnect instead of a crash, but does not by itself explain why Webull sends this close so often. Ruled out with evidence already in hand before this fix: the 60s keep-alive/30s
PINGREQschedule cannot explain a ~10s close cadence, and shards do not share an MQTT client id (each socket, and each reopen, gets its owngenerate_session_id()), so the venue's documented "a new connection with the same session_id kicks the previous one" rule does not apply at the shard level. The consumer's own hypothesis — a crashed socket never sent a clean MQTTDISCONNECT, so the broker held the dead session for its documented ~1 minute retention window while shards kept reopening, and enough overlapping zombie sessions eventually hit the "5 concurrent connections per App Key" ceiling (Webull's own docs: exceeding it returns error code 105, and duplicate/expired sessions get kicked with no more specific reason given at the WebSocket layer) — is plausible, evidence-consistent, and testable, but not established: this repo holds no Webull credential and cannot run it live. This fix is the fix that makes the hypothesis testable at all, since before it no shard ever sent a cleanDISCONNECTin the first place.The exact probe, for a consumer running this live (as the reporting consumer has offered to): after upgrading, watch a shard's
:link_downnotices across an extended run. If the ~10-second-per-shard close cadence stops or drops substantially, the zombie-session hypothesis is confirmed. If it persists at the same rate with clean reconnects now happening every time, the hypothesis is falsified and the cause is something else — a concurrent-connection cap behaving differently than documented, an idle-connection policy independent of MQTT keep-alive, or something the CONNECT packet itself triggers — and would need a second, separate investigation with the close reason now visible on every occurrence (this fix logsWebSockex.FrameError's full detail on the malformed case; an unrelated future close would carry its own, differentreason, distinguishable in the same:link_downnotice).dp_exchange_corewas pinned to~> 0.1.48, a floor this package has not actually run against sincecapabilities/0started declaringno_venue_contact: [{:get_fees, 2}].Capabilities.new/1builds the struct withstruct!/2, andno_venue_contactis a key Core only defines from 0.1.68 — any lower resolution raisesKeyErrorthe first timecapabilities/0is called, which is every conformance run and every host that calls it during venue discovery.~> 0.1.48compiled and every test passed because CI always resolves the newest allowed version (0.1.68, permix.lock); a consumer whose own dependency graph forced an older Core would not resolve that. Found in a family-wide audit of declared-vs-actual dependency floors, prompted by the same defect class already fixed inwebsockex(~> 0.4→~> 0.5, next entry below — which itself needed a second correction the same day; see that entry). Raised to~> 0.1.68. This also coversTimeframe.nameable/0admitting1y, needed since Core 0.1.57 (see the "capabilities/0 withheld 1y" entry below) — 0.1.68 is the binding constraint of the two. A new test inwebull_test.exsasserts the resolved Core'sCapabilitiesstruct defines:no_venue_contact, so a future loosening of this pin without a matching code change fails loudly instead of only failing for a consumer.websockexwas corrected twice in one day, and the first correction was itself wrong.Socket.disconnect/2callsWebSockex.send_frame/3;mix.exsdeclared~> 0.4, which does not have that arity at all. The first fix (0646201, this package's own commit, undocumented here at the time — a gap this entry also closes) raised the floor to~> 0.5, reasoning that the thirdsend_frameargument "only exists from 0.5". It does not:send_frame/3is new in 0.5.1, and~> 0.5still permits0.5.0, which lacks it — confirmed by reading both resolved sources directly (deps/websockex/lib/websockex.exdefines onlysend_frame/2at0.5.0; the same file definessend_frame(client, frame, timeout \\ 5_000)at0.5.1). Caught byscript/check_dependency_floor.sh, added the same day family-wide to close exactly this blind spot, on its first real run against this package: resolving~> 0.5to its actual floor reproduced the originalWebSockex.send_frame/3 is undefined or privatewarning against0.5.0itself. Raised to~> 0.5.1. Nomix.lockchange was needed — this package already had0.5.1locked; only the stated floor, and the comment reasoning about it instead of resolving it, were wrong. Seedocs/design/closed/2026-09-08_dependency-floor-check.mdindp_exchange_corefor the full family-wide writeup.This reverts and corrects
get_fees/2's credential gate, added in the "Fix Fake credential gate (Core 0.1.57 assertion 17)" entry below. That earlier entry was wrong about this one endpoint.get_fees/2builds no request — it answers a crypto spread rate captured from Webull's own published pricing (source: :published_rate) — and yet, since that sweep, bothRest.get_fees/2andFake.get_fees/2refused with{:error, {:missing_credentials, :webull}}when called without one. The sweep's reasoning was that the real path "had never run throughAuth.headers/2", which was true and was exactly the point: there is nothing here to sign. The fake should have been matched to the real path's ungated behaviour; instead the real path was gated to match the fake's incorrect refusal.Reported by a consumer (DpCryptoManagement) who resolves venue fees to score candidate strategy genomes before any account is attached — no credential exists at that point by design. With
get_fees/2unanswerable without one, theirround_trip_bpscomputation lost its input and their fee-overcome admission gate, a live-trading gate, could not run.Now
get_fees/2answers unconditionally on bothRestandFake, matching its pre-sweep behaviour.credentialsis accepted for shape parity with every other callback and never inspected. A swept audit of the other twelve callbacks the same commit gated (get_accounts/2,get_balances/2,get_positions/1,get_transfers/2,get_transactions/2,quantization/2,place_order/3,place_orders/3,preview_order/3,replace_order/4,cancel_order/3,get_order/3,get_orders/2) and the twelve gated by "Gate the widened Fake surface on credentials" (get_option_chain/2,get_option_expirations/2,list_watchlists/1,get_watchlist/2,create_watchlist/3,update_watchlist/2,delete_watchlist/2,get_financials/3,get_corporate_events/1,get_filings/2,get_news/1,get_screener/2) confirmed every one of them reaches the venue throughRest's signedget/4orpost/4request path — the gate on all twenty-four is correct and stays.Regression test added asserting
get_fees/2answers{:ok, _}with no credential on bothRestandFake, so this cannot be re-broken by the next credential-gate sweep.Moved to
dp_exchange_core0.1.68, which resolves this the way the false positive is actually fixed rather than papered over: assertion 17 gainsCapabilities.no_venue_contact, a per-endpoint declaration a venue makes when a specific active endpoint's real implementation never builds a request to the venue.capabilities/0now declaresno_venue_contact: [{:get_fees, 2}].market_status/1answered{:ok, :open}unconditionally — true only for the one asset class this venue does not restrict to trading hours, and a lie for the other four. This venue is not crypto-only:asset_classes/0is[:crypto, :equity, :option, :future, :event_contract], and equities, options, futures and event contracts all trade on real, exchange-set hours. A consumer checking market status before placing an equity or options order was told the market was open at 3am on a Sunday. Found bydp_exchange_core0.1.66's widened assertion 17 (the credential gate): this venue declarescredential_benefit: :required, and the literal answered{:ok, _}with no credential because nothing here ever read one.Now
{:error, :not_supported}, declared:unsupportedincapabilities/0. Checked against the vendor's own documentation before deciding, per this package's own rule (fail closed; never substitute) rather than assumed: Webull's OpenAPI documents 85 endpoints (docs/reference/webull/endpoint-inventory.md) and none of them is a market-status or trading-calendar call. The one trading-calendar endpoint Webull publishes anywhere,GET /broker/master-data/trading-calendars/list, belongs to a different product entirely — the Broker API, served frombroker-api.webull.comrather than this package'sapi.webull.com, reachable only with its own broker-tier credential obtained through a separate business relationship this package has none of. Even setting the credential question aside,market_status/1carries no symbol or asset-class argument — it answers ONE status for the whole venue, and this venue spans five asset classes with different calendars, so no single value could ever honestly describe it, reachable endpoint or not. Full reasoning indocs/reference/webull/negative-claims.mdandDpExchange.Webull.market_status/1's own doc.Breaking for any consumer routing on
market_status/1. A caller that previously got{:ok, :open}now gets{:error, :not_supported}and must treat this venue as not answerable for market status, the same way it already must fortest_connection/2andget_rate_limit_status/2.Regression tests added in
webull_test.exs(describe "market_status/1"); the existingcapabilities().endpoints[{name, arity}] == :unsupportedsweep inwebull_test.exs's "the declaration" tests now covers it automatically.fake_test.exsandfake_injection_test.exsupdated:market_status/1no longer has a real success path, so it is removed fromFakeInjection's whole-call injection surface, the same waytest_connection/2andget_rate_limit_status/2already are.Fake.get_corporate_events/1andFake.get_news/1checked credentials before the argumentRest's own equivalents require first, the reverse ofRest.get_corporate_events/2andRest.get_news/2's own order (both runrequired_symbol/1/required_symbols/1before anything reachesAuth.headers/2). Calling either with neither the argument nor credentials answered{:error, {:missing_credentials, :webull}}here and:symbol_required/:symbols_requiredfor real — a narrow but real way this fake was differently capable than the venue it stands in for. Found by a cross-package audit. Both now check the argument first, matchingRest's order exactly.A crash of
Feedprinted theapp_secretHMAC-SHA1 signing key — and, if present, the account'saccess_token— in cleartext, in OTP's own crash report.Feedkeepsstate.resubscribe_optsfor its entire lifetime so a reconnect or a rebalance can replay a shard's subscription, and that keyword list's:credentialsentry was a bare map.replayable/2also let a fresh:credentialsfrom a latersubscribe/2orupdate_symbols/2call overwrite it — unwrapped — on every call that supplied one. OTP's default crash report prints aGenServer's state in full on termination, so a crash ofFeedlogged the signing key in cleartext — verified by crashing an equivalent process holding%{app_key: "...", app_secret: "...", access_token: "..."}as a bare field and reading the resulting log line back.Process.flag(:sensitive, true)was tried as an alternative and does not help: the same crash, with the flag set, printed the same cleartext state.Now
Feedwraps the triple inDpExchange.Webull.Credentials, a struct whoseInspectis derived withexcept:naming all three fields, at both entry points (init/1andreplayable/2) —replayable/2's wrap is conditional on the key actually being present in the caller's own opts, because an unconditional default would have insertedcredentials: niland letKeyword.merge/2silently discard the already-wrapped credentials sitting instate.resubscribe_optson every call that did not itself supply fresh ones. Nothing downstream changes: a struct is a map, soAuth.headers/2's%{app_key: k, app_secret: s} = credentialsstill binds the real values inside the one function that has to sign with them, andapp_key_from/1's%{app_key: app_key} when is_binary(app_key) -> app_keystill extracts it forSocket.start_link/1. Re-verified against a real crash of the new shape: the log line now readscredentials: #DpExchange.Webull.Credentials<...>.Socket's ownstate.app_keyis deliberately left as a bare string —app_keyis sent as a plaintext header (x-app-key) on every signed request this venue accepts, so it carries none of the confidentialityapp_secret/access_tokendo, and wrapping it would add a call-site change for no reduction in what a crash ofSocketactually exposes.coverage/1/coverage_by_kind/1kept reporting:streamfor a shard's symbols after that shard's socket crashed.isolate_crashed_shard/3(added for the W2 fix, see below) already rebuiltstate.shardsand fanned out a:link_downnotice on a shard crash, but never touchedstate.delivering/state.delivering_by_kind— the same gap the WS-level:link_downhandler has (it only flipsconnected?: false). A symbol whose only shard had just crashed kept reading as delivering until something else happened to overwrite its entry. Found by a 2026-09-07 supervision audit that asked directly whethercoverage/1tells the truth immediately after a crash. Now cleared inisolate_crashed_shard/3the same wayunsubscribe/2already clears a departing symbol's delivery record — a symbol whose only shard just died has exactly as little arriving for it as one that was never subscribed.FeedTest'sterminate/2describe block asserted onExUnit.CaptureLogcontent underasync: true, and lost the race on seed 42.capture_log/1,2's isolation depends on a global swap of the Logger backend's output device; a content assertion raced against it is exactly the case concurrency breaks, and this file has 60+ other tests logging at:debugalongside unrelated HTTP request logging elsewhere in the suite undermax_cases: 20.refute log =~ "skipped"failed against a blob of another test's log output that happened to contain it. Split intoFeedTerminateLogTest,async: false, which removes the race structurally rather than narrowing the substring or widening a timeout — the underlying mechanism genuinely is not concurrency-safe for content assertions. Found by a cross-package audit running the full suite on multiple explicit seeds, which this family's CI does not do by default.child_spec/1did not declaretype: :supervisor, so OTP defaulted it to:worker— which also defaults:shutdownto5_000ms instead of:infinity. A consumer terminating this child gave the whole nested tree (shards, MQTT sessions, rate limiter) only five seconds to shut down gracefully before:kill, rather than letting it unwind on its own terms. Invisible to any single-package review, and found only by diffingchild_spec/1across all five venue packages against each other;dp_exchange_schwabwas the only one that already declared it.capabilities/0withheld1y, a width this venue serves, three Core releases after the reason stopped applying.historical_timeframeswasRest.wide_timeframes() -- @core_unnameable_widths, where the subtracted list was~w(1y). That workaround was correct when written:dp_exchange_core0.1.48'sTimeframe.nameable/0had no entry for1y, soCapabilities.new/1raised on it, and the package chose to report the gap upstream rather than invent a boundary rule or substitute a neighbouring width. Core 0.1.57's@unbucketableis~w(1w 1M 1y); the gap closed and the subtraction did not. The declaration is nowRest.wide_timeframes()directly, so it tracks whatRestactually serves instead of a hand-maintained list, and a test asserts the two are equal rather than asserting a literal.Worth stating because it will recur: nothing failed while this was stale. The workaround was documented, dated, tested and correct on the day it was written, and its own comment said to remove it "the day
nameable/0adds1y" — a condition no test could check, in a package whose tests all passed. Found only by a cross-package audit reading each venue's declaration against the Core version it now depends on.authenticated_streamablewas[]on a venue where every call is signed. It reads as "none of the streamed kinds needs a credential", on a package that declarescredential_benefit: :required, builds its MQTT token fromAuth.headers/2inSubscription, and whose broker answers CONNACK103/104to anything unsigned. A host asking whether it needed a credential to stream quotes, top-of-book or trades was told no. Now[:quotes, :top_of_book, :trades]— the whole ofstreamable, since there is no anonymous path to any of them.Note the direction, which is easy to get backwards and which
usage-rules/feeds.mdcurrently states the wrong way round:Capabilities.new/1enforcesauthenticated_streamableas a subset ofstreamable— "of the kinds you stream, which need a credential" — and raises with "a kind that needs credentials must first be a kind the venue streams". Found by a cross-package audit;dp_exchange_robinhoodcarried the identical[]for the identical reason.FeedTest's W3 control-plane test raced under load and failed intermittently on certain random seeds.assert_receive {:blocked, blocked_pid}waited only the suite's default timeout for a message a blocking testplugsends essentially synchronously once its process is scheduled — normally near-instant, but too tight once this file ran alongside its siblings underasync: truewithmax_cases: 20. Widened to 3,000ms locally; the assertion still returns as soon as the message arrives, so this costs nothing on the passing path. Found by a cross-package audit running the full suite on multiple explicit seeds, which this family's CI does not do by default.
Added
script/check_doc_sources.shanddocs/reference/webull/doc-sources.tsv— a weekly, non-blocking check that every vendor documentation page this package cites still resolves the way it did when a person read it. It records status and redirect destination, and does not follow redirects or diff content: a permanent redirect is itself the change notice (this family lost a streaming API to one), while content diffing a rendered docs site would be red every week for reasons that are never the reason we care about. This is the instrument that found the 404 above, on its first run. Scheduled Mondays 09:20 UTC via.github/workflows/doc-sources-check.yml, never on push, never in the publish chain, and it touches documentation sites only — never a venue API, which tier-2's never-on-a-schedule rule still forbids.
Changed
docs/reference/webull/rest-rate-limits.mdrewritten against the per-endpoint table, and now records exactly what is and is not machine-readable on this vendor's site. Endpoint descriptions and their rate-limit blocks live in<meta>content and are fetchable anonymously; per-endpoint parameter tables are not, and still require an authenticated console — soget_order_book/3'sdepthdefault remains uncaptured, asnegative-claims.mdalready records. The belief that the rate-limit data was likewise unreachable was wrong, and it was wrong partly for a mundane reason now written down: everydocs/URL without a trailing slash answers301, and the 189-byte redirect stub that comes back reads exactly like a JS-rendered shell unless you check the status code.
Documentation
Fake's own moduledoc still described a{:refused, :missing_credentials}path underauthenticated/1after that path was changed to{:error, {:missing_credentials, :webull}}— the code was already correct, only the prose describing it had drifted. Found by a cross-package audit comparing this family's credential-refusal vocabulary against each other.
Fixed
BREAKING: twelve more
Fakecallbacks on the widened surface still succeeded with no credentials — the same defect as the entry below, on the endpoints assertion 17 structurally cannot reach.get_option_chain/2,get_option_expirations/2,list_watchlists/1,get_watchlist/2,create_watchlist/3,update_watchlist/2,delete_watchlist/2,get_financials/3,get_corporate_events/1,get_filings/2,get_news/1andget_screener/2all bound their options as_optsand never inspectedcredentials, while every one of their real counterparts reaches the venue throughRest's signed request path and answers{:error, {:missing_credentials, :webull}}without one. All twelve now gate through the sameauthenticated/1→Auth.present?/1helper the rest of the fake already used, checked before the argument validation they already did, matchingRest's own order — soFake.get_news(symbols: ["AAPL"])with no credentials is now a credential error rather than a success, andFake.get_corporate_events()with neither is the credential error rather than:symbol_required.Why the earlier fix missed them. Assertion 17 gates on
Core.AdapterContract's hardcoded@credentialedlist —get_balances,get_accounts,get_fees,get_transfers,place_order,cancel_order,get_order,get_orders,get_trade_history— which names none of the twelve. The list predates the widened callback surface and was never extended with it, so a venue can pass assertion 17 with the whole options/watchlists/fundamentals/news/screener surface ungated. This was found by a cross-package audit that compared all five venue packages against each other, which also found the identical gap indp_exchange_schwab's fake on its own widened surface — it is a property of the assertion's fixed list, not of either venue, and the durable fix belongs in Core rather than here.BREAKING:
Fakelet several credentialed account and order calls succeed with no credentials at all — the venue declarescredential_benefit: :requiredand the fake did not honour it. Found bydp_exchange_core0.1.57's new assertion 17 ("credential gate"), which reportedget_fees/2first; auditing every credentialed callback against its realRestcounterpart turned up the same defect onget_accounts/2,get_balances/2,get_transfers/2,get_transactions/2,get_positions/1,quantization/2,place_order/3,place_orders/3,preview_order/3,replace_order/4,cancel_order/3,get_order/3andget_orders/2. Three different shapes of the same gap:get_fees/2andget_accounts/2discarded thecredentialsargument outright (_credentials) and checked nothing;get_balances/2andget_transfers/2gated only onfake_account(opts), an account-id check that is a different question from a credential check; the rest simply never referencedcredentialsat all. A consumer's own suite calling any of these with no credentials and asserting success was going green against behaviour the real venue does not have — every one of these hits a signed endpoint and gets a real 401.All now gate through
DpExchange.Webull.Auth.present?/1— the same%{app_key: <binary>, app_secret: <binary>}shape checkAuth.headers/2runs for every real signed request — checked in the same order the realRestcall checks it (account id before credentials, where both apply), so the fake's refusal point matches the real one, not just its final answer.get_fees/2's real counterpart (Rest.get_fees/2) builds no HTTP request at all — it is a published flat rate, not a query — so it never ran throughAuth.headers/2to get this check for free; it now callsAuth.present?/1directly, which is the one behaviour change on the real facade in this fix (previously{:ok, _}for any input whatsoever, including no credential; now{:error, {:missing_credentials, :webull}}without one). Every other realRestfunction was already correctly gated — only the fake had drifted from it.The refusal shape changed too, and this was wrong independently of assertion 17.
Fake.get_price/2and the rest of the market-data surface answered{:refused, :missing_credentials}for a missing credential.DpExchange.Core.Venue's own moduledoc reserves{:refused, reason}for the venue's own permanent word about a request it received — a request built with no credential never reaches the venue at all;Auth.headers/2refuses it locally first. Every credentialed callback, market-data and account/order alike, now answers{:error, {:missing_credentials, :webull}}instead —Auth.headers/2's own return value, echoed rather than invented.market_status/1is the one callback left unchanged: the real venue answers it with no credential too, so the fake doing the same is not a gap.FakeInjection.credentials_bypassed?/1still short-circuits every one of these gates, unchanged, for a test that needs to skip the check deliberately.A clean-shutdown
DISCONNECTwarning fired on the ordinary shutdown path, on every test teardown in this package's own suite.Feed.terminate/2'sDISCONNECTsweep (added the same day, above) loggedLogger.warning/1for everySocket.disconnect/2failure without distinguishing why it failed. Two ofdisconnect/2's three error reasons are structural, not a failed send::not_alivemeans the socket process was already gone — the ordinary shape of shutdown, since a shard's socket and theFeedsupervising it are usually torn down together — and:calling_selfcan only happen through a test fixture handingterminate/2its own pid, never in production. Neither is "the venue will see an abrupt disconnect instead of a clean one," which is what the warning said every time. A warning that fires on the normal path is noise that trains a reader to stop reading[Webull Feed]warnings at all — the same failure mode a red CI run for a non-failure was fixed for earlier in this package's history.Feed.terminate/2now logs:not_aliveand:calling_selfat:debug, and warns as before for every other reason — a genuine send failure against a socket that was alive and reachable, where the venue really will see an abrupt disconnect. Nothing about whenDISCONNECTis attempted changed; only which outcomes are worth a warning.capabilities/0under-declaredhistorical_timeframesandreports_trade_volume— both true crypto-only facts generalised to the whole venue. Found by a documentation-accuracy sweep (the one behind commit94ea0a8) that deliberately left code untouched; this is the code-side fix it called for, run through the full gates.historical_timeframesnamed the eight crypto/event-contract widths only (1m 5m 15m 30m 1h 2h 4h 1d) and omitted1w,1Mand1y, whichRest.get_stock_bars/5's@stock_timespansmap has served — and this package's own tests have exercised — on the equity, option and futures bars since that endpoint shipped.reports_trade_volumewas unconditionallyfalse, thoughRest.get_price/3withcategory: "US_STOCK"/"US_ETF"has always carried a real day-aggregatevolume.Core.Capabilitieshas one flat list and one flat boolean for the whole package, with no way to say "true for equities, false for crypto" — reported upstream as a gap indp_exchange_core's expressiveness rather than worked around here.historical_timeframesis nowRest.wide_timeframes/0minus1y(see below);reports_trade_volumeis nowtrue. Both fields' crypto exception is spelled out inmeasured_against,Webull's andRest's moduledocs, andusage-rules.md, and both are still enforced per-call: a crypto or event-contractget_historical_prices/5still refuses1w/1M/1yrather than degrading to the nearest width, and a cryptoget_price/2still returnsnilvolume rather than a number.1ysurfaced a genuinedp_exchange_coregap and is reported, not worked around.Rest.get_stock_bars/5serves1w,1Mand1y, butdp_exchange_core0.1.48'sTimeframe.nameable/0— the vocabularyCapabilities.new/1validateshistorical_timeframesagainst — admits1wand1Mbeyond what it can bucket and has no entry for1yat all. Declaring1yhere raisesCapabilities.new/1's own validation, sohistorical_timeframesnames ten widths, not eleven, and1ystays reachable only by callingRest.get_stock_bars/5/get_historical_prices/5directly. This is a Core vocabulary gap, not a decision made here — flagged fordp_exchange_coreto widenTimeframe.nameable/0by one more width rather than silently narrowed to fit, invented a boundary rule for, or substituted with a neighbouring width. Seewebull.ex's@core_unnameable_widthsfor the full account.A consumer routing on the old declaration changes behaviour. One that read
reports_trade_volume: falseand routed all Webull volume work to another venue was needlessly discarding a real, reachable equity signal; one that readhistorical_timeframesas the ceiling of whatget_historical_prices/5could serve and never asked for1w/1Mon an equity, option or futures symbol was doing the same for those three widths. Neither previously received a wrong value — the under-declaration only ever caused a consumer to route around a call it could safely have made.
Removed — breaking
MqttPacket.subscribe/2deleted. Found bydp_exchange_core's new "16. internal wiring" conformance assertion: nothing in this package'slib/ever called it. Traced rather than assumed dead — this venue's own documentation (docs/reference/webull/streaming-api.md) states plainly that "subscriptions are not managed over MQTT" and are HTTP calls instead (DpExchange.Webull.Subscription), so an MQTTSUBSCRIBEpacket was never the mechanism this venue uses.SUBACKdecoding (private, unreachable without aSUBSCRIBEever going out) is removed with it. Anyone who calledMqttPacket.subscribe/2directly — an internal module, not part of the facade, but a public function until now — has it removed under them; the real subscription surface is unchanged: the facade's ownsubscribe/2.
Added
The MQTT
ticktopic is now streamed asDpExchange.Core.Types.Trade— the third kindsubscribe/2delivers, alongsideQuoteandTopOfBook. Found by the same conformance assertion as above:QuoteProto.decode_tick/1fully decoded the venue's documentedTickmessage and nothing ever called it —Subscription's defaultsub_typesnever asked the venue forTICK, andSockethad notick-topic clause to route a payload to if one had arrived. A genuine venue capability, built and never wired end to end. Fixed in all three places it broke:Subscription's defaultsub_typesnow includes"TICK"(was["SNAPSHOT", "QUOTE"], now["SNAPSHOT", "QUOTE", "TICK"]);Socketdecodes theticktopic to%DpExchange.Core.Types.Trade{};Feed.kind_for/1recognises it forcoverage_by_kind/1.capabilities/0'sstreamablenow includes:trades.Trade.idis alwaysnilon this venue — the streamed tape carries no per-print identifier, matchingget_trades/2's REST tape, which has the identical gap and already says so.TICK's inclusion in the defaultsub_typesis read fromstreaming-api.md's topic table, not yet confirmed against the live venue — unlikeSNAPSHOT/QUOTE, which are confirmed live (DpCryptoManagement's issue #19).capabilities/0'smeasured_againstsays so explicitly. If the venue answersTICKdifferently than documented, that surfaces throughFeed's existing generic-subscribe- failure handling, the same as any other refusalSubscriptionhands back.DpExchange.Webull.live?/1— whether the environmentoptsresolves to moves real money, resolved through the same precedence every call on this venue uses. Same shape and same reasoning asDpExchange.Gemini.live?/1. Found unwired:Environment.live?/1existed and nothing inlib/called it — a safety check built and never given a way for a consumer to reach it, since only the facade is public API on this venue.DpExchange.Webull.adjusted?/1— whether bars of a timeframe on the equity/ETF tape are forward-adjusted.Rest.adjusted?/1was already fully built and documented for exactly this question ("a caller stitching two widths together needs to know") but had no facade entry point to be called through;Core.Types.Candlehas no field to carry the answer on the bar itself, so this was always meant to be asked of the package directly, not read off a returned candle.
Fixed
A clean shutdown now sends MQTT
DISCONNECTon every still-connected shard.MqttPacket.disconnect/0built the packet and nothing ever sent it — found by the same conformance assertion, and the one violation worth reasoning through rather than just wiring: MQTT 3.1.1 §3.14 makesDISCONNECTthe protocol's normal-close signal, and its absence is what makes a broker treat a closed connection as abnormal. That is a protocol fact, not a venue-specific one, and this venue's documentation is silent on whether it changes the ~1-minute session-retention window either way — nothing here claims it does.Socket.disconnect/2sends the packet viaWebSockex.send_frame/3, the only way to put a frame on an already-runningSocketfrom outside its own callbacks;Feed.terminate/2calls it once per connected shard, and only for the reasonsGenServertreats as normal termination (:normal,:shutdown,{:shutdown, _}) — never for a crash, where extra socket I/O on a connection that may be the reason for the crash is the wrong trade against a fast supervisor restart.Feed.init/1already traps exits (for shard-crash isolation), which is what makesterminate/2actually run on an ordinary supervised shutdown, without needing its ownhandle_info({:EXIT, ...})clause for that case.Found and fixed live during this work:
WebSockex.send_frame/3answersclient == self()by raisingWebSockex.CallingSelfErrorrather than returning an error, which a barecatch :exitdoes not stop.Socket.disconnect/2now guardspid == self()explicitly and itscatchclause is broadened to any exception kind, so it holds its own "never raises" contract regardless of howsend_framefails underneath it.capabilities/0'ssupported_instrument_typesno longer hand-copiesRest.order_instrument_types/0's list — it derives from it. The prior comment ("Five, because the order builder builds five... so this cannot drift") asserted the equivalence without anything checking it;order_instrument_types/0was itself unwired — nothing inlib/called it, the declaration it was "exposed because" of read a separate literal instead. A privatesupported_instrument_types/0now maps the order builder's five instrument types (:crypto,:equity,:option,:futures,:event) onto Core's four asset classes (:crypto/:equityboth collapse to:spot) and the declaration is now the mapping, not a second list that could silently disagree with it.Environment.validate!/1now checks membership inknown/0instead of carrying its own separate, literal[:production, :uat]guard — the same fix, same reasoning, asDpExchange.Gemini.Environment.known/0existed and nothing inlib/called it.SymbolFormat.to_canonical_symbol/1andto_exchange_symbol/1now read the mapping throughmapping/0instead of the private@mappingattribute directly — the module's own moduledoc has always argued both directions must run through one mapping so they cannot drift; the accessor sitting unused beside the attribute it exposes was exactly that risk, even though the two held the same value today.mapping/0existed only as a conformance-suite seam and nothing in this package's ownlib/called it.Documented usage —
children = [{DpExchange.Webull, []}], no:limiteranywhere — made every streaming HTTP call fail closed with "Rate limiter unavailable", silently, for the whole tree's lifetime.Supervisor's owninit/1startedFeedwith the bareoptsthe tree was given, soFeed's ownresubscribe_opts— built once there and replayed on every reconnect and every 60-second blind resubscribe thereafter — carried no:limiter.Core.HttpClient.check_rate_limits/1then resolved the limiter byConfig.opt(opts, :limiter, DpExchange.Core.DefaultRateLimiter)— the bare module name — and nothing in this venue's supervision tree starts a rate limiter under that name, only underSupervisor.limiter_name(opts), registered one line above the brokenFeedchild spec.GenServer.whereis/1on the bare name returnsnil, and every subscribe, unsubscribe and blind resubscribe this package ever issued under the documented supervision form failed with{:error, "Rate limiter unavailable"}— proven live against a connected shard.Feed.subscribe/3's own synchronous path for an already-connected shard (touch_primary_shard/7, viareconcile_now/4) has the identical gap one layer up: it reconciles using the caller's ownopts, notresubscribe_opts, so the facade'ssubscribe/2,unsubscribe/2andupdate_symbols/2needed the same default a caller who never names:limiterwas always going to hit. Fixed in both places —Supervisor's owninit/1now defaultsFeed's child spec:limiterthe same waywith_limiter/1already does for every REST-backed facade function, andsubscribe/2,unsubscribe/2andupdate_symbols/2now callwith_limiter/1too, where before they forwardedoptsunchanged. Tests added intest/dp_exchange/webull_test.exsprove both halves: that a tree started the documented way wiresFeed's ownresubscribe_optsto the limiter it actually started, and thatsubscribe/2reaches the venue without the caller naming one.The protobuf decoder's one nested field (
Basic basic = 1) kept the FIRST occurrence on a repeated wire encoding instead of the LAST, inconsistent withscalar/1's own documented proto3 rule for a repeated scalar — the exact bug class this module's own moduledoc already records an incident for, now found in the one place that rule was not actually applied.decode_nested/1's list clause now filters to the binary occurrences and decodes the last one, matchingscalar/1.A plain
STOP_LOSSorder built byplace_order/3carried no trigger price at all, and aTRAILING_STOP_LOSSorder carried no trail distance — an architect-directed audit finding.replace_order/4's own field table (read from the vendor's reference) namesstop_pricefor bothSTOP_LOSSandSTOP_LOSS_LIMIT, andtrailing_stop_stepforTRAILING_STOP_LOSS— all three are real,@combinations-listed order types for equity, option and futures instruments.order_leaf/3'sstop_for/2only attachedstop_pricewhen the venue's wire name was"STOP_LOSS_LIMIT", and never attachedtrailing_stop_stepfor any order type at all. A caller placing a genuine stop-loss or trailing-stop order therefore had the one field that makes it that order type silently discarded before the request ever reached the venue — the single most expensive shape of substitution this family names, because the order still looks well-formed and the venue either rejects it for a reason the caller cannot connect back to a missing field, or (worse) accepts it with no trigger configured at all. No test exercised a plain:stoporder's body at all, and the one existing:trailing_stoptest asserted onlyorder_type/time_in_forceon the wire, nevertrailing_stop_step— the exact "test proves the wrapper, not the wire" seam this family has shipped bugs through before.price_for/2was tightened the same pass: it used to attachlimit_pricefor any non-MARKETorder type, so aSTOP_LOSSrequest built from a limit-order template (with:pricestill set) would have sent alimit_pricethe venue's schema for that order type does not have. It now attacheslimit_priceonly forLIMITandSTOP_LOSS_LIMIT, the two types the vendor's own table lists it for.Fixed in
lib/dp_exchange/webull/rest.ex'sorder_leaf/3,price_for/2,stop_for/2and a newtrailing_stop_step_for/2. Tests added intest/dp_exchange/webull/instrument_orders_test.exsassert the actual wire body for a plainSTOP_LOSS(carriesstop_price, nolimit_price), aSTOP_LOSS_LIMIT(carries both), and aTRAILING_STOP_LOSS(carriestrailing_stop_step).The same audit found the read side of the same field missing too:
stop_pricenever came back on anOrder, anywhere.Core.Types.Ordercarries:stop_pricefor exactly this purpose, and this package sends it onplace_order/3andreplace_order/4(once the fix above landed), but:to_placed_order/4— the structplace_order/3hands back immediately — never echoed it from the request, unlikepriceandquantity, which it already did.to_order/1— the real decode path behindget_order/3andget_orders/2— never read it from the venue's row at all, under either of the venue's usualstop_price/stopPricenames (the same dual-naming convention already proven correct forlimit_price/limitPriceon the same row).Fake.place_order/3had the identical gap asto_placed_order/4, which would have let a consumer's suite go green against the fake while the real path answerednil— ausage-rules/testing.mdviolation ("never differently capable") this audit also checked for and found here.
A caller placing a stop-loss or stop-limit order and then reading it back — by any of the three paths this package offers — got
stop_price: nilregardless of what was sent or what the venue reported. Fixed in all three; tests added intest/dp_exchange/webull/order_mapping_test.exs(both wire-name forms, and that an ordinary order still carriesnilrather than picking up a stray value),place_order_test.exs, andfake_test.exs.Two
feed_test.exstests synchronised on async work with a fixedProcess.sleep/1instead of an event — flaky under load, and exactly the anti-pattern this family has shipped CI-only failures through before. One slept 20ms hoping asend/2'shandle_infohad already run before assertingProcess.alive?/1; fixed by replacing the sleep with aFeed.coverage/1call, which — being aGenServer.call— queues behind the earliersendin the mailbox and so cannot return before it was processed, the same pattern already used elsewhere in this file. The other slept 100ms hopingisolate_crashed_shard/3had already rebuiltstate.shardsbefore asserting on:sys.get_state/1—:sys.get_state/1answers over OTP's system-message channel and is not guaranteed ordered after a regular mailbox message, so the sleep was load-bearing and still a race even with it; fixed by subscribing to notices first and assertingassert_receive {:dp_exchange, :webull, %Notice{kind: :link_down}}, whichisolate_crashed_shard/3fans out synchronously inside the same handler that rebuilds the shard map.A third
feed_test.exstest — therate_limit_blockingregression test itself — flaked under full-suite load with a too-tightassert_receivewindow, not aProcess.sleep/1this time. "the blind resubscribe defaults to blocking" forces a real ~300ms wait insideacquire/3(viaexhausted_limiter/0's spent single-token bucket) before its stubbed HTTP call is even sent, on top of the process-hop latencytest_helper.exs's ownassert_receive_timeout: 1_000exists to absorb — so the global 1_000ms default was not enough headroom for this test specifically, on top of that. It reproduced under a full 700-test async run (mix test --seed 849478) while passing in isolation every time. Given its own explicitassert_receive/2timeout, widened to5_000— margin over the ~300ms wait, not a weakened assertion, since the test still fails if the message never arrives at all.
Added
A shard's blind resubscribe failing for a generic reason now surfaces as a
Core.Noticetoo, latched per shard — DpCryptoManagement's issue #23. The 60-second blind resubscribe timer (see the moduledoc's "The resubscribe timer must never fail-fast") already had two structured, caller-visible outcomes for a shard's HTTP subscribe failing::oversubscribedrebalances silently, and{:invalid_symbols, symbols}gets its own:refusalnotice (issue #24, above). Everything else an{:error, reason}could be — the rate-limiter throttling that issue #23 itself is ("Throttled by our own rate limiter (not the venue)"), an HTTP 5xx, a transport error — fell throughhandle_subscribe_result/3's catch-all clause to aLogger.warningand nothing else. Issue #23's own numbers are the cost of that gap: a node restart, all 4 shards linking up cleanly, then 58 consecutive blind-resubscribe failures across 13 minutes, every one the identical refusal, found only because a human went grepping this module's own log for the sentence it had been repeating the whole time.Core.PollingFeed's own:on_notice— added per DpCryptoManagement's issue #21, the poll-feed sibling of this same gap ("a feed that knows it has delivered nothing now says so on a channel a consumer can act on, not only in a log line") — is the pattern this follows, applied to a shard's resubscribe rather than a whole feed's fetch cycle: a%Core.Notice{kind: :coverage_change}fires the instant a shard's blind resubscribe crosses INTO this generic failure, and aseverity: :inforecovery notice fires the instant it crosses back OUT.:coverage_changeis reused rather than inventing a new kind, for the same reason issue #21's own entry gives: "subscribed intent not becoming delivery is exactly what a feed delivering nothing is," and a shard whose resubscribe keeps failing is exactly that, one shard at a time.Latched per shard (
state.resubscribe_failed, aMapSetof currently-failing shard indices) rather than globally, because each of this venue's up to 5 shards is its own independent MQTT session with its own independent failure and recovery schedule — a global latch would either swallow a second shard's own transition while the first stayed latched, or, left unlatched altogether, fire a fresh notice from every still- failing shard on every single 60-second tick during a widespread outage. A notice storm is its own defect, exactly as issue #21's design already established. The existingLogger.warningkeeps firing every tick regardless, unchanged — this notice is additive, not a replacement. The latch for a crashed shard's index is cleared the momentisolate_crashed_shard/3tears that shard down for reopening: the freshly reopened connection has never itself failed a resubscribe, and firing a "recovered" notice for it the moment its first resubscribe merely succeeds would be reporting a recovery from a failure the new connection never had — the crash itself is already reported separately, via the existing:link_downnotice.
Fixed
17 venue-rejected symbols were blocking all 342 of a consumer's pairs from streaming, permanently — DpCryptoManagement's issue #24. Webull's subscribe is rejected per request, not per symbol: one symbol its streaming category does not carry fails the entire shard's batch. The venue answers
HTTP 417 INVALID_SYMBOLand names the offending symbols inmessage("The symbols does not exist in the category. [BNBUSD]"for one, a comma-separated bracketed list for several — confirmed against the real response shape and againstDpCryptoManagement.Data.Collection.VenueRefusalsTest's own fixture), byte-for-byte identical every 60-second resubscribe tick, forever — because nothing downstream could act on it.Subscription.subscribe/3collapsed the whole response into an opaque{:exchange_error, :webull, "HTTP 417: ..."}string, the treatment its siblingTOO_MANY_SYMBOLS_SUBSCRIPTIONwas deliberately spared (see that clause's own comment). Measured consumer impact:stream_covered0/342, every pair falling back to REST polling, which is where the sustained HTTP 429 storm from issue #23 came from.Subscriptionnow matchesINVALID_SYMBOLspecifically, parses the venue's own named symbols out ofmessage, and converts them back to canonical form (SymbolFormat.to_canonical_symbol/1) before returning{:error, {:invalid_symbols, [canonical_symbol, ...]}}— mirroring:oversubscribed's existing structured shape. A message the parser cannot attribute to any symbol falls through to the previous opaque error rather than inventing an empty exclusion list — a rejection nobody can attribute is not oneFeedcan act on.Feedrecords each rejected symbol with a 24-hour expiry (state.rejected, overridable viaopts[:rejected_symbol_ttl_ms]— deliberately the same order of magnitude asDpCryptoManagement.Data.Collection.VenueRefusals' own TTL for exactly this shape of fact: a venue's streaming catalogue is true at a point in time, not permanently) and excludes unexpired entries fromplan_reshard/1's effective wanted set, so the next chunk built for an affected shard carries only symbols the venue actually accepts. A rejection is retried immediately within the same call via the existingreshard_step/4retry (same mechanism as:oversubscribed), so a caller's ownsubscribe/3still returns a clean:okfor the good symbols despite the shard's first attempt being refused.state.wantedis never pruned — only what's currently eligible for shard composition shrinks — so a symbol's exclusion lapses on its own once the TTL expires and the next resubscribe tick'sresync/1(now also run wheneverstate.rejectedis non-empty) places it back into a shard, with nobody callingupdate_symbols/2.Also emitted as a
Core.Notice—:refusal, Core's own documented kind for "a symbol the venue will not carry" — naming the rejected symbols in canonical form, since a symbol excluded from shard composition never appears (or fails to appear) incoverage/1either way, and a notice is the only way a consumer learns why 17 of its 342 symbols stopped being tried. This is what lets a consumer's own venue-refusal cache (e.g.DpCryptoManagement.Data.Collection.VenueRefusals) populate for this venue for the first time.
Added
coverage_by_kind/1,dp_exchange_core's new optional contract callback (~> 0.1.48, bumped from~> 0.1.36).coverage/1reports one:stream/:not_coveredboolean per symbol, folding every streamed kind into it — which is exactly how a Coinbase venue in this family once reported full coverage for hundreds of symbols while one of its two streamed kinds had gone dark for nearly all of them, the discrepancy hidden behind the single boolean across two issues. This venue genuinely has the same shape to protect against, not a formality adopted only for cross-venue uniformity: every subscribe asks for bothSNAPSHOTandQUOTE, andSocketdecodes them on separate topics into two different structs that can go dark independently —snapshotintoCore.Types.Quote(kind:quotes),quoteintoCore.Types.TopOfBook(kind:top_of_book).Feednow tracks arrivals per kind (delivering_by_kind, alongside the existingdelivering) and derives the kind from the struct type that actually arrived (kind_for/1) rather than assuming it fromcapabilities/0— so a third kind reaching the feed without a matching case here is logged loudly instead of silently folded into an existing one. Verified against Core's own conformance suite, assertion group 15, which assertscoverage_by_kind/1's symbol union equalscoverage/1's keys exactly and that every kind key it reports is onecapabilities().streamabledeclares — both now run against this venue for the first time (previously skipped: the callback did not exist) and both pass.Fake.coverage_by_kind/1reports a single:quoteskey, honestly: the fake'ssubscribe/2only ever builds aTypes.Quote, never aTypes.TopOfBook, and "less capable is allowed, differently capable is not" means it must not claim a second kind it cannot produce.
Fixed
capabilities().streamablesaid[:quotes]; this venue has always also streamed:top_of_book. Found while implementingcoverage_by_kind/1above, which requires naming a kind for every struct actually delivered — deriving one honestly forCore.Types.TopOfBookand then declaring it undeclared would have been exactly the kind of self-contradiction Core's own conformance suite checks for. Not a new venue capability:Subscription's defaultsub_typeshas always requested bothSNAPSHOTandQUOTE, andSocket'squote-topic clause has decoded toCore.Types.TopOfBooksince the bid/ask-as-price fix recorded in its own comment (a real quoted number is not a traded price — a bid is a resting order, a price is an execution). Both kinds have therefore always reached a subscriber;streamablesimply never caught up when theTopOfBookfix landed. A consumer readingstreamable: [:quotes]had no reason to expect a%DpExchange.Core.Types.TopOfBook{}on its subscriber's mailbox at all, and may not even have pattern-matched on one.usage-rules.mdandREADME.mdare corrected alongside — both are shipped in the Hex tarball and both previously implied a single streamed kind.
Documentation
**
README.mdanddocs/reference/webull/endpoint-inventory.mdboth stated stale endpoint counts — "44 of 87:experimental, 43:unsupported" — a snapshot from db17ff3 (2026-09-03 09:33) that went stale that same afternoon when2f6c65b(2026-09-03 13:43) movedget_fees/2andquantization/1out of@not_ported, and neither document was told. Verified by callingcapabilities/0directly rather than reading@unsupported/@not_portedby eye:mix run -eagainstDpExchange.Webull.capabilities().endpointson 2026-09-05 counts 46:experimentaland 41:unsupported(87 total, unchanged), of whichvenue_does_not_serve/0still names 30 as the venue's own absence and 11 remain not yet ported (was 13). Both documents corrected to 46/41/11;endpoint-inventory.mdalso now records the correction with its own evidence rather than silently overwriting the old figure.Audited against this release's other claim under the same suspicion — that
order_type/time_in_forceround-trip for all five declared values each, per the W1 fix below — by actually running the encode/decode round trip in code for every declared value of both, rather than trusting the fix's own description: all five order types (market,limit,stop,stop_limit,trailing_stop) and all five time-in-force values (ioc,day,gtc,gtd,fok) decode back to the atom that encoded them. Neitherusage-rules.mdnorREADME.mdmade a claim about this narrower than whatcapabilities/0already declares, so there was no stale warning to remove — this is recorded here as the evidence that the audit looked, not as a document fix.
Added
Fakewired toCore.FakeInjection— DpCryptoManagement's issue #14. Every function with a real success path (not an unconditionalVenue.not_supported()) now checks a queued or always-set outcome first. Symbol-taking functions (get_price/2,get_top_of_book/2,get_historical_prices/4,get_order_book/2,get_trades/2,get_volume_profile/3,get_auction_imbalance/2,quantization/2,get_option_chain/2,get_option_expirations/2,get_financials/3,get_filings/2) support per-symbol targeting; the rest (get_symbols/1,get_balances/2,get_accounts/2,get_fees/2,get_transfers/2,get_transactions/2,preview_order/3,replace_order/4,market_status/1,get_positions/1, the watchlist functions,get_corporate_events/1,get_news/1,get_screener/2,place_order/3,cancel_order/3,get_order/3,get_orders/2) support whole-call injection.authenticated/1also honoursFakeInjection.credentials_bypassed?/1for wiring-only tests, without changing the venue-faithful default for anyone who doesn't opt in.subscribe/2,unsubscribe/2,update_symbols/2andplace_orders/3are deliberately not wired — each takes a list (symbols or orders) in one call, which whole-call injection cannot express partial failure for. Follows the reference implementation shipped indp-exchange-robinhood; the shared mechanism itself lives indp_exchange_core.Feedshards across multiple MQTT sessions — DpCryptoManagement's issue #13. A single session caps out at the venue's own stated ceiling ("Maximum number of subscribe tickers:100"); a consumer with more than 100 symbols on this venue could not reach full coverage through one session no matter how the HTTP calls were split, since the limit is per-session, not per-request.Feednow opens up to five sessions (the venue's own per-App-Key ceiling) and partitions symbols across them internally — the host still only ever callssubscribe/3with a symbol list and never learns a shard, session id, or connection count exists.Adapted from
dp-exchange-coinbase's ownFeed, which shards for the identical reason: recompute from the full wanted set on every call, touch only what changed, one shard synchronous per call (its outcome is the reply) and the rest staggered. What differs here is the leaf operation — this venue's shard identity is also its MQTT session, so a brand-new shard waits for its own CONNACK (reusing the #9 fix, now per-shard) before its first HTTP subscribe means anything to the venue.A shard that rejects a batch as oversubscribed is rebalanced internally, never surfaced to the host as something to route around — the affected symbols move to another shard with room (opening one if needed) and the subscribe is retried. Per the design doc §3.5, this was an explicit architectural requirement, not a nice-to-have: the host must never be handed a shard index or session id to reason about. Only every shard already full and the venue still refusing — a genuine capacity ceiling this package cannot paper over — surfaces as a real
{:error, {:capacity_exceeded, symbols}}.@pairs_per_socketis exactly the venue's own stated 100, not a guessed margin below it — see the design doc §3.1 for why padding an already-stated number would be the same unlabeled guess this family's conventions rule out elsewhere.See
docs/design/2026-09-04_webull-sharding-and-fake-injection.mdindp-exchange-corefor the full design.get_fees/2andquantization/1are implemented. Both had sat in@not_portedsince Phase 2 with no comment recording why — DpCryptoManagement's own filed questions (issues #5, #6 againstdp_exchange_core) were right that the classification did not distinguish "checked and absent" from "never looked".quantization/1reads the sameinstruments/.../profiles/listendpointget_symbols/1already calls. Checked the vendor's live schema before writing anything: crypto and stock rows are disjoint, not the same shape with some fields blank. Crypto carriesprice_step,lot_size,min_trade_qty,max_trade_qty,min_trade_amt,max_trade_amt— all six. Stock/ETF carrieslot_sizealone; no price step, no per-unit or per-cash min or max anywhere on the row. A stock symbol now answers withquantity_incrementand every other fieldnil, not the crypto shape reused because it was already written.get_fees/2required checking two different products before answering. Trading API — the one this package speaks — has no fee-schedule endpoint anywhere in its surface, checked acrossInstruments,Accounts,AssetsandActivities. What carries the name "Fees and Credits" lives entirely in Broker API, and is an administrative interface for a broker crediting or debiting a sub-account — not a schedule a caller queries, and out of reach on principle (D8) as well as on credentials. What the venue does publish, onwebull.com/pricing: a single flat 1.00% crypto spread, charged by Webull Pay/Bakkt, the same for every account.get_fees/2returns that captured, dated rate rather than a live per-credential lookup, because there is no live lookup to make — the rate does not vary by credential to look up.
Fixed
The blind resubscribe timer's own rate-limit retry could never actually reach
Core.HttpClient— DpCryptoManagement's issue #23. Live evidence, from a node restart: all 4 MQTT shards linked up cleanly, then 58 consecutive blind-resubscribe failures across 13 minutes, every one the identical refusal —{:exchange_error, :webull, "Throttled by our own rate limiter (not the venue) — retry after 1s; callers that can wait should set rate_limit_blocking: true"}. The refusal asks for a one-second wait;@resubscribe_interval_msis 60,000 — so fail-fast (check/3) on this timer meant every tick was dropped for a full minute to avoid waiting a second. Measured consumer impact: 0 of 342 pairs streaming, all of them falling back to REST polling. It also made the venue's own transient, ordinarily self-healingINVALID_SYMBOLrejections on an initial subscribe permanent, because recovery from those runs through this same blind resubscribe.:rate_limit_blocking— the optionCore.HttpClient.check_rate_limits/1reads to chooseacquire/3overcheck/3— was missing from every allowlist on the path a blind resubscribe actually takes:Feed's ownresubscribe_opts(built once ininit/1),replayable/2(what carries it forward across every later subscribe), andSubscription.request_opts/1(the last allowlist beforeCore.HttpClientitself). Fixing only the two inFeed— what the filed issue suggested — would have shipped a change that reads as a fix and does nothing: the option would still be silently stripped one layer down, bySubscription.request_opts/1, beforeCore.HttpClientever saw it. All three now forward it. OnlyFeed's own opts default it totrue— the resubscribe timer runs off a 60-second clock with nothing waiting on its result, so blocking for a second is free — followingdp_exchange_robinhood'sFeedprecedent for the identical shape (its own issue #16) exactly:Subscription.request_opts/1forwards the option without defaulting it, since a caller invokingSubscriptiondirectly, one-off, may legitimately want fail-fast, and this module must not decide that for it. An explicit caller-suppliedrate_limit_blocking: falsestill wins over the default, at bothinit/1and every laterreplayable/2call.Proven end to end with a real, pre-exhausted
Core.DefaultRateLimiter(named, passed via:limiter— aConfig.put_overridein the test process is not visible inside the separately-startedFeedGenServer): the resubscribe's HTTP call reaches the stubbed venue in blocking mode by default, an explicitrate_limit_blocking: falseat start keeps it fail-fast, and a latersubscribe/3call's own explicitfalseoverrides the default on the very next resubscribe tick viareplayable/2.Additional gap found while tracing this, and fixed for consistency:
Webull.Rest.request_opts/1(the allowlist for every other REST call this package makes — accounts, orders, market data) had the identical missing-allowlist gap, for the same reason a one-offSubscription.subscribe/3call must not have blocking imposed on it: not defaulted, forwarded only, matchingdp_exchange_robinhood'sRest.The socket's connect budget was inherited by accident, not chosen — family-wide defect sweep, W6.
Socket.start_link/1passed no options toWebSockex.start_link/4, so it silently accepted the dependency's general-purpose defaults:socket_connect_timeout: 6_000andsocket_recv_timeout: 5_000(measured indeps/websockex/lib/websockex/conn.ex:10-11). That is 11 seconds ofFeed's own 15-second@call_timeoutspent on TCP and the HTTP upgrade before this venue's CONNACK is even awaited — and a shard is not usable until the broker accepts its session id, so the CONNACK wait and the HTTP subscribe both have to fit in the same call too.Feedis a named, shared process, so an unreachable venue made every other consumer's queuedsubscribe/unsubscribe/coveragewait out that window as well.Now set deliberately to 3s and 2s, chosen against that budget and documented with the arithmetic, both overridable and forwarded from
Feed.start_link/1. This changes no failure semantics —start_link/1still returns{:error, reason}synchronously exactly as before. The regression test asserts the values reachWebSockexand that overrides win, so a later refactor cannot quietly fall back to the dependency's defaults.order_type/time_in_forcesilently lost 2 of 5 declared values each on round-trip — family-wide defect sweep, W1.order_type_atom/1andtif_atom/1hand-listed only 3 of the 5 values their own forward encoders (@order_type_names,@tif_names) produce;capabilities/0declares all five of each supported. A caller placing or reading back a:stop,:trailing_stop,:gtdor:fokorder — every one a genuinely real order this package itself sent — gotnilon that field, not an error. Fixed by deriving the reverse lookup maps from the same source maps the forward encoders use, so the two vocabularies cannot drift apart again; a value this package truly does not recognise still decodes tonil, unchanged.Fake.place_order/3was also changed to round-trip an order's type and TIF through the real encode/decode functions (now exposed publicly for this reason) instead of handing the caller's atom straight back — the previous shape could not have caught this class of bug at all.A shard's socket crash took down the whole
Feed, not just that shard — family-wide defect sweep, W2.Socket.start_link/1links toFeed(ordinaryWebSockex.start_link/4behaviour), andFeednever trapped exits, so any abnormal socket exit — an uncaught exception in a callback, an internalwebsockex/gunfailure — killed every shard's connection and every symbol's coverage, not just the one that failed. The opposite of the isolationreshard/4,resync/1and the resubscribe timer all otherwise provide.Feednow traps exits; a crashed shard is logged, reported as a:link_downnotice, any caller with a reply pending on it answered{:error, {:shard_crashed, reason}}rather than left to time out, and the shard is reopened at the same index with the same wanted symbols — the ordinary CONNACK-then-resubscribe path brings it back exactly as a first open would. Every other shard is untouched throughout.The unconditional 60s resubscribe (and every other control-plane HTTP call) ran inline inside
Feed's ownhandle_call/handle_info, blocking the same mailbox that carries ticks — family-wide defect sweep, W3. Worst case, up to five shards' blockingSubscription.subscriberound-trips (measured ~118ms each) ran sequentially inside onehandle_info, once a minute, stalling delivery for every shard — including shards the resubscribe was not even touching — for the whole duration, by design. Every such call now runs in its own task under aTask.SupervisorFeedowns; a caller'ssubscribe/3still does not receive its reply until the real HTTP round trip finishes (unchanged observable contract, via a deferredGenServer.reply/2), but the mailbox stays free to keep draining ticks from every shard while that round trip is in flight. The oversubscribed-retry behaviour and the capacity-aware rebalance are unchanged.Shard assignment was recomputed from scratch on every call, so one newly-added symbol could touch every shard — family-wide defect sweep, W4.
derive_shards/2sorted the entire wanted set and cut it into fixed-size chunks; a symbol that happened to sort early shifted every symbol after it across every shard boundary, unsubscribing and resubscribing symbols that had nothing to do with the change — contradicting the moduledoc's own "touch only what changed" design claim and spending calls from the tightest budget in the family.derive_shards/3now takes the existing shard assignment and is sticky: a symbol already assigned keeps its shard for as long as it is still wanted and still fits that shard's measured capacity; only genuinely new symbols, and anything just evicted by a capacity reduction, get placed into whichever shard (in index order) still has room.sub_typeswas sent lowercase, and every subscribe was rejectedHTTP 417 UNSUPPORTED_SUB_TYPE "Subtype not supported:quotesnapshot"— DpCryptoManagement's issue #19, filed immediately after issue #18's fix unblocked the request enough to reach this validation for the first time.Subscription.post/4defaulted"sub_types"to["snapshot", "quote"], a comment mislabelling them as "the venue's own topic names" — they are not:quote/snapshotlowercase is the MQTT topic namespace a connected session receives on, a different field on a different protocol from the REST subscribe body'ssub_types. The reporter's own retired in-repo client proved the working values:["SNAPSHOT", "QUOTE"]uppercase, sent against this same endpoint for months. The venue's own error text is the two rejected values joined with no separator —"quote"+"snapshot"— which is what made the fix legible rather than a guess. Default changed to uppercase; nothing else about the request shape changed, since it was already sending a proper array, never a joined string.Every signed request went out with no
Content-Typeat all, and the venue's streaming-subscribe endpoint rejected every one withHTTP 415 "Request media type not support"— DpCryptoManagement's issue #18. Streaming never delivered a tick.Auth.headers/2never declared a media type for any request, signed or not. The reported symptom was specifically the streaming subscribe/unsubscribe calls, but the same gap reached every JSON-bodied signed POST this package makes,place_orderincluded — found while readingAuth.headers/2directly rather than patching only the reported call site. Fixed at the source:Content-Type: application/jsonis added whenever a signed request carries a body; a GET's body is""and stays bare, correctly. Not one of the six header pairs the signature itself covers, so this cannot desync a request from what was actually signed.Feedhad no periodic resubscribe safety net, unlike the siblingdp_exchange_coinbasepackage this one's sharding adapts from — DpCryptoManagement's issue #17. This venue can stop delivering to an already-subscribed, already-connected session on its own — no error, no disconnect, no unsubscribe, nothing a health check can see.dp_crypto_management's own prior MQTT client found this empirically: a blind, unconditional resubscribe on a timer, independent of whether the wanted set had changed, took live coverage from 47 symbols back to ~240.reshard/4alone cannot recover from this — it only touches a shard whose wanted symbol set changed, and re-asking for exactly what is already wanted computes an empty diff. Every connected shard's current subscription is now re-issued unconditionally every 60 seconds, matching the shape Coinbase'sFeedalready carries for its own reconnect case, applied here to a steady-state failure mode Coinbase does not have.Feed.fan_out/2crashed on a subscriber registered by name — DpCryptoManagement's issue #15, same defect found on the siblingdp_exchange_coinbasepackage.subscribe/2'sto:option accepts any value, andfan_out/2calledProcess.alive?/1on it directly — which only accepts a pid and raises on anything else. A consumer registering itself under a name (ordinary OTP practice) and handing that name toto:crash-looped the wholeFeedGenServer on every delivery. Fixed by resolving a subscriber (pid or name) to a pid first, treating an unregistered name the same as a dead pid: silently skipped, never a crash.Feeddroppedapp_keyentirely, and never waited for the venue's CONNACK before subscribing over HTTP — DpCryptoManagement's issues #8 and #9.Feed.init/1kept only:url/:environmentfrom its own start opts, soapp_keynever reachedensure_socket/2— which silently defaulted to""rather than refusing. Credentials arrive per call in this family, same as every other venue, so the fix readsapp_keyfrom the subscribe call's owncredentials, and refuses ({:error, {:missing_required_field, :app_key}}) rather than opening a socket that would connect and then sit unauthenticated where nothing visible said why.Separately,
ensure_socket/2treated a live socket process as a ready socket — it returned as soon asSocket.start_link/1did, which is once the WebSocket is up, not once the venue's CONNACK has actually arrived. The HTTP subscribe that followed could race the venue's own auth handshake and name a session id it had not yet registered. A reconnect already waited for the:link_upnotice before replaying; the first-ever subscribe against a fresh socket now does too — the reply is deferred and answered from the same:link_uphandler, rather than fired inline.decimal/1(rest.ex,socket.ex) admitted"NaN","Inf"and"-Inf"as real prices — DpCryptoManagement's issue #11. TheDecimal.parse/1fix for the"null"crash below was not a sufficient guard on its own: all three fully parse, and a NaN or Infinity flowing into downstream arithmetic as a real price is worse than the crash it replaced, since it poisons a calculation silently instead of failing where it happened. Both now also refuse viaDecimal.nan?/1andDecimal.inf?/1.Decimal.new/1raised on a non-numeric price string — the exact crash filed as DpCryptoManagement's issue #3. Reproduced first:Decimal.new("null")raises, and a delisted Webull crypto pair is a real, previously observed shape that returns exactly that string for a price field. Fixed in both copies (rest.ex,socket.ex) withDecimal.parse/1, requiring the whole string be consumed — the family's established idiom, already used bychain_strike/1elsewhere in this package.The lenient fix alone would have introduced a second, quieter defect: a malformed required field silently becoming
nilinstead of raising, which@enforce_keysdoes not catch.get_price/3, the socket's ownemit_decoded/2,to_trade/2anddecode_bar/3now refuse the record instead ({:error, {:invalid_decimal, field, value}}or{:error, {:missing_required_field, field}}), rather than delivering aQuote,TradeorCandlewith a fabricated-lookingnilin a field the type promises is real. One test fixture inorder_book_test.exshad itself been supplying an incomplete bar (missinghigh/low) that only worked because the old lenient behaviour tolerated it; corrected to a complete bar rather than loosened back.
Documentation
docs/reference/webull/endpoint-inventory.md's open question on the old/openapi/...paths is answered — family-wide defect sweep, W5. It previously said "whether the old paths still resolve is not established here." Live-probed unauthenticated againstapi.webull.com, 2026-09-05: the current paths answer fromserver: WEBULL OPENAPIwith a400naming a missing signing header (reaches the real backend); the old/openapi/...paths answer fromserver: APISIXwith a404 Route Not Found(never leave the gateway in front of it). The old paths do not still resolve — the vendor retired the routes, not just their documentation — and this package no longer calls any of them (documented_paths_test.exsalready guards that). No code change; the doc now records the answer with its evidence and date rather than leaving the question open.The
:unsupportedlist is now split.venue_does_not_serve/0names the 30 endpoints that are Webull's own absence — staking, one-step convert, funding rails, greeks, bulk cancel, position closing — each with the source and date behind it; 13 stay under@not_ported, includinglist_instruments/1, which the instrument profile endpoints already back throughget_symbols/1but which this callback has not been built against.README.mdstates what the contract covers — 44 of 87 callbacks:experimentalacross five asset classes.docs/reference/webull/endpoint-inventory.md's counts refreshed. It read "5 of 85" until this release, from the pre-Phase-8 capture.
Documentation
Every negative this package makes is audited —
docs/reference/webull/negative-claims.md, fourteen claims with the source and date consulted for each. Eleven hold; three were wrong, and all three were the same mistake: a true statement about the stock endpoint restated as a claim about the venue. The tell is in the wording — "the vendor states the stock snapshot does not serve it" is correct, and was used to justify a refusal that was not.It also records why this vendor's pages have to be rendered to be read: their parameter and schema tables are built in JavaScript, so a plain fetch returns the method, the path and a one-line description. An inventory captured that way looks finished and cannot be implemented from — which is exactly what the 2026-08-31 capture was.
usage-rules.mdis rewritten around the surface this release added. Its "what this package does not do yet" section listed order placement, balances and accounts as unimplemented; all of them ship. What replaces it points atcapabilities/0and states the one group that will not arrive — money movement, which this venue's API does not do.New sections cover the five categories and why their parameter sets differ, event contracts' two prices and four books, the option chain and the greeks that do not exist here, the twenty-three fundamentals endpoints, watchlists' three absences and their boolean, batch orders, and the token lifecycle.
AGENTS.mdgains a pointer to this package's ownusage-rules.md.
Added
place_orders/3— batch order placement,POST /trading/orders/batch-place. This is the last open endpoint in the coverage plan's implementation phases.Not
place_order/3in a loop. The venue accepts the batch as one request; a caller that looped would reconcile N outcomes instead of reading one response, and the reconciliation is what goes wrong when the third of five fails.Both of the venue's limits are enforced before the request. A maximum of 50 orders, and equities only — its page says so in both cases. A batch over the cap is refused rather than split, because splitting turns one atomic request into several and undoes the only reason to call this. A non-equity order, or one outside the venue's own order matrix, is refused by index: a caller with fifty orders needs to know which.
The result is per order, because a partial batch is the normal shape. The venue validates each and returns each; collapsing that into ok-or-error would let a caller believe "the batch failed" while holding four positions it does not know about.
The vendor notes the endpoint is not available to every client, so a refusal can mean the account is not entitled rather than that the batch was wrong — the venue's own message is carried through unchanged.
The token lifecycle —
create_token/1,check_token/2andoauth_token/3.A token that exists is not a token that works.
create_token/1returns one that isPENDING, 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 treating a successful response as an authenticated session finds every subsequent call refused.check_token/2is the only call that distinguishes the four states.PENDINGhas never been verified,EXPIREDhas run out,INVALIDwas revoked or never existed — all three fail identically at the next request and each has a different remedy, so the venue's own string travels unmapped rather than being collapsed into a boolean.oauth_token/3is one endpoint doing two jobs on a different host.opts[:code]exchanges the authorization code the host obtained;opts[:refresh_token]refreshes. Exactly one is required — both together is refused, because the venue would choose and the response would not say which. The host isoauth-open-api…and the body is a form, where every other endpoint in this package signs JSON: the same URL serves the host's code exchange and the package's refresh, which is why the boundary cannot be read off a path.Two expiries come back and they are not the same clock.
expires_inis the access token's;rt_expires_inis the refresh token's, and it is the one that ends the session.Reference data and watchlists — thirty-eight endpoints: twenty-three fundamentals, six screeners, news summaries, and the eight watchlist calls.
The fundamentals table is the design. Every one of the twenty-three takes
symbolandcategoryand differs only in what it adds, so the table names each endpoint's own extras andtype/countare dropped on the endpoints that do not document them — an unknown parameter is at best ignored and at worst a refusal, and neither tells the caller which happened.get_fundamental/3reaches any of them,fundamental_kinds/0lists them, and a kind this venue does not publish is refused before a request is made.fiscal_periodis translated through the venue's own legend — its page states0=FY, 1=Q1, 2=Q2, 3=Q3, 4=Q4— because the contract wants a label and the venue publishes a code. The raw integer stays inline_items, and a code outside the legend leaves the labelnilrather than inventing one.get_financials/3refuses a fundamentals kind that is real but is not a statement.:company_profileexists on this venue and answeringget_financials/3with it would put a profile in a statement's shape.get_corporate_events/1has no:split. Webull publishesfund-splitsfor funds and nothing for equities, so a split kind would be answerable for some symbols and silently empty for the rest. Withoutopts[:kind]both calendars are read, which is two requests, andopts[:symbol]is required — these calendars are per issuer, not market-wide.get_news/1is generated, not reported. The vendor's own description is "invokes LLM to generate news summaries", so eachsummaryis a model's paraphrase andsourcenames the venue rather than a wire. A caller quoting it is quoting a summary.A screener's rank is the position the venue returned the row in. Nothing is merged or re-ranked: two venues' "top movers" answer different questions. Each screener sends only the parameters its own page documents, and the venue's documented defaults are sent explicitly where it marks them required.
Watchlists: three absences and a boolean.
symbolsisnilon a listing row — that endpoint names watchlists and does not read membership, where[]would say the watchlist is empty.nameisnilon a membership read, because that endpoint does not return it.update_watchlist/2refusesopts[:symbols]rather than silently skipping it: this venue's update endpoint touches properties only, andadd_watchlist_instruments/3andremove_watchlist_instruments/3are the membership writes.Every watchlist write answers
{"success": …}rather than an error status, so afalseis a 200 that did nothing — reported as{:refused, :watchlist_write_rejected}. And creating with members is two requests: where the add fails, the watchlist exists and is empty, and that returns{:error, {:watchlist_created_without_members, id, reason}}carrying the id rather than an{:ok, watchlist}a caller would read as complete.Futures and event contracts — sixteen endpoints, and a reference document (
docs/reference/webull/futures-and-event-contracts.md) recording every parameter and response field.The parameters needed a browser to read. These pages render their tables in JavaScript: a plain fetch returns the method, the path and a one-line description and nothing else, which is exactly what the 2026-08-31 inventory captured. A capture that stops at the method and path looks complete and cannot be implemented from.
Futures route by
US_FUTURESthrough the existing callbacks —get_price/2,get_historical_prices/4,get_trades/2,get_order_book/2andget_volume_profile/3, each to its own endpoint with its own parameter set, which is not the union of all of them: futures bars take noreal_time_requiredand no range, the futures tape and depth take no session flags, and the futures snapshot takes no extended-hours block.Event contracts add the venue's four-level hierarchy —
list_event_categories/1,list_event_series/1,list_event_events/1,list_event_markets/1— alongsidelist_futures_contracts/1andlist_futures_product_classes/1.Two event endpoints are deliberately not behind the contract's callbacks. An event tick carries a
yes_priceand ano_priceand a side ofyes/no;Types.Tradehas one price and a side of:buy/:sell. An event book returnsyes_bids,yes_asks,no_bidsandno_asks;Types.OrderBookhas two sides. In both cases the nearest mapping produces a number that looks right and belongs to the other instrument of a two-instrument market, soget_trades/2andget_order_book/2refuseUS_EVENTandget_event_trades/2andget_event_order_book/2return the venue's own rows. The venue notes that a yes bid at X equals a no ask at 1−X; this package does not derive one side from the other, because a derived level cannot be told from a quoted one.Paged lists return their key.
list_event_series/1andlist_event_markets/1answer%{rows: [...], pagination_key: key_or_nil}; a bare list would make the last page and a truncated one look identical.statusandtradable_statusare two fields on an event market, and both survive: a market can beLISTINGandNTat once, and one boolean would route an order at a market that is listed and not accepting one.Recorded as found, not resolved: the futures footprint page's
categoryprose says "Only US_STOCK type queries are supported" while its own enum lists onlyUS_FUTURES. The enum is what this package sends.asset_classes/0gains:futureand:event_contract.get_transactions/2— the same/trading/activities/cash-activities/listendpointget_transfers/2narrows, asked without the filter.get_transfers/2sendsDEPOSIT,WITHDRAW,TRANSFERbecause the contract documents it as deposit and withdrawal history. This sends noactivity_typesat all, which is what asks the venue for everything it carries —TRADE,FEES,DIVIDENDS,TAX,INTERESTS,CORPORATE_ACTION,OPTION_EA,JOURNAL,EC_SETTLEMENTandOTHERbeside the three. A default list here would be this package deciding what "every activity" means.The two are not interchangeable in either direction. A dividend and a deposit both credit cash and neither is the other: a caller computing contributions uses
get_transfers/2, and one reconciling a balance uses this — summing the former leaves out the fees. Summing either is still not a balance;get_balances/2is the authority.
Changed
Core dependency moves to
~> 0.1.34, and eleven further callbacks are declared rather than missing — all of them absent, with the reason, checked against the venue's own endpoint list on 2026-09-01.Webull's published API moves no money. There is no payment-method endpoint at either scope, no bank registration, no crypto network list, no allowlist and no transfer between accounts; funding happens in Webull's own applications, which need a person.
/trading/activities/cash-activities/listreports money that moved and does not move any. No fee-promotion list, FX publication, notional valuation or custody product either.The options surface:
get_option_chain/2,get_option_expirations/2, andUS_OPTIONon the snapshot, bars and tape —/trading/instruments/options/contracts/list,/market-data/options/snapshots/list,/market-data/options/bars/listand/market-data/options/ticks/list.Three of those four were previously recorded as refusals, and the refusals were false negatives. This package held that
US_OPTION"is refused: the vendor states the stock snapshot does not serve it" — true of the stock snapshot, and wrong about the venue, which publishes a separate option endpoint beside each one. Three tests asserted the refusal, which is how a claim about the venue survived being wrong.A chain is expiry × strike, and Webull publishes a flat list. The grid is rebuilt here, because a flat list is lossless in data and answers none of the questions a chain is asked. A contract this package cannot address is refused, naming the keys the venue actually sent — an expiry, a strike and a right are what address a contract, and a dropped row leaves a chain with a hole in it that looks complete. A strike listed with one side keeps a
nilon the other rather than being absent.:underlying_priceisnil: this endpoint lists contracts and does not quote the underlying, and fetching it separately would be two observations at two times presented as one.get_option_greeks/2stays absent, and that one is real. Webull publishes no delta, gamma or implied volatility on any endpoint. Computing them would need a rate and a volatility surface it does not publish either — every number would be this package's model presented as the venue's, which is the most tempting substitution available here.asset_classes/0gains:option.get_symbols/2reaches the stock instrument profiles,/trading/instruments/stocks/profiles/list, routed byopts[:category]. Both endpoints paginate the same way and both are bounded — a truncated instrument list is the worst shape this family has, because every symbol in it is real and the missing ones are simply never traded.get_historical_prices/5reaches the stock bars,POST /market-data/stocks/bars/list, routed byopts[:category]. A POST where the crypto bars are a GET, with its parameters in a JSON body.Daily and above are forward-adjusted; minute bars are not — the vendor states it, and it means these are not the same series at different resolutions. Stitching 1m bars onto a daily series across a split gives a discontinuity that is entirely real in each half and wrong where they meet, and nothing in the bar data says which side was adjusted. This package cannot fix that, so it reports it:
adjusted?/1answers it for a width, and returnsnilfor a width it does not serve rather thanfalse, which would be a claim.real_time_requireddefaults toYon this endpoint, unlike every other one here — that is an in-progress bar whose boundary has not happened yet.falseis sent unless asked, matching the crypto path: a package that stored the venue's default would save a bar that changes after it is written.Three widths the crypto endpoint does not serve —
1w,1M,1y— are available here.The body sends
count,start_timeandend_timeas numbers, not strings: the venue types themint32/int64, and a quoted number in a typed JSON field is a different value.get_price/3andget_top_of_book/3reach the stock snapshot,/market-data/stocks/snapshots/list, chosen byopts[:category].The two snapshot endpoints are not interchangeable and the category picks the path. Sending a stock symbol to the crypto endpoint returns nothing rather than an error, so passing the category through to one endpoint would have produced silence.
US_OPTIONis refused — the vendor states the stock snapshot does not serve it.The default is still
US_CRYPTO, which is what this package served before its asset classes widened; changing it would silently re-route existing callers onto a different market.Volume is real on stocks and stays
nilon crypto. The venue publishes no crypto volume anywhere, andnilsays so where zero would claim a genuinely flat interval. The stock figure is the day's aggregate, which is what the venue names on this endpoint.extend_hour_requiredandovernight_requiredare sent explicitly on stocks so a caller readingnilknows it did not ask, rather than that the venue had nothing. Equity tickers bypass the canonical pair mapper, as they do on the order path.get_trades/2— tick-by-tick public trades,/market-data/stocks/ticks/list.The venue documents
sideas "Such as: B S G L N" and defines none of them.BandSare unambiguous;G,LandNare documented nowhere the vendor publishes, so they map tonil— a real trade with an unknown aggressor. Folding them into the nearer of buy or sell would put volume on the wrong side of a delta, which is the number a caller reads a tape for.trading_sessionsis required by the venue and defaults toRTHhere, which is the session the rest of this package's price data comes from. There is no per-tick id on this endpoint andnilsays so;brokenisfalsebecause the venue publishes no bust flag.get_volume_profile/3— stock footprints,/market-data/stocks/footprints/list. Traded volume split by price and by side within each interval.deltais the venue's own figure and is not recomputed from the totals. A venue that classifies some prints as neither aggressive buy nor aggressive sell reports numbers that do not reconcile, and that gap is information about its classifier rather than a fault to correct. The price maps keep the venue's own price strings — two strings that parse to equal decimals are the same level, and re-keying would silently merge two of its rows.Five widths, where
get_historical_prices/4serves more:5s,15s,1m,5m,30m. Anything else is{:unsupported_timeframe, width}rather than the nearest one this endpoint happens to have.OVNis in the venue's session enum and its own note says it is unsupported, so it is refused rather than sent.real_time_requiredisfalse— completed intervals only, because an unfinished footprint's split still moves.Requires a separate Webull subscription, which the vendor states on the endpoint.
get_auction_imbalance/2— the NOII snapshot and the published series,/market-data/stocks/noii-snapshots/list.opts[:auction]is required::openingand:closingare different auctions with different windows.Two endpoints.
history: truereads/noii-bars/listinstead of/noii-snapshots/list, and the bars publish the three prices and the time and nothing else — no paired quantity, no imbalance quantity, no side. Those come backnil, which says the venue did not publish them there; a caller computing a ratio over the series getsnilrather than a number that looks balanced.Outside the auction window the venue returns the last imbalance, not nothing — its own documentation says so. Both the venue's
imbalance_timeand this package'sobserved_atare carried, because together they are the only way a caller tells a live imbalance from this morning's. An undated one leavesvenue_timenilrather than borrowingobserved_at, which would make a stale imbalance look fresh.sideis the venue's own code, carried as sent — it documentsimbalance_sidewith the example"2"and does not say what 2 means.Requires a Nasdaq TotalView non-display subscription.
get_order_book/2— stock and ETF depth,/market-data/stocks/depths/list. The first endpoint of the equity market-data surface.The book is equities-only on this venue. The crypto snapshot publishes a top of book and nothing beneath it, and the vendor states
US_OPTIONis not supported here — so the category is checked before the request rather than after the venue's refusal.The attribution is dropped, and that is worth saying out loud. Each level carries the venue's
orderarray of market-participant IDs with per-participant sizes, andbrokernames beneath that.Core.Types.OrderBooklevels are{price, size}, so on a lit book this package discards who is quoting. The size that survives is the venue's own level size, not a sum over the participants — attribution can be partial, and the level size is the number the venue stands behind.A book the venue did not stamp is refused; a depth snapshot wearing the local clock cannot be told apart from a current one.
sequenceisnil, so a caller cannot use a REST book to detect a gap in a stream.overnight_requiredis always sent because the venue marks it required, and an omitted required parameter is a refusal a caller cannot read.BREAKING: this package is no longer crypto-only.
asset_classesis[:crypto, :equity].The order builder now serves all five instrument types the venue names, and the matrix is per type because the venue's rules differ per type:
CRYPTO MARKET/IOC, LIMIT/DAY|GTC, STOP_LOSS_LIMIT/DAY|GTC EQUITY MARKET, LIMIT, STOP_LOSS, STOP_LOSS_LIMIT, TRAILING_STOP_LOSS × DAY|GTC OPTION as EQUITY minus TRAILING_STOP_LOSS ("Options not supported") FUTURES as OPTION EVENT LIMIT only, and DAY|GTC|IOC|GTD|FOKOne matrix for all five would be wrong four times, and wrong in the direction that gets an order rejected after it was sent.
{:unsupported_order_combination, instrument, type, tif}now names the instrument, and the fake enforces the same matrix from the same source —Rest.order_combinations/1— rather than a hand-copied list that drifts.A request that does not say
instrument_typeis still crypto. Changing that default would silently re-route existing callers' orders onto a different market.Only crypto symbols go through the canonical pair mapper. An equity ticker is already the venue's own identifier, and pushing
SOLVthrough a splitter that hunts for a quote currency would mangle it.Cash sizing (
AMOUNT) is refused on futures and options, naming the instrument — the vendor states it for U.S. stock and event contract trading only.GTDcarries an expire date and nothing else does; a missing one is left missing rather than defaulted, because a date chosen here would be an expiry the caller never asked for.preview_order/3andreplace_order/4. Both endpoints exist and both exclude crypto, which they refuse before sending — the vendor's own words, rather than a business error a caller cannot tell from a rejected order.preview_order/3builds the same body a placement would, so a preview cannot diverge from the order it previews. It returns the venue'sestimated_costandestimated_transaction_fee, with the instrument type alongside them: for stocks and options the cost is total consideration, for futures it is initial margin, and a caller reading one as the other is off by the whole notional.replace_order/4enforces the venue's per-type edit surface — a MARKET order takes quantity only, a trailing stop takes only its step — and reads the order back, because the venue's response carries no order and reporting the requested change as though it were confirmed is a different claim.get_transfers/2— cash activities.The endpoint is much wider than transfers. It lists
TRADE,FEES,DIVIDENDS,TAX,INTERESTS,CORPORATE_ACTION,OPTION_EA,JOURNAL,EC_SETTLEMENTandOTHERalongsideDEPOSIT,WITHDRAWandTRANSFER. The contract asksget_transfers/2for deposit and withdrawal history, and 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, so a caller computing what it put in would count income as contribution. This asks the venue for the three, andopts[:activity_types]widens it.The venue's two constraints are enforced rather than discovered. Without a range it answers the last 7 days — its default, stated in the docs here so an empty list is not read as "no deposits ever".
start_timeandend_timemust be in the same calendar year, and a cross-year range is refused up front rather than sent: a venue that silently truncates returns a real list missing the other half.Rows come back whole.
activity_sub_typealone has 60-odd values carrying the difference between an ACH deposit and a wire, and no struct in this contract has anywhere to put them.get_accounts/2,get_balances/2andget_positions/1. The package could not say what the credential holds or what it is exposed to.account_classis where this venue's breadth shows. The documented values areINDIVIDUAL_CASH,INDIVIDUAL_MARGIN, four IRA classes,CRYPTO,FUTURESandEVENTS_CASH— one credential reaches all of them.get_accounts/2returns the venue's rows whole rather than filtered, because an account is not a value type here and normalisingaccount_labelaway would lose the field a caller picking an account needs.available_balanceisnil, deliberately. The venue publishesfrozen_amount,held_amount(in transit),unsettled_cash,buying_powerandavailable_withdrawal— five different numbers that do not agree.available_withdrawalis what can leave the account;buying_poweris what can be traded and on a margin account exceeds the cash. Each is "available" to a different caller, and labelling one of them as the available balance would be right once and wrong four times.balanceiscash_balanceandholdisfrozen_amount, both single-meaning fields of the venue's own. The rest is a gap inCore.Types.Balancerather than in this venue.A position's side comes from the sign of the quantity, via
Position.from_signed_quantity/1. It is the only place this venue states direction, and a package that assumed:longbecause equities usually are would report a short that is exactly backwards with every number in it still plausible.liquidation_priceandleveragestaynil— the venue publishes neither here, andnilmeans "not stated", never "no liquidation risk".get_balances/2andget_positions/1requireopts[:account_id], as every account call on this venue does.get_accounts/2takes none: the credential decides what it sees.place_order/3. This venue could not place an order; it can now.The venue documents which crypto pairs it accepts, and the list is short:
MARKETtakesIOConly,LIMITandSTOP_LOSS_LIMITtakeDAYorGTC. There is no market GTC and no limit IOC. A pair outside that list is refused before the request is sent, so a caller gets both halves of what was wrong rather than the venue's business error.account_idis required and never inferred. An account is where the money is, and a package that looked one up and chose would place a real order against the wrong balance for a caller holding several. It comes fromopts[:account_id]or the call fails.QTYandAMOUNTare different orders. Sizing in units and sizing in cash are named separately by the venue; giving neither is an error rather than a default, and giving both is refused as ambiguous. Cash sizing on a stop-limit is refused outright — the venue allows it on a buy and not on a sell, and accepting it on one side invites a surprise on the other.Crypto orders are sent as
NORMALcombos only, which is what the venue supports; MASTER, OTO, OCO and OTOCO are equities groupings.cancel_order/3,get_order/3andget_orders/2. The order lifecycle, which stopped at placement.This venue's order API is keyed on the client order id, not the venue's. Both
/trading/orders/canceland/trading/orders/gettakeclient_order_id, soOrder.idnow carries it andplace_order/3was corrected to return it. Returning the venue's ownorder_idhanded a caller an identifier that round-trips nowhere: place, then cancel, and the cancel fails on an id the venue does not accept.Open and historical orders are two endpoints, not one with a filter.
/orders/open-orders/listand/orders/historical-orders/listanswer different questions, and a caller asking for "orders" without saying which gets the open ones — the set that can still change.history: trueasks for the other.account_idis required on all three, as it is on placement.Rest.post/4, which signs the body. Unlike Coinbase's URI-scoped JWT, this venue signs the payload, so the encoded string is built once and used for both the signature and the request. Encoding twice risks two orderings of the same map and a signature that does not match what was sent — which the venue would reject as an authentication failure rather than as the encoding bug it is.
Changed
convert/4andget_trade_volume/2(Core 0.1.22) are declared unsupported. The venue publishes neither a one-step conversion nor the two-step quote/commit pair, and no account-volume report. Summing fills here would be this package's arithmetic rather than the venue's ledger, which is the number its fee tiers actually come from.Core 0.1.21's three new callbacks are declared, each with the venue checked.
preview_replace/4followsreplace_order/4: the venue excludes crypto from the amendment endpoint, and crypto is what this package declares today — so it is unimplemented rather than unavailable, and it unblocks when this package'sasset_classeswiden to the stocks, options and futures Webull also serves.cancel_all_orders/2has no endpoint at all:/trading/orders/canceltakes oneclient_order_idand the venue publishes no cancel-all or cancel-session.close_position/3likewise —/trading/assets/positions/listreads positions and nothing closes one, at any asset class.Corrected a false claim about the venue.
@unsupportedsaidpreview_order/3"has no endpoint at all"./trading/orders/previewexists and is documented; what the vendor says is "For crypto trading, this feature is currently not supported", and the same sentence appears on/trading/orders/replace./trading/orders/batch-placeis stocks only and gated per client. They are unimplemented, not unavailable.asset_classes: [:crypto]describes this package as it stands, not a boundary of the venue, and treating the two as the same is the same mistake in a different place. Widening the package reaches all three.BREAKING:
get_historical_prices/4returnsCore.Types.Candlestructs, and the bar's time isopened_at. It returned bare maps keyed on:timestamp.timestampdid not say which time it was. A bar covers an interval, and the only time a venue publishes for it is the interval's opening — a caller readingtimestampas "when this bar closed" is off by exactly one interval, in a value that looks entirely reasonable.opened_atsays which end it is. The struct also bringsCandle.coherent?/1, which catches a high below the close at the boundary rather than downstream in a range or volatility calculation that will not error on it.Every endpoint moved to its documented path (D6). All five calls used an
/openapi/…prefix that appears nowhere in Webull's current documentation; they were inherited from an older reading of the site./openapi/market-data/crypto/snapshot -> /market-data/crypto/snapshots/list /openapi/market-data/crypto/bars -> /market-data/crypto/bars/list /openapi/instrument/crypto/list -> /trading/instruments/crypto/profiles/list /openapi/market-data/streaming/subscribe -> /market-data/streaming/subscribe /openapi/market-data/streaming/unsubscribe -> /market-data/streaming/unsubscribeThree of the five changed more than their path, and a path-only rewrite would have compiled and passed:
- snapshots stamp rows
last_trade_time/quote_time; the timestamp reader accepted neither, so every quote would have failed:missing_venue_timestamp - bars renamed
symboltosymbolsand added a requiredreal_time_required - instruments made
categoryrequired and is now paginated
- snapshots stamp rows
get_symbols/1follows pagination. The replacement returns one page per call, so a single request would have returned a plausible, silently truncated catalogue. The walk is bounded (@max_pages) and refuses a key that does not advance.
Added
DocumentedPathsTest— asserts the documented paths are called and the/openapi/ones are not. No test asserted any path before this, which is why the wrong ones survived.
Added
- Repo scaffold from the DpExchange standard; extraction pinned to the host's
553fa787with per-file SHA-256, since the Webull subtree was dirty at extraction time — the third venue running. docs/reference/webull/streaming-api.md: the venue's MQTT-over-WebSocket streaming contract, committed verbatim, with its connection limits and protobuf schema.
Added — the package
- Market data (snapshot, bars, catalogue), streaming, and the whole supervision tree. 214 tests including Core's 28 conformance assertions, passing first run.
- Credentials are required for market data — every OpenAPI call is signed and
there is no anonymous endpoint, so this is the family's first
credential_benefit: :required. environment: :uatpoints REST at the UAT host. Streaming there refuses with{:streaming_unavailable, :uat}rather than falling back to production: the venue has no UAT broker, and a consumer testing against UAT that received production prices would be reading real market data believing it was fake.- No trade volume anywhere on this venue, so
volumeisnilrather than0. Zero would look like a real measurement of no trading.
Measured against the live venue, 2026-08-28
- The documented "TCP/IP" endpoint speaks TLS, not plaintext MQTT. Sending a
well-formed MQTT 3.1.1 CONNECT to
data-api.webull.com:1883returns15 03 01 00 02 01 00— a TLSclose_notifyalert. The prior adapter reached the right conclusion (use WebSocket) from the wrong evidence ("never speaks MQTT", "no TLS handshake"); there is a TLS peer there, refusing a malformed ClientHello.wss://data-api.webull.com:8883/mqttanswers101immediately.