Packages

Pure-function trading analytics for Elixir — Black-Scholes pricing and IV, options chain analytics (max pain, GEX, skew, surfaces), funding rates, basis, volatility estimators, risk metrics, position sizing, orderflow, and portfolio analytics. Self-describing for AI agents.

Current section

Files

Jump to
zen_quant CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
Completed tasks and milestones. For upcoming work, see the project roadmap.
---
## v0.2.0 — 2026-07-25
First Hex release. Milestone `v0.2` ("Pure analytics core") complete: 27
discoverable modules, 1,024 unit tests plus 66 live integration tests against
Bybit, Deribit and OKX, 92.47% line coverage.
### Renamed
- **`Quantex``ZenQuant`** (app `:quantex``:zen_quant`). Every module moved
from `Quantex.*` to `ZenQuant.*`. This is a hard break for anyone on the old
name; there is no compatibility shim.
### Added since v0.1.1
- `ZenQuant.Options.Pricing` — European Black-Scholes-Merton price, analytic
greeks, and an implied-volatility solver. Time is a caller-supplied year
fraction; no calendar convention is imposed.
- `ZenQuant.Options.Surface` — sparse implied-volatility surfaces from a
pre-fetched chain.
- `ZenQuant.Options.Skew` — deterministic term structures from normalized skew
observations.
- `ZenQuant.Options.Probability` — terminal risk-neutral probabilities from
vertical spreads.
- `ZenQuant.Options.BlockTrades` — block-trade aggregation. Aggressor side is
preserved when supplied and left unknown when absent; it is never inferred
from trade size.
- `ZenQuant.Options.Snapshot` — structured options briefing over a chain, with
independently-failing sections.
- `ZenQuant.OrderBook` — shared order-book level contract. Unknown level shapes
return `{:error, {:invalid_orderbook_level, level}}` instead of degrading
silently.
- `ZenQuant.Backtest` — deterministic strategy evaluation over recorded JSONL.
- `ZenQuant.Execution`, `ZenQuant.OrderState`, `ZenQuant.MeanReversion`.
- `ZenQuant.Options.Chain` — builds and enriches an option chain from a payload
the caller already fetched, so chain analytics no longer require an exchange
client. Legs that can be read but not priced (expired, at expiry, missing
spot or IV) are marked `{:unpriceable, reason}` and kept by default, so open
interest and GEX aggregates stay complete; `on_unpriceable: :error` opts into
failing loudly instead.
- `ZenQuant.Options.atm_iv_term_structure/3` and `moneyness_skew/3` — the ATM IV
term structure and moneyness skew straight from a chain, at caller-supplied
moneyness levels and against a caller-supplied reference date. Output feeds
`Options.Skew.term_structure/1` without reshaping; an expiry with no readable
IV is labelled as such rather than dropped or reported as a number.
### Changed
- **No application module.** The package no longer declares `mod:` — it started
an empty supervisor for no reason. ZenQuant is a pure library and starts
nothing.
- **Corrected the dependency claim.** The README, package description, and root
moduledoc previously said "zero runtime dependencies". `descripex` is a real
runtime dependency (it powers `describe/0..2` and `__api__/0`). The docs now
say so.
- `AGENTS.md` is no longer shipped in the Hex package or the docs. It is
generated contributor tooling, not library documentation.
- ex_doc now groups the modules into nine sections instead of one flat list;
`README.md` is the docs landing page.
- **`Funding.compare/1` and `rank/2` no longer assume an 8-hour funding
cadence.** Both annualized every venue's rate on a hardcoded 8h period, which
overstates a venue on a shorter cadence by the ratio between the two. Each
rate map now supplies its own `:funding_interval_hours`, matching
`carry_candidates/2`, which already handled cadence per venue. A rate without
a positive cadence is omitted rather than annualized on a guess — this is a
breaking change for callers that relied on the 8h default.
- **`Execution.best_price/2` no longer reports arbitrage across incomparable
venues.** A USD-quoted book and a USDT-quoted book were compared as raw
numbers, so a stablecoin depeg read as free money. Venues now carry an
optional caller-declared `:group`; a cross between venues that do not share
one returns `arb_status: :incomparable` with `arb_opportunity: false`.
ZenQuant does not infer comparability from symbol strings.
- README rewritten: every example is a verified return value, `Risk.kelly` and
`Sizing.percent_equity` (neither of which exists) are gone, and the module
table covers the full public surface.
### Fixed
- Extracted shared concentration statistics; cleared the wave-5 clone and smell
regressions (`reach.check --arch --smells`: 185 → 0).
- The live options-chain integration test no longer depends on the time of day.
It derived expiries from system time, so it passed before a venue's daily
expiry roll and failed after it — a test that was green by accident for most
of each day. It now takes its reference from the payload's own
`creation_timestamp`.
---
## Code review + reach smell sweep
**Done** | 2026-07-25
Applied all findings from the pre-commit code review and `mix reach.check --arch --smells --strict` (185 → 0):
- `mix ci` AGENTS.md-freshness step now skips with a notice on machines without the claude-marketplace checkout (CI runners, other clones) instead of hard-failing.
- Completed the private `Options.Internal` consolidation: `accumulate_gex/6`, `get_raw_field/2`, `type_sign/2`, and the shared `gex_move_pct/0` GEX convention now live only there; GammaWalls/ZeroDTE/Options delegate. Gamma key precedence (atom before string) is now identical across all GEX paths.
- Removed 154 redundant `@doc false` on `defp`; fused 17 `Enum.map |> Enum.sum` into `Enum.sum_by/2`; `String.split parts: 2`, `match?/2`, `Enum.max_by`, `Enum.min_max`, `Stream.with_index`, and `:erlang.float/1` cleanups.
- Three findings declined with inline `reach:disable` reasons: the float `== 0` guard (head pattern would change match semantics) and two fixed-shape-map candidates (plain maps are the documented library contract).
---
## Phase 6: Runtime Connectivity (moved to trading dashboard)
**Closed** | 2026-07-25
ZenQuant remains a pure analytics library. The former runtime tasks were superseded here and re-homed in `trading_dashboard` Task 15:
- Task 26 liquidation monitoring → dashboard streaming/alerts; pure headroom calculation remains ZenQuant Task 13.
- Task 27 cross-exchange BBO and Task 29 WS aggregation → dashboard connection supervision and stream aggregation.
- Task 28 funding monitoring → dashboard subscriptions/alerts over ZenQuant funding calculations.
---
## Phase 7: Runtime Market Data (moved to trading dashboard)
**Closed** | 2026-07-25
- Tasks 30-31 and 33-34 → dashboard observability, latest-value state, bootstrap, and subscription ownership.
- Task 32 generic cache macro → superseded. Exchange-generic request caching belongs in `ccxt_client`; product-specific reconstructible runtime state belongs in the dashboard.
- The exchange remains the source of truth; no application cache task was ported blindly.
---
## Phase 8: Additional Recording Backends (not planned)
**Closed** | 2026-07-25
- Task 35 SQLite backend → superseded. ZenQuant keeps its dependency-free JSONL boundary; `trading_dashboard` already owns durable application state in Postgres.
---
## Phase 9: Testing Infrastructure (folded into feature work)
**Closed** | 2026-07-25
- Task 36 standalone fixture framework → superseded. Feature tasks own the smallest realistic fixtures they need, while tagged integration tests pin live ccxt_client shapes.
---
## Phase 11: Agent Composition (consumer-owned)
**Closed** | 2026-07-25
- Task 38 fixed cross-domain overview → superseded. The consuming dashboard or agent selects and composes ZenQuant's self-describing domain functions; bounded options briefing remains Task 4.
---
## Pin ccxt_client to 0.4.0
**Completed** | 2026-04-21
Pinned `ccxt_client` to the last pre-rewrite hex version after discovering that 0.6.x dropped the unified normalization layer ZenQuant depends on for cross-venue analytics.
**What changed upstream (ccxt_client 0.5 → 0.6):**
- `CCXT.Types.*` namespace removed — structs flattened to top-level (`CCXT.FundingRate`, `CCXT.OptionData`, `CCXT.Ticker`, etc.). Struct definitions still exist but no fetch method populates them.
- Unified fetch methods (`fetch_ticker`, `fetch_trades`, `fetch_order_book`, `fetch_funding_rates`, `fetch_option_chain`, `fetch_greeks`) no longer exist on per-exchange modules (`CCXT.Bybit`, `CCXT.Deribit`, `CCXT.Okx`). Top-level `CCXT.fetch_*(exchange, …)` returns raw HTTP envelope `%{status, body, headers}` — not parsed structs.
- `normalize:` option is gone. Phase 5 parsers that would populate unified structs are upstream-gated on ccxt_extract Phase 12 with no ETA.
- Symbol-bearing unified methods are marked "🔶 Evolving" in ccxt_client's own ROADMAP, which explicitly recommends raw per-exchange endpoints for production consumers.
**Why pinning over migrating:**
- Cross-venue analytics (`Execution.best_price/2`, `Execution.from_multi/1`, basis comparisons across Bybit/OKX) require a unified field contract. Raw endpoints expose exchange-specific field names (`bid1Price` vs `bidPx` vs `best_bid_price`), collapsing ZenQuant's cross-venue API into per-exchange conditionals.
- Nothing in 0.6.1 benefits ZenQuant today — WS layer (T91-T103) isn't used yet, hex publishing is irrelevant under path-dep usage, error struct improvements are minor.
- Migration cost without value: rewrite 7 integration test files + `ZenQuant.Options.Deribit` for no normalization benefit.
**What was done:**
- `mix.exs` — swapped `{:ccxt_client, path: "../ccxt_client", only: [:dev, :test]}``{:ccxt_client, "~> 0.4.0", only: [:dev, :test]}` with inline comment documenting the rationale and unpin condition.
- `mix deps.get` switched to hex release (2026-03-12 publish).
- ROADMAP entry added under "⏸ Deferred — Awaiting Upstream" to track the unpin trigger (Phase 5 parsers landing).
**Verification:**
- `mix compile` — clean.
- `mix test.json --quiet --summary-only` — 843/867 pass. All 24 failures are Deribit-only integration tests: 17 × circuit-breaker-open (transient network state from rapid re-runs), 7 × pre-existing `deribit_dvol_integration_test.exs` call-signature drift (tracked as an open bug).
- Non-Deribit integration suite (`funding`, `basis`, `mm`, `orderflow`, `execution`, `volatility`) — 44/44 green.
- Zero changes to ZenQuant `lib/` code; public API untouched.
---
## Volatility & Mean Reversion Bundle
**Completed** | 2026-02-25
Yang-Zhang volatility estimator and Ornstein-Uhlenbeck half-life for mean reversion detection.
**What was done:**
- Added `Volatility.yang_zhang/2` — OHLC estimator combining overnight, open-to-close, and Rogers-Satchell components.
- Created `ZenQuant.MeanReversion` with `half_life/1` — Ornstein-Uhlenbeck half-life estimation via OLS regression.
- Added Descripex `api()` declarations for both functions.
- Registered `ZenQuant.MeanReversion` in `lib/zen_quant.ex` discoverability modules list.
**Tests:**
- 10 unit tests for `yang_zhang/2` and `half_life/1` additions.
- 1 integration test for `yang_zhang/2` using Bybit OHLCV.
---
## v0.1.1 — API Metadata Completion + Manifest JSON Safety
**Completed** | 2026-02-25
Completed Descripex 0.4 metadata adoption across all discoverable modules and fixed JSON export compatibility for richer example values.
**What was done:**
- Added `returns_example` to every `api()` declaration in `lib/zen_quant/**` (all 127 discoverable functions now include concrete return examples).
- Added/updated `errors` metadata for functions returning `{:error, reason}` tuples (including `Volatility`, `Options`, `Options.Deribit`, `OrderState`, `Risk`, `Recorder.JSONL`, and `Recorder.Replay`).
- Added intra-module `composes_with` hints where output/input chaining is natural (for example `Funding.average -> annualize`, `Execution.from_multi -> best_price`, `Options.oi_by_strike -> max_pain`).
- Expanded manifest enforcement tests in `test/zen_quant/manifest_test.exs`:
- every function must have `returns_example`
- tuple/result-tuple functions with error contracts must declare `errors`
- Fixed `mix zen_quant.manifest` JSON export for non-JSON-native terms in hints:
- tuple values are now recursively converted to JSON-safe lists before encoding
- structs are preserved to use their existing encoders (`Date`, `DateTime`, etc.)
**Verification:**
- `mix compile --warnings-as-errors`
- `mix test test/zen_quant/manifest_test.exs`
- `mix test --failed`
- `mix format`
## Execution: from_multi + best_for_side
**Completed** | 2026-02-24 | Composable pipeline additions to `ZenQuant.Execution`
Two new functions bridging CCXT.Multi output to best-price analysis and extracting directional results.
**What was done:**
- `Execution.from_multi/1` — Convert `%{Module => ticker_map}` from CCXT.Multi into venue list for `best_price/2`. Extracts exchange name from module (e.g., `CCXT.Bybit``"bybit"`), reads bid/ask via dual atom/string key access.
- `Execution.best_for_side/2` — Extract best venue for a given trade side from a `best_price` result. `:buy` returns best ask, `:sell` returns best bid. Pattern-matched function heads.
- Integration tests updated to use `from_multi → best_price` pipeline instead of manual venue map construction.
- Fixed phantom `side` option in `best_price/2` Descripex hints (documented but not implemented).
**Key decisions:**
- Exchange name derived via `Module.split() |> List.last() |> String.downcase()` — no atom creation from external input
- `from_multi/1` passes through nil bid/ask — filtering happens downstream in `best_price/2`
- `best_for_side/2` uses pattern matching, no guard on invalid side atoms (let it crash)
**Tests:**
- 5 unit tests for `from_multi/1`: module-to-string conversion, string-keyed tickers, empty map, nil passthrough, pipeline composition
- 3 unit tests for `best_for_side/2`: buy side, sell side, from_multi pipeline
- 2 new integration tests: from_multi pipeline, best_for_side with live data
- 2 existing integration tests updated to use from_multi pipeline
- 0 Credo issues, 0 Dialyzer warnings
---
## Execution Primitives Bundle: best_price + OrderState lifecycle
**Completed** | 2026-02-24 | 3 tasks: `Execution.best_price/2` [D:3/B:8], `OrderState` lifecycle [D:3/B:8], `Aggregate.bbo/2` (merged)
Two new modules — cross-venue price comparison and immutable order state tracking. Pure functions for agents making routing/execution decisions.
**What was done:**
- `Execution.best_price/2` — Find best bid/ask across venues from pre-fetched ticker data. Filters invalid venues (nil/zero/non-numeric bid/ask), finds max bid and min ask across venues, computes spread/mid/spread_bps, detects arbitrage (best_bid > best_ask cross-venue). Returns sorted venue list. Merges roadmap tasks `Execution.best_price/3`, `Aggregate.bbo/2`, and core of `Best Price Routing`. Options: `min_venues:` minimum valid venue count.
- `OrderState.new/2` — Create initial order state from params map. Normalizes string sides/types to atoms. Validates required fields, positive quantity, price required for limits. Returns `{:ok, order_state}` with `status: :pending`.
- `OrderState.on_fill/3` — Apply fill event with VWAP accumulation. Overfill protection (caps at remaining qty). Status transitions: `:pending``:partial``:filled`. Rejects fills on terminal orders.
- `OrderState.cancel/2` — Cancel non-terminal order, preserves partial fill history. Sets `remaining_qty: 0.0`.
- `OrderState.summary/1` — Structured summary: id, status, fill_pct, avg_price, fill_count, remaining_qty.
**Key decisions:**
- Four statuses only: `:pending`, `:partial`, `:filled`, `:cancelled` — no separate `:open` (pragmatic for agents that get fills without explicit "order opened" events)
- Plain maps throughout (no structs) — consistent with ZenQuant's zero-coupling principle
- Dual atom/string key access via `get_field/2` (same pattern as MM, Orderflow)
- VWAP formula: `(old_avg * old_filled + fill_price * fill_qty) / new_filled`
- `Aggregate.bbo/2` merged into `Execution.best_price/2` — cross-venue BBO is execution concern
- Both modules use `use Descripex` with `api()` on every function, added to Discoverable list (now 19 modules)
- Float tolerance (`1.0e-9`) for fill completion check — prevents floating-point accumulation edge cases
- `@bps_multiplier` module attribute for BPS constant (no magic numbers)
- Explicit `@string_to_type` map for type normalization — no rescue-as-control-flow
**Tests:**
- 17 unit tests for `best_price/2`: 3 venues, single venue, arb detection, no-arb, spread_bps math, mid calculation, venue sorting, venue_count, string keys, mixed keys, empty list, nil/zero bid/ask filtering, all invalid, min_venues option, tie validity
- 33 unit tests for `OrderState`: creation (8), string normalization, string keys, market without price, quantity coercion, error cases (9), fill transitions (8 incl VWAP with equal/unequal fills, overfill cap, fills list growth), fill errors (5), cancel (5), summary (4)
- 3 integration tests: raw tickers (Bybit+OKX, exchange-specific field extraction), normalized tickers, composition (mid → Basis.spot_perp)
- 0 Credo issues, 0 Dialyzer warnings
---
## Progressive Discovery: `ZenQuant.describe/0-2`
**Completed** | 2026-02-23 | [D:2/B:7 → Priority:3.5]
Added `Descripex.Discoverable` integration for three-level progressive API discovery. Upgraded descripex from `~> 0.2.0` to `~> 0.3.1`.
**What was done:**
- `ZenQuant` now uses `Descripex.Discoverable` — generates `describe/0` (module overview), `describe/1` (functions by short name), `describe/2` (full function detail with params/returns/errors)
- Module list centralized as single source of truth in `lib/zen_quant.ex``ZenQuant.Manifest` delegates to `ZenQuant.__descripex_modules__/0` instead of maintaining its own list
- `Manifest.build/0` delegates through `modules/0` to avoid duplicating the `__descripex_modules__()` call
**Key decisions:**
- Short names derived from last module segment (e.g., `ZenQuant.PowerLaw``:power_law`, `ZenQuant.Recorder.JSONL``:jsonl`)
- When adding a new module: add it to the `use Descripex.Discoverable, modules: [...]` list in `lib/zen_quant.ex` — everything else derives from it
- `@moduledoc` updated with discovery examples so agents can learn the API from docs alone
**Tests:**
- 19 tests in `describe_test.exs`: overview structure (keys, count, annotated?), short name coverage for all 17 modules, function resolution for 8 modules (including nested: `:gamma_walls`, `:zero_dte`, `:deribit`, `:jsonl`, `:replay`), function entry keys, unknown module raises, function detail with params/returns, unknown function returns nil, `__descripex_modules__/0` matches `Manifest.modules/0`
- Structural assertions (`:annualize in names`) instead of brittle count assertions
---
## 0DTE Options Analytics Bundle: near_strikes + theta_acceleration + gamma_exposure
**Completed** | 2026-02-23 | 3 tasks: `near_strikes/3` [D:2/B:7], `theta_acceleration/3` [D:3/B:7], `gamma_exposure/3` [D:4/B:9]
New `ZenQuant.Options.ZeroDTE` module — analytics for options near expiry where greeks behave non-linearly. Theta decays explosively and gamma creates outsized hedging flows.
**What was done:**
- `ZeroDTE.near_strikes/3` — Filter chain to near-expiry strikes clustered around spot. Composes `Options.filter_by_dte/3` then filters by strike distance. Output is same chain shape as input, pipes directly into `oi_by_strike/1`, `pin_risk/3`, `gex_by_strike/2`, `GammaWalls.aggregate/3`.
- `ZeroDTE.theta_acceleration/3` — Measure theta decay acceleration by comparing near vs far expiry at same strikes. Groups by strike, averages theta across call+put at same `{strike, expiry}`, computes `abs(near_theta) / abs(far_theta)`. Requires at least 2 different expiries per strike. Sorted by acceleration descending.
- `ZeroDTE.gamma_exposure/3` — Gamma exposure concentration in near-expiry options. Internally filters to near-expiry for walls, uses full chain for concentration denominator. Returns `%{walls, summary}` where walls match `GammaWalls.aggregate/3` format and summary includes `total_gex`, `max_strike`, `concentration_pct`.
**Private helpers (duplicated from GammaWalls — zero coupling between sibling modules):**
- `get_raw_field/2` — Atom/string key extraction from raw map (copied from Options)
- `compute_gex_by_strike/4`, `compute_total_abs_gex/3` — GEX computation with configurable side
- `type_sign/2`, `classify_wall/2`, `maybe_take/2`, `build_summary/2` — Wall classification
- `group_theta_by_strike_and_expiry/4`, `compute_accelerations/1`, `build_acceleration_entry/3` — Theta grouping
**Key decisions:**
- Follows `GammaWalls` module structure exactly (Descripex, same opts pattern, same wall format)
- `near_strikes` composes existing `Options.filter_by_dte/3` — no DTE reimplementation
- `theta_acceleration` takes full chain (needs both near and far expiry for ratio)
- `gamma_exposure` concentration denominator uses full chain abs GEX with `min_oi: 0`
- All functions accept `now_dt:` option for deterministic testing
- Added to manifest (now 17 modules)
**Tests:**
- 10 unit tests for `near_strikes/3`: DTE + threshold filtering, same-shape output, composability with `oi_by_strike` and `pin_risk`, empty chain, no near-expiry, all out-of-range, max_dte/threshold_pct opts, invalid symbols
- 8 unit tests for `theta_acceleration/3`: manual ratio verification, descending sort, correct fields, single expiry → empty, missing/zero theta, call+put averaging, threshold_pct filtering
- 11 unit tests for `gamma_exposure/3`: walls+summary structure, GammaWalls format match, concentration bounds, max_strike, total_gex sum, empty chain, no near-expiry, customer side inversion, min_oi, top_n, invalid side raises
- 0 Credo issues, 0 Dialyzer warnings
---
## Backtest Module: Pure Strategy Evaluation Harness
**Completed** | 2026-02-23 | [D:4/B:8 → Priority:2.0]
Pure-function backtest harness for evaluating trading strategies over recorded JSONL data. Built on `Recorder.Replay.reduce/4` — same data + same strategy = same result (deterministic, verifiable).
**What was done:**
- `run/4` — replay recorded entries through strategy reducer `(entry, state) -> state`, returns final state + entry count
- `metrics/1-2` — bundle of standard risk metrics (Sharpe, Sortino, max drawdown, Calmar) from returns list, delegates to `ZenQuant.Risk`
- `evaluate/4` — composition of run + metrics. Convention: state with `:returns` key gets auto-metrics
**Key decisions:**
- Not in the analytics manifest — infrastructure/harness module with function reference params (BEAM-only). Plain `@doc`/`@spec` only, no Descripex.
- `metrics/2` gates on `length(returns) < 2` before delegating to Risk — avoids misleading values from insufficient data
- `extract_returns/1` uses `is_map(state) and is_map_key(state, :returns)` guard — safe for any strategy state shape (integers, strings, etc.)
- Replay filter opts (`type:`, `from:`, `to:`, `speed:`) pass through unchanged
**Agent value:** Deterministic evaluation is the foundation for EIP-8004 trustless verification — validators re-execute the same backtest and compare results.
**Tests:** 17 unit tests, 0 dialyzer warnings, 0 credo issues
---
## Recorder: JSONL Snapshot + Filtered Replay
**Completed** | 2026-02-23 | JSONL [D:2/B:8 → Priority:4.0] + Replay [D:3/B:7 → Priority:2.33]
New `ZenQuant.Recorder` namespace — JSONL snapshot recording and filtered replay for trading data backtesting and debugging.
**What was done:**
- `Recorder.JSONL` — 6 public functions: `encode_entry/2` (pure encoder with injectable ts), `decode_entry/1` (roundtrip decoder with atom keys), `append/3` (single entry I/O), `write_many/2` (batch I/O), `stream/1` (lazy `{:ok, entry} | {:error, reason}`), `read_all/1` (eager, skips malformed), `info/1` (file metadata: count, timestamps, duration, type frequencies)
- `Recorder.Replay` — 3 public functions: `stream/2` (unwrapped + filtered by type/from/to), `run/3` (callback-based with speed delays), `reduce/4` (accumulator-based replay)
- Safe atom handling via `@known_keys` whitelist (no `String.to_atom/1`)
- Sobelow annotations on all File I/O functions (caller-controlled paths by design)
- Descripex `api()` declarations on all 9 functions, added to manifest (now 16 modules)
**Key decisions:**
- JSONL `stream/1` returns `{:ok, entry} | {:error, reason}` — consumer chooses strictness
- Replay `stream/2` unwraps for convenience (skips errors, returns plain maps)
- Speed delays via `Process.sleep(round(delta_ms / speed))``nil` = instant, `1.0` = realtime
- `reduce/4` rescues `File.Error` for ergonomic `{:error, :enoent}` returns
- `write_many/2` arity /2 (unused opts param removed during review)
**Tests:**
- 28 unit tests for JSONL: encode/decode roundtrips, append, write_many, stream (lazy + error + blank lines), read_all (valid + empty + missing), info (counts, timestamps, duration, type frequencies)
- 23 unit tests for Replay: stream (unwrapping, malformed skip, type/from/to/combined filters), run (callback, count, filters, missing file, empty), reduce (accumulation, order, filters, missing, empty), speed delays (instant, comparative, measurable, invalid)
- 0 Credo issues, 0 Dialyzer warnings
---
## Descripex `api()` Declarations — All Modules Annotated
**Completed** | 2026-02-23 | [D:5/B:9 → Priority:1.8]
Added `use Descripex` and `api()` declarations to all 14 ZenQuant modules, making the entire API self-describing for AI agent consumers. Every public function (~106 total) now has machine-readable hints (params with kinds, returns with types, descriptions).
**Modules annotated (with function counts):**
| Module | Functions | Namespace |
|--------|-----------|-----------|
| Funding | 10 | `/funding` |
| Greeks | 9 | `/greeks` |
| PowerLaw | 9 | `/power-law` |
| Volatility | 9 | `/volatility` |
| Risk | 11 | `/risk` |
| Options | 21 | `/options` |
| Options.Deribit | 11 | `/options/deribit` |
| Options.GammaWalls | 1 | `/options/gamma-walls` |
| Sizing | 6 | `/sizing` |
| Basis | 6 | `/basis` |
| Orderflow | 6 | `/orderflow` |
| Portfolio | 3 | `/portfolio` |
| MM | 2 | `/mm` |
| WS | 2 | `/ws` |
**Enforcement test added to `manifest_test.exs`:**
- Every module has a namespace
- Every function has non-empty hints with description, params (when arity > 0), and returns
- Every param has a valid `:kind` (`:value` or `:exchange_data`)
- Every returns has a `:type`
- Every function has a `@spec`
- `__api__/0` exported on all manifest modules
**Key challenge:** Descripex validates that `api()` param names match actual `def` param names across ALL function clauses. Polymorphic functions (accepting Date, DateTime, integer, nil) needed consistent param names across all clauses (e.g., all `fair_value` clauses use `date_or_days`, not `date`/`datetime`/`days`).
**Result:** 649 tests passing, 0 dialyzer warnings. The full API manifest (`mix zen_quant.manifest`) now produces complete, verified metadata for all functions.
---
## `mix zen_quant.manifest` — Static JSON Export
**Completed** | 2026-02-23 | [D:2/B:7 → Priority:3.5]
Mix task that exports the API manifest as a static JSON file for non-BEAM consumers.
**What was done:**
- `Mix.Tasks.ZenQuant.Manifest` — calls `ZenQuant.Manifest.build/0`, encodes with `Jason.encode!/2` (pretty-printed), writes to `api_manifest.json` (default) or `--output PATH`. Prints confirmation with module/function counts.
- Added `api_manifest.json` to `.gitignore` (generated artifact).
**Usage:**
- `mix zen_quant.manifest` — writes `api_manifest.json` (14 modules, 106 functions)
- `mix zen_quant.manifest --output /tmp/manifest.json` — custom output path
**Tests:**
- 3 unit tests: valid JSON output with expected keys, `--output` flag, function/module structure
- Uses `tmp_dir` ExUnit tag for cleanup
**Why it matters:**
- CI pipelines can generate and ship the manifest as a build artifact
- Non-Elixir consumers (Python, TypeScript, Rust) can read the static JSON
- EIP-8004 registration files reference the manifest for capability discovery
- Zero runtime cost — purely a build-time export
---
## MM Fundamentals: fair_value + spread_calculator
**Completed** | 2026-02-23 | 2 tasks: `fair_value/3`, `spread_calculator/2`
New `ZenQuant.MM` module — market making primitives for micro-price estimation and optimal spread calculation.
**What was done:**
- `MM.fair_value/3` — Micro-price from orderbook imbalance. Takes top N levels from each side, computes volume-weighted average prices, then weights by imbalance: `ask_vwap * imbalance + bid_vwap * (1 - imbalance)` where `imbalance = bid_vol / (bid_vol + ask_vol)`. Heavier bids → shifts toward ask (anticipating upward pressure). Returns `nil` for empty/one-sided/invalid books. Atom + string key support via `get_field/2`.
- `MM.spread_calculator/2` — Simplified Avellaneda-Stoikov spread. `base_spread = risk_aversion * vol * sqrt(time_horizon)`, `inventory_adjustment = gamma * inventory * vol²`, `spread = base_spread + abs(adjustment)`. Returns asymmetric bid/ask offsets that encourage inventory rebalancing. Long → tighter ask, short → tighter bid. Returns `nil` for invalid options (non-positive risk_aversion/time_horizon, negative gamma).
**Private helpers:**
- `get_field/2` — Atom-first, string-fallback field access (duplicated per module, matching Orderflow pattern)
- `weighted_side/2` — VWAP for one side of the orderbook
- `compute_microprice/4` — Core micro-price formula
- `sum_volume/1`, `level_price/1`, `level_size/1` — Level parsing helpers
- `compute_spread/5` — Avellaneda-Stoikov formula
**Key decisions:**
- `opts` parameter on `fair_value/3` reserved for future weighting modes (`:linear`, `:exponential`). V1 ships with volume-weighting only per minimalist philosophy.
- Module attributes for `spread_calculator` defaults: `@default_risk_aversion 1.0`, `@default_time_horizon 1.0`, `@default_gamma 0.1`
- Returns `nil` (not error tuples) for invalid inputs, matching `Volatility.realized/2` style
**Tests:**
- 13 unit tests for `fair_value/3`: balanced/heavy-bid/heavy-ask depth 1, multi-level depth 2, empty/one-sided/missing keys, string keys, depth exceeds levels, malformed levels, classic formula match, bounds checks
- 14 unit tests for `spread_calculator/2`: zero vol, symmetric, higher vol, inventory effects (long/short), risk_aversion/time_horizon/gamma scaling, spread non-negative, adjustment sign, invalid params → nil
- 6 integration tests: raw + normalized fair_value (within depth price range), raw + normalized spread_calculator (real vol from trades), raw + normalized composition pipelines (orderbook → fair_value + trades → vol → spread)
- 0 Credo issues, 0 Dialyzer warnings
---
## Funding Intelligence Bundle: trend + mean_reversion_signal + rank
**Completed** | 2026-02-22 | 3 tasks: `trend/2`, `mean_reversion_signal/2`, `rank/2`
Funding rate intelligence functions — trend detection, mean reversion signals, and cross-exchange ranking. Composable: rank exchanges → detect trend → signal overbought/oversold.
**What was done:**
- `Funding.trend/2` — Linear regression slope on time-ordered funding rates. Returns `:rising`, `:falling`, `:stable`, or `nil` (< 3 valid points). Configurable `:threshold` (default 0.0) with internal `@trend_epsilon` (1.0e-12) to guard against float noise classifying equal rates as a trend.
- `Funding.mean_reversion_signal/2` — Z-score of current (last) rate against historical baseline (all prior rates, excluding current). Returns `:overbought`, `:oversold`, `:neutral`, or `nil`. Configurable `:threshold` (default 2.0, standard z-score cutoff matching `detect_spikes/2`) and `:lookback` (baseline point count). Population std dev (÷n), consistent with existing `standard_deviation/2`.
- `Funding.rank/2` — Cross-exchange ranking from `%{exchange_name => [rate_maps]}`. Flattens, filters nil rates, enriches with exchange name + APR (reuses `annualize/2`), sorts by rate. Options: `:direction` (:desc/:asc), `:limit`/`:top` (limit wins). Exchange keys normalized to strings via `to_string/1`.
**Private helpers added:**
- `extract_valid_rates/1` — Non-nil funding_rate extraction as flat float list
- `linear_regression_slope/1` — Least-squares slope over indexed values
- `classify_trend/2` — Slope + threshold → direction atom
- `compute_z_signal/3` — Z-score computation with baseline split
- `split_baseline/2` — Splits valid rates into {baseline, current} with lookback support
- `baseline_std_dev/2` — Population standard deviation for baseline values
- `classify_z_value/2` — Z-score + threshold → signal atom
- `flatten_exchange_rates/1` — Exchange-keyed map → sorted list with exchange names
- `maybe_limit/2` — Optional result truncation
**Key decisions:**
- All three in `ZenQuant.Funding` (not creating `ZenQuant.Aggregate` for a single function — `rank/2` can move later when `bbo/2` is also built)
- `mean_reversion_signal` excludes current rate from baseline (standard statistical practice)
- Returns `nil` when baseline std_dev == 0 (all-equal baseline gives no meaningful z-score)
- `rank/2` placed in Funding rather than roadmap's `Aggregate.funding_rank` — matches module cohesion
**Tests:**
- 9 unit tests for `trend/2`: rising, falling, stable, custom threshold, < 3 points, empty list, nil filtering, noisy-but-trending, epsilon guard
- 11 unit tests for `mean_reversion_signal/2`: overbought, oversold, neutral, custom threshold, lookback window, < 3 points, < 2 baseline points, zero std_dev, nil filtering, lookback > data, lookback of 1
- 9 unit tests for `rank/2`: descending default, ascending, limit, top, limit-wins-over-top, invalid limit, enrichment, empty input, nil filtering
- 1 integration test for `rank/2`: real Bybit rates for BTC/ETH/SOL, structure + type + sort + domain bounds assertions
- 0 Credo issues, 0 Dialyzer warnings
---
## Risk Portfolio Bundle: portfolio_delta + exposure_by_asset
**Completed** | 2026-02-22 | 2 tasks: `portfolio_delta/1`, `exposure_by_asset/1`
Portfolio-level directional risk functions — net delta exposure and per-asset concentration.
**What was done:**
- `Risk.portfolio_delta/1` — Sums `delta * notional` across all positions. Returns single float (positive = net long, negative = net short). Missing/nil `:delta` or `:notional` default to `0.0`. Empty list returns `0.0`.
- `Risk.exposure_by_asset/1` — Groups positions by base asset (extracted from symbol), sums `delta * notional` per group. Returns `%{"BTC" => float, "ETH" => float}`. Missing/nil symbol → `"UNKNOWN"`.
- Private `extract_base_asset/1` — Handles three symbol formats: `"BTC/USDT"` (split `/`), `"BTC/USDT:USDT"` (split `/`), `"BTC-31JAN26-84000-C"` (split `-`), nil → `"UNKNOWN"`.
- New `delta_position` type for shared typespec.
**Key decisions:**
- Notional is always absolute (unsigned) — directionality comes solely from `:delta`. Prevents double-flip confusion with signed delta * signed notional.
- Atom keys only (bracket access `pos[:field]`), consistent with existing Risk functions
- No integration tests needed — these accept pre-structured position maps, not exchange data
- Private `extract_base_asset` — 3 lines of trivial logic, not worth coupling to Portfolio module
**Tests:**
- 9 unit tests for `portfolio_delta/1`: net long, mixed long/short, partial deltas (options), empty list, missing delta, missing notional, nil field values, net short, notional-is-absolute contract validation
- 8 unit tests for `exposure_by_asset/1`: group by asset, settle-currency symbols, Deribit option symbols, empty list, missing symbol → UNKNOWN, nil symbol → UNKNOWN, missing fields, netting within same asset
- 0 Credo issues, 0 Dialyzer warnings
---
## PowerLaw: Fair Value Range + Forecast
**Completed** | 2026-02-22 | 2 tasks: `fair_value_range/1`, `forecast/2`
Agent-friendly composites for Bitcoin power law model — confidence bands and forward projections.
**What was done:**
- `PowerLaw.fair_value_range/1` — Returns 1σ and 2σ bands: `%{fair, lower_1s, upper_1s, lower_2s, upper_2s}`. Composes existing `fair_value/1`, `support/2`, `resistance/2`. Accepts Date, DateTime, integer days, or nil (today). Arity /1 (not roadmap's /2) — single polymorphic arg is cleaner.
- `PowerLaw.forecast/2` — Projects range to future date: `fair_value_range` + `%{date, days_since_genesis}`. `forecast(365)` = "where will fair value be in a year?" Accepts same base types.
- Private `normalize_base/1` — Shared Date/DateTime/integer/nil → `{days, date}` normalization.
**Tests:**
- 8 unit tests for `fair_value_range/1`: key presence, band ordering, all-positive, 4 input types, cross-check against individual calls
- 11 unit tests for `forecast/2`: key presence, date arithmetic, days_since_genesis correctness, band ordering, consistency with fair_value_range, monotonicity, 4 input types, pre-genesis raises
- 0 Credo issues, 0 Dialyzer warnings
---
## Options Chain Analytics Bundle
**Completed** | 2026-02-22 | 3 tasks: `chain/2`, `GammaWalls.aggregate/3`, `gamma_walls/2`
Fetch, enrich, and analyze Deribit option chains in composable steps — from raw summary data through selective greeks enrichment to gamma exposure wall computation.
**What was done:**
- `Options.Deribit.chain/2` — Fetches full option chain via `fetch_option_chain` (single API call, ~880 instruments). Optionally enriches top instruments (by OI) with greeks via `fetch_greeks` using `Task.async_stream`. Flattens greeks to top-level raw keys for compatibility with existing `Options` functions (`gex_by_strike`, `get_gamma`, etc.). Adds `bid_iv`/`ask_iv` from ticker. Preserves nested `"greeks"` map for no data loss.
- `Options.GammaWalls.aggregate/3` — Pure GEX wall computation from enriched chain + spot. Configurable `:side` (`:dealer`/`:customer`) controls sign convention. `:min_oi` filters low-OI options, `:top_n` limits output. Returns `[%{strike, gex, type}]` sorted by absolute GEX descending, where `:support` = positive GEX and `:resistance` = negative.
- `Options.Deribit.gamma_walls/2` — Composes `chain/2` (with `enrich: :greeks`) and `GammaWalls.aggregate/3`. Splits opts internally between chain and wall concerns. Returns `%{walls, spot, chain_size, enriched_count}`. Guards against `spot <= 0` with `{:error, :no_spot_price}`.
**Key decisions:**
- `gamma_walls/2` not `/4` — arity /2 follows `dvol/2` pattern (exchange_mod + opts keyword list)
- Two-phase fetch strategy: summary first (1 call), then selective enrichment for top N by OI — avoids 880 individual ticker calls
- `chain/2` builds plain maps (not `%Option{}` struct) — maintains zero coupling
- Failed individual enrichments are silently skipped (partial enrichment is acceptable)
- `GammaWalls` is a separate module — has its own opts and keeps Options from growing larger
- GEX formula inline (not reusing `gex_by_strike`) to support configurable side convention
**Tests:**
- 13 unit tests for `GammaWalls.aggregate/3`: sorted output, strike/gex/type fields, same-strike aggregation, dealer/customer sign inversion, min_oi filtering, top_n limiting, empty chain, missing greeks, atom keys, nil OI, invalid symbols
- 4 integration tests against real Deribit: chain without enrichment, chain with greeks enrichment (limit 5), enriched chain → `gex_by_strike` compatibility, end-to-end `gamma_walls/2`
- 0 Credo issues, 0 Dialyzer warnings
---
## Options Bundle 1: Six Pure Functions
**Completed** | 2026-02-21 | 6 tasks (all D:2, highest-ROI options analytics)
Added 6 pure functions to `ZenQuant.Options` for options analytics — ATM IV extraction, pin risk assessment, cross-chain OI aggregation, breakeven analysis, expected range, and theta decay.
**What was done:**
- `theta_per_hour/1` — Hourly theta decay (daily theta / 24). Returns `nil` when no theta data.
- `expected_range/3` — 1-sigma price range from IV (percentage, e.g. 46.3) and time horizon. Uses `spot * (iv/100) * sqrt(hours/8760)`. Composes naturally with `atm_iv/2`.
- `atm_iv/2` — ATM implied volatility extraction. Finds closest strike to spot, averages call+put IV. Fallback chain: `(bid_iv + ask_iv) / 2``mark_iv``implied_volatility`. Returns percentage.
- `pin_risk/3` — Per-strike pin risk classification (`:high`/`:medium`/`:low`) from OI concentration + distance to spot. Complements `pin_magnets/3` with richer analysis.
- `aggregate_oi/1` — Cross-chain OI aggregation. Takes list of chains, returns `%{calls, puts, total, ratio}`. Reuses `accumulate_put_call_oi/3`.
- `breakeven_move/2` — Percentage move to breakeven. Assumes Deribit fraction format (`mark_price * underlying_price` = premium USD).
**Private helpers added:**
- `get_raw_field/2` — Like `get_greek/2` but returns `nil` (not `0.0`) for missing data
- `best_iv/1` — Per-option IV extraction with bid/ask mid → mark_iv → implied_volatility fallback
- `collect_iv_by_strike/1` — Groups best IV values by strike across chain
- `closest_strike/2` — Finds nearest strike to spot price
- `classify_pin_risk/2` — OI ratio + distance → risk level
- `breakeven_price/3` — Call: strike + premium, Put: strike - premium
**Tests:**
- 35 unit tests across 6 describe blocks with new `@iv_chain` fixture (Deribit-realistic: mark_price as BTC fractions, percentage IVs in raw)
- 6 integration tests against real Deribit option chains (atm_iv, pin_risk, aggregate_oi, breakeven_move, expected_range composition, theta_per_hour)
- Existing `@sample_chain` and tests untouched
**Key decisions:**
- IV in percentage (48.6, not 0.486) — matches Deribit's `mark_iv` format and composes with `expected_range`
- `breakeven_move` always uses Deribit fraction format (module already assumes Deribit via `parse_option/1`)
- `pin_risk` is separate from `pin_magnets` — different return shapes serve different consumers
- `aggregate_oi` reduces across chains then across pairs (no flattening) to avoid duplicate symbol issues
- 0 Credo issues, 0 Dialyzer warnings
---
## Bundle 3: Three Tiny Wins
**Completed** | 2026-02-21 | 3 tasks (D:1 each, highest ROI)
### `ZenQuant.WS` Module
[D:1/B:8 → Priority:8.0] + [D:1/B:6 → Priority:6.0]
New module for WebSocket health monitoring — pure functions over timestamps and metrics.
**What was done:**
- `stale_check/2` — Pure timestamp comparison returning `%{stale, age_seconds, threshold_seconds}`. Configurable threshold (default 60s), injectable `now` for testability.
- `reconnect_metrics/2` — Derives `healthy` boolean and `heartbeat_age_seconds` from ZenWebsocket state metrics map. Passes through all original fields. Health requires: connected AND heartbeat_timer_active AND heartbeat_failures < 3 AND last_heartbeat_at not nil.
- 14 unit tests covering edge cases (zero age, nil heartbeat, missing fields, empty metrics)
### `Options.Deribit.dvol/2` + `parse_dvol/1`
[D:1/B:7 → Priority:7.0]
DVOL (Deribit Volatility Index) fetch and parsing — added to existing `ZenQuant.Options.Deribit` module.
**What was done:**
- `parse_dvol/1` — Pure parser for `[[unix_ms, value], ...]``{:ok, %{current, history}}`. Tolerant: skips invalid rows (non-numeric, out-of-range timestamps) via `flat_map` + `DateTime.from_unix` error handling. Returns `{:error, :empty_history}` or `{:error, :invalid_input}`.
- `dvol/2` — Networked convenience: takes exchange module + opts, calls `fetch_volatility_history`, parses result. Arity `/2` (not roadmap's `/1`) for explicit exchange module per library design principles — ZenQuant has zero runtime coupling to ccxt_client.
- 12 unit tests (valid parsing, edge cases, invalid input, out-of-range timestamps)
- 6 integration tests (BTC, ETH, normalize modes, raw API passthrough) against real Deribit
**Key decisions:**
- `dvol/2` not `dvol/1` — exchange module is always the first explicit argument (library design)
- Tolerant parsing — skips invalid rows rather than failing on first bad entry, matching `oi_by_strike` pattern
- `flat_map` with `DateTime.from_unix` error handling prevents crash on numeric-but-invalid timestamps
---
## Orderflow Module
**Completed** | 2026-02-21 | [D:2/B:8 → Priority:4.0] (bundle of 6 tasks)
New `ZenQuant.Orderflow` module — 6 pure functions for trade-level and orderbook analytics.
**What was done:**
- `cvd_delta/1` — Single-trade CVD contribution (+buy/-sell aggression). Uses `side` field, not `taker_or_maker`.
- `vwap/1` — Volume-weighted average price from trade list. Skips trades with nil price/amount.
- `imbalance/2` — Orderbook bid/ask imbalance normalized to [-1, 1] at configurable depth.
- `heatmap_points/2` — Orderbook visualization points sorted by price with :bid/:ask labels.
- `footprint_cells/3` — Price/time bucketed buy/sell volume breakdown for footprint charts.
- `dom_level/2` — Depth-of-market: resting liquidity + executed volume at a price level.
**Key decisions:**
- CVD uses CCXT `side` field (taker direction), not `taker_or_maker` (fee classification)
- All functions accept atom or string keys (dual-key access pattern from volatility.ex)
- Missing/nil fields return neutral values (0.0 for cvd, nil for vwap, skip for aggregations)
- Non-numeric amounts handled gracefully (return neutral instead of crashing)
- No structs — plain maps consistent with library design principles
- 56 unit tests covering happy paths, edge cases, malformed inputs, and string keys
- 12 integration tests against real Bybit trades + orderbook (public endpoints, no credentials)
- 6 raw format tests (`normalize: false`): helpers parse Bybit format (`"size"``amount`, `"Buy"``"buy"`, `"b"``bids`, strings → floats)
- 6 normalized format tests (`normalize: true`): uses `%CCXT.Types.Trade{}` and `%CCXT.Types.OrderBook{}` structs directly. OrderBook works as-is; Trade needs `side` atom→string conversion (`:buy``"buy"`)
- Assertions: type checks, domain bounds (VWAP in price range, imbalance in [-1,1]), structural integrity (delta == buy - sell)
- Atom side values (`:buy`/`:sell`) now supported alongside strings in `cvd_sign/1`, `split_volume/1`, and `valid_footprint_trade?/1` — normalized CCXT Trade structs work directly without conversion
- 0 Credo issues, 0 Dialyzer warnings
## Orderflow Ergonomics Bundle
**Completed** | 2026-02-25 | [D:1/B:7 → Priority:7.0] + [D:1/B:5 → Priority:5.0]
Two ergonomic improvements to `ZenQuant.Orderflow` plus doc corrections.
### `cvd/1` — List Variant
**What was done:**
- Added `cvd/1` public function: takes a trade list, returns total CVD as a single float
- Implemented as `Enum.reduce` over `cvd_delta/1` — minimal, composes existing logic
- Annotated with `api()` declaration (hints, returns_example)
- 7 unit tests: empty list, all buys, all sells, mixed, atom sides, invalid side, missing amount
**Key decisions:**
- `cvd/1` is the list variant; `cvd_delta/1` remains the single-trade variant
- No change to `cvd_delta/1` — agents who want per-trade deltas still use it directly
- Placed before `cvd_delta/1` in source — list API is the natural entry point
### `dom_level/2` — Direct Orderbook Acceptance
**What was done:**
- Modified `dom_level/2` to detect if `data` itself IS an orderbook (has `:bids`/`:asks` keys)
- Uses existing `has_orderbook_side?/1` private helper for detection
- If direct orderbook: uses `data` as orderbook, reads `:trades` from it if present
- If wrapper map: existing behavior unchanged (`data.orderbook` + `data.trades`)
- Updated `api()` description to document both accepted formats
- 5 new unit tests: direct bids, direct asks, string keys, with trades field, wrapper regression
**Key decisions:**
- Reused `has_orderbook_side?/1` — no new logic, just routing
- `%{bids: ..., asks: ..., trades: [...]}` works: direct orderbook with inline trades
- All existing wrapper-format tests pass unchanged
### Documentation Corrections (SKILLS.md)
Fixed two factually wrong gotchas that were misleading agents:
- **Gotcha #1** (was wrong): claimed `cvd_delta/1` "enriches a list" → corrected to explain `cvd/1` vs `cvd_delta/1`
- **Gotcha #2** (was wrong): claimed `dom_level/2` needs conversion from CCXT tuples → corrected to document direct acceptance
- Fixed composition example: `cvd_delta(trades)``cvd(trades)` for list CVD
- Fixed `footprint_cells` example: removed non-existent keyword arg form, used positional args
---
## v0.1.0 — Initial Extraction
### Project Setup
**Completed** | 2026-02-20
Extracted from `ccxt_client`'s `CCXT.Trading.*` modules into standalone library.
**What was done:**
- Created project with `mix new zen_quant --sup`
- Standard dev tooling: ex_unit_json, dialyzer_json, styler, credo, dialyxir, ex_doc, doctor, sobelow
- Tidewave MCP integration on port 4002
- ccxt_client as test-only dependency for integration tests
- CLAUDE.md with project context and module inventory
### Source + Test Migration
**Completed** | 2026-02-20
Moved 13 source files and 17 test files from ccxt_client's `CCXT.Trading.*` into zen_quant.
**What was done:**
- Copied 13 source modules (`lib/ccxt/trading/``lib/zen_quant/`)
- Copied 13 unit test files and 4 integration test files
- Renamed all namespaces: `CCXT.Trading.*``ZenQuant.*`
- Decoupled from `CCXT.Types` in source + unit tests:
- `%FundingRate{...}``%{...}` (funding.ex, ~40 test instances)
- `%Option{...}``%{...}` (options.ex, greeks.ex, ~25 test instances)
- `%Position{...}``%{...}` (portfolio.ex, helpers/risk.ex, ~42 test instances)
- All typespecs: `FundingRate.t()` / `Option.t()` / `Position.t()``map()`
- Integration tests keep `CCXT.Types` structs (testing interop with real exchange data)
- Integration test modules renamed: `CCXT.Trading.TradingHelpers.*``ZenQuant.Integration.*`
- 355 tests passing, 0 failures, clean compile