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 fromQuantex.*toZenQuant.*. 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: :erroropts into failing loudly instead.ZenQuant.Options.atm_iv_term_structure/3andmoneyness_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 feedsOptions.Skew.term_structure/1without 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".
descripexis a real runtime dependency (it powersdescribe/0..2and__api__/0). The docs now say so. AGENTS.mdis 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.mdis the docs landing page. Funding.compare/1andrank/2no 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, matchingcarry_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/2no 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 returnsarb_status: :incomparablewitharb_opportunity: false. ZenQuant does not infer comparability from symbol strings.- README rewritten: every example is a verified return value,
Risk.kellyandSizing.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 ciAGENTS.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.Internalconsolidation:accumulate_gex/6,get_raw_field/2,type_sign/2, and the sharedgex_move_pct/0GEX 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 falseondefp; fused 17Enum.map |> Enum.sumintoEnum.sum_by/2;String.split parts: 2,match?/2,Enum.max_by,Enum.min_max,Stream.with_index, and:erlang.float/1cleanups. - Three findings declined with inline
reach:disablereasons: the float== 0guard (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_dashboardalready 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-levelCCXT.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 (bid1PricevsbidPxvsbest_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.Deribitfor 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.getswitched 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-existingderibit_dvol_integration_test.exscall-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.MeanReversionwithhalf_life/1— Ornstein-Uhlenbeck half-life estimation via OLS regression. - Added Descripex
api()declarations for both functions. - Registered
ZenQuant.MeanReversioninlib/zen_quant.exdiscoverability modules list.
Tests:
- 10 unit tests for
yang_zhang/2andhalf_life/1additions. - 1 integration test for
yang_zhang/2using 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_exampleto everyapi()declaration inlib/zen_quant/**(all 127 discoverable functions now include concrete return examples). - Added/updated
errorsmetadata for functions returning{:error, reason}tuples (includingVolatility,Options,Options.Deribit,OrderState,Risk,Recorder.JSONL, andRecorder.Replay). - Added intra-module
composes_withhints where output/input chaining is natural (for exampleFunding.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
- every function must have
- Fixed
mix zen_quant.manifestJSON export for non-JSON-native terms in hints:
Verification:
mix compile --warnings-as-errorsmix test test/zen_quant/manifest_test.exsmix test --failedmix 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 forbest_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 abest_priceresult.:buyreturns best ask,:sellreturns best bid. Pattern-matched function heads.- Integration tests updated to use
from_multi → best_pricepipeline instead of manual venue map construction. - Fixed phantom
sideoption inbest_price/2Descripex 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/1passes through nil bid/ask — filtering happens downstream inbest_price/2best_for_side/2uses 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 tasksExecution.best_price/3,Aggregate.bbo/2, and core ofBest 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}withstatus: :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. Setsremaining_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/2merged intoExecution.best_price/2— cross-venue BBO is execution concern- Both modules use
use Descripexwithapi()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_multipliermodule attribute for BPS constant (no magic numbers)- Explicit
@string_to_typemap 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:
ZenQuantnow usesDescripex.Discoverable— generatesdescribe/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.Manifestdelegates toZenQuant.__descripex_modules__/0instead of maintaining its own list Manifest.build/0delegates throughmodules/0to 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 inlib/zen_quant.ex— everything else derives from it @moduledocupdated 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__/0matchesManifest.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. ComposesOptions.filter_by_dte/3then filters by strike distance. Output is same chain shape as input, pipes directly intooi_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}, computesabs(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 matchGammaWalls.aggregate/3format and summary includestotal_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 sidetype_sign/2,classify_wall/2,maybe_take/2,build_summary/2— Wall classificationgroup_theta_by_strike_and_expiry/4,compute_accelerations/1,build_acceleration_entry/3— Theta grouping
Key decisions:
- Follows
GammaWallsmodule structure exactly (Descripex, same opts pattern, same wall format) near_strikescomposes existingOptions.filter_by_dte/3— no DTE reimplementationtheta_accelerationtakes full chain (needs both near and far expiry for ratio)gamma_exposureconcentration denominator uses full chain abs GEX withmin_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 withoi_by_strikeandpin_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 countmetrics/1-2— bundle of standard risk metrics (Sharpe, Sortino, max drawdown, Calmar) from returns list, delegates toZenQuant.Riskevaluate/4— composition of run + metrics. Convention: state with:returnskey gets auto-metrics
Key decisions:
- Not in the analytics manifest — infrastructure/harness module with function reference params (BEAM-only). Plain
@doc/@speconly, no Descripex. metrics/2gates onlength(returns) < 2before delegating to Risk — avoids misleading values from insufficient dataextract_returns/1usesis_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_keyswhitelist (noString.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/1returns{:ok, entry} | {:error, reason}— consumer chooses strictness- Replay
stream/2unwraps for convenience (skips errors, returns plain maps) - Speed delays via
Process.sleep(round(delta_ms / speed))—nil= instant,1.0= realtime reduce/4rescuesFile.Errorfor ergonomic{:error, :enoent}returnswrite_many/2arity /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(:valueor:exchange_data) - Every returns has a
:type - Every function has a
@spec __api__/0exported 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— callsZenQuant.Manifest.build/0, encodes withJason.encode!/2(pretty-printed), writes toapi_manifest.json(default) or--output PATH. Prints confirmation with module/function counts.- Added
api_manifest.jsonto.gitignore(generated artifact).
Usage:
mix zen_quant.manifest— writesapi_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,
--outputflag, function/module structure - Uses
tmp_dirExUnit 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)whereimbalance = bid_vol / (bid_vol + ask_vol). Heavier bids → shifts toward ask (anticipating upward pressure). Returnsnilfor empty/one-sided/invalid books. Atom + string key support viaget_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. Returnsnilfor 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 orderbookcompute_microprice/4— Core micro-price formulasum_volume/1,level_price/1,level_size/1— Level parsing helperscompute_spread/5— Avellaneda-Stoikov formula
Key decisions:
optsparameter onfair_value/3reserved for future weighting modes (:linear,:exponential). V1 ships with volume-weighting only per minimalist philosophy.- Module attributes for
spread_calculatordefaults:@default_risk_aversion 1.0,@default_time_horizon 1.0,@default_gamma 0.1 - Returns
nil(not error tuples) for invalid inputs, matchingVolatility.realized/2style
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, ornil(< 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, ornil. Configurable:threshold(default 2.0, standard z-score cutoff matchingdetect_spikes/2) and:lookback(baseline point count). Population std dev (÷n), consistent with existingstandard_deviation/2.Funding.rank/2— Cross-exchange ranking from%{exchange_name => [rate_maps]}. Flattens, filters nil rates, enriches with exchange name + APR (reusesannualize/2), sorts by rate. Options::direction(:desc/:asc),:limit/:top(limit wins). Exchange keys normalized to strings viato_string/1.
Private helpers added:
extract_valid_rates/1— Non-nil funding_rate extraction as flat float listlinear_regression_slope/1— Least-squares slope over indexed valuesclassify_trend/2— Slope + threshold → direction atomcompute_z_signal/3— Z-score computation with baseline splitsplit_baseline/2— Splits valid rates into {baseline, current} with lookback supportbaseline_std_dev/2— Population standard deviation for baseline valuesclassify_z_value/2— Z-score + threshold → signal atomflatten_exchange_rates/1— Exchange-keyed map → sorted list with exchange namesmaybe_limit/2— Optional result truncation
Key decisions:
- All three in
ZenQuant.Funding(not creatingZenQuant.Aggregatefor a single function —rank/2can move later whenbbo/2is also built) mean_reversion_signalexcludes current rate from baseline (standard statistical practice)- Returns
nilwhen baseline std_dev == 0 (all-equal baseline gives no meaningful z-score) rank/2placed in Funding rather than roadmap'sAggregate.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— Sumsdelta * notionalacross all positions. Returns single float (positive = net long, negative = net short). Missing/nil:deltaor:notionaldefault to0.0. Empty list returns0.0.Risk.exposure_by_asset/1— Groups positions by base asset (extracted from symbol), sumsdelta * notionalper 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_positiontype 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 existingfair_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 viafetch_option_chain(single API call, ~880 instruments). Optionally enriches top instruments (by OI) with greeks viafetch_greeksusingTask.async_stream. Flattens greeks to top-level raw keys for compatibility with existingOptionsfunctions (gex_by_strike,get_gamma, etc.). Addsbid_iv/ask_ivfrom 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_oifilters low-OI options,:top_nlimits output. Returns[%{strike, gex, type}]sorted by absolute GEX descending, where:support= positive GEX and:resistance= negative.Options.Deribit.gamma_walls/2— Composeschain/2(withenrich: :greeks) andGammaWalls.aggregate/3. Splits opts internally between chain and wall concerns. Returns%{walls, spot, chain_size, enriched_count}. Guards againstspot <= 0with{:error, :no_spot_price}.
Key decisions:
gamma_walls/2not/4— arity /2 followsdvol/2pattern (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/2builds plain maps (not%Option{}struct) — maintains zero coupling- Failed individual enrichments are silently skipped (partial enrichment is acceptable)
GammaWallsis 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_strikecompatibility, end-to-endgamma_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). Returnsnilwhen no theta data.expected_range/3— 1-sigma price range from IV (percentage, e.g. 46.3) and time horizon. Usesspot * (iv/100) * sqrt(hours/8760). Composes naturally withatm_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. Complementspin_magnets/3with richer analysis.aggregate_oi/1— Cross-chain OI aggregation. Takes list of chains, returns%{calls, puts, total, ratio}. Reusesaccumulate_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— Likeget_greek/2but returnsnil(not0.0) for missing databest_iv/1— Per-option IV extraction with bid/ask mid → mark_iv → implied_volatility fallbackcollect_iv_by_strike/1— Groups best IV values by strike across chainclosest_strike/2— Finds nearest strike to spot priceclassify_pin_risk/2— OI ratio + distance → risk levelbreakeven_price/3— Call: strike + premium, Put: strike - premium
Tests:
- 35 unit tests across 6 describe blocks with new
@iv_chainfixture (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_chainand tests untouched
Key decisions:
- IV in percentage (48.6, not 0.486) — matches Deribit's
mark_ivformat and composes withexpected_range breakeven_movealways uses Deribit fraction format (module already assumes Deribit viaparse_option/1)pin_riskis separate frompin_magnets— different return shapes serve different consumersaggregate_oireduces 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), injectablenowfor testability.reconnect_metrics/2— Deriveshealthyboolean andheartbeat_age_secondsfrom 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) viaflat_map+DateTime.from_unixerror handling. Returns{:error, :empty_history}or{:error, :invalid_input}.dvol/2— Networked convenience: takes exchange module + opts, callsfetch_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/2notdvol/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_strikepattern flat_mapwithDateTime.from_unixerror 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). Usessidefield, nottaker_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
sidefield (taker direction), nottaker_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 needssideatom→string conversion (:buy→"buy") - Assertions: type checks, domain bounds (VWAP in price range, imbalance in [-1,1]), structural integrity (delta == buy - sell)
- 6 raw format tests (
- Atom side values (
:buy/:sell) now supported alongside strings incvd_sign/1,split_volume/1, andvalid_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/1public function: takes a trade list, returns total CVD as a single float - Implemented as
Enum.reduceovercvd_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/1is the list variant;cvd_delta/1remains the single-trade variant- No change to
cvd_delta/1— agents who want per-trade deltas still use it directly - Placed before
cvd_delta/1in source — list API is the natural entry point
dom_level/2 — Direct Orderbook Acceptance
What was done:
- Modified
dom_level/2to detect ifdataitself IS an orderbook (has:bids/:askskeys) - Uses existing
has_orderbook_side?/1private helper for detection - If direct orderbook: uses
dataas orderbook, reads:tradesfrom 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 explaincvd/1vscvd_delta/1 - Gotcha #2 (was wrong): claimed
dom_level/2needs conversion from CCXT tuples → corrected to document direct acceptance - Fixed composition example:
cvd_delta(trades)→cvd(trades)for list CVD - Fixed
footprint_cellsexample: 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.Typesin 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.Typesstructs (testing interop with real exchange data) - Integration test modules renamed:
CCXT.Trading.TradingHelpers.*→ZenQuant.Integration.* - 355 tests passing, 0 failures, clean compile