All notable changes to this project are documented here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
0.7.0 - 2026-08-22
Added
Bourse.fetch_account_facts/1reads provider-owned product access, account margin model, and position margin modes as independent facts. Observed fields keep their native names and values; missing ones stay:unavailablerather than being inferred from caller options orcapabilities.has. Mapped on alpaca, binance (spot / USD-M / COIN-M families), bybit, deribit, hyperliquid, and lighter. Other runtime venues return:not_supported.Bourse.Market.combo?/1identifies multi-leg strategy books, andquantity_resolvable?/1tells exposure consumers whether a market carries usable quantity semantics.Bourse.Positiongainednotional_currency— the currencynotionalis denominated in, populated on the unified read path whenevernotionalis present. An unresolved currency fails loud rather than emitting an unlabelled number.Bourse.Tradegainedclient_order_id— the client-assigned order ID echoed on a fill when the venue returns one.- alpaca
fetch_tradesandfetch_my_tradesnow dispatch. Public stock prints come fromGET /v2/stocks/{symbol}/tradesondata.alpaca.markets(IEX feed, 60-day lookback; a present-nulltradeskey is an empty window). Paper fills come fromGET /v2/account/activities/FILL. Wallet deposits, withdrawals, and transfers stay unsupported: the paper host 404s those endpoints, and paperJNLCfunding is not a customer transfer. alpacaandlighternow have public WebSocket transport. Alpaca's market-data socket authenticates with anaction: authkey/secret frame beforetrades/quotessubscriptions on the IEX (prod) or test feed. Lighter uses{"type":"subscribe","channel":"..."}with numeric market indexes; unifiedwatch_*templates stay unresolved because the venue does not accept a unified symbol. Coinbase Exchange is the remaining runtime venue without a WS config, andBourse.WS.connect/3answers{:error, :websocket_not_configured}for it, distinct from:unsupported_exchangefor a venue outside runtime support.Bourse.Exchange.capability_surface/0exposes the release-pinnedhasdeclarations for every runtime venue, andcapability_surface_differences/2reports sorted additions, removals, and value changes between two surfaces. The machine-readable surface ships in the Hex package and the offline oracle requires an explicit re-pin when an authored capability changes.- The scheduled live lane now runs the
:network/:capability_livecorpus (including WebSocket auth smoke), the listen-key auth-smoke file on:dangerous, and a classified public WebSocket first-frame matrix. The repo-internal first-frame and aggregation tasks merge those surfaces into one durablelive-lane-report.json. Silence after a successful connect is a named venue/channel failure, not a pass. - Incomplete unified reads return
{:ok, %Bourse.RawResponse{}}labelled with the provider payload, venue, method, and verification state. Callers no longer receive an unlabelled transport envelope posing as a normalized struct, and an offered-but-unmapped method stays callable instead of disappearing from the surface.
Changed
- Breaking (deribit):
fetch_marketspreserves multi-leg venue kinds as"option_combo"/"future_combo"instead of reporting them as single-leg"option"/"future"markets. Their corresponding single-leg flags stay false, so type and capability signals no longer disagree. capabilities.hasis provider support only (true/false/"emulated"). Mapping completeness and verification are separate authored maps.Bourse.Exchange.has?/2reports the derived callable surface; order-type flags such ascreateMarketOrderstay onvenue_support/2. A raw parse slot the provider does not offer answers{:error, {:unsupported_operation, slot}}instead of:no_field_map.Bourse.load_markets/2accepts and ignores:type/:subType/:sub_type, so one option list can be threaded through it and the private reads alike. The catalog is never filtered by them.
Fixed
An unrecognized request option is now rejected before any wire attempt as
{:error, %Bourse.Error{type: :bad_request, recoverable: false, retry_class: :non_retryable}}naming the offending key. Options forwarded to the HTTP client are an allowlist rather than a deny-list, and the request rescue classifies before recording, so a caller typo no longer reports as a recoverable venue network fault that melts the circuit breaker and takes every read on that venue down. A genuine transport failure still melts it.Bybit
categoryis resolved from the authored request contract for all 67 category-carrying methods, so declared reads no longer fail live with error10001.fetch_leverage_tierswith no caller parameters resolves the category from the market instead of assuminglinear, and an ambiguous ID-only order lookup returnsinvalid_parameters/missing_required_parambefore dispatch rather than failing at the venue.Deribit option and option-combo amounts no longer take inverse-future arithmetic when
instrument_typesaysreversed. Loaded markets and the instrument-id fallback now share the same positive inverse classifier; unrecognized shapes keep the multiplication identity.binance (USD-M umbrella), bybit linear and derive perpetual markets now populate
contract_sizefrom the venue's own contract unit (1,quantity_unit: "base"), closing the last known linear gaps. Each recipe was confronted against the provider's contract, not copied from binanceusdm: a provider-publishedcontractSizestill wins, inverse COIN-M and bybit inverse keep reading the venue's field, and venues that state no unit (okx, binancecoinm) stay nil.Bybit inverse positions now populate
contract_sizewith the authored 1 USD contract unit even though/v5/position/listomitscontractSize. Inverse markets remain nil because their instruments-info rows omit that field.binanceusdm unified
watch_tickerdelivers ticker frames. Binance's USD-M socket splits its streams across two hosts:@miniTicker,@tickerand@aggTradeare carried on/market/wsand merely acknowledged — then silent — on the public host, while@depth20@100ms,@tradeand@bookTickerlive on/public/ws. Both hosts are now authored andwatch_*routes each stream to the one that carries it.WebSocket connections opened for an authored host switch are owned and reused. Repeated watches for streams on the same host share one socket instead of opening a new one per subscription, the
connect/3options (message handler, heartbeat,on_disconnect, timeout) carry over to every routed connection, andBourse.WS.close/1closes all of them — a caller never reaches into the handle's internals to clean up. RawBourse.WS.subscribe/3andWS.Adapter.subscribe/3route by authored host as well: a stream sent on a connection whose host does not carry it now either reaches the host that does or returns{:stream_host_unavailable, url, reason}, never an acknowledgement followed by silence.Mixed-host WebSocket subscriptions are atomic across authored hosts. If a later host fails, hosts that already accepted the call are unsubscribed before the error returns, so retrying does not stack a hidden subscription. Routed sockets are linked to their connection owner and cannot survive an owner crash as unreachable orphan connections.
Bourse.WS.Adapterreconnects close the previous connection owner after the replacement is adopted, so repeated reconnects no longer leak one owner per attempt. Connection owners are temporary supervised children, and an owner crash is visible to the adapter instead of leaving an unreachable socket.Lighter's
subscribed/*response is both the subscription acknowledgement and the first market snapshot. DefaultBourse.WS.subscribe/3now re-queues that frame after returning:ok; laterupdate/*frames remain data and an invalid channel still returns{:subscription_rejected, frame}.The unified boundary validates parameter value shapes before dispatch. A non-encodable value (keyword list, tuple, struct) returns
{:error, %Bourse.Error{type: :invalid_parameters}}naming the parameter instead of raising inside the signing layer. Nothing is coerced and no positional signature changed.The non-bang unified boundary also returns error tuples for Binance-family orders carrying both conditional legs and empty Bybit batch-order lists; those request-shape rejections previously escaped as exceptions. Bang variants continue to raise.
Binance-family unified order reads now see the Algo book.
fetch_order,fetch_open_order,fetch_orders,fetch_closed_orders, andfetch_canceled_ordersfan out to the algo endpoints, so an identifiercancel_orderaccepts no longer makesfetch_orderanswer:order_not_found. A successful algo-cancel acknowledgement{algoId, code: 200}synthesizes unifiedstatus: "canceled". VenueSTOP/STOP_MARKETtypes staystop/stop_marketinstead of collapsing to"limit".Binance-family order reads preserve every authored conditional type instead of collapsing it to
marketorlimit:stop,stop_market,take_profit,take_profit_market, andtrailing_stop_marketnow round-trip through their native literals. Spot, futures, and options use separate provider enums, and an unknown type fails loudly instead of being silently downcased.Unified
clientOrderIdnow round-trips on Deribit: it goes out aslabeland comes back on both%Bourse.Order{}and%Bourse.Trade{}. A caller-supplied nativelabelwins; values longer than 64 characters return{:error, %Bourse.Error{type: :invalid_parameters}}from the non-bang API and raise from the bang API. A venue may map a client identifier in both directions or in neither; one-way mapping fails a catalog invariant.OKX candle, deposit-history, and positions-history
since/untilstay inclusive on exclusivebefore/aftercursors: the request sendsbefore = since - 1andafter = until + 1. A row sitting on the unifieduntilbound was previously dropped. Explicit native cursors still win.Bybit
fetch_tickernow stampstimestampanddatetimefrom the response envelope. Authored field rules can select the original envelope without changing which row is parsed, and recorded-response verification rejects an envelope clock that is present but dropped.Binance spot partial-depth snapshots now route through the
watch_order_bookBroadcast instead of falling through as raw frames when their provider payload has noediscriminator. Subscription acknowledgments and unmatched frames remain system/raw messages.Derive
fetch_transfers(code: ..., limit: ...)applies the unsupported provider filters client-side in currency-then-limit order, and Binance COIN-Mfetch_leverages(symbol: ...)filters the account-wide position map to the requested symbol without sending an unsupported wire parameter.Plural symbol-keyed reads now reject a single parsed record instead of returning the wrong shape inside
{:ok, ...}. Binance spotfetch_trading_feesis pinned to a production SAPI recording and returns the expected symbol-keyed fee map.Binance spot
fetch_closed_orders,fetch_canceled_orders,fetch_canceled_and_closed_orders, andfetch_order_tradesnow mapsince/untiltostartTime/endTime.fetch_open_ordersdrops those bounds rather than sending unread parameters the venue rejects with-1104.Generic Binance
fetch_funding_rate/2no longer relabels a USD-M perpetual rate as the spot pair that shares its compact market id. Spot requests now return a named fundingless-market error, an unservable COIN-M request returns a named error instead of{:ok, []}, and served perpetuals preserve the venue-answered market identity.RequestShape caller-input rejections that still raised
ArgumentError(Binance batch-order field rules, Hyperliquid and Lighter order validation, OKX cost-based derivative markets, option-underlying and open-interest period checks) now raise%Bourse.Error{type: :invalid_parameters}so the non-bang unified API returns a tuple. Bang variants continue to raise.Unified
convert_date/3no longer raisesFunctionClauseErrorfor date formats the symbol layer does not enumerate. Unsupported format pairs and unmatched source strings raiseArgumentErrornaming both formats and the input.Bybit dated-future unified symbols convert to venue-padded DDMMMYY (
BTCUSDT-04SEP26) instead of carrying the native expiry through. Deribit keepsconvert_date/3's unpadded width (BTC-4SEP26).Non-numeric order amounts and prices that previously raised
MatchErrorinside precision snapping now return{:error, %Bourse.Error{type: :invalid_parameters}}at the non-bang unified boundary. Bang variants continue to raise.Emulated unified reads forward the caller's full parameter map into the nested method. Handlers used to rebuild that map from a few hardcoded keys, so
untiland venue-native options never reached the delegated request. Only locally consumed selectors (a singularsymbolrewritten assymbols, anidused to pick one row) are stripped.
0.6.0 - 2026-08-18
Changed
- Breaking (deribit): linear future
contractsnow divide basesize_currencyby the basecontract_size. The 0.5.0 formula|notional| / contract_sizeis inverse-only: on a USDC-perp it mixed quotesizewith a base contract size and reported ~mark_price contracts. Inverse futures are unchanged. Quotenotionalstill comes fromsizeon both books.
Fixed
- binanceusdm linear markets now populate
contract_sizefrom the authored venue-level contract unit (1,quantity_unit: "base") instead of leaving it nil whenexchangeInfoomitscontractSize. A provider-published size still wins; a venue that states no unit stays nil rather than defaulting to one. Inverse COIN-M continues to read the venue'scontractSizefield. - Signed private requests are re-signed before every Req retry. A
transient 408 no longer replays a frozen timestamp, nonce, or deadline
that the venue then rejects as a recv-window or nonce error. After
retries are exhausted the caller sees the original 408, not a follow-on
rejection. The already-signed
HTTP.signed_request/4path is now single-attempt; Dispatch usessigned_request/5. - Bybit
SETTLEMENTledgeramountanddirectionsource the venue'sfundingcomponent instead ofchange. USDC-perp rows were mixing 8-hour session P&L (cashFlow) intofunding_fee; linear-USDT rows wherecashFlowis 0 are unchanged. Walletbefore/afterstill describe the combined settlement. - Authored conditional request entries no longer overwrite or delete a
caller-supplied native parameter. The conditional only supplies the
default; a matching case (for example deribit
trailingAmount→trailing_stop) still applies. Deribittrigger: "index_price"now reaches the venue instead of being stripped. - Binance and binanceusdm unified
watch_*channels author the provider's own stream names ({symbol}@depth20@100ms,{symbol}@trade,{symbol}@miniTicker) instead of CCXT message hashes such asorderbook::{symbol}. The venue acknowledged those hashes and then delivered nothing, so a subscription looked healthy and stayed silent; live tests now pin frame arrival, not the subscribe acknowledgement. binancecoinm authors no market-stream templates and fails loud with:no_channel_templatesrather than subscribing to a name that cannot deliver. One residual is recorded in the venue carve register: on the authored USD-M/wshost,watch_ticker's@miniTickerstill acks without delivering. - Deribit mutation-lifecycle compensation holds when a mutating call fails
after the request left the process — a transport raise, a non-JSON 200, or
a redaction failure. The attempted act is tracked from the moment the request
is built, so compensation never reports that no call was needed; when the
order id is unrecoverable it sweeps the run's session label via
private/cancel_by_label. Lifecycle plans are rejected up front when a mutating step follows cleanup without its own authorized compensator.
0.5.0 - 2026-08-18
Added
- Deribit current-REST mutation adjudication records reviewed safety and reachability decisions for every raw mutating operation. Its capture task executes only an approved, reversible buy/cancel lifecycle on testnet, redacts credential material, verifies cleanup and the final state, and feeds the registered observations into the reality oracle; unsafe, value-moving and persistent operations remain explicitly unverified in the production verification ledger.
Bourse.Positiongainedbase_quantity— the absolute position size in the base currency where the venue reports it natively (currently populated for deribit futures fromsize_currency;nilelsewhere).Bourse.Errorgained a dedicated:invalid_noncetype: venue errors that resolve through the InvalidNonce class (nonce/timestamp drift, e.g. Binance-1021outside recvWindow) now carryretry_class: :networkandshould_retry?/1true, instead of being folded into the terminal:authentication_error/:authbucket. Genuine credential rejection (:authentication_error,:permission_denied) stays non-retryable.:invalid_noncenever melts the circuit breaker: clock/nonce drift is a client-side condition, not venue downtime, so it cannot open the exchange-wide circuit.
Changed
Breaking (lighter): transfer history rows changed shape.
TransferEntrytimestamp/datetimeare now read as milliseconds (previously mis-scaled 1000× as seconds),from_account/to_accountcarry account-index strings (previously the route strings"perps"/"spot", which moved intoinfoasfrom_route/to_route), andfee.currencyis pinned to"USDC"— the venue's signed payload names the fieldusdc_fee, so the fee is USDC-denominated regardless of the asset moved (previously derived fromasset_id).Breaking (lighter): trade history rows changed shape.
Tradetimestamp/datetimeare now milliseconds (previously mis-scaled 1000×),side/taker_or_maker/order_idare populated from the account's role in the fill (ask/bid account matching),typedropped tonil(the venue'stype: "trade"is not an order type), andfeeappears when the venue returnsmaker_fee/taker_fee. The fee VALUE is a raw pass-through with an unverified scale — the provider types itint32and no observed testnet fill carries the field; seedocs/prod-verification-ledger.md(C-T546i) before trustingfee.coston lighter.Lighter
Balance.free["USDC"]is populated from the account-levelavailable_balance(previously unmapped);usedremains the per-assetlocked_balance, which does not include cross-margin encumbrance — see the C-T546 register note on the two accounting layers.Time-window translation is now asserted against returned rows, not the absence of an error:
untilactually reaches the wire on binance-family, okx and deribit reads, and binance spot no longer dropssinceon its klines/trades reads (task 553's live returned-window matrix pins both boundaries per probed venue/method).Deribit
fetch_tradeshonorsuntil: the authored request now mapsuntil → end_timestampand an until-only call routes ontoget_last_trades_by_instrument_and_time(previouslyuntilsilently never reached the wire and the newest page came back; caught live by the promoted time-window probe, C-T553f).Emulated configuration reads no longer answer
{:ok, nil}when the underlying plural has no row for the requested symbol:fetch_trading_fee,fetch_leverage,fetch_margin_modeandfetch_market_leverage_tiersnow return{:error, %Bourse.Error{type: :exchange_error}}naming the symbol. Live blast radius today:fetch_leverage(binance, binancecoinm, binanceusdm) andfetch_market_leverage_tiers(binance); the other two handlers are defensive uniformity with no venue currently emulating them.fetch_positiondeliberately keeps{:ok, nil}— a missing row means the account is flat, which is a valid answer. Emulation errors also now carry the venue id string inBourse.Error.exchange(previously a boot-dependent atom or:unknown).binancecoinm trading fees are singular-only:
fetch_trading_fee/2wires the symbol-mandatoryGET /dapi/v1/commissionRate(COIN-M has no all-symbols commission read), andfetch_trading_fees/1now refuses with:not_supportedinstead of surfacing the venue's raw-1102missing-symbol error. The venue-agnostic parse compensation that wrapped a loneTradingFeestruct into a symbol-keyed map is retired.Ledger parsing is route-scoped: venues whose ledger endpoints carry different type vocabularies per route (OKX
account/billsvsasset/bills, binance-familyincomevs the optionsbillendpoint) parse each response with the vocabulary of the endpoint that produced it. Binance options ledger entries no longer hard-fail (the venue documentstypeas a free string — it passes through). Generatedparse_ledger_entry/2on routed venues requiresopts[:route]and fails loudly on an unknown route rather than parsing with the wrong vocabulary.Ledger
typecarries one registered cross-venue taxonomy: sixteen unified values (trade,fee,deposit,withdrawal,transfer,funding_fee,realized_pnl,liquidation,settlement,interest,rebate,commission,cashback,referral,conversion,bonus) plus venue-faithful snake_case labels for events outside the registry. The same economic event now emits the same value on the remapped venues: OKX bill type8and binance-familyFUNDING_FEEboth emitfunding_fee;REALIZED_PNLisrealized_pnlinstead of the flattenedtrade;AUTO_EXCHANGEisconversion. OKX account-bills labels are derived from the venue's ownaccount/subtypesrecording (mechanically re-derived in the suite, not asserted in prose). Bybit and hyperliquid are reconciled onto the same set: bybitLIQUIDATION/SETTLEMENT/DELIVERY/INTERESTand transfer events emit their registered classes, hyperliquidwithdraw/vaultWithdrawemitwithdrawalandvaultDepositemitsdeposit, and the coverage suite rejects any venue-specific label whose raw event carries a registered class. BybitSETTLEMENTemitsfunding_fee(the venue's transaction-log enum pins it as perpetual funding settlement), theBONUSfamily emitsbonus, andCURRENCY_BUY/CURRENCY_SELL/CONVERTemitconversion; hyperliquidrewardsClaimemits the venue-faithfulrewards_claim(the L1 schema defines it as builder/referrer fee claims, not a promotional credit).Breaking (deribit): positions carry one unit contract. Future
notionalis the venue's quote-USDsize(it was previously sourced from the base-denominatedsize_currency),base_quantitycarries the base size, andcontractsis derived as|notional| / contract_sizefrom loaded market metadata. Withoutload_markets, deribit futurecontractsandcontract_sizeare nownil(previouslycontractscarried the raw quotesize). Binance COIN-Mnotionalremains coin-settled under a named carve exception.Upgrade note — this one changes a number, not a shape. A denomination change does not fail at a match site the way a row-shape change does: a consumer that reads
position.notionalfor deribit futures keeps compiling and keeps running, and silently computes exposure in the other currency at the other magnitude. Re-check everynotionalconsumer on a money path (exposure, risk, hedging, sizing), not just the ones that pattern-match the struct. If you build the exchange without callingload_markets— a common shape for a long-lived connection process that constructs once and reads positions on demand — thencontractsandcontract_sizearenilon every position read on that path; attach markets at construction, or readnotional/base_quantity, which do not depend on market metadata.
Fixed
- Deribit trade
coston symbol-less reads (fetch_my_tradeswithout a symbol) is payload-derived: inverse fills emitamount / priceinstead ofamount * price(previously off by ~2.5e9x on BTC-PERPETUAL), options keep the base-coinamount * priceidentity, and the classifier consults loaded markets before degrading to instrument-id parsing. Unified endpoint identities now include every section plus the HTTP method and path, so same-path routes under different methods or sections no longer collide. - Unified rate-like fields carry pinned units end-to-end: implied volatility
and funding/margin rates are fractions, ticker/option
percentageis percent points, and the unit invariant now grades emitted parser output against frozen venue bodies rather than authored declarations alone.
0.4.0 - 2026-08-12
Added
- New venue:
coinbaseexchange(api.exchange.coinbase.com), the client's first deliberately public-only venue —fetch_ohlcvandfetch_ticker, no auth path (capabilities.has: 2 supported, 111 explicitly unsupported). Unified symbols (ETH/USD) route to Coinbase's dash product ids; requests spanning more than 300 rows are paginated at 299 inclusive intervals per page and merged back into the venue's newest-first wire order. Live-recorded venue behavior is documented in the authored spec and carve register: the series is sparse (trade-less intervals are omitted) and the forming bucket appears once it contains a trade. A follow-up completed half-open candle windows, covered unaligned page tails, and relaxed the credential gate for public-only venues. binancecoinmgrew the venue surface it previously declared unsupported: order history, leverage tiers, open interest, trading fees, ledger and ADL quantile reads.lighternow exposes balance and positions (previously absent despite the account response carrying both), plus liquidations, trades, transfers and withdrawal history recordings.- Provider-operation reality capture: recorded-evidence manifests for provider
operations, proven on Deribit public REST with a populated success, a
get_timesuccess and an invalid-parameter error fixture.
Changed
lighterdeposit history (fetchDeposits) now requires a caller-suppliedl1_address— the venue endpoint cannot infer the account. Callers that omitted it must pass it explicitly.
Fixed
lighterfunding rates are scaled from the venue's percent representation to the unified fraction.- Binance-family plural funding reads no longer stamp a fabricated 8h interval onto instruments that never fund; the default is gated on perpetuals.
- Bulk list reads return unified symbols instead of venue-native ones, so their rows join against other unified results.
- Binance futures capability declarations corrected:
binancecoinmsetPositionMode/setLeverageandbinanceusdmfetchLeverage(viasymbolConfig) are served and now declared. binancecoinmmaps the self-trade-prevention statusEXPIRED_IN_MATCHtocanceled, per the venue's STP contract.binanceusdmleverage reads share thefetch_margin_modevocabulary formarginModevia the enum map.- String-keyed market rows are restricted to string ids, preserving the Task 215 rejection semantics.
Removed
- The trading-domain layer (
Bourse.OptionProposal,Bourse.OptionReadiness,Bourse.OptionSaga,Bourse.PortfolioRiskand their submodules, tests and the domain-boundary guard) moved to its own repository, https://github.com/ZenHive/bourse_trading, which consumes this client's published Hex package. The package contents are unchanged — these modules were never in the tarball; the@domain_prefixesexclusion machinery inmix.exswent with them.
0.3.0 - 2026-08-10
Security
- The Lighter signer's Go module pinned
go-ethereum1.15.6 andgnark-crypto0.14.0 throughlighter-go, carrying six advisories (four p2p denial-of-service issues, an ECIES public-key validation gap in the RLPx handshake, and unchecked memory allocation during gnark-crypto vector deserialization). Both are now overridden togo-ethereum1.17.0 andgnark-crypto0.18.1. Signing is unchanged: an authenticated testnet call was verified againstzklighterbefore and after the bump, and the parser/framing coverage gate still holds.
Fixed
The Lighter signer helper could not talk to the BEAM on Windows. Windows opens the standard streams in text mode, which rewrites
0x0Aon the way out and stops reading at0x1A— both corrupt the length-prefixed binary frames the Port protocol exchanges, so the helper exited and the nextPort.command/2raised:epipe. The helper now putsstdin/stdoutin binary mode before reading its first frame. The build itself was also broken on that platform::erlang.system_info(:system_architecture)answers"win32"there — the OS, not the CPU — somix ccxt.build_lighter_signernow resolves the Windows architecture from the environment instead.Bourse.WS.connect(exchange, :private)returned an open but unauthenticated socket on every venue. The auth patterns and the state machine that drives them both existed; nothing called them from the facade. Private subscriptions on such a connection are accepted by some venues and simply never deliver, so the failure surfaced as an empty stream rather than an error. A:privateconnection now completes the venue's handshake before it is returned, and a rejected handshake closes the socket and surfaces the venue's reason. Confirmed differentially against bybit, deribit and okx: each private subscribe is accepted on the authenticated connection and rejected onauthenticate: false.Deribit's refusal of a subscribe is an empty
resultlist, not an error object — an envelope otherwise identical to success, whichsubscribe/3read as acceptance. Observed ontest.deribit.com: the sameuser.portfolio.btcsubscribe returns"result" => []unauthenticated and the channel name back when authenticated.The listen-key pre-auth step in
Bourse.WS.Auth.ListenKeyraisedBadMapErroron the authored binance config, which carries its endpoints as a map where the module expected a list.The binance family had no working private WebSocket path at all, and both halves failed differently.
binanceusdmneeded a REST round-trip nothing performed.connect/3now issues the listen key before opening the socket and connects to the URL the key produces, andBourse.WS.Adapterrefreshes it on the venue's schedule. The endpoints it resolves are the generated raw endpoint names; the authored config previously named CCXT methods that match no function in this client, so resolution looked complete and could not be called. Confirmed againstdemo-fstream.binance.com: an order placed on the account producedORDER_TRADE_UPDATEon the authenticated connection, and a syntactically valid but wrong key produced nothing while reporting:connectedthroughout — which is whyauthenticate: falseis now refused for this pattern with{:error, {:auth_not_optional, :listen_key}}rather than returning a socket that cannot be authenticated later.binancespot was authored against an endpoint the venue has removed: Binance retired the spot and margin listen keys on 2026-02-20, andPOST /api/v3/userDataStreamanswers HTTP 410 Gone. The private section is re-authored onto the venue's WebSocket API — hostws-api.binance.com/ws-api/v3, opened by a signeduserDataStream.subscribe.signaturerequest — under the new:ws_api_signatureauth pattern. Confirmed againstws-api.testnet.binance.vision: with the request sent, an order producedexecutionReportandoutboundAccountPosition; without it, the identical order produced nothing.binancecoinmhad no WebSocket configuration at all, soBourse.WS.connect/3answered{:error, :unsupported_exchange}for a venue that streams and issues listen keys like its USD-M sibling. Its authored slice now carries the delivery stream hosts and theurl_paramauth mechanism, and its listen key resolves fromdapiPrivate_*rather than the linear endpoints — COIN-M and USD-M share one demo account and one key pair but are separate wallets with separate user data streams, so the other half's key connects and delivers nothing. Confirmed againstdemo-dstream.binance.com: an order placed and cancelled on the COIN-M wallet producedORDER_TRADE_UPDATEfor both transitions on the keyed socket, while a decoy key reported:connectedand received nothing.Bourse.WS.connect/3forcedmarket_type: :spotwhen resolving a listen key endpoint, so a venue that trades no spot resolved either an endpoint it does not serve or none at all. The market type now comes from the venue's own authored default unless the caller names one.Fifty-one declared unified reads resolved to no parser slot: their descriptor return tokens were plural collection names (
LeverageTiers,Liquidations,MarginModes,OpenInterests,IsolatedBorrowRates) that the return-type table did not recognise, so the reads fell through and returned the provider's raw transport envelope inside{:ok, …}. The alias table now maps each plural token onto its singular parse type, thelast_priceparse type andBourse.LastPriceare wired,fetchLeverageTiersis forced to a list return so a flat tier body is not collapsed into one all-nil record, andfetchMarginModes/fetchOpenInterests/fetchIsolatedBorrowRatesre-key their row lists by symbol likefetchTickers.Parser aliases are now gated on registered venue recordings rather than declared blind. Binance leverage-tier brackets are flattened to per-symbol tier rows, Hyperliquid open interest is annotated from
metaAndAssetCtxs, and reads that cannot be satisfied by a single provider response or verified against a sandbox — DeribitfetchLiquidations(settlement history, not liquidations), the Binance composite position / dust / isolated-borrow reads, and OKX single deposit/withdrawal lookups — are marked unsupported with carve records instead of silently mis-parsing.Bourse.create_order/6on the binance family submitted a requested stop as a naked market order.time_in_force,reduce_only,trigger_priceandstop_loss_priceare unified options the futures write path had no authored binding for, so they were dropped before signing — an order meant as a protective stop reached the venue with neither its trigger nor its reduce-only flag and executed immediately as an opening market sell. All four now reach the signed request, and a conditional order routes to Binance's Algo Order API (POST /fapi/v1/algoOrder) rather than the regular order endpoint, which rejects migrated stop types with-4120. Confirmed againstdemo-fapi.binance.com: an ETHUSDT stop-limit remainedNEWcarrying its requested trigger,reduceOnly=trueandGTC; the request is pinned by an accepted-request golden and-4120on the retired route is pinned as a recorded exchange error.take_profit_pricewas accepted bycreate_order/6and then discarded on the same path, so a take-profit order was also sent as a naked market order. It now routes to the Algo book and resolves to the venue'sTAKE_PROFIT/TAKE_PROFIT_MARKETtypes by the caller's order type. Binance's Algo contract accepts one conditional leg per order, so passingstop_loss_priceandtake_profit_pricetogether is refused up front withBourse.Error:invalid_parametersnaming the two options — two-leg protection on this venue is the separate order-list surface, not an algo order.Binance USD-M conditional orders were write-only. Once placed on the Algo book they were invisible to every read and cancel path:
fetch_open_orders/2returned only the regular book,cancel_order/3answered:order_not_foundfor a live algo order, andcancel_all_orders/2left the algo book resting. Authored order-book routes now span both books —fetch_open_ordersmerges the two responses,cancel_all_ordersbroadcasts to both, andcancel_ordertries the regular book and falls through to the algo book on:order_not_found(and only on that error). The algo cancel sendsalgoIdrather thanorderId.Bourse.cancel_all_orders/2on binance USD-M used a route that cancelled nothing and then failed to parse the venue's acknowledgement. The call reached a spot-shaped endpoint, left resting FAPI orders untouched, and returned a parse error built from an all-nil order because Binance answers a bare{"code": 200, "msg": "The operation of cancel all open order is done."}envelope, not an order row. It now sendsDELETE /fapi/v1/allOpenOrders?symbol=…, treats thecode=200body as a success acknowledgement, and preserves-1121for an unknown symbol. Confirmed againstdemo-fapi.binance.com: three resting orders were cancelled andfetch_open_orders/2returned zero afterwards.Bourse.set_margin_mode/3never sent the symbol. On the generic binance client both the symbol and the margin mode were authored as unresolved identifier references, so every argument variant returned Binance-1102while the rawPOST /fapi/v1/marginTypesucceeded with the same credentials. On the dedicatedbinanceusdmclient the same shape was worse: the unified symbol was written intomarginType, so the venue receivedmarginType=ETHUSDT. The unified symbol now becomessymbol=ETHUSDTand"cross"/"isolated"map to the provider valuesCROSSED/ISOLATED. Verified live in both directions on USD-M and COIN-M demo, with the account restored to its original mode.Bourse.fetch_balance(exchange, type: :swap)on the generic binance client read the Spot Testnet wallet. Atom market types did not participate in endpoint selection at all, so:swapfell through to the spot route: futures keys got a 401 invalid-key response from the spot host and spot keys returned the spot asset list — silently the wrong account.fetch_swap_balance/2was also unsupported, leaving no unified route to the USD-M wallet.:spotnow reaches Spot,:swapreaches USD-Mfapi/v3/account,:delivery/:inversereach COIN-Mdapi/v1/account, and:linearnormalizes to:swap. All three succeeded live against their matching sandbox hosts;:marginis a named exclusion because Spot Testnet serves no SAPI host.An authored request parameter whose value was the boolean
falsewas treated as absent and replaced by the authored default. The lookup that walks a parameter's source and its fallback sources stopped on the first truthy value, sofalsenever survived to the wire. The visible case wasset_position_mode/2onbinanceusdm: asserting one-way mode lostdualSidePosition=falseand failed-1102instead of reaching the venue. Presence is now decided bynil, sofalseis sent. Confirmed againstdemo-fapi.binance.com: re-asserting the live one-way mode now reaches Binance's business validation-4059with the boolean intact.Bourse.fetch_funding_rate/2leftintervalnil on all three binance venues. The funding-cadence carve had been confronted for bybit and hyperliquid and never for binance, so the current-rate read returned a%Bourse.FundingRate{}with no cadence at all — anything annualizing or summing funding had nothing to multiply by. The current premium-index row is now joined to the venue's own per-symbol funding-info list (fundingIntervalHours), falling back to Binance's documented eight-hour cadence only when the venue publishes no adjusted row for that symbol. OKX derives its cadence from the provider's ownnextFundingTime − fundingTimepair instead of an authored constant. Live sandbox calls returnedinterval: "8h"on all four surfaces, against an observed pre-changeinterval: nil.The funding-interval join was wired to the USD-M list for every symbol and ran only on the singular read. On the generic binance client an inverse symbol looked its cadence up in the USD-M funding list, which does not carry it, and
fetch_funding_rates/2— the plural read most consumers use — was never enriched at all and kept returninginterval: nilfor every row. Inverse andfuturemarket families now resolvedapiPublicfor both the premium index and the funding-info list, and the plural read is enriched row by row. Verified live: 857 symbols enriched in one call — 443 at4h, 413 at8h, 1 at1h. The dedicatedbinancecoinmclient serves the inverse read; on the generic client an inverse symbol still resolves to the venue's linear pair form, which the premium-index endpoint answers with an empty list — a known open defect.The funding-interval join silently returned the wrong answer when it could not identify the instrument. If no native symbol could be resolved from the parsed row or the caller's params, the lookup matched nothing and fell through to the eight-hour default, stamping a plausible cadence onto a row it had never matched. It now refuses with a
Bourse.Errorinstead of joining nothing.Bourse.fetch_funding_history/2on binance returned{:error, {:no_field_map, …}}— the parse type was declared and wired with no authored field map behind it, so a declared read failed on a successful venue response. The USD-M income rows now parse into%Bourse.FundingHistory{}withid,code,amount,timestampand normalizedsymbol, pinned against a registered live row (tranId 1380186948815340520,-0.01286054 USDT,BTC/USDT:USDT). The three remaining unparsed pairs — binance and OKXfetch_margin_adjustment_history/2, hyperliquidfetch_funding_history/2— stay explicitly unsupported rather than shipping a field map guessed from documentation: producing a row on those venues needs an isolated position and a margin mutation, or a position held across a funding boundary, so each is recorded indocs/prod-verification-ledger.mdwith the exact call that would close it.Unified endpoint selection ignored version priority and the private half of each venue family. The section preference lists were ordered so that
fapiPublicoutrankedfapiPublicV2/V3, spot listed no private orsapisections at all, and options listed noeapiPrivate— so a mapped method whose only route lived in a versioned or private section could not be reached by any documented parameter set. The lists are now ordered newest-version-first and carry the private sections, and ten previously unreachable binance-family methods gained authored selection rules:fetchOpenOrder,fetchOrderTrades,fetchMyLiquidations,fetchConvertTrade,fetchConvertTradeHistory,fetchTradingFee,fetchOptionMarkets,fetchLeverages,fetchAccountPositionsandfetchPositionsRisk.Bourse.fetch_leverages/2onbinanceusdmcollapsed the venue's rows into a single all-nil record. The read shared the singularfetchLeverageparse branch, which unwraps one row, and the response was not recognized as a list body — so a per-symbol leverage read returned one row with no symbol on it.fetchLeveragesnow re-keys its rows by unified symbol likefetchTickers,fetch_account_positions/2andfetch_positions_risk/2are recognized as list bodies, and a leverage row whose symbol must be back-filled resolves against the loaded markets first, so a native id that exists in both the linear and inverse catalogs resolves to the one the answering endpoint actually serves.Lighter's private order reads demanded a symbol the venue documents as optional.
market_idwas authored as unconditional dynamic construction, sofetch_open_orders/2andfetch_closed_orders/2could not be called without one, although the provider's OpenAPI marks it optional on bothaccountActiveOrdersandaccountInactiveOrdersand states that omitting it returns orders across all markets. A symbol-less call now omitsmarket_id; a symbol-scoped call still resolves and sends the numeric market id. Confirmed againsttestnet.zklighter.elliot.ai: both endpoints answered 200 withmarket_idomitted and with market0supplied.
Changed
Lighter order statuses now normalize to the unified vocabulary. The authored slice declared
enum_passthrough, so%Bourse.Order{}.statuscarried the venue's own strings verbatim — a consumer matching on"canceled"missed"canceled-post-only","canceled-self-trade","canceled-reduce-only"and nine further cancellation reasons, and"filled"never matched"closed". All sixteen documented values are now enumerated: the twelve cancellation variants map tocanceled,filledtoclosed, andopen/pending/in-progresstoopen. The venue's own string remains available oninfo.An ambiguous multi-endpoint refusal now names the parameter sets that would resolve it. The error previously said only to author a default family or pass
type/subType/symbol; it now probes the documented selection parameter sets against the method's own endpoints and reports the ones that work — or states that none does, which is a different and more actionable failure.
Added
Bourse.WS.authenticate/2, the handshake as a callable step, for connections opened withauthenticate: falseor credentials that expired mid-session. It returns the venue's session metadata (%{ttl_ms: …}where disclosed), which is whatBourse.WS.Adapterschedules re-auth from.%Bourse.WS{}carries an:authfield recording which pattern the venue accepted and what it disclosed about the session. A public connection and one that connected without a handshake both leave itnil.Bourse.WS.ListenKey, the listen key round-trip and its refresh, andconnect/3's:pre_auth_optsfor the request options that belong to it — a timeout or a base URL override — rather than to the socket.Bourse.OrderListand three unified reads for it —fetch_order_list/2,fetch_order_lists/2andfetch_open_order_lists/2. Binance OCO groups have their ownorderListId, client id, contingency type, lifecycle status and transaction time, and theirordersentries are references rather than complete order rows — so they were invisible to the unified surface entirely: no read returned them, and they did not appear infetch_orders/2orfetch_open_orders/2either. The new type is venue-neutral, because Binance also publishes OTO, OTOCO, OPO and OPOCO groups. Routing and the error contract are confirmed againsttestnet.binance.vision:/api/v3/allOrderListand/api/v3/openOrderListreturned successful empty arrays, and/api/v3/orderListwithout an identifier returned-1102namingorigClientOrderIdandorderListId. The populated-row projection follows Binance's own contract; the test account carried no order group, so those rows are not reality-verified yet.Derive
fetch_transfers/2, mapped to the venue's ERC-20 transfer history. The capability was authoredfalsewhile the endpoint exists and is enabled:tx_hashbecomes the transfer id,asset,amountandtimestampkeep their provider values, andis_outgoingselects the source and destination subaccount. Confirmed againstapi-demo.lyra.finance: the authenticated call returned HTTP 200 for demo subaccount 144422, with an empty event list.Three sibling capabilities were confronted in the same pass and stay
falsedeliberately, each with a recorded reason rather than an unexplained gap.fetchLiquidations— Derive's liquidation history describes portfolio auctions with no instrument, price, side or contract size, so it cannot produce a%Bourse.Liquidation{}without inventing them.fetchBorrowInterest— the interest history is a two-sided subaccount cash ledger with no borrowed currency, principal or rate.fetchSettlementHistory— the endpoint does serve settlement rows, but this client has no typed settlement-history return contract, and enabling the route would hand back a raw transport map.
0.2.0 - 2026-08-06
Fixed
Findings from the 2026-08-04 live venue sweep, which compared this client against CCXT JS endpoint by endpoint on the same testnets. Each was generalized to the defect class rather than patched per venue.
- Unified reads returned raw venue envelopes, collapsed multi-row responses to a single row, or keyed results by un-normalized venue symbols. A whole-surface contract guard now holds every unified read to the same shape.
fetch_canceled_orders/2,fetch_closed_orders/2andfetch_orders/2returned identical, unfiltered rows.- Unified read parsing raised on legitimate venue responses instead of returning a typed error.
- Authored enum slices rejected real venue values; a single unmapped order status disabled four Hyperliquid read methods.
- Field maps were present but inert: populated venue fields arrived as
nil, and one scalar parse dropped the year from a timestamp. - Time-window request params (
since,limit) did not reach the venue on every venue that accepts them. Bourse.WS.subscribe/2reported success when the venue rejected the subscription, and its return shape varied by venue.- Funding cadence came from an authored constant rather than observed venue data — Deribit was recorded as 8h for an hourly venue, overstating funding roughly eightfold in anything that multiplied by it.
Reported by consumers against the published package:
Bourse.Testnetexited the calling process when the registry was not running. Because the registry is deliberately not an application child, a consumer callingregister_all_from_env/1from its owntest_helper.exslost its entire suite before a single test ran. Writes now return{:error, :not_started}and reads raise anArgumentErrornamingstart_link/1, instead of aGenServerexit and an opaque ETS badarg.- Derive's ticker mapped
high,low,changeandpercentagefrom astatsobject the venue publishes on neither its demo nor its production host, and documents nowhere — an inherited carve whose only surviving evidence was a January 2025 sample. The four fields are recorded as absent, registered as carveC-T560d.
Packaging and attribution, found while auditing the extraction:
- The tarball shipped Bourse.Spec.Promotion and its two helpers — 1,049 lines
of repo-internal tooling that reads deliberately unpackaged reality manifests,
so in a consumer project it could only fail on missing files.
Path.wildcard/1yields directory entries, Hex expands a listed directory recursively, andlib/bourse/specmatched no exclusion prefix. Directory entries are now dropped outright rather than excluded one prefix at a time, and the guard asserts against the built tarball, where the expansion is actually visible. - The tarball also shipped the oracle / recording / replay / drift cluster, which
reads
test/fixtures/**andpriv/reference_cache/— neither of them packaged. Two of those modules namedReq.Plug, which exists only from req 0.7 and only behind theonly: [:dev, :test]:plugdependency, so a consumer resolving~> 0.6.1compiled the package with undefined-module warnings. The cluster is now excluded from both the tarball and hexdocs, and a new gate scans every shipped module's AST for references to dependencies a consumer may not have.
Changed
Bourse.Testnetno longer starts as a child ofBourse.Application. It is a credential registry for sandbox testing and has no place in a consumer's always-on supervision tree; callers that want it start it explicitly.
Added
Bourse.Testnet.started?/0, so a caller can ask whether the registry is running rather than discover it from a failure.NOTICE, shipped in the package. The authored venue specs still carry method and return descriptions taken verbatim from CCXT,docs.ccxt.comlinks included; CCXT is MIT, whose terms require the copyright notice to travel with that text. No such notice was ever tracked, in this repository or its predecessor — publishing the package is what made the omission consequential.A CI workflow running the offline gate — format, warnings-as-errors, Credo, Doctor, Sobelow, the offline suite, the reality oracle, the documentation claims,
deps.auditand Dialyzer. Until now those ran only on the maintainer's host and through dispatch review, so an outside pull request and a fresh clone had no gate at all.This repository.
bourseis extracted from the working repo it grew up in, which stays behind as the private authoring workbenchbourse-workbench. Carried over: the client (Bourse.Exchange/Dispatch/HTTP/Signing/Symbol/Unified/WSplus the unified response structs), the ten authored runtime specs, the verification layer (the ccxt.oracle_gate Mix task, the recorded response and accepted-request evidence, live drift checking), the spec-authoring and venue-promotion tooling, the authority corpus and its validators, and the trading domain layer.Left in the workbench: the complete version-pinned CCXT reference corpus (110 documents), the classification tooling and corpus-wide audits that can only be answered against it, and the task roadmap with its CHANGELOG gate. This repository carries a 15-document reference slice covering the supported venues, which its own offline tests read; both manifests pin the same upstream revision, so the two copies are checkable rather than silently divergent.
0.1.0 - 2026-08-03
First hex.pm release as bourse, succeeding the retired ccxt_client package.
Published before this repository existed, from the tree that is now the private
bourse-workbench history — there is no v0.1.0 tag here.
Added
- Ten provider-authored venue integrations —
alpaca,binance,binancecoinm,binanceusdm,bybit,deribit,derive,hyperliquid,lighter,okx— each generated at compile time from one complete owned JSON spec. Runtime support is a closed set: constructing any other exchange fails withunsupported_exchange. - Two API surfaces. Raw per-exchange endpoint functions pass exchange responses
through unchanged with signing, rate limiting, circuit breaking, and transport
handled. The unified
BourseAPI adds cross-exchange methods returning normalized structs, with bang variants and machine-readable descriptions. - Signing for every supported venue, including first-party signers for the three
DEX venues: EIP-712 for Derive, msgpack action hashing for Hyperliquid, and a
zk-Schnorr Port helper for Lighter.
Bourse.Signingdispatches the authored recipes; no signing behavior is inferred at runtime. - WebSocket support via
Bourse.WS— a thin wrapper overzen_websocketdriven by authored per-exchange subscription and auth patterns. - Discovery and agent integration:
Bourse.describe/0-2for method signatures, parameters, errors, and return shapes, plusBourse.MCP.tools/0for MCP tool autodiscovery. - Operational layers: per-credential weighted rate limiting with response-header
feedback, per-exchange circuit breakers, telemetry events, and sandbox
resolution for all ten venues via
Bourse.Exchange.new/2.
Changed
- Renamed from
ccxt_clienttobourse, with theCCXT.*namespace becomingBourse.*. See the migration notes in the README. - Interpretive judgment moved out of the runtime and into the authored specs. The heuristic signing classifier, symbol pattern inference, and long-tail fallback are removed; the runtime reads authored fields instead of guessing.
- Correctness is verified against recorded venue reality — registered response recordings, accepted-request goldens, and recorded exchange errors — rather than against third-party client behavior.
Fixed
- Alpaca
fetch_ohlcv/3-4had no working call shape: the default path returned an empty list, failing silently as success, and the documentedsinceoption produced an HTTP 400. The authored request slice now emits a real dated window.
Packaging
- The published package carries the library and the ten authored specs. The
repo-internal authoring and audit tooling is not shipped;
mix ccxt.build_lighter_signer, the prerequisite for private Lighter calls, is the one task consumers receive.