DpExchange.Webull.Fake (DpExchangeWebull v0.2.15)

Copy Markdown View Source

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, not just for market data. get_price/2 without 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, get_fees/2, place_order/3, and the rest). {:error, {:missing_credentials, :webull}}, not {:refused, _}: a missing local credential never reaches the venue at all, and DpExchange.Core.Venue's own moduledoc reserves :refused for the venue's own permanent word about a request it received. It is Auth.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 by dp_exchange_core 0.1.57's assertion 17. A fake that answered anyway would let a consumer's test pass while the real call returns 401.

    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 Rest request path as everything else, so they refuse without credentials exactly as get_price/2 does. They were missed originally because assertion 17 gates on Core.AdapterContract's hardcoded @credentialed list — 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 in dp_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. volume is nil on every quote and every bar, matching a venue that reports none. Returning 0 would look like a real measurement of no trading.

  • UAT has no stream. subscribe/2 under environment: :uat refuses, 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.