An in-process Webull, for a consumer's tier-1 tests and for the conformance suite.
It is not a mock. Nothing is stubbed, no expectation is recorded, no call is
verified. It is a real implementation of DpExchange.Core.Venue answering from memory,
and it runs the same conformance suite as the real adapter.
Two rules
Less capable is allowed. Differently capable is not. Where this cannot answer it returns an error, never an empty success.
It never rewrites a value the caller supplied, and never stamps the current clock —
@at is fixed, because a fake that stamps utc_now/0 cannot be used to test anything
about freshness and is itself the substitution this family refuses.
It models the three things that make this venue different
Credentials are required everywhere a real call reaches the venue — not for market data alone, and not for a callback that never builds a request.
get_price/2without them is{:error, {:missing_credentials, :webull}}, because the real venue signs every call and has no anonymous endpoint — and so is every credentialed account and order call (get_balances/2,get_accounts/2,place_order/3, and the rest).{:error, {:missing_credentials, :webull}}, not{:refused, _}: a missing local credential never reaches the venue at all, andDpExchange.Core.Venue's own moduledoc reserves:refusedfor the venue's own permanent word about a request it received. It isAuth.headers/2's own return value for a credential that does not match its shape, echoed here rather than invented — this used to answer{:refused, :missing_credentials}here and{:ok, _}on several of the account and order callbacks, both wrong for the reasons above; found bydp_exchange_core0.1.57's assertion 17. A fake that answered anyway would let a consumer's test pass while the real call returns 401.get_fees/2is the one deliberate exception. It answers a captured published rate and never builds a request, so there is nothing for a credential to gate — see its own moduledoc. A 2026-09-06 sweep gated it anyway, on the reasoning that the real path "had never run throughAuth.headers/2"; that broke a real consumer who resolves fees before any account is attached, and was reverted 2026-09-07.The gate covers the widened surface too, and that part assertion 17 cannot check. Options, watchlists, financials, corporate events, filings, news and the screener all reach the venue through the same signed
Restrequest path as everything else, so they refuse without credentials exactly asget_price/2does. They were missed originally because assertion 17 gates onCore.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 them, so twelve callbacks answered{:ok, _}with no credentials while the real venue returned 401. A cross-package audit found the identical gap indp_exchange_schwab's fake on the same widened surface; it is a property of the assertion's fixed list, not of either venue.No volume.
volumeisnilon every quote and every bar, matching a venue that reports none. Returning0would look like a real measurement of no trading.UAT has no stream.
subscribe/2underenvironment: :uatrefuses, exactly as the real feed does, rather than delivering fake production data.
Failure injection and anonymous mode
Every function below that has a real success path (not an unconditional
Venue.not_supported()) checks DpExchange.Core.FakeInjection.next_outcome/1 or /2
first — a queued or always-set outcome from FakeInjection.queue_failures/2,3 or
fail_always/2,3 short-circuits the fake's normal logic and is returned as-is.
authenticated/1 also checks FakeInjection.credentials_bypassed?/1 before its normal
{:error, {:missing_credentials, :webull}} path. Neither changes anything for a test
that never calls FakeInjection — see that module for the full contract.
subscribe/2, unsubscribe/2 and update_symbols/2 are NOT wired: each takes a list
of symbols in one call, and "this one symbol in the batch fails, the rest succeed" is a
case whole-call injection cannot express — see FakeInjection's own moduledoc.
place_orders/3 is the same shape for the same reason: one call, many orders.