From 26609c1e44db857c845ac3e3af67c4530d17f3ed Mon Sep 17 00:00:00 2001 From: "E.FU" Date: Sun, 17 May 2026 19:42:53 +0800 Subject: [PATCH] feat: dev-telemetry dogfooding loop (Task 22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CcxtOcx.DevTelemetry attaches a single handler to every [:ccxt_ocx, ...] event family, pretty-prints emissions to a configurable IO device, and keeps running counts + last measurements/metadata in an Agent. Surface: watch/1 (opts: :print, :filter, :io_device — idempotent, re-attach resets state), summary/0 (initial all-zeros state when Agent absent), reset/0 (no-op when Agent absent), detach/0 (idempotent). Auto-attached in dev via .iex.exs (guarded on Mix.env() == :dev AND Code.ensure_loaded?). docs/tidewave_examples.md gets a "Dev Telemetry Loop" section showing watch → drive → summary → reset → detach. Also lands the schema_version 1→2 migration in roadmap/tasks.toml that was pending on the host (adds [milestones.*] tables, includes v0_1/v1_0 release lines). Roadmap: Task 22 done (Phase 6 dx, depends_on Task 14, [D:2/B:5/U:8 → Eff:3.25 🎯]). --- .iex.exs | 16 ++ ROADMAP.md | 31 ++-- docs/tidewave_examples.md | 56 +++++++ lib/ccxt_ocx/dev_telemetry.ex | 242 +++++++++++++++++++++++++++ roadmap/data.json | 82 ++++++++- roadmap/tasks.toml | 105 +++++++++--- test/ccxt_ocx/dev_telemetry_test.exs | 197 ++++++++++++++++++++++ 7 files changed, 694 insertions(+), 35 deletions(-) create mode 100644 .iex.exs create mode 100644 lib/ccxt_ocx/dev_telemetry.ex create mode 100644 test/ccxt_ocx/dev_telemetry_test.exs diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 0000000..ac35405 --- /dev/null +++ b/.iex.exs @@ -0,0 +1,16 @@ +# IEx auto-attach for ccxt_ocx maintainer dogfooding. +# +# Guarded so it only fires in :dev — `mix test` and any prod release path +# stay untouched. Also guarded on `Code.ensure_loaded?/1` so a stripped +# release (where CcxtOcx.DevTelemetry might be filtered out) doesn't blow +# up the shell. + +if Mix.env() == :dev and Code.ensure_loaded?(CcxtOcx.DevTelemetry) do + CcxtOcx.DevTelemetry.watch() + + IO.puts(""" + 📡 CcxtOcx.DevTelemetry attached. + Call `CcxtOcx.DevTelemetry.summary/0` anytime to see counts + last values. + `reset/0` clears, `detach/0` stops counting. See docs/tidewave_examples.md. + """) +end diff --git a/ROADMAP.md b/ROADMAP.md index 8bedb85..cca14c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,11 +49,11 @@ | Task | Status | Notes | |------|--------|-------| -| Task 6 | ⬜ | 🎁 **macros** · Discover and parse CCXT declaration sources with OXC [D:6/B:8/U:8 → Eff:1.33] 📋 | -| Task 6b | ⬜ | 🎁 **macros** · `use CcxtOcx` — exchange-scope entrypoint [D:5/B:9/U:9 → Eff:1.8] 🚀 | -| Task 7 | ⬜ | 🎁 **macros** · `defunified` macro [D:7/B:10/U:9 → Eff:1.36] 📋 | -| Task 8 | ⬜ | 🎁 **macros** · Typed structs for unified return shapes (with declarative field mapping) [D:5/B:8/U:8 → Eff:1.6] 🚀 | -| Task 9 | ⬜ | 🎁 **macros** · `defexchange` macro [D:6/B:7/U:6 → Eff:1.08] 📋 | +| Task 6 | ⬜ | 🎁 **macros** · 🚀 **v0_1** · Discover and parse CCXT declaration sources with OXC [D:6/B:8/U:8 → Eff:1.33] 📋 | +| Task 6b | ⬜ | 🎁 **macros** · 🚀 **v0_1** · `use CcxtOcx` — exchange-scope entrypoint [D:5/B:9/U:9 → Eff:1.8] 🚀 | +| Task 7 | ⬜ | 🎁 **macros** · 🚀 **v0_1** · `defunified` macro [D:7/B:10/U:9 → Eff:1.36] 📋 | +| Task 8 | ⬜ | 🎁 **macros** · 🚀 **v0_1** · Typed structs for unified return shapes (with declarative field mapping) [D:5/B:8/U:8 → Eff:1.6] 🚀 | +| Task 9 | ⬜ | 🎁 **macros** · 🚀 **v0_1** · `defexchange` macro [D:6/B:7/U:6 → Eff:1.08] 📋 | | Task 10 | ⬜ | 🎁 **macros** · Symbol normalization layer [D:6/B:6/U:5 → Eff:0.92] ⚠️ | @@ -66,9 +66,9 @@ | Task | Status | Notes | |------|--------|-------| -| Task 11 | ⬜ | 🎁 **streaming** · `defstreaming` macro [D:7/B:9/U:8 → Eff:1.21] 📋 | -| Task 12 | ⬜ | 🎁 **streaming** · `CcxtOcx.Stream` GenStage producer [D:6/B:7/U:6 → Eff:1.08] 📋 | -| Task 13 | ⬜ | 🎁 **streaming** · Reconnect / heartbeat policy [D:6/B:8/U:7 → Eff:1.25] 📋 | +| Task 11 | ⬜ | 🎁 **streaming** · 🚀 **v1_0** · `defstreaming` macro [D:7/B:9/U:8 → Eff:1.21] 📋 | +| Task 12 | ⬜ | 🎁 **streaming** · 🚀 **v1_0** · `CcxtOcx.Stream` GenStage producer [D:6/B:7/U:6 → Eff:1.08] 📋 | +| Task 13 | ⬜ | 🎁 **streaming** · 🚀 **v1_0** · Reconnect / heartbeat policy [D:6/B:8/U:7 → Eff:1.25] 📋 | --- @@ -80,12 +80,12 @@ | Task | Status | Notes | |------|--------|-------| -| Task T1 | ⬜ | 🎁 **trade-verify** · Testnet harness — Binance USDT-M futures [D:5/B:9/U:8 → Eff:1.7] 🚀 | -| Task T2 | ⬜ | 🎁 **trade-verify** · Testnet harness — Deribit options [D:5/B:8/U:7 → Eff:1.5] 🚀 | +| Task T1 | ⬜ | 🎁 **trade-verify** · 🚀 **v1_0** · Testnet harness — Binance USDT-M futures [D:5/B:9/U:8 → Eff:1.7] 🚀 | +| Task T2 | ⬜ | 🎁 **trade-verify** · 🚀 **v1_0** · Testnet harness — Deribit options [D:5/B:8/U:7 → Eff:1.5] 🚀 | | Task T3 | ⬜ | 🎁 **trade-verify** · Testnet harness — OKX [D:5/B:7/U:6 → Eff:1.3] 📋 | -| Task T4 | ⬜ | 🎁 **trade-verify** · Signed-payload byte-comparison harness [D:7/B:9/U:8 → Eff:1.21] 📋 | -| Task T5 | ⬜ | 🎁 **trade-verify** · WS authenticated streams (`watchBalance`, `watchMyTrades`, `watchOrders`) [D:6/B:8/U:7 → Eff:1.25] 📋 | -| Task T6 | ⬜ | 🎁 **trade-verify** · Document the actual stability surface [D:2/B:6/U:7 → Eff:3.25] 🎯 | +| Task T4 | ⬜ | 🎁 **trade-verify** · 🚀 **v1_0** · Signed-payload byte-comparison harness [D:7/B:9/U:8 → Eff:1.21] 📋 | +| Task T5 | ⬜ | 🎁 **trade-verify** · 🚀 **v1_0** · WS authenticated streams (`watchBalance`, `watchMyTrades`, `watchOrders`) [D:6/B:8/U:7 → Eff:1.25] 📋 | +| Task T6 | ⬜ | 🎁 **trade-verify** · 🚀 **v1_0** · Document the actual stability surface [D:2/B:6/U:7 → Eff:3.25] 🎯 | --- @@ -96,9 +96,9 @@ | Task | Status | Notes | |------|--------|-------| | Task 14 | ✅ | 🎁 **production** · Telemetry events [D:3/B:7/U:7 → Eff:2.33] 🎯 | -| Task 15 | ⬜ | 🎁 **production** · Memory monitoring + restart policy [D:5/B:8/U:7 → Eff:1.5] 🚀 | +| Task 15 | ⬜ | 🎁 **production** · 🚀 **v1_0** · Memory monitoring + restart policy [D:5/B:8/U:7 → Eff:1.5] 🚀 | | Task 16 | ⬜ | 🎁 **production** · ApiToolkit integration [D:4/B:6/U:6 → Eff:1.5] 🚀 | -| Task 16b | ⬜ | 🎁 **production** · Hoist rate-limit + nonce state into Elixir [D:7/B:9/U:8 → Eff:1.21] 📋 | +| Task 16b | ⬜ | 🎁 **production** · 🚀 **v1_0** · Hoist rate-limit + nonce state into Elixir [D:7/B:9/U:8 → Eff:1.21] 📋 | | Task 17 | ⬜ | 🎁 **production** · Sandboxed deployment shape [D:5/B:8/U:5 → Eff:1.3] 📋 | | Task 21 | ✅ | 🎁 **production** · PromEx plugin (`CcxtOcx.PromEx.Plugin`) [D:3/B:7/U:7 → Eff:2.33] 🎯 | @@ -113,6 +113,7 @@ | Task 18 | ⬜ | 🎁 **dx** · ex_doc + llms.txt [D:2/B:5/U:5 → Eff:2.5] 🎯 | | Task 19 | ⬜ | 🎁 **dx** · Descripex annotations on the public API [D:3/B:5/U:4 → Eff:1.5] 🚀 | | Task 20 | 🔄 | 🎁 **dx** · Tidewave examples [D:2/B:4/U:5 → Eff:2.25] 🎯 | +| Task 22 | ✅ | 🎁 **dx** · Dev telemetry dogfooding loop (CcxtOcx.DevTelemetry) [D:2/B:5/U:8 → Eff:3.25] 🎯 | --- diff --git a/docs/tidewave_examples.md b/docs/tidewave_examples.md index abeb044..cd30e7a 100644 --- a/docs/tidewave_examples.md +++ b/docs/tidewave_examples.md @@ -149,6 +149,62 @@ CcxtOcx.Runtime.memory_usage(:rt) # same map, no telemetry side-effect end, %{}) ``` +## Dev Telemetry Loop (Dogfooding) + +`CcxtOcx.DevTelemetry` is the maintainer-side companion to `CcxtOcx.Telemetry`: one +call attaches a handler to every `[:ccxt_ocx, ...]` event family, pretty-prints each +emission to stdout, and keeps running counts + last-values in an `Agent` you can +`summary/0` at any time. Use it as the validation loop for new emission sites +(Task 7 `defunified`, Task 11 `defstreaming`, Task 15 memory monitor) before +downstream consumers (`CcxtOcx.PromEx.Plugin`, Grafana dashboards) bake in +assumptions about the contract. + +```elixir +# Attach once per session — idempotent (re-attach detaches the prior handler +# and resets counts). +CcxtOcx.DevTelemetry.watch() + +# Drive load. Every emission lands as a one-line print + an Agent bump. +{:ok, _} = CcxtOcx.Runtime.start_link(name: :rt) +CcxtOcx.Runtime.memory(:rt) +# => [memory] malloc=12.3M used=8.1M objs=14523 server=#PID<0.345.0> phase=manual + +# Snapshot when you want to assert the shape. +CcxtOcx.DevTelemetry.summary() +# => %{ +# runtime_memory: %{count: 2, last_measurements: %{...}, last_metadata: %{...}}, +# rest_start: %{count: 0, last_measurements: nil, last_metadata: nil}, +# rest_stop: %{count: 0, last_measurements: nil, last_metadata: nil}, +# rest_exception: %{count: 0, last_measurements: nil, last_metadata: nil}, +# ws_tick: %{count: 0, last_measurements: nil, last_metadata: nil} +# } + +# Clear counters without re-attaching. +CcxtOcx.DevTelemetry.reset() + +# Detach when you're done. +CcxtOcx.DevTelemetry.detach() +``` + +`watch/1` takes a few options for ad-hoc filtering: + +```elixir +# Silence pretty-print, keep counters only (handy when driving high-frequency +# WS load and the prints would scroll past too fast to read). +CcxtOcx.DevTelemetry.watch(print: false) + +# Restrict to a subset of event families. +CcxtOcx.DevTelemetry.watch(filter: [:runtime_memory, :ws_tick]) + +# Send pretty-print to a different IO device (e.g. a log file). +{:ok, log} = File.open("/tmp/ccxt_ocx.log", [:write]) +CcxtOcx.DevTelemetry.watch(io_device: log) +``` + +In dev, `iex -S mix` (and therefore `iex -S mix tidewave`) auto-attaches +`DevTelemetry` via the project's `.iex.exs`, so you can drop straight into +`summary/0` without a manual `watch/0` call. + ## Using Other Tidewave Tools Together While you have a runtime running, combine it with Tidewave's other MCP tools: diff --git a/lib/ccxt_ocx/dev_telemetry.ex b/lib/ccxt_ocx/dev_telemetry.ex new file mode 100644 index 0000000..532064a --- /dev/null +++ b/lib/ccxt_ocx/dev_telemetry.ex @@ -0,0 +1,242 @@ +defmodule CcxtOcx.DevTelemetry do + @moduledoc """ + Maintainer-side dogfooding helpers for ccxt_ocx telemetry. + + Attach to every `[:ccxt_ocx, ...]` event family in one call, pretty-print + each emission to a configurable IO device, keep running counts plus the + last-seen measurements/metadata in an `Agent`, dump the summary on demand. + + For Tidewave / IEx sessions only — this module owns a named process and + prints to stdout by default, so it has no business in `start/0` / + `Application.start/2`. There is no supervisor entry; `watch/1` starts the + Agent lazily. + + ## Usage + + iex> CcxtOcx.DevTelemetry.watch() + :ok + iex> CcxtOcx.Runtime.memory(:rt) + [memory] malloc=12.3M used=8.1M objs=14523 server=#PID<0.345.0> phase=manual + iex> CcxtOcx.DevTelemetry.summary() + %{ + runtime_memory: %{count: 1, last_measurements: %{...}, last_metadata: %{...}}, + rest_start: %{count: 0, last_measurements: nil, last_metadata: nil}, + rest_stop: %{count: 0, last_measurements: nil, last_metadata: nil}, + rest_exception: %{count: 0, last_measurements: nil, last_metadata: nil}, + ws_tick: %{count: 0, last_measurements: nil, last_metadata: nil} + } + + See `docs/tidewave_examples.md` § "Dev Telemetry Loop" for the + observe-while-driving cycle this module is designed for. + + ## Options for `watch/1` + + * `:print` — print each emission to `:io_device`. Default: `true`. + * `:filter` — restrict to a subset of event keys (see `t:event_key/0`). + Default: all events. + * `:io_device` — where to write pretty-print output. Default: `:stdio`. + + Idempotent — re-calling `watch/1` detaches the previous handler and + resets state before re-attaching with the new options. + """ + + @handler_id "ccxt-ocx-dev-telemetry" + + @events %{ + runtime_memory: [:ccxt_ocx, :runtime, :memory], + rest_start: [:ccxt_ocx, :rest, :start], + rest_stop: [:ccxt_ocx, :rest, :stop], + rest_exception: [:ccxt_ocx, :rest, :exception], + ws_tick: [:ccxt_ocx, :ws, :tick] + } + + @type event_key :: + :runtime_memory | :rest_start | :rest_stop | :rest_exception | :ws_tick + + @type entry :: %{ + count: non_neg_integer(), + last_measurements: map() | nil, + last_metadata: map() | nil + } + + @type state :: %{event_key() => entry()} + + @type opts :: [ + print: boolean(), + filter: [event_key()], + io_device: IO.device() + ] + + @doc """ + Attach a single handler to every `[:ccxt_ocx, ...]` event family. + + Idempotent — re-calling detaches the previous handler and resets the + in-memory state before re-attaching. + """ + @spec watch(opts()) :: :ok + def watch(opts \\ []) do + print? = Keyword.get(opts, :print, true) + io_device = Keyword.get(opts, :io_device, :stdio) + filter = Keyword.get(opts, :filter, Map.keys(@events)) + + :ok = detach() + :ok = ensure_agent() + :ok = Agent.update(__MODULE__, fn _ -> initial_state() end) + + events = Enum.map(filter, &Map.fetch!(@events, &1)) + + :ok = + :telemetry.attach_many( + @handler_id, + events, + &__MODULE__.handle_event/4, + %{print?: print?, io_device: io_device} + ) + + :ok + end + + @doc """ + Detach the dev-telemetry handler. Idempotent. + + Leaves the Agent (and its accumulated counts) alone — call `reset/0` to + clear, or `summary/0` to inspect post-detach. + """ + @spec detach() :: :ok + def detach do + _ = :telemetry.detach(@handler_id) + :ok + end + + @doc """ + Current in-memory state. + + Returns the initial all-zeros state if `watch/1` was never called. + """ + @spec summary() :: state() + def summary do + case Process.whereis(__MODULE__) do + nil -> initial_state() + _pid -> Agent.get(__MODULE__, & &1) + end + end + + @doc """ + Reset all counts and last-values to the initial state. + + No-op if `watch/1` was never called. + """ + @spec reset() :: :ok + def reset do + case Process.whereis(__MODULE__) do + nil -> :ok + _pid -> Agent.update(__MODULE__, fn _ -> initial_state() end) + end + end + + @doc false + @spec handle_event([atom()], map(), map(), map()) :: :ok + def handle_event(event, measurements, metadata, config) do + key = event_to_key(event) + + if Process.whereis(__MODULE__) do + Agent.update(__MODULE__, fn state -> + entry = Map.fetch!(state, key) + + new_entry = %{ + entry + | count: entry.count + 1, + last_measurements: measurements, + last_metadata: metadata + } + + Map.put(state, key, new_entry) + end) + end + + if Map.get(config, :print?, false) do + io_device = Map.get(config, :io_device, :stdio) + IO.puts(io_device, format(key, measurements, metadata)) + end + + :ok + end + + # ------------------------------------------------------------------ + # private + # ------------------------------------------------------------------ + + @spec ensure_agent() :: :ok + defp ensure_agent do + case Agent.start(fn -> initial_state() end, name: __MODULE__) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + + @spec initial_state() :: state() + defp initial_state do + @events + |> Map.keys() + |> Map.new(fn key -> {key, %{count: 0, last_measurements: nil, last_metadata: nil}} end) + end + + @spec event_to_key([atom()]) :: event_key() + defp event_to_key([:ccxt_ocx, :runtime, :memory]), do: :runtime_memory + defp event_to_key([:ccxt_ocx, :rest, :start]), do: :rest_start + defp event_to_key([:ccxt_ocx, :rest, :stop]), do: :rest_stop + defp event_to_key([:ccxt_ocx, :rest, :exception]), do: :rest_exception + defp event_to_key([:ccxt_ocx, :ws, :tick]), do: :ws_tick + + @spec format(event_key(), map(), map()) :: String.t() + defp format(:runtime_memory, meas, meta) do + malloc = format_bytes(Map.get(meas, :malloc_size)) + used = format_bytes(Map.get(meas, :memory_used_size)) + objs = Map.get(meas, :obj_count, "?") + phase = Map.get(meta, :phase, :manual) + + target = + cond do + Map.has_key?(meta, :server) -> "server=#{inspect(meta.server)}" + Map.has_key?(meta, :pool) -> "pool=#{inspect(meta.pool)}" + true -> "target=?" + end + + "[memory] malloc=#{malloc} used=#{used} objs=#{objs} #{target} phase=#{phase}" + end + + defp format(:rest_start, _meas, meta) do + "[rest:start] exchange=#{Map.get(meta, :exchange, "?")} method=#{Map.get(meta, :method, "?")}" + end + + defp format(:rest_stop, meas, meta) do + duration = format_duration(Map.get(meas, :duration)) + + "[rest:stop] exchange=#{Map.get(meta, :exchange, "?")} method=#{Map.get(meta, :method, "?")} duration=#{duration}" + end + + defp format(:rest_exception, _meas, meta) do + "[rest:exception] exchange=#{Map.get(meta, :exchange, "?")} method=#{Map.get(meta, :method, "?")} kind=#{Map.get(meta, :kind, "?")}" + end + + defp format(:ws_tick, _meas, meta) do + "[ws:tick] exchange=#{Map.get(meta, :exchange, "?")} stream=#{Map.get(meta, :stream, "?")} type=#{Map.get(meta, :type, "?")}" + end + + @spec format_bytes(integer() | nil) :: String.t() + defp format_bytes(nil), do: "?" + + defp format_bytes(n) when is_integer(n) and n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M" + + defp format_bytes(n) when is_integer(n) and n >= 1_000, do: "#{Float.round(n / 1_000, 1)}K" + + defp format_bytes(n) when is_integer(n), do: "#{n}B" + + @spec format_duration(integer() | nil) :: String.t() + defp format_duration(nil), do: "?" + + defp format_duration(n) when is_integer(n) do + us = System.convert_time_unit(n, :native, :microsecond) + "#{Float.round(us / 1000, 2)}ms" + end +end diff --git a/roadmap/data.json b/roadmap/data.json index 4b83cbb..71f4bc2 100644 --- a/roadmap/data.json +++ b/roadmap/data.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "project": "ccxt_ocx", "default_branch": "development", "focus": { @@ -79,6 +79,22 @@ "description": "Testnet harnesses + signed-payload byte-comparison for the trade plane" } }, + "milestones": { + "v0_1": { + "name": "v0.1 — first usable (data plane)", + "description": "Macro-generated read-only data plane on Tier-1 venues end-to-end. Library is callable from IEx for fetchTicker/fetchOrderBook/fetchOHLCV via typed wrappers.", + "order": 1, + "status": "active", + "target_version": "0.1.0" + }, + "v1_0": { + "name": "v1.0 — production-ready trade plane (orders + WS auth streams)", + "description": "Full unified CCXT surface incl. signed trade plane verified against Binance USDT-M futures + Deribit options testnets, with auth WS streams (watchBalance/watchOrders/watchMyTrades), reconnect/heartbeat policy, rate-limit + nonce hoisted into Elixir, and memory/restart safety. OKX explicitly out of scope for v1.0.", + "order": 2, + "status": "pending", + "target_version": "1.0.0" + } + }, "task": [ { "id": "1", @@ -97,6 +113,7 @@ "shipped_in": "see CHANGELOG.md", "body": "Implemented as `lib/ccxt_ocx/runtime.ex` — GenServer owning one QuickBEAM runtime with browser stubs (`self`/`window`/`navigator`/`location`) and the ccxt browser bundle pre-loaded. `eval/3`, `call/4`, `with_runtime/2`, `info/1` helpers fetch the raw rt once and dispatch directly (no double GenServer hop). Caller-crash isolation verified by test. See CHANGELOG.md#unreleased.\n", "scored_at": "2026-05-16", + "implemented": "As specified in body — `lib/ccxt_ocx/runtime.ex` GenServer with browser stubs + pre-loaded CCXT bundle; direct-dispatch helpers shipped; caller-crash isolation test green.", "cross_repo": [] }, { @@ -116,6 +133,7 @@ "shipped_in": "see CHANGELOG.md", "body": "`mix.exs` floor lifted to `~> 0.10.4`; rationale recorded in CLAUDE.md \"Why these dependency floors\" section.\n", "scored_at": "2026-05-16", + "implemented": "As specified in body — floor at `~> 0.10.4` (since bumped to 0.10.12 floor as documented in CLAUDE.md § 'Dependency notes').", "cross_repo": [] }, { @@ -140,6 +158,7 @@ ], "body": "Build the supervised runtime pooling layer from observed QuickBEAM behavior, not assumption. Tidewave confirmed `QuickBEAM.Pool.run/3` resets and re-runs `:init` after each checkout; because the CCXT browser bundle is ~5.4 MB, first benchmark whether that reset cost is acceptable for ccxt workloads. If it is acceptable, wrap `QuickBEAM.Pool` behind `CcxtOcx.RuntimePool`. If it is not, implement a long-lived pool of `CcxtOcx.Runtime` workers that keeps CCXT loaded across calls. The public pool API should hide which strategy is chosen and should support sizing per `(exchange, market_type)`.\n", "scored_at": "2026-05-16", + "implemented": "Long-lived pool strategy chosen after benchmarking confirmed `QuickBEAM.Pool.run/3` reset cost was unacceptable (~5.4MB CCXT bundle re-eval per checkout). `CcxtOcx.RuntimePool` keeps CCXT loaded across calls, supervised under the application, sizable per `(exchange, market_type)`. Worker crash/restart and concurrent-checkout behavior covered by tests.", "cross_repo": [] }, { @@ -158,6 +177,7 @@ "depends_on": [], "body": "Define the public error contract once. Delivered: closed 9-tag taxonomy (`:bad_symbol | :network | :rate_limit | :auth | :not_found | :permission | :exchange | :timeout | :unknown`), `%CcxtOcx.Error{}` struct with `:source` / `:source_name` discriminator (future-proofs Phase 7 native adapters), three-function API (`tag_for_ccxt_class/1` pure, `from_js_error/2`, `normalize/2` with `[:exchange, :method, :original, :meta]` opts for context injection), and a compile-time drift gate using `@external_resource \"ccxt/js/src/base/errors.d.ts\"` + exhaustive check of exported CCXT error classes.\n\nJS path (QuickBEAM): raw `%{name: \"BadSymbol\", ...}` or `%QuickBEAM.JSError{}` → normalized struct with `source: :js`, `source_name: \"BadSymbol\"`. Wrappers call `normalize(raw, exchange: :binance, method: \"createOrder\")`.\n\nNative adapters (Phase 7): will supply `source: :binance`, `source_name: venue_code`. Consumers match on `:tag`; provenance lives in the two discriminator fields.\n\nThe gate fails compilation when a new CCXT subclass appears in the .d.ts and lacks a mapping. Real QuickBEAM tests exercise actual CCXT error constructors (BadSymbol, RateLimitExceeded, AuthenticationError, ...).\n", "scored_at": "2026-05-16", + "implemented": "As specified in body — 9-tag closed taxonomy, `%CcxtOcx.Error{}` struct with `source`/`source_name` discriminator, three-function API (`tag_for_ccxt_class/1`, `from_js_error/2`, `normalize/2`), compile-time drift gate via `@external_resource`. Live CCXT error constructors exercised in tests.", "cross_repo": [] }, { @@ -176,6 +196,7 @@ "depends_on": [], "body": "ExUnit tag `:integration` (off by default), tagged `:network` for tests that hit Binance. Repeat the live verification done in tidewave: bundle loads, OXC parses, ticker/orderbook/OHLCV/trades arrive, WS streams 3 ticker pushes. Use `flunk/1` with actionable messages on missing-network — never skip silently.\n", "scored_at": "2026-05-16", + "implemented": "As specified in body — `:integration` + `:network` tagged ExUnit smoke suite covering bundle load, OXC parse, ticker/orderbook/OHLCV/trades, and 3-tick WS verification. `flunk/1` with actionable messages on missing-network (never silent skip).", "cross_repo": [] }, { @@ -194,6 +215,7 @@ "depends_on": [], "body": "CCXT releases multiple times per week. A `mix npm.update ccxt` must not silently regress codegen. Bake a `mix ccxt.verify_bundle` task that: re-parses `node_modules/ccxt/js/ccxt.d.ts` via OXC, diffs the unified-method list and the per-exchange `has` table against a checked-in manifest (`priv/ccxt_surface.exs`), and re-runs the testnet harnesses (T1–T3) before the bump can be merged. Manifest drift = explicit human review, not silent acceptance. Run on every PR that touches `node_modules/ccxt/` or `package.json`.\n", "scored_at": "2026-05-16", + "implemented": "As specified in body — `mix ccxt.verify_bundle` task implemented; OXC-driven re-parse + manifest diff vs `priv/ccxt_surface.exs`. Testnet harness invocation deferred to land alongside T1–T3 themselves (verify_bundle infrastructure ready when those exist).", "cross_repo": [] }, { @@ -213,12 +235,14 @@ "shipped_in": "e5609ae (PR #1)", "body": "Mirror ccxt_extract's tier concept locally — no cross-project coupling, since ccxt_ocx ships to hex and can't path-dep on a sibling. Same four buckets (`:tier1`, `:tier2`, `:tier3`, `:dex`) with the same hand-curated roots (tier1: binance, bybit, okx, deribit, coinbaseexchange; tier2: kraken, kucoin, gate, htx, bitmex, bitfinex; etc.) committed to `priv/priority_tiers.json`. Variant/alias inheritance (binance → binanceus/binancecoinm/binanceusdm; okx → okxus/myokx; htx → huobi) derived at compile time from the **loaded CCXT bundle's class hierarchy** — read `Object.getPrototypeOf` chains via QuickBEAM, no `priv/discoveries/` dependency. Public API mirrors ccxt_extract: `tier1_members/0`, `members_for_tier/1`, `get_priority_tier/1`, `tier1?/1`, `collect_tier_exchanges/1`. Used downstream by: Phase 3 WS verification spread, Phase 4 testnet harnesses (`mix ccxt_ocx.testnet --tier1`), Phase 5 rate-limit pinning priority, Phase 7 native-port scoping (N4–N7 = \"Tier 1 venues\"). **Drift policy:** ccxt_extract's `priority_tiers.json` is the inspiration but not the contract — when curation changes there, manually port relevant changes here. Document the divergence risk in the moduledoc.\n", "scored_at": "2026-05-16", + "implemented": "Shipped in e5609ae (PR #1). `priv/priority_tiers.json` + `CcxtOcx.Tiers` with the public API specified (tier1_members/0, members_for_tier/1, get_priority_tier/1, tier1?/1, collect_tier_exchanges/1). Compile-time variant/alias inheritance from CCXT bundle's class hierarchy via QuickBEAM `Object.getPrototypeOf` walk.", "cross_repo": [] }, { "id": "6", "phase": 2, "bundle": "macros", + "milestone": "v0_1", "status": "pending", "title": "Discover and parse CCXT declaration sources with OXC", "scores": { @@ -243,6 +267,7 @@ "id": "6b", "phase": 2, "bundle": "macros", + "milestone": "v0_1", "status": "pending", "title": "`use CcxtOcx` — exchange-scope entrypoint", "scores": { @@ -261,6 +286,7 @@ "id": "7", "phase": 2, "bundle": "macros", + "milestone": "v0_1", "status": "pending", "title": "`defunified` macro", "scores": { @@ -283,6 +309,7 @@ "id": "8", "phase": 2, "bundle": "macros", + "milestone": "v0_1", "status": "pending", "title": "Typed structs for unified return shapes (with declarative field mapping)", "scores": { @@ -318,6 +345,7 @@ "id": "9", "phase": 2, "bundle": "macros", + "milestone": "v0_1", "status": "pending", "title": "`defexchange` macro", "scores": { @@ -356,6 +384,7 @@ "id": "11", "phase": 3, "bundle": "streaming", + "milestone": "v1_0", "status": "pending", "title": "`defstreaming` macro", "scores": { @@ -376,6 +405,7 @@ "id": "12", "phase": 3, "bundle": "streaming", + "milestone": "v1_0", "status": "pending", "title": "`CcxtOcx.Stream` GenStage producer", "scores": { @@ -396,6 +426,7 @@ "id": "13", "phase": 3, "bundle": "streaming", + "milestone": "v1_0", "status": "pending", "title": "Reconnect / heartbeat policy", "scores": { @@ -414,6 +445,7 @@ "id": "T1", "phase": 4, "bundle": "trade-verify", + "milestone": "v1_0", "status": "pending", "title": "Testnet harness — Binance USDT-M futures", "scores": { @@ -434,6 +466,7 @@ "id": "T2", "phase": 4, "bundle": "trade-verify", + "milestone": "v1_0", "status": "pending", "title": "Testnet harness — Deribit options", "scores": { @@ -470,6 +503,7 @@ "id": "T4", "phase": 4, "bundle": "trade-verify", + "milestone": "v1_0", "status": "pending", "title": "Signed-payload byte-comparison harness", "scores": { @@ -488,6 +522,7 @@ "id": "T5", "phase": 4, "bundle": "trade-verify", + "milestone": "v1_0", "status": "pending", "title": "WS authenticated streams (`watchBalance`, `watchMyTrades`, `watchOrders`)", "scores": { @@ -506,6 +541,7 @@ "id": "T6", "phase": 4, "bundle": "trade-verify", + "milestone": "v1_0", "status": "pending", "title": "Document the actual stability surface", "scores": { @@ -544,12 +580,14 @@ "started_at": "2026-05-17", "done_at": "2026-05-17", "scored_at": "2026-05-16", + "implemented": "As specified in body — `:telemetry` events shipped: `[:ccxt_ocx, :rest, :start|:stop|:exception]`, `[:ccxt_ocx, :ws, :tick]`, `[:ccxt_ocx, :runtime, :memory]`. Validated live via Tidewave against Deribit option-chain surfaces.", "cross_repo": [] }, { "id": "15", "phase": 5, "bundle": "production", + "milestone": "v1_0", "status": "pending", "title": "Memory monitoring + restart policy", "scores": { @@ -586,6 +624,7 @@ "id": "16b", "phase": 5, "bundle": "production", + "milestone": "v1_0", "status": "pending", "title": "Hoist rate-limit + nonce state into Elixir", "scores": { @@ -711,6 +750,7 @@ "started_at": "2026-05-17", "done_at": "2026-05-17", "scored_at": "2026-05-17", + "implemented": "As specified in body and ACs — `CcxtOcx.PromEx.Plugin` with `event_metrics/1` + `polling_metrics/1`; runtime-memory metrics live, REST/WS reserved for Phase 2/3; tag normalizers default missing keys to `\"none\"`; `{:prom_ex, optional: true}` in `mix.exs`; coverage ≥80%; README 'Observability — PromEx' section added.", "cross_repo": [] }, { @@ -896,6 +936,46 @@ "body": "Document the operational tax: every CCXT release ships adapter fixes (new fields, normalized formats, bug fixes for venue API changes). Once on native, those don't propagate. Either pin a CCXT version and accept drift, or subscribe to CCXT's git log and port relevant fixes per release for migrated exchanges. The honest framing is \"native exchanges are now your responsibility to maintain\"; budget engineering time accordingly.\n", "scored_at": "2026-05-16", "cross_repo": [] + }, + { + "id": 22, + "phase": 6, + "bundle": "dx", + "status": "done", + "title": "Dev telemetry dogfooding loop (CcxtOcx.DevTelemetry)", + "scores": { + "d": 2, + "b": 5, + "u": 8 + }, + "eff": 3.25, + "markers": [], + "depends_on": [ + "14" + ], + "acceptance_criteria": [ + "lib/ccxt_ocx/dev_telemetry.ex exports watch/1, summary/0, reset/0, detach/0", + "watch/1 is idempotent — re-call resets state and only one handler stays attached", + "watch/1 supports :print, :filter, :io_device options", + "summary/0 returns initial all-zeros state when watch/1 was never called (no Agent process)", + "reset/0 is a no-op when Agent was never started", + "detach/0 is idempotent", + "Every event family routes to its own counter bucket (runtime_memory, rest_start/stop/exception, ws_tick)", + ".iex.exs auto-attaches in dev with a one-line banner; guarded on Mix.env() == :dev AND Code.ensure_loaded?", + "docs/tidewave_examples.md has a 'Dev Telemetry Loop' section showing watch → drive → summary → reset → detach", + "Full harness green: format, compile --warnings-as-errors, test.json, credo --strict, dialyzer.json, doctor" + ], + "out_of_scope": [ + "Persisting DevTelemetry state across IEx sessions", + "Production-mode telemetry (logging handlers, structured-log shipping)", + "Supervisor entry — watch/1 starts the Agent lazily; dev tool only" + ], + "body": "Maintainer-side dogfooding companion to CcxtOcx.Telemetry. One `watch/1` call\nattaches to every [:ccxt_ocx, ...] event family, pretty-prints each emission\nto a configurable IO device, and keeps running counts plus last-seen\nmeasurements/metadata in an Agent. `summary/0` dumps state on demand;\n`reset/0` clears; `detach/0` stops counting.\n\nUsed as the validation loop for every new emission site (Task 7 defunified,\nTask 11 defstreaming, Task 15 memory monitor) before downstream consumers\n(PromEx plugin, Grafana) bake in assumptions about the contract. Auto-attached\nin dev via .iex.exs banner.\n", + "created_at": "2026-05-17", + "done_at": "2026-05-17", + "scored_at": "2026-05-17", + "implemented": "Shipped lib/ccxt_ocx/dev_telemetry.ex (Agent-backed dogfooding module: watch/1, summary/0, reset/0, detach/0; idempotent attach; :print/:filter/:io_device options; one-line pretty-print per event family). 14 tests covering attach/idempotency/filter/io_device/per-event routing/accumulation/last-wins/reset/detach. Added .iex.exs auto-attach with banner (guarded on Mix.env() == :dev AND Code.ensure_loaded?). docs/tidewave_examples.md: new 'Dev Telemetry Loop' section between Memory monitoring and Tidewave tools.", + "cross_repo": [] } ] } \ No newline at end of file diff --git a/roadmap/tasks.toml b/roadmap/tasks.toml index 12dd4d6..5017677 100644 --- a/roadmap/tasks.toml +++ b/roadmap/tasks.toml @@ -1,4 +1,4 @@ -schema_version = 1 +schema_version = 2 project = "ccxt_ocx" default_branch = "development" @@ -80,6 +80,22 @@ phase = 7 order = 1 description = "Native-Elixir adapters for Tier-1 venues — defendpoint macro, behaviours, routing, conformance harness, per-venue ports" +# ── Milestones — release lines (cross-phase) ────────────────────────────── + +[milestones.v0_1] +name = "v0.1 — first usable (data plane)" +description = "Macro-generated read-only data plane on Tier-1 venues end-to-end. Library is callable from IEx for fetchTicker/fetchOrderBook/fetchOHLCV via typed wrappers." +target_version = "0.1.0" +order = 1 +status = "active" + +[milestones.v1_0] +name = "v1.0 — production-ready trade plane (orders + WS auth streams)" +description = "Full unified CCXT surface incl. signed trade plane verified against Binance USDT-M futures + Deribit options testnets, with auth WS streams (watchBalance/watchOrders/watchMyTrades), reconnect/heartbeat policy, rate-limit + nonce hoisted into Elixir, and memory/restart safety. OKX explicitly out of scope for v1.0." +target_version = "1.0.0" +order = 2 +status = "pending" + # ── Tasks ───────────────────────────────────────────────────────────────── # Phase 1 — Foundation ───────────────────────────────────────────────────── @@ -96,6 +112,7 @@ shipped_in = "see CHANGELOG.md" body = """ Implemented as `lib/ccxt_ocx/runtime.ex` — GenServer owning one QuickBEAM runtime with browser stubs (`self`/`window`/`navigator`/`location`) and the ccxt browser bundle pre-loaded. `eval/3`, `call/4`, `with_runtime/2`, `info/1` helpers fetch the raw rt once and dispatch directly (no double GenServer hop). Caller-crash isolation verified by test. See CHANGELOG.md#unreleased. """ +implemented = "As specified in body — `lib/ccxt_ocx/runtime.ex` GenServer with browser stubs + pre-loaded CCXT bundle; direct-dispatch helpers shipped; caller-crash isolation test green." [[task]] id = "2" @@ -109,6 +126,7 @@ shipped_in = "see CHANGELOG.md" body = """ `mix.exs` floor lifted to `~> 0.10.4`; rationale recorded in CLAUDE.md "Why these dependency floors" section. """ +implemented = "As specified in body — floor at `~> 0.10.4` (since bumped to 0.10.12 floor as documented in CLAUDE.md § 'Dependency notes')." [[task]] id = "3" @@ -127,6 +145,7 @@ acceptance_criteria = [ "Each checked-out runtime has CCXT loaded and reports the same bundle version/exchange count as `CcxtOcx.Runtime.info/1`", "Tests cover worker crash/restart behavior and concurrent checkout behavior" ] +implemented = "Long-lived pool strategy chosen after benchmarking confirmed `QuickBEAM.Pool.run/3` reset cost was unacceptable (~5.4MB CCXT bundle re-eval per checkout). `CcxtOcx.RuntimePool` keeps CCXT loaded across calls, supervised under the application, sizable per `(exchange, market_type)`. Worker crash/restart and concurrent-checkout behavior covered by tests." [[task]] id = "4" @@ -145,6 +164,7 @@ Native adapters (Phase 7): will supply `source: :binance`, `source_name: venue_c The gate fails compilation when a new CCXT subclass appears in the .d.ts and lacks a mapping. Real QuickBEAM tests exercise actual CCXT error constructors (BadSymbol, RateLimitExceeded, AuthenticationError, ...). """ +implemented = "As specified in body — 9-tag closed taxonomy, `%CcxtOcx.Error{}` struct with `source`/`source_name` discriminator, three-function API (`tag_for_ccxt_class/1`, `from_js_error/2`, `normalize/2`), compile-time drift gate via `@external_resource`. Live CCXT error constructors exercised in tests." [[task]] id = "5" @@ -157,6 +177,7 @@ scored_at = "2026-05-16" body = """ ExUnit tag `:integration` (off by default), tagged `:network` for tests that hit Binance. Repeat the live verification done in tidewave: bundle loads, OXC parses, ticker/orderbook/OHLCV/trades arrive, WS streams 3 ticker pushes. Use `flunk/1` with actionable messages on missing-network — never skip silently. """ +implemented = "As specified in body — `:integration` + `:network` tagged ExUnit smoke suite covering bundle load, OXC parse, ticker/orderbook/OHLCV/trades, and 3-tick WS verification. `flunk/1` with actionable messages on missing-network (never silent skip)." [[task]] id = "5b" @@ -169,6 +190,7 @@ scored_at = "2026-05-16" body = """ CCXT releases multiple times per week. A `mix npm.update ccxt` must not silently regress codegen. Bake a `mix ccxt.verify_bundle` task that: re-parses `node_modules/ccxt/js/ccxt.d.ts` via OXC, diffs the unified-method list and the per-exchange `has` table against a checked-in manifest (`priv/ccxt_surface.exs`), and re-runs the testnet harnesses (T1–T3) before the bump can be merged. Manifest drift = explicit human review, not silent acceptance. Run on every PR that touches `node_modules/ccxt/` or `package.json`. """ +implemented = "As specified in body — `mix ccxt.verify_bundle` task implemented; OXC-driven re-parse + manifest diff vs `priv/ccxt_surface.exs`. Testnet harness invocation deferred to land alongside T1–T3 themselves (verify_bundle infrastructure ready when those exist)." [[task]] id = "5c" @@ -182,6 +204,7 @@ shipped_in = "e5609ae (PR #1)" body = """ Mirror ccxt_extract's tier concept locally — no cross-project coupling, since ccxt_ocx ships to hex and can't path-dep on a sibling. Same four buckets (`:tier1`, `:tier2`, `:tier3`, `:dex`) with the same hand-curated roots (tier1: binance, bybit, okx, deribit, coinbaseexchange; tier2: kraken, kucoin, gate, htx, bitmex, bitfinex; etc.) committed to `priv/priority_tiers.json`. Variant/alias inheritance (binance → binanceus/binancecoinm/binanceusdm; okx → okxus/myokx; htx → huobi) derived at compile time from the **loaded CCXT bundle's class hierarchy** — read `Object.getPrototypeOf` chains via QuickBEAM, no `priv/discoveries/` dependency. Public API mirrors ccxt_extract: `tier1_members/0`, `members_for_tier/1`, `get_priority_tier/1`, `tier1?/1`, `collect_tier_exchanges/1`. Used downstream by: Phase 3 WS verification spread, Phase 4 testnet harnesses (`mix ccxt_ocx.testnet --tier1`), Phase 5 rate-limit pinning priority, Phase 7 native-port scoping (N4–N7 = "Tier 1 venues"). **Drift policy:** ccxt_extract's `priority_tiers.json` is the inspiration but not the contract — when curation changes there, manually port relevant changes here. Document the divergence risk in the moduledoc. """ +implemented = "Shipped in e5609ae (PR #1). `priv/priority_tiers.json` + `CcxtOcx.Tiers` with the public API specified (tier1_members/0, members_for_tier/1, get_priority_tier/1, tier1?/1, collect_tier_exchanges/1). Compile-time variant/alias inheritance from CCXT bundle's class hierarchy via QuickBEAM `Object.getPrototypeOf` walk." # Phase 2 — Macro-Driven Method Generation ──────────────────────────────── @@ -189,28 +212,29 @@ Mirror ccxt_extract's tier concept locally — no cross-project coupling, since id = "6" phase = 2 bundle = "macros" +milestone = "v0_1" status = "pending" title = "Discover and parse CCXT declaration sources with OXC" scores = { d = 6, b = 8, u = 8 } -scored_at = "2026-05-16" -body = """ -Build a compile-time module that discovers the actual CCXT TypeScript declaration sources and extracts unified method metadata with `OXC.parse/2` + `OXC.collect/2`. Tidewave/file inspection showed `node_modules/ccxt/js/ccxt.d.ts` is an import/export barrel and does not itself contain methods such as `fetchTicker`, `createOrder`, or `watchTicker`; method declarations live in `js/src/base/Exchange.d.ts`, per-exchange declarations under `js/src/*.d.ts`, and pro declarations under `js/src/pro/*.d.ts`. Output an Elixir term describing the method name, parameter list, return type, owning surface (`:base`, `:exchange`, `:pro`), and exchange-specific overrides where present. -""" acceptance_criteria = [ "Parser extracts `fetchTicker`, `createOrder`, and `watchTicker` from the current CCXT package", "Parser distinguishes base unified methods from per-exchange and pro declarations", "Output includes method name, params, return type, source file, and owning surface", "Tests fail loudly if CCXT declaration file layout changes or required methods disappear" ] +body = """ +Build a compile-time module that discovers the actual CCXT TypeScript declaration sources and extracts unified method metadata with `OXC.parse/2` + `OXC.collect/2`. Tidewave/file inspection showed `node_modules/ccxt/js/ccxt.d.ts` is an import/export barrel and does not itself contain methods such as `fetchTicker`, `createOrder`, or `watchTicker`; method declarations live in `js/src/base/Exchange.d.ts`, per-exchange declarations under `js/src/*.d.ts`, and pro declarations under `js/src/pro/*.d.ts`. Output an Elixir term describing the method name, parameter list, return type, owning surface (`:base`, `:exchange`, `:pro`), and exchange-specific overrides where present. +""" +scored_at = "2026-05-16" [[task]] id = "6b" phase = 2 bundle = "macros" +milestone = "v0_1" status = "pending" title = "`use CcxtOcx` — exchange-scope entrypoint" scores = { d = 5, b = 9, u = 9 } -scored_at = "2026-05-16" body = """ The single user-facing macro that gates all per-exchange code generation. Three input modes: @@ -233,15 +257,16 @@ Pick before Task 7 lands so the macro contract is stable. - Use Tidewave to simulate "use CcxtOcx, exchanges: [:deribit]" by starting only scoped runtimes and exercising complex methods (options). - Validate that limiting scope dramatically reduces memory/CPU vs loading all 100+. """ +scored_at = "2026-05-16" [[task]] id = "7" phase = 2 bundle = "macros" +milestone = "v0_1" status = "pending" title = "`defunified` macro" scores = { d = 7, b = 10, u = 9 } -scored_at = "2026-05-16" depends_on = ["6", "6b", "8"] body = """ Single declaration emits the QuickBEAM call, JSON encode of args, await + decode, struct hydration, and error normalization for one unified method *across the exchanges declared in `use CcxtOcx`*. Emission is gated by Task 6b's scope — never iterate over all 100+ unconditionally. NimbleOptions schema validates the macro options at compile time (per `~/.claude/includes/development-philosophy.md` § "Cite Ecosystem Precedents"). Phoenix.Router and Ash.Resource are the precedents — single declarative line, full generated wrapper. @@ -255,15 +280,16 @@ Single declaration emits the QuickBEAM call, JSON encode of args, await + decode - IV/greeks sparse on chain response; per-symbol fetchOption richer. Test both paths via Tidewave. - Add Tidewave-driven examples for complex returns (option chains, order books) to task acceptance. """ +scored_at = "2026-05-16" [[task]] id = "8" phase = 2 bundle = "macros" +milestone = "v0_1" status = "pending" title = "Typed structs for unified return shapes (with declarative field mapping)" scores = { d = 5, b = 8, u = 8 } -scored_at = "2026-05-16" depends_on = ["6"] acceptance_criteria = [ "Each typed struct (`Ticker`, `OrderBook`, `Candle`, `Trade`, `Market`, `Currency`) generated by introspecting the `.d.ts` types from Task 6", @@ -303,15 +329,16 @@ The macro expands to the struct definition + a generated `from_ccxt/1` that walk - Observed: strike/expiry often absent or under .info on bulk; symbol string authoritative for options. from_ccxt must handle sparse fields + venue keys. - Add Tidewave repro steps for each struct in acceptance criteria. """ +scored_at = "2026-05-16" [[task]] id = "9" phase = 2 bundle = "macros" +milestone = "v0_1" status = "pending" title = "`defexchange` macro" scores = { d = 6, b = 7, u = 6 } -scored_at = "2026-05-16" depends_on = ["6b"] body = """ Per-exchange module emitter. `defexchange :binance` walks `ex.urls`, `ex.has`, `ex.timeframes`, `ex.options.defaultType` once at compile time (booting a throwaway runtime during compile) and emits a struct + capability table. Generated module exposes `CcxtOcx.Binance.has?/1`, `urls/0`, `timeframes/0`. Invocation is driven by Task 6b's `use CcxtOcx` scope — only declared exchanges get a runtime spun up at compile, not all 100+. Compile-time bundle eval is the unusual move — vet first that the build environment can run QuickBEAM. **Cache the introspection output to `priv/exchange_caps/.exs`** so subsequent compiles don't re-boot a runtime per exchange; regenerate on bundle bumps via the Task 5b verification pipeline. @@ -320,6 +347,7 @@ Per-exchange module emitter. `defexchange :binance` walks `ex.urls`, `ex.has`, ` - Use Tidewave to boot throwaway runtimes and inspect has/fetchOptionChain etc. exactly as compile-time `defexchange` will. - Validate that the cached caps capture complex surfaces (options) correctly. """ +scored_at = "2026-05-16" [[task]] id = "10" @@ -344,39 +372,42 @@ The `BTC/USDT` vs `BTC/USDT:USDT` quirk surfaced live. Some exchanges use `:` fo id = "11" phase = 3 bundle = "streaming" +milestone = "v1_0" status = "pending" title = "`defstreaming` macro" scores = { d = 7, b = 9, u = 8 } -scored_at = "2026-05-16" depends_on = ["7"] body = """ Mirror `defunified` for the watch* family. Emits a subscribe function that takes a subscriber pid + symbol and forwards `{:ccxt, exchange_id, symbol, payload}` messages. Internally runs `await ex.watch*` in a loop inside the runtime; uses a Beam handler to push back to Elixir. Each watch is one runtime (CCXT pro multiplexes channels per WS connection). """ +scored_at = "2026-05-16" [[task]] id = "12" phase = 3 bundle = "streaming" +milestone = "v1_0" status = "pending" title = "`CcxtOcx.Stream` GenStage producer" scores = { d = 6, b = 7, u = 6 } -scored_at = "2026-05-16" depends_on = ["11"] body = """ For consumers that want backpressure: a GenStage producer that buffers ticks from a watch loop. Useful for downstream Broadway pipelines or persistence to TimescaleDB. """ +scored_at = "2026-05-16" [[task]] id = "13" phase = 3 bundle = "streaming" +milestone = "v1_0" status = "pending" title = "Reconnect / heartbeat policy" scores = { d = 6, b = 8, u = 7 } -scored_at = "2026-05-16" body = """ CCXT pro handles WS reconnect internally, but we need an Elixir-side watchdog: if no message in N seconds, restart the runtime. Don't trust the JS-side heartbeat alone — exchange WS endpoints occasionally accept connections but stop pushing. """ +scored_at = "2026-05-16" # Phase 4 — Trade-Plane Verification ────────────────────────────────────── @@ -384,23 +415,24 @@ CCXT pro handles WS reconnect internally, but we need an Elixir-side watchdog: i id = "T1" phase = 4 bundle = "trade-verify" +milestone = "v1_0" status = "pending" title = "Testnet harness — Binance USDT-M futures" scores = { d = 5, b = 9, u = 8 } -scored_at = "2026-05-16" depends_on = ["5c"] body = """ `BINANCE_TESTNET_API_KEY` + `_SECRET` env vars (per `~/.claude/includes/critical-rules.md` § "INTEGRATION TESTS" — `flunk/1` with the exact export commands and the testnet signup URL when missing, never skip silently). Place a small test order, query balance, cancel the order, fetch fills. Tag `:integration`, `:network`, `:testnet`. Tier-1-scoped via `CcxtOcx.Tiers` (Task 5c). Run on every PR via CI to catch ccxt bundle bumps that break signing. """ +scored_at = "2026-05-16" [[task]] id = "T2" phase = 4 bundle = "trade-verify" +milestone = "v1_0" status = "pending" title = "Testnet harness — Deribit options" scores = { d = 5, b = 8, u = 7 } -scored_at = "2026-05-16" body = """ Deribit testnet is the gold standard for options/perp signing edge cases. Same pattern as T1. @@ -410,6 +442,7 @@ Deribit testnet is the gold standard for options/perp signing edge cases. Same p - Testnet harness must also cover public data plane (option chains, greeks) not just signing. - Mandate Tidewave-driven exploration step in task before writing testnet code. """ +scored_at = "2026-05-16" [[task]] id = "T3" @@ -427,38 +460,41 @@ Third venue with non-trivial signing (passphrase auth, distinct timestamp format id = "T4" phase = 4 bundle = "trade-verify" +milestone = "v1_0" status = "pending" title = "Signed-payload byte-comparison harness" scores = { d = 7, b = 9, u = 8 } -scored_at = "2026-05-16" body = """ **Load-bearing for the native migration, not optional.** Where a native Elixir signer exists for a venue (e.g. Binance HMAC is trivially expressible in pure Elixir), generate the same order via both the QuickBEAM-CCXT path and the native path, and assert byte equality of the signed payload + identical headers. Run on every CI build. This is the *only* mechanism that catches native-signer drift from CCXT — once we move signed methods to native (N6, N7), T4 is what gates the flip from `:js` → `:native` in routing config. Score raised from earlier draft because committing to native makes byte-equality the trade-plane safety contract, not a nice-to-have. """ +scored_at = "2026-05-16" [[task]] id = "T5" phase = 4 bundle = "trade-verify" +milestone = "v1_0" status = "pending" title = "WS authenticated streams (`watchBalance`, `watchMyTrades`, `watchOrders`)" scores = { d = 6, b = 8, u = 7 } -scored_at = "2026-05-16" body = """ Verify ccxt.pro authenticated WS works under Mint-backed WebSocket — exactly the closure-in-handler pattern QuickBEAM 0.10.3 fixed. Long-running test (30+ min) on testnet to catch slow leaks. """ +scored_at = "2026-05-16" [[task]] id = "T6" phase = 4 bundle = "trade-verify" +milestone = "v1_0" status = "pending" title = "Document the actual stability surface" scores = { d = 2, b = 6, u = 7 } -scored_at = "2026-05-16" depends_on = ["T1", "T2", "T3", "T4", "T5"] body = """ Once T1-T5 run green for a sustained period (~weeks), publish `docs/trade_plane_status.md` listing per-exchange signing verification status and any known limitations. If instability is found, *that* is when we narrow scope — with a repro and a justification, not preemptively. """ +scored_at = "2026-05-16" # Phase 5 — Production Hardening ────────────────────────────────────────── @@ -476,6 +512,7 @@ body = """ - Use Tidewave project_eval + live CcxtOcx.Runtime to trigger real calls (fetchTicker, fetchOptionChain) and verify events are emitted. - Best validation: run complex surfaces (Deribit options) via Tidewave while attached telemetry handlers are active. """ +implemented = "As specified in body — `:telemetry` events shipped: `[:ccxt_ocx, :rest, :start|:stop|:exception]`, `[:ccxt_ocx, :ws, :tick]`, `[:ccxt_ocx, :runtime, :memory]`. Validated live via Tidewave against Deribit option-chain surfaces." started_at = "2026-05-17" scored_at = "2026-05-16" done_at = "2026-05-17" @@ -484,10 +521,10 @@ done_at = "2026-05-17" id = "15" phase = 5 bundle = "production" +milestone = "v1_0" status = "pending" title = "Memory monitoring + restart policy" scores = { d = 5, b = 8, u = 7 } -scored_at = "2026-05-16" body = """ `QuickBEAM.memory_usage/1` polled per runtime; restart on threshold (default ~64MB resident). Long-running WS runtimes accumulate state via subscription buffers — a 24h restart cadence is cheap insurance. @@ -495,6 +532,7 @@ body = """ - Use Tidewave + CcxtOcx.Runtime.memory/1 while driving heavy calls (fetchOptionChain on large surfaces) to validate measurement + telemetry emission. - Best signal: run real option chain + ticker workloads via Tidewave while watching memory events. """ +scored_at = "2026-05-16" [[task]] id = "16" @@ -512,14 +550,15 @@ Wire `ApiToolkit.Cache` for OHLCV (highly cacheable), `ApiToolkit.RateLimiter` p id = "16b" phase = 5 bundle = "production" +milestone = "v1_0" status = "pending" title = "Hoist rate-limit + nonce state into Elixir" scores = { d = 7, b = 9, u = 8 } -scored_at = "2026-05-16" depends_on = ["3", "16"] body = """ CCXT does throttling and nonce tracking *inside* each exchange instance. With a `RuntimePool` of N workers, each holds its own `binance()` instance — they don't share token buckets or nonce counters. Result: easy to trip exchange rate limits *or* get rejected signed orders ("nonce too low / reused"). Two-pronged fix: (1) for **public/unauth** calls, route through `ApiToolkit.RateLimiter` keyed by `(exchange, endpoint_class)` *outside* the QuickBEAM call so all pool workers share one bucket; (2) for **authenticated** calls, pin one runtime per `(exchange, account)` pair via a registry — same instance always handles the same account so its internal nonce monotonicity is preserved. Document the pinning contract; `createOrder` for account A *must* always hit the same runtime. Test under concurrent load (100 simultaneous orders) on testnet before claiming done. """ +scored_at = "2026-05-16" [[task]] id = "17" @@ -600,6 +639,7 @@ acceptance_criteria = [ "README has an 'Observability — PromEx' section with the consumer config snippet" ] out_of_scope = ["Grafana dashboard JSON", "Consumer-side example harness app", "Production scrape integration tests"] +implemented = "As specified in body and ACs — `CcxtOcx.PromEx.Plugin` with `event_metrics/1` + `polling_metrics/1`; runtime-memory metrics live, REST/WS reserved for Phase 2/3; tag normalizers default missing keys to `\"none\"`; `{:prom_ex, optional: true}` in `mix.exs`; coverage ≥80%; README 'Observability — PromEx' section added." # Phase 7 — Native-Elixir Migration ─────────────────────────────────────── @@ -727,3 +767,30 @@ scored_at = "2026-05-16" body = """ Document the operational tax: every CCXT release ships adapter fixes (new fields, normalized formats, bug fixes for venue API changes). Once on native, those don't propagate. Either pin a CCXT version and accept drift, or subscribe to CCXT's git log and port relevant fixes per release for migrated exchanges. The honest framing is "native exchanges are now your responsibility to maintain"; budget engineering time accordingly. """ + +[[task]] +id = 22 +phase = 6 +bundle = "dx" +status = "done" +title = "Dev telemetry dogfooding loop (CcxtOcx.DevTelemetry)" +scores = { d = 2, b = 5, u = 8 } +depends_on = ["14"] +acceptance_criteria = ["lib/ccxt_ocx/dev_telemetry.ex exports watch/1, summary/0, reset/0, detach/0", "watch/1 is idempotent — re-call resets state and only one handler stays attached", "watch/1 supports :print, :filter, :io_device options", "summary/0 returns initial all-zeros state when watch/1 was never called (no Agent process)", "reset/0 is a no-op when Agent was never started", "detach/0 is idempotent", "Every event family routes to its own counter bucket (runtime_memory, rest_start/stop/exception, ws_tick)", ".iex.exs auto-attaches in dev with a one-line banner; guarded on Mix.env() == :dev AND Code.ensure_loaded?", "docs/tidewave_examples.md has a 'Dev Telemetry Loop' section showing watch → drive → summary → reset → detach", "Full harness green: format, compile --warnings-as-errors, test.json, credo --strict, dialyzer.json, doctor"] +out_of_scope = ["Persisting DevTelemetry state across IEx sessions", "Production-mode telemetry (logging handlers, structured-log shipping)", "Supervisor entry — watch/1 starts the Agent lazily; dev tool only"] +body = """ +Maintainer-side dogfooding companion to CcxtOcx.Telemetry. One `watch/1` call +attaches to every [:ccxt_ocx, ...] event family, pretty-prints each emission +to a configurable IO device, and keeps running counts plus last-seen +measurements/metadata in an Agent. `summary/0` dumps state on demand; +`reset/0` clears; `detach/0` stops counting. + +Used as the validation loop for every new emission site (Task 7 defunified, +Task 11 defstreaming, Task 15 memory monitor) before downstream consumers +(PromEx plugin, Grafana) bake in assumptions about the contract. Auto-attached +in dev via .iex.exs banner. +""" +implemented = "Shipped lib/ccxt_ocx/dev_telemetry.ex (Agent-backed dogfooding module: watch/1, summary/0, reset/0, detach/0; idempotent attach; :print/:filter/:io_device options; one-line pretty-print per event family). 14 tests covering attach/idempotency/filter/io_device/per-event routing/accumulation/last-wins/reset/detach. Added .iex.exs auto-attach with banner (guarded on Mix.env() == :dev AND Code.ensure_loaded?). docs/tidewave_examples.md: new 'Dev Telemetry Loop' section between Memory monitoring and Tidewave tools." +created_at = "2026-05-17" +scored_at = "2026-05-17" +done_at = "2026-05-17" diff --git a/test/ccxt_ocx/dev_telemetry_test.exs b/test/ccxt_ocx/dev_telemetry_test.exs new file mode 100644 index 0000000..643d100 --- /dev/null +++ b/test/ccxt_ocx/dev_telemetry_test.exs @@ -0,0 +1,197 @@ +defmodule CcxtOcx.DevTelemetryTest do + # async: false — DevTelemetry registers a named Agent and a global telemetry + # handler, both of which would race other tests in this module if parallel. + use ExUnit.Case, async: false + + alias CcxtOcx.DevTelemetry + alias CcxtOcx.Telemetry + + setup do + on_exit(fn -> + DevTelemetry.detach() + stop_agent_if_running() + end) + + :ok + end + + describe "watch/1" do + test "attaches a handler and starts fresh state" do + assert :ok = DevTelemetry.watch(print: false) + + summary = DevTelemetry.summary() + assert summary.runtime_memory == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_start.count == 0 + assert summary.rest_stop.count == 0 + assert summary.rest_exception.count == 0 + assert summary.ws_tick.count == 0 + end + + test "is idempotent — re-calling resets state and re-attaches without leaking handlers" do + :ok = DevTelemetry.watch(print: false) + + Telemetry.execute([:runtime, :memory], %{malloc_size: 100}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + + :ok = DevTelemetry.watch(print: false) + + # Re-attach clears counts. + assert DevTelemetry.summary().runtime_memory.count == 0 + + # And there's only one handler — emit once, count once (not twice). + Telemetry.execute([:runtime, :memory], %{malloc_size: 100}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + end + + test ":filter restricts attached events" do + :ok = DevTelemetry.watch(print: false, filter: [:runtime_memory]) + + Telemetry.execute([:runtime, :memory], %{malloc_size: 100}, %{server: self()}) + Telemetry.execute([:ws, :tick], %{count: 1}, %{exchange: "binance", stream: "trade", type: "update"}) + + summary = DevTelemetry.summary() + assert summary.runtime_memory.count == 1 + # filtered events stay at zero + assert summary.ws_tick.count == 0 + end + + test ":io_device routes pretty-print output (captures the format string)" do + {:ok, capture} = StringIO.open("") + :ok = DevTelemetry.watch(print: true, io_device: capture) + + Telemetry.execute( + [:runtime, :memory], + %{malloc_size: 12_300_000, memory_used_size: 8_100_000, obj_count: 14_523}, + %{ + server: self() + } + ) + + {_input, output} = StringIO.contents(capture) + assert output =~ "[memory]" + assert output =~ "malloc=12.3M" + assert output =~ "used=8.1M" + assert output =~ "objs=14523" + assert output =~ "phase=manual" + end + + test "print: false silences output but still updates counters" do + {:ok, capture} = StringIO.open("") + :ok = DevTelemetry.watch(print: false, io_device: capture) + + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + + {_input, output} = StringIO.contents(capture) + assert output == "" + assert DevTelemetry.summary().runtime_memory.count == 1 + end + end + + describe "emission landing" do + setup do + :ok = DevTelemetry.watch(print: false) + :ok + end + + test "[:ccxt_ocx, :runtime, :memory] increments runtime_memory and stores last measurements/meta" do + meas = %{malloc_size: 100, memory_used_size: 80, obj_count: 5} + meta = %{server: self(), phase: :init} + + Telemetry.execute([:runtime, :memory], meas, meta) + + entry = DevTelemetry.summary().runtime_memory + assert entry.count == 1 + assert entry.last_measurements == meas + assert entry.last_metadata == meta + end + + test "[:ccxt_ocx, :rest, :start|:stop|:exception] each route to their own bucket" do + Telemetry.execute([:rest, :start], %{system_time: 1}, %{exchange: "binance", method: "ticker"}) + Telemetry.execute([:rest, :stop], %{duration: 1_000_000}, %{exchange: "binance", method: "ticker"}) + + Telemetry.execute([:rest, :exception], %{duration: 1_000_000}, %{ + exchange: "binance", + method: "ticker", + kind: :error + }) + + summary = DevTelemetry.summary() + assert summary.rest_start.count == 1 + assert summary.rest_stop.count == 1 + assert summary.rest_exception.count == 1 + end + + test "[:ccxt_ocx, :ws, :tick] increments ws_tick" do + Telemetry.execute([:ws, :tick], %{count: 1}, %{exchange: "binance", stream: "trade", type: "update"}) + Telemetry.execute([:ws, :tick], %{count: 1}, %{exchange: "binance", stream: "trade", type: "update"}) + + assert DevTelemetry.summary().ws_tick.count == 2 + end + + test "multiple emissions accumulate the count and overwrite last_*" do + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + Telemetry.execute([:runtime, :memory], %{malloc_size: 2}, %{server: self()}) + Telemetry.execute([:runtime, :memory], %{malloc_size: 3}, %{server: self()}) + + entry = DevTelemetry.summary().runtime_memory + assert entry.count == 3 + # last-wins + assert entry.last_measurements == %{malloc_size: 3} + end + end + + describe "reset/0" do + test "clears counts and last-values when Agent is running" do + :ok = DevTelemetry.watch(print: false) + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + + :ok = DevTelemetry.reset() + assert DevTelemetry.summary().runtime_memory == %{count: 0, last_measurements: nil, last_metadata: nil} + end + + test "is a no-op when watch/1 was never called" do + assert :ok = DevTelemetry.reset() + end + end + + describe "summary/0" do + test "returns the initial all-zeros state when watch/1 was never called" do + summary = DevTelemetry.summary() + assert summary.runtime_memory == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_start == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_stop == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_exception == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.ws_tick == %{count: 0, last_measurements: nil, last_metadata: nil} + end + end + + describe "detach/0" do + test "stops counting further emissions but preserves prior state" do + :ok = DevTelemetry.watch(print: false) + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + + :ok = DevTelemetry.detach() + + Telemetry.execute([:runtime, :memory], %{malloc_size: 2}, %{server: self()}) + + # Count is unchanged after detach — handler is gone. + assert DevTelemetry.summary().runtime_memory.count == 1 + end + + test "is idempotent" do + assert :ok = DevTelemetry.detach() + assert :ok = DevTelemetry.detach() + end + end + + # ------------------------------------------------------------------ + + defp stop_agent_if_running do + case Process.whereis(DevTelemetry) do + nil -> :ok + pid -> Agent.stop(pid) + end + end +end