Packages
dp_exchange_webull
0.4.4
0.4.97
0.4.96
0.4.95
0.4.94
0.4.93
0.4.92
0.4.91
0.4.90
0.4.89
0.4.88
0.4.87
0.4.86
0.4.85
0.4.84
0.4.83
0.4.82
0.4.81
0.4.80
0.4.79
0.4.78
0.4.77
0.4.76
0.4.75
0.4.74
0.4.73
0.4.72
0.4.71
0.4.70
0.4.69
0.4.68
0.4.67
0.4.66
0.4.65
0.4.64
0.4.63
0.4.62
0.4.61
0.4.60
0.4.59
0.4.58
0.4.57
0.4.56
0.4.55
0.4.54
0.4.53
0.4.52
0.4.51
0.4.50
0.4.49
0.4.48
0.4.47
0.4.46
0.4.45
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.31
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.3.3
0.3.2
0.3.1
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.1.25
0.1.24
0.1.23
0.1.22
0.1.21
0.1.20
0.1.19
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.13
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
EXPERIMENTAL — Webull venue package for the DpExchange family. Market data, trading and streaming behind the shared DpExchange.Core.Venue facade.
Current section
Files
Jump to
Current section
Files
dp_exchange_webull
mix.exs
mix.exs
defmodule DpExchangeWebull.MixProject do
use Mix.Project
# SEED, not a release. CI increments the last segment of whatever it finds here, so
# `0.1.0` first publishes as `0.1.1`. Hand-editing this to `0.2.0` is how a breaking
# change is signalled. The bump script matches the attribute assignment below by its
# exact literal form — do not reformat it, and do not repeat that form anywhere else
# in this file, comments included, or the script will rewrite the wrong line.
@version "0.4.4"
@source_url "https://github.com/DistortionPoint/dp-exchange-webull"
def project do
[
app: :dp_exchange_webull,
version: @version,
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
dialyzer: dialyzer(),
preferred_cli_env: preferred_cli_env(),
test_coverage: test_coverage(),
# Hex.pm
name: "DpExchangeWebull",
description:
"EXPERIMENTAL — Webull venue package for the DpExchange family. Market data, " <>
"trading and streaming behind the shared DpExchange.Core.Venue facade.",
package: package(),
source_url: @source_url,
docs: docs(),
usage_rules: usage_rules()
]
end
# No `mod:` — a library does not start itself. A consumer supervises this venue
# through `child_spec/1` and decides restart strategy, shutdown order and naming. A
# consumer that has not asked for Webull must not find a socket open.
def application do
[extra_applications: [:logger]]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_env), do: ["lib"]
defp deps do
[
# The contract. Three-part pin: while Core is 0.x a minor bump may break us, and
# that is the signal it is meant to send.
#
# `0.1.68` is the floor because `capabilities/0` declares
# `no_venue_contact: [{:get_fees, 2}]`, and `Capabilities.new/1` builds the struct
# with `struct!/2` — a key the struct does not define raises `KeyError` rather than
# being silently dropped. `no_venue_contact` was added to `Capabilities` in Core
# 0.1.68; under any lower floor (this pin previously allowed down to 0.1.48) a
# consumer resolving an old-enough Core gets this package's `capabilities/0`
# crashing on every call. `~> 0.1.48` compiled and passed here only because CI always
# resolves the newest allowed version. 0.1.68 also covers `Timeframe.nameable/0`
# admitting `1y` (needed since 0.1.57 — see `webull_test.exs`'s "1y is declared"
# test), so it is the binding constraint, not an additional one.
{:dp_exchange_core, "~> 0.2.1"},
# This venue's own transport. Core ships no transport library at any strength —
# a venue that speaks WebSocket ships what it needs to speak it.
#
# `== 0.5.1`, not `~>` — pinned exactly, not floored, since dp-exchange-core issue
# #27. `DpExchange.Webull.Socket` no longer calls this dependency's own `WebSockex`
# module directly: it calls `DpExchange.Webull.Vendor.WebSockex`, a private
# vendored fork of `websockex` 0.5.1's single process-loop file
# (`lib/vendor/websockex.ex`), carrying a two-line fix. Webull
# can close this venue's socket with a WebSocket close frame that carries prose
# instead of an RFC 6455 status code; upstream 0.5.1 (and its unreleased
# successor, confirmed 2026-09-08) turns that into an uncaught `CaseClauseError`
# that kills the process before `handle_disconnect/2` ever runs. See the vendored
# module's own moduledoc for the full incident, exactly what changed (two lines),
# and why vendoring — not a transport swap, not waiting on upstream — was correct
# here. This history (the `send_frame/3` arity floor below) is why an exact pin,
# not a range, is what this dependency gets now.
#
# Still required directly: the vendored file calls `WebSockex.Frame`,
# `WebSockex.Conn`, `WebSockex.Utils`, `WebSockex.Application` and every
# `WebSockex.*Error` struct from this real, unmodified dependency — none of those
# carried the bug, and vendoring them too would have tripled the diff for no
# safety gained.
#
# The exact pin matters more here than an ordinary dependency's would: the
# vendored file calls into `WebSockex.Conn`'s and `WebSockex.Frame`'s functions
# the same way the original `websockex.ex` did — a private-in-spirit internal API
# those modules never promised to keep stable across releases the way their own
# public behaviour is. A `~>` floor invites the exact failure this dependency
# already caused this family once (below): a version the range permits silently
# changing a shape this file depends on, with neither SemVer nor this comment's
# own history catching it. `script/check_dependency_floor.sh` still resolves and
# compiles this pin — an exact pin removes the *range* failure mode that script
# exists to catch, not the value of running it.
#
# Original floor history, preserved because the lesson still applies: `~> 0.5.1`,
# not `~> 0.5`, used to be the constraint, because `Socket.disconnect/2` called
# `WebSockex.send_frame/3` directly and the third argument — the send timeout —
# only exists from 0.5.1, not 0.5.0. Under `~> 0.4` this package resolved 0.5.1
# locally and so compiled and passed, while a consumer who resolved 0.4.x got
# `:undef` at the call. DpCryptoManagement hit exactly that on 2026-09-08. Arity
# is only checked when the function runs, so it shipped as a compile warning
# rather than an error, and only on a path nothing exercised.
#
# The floor was first corrected to `~> 0.5`, on the belief that the argument
# landed somewhere in the 0.5 line. `script/check_dependency_floor.sh` (added the
# same day, to close exactly this blind spot family-wide) resolved that floor for
# real and found `WebSockex.send_frame/3 is undefined or private` against 0.5.0
# itself — `send_frame/3` is new in 0.5.1, one patch later than the first fix
# assumed, and `~> 0.5` still permitted the version that lacks it. Confirmed by
# reading both resolved sources directly: 0.5.0's `lib/websockex.ex` defines only
# `send_frame/2`; 0.5.1's defines `send_frame(client, frame, timeout \\ 5_000)`.
# The lesson this family had already drawn — a "corrected" floor still needs to be
# resolved to prove it, not just reasoned about — applied to its own correction
# within the same day.
#
# `send_frame/2` works on both and is the WRONG fix: it takes WebSockex's own
# 5_000ms default, where `@disconnect_timeout_ms` is deliberately 500ms because
# `disconnect/2` runs inside `Feed.terminate/2`, under a supervisor's shutdown
# budget. Ten times the wait during shutdown is not a free compatibility win, so
# the vendored `send_frame/3` (copied verbatim from 0.5.1) is what this package
# calls now — the honest fix is still to depend on the version whose API this
# package actually uses, now enforced by an exact pin instead of a range.
{:websockex, "== 0.5.1"},
{:jason, "~> 1.4"},
{:decimal, "~> 2.0"},
# Dev/Test
{:usage_rules, "~> 1.2", only: :dev},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.13", only: [:dev, :test], runtime: false},
# Exercises the REST pipeline through Req's test seam, so tier-1 tests reach no
# network. Never ships.
{:plug, "~> 1.16", only: :test}
]
end
# `DpExchange.Webull.Vendor.WebSockex` is excluded: a private vendored fork of
# `websockex` 0.5.1's process-loop file, carrying a two-line fix for dp-exchange-core
# issue #27 (see its own moduledoc). This package's tier-1 suite exercises the paths
# this venue actually uses — connect, CONNACK, frame draining, the malformed-close fix
# itself (`socket_malformed_close_test.exs`), a clean shutdown DISCONNECT — not the
# SSL transport, named-process registration, `:async` start, fragmented-frame
# reassembly or `:sys` debug tracing this venue never exercises. Writing tests to hit
# 90% on code copied verbatim from an already-tested upstream library would mean
# re-deriving that library's own test suite rather than testing anything about this
# package. Same reasoning `.credo.exs` and `webull_contract_test.exs`'s narrowed
# `package_root` already apply to this file for the same reason.
defp test_coverage,
do: [threshold: 90, ignore_modules: [DpExchange.Webull.Vendor.WebSockex]]
defp aliases do
[quality: ["format --check-formatted", "credo --strict", "dialyzer", "sobelow --config"]]
end
defp dialyzer do
[plt_add_apps: [:mix, :ex_unit], plt_file: {:no_warn, "priv/plts/dialyzer.plt"}]
end
defp preferred_cli_env, do: [quality: :test]
defp package do
[
licenses: ["MIT"],
links: %{"GitHub" => @source_url},
maintainers: ["bcatherall"],
# `files:` belongs HERE, not at the project level. Hex reads `package[:files]`; a
# `files:` in `project/0` is silently ignored and Hex ships its own defaults —
# which puts `priv/plts/dialyzer.plt` in the tarball and leaves out anything you
# meant to add. Nothing warns. Inspect `mix hex.build` before every publish.
#
# `priv/` is absent because the only thing in it is the dialyzer PLT.
# `config/` is absent: it governs this package's own dev and test, never a
# consumer's.
files: [
"lib",
"mix.exs",
".formatter.exs",
"README.md",
"LICENSE",
"CHANGELOG.md",
"AGENTS.md",
"usage-rules.md",
"docs/reference"
]
]
end
defp docs do
[
main: "DpExchange.Webull",
extras: ["README.md", "CHANGELOG.md", "usage-rules.md"],
source_ref: "v#{@version}"
]
end
defp usage_rules, do: [file: "AGENTS.md", usage_rules: [:usage_rules]]
end