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

  • QuantexZenQuant (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.exZenQuant.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):

ModuleFunctionsNamespace
Funding10/funding
Greeks9/greeks
PowerLaw9/power-law
Volatility9/volatility
Risk11/risk
Options21/options
Options.Deribit11/options/deribit
Options.GammaWalls1/options/gamma-walls
Sizing6/sizing
Basis6/basis
Orderflow6/orderflow
Portfolio3/portfolio
MM2/mm
WS2/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:

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) / 2mark_ivimplied_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