From 5adaa4dcf1c6eaba787234761704decac9a981ed Mon Sep 17 00:00:00 2001 From: MaxOxide <309752195+MaxOxide@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:05:50 +0200 Subject: [PATCH] release: kraken-cli 0.4.0 Workspaces, recorded sessions, market tapes, and the autoresearch lab form a repeatable research loop around paper trading. A readable response schema replaces wire acronyms across REST and WebSocket output. New `kraken feedback` command for bug reports and feature requests. MCP account-settings mutations now require an explicit acknowledgment, and futures WebSocket URL overrides are gated on a trusted-host allowlist. Toolchain and dependency upgrades. Full details in CHANGELOG.md under 0.4.0. --- .cargo/config.toml | 4 + .claude-plugin/plugin.json | 4 +- .codex-plugin/plugin.json | 6 +- .config/nextest.toml | 3 + .cursor-plugin/plugin.json | 4 +- .editorconfig | 25 + .gitattributes | 17 + .github/workflows/release.yml | 48 +- .gitignore | 15 + .mcp.json | 2 +- AGENTS.md | 85 +- CHANGELOG.md | 120 + CLAUDE.md | 31 +- CONTEXT.md | 43 +- CONTRIBUTING.md | 8 +- Cargo.lock | 2737 ++++++++++++----- Cargo.toml | 208 +- DISCLAIMER.md | 5 + README.md | 210 +- agents/README.md | 8 +- agents/error-catalog.json | 173 +- agents/examples/market-monitor.sh | 16 +- agents/examples/paper-trading-workflow.sh | 2 +- agents/examples/portfolio-rebalance.sh | 6 +- agents/tool-catalog.json | 875 +++++- clippy.toml | 3 + crates/kraken-core/Cargo.toml | 35 + crates/kraken-core/src/endpoint.rs | 81 + crates/kraken-core/src/error.rs | 174 ++ crates/kraken-core/src/inbound.rs | 114 + crates/kraken-core/src/lib.rs | 49 + crates/kraken-core/src/name.rs | 36 + crates/kraken-core/src/primitives.rs | 32 + crates/kraken-core/src/request/add_order.rs | 171 + crates/kraken-core/src/request/amend_order.rs | 103 + crates/kraken-core/src/request/batch_add.rs | 169 + .../kraken-core/src/request/batch_cancel.rs | 66 + crates/kraken-core/src/request/cancel_all.rs | 30 + .../src/request/cancel_all_orders_after.rs | 60 + .../kraken-core/src/request/cancel_order.rs | 83 + .../kraken-core/src/request/decimal_wire.rs | 140 + crates/kraken-core/src/request/mod.rs | 40 + crates/kraken-core/src/request/ping.rs | 35 + crates/kraken-core/src/request/value.rs | 65 + crates/kraken-core/src/response/envelope.rs | 178 ++ crates/kraken-core/src/response/mod.rs | 10 + crates/kraken-core/src/response/result.rs | 63 + crates/kraken-core/src/subscribe/channel.rs | 371 +++ .../src/subscribe/message/balances.rs | 214 ++ .../kraken-core/src/subscribe/message/book.rs | 29 + .../src/subscribe/message/channel_message.rs | 437 +++ .../src/subscribe/message/envelope.rs | 17 + .../src/subscribe/message/executions.rs | 298 ++ .../src/subscribe/message/instrument.rs | 118 + .../src/subscribe/message/level3.rs | 66 + .../kraken-core/src/subscribe/message/mod.rs | 45 + .../kraken-core/src/subscribe/message/ohlc.rs | 39 + .../src/subscribe/message/status.rs | 45 + .../src/subscribe/message/ticker.rs | 27 + .../src/subscribe/message/trade.rs | 26 + crates/kraken-core/src/subscribe/mod.rs | 22 + .../kraken-core/src/subscribe/subscription.rs | 310 ++ crates/kraken-core/src/wire.rs | 143 + crates/kraken-lab/Cargo.toml | 38 + crates/kraken-lab/README.md | 95 + crates/kraken-lab/src/app.rs | 516 ++++ crates/kraken-lab/src/experiment.rs | 389 +++ crates/kraken-lab/src/hash.rs | 45 + crates/kraken-lab/src/lib.rs | 112 + crates/kraken-lab/src/mark.rs | 350 +++ crates/kraken-lab/src/plan.rs | 396 +++ crates/kraken-lab/src/replay.rs | 1003 ++++++ crates/kraken-lab/src/score.rs | 946 ++++++ crates/kraken-lab/src/store.rs | 350 +++ crates/kraken-lab/src/trades.rs | 156 + crates/kraken-lab/src/window.rs | 49 + .../kraken-lab/tests/experiment_lifecycle.rs | 123 + crates/kraken-lab/tests/fold_pipeline.rs | 88 + crates/kraken-lab/tests/score_pipeline.rs | 72 + crates/kraken-lab/tests/snapshots.rs | 154 + .../snapshots__replay_curve_and_ledger.snap | 51 + ...ots__scorecard_of_a_closed_round_trip.snap | 76 + ..._session_carries_the_honesty_envelope.snap | 81 + ...hots__scorecard_of_the_worked_example.snap | 75 + .../snapshots/snapshots__verdict_fail.snap | 21 + .../snapshots/snapshots__verdict_pass.snap | 21 + crates/kraken-paper/Cargo.toml | 28 + crates/kraken-paper/README.md | 54 + crates/kraken-paper/src/account.rs | 1051 +++++++ crates/kraken-paper/src/lib.rs | 1910 ++++++++++++ .../kraken-paper/tests/account_lifecycle.rs | 131 + crates/kraken-recording/ARCHITECTURE.md | 97 + crates/kraken-recording/Cargo.toml | 32 + crates/kraken-recording/README.md | 81 + crates/kraken-recording/src/actions/mod.rs | 6 + crates/kraken-recording/src/error.rs | 29 + crates/kraken-recording/src/frames/capture.rs | 683 ++++ .../kraken-recording/src/frames/duckdb/mod.rs | 191 ++ .../src/frames/duckdb/sink.rs | 716 +++++ .../src/frames/duckdb/source.rs | 854 +++++ .../src/frames/duckdb/table.rs | 471 +++ .../src/frames/duckdb/tables.rs | 178 ++ .../src/frames/duckdb/tables/book.rs | 160 + .../src/frames/duckdb/tables/ohlc.rs | 89 + .../src/frames/duckdb/tables/ticker.rs | 90 + .../src/frames/duckdb/tables/trade.rs | 81 + .../kraken-recording/src/frames/jsonl/mod.rs | 7 + .../kraken-recording/src/frames/jsonl/sink.rs | 417 +++ .../src/frames/jsonl/source.rs | 579 ++++ crates/kraken-recording/src/frames/mod.rs | 159 + crates/kraken-recording/src/fsync.rs | 25 + crates/kraken-recording/src/jsonl.rs | 363 +++ crates/kraken-recording/src/lib.rs | 48 + crates/kraken-recording/src/lock.rs | 282 ++ crates/kraken-recording/src/paper/mod.rs | 89 + crates/kraken-recording/src/schema.rs | 88 + crates/kraken-recording/src/sink.rs | 154 + crates/kraken-recording/src/source.rs | 112 + crates/kraken-recording/src/tape.rs | 400 +++ crates/kraken-recording/src/time.rs | 20 + crates/kraken-recording/tests/e2e.rs | 448 +++ crates/kraken-replay/Cargo.toml | 36 + crates/kraken-replay/README.md | 53 + crates/kraken-replay/src/explain.rs | 1931 ++++++++++++ crates/kraken-replay/src/explain/report.rs | 298 ++ crates/kraken-replay/src/lib.rs | 53 + crates/kraken-replay/src/playback.rs | 342 ++ crates/kraken-replay/src/source.rs | 121 + crates/kraken-replay/tests/replay_pipeline.rs | 133 + crates/kraken-session/Cargo.toml | 53 + crates/kraken-session/README.md | 67 + crates/kraken-session/src/decision.rs | 204 ++ crates/kraken-session/src/lib.rs | 199 ++ crates/kraken-session/src/manifest.rs | 458 +++ crates/kraken-session/src/session.rs | 411 +++ crates/kraken-session/src/timeline.rs | 988 ++++++ crates/kraken-session/src/timeline/event.rs | 231 ++ .../kraken-session/tests/duckdb_roundtrip.rs | 49 + .../kraken-session/tests/session_roundtrip.rs | 84 + crates/kraken-session/tests/session_window.rs | 374 +++ crates/kraken-workspace/Cargo.toml | 33 + crates/kraken-workspace/src/app.rs | 508 +++ crates/kraken-workspace/src/lib.rs | 260 ++ crates/kraken-workspace/src/manifest.rs | 85 + crates/kraken-workspace/src/naming.rs | 80 + crates/kraken-workspace/src/policy.rs | 118 + crates/kraken-workspace/src/promote.rs | 221 ++ crates/kraken-workspace/src/receipt.rs | 114 + crates/kraken-workspace/src/report.rs | 321 ++ crates/kraken-workspace/src/session.rs | 495 +++ crates/kraken-workspace/src/store.rs | 225 ++ crates/kraken-workspace/tests/snapshots.rs | 178 ++ ...pshots__balances_wire_shape_is_pinned.snap | 20 + ...s_include_the_synthesized_default_row.snap | 17 + ...ve_order_receipt_wire_shape_is_pinned.snap | 22 + ...er_order_receipt_wire_shape_is_pinned.snap | 19 + ...rkspace_manifest_wire_shape_is_pinned.snap | 19 + ...rkspace_overview_wire_shape_is_pinned.snap | 18 + crates/kraken-ws/Cargo.toml | 30 + crates/kraken-ws/src/backoff.rs | 224 ++ crates/kraken-ws/src/config.rs | 115 + crates/kraken-ws/src/connection.rs | 1102 +++++++ crates/kraken-ws/src/connection_tests.rs | 971 ++++++ crates/kraken-ws/src/lib.rs | 32 + crates/kraken-ws/src/mock_transport.rs | 133 + crates/kraken-ws/src/request_client.rs | 551 ++++ crates/kraken-ws/src/stream_client.rs | 230 ++ crates/kraken-ws/src/token.rs | 365 +++ crates/kraken-ws/src/transport.rs | 261 ++ deny.toml | 33 + dist-workspace.toml | 7 +- examples/01-playground.md | 52 + examples/02-paper-workspaces.md | 71 + examples/03-market-memory.md | 58 + examples/04-sessions-replay-explain.md | 75 + examples/05-autoresearch-lab.md | 72 + examples/06-agent-skills.md | 112 + examples/README.md | 22 + gemini-extension.json | 4 +- justfile | 75 + rust-toolchain.toml | 3 + rustfmt.toml | 7 + skills/INDEX.md | 15 +- skills/README.md | 8 +- skills/kraken-alert-patterns/SKILL.md | 5 +- skills/kraken-autonomy-levels/SKILL.md | 9 +- skills/kraken-basis-trading/SKILL.md | 3 +- skills/kraken-dca-strategy/SKILL.md | 14 +- skills/kraken-earn-staking/SKILL.md | 1 + skills/kraken-error-recovery/SKILL.md | 3 +- skills/kraken-fee-optimization/SKILL.md | 1 + skills/kraken-funding-carry/SKILL.md | 1 + skills/kraken-funding-ops/SKILL.md | 1 + skills/kraken-futures-risk/SKILL.md | 3 +- skills/kraken-futures-trading/SKILL.md | 1 + skills/kraken-grid-trading/SKILL.md | 6 +- skills/kraken-lab-experiment/SKILL.md | 251 ++ skills/kraken-liquidation-guard/SKILL.md | 2 + skills/kraken-market-intel/SKILL.md | 2 + skills/kraken-mcp-integration/SKILL.md | 16 +- skills/kraken-multi-pair/SKILL.md | 8 +- skills/kraken-order-types/SKILL.md | 1 + skills/kraken-paper-strategy/SKILL.md | 17 +- skills/kraken-paper-to-live/SKILL.md | 84 +- skills/kraken-playground/SKILL.md | 142 + skills/kraken-portfolio-intel/SKILL.md | 4 +- skills/kraken-rate-limits/SKILL.md | 2 + skills/kraken-rebalancing/SKILL.md | 10 +- skills/kraken-risk-operations/SKILL.md | 1 + skills/kraken-setup/SKILL.md | 9 +- skills/kraken-shared/SKILL.md | 11 +- skills/kraken-spot-execution/SKILL.md | 1 + skills/kraken-stop-take-profit/SKILL.md | 1 + skills/kraken-subaccount-ops/SKILL.md | 3 +- skills/kraken-tax-export/SKILL.md | 3 +- skills/kraken-twap-execution/SKILL.md | 8 +- skills/kraken-ws-streaming/SKILL.md | 1 + skills/recipe-basis-trade-entry/SKILL.md | 4 +- skills/recipe-daily-pnl-report/SKILL.md | 2 + .../recipe-drawdown-circuit-breaker/SKILL.md | 2 + skills/recipe-earn-yield-compare/SKILL.md | 2 + skills/recipe-emergency-flatten/SKILL.md | 2 + skills/recipe-fee-tier-progress/SKILL.md | 2 + skills/recipe-funding-rate-scan/SKILL.md | 2 + skills/recipe-futures-hedge-spot/SKILL.md | 4 +- skills/recipe-launch-grid-bot/SKILL.md | 9 +- skills/recipe-morning-market-brief/SKILL.md | 2 + .../recipe-multi-pair-breakout-watch/SKILL.md | 2 + .../recipe-paper-strategy-backtest/SKILL.md | 23 +- .../recipe-playground-dca-triggered/SKILL.md | 155 + skills/recipe-playground-dca/SKILL.md | 186 ++ skills/recipe-playground-price-alert/SKILL.md | 179 ++ skills/recipe-playground-rebalance/SKILL.md | 177 ++ skills/recipe-playground-webhook/SKILL.md | 174 ++ skills/recipe-portfolio-snapshot-csv/SKILL.md | 2 + skills/recipe-price-level-alerts/SKILL.md | 2 + skills/recipe-session-post-mortem/SKILL.md | 125 + skills/recipe-start-dca-bot/SKILL.md | 9 +- .../SKILL.md | 2 + skills/recipe-track-orderbook-depth/SKILL.md | 2 + skills/recipe-trailing-stop-runner/SKILL.md | 2 + skills/recipe-weekly-rebalance/SKILL.md | 2 + .../SKILL.md | 2 + src/auth.rs | 26 +- src/cli.rs | 797 +++++ src/client.rs | 867 ++++-- src/commands/account.rs | 1380 +++++---- src/commands/auth.rs | 382 +-- src/commands/earn.rs | 327 +- src/commands/executor.rs | 75 + src/commands/explain.rs | 270 ++ src/commands/feedback.rs | 430 +++ src/commands/funding.rs | 770 ++--- src/commands/futures.rs | 1891 ++++++------ src/commands/futures_paper.rs | 768 ++--- src/commands/futures_ws.rs | 407 ++- src/commands/helpers.rs | 54 - src/commands/journal_mirror.rs | 279 ++ src/commands/lab.rs | 955 ++++++ src/commands/market.rs | 770 +++-- src/commands/mod.rs | 178 +- src/commands/paper.rs | 1708 +++++++--- src/commands/playground.rs | 105 + src/commands/record.rs | 255 ++ src/commands/replay.rs | 247 ++ src/commands/replay_venue.rs | 420 +++ src/commands/schema.rs | 1083 +++++++ src/commands/session.rs | 2004 ++++++++++++ src/commands/{utility.rs => setup.rs} | 22 +- src/commands/streamd.rs | 151 + src/commands/streaming.rs | 145 + src/commands/subaccount.rs | 121 +- src/commands/subscription.rs | 200 ++ src/commands/tape.rs | 107 + src/commands/trade.rs | 1599 ++++++---- src/commands/value_types.rs | 268 ++ src/commands/websocket.rs | 2286 -------------- src/commands/workspace.rs | 708 +++++ src/commands/workspace_guard.rs | 173 ++ src/commands/ws/channels.rs | 236 ++ src/commands/ws/methods.rs | 604 ++++ src/commands/ws/mod.rs | 486 +++ src/config.rs | 669 ++-- src/errors.rs | 597 +++- src/futures_paper.rs | 616 ++-- src/lib.rs | 1494 +-------- src/logging.rs | 177 ++ src/main.rs | 262 +- src/mcp/mod.rs | 2 + src/mcp/registry.rs | 843 ++++- src/mcp/schema.rs | 20 +- src/mcp/server.rs | 641 ++-- src/monitor.rs | 1481 +++++++++ src/output/json.rs | 39 +- src/output/mod.rs | 184 +- src/output/summary/channel.rs | 155 + src/output/summary/method.rs | 101 + src/output/summary/mod.rs | 78 + src/output/summary/timeline.rs | 168 + src/output/table.rs | 17 +- src/paper.rs | 1402 --------- src/process.rs | 98 + src/record.rs | 82 + src/session/mod.rs | 6 + src/shell.rs | 134 +- src/sink/driver.rs | 268 ++ src/sink/fanout.rs | 272 ++ src/sink/mod.rs | 13 + src/sink/stdout.rs | 154 + src/stream.rs | 961 ++++++ src/telemetry.rs | 163 +- tests/cli.rs | 3 + tests/integration/cli_tests.rs | 1229 ++------ tests/integration/common.rs | 31 + tests/integration/explain_tests.rs | 44 + tests/integration/feedback_tests.rs | 221 ++ tests/integration/lab_tests.rs | 528 ++++ tests/integration/logging_tests.rs | 153 + tests/integration/mod.rs | 12 + tests/integration/playground_tests.rs | 132 + tests/integration/record_tests.rs | 135 + tests/integration/replay_tests.rs | 61 + tests/integration/session_tests.rs | 439 +++ tests/integration/streamd_tests.rs | 84 + tests/integration/tape_tests.rs | 135 + tests/integration/wiremock_tests.rs | 193 +- tests/integration/workspace_tests.rs | 944 ++++++ 327 files changed, 64669 insertions(+), 13421 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .config/nextest.toml create mode 100644 .editorconfig create mode 100644 CHANGELOG.md create mode 100644 clippy.toml create mode 100644 crates/kraken-core/Cargo.toml create mode 100644 crates/kraken-core/src/endpoint.rs create mode 100644 crates/kraken-core/src/error.rs create mode 100644 crates/kraken-core/src/inbound.rs create mode 100644 crates/kraken-core/src/lib.rs create mode 100644 crates/kraken-core/src/name.rs create mode 100644 crates/kraken-core/src/primitives.rs create mode 100644 crates/kraken-core/src/request/add_order.rs create mode 100644 crates/kraken-core/src/request/amend_order.rs create mode 100644 crates/kraken-core/src/request/batch_add.rs create mode 100644 crates/kraken-core/src/request/batch_cancel.rs create mode 100644 crates/kraken-core/src/request/cancel_all.rs create mode 100644 crates/kraken-core/src/request/cancel_all_orders_after.rs create mode 100644 crates/kraken-core/src/request/cancel_order.rs create mode 100644 crates/kraken-core/src/request/decimal_wire.rs create mode 100644 crates/kraken-core/src/request/mod.rs create mode 100644 crates/kraken-core/src/request/ping.rs create mode 100644 crates/kraken-core/src/request/value.rs create mode 100644 crates/kraken-core/src/response/envelope.rs create mode 100644 crates/kraken-core/src/response/mod.rs create mode 100644 crates/kraken-core/src/response/result.rs create mode 100644 crates/kraken-core/src/subscribe/channel.rs create mode 100644 crates/kraken-core/src/subscribe/message/balances.rs create mode 100644 crates/kraken-core/src/subscribe/message/book.rs create mode 100644 crates/kraken-core/src/subscribe/message/channel_message.rs create mode 100644 crates/kraken-core/src/subscribe/message/envelope.rs create mode 100644 crates/kraken-core/src/subscribe/message/executions.rs create mode 100644 crates/kraken-core/src/subscribe/message/instrument.rs create mode 100644 crates/kraken-core/src/subscribe/message/level3.rs create mode 100644 crates/kraken-core/src/subscribe/message/mod.rs create mode 100644 crates/kraken-core/src/subscribe/message/ohlc.rs create mode 100644 crates/kraken-core/src/subscribe/message/status.rs create mode 100644 crates/kraken-core/src/subscribe/message/ticker.rs create mode 100644 crates/kraken-core/src/subscribe/message/trade.rs create mode 100644 crates/kraken-core/src/subscribe/mod.rs create mode 100644 crates/kraken-core/src/subscribe/subscription.rs create mode 100644 crates/kraken-core/src/wire.rs create mode 100644 crates/kraken-lab/Cargo.toml create mode 100644 crates/kraken-lab/README.md create mode 100644 crates/kraken-lab/src/app.rs create mode 100644 crates/kraken-lab/src/experiment.rs create mode 100644 crates/kraken-lab/src/hash.rs create mode 100644 crates/kraken-lab/src/lib.rs create mode 100644 crates/kraken-lab/src/mark.rs create mode 100644 crates/kraken-lab/src/plan.rs create mode 100644 crates/kraken-lab/src/replay.rs create mode 100644 crates/kraken-lab/src/score.rs create mode 100644 crates/kraken-lab/src/store.rs create mode 100644 crates/kraken-lab/src/trades.rs create mode 100644 crates/kraken-lab/src/window.rs create mode 100644 crates/kraken-lab/tests/experiment_lifecycle.rs create mode 100644 crates/kraken-lab/tests/fold_pipeline.rs create mode 100644 crates/kraken-lab/tests/score_pipeline.rs create mode 100644 crates/kraken-lab/tests/snapshots.rs create mode 100644 crates/kraken-lab/tests/snapshots/snapshots__replay_curve_and_ledger.snap create mode 100644 crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_closed_round_trip.snap create mode 100644 crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_replayed_session_carries_the_honesty_envelope.snap create mode 100644 crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_the_worked_example.snap create mode 100644 crates/kraken-lab/tests/snapshots/snapshots__verdict_fail.snap create mode 100644 crates/kraken-lab/tests/snapshots/snapshots__verdict_pass.snap create mode 100644 crates/kraken-paper/Cargo.toml create mode 100644 crates/kraken-paper/README.md create mode 100644 crates/kraken-paper/src/account.rs create mode 100644 crates/kraken-paper/src/lib.rs create mode 100644 crates/kraken-paper/tests/account_lifecycle.rs create mode 100644 crates/kraken-recording/ARCHITECTURE.md create mode 100644 crates/kraken-recording/Cargo.toml create mode 100644 crates/kraken-recording/README.md create mode 100644 crates/kraken-recording/src/actions/mod.rs create mode 100644 crates/kraken-recording/src/error.rs create mode 100644 crates/kraken-recording/src/frames/capture.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/mod.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/sink.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/source.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/table.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/tables.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/tables/book.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/tables/ohlc.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/tables/ticker.rs create mode 100644 crates/kraken-recording/src/frames/duckdb/tables/trade.rs create mode 100644 crates/kraken-recording/src/frames/jsonl/mod.rs create mode 100644 crates/kraken-recording/src/frames/jsonl/sink.rs create mode 100644 crates/kraken-recording/src/frames/jsonl/source.rs create mode 100644 crates/kraken-recording/src/frames/mod.rs create mode 100644 crates/kraken-recording/src/fsync.rs create mode 100644 crates/kraken-recording/src/jsonl.rs create mode 100644 crates/kraken-recording/src/lib.rs create mode 100644 crates/kraken-recording/src/lock.rs create mode 100644 crates/kraken-recording/src/paper/mod.rs create mode 100644 crates/kraken-recording/src/schema.rs create mode 100644 crates/kraken-recording/src/sink.rs create mode 100644 crates/kraken-recording/src/source.rs create mode 100644 crates/kraken-recording/src/tape.rs create mode 100644 crates/kraken-recording/src/time.rs create mode 100644 crates/kraken-recording/tests/e2e.rs create mode 100644 crates/kraken-replay/Cargo.toml create mode 100644 crates/kraken-replay/README.md create mode 100644 crates/kraken-replay/src/explain.rs create mode 100644 crates/kraken-replay/src/explain/report.rs create mode 100644 crates/kraken-replay/src/lib.rs create mode 100644 crates/kraken-replay/src/playback.rs create mode 100644 crates/kraken-replay/src/source.rs create mode 100644 crates/kraken-replay/tests/replay_pipeline.rs create mode 100644 crates/kraken-session/Cargo.toml create mode 100644 crates/kraken-session/README.md create mode 100644 crates/kraken-session/src/decision.rs create mode 100644 crates/kraken-session/src/lib.rs create mode 100644 crates/kraken-session/src/manifest.rs create mode 100644 crates/kraken-session/src/session.rs create mode 100644 crates/kraken-session/src/timeline.rs create mode 100644 crates/kraken-session/src/timeline/event.rs create mode 100644 crates/kraken-session/tests/duckdb_roundtrip.rs create mode 100644 crates/kraken-session/tests/session_roundtrip.rs create mode 100644 crates/kraken-session/tests/session_window.rs create mode 100644 crates/kraken-workspace/Cargo.toml create mode 100644 crates/kraken-workspace/src/app.rs create mode 100644 crates/kraken-workspace/src/lib.rs create mode 100644 crates/kraken-workspace/src/manifest.rs create mode 100644 crates/kraken-workspace/src/naming.rs create mode 100644 crates/kraken-workspace/src/policy.rs create mode 100644 crates/kraken-workspace/src/promote.rs create mode 100644 crates/kraken-workspace/src/receipt.rs create mode 100644 crates/kraken-workspace/src/report.rs create mode 100644 crates/kraken-workspace/src/session.rs create mode 100644 crates/kraken-workspace/src/store.rs create mode 100644 crates/kraken-workspace/tests/snapshots.rs create mode 100644 crates/kraken-workspace/tests/snapshots/snapshots__balances_wire_shape_is_pinned.snap create mode 100644 crates/kraken-workspace/tests/snapshots/snapshots__listed_workspaces_include_the_synthesized_default_row.snap create mode 100644 crates/kraken-workspace/tests/snapshots/snapshots__live_order_receipt_wire_shape_is_pinned.snap create mode 100644 crates/kraken-workspace/tests/snapshots/snapshots__paper_order_receipt_wire_shape_is_pinned.snap create mode 100644 crates/kraken-workspace/tests/snapshots/snapshots__workspace_manifest_wire_shape_is_pinned.snap create mode 100644 crates/kraken-workspace/tests/snapshots/snapshots__workspace_overview_wire_shape_is_pinned.snap create mode 100644 crates/kraken-ws/Cargo.toml create mode 100644 crates/kraken-ws/src/backoff.rs create mode 100644 crates/kraken-ws/src/config.rs create mode 100644 crates/kraken-ws/src/connection.rs create mode 100644 crates/kraken-ws/src/connection_tests.rs create mode 100644 crates/kraken-ws/src/lib.rs create mode 100644 crates/kraken-ws/src/mock_transport.rs create mode 100644 crates/kraken-ws/src/request_client.rs create mode 100644 crates/kraken-ws/src/stream_client.rs create mode 100644 crates/kraken-ws/src/token.rs create mode 100644 crates/kraken-ws/src/transport.rs create mode 100644 deny.toml create mode 100644 examples/01-playground.md create mode 100644 examples/02-paper-workspaces.md create mode 100644 examples/03-market-memory.md create mode 100644 examples/04-sessions-replay-explain.md create mode 100644 examples/05-autoresearch-lab.md create mode 100644 examples/06-agent-skills.md create mode 100644 examples/README.md create mode 100644 justfile create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 skills/kraken-lab-experiment/SKILL.md create mode 100644 skills/kraken-playground/SKILL.md create mode 100644 skills/recipe-playground-dca-triggered/SKILL.md create mode 100644 skills/recipe-playground-dca/SKILL.md create mode 100644 skills/recipe-playground-price-alert/SKILL.md create mode 100644 skills/recipe-playground-rebalance/SKILL.md create mode 100644 skills/recipe-playground-webhook/SKILL.md create mode 100644 skills/recipe-session-post-mortem/SKILL.md create mode 100644 src/cli.rs create mode 100644 src/commands/executor.rs create mode 100644 src/commands/explain.rs create mode 100644 src/commands/feedback.rs delete mode 100644 src/commands/helpers.rs create mode 100644 src/commands/journal_mirror.rs create mode 100644 src/commands/lab.rs create mode 100644 src/commands/playground.rs create mode 100644 src/commands/record.rs create mode 100644 src/commands/replay.rs create mode 100644 src/commands/replay_venue.rs create mode 100644 src/commands/schema.rs create mode 100644 src/commands/session.rs rename src/commands/{utility.rs => setup.rs} (82%) create mode 100644 src/commands/streamd.rs create mode 100644 src/commands/streaming.rs create mode 100644 src/commands/subscription.rs create mode 100644 src/commands/tape.rs create mode 100644 src/commands/value_types.rs delete mode 100644 src/commands/websocket.rs create mode 100644 src/commands/workspace.rs create mode 100644 src/commands/workspace_guard.rs create mode 100644 src/commands/ws/channels.rs create mode 100644 src/commands/ws/methods.rs create mode 100644 src/commands/ws/mod.rs create mode 100644 src/logging.rs create mode 100644 src/monitor.rs create mode 100644 src/output/summary/channel.rs create mode 100644 src/output/summary/method.rs create mode 100644 src/output/summary/mod.rs create mode 100644 src/output/summary/timeline.rs delete mode 100644 src/paper.rs create mode 100644 src/process.rs create mode 100644 src/record.rs create mode 100644 src/session/mod.rs create mode 100644 src/sink/driver.rs create mode 100644 src/sink/fanout.rs create mode 100644 src/sink/mod.rs create mode 100644 src/sink/stdout.rs create mode 100644 src/stream.rs create mode 100644 tests/integration/common.rs create mode 100644 tests/integration/explain_tests.rs create mode 100644 tests/integration/feedback_tests.rs create mode 100644 tests/integration/lab_tests.rs create mode 100644 tests/integration/logging_tests.rs create mode 100644 tests/integration/playground_tests.rs create mode 100644 tests/integration/record_tests.rs create mode 100644 tests/integration/replay_tests.rs create mode 100644 tests/integration/session_tests.rs create mode 100644 tests/integration/streamd_tests.rs create mode 100644 tests/integration/tape_tests.rs create mode 100644 tests/integration/workspace_tests.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..f330a53 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# Debug schema generation exceeds the platform's 2 MiB thread stack. +# Release builds are unaffected. +[env] +RUST_MIN_STACK = "16777216" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 73047a2..2a5140d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kraken-cli", - "version": "0.3.2", + "version": "0.4.0", "description": "Agent-first CLI for trading crypto, stocks, forex, and derivatives on Kraken. Paper trading by default; live trading opt-in via API keys.", "author": { "name": "Kraken", @@ -38,7 +38,7 @@ "mcpServers": { "kraken": { "command": "kraken", - "args": ["mcp", "-s", "market,paper"], + "args": ["mcp", "-s", "market,paper,feedback"], "env": { "KRAKEN_API_KEY": "${user_config.api_key}", "KRAKEN_API_SECRET": "${user_config.api_secret}" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f90ece6..c9cc1d6 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kraken-cli", - "version": "0.3.2", + "version": "0.4.0", "description": "Agent-first CLI for trading crypto, stocks, forex, and derivatives on Kraken.", "author": { "name": "Kraken", @@ -24,13 +24,13 @@ "mcpServers": { "kraken": { "command": "kraken", - "args": ["mcp", "-s", "market,paper"] + "args": ["mcp", "-s", "market,paper,feedback"] } }, "interface": { "displayName": "Kraken CLI", "shortDescription": "Trade crypto, stocks, forex, and derivatives from your AI agent.", - "longDescription": "Agent-first CLI with a built-in MCP server. 151 commands covering spot, futures, xStocks, forex, staking, and paper trading. Ships with 50 goal-oriented skills for strategies like DCA, grid trading, basis trades, and portfolio rebalancing. Paper trading works out of the box with no API keys; live trading is an explicit opt-in via expanded MCP service flags.", + "longDescription": "Agent-first CLI with a built-in MCP server. 173 commands covering spot, futures, xStocks, forex, staking, workspaces, recorded sessions, and paper trading. Ships with 59 goal-oriented skills for strategies like DCA, grid trading, basis trades, and portfolio rebalancing. Paper trading works out of the box with no API keys; live trading is an explicit opt-in via expanded MCP service flags.", "developerName": "Kraken", "category": "Finance", "capabilities": ["Read"], diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..fc2f6e3 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Emits JUnit for GitLab's MR test widget (wired up in .gitlab-ci.yml). +[profile.ci.junit] +path = "junit.xml" diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 495c489..55b93bf 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kraken-cli", - "version": "0.3.2", + "version": "0.4.0", "description": "Agent-first CLI for trading crypto, stocks, forex, and derivatives on Kraken. Paper trading by default; live trading opt-in via API keys.", "author": { "name": "Kraken", @@ -25,7 +25,7 @@ "mcpServers": { "kraken": { "command": "kraken", - "args": ["mcp", "-s", "market,paper"] + "args": ["mcp", "-s", "market,paper,feedback"] } } } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..eb8f468 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# EditorConfig is awesome: https://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +# Rust uses 4-space indentation (rustfmt default). +[*.rs] +indent_size = 4 + +[*.{json,yml,yaml,toml}] +indent_size = 2 + +[*.{md,mdx}] +indent_size = 2 +max_line_length = off +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes index 25f898a..586f610 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,20 @@ # Machine-readable catalogs: collapse in diffs, exclude from language stats. agents/tool-catalog.json linguist-generated agents/error-catalog.json linguist-generated + +.internal/ export-ignore +.internal/** export-ignore +.agents/ export-ignore +.agents/** export-ignore +.codex/ export-ignore +.codex/** export-ignore +.gitlab-ci.yml export-ignore +ci/ export-ignore +ci/** export-ignore +docs/ export-ignore +docs/** export-ignore +scripts/ci/ export-ignore +scripts/ci/** export-ignore +scripts/release/ export-ignore +scripts/release/** export-ignore +renovate.json5 export-ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d2be08..24a00bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ on: jobs: # Run 'dist plan' (or host) to determine what tasks we need to do plan: - runs-on: "ubuntu-22.04" + runs-on: "ubuntu-24.04" outputs: val: ${{ steps.plan.outputs.manifest }} tag: ${{ !github.event.pull_request && github.ref_name || '' }} @@ -56,7 +56,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false submodules: recursive @@ -66,7 +66,7 @@ jobs: shell: bash run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - name: Cache dist - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cargo-dist-cache path: ~/.cargo/bin/dist @@ -82,7 +82,7 @@ jobs: cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json @@ -120,7 +120,7 @@ jobs: - name: enable windows longpaths run: | git config --global core.longpaths true - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false submodules: recursive @@ -135,7 +135,7 @@ jobs: run: ${{ matrix.install_dist.run }} # Get the dist-manifest - name: Fetch local artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: artifacts-* path: target/distrib/ @@ -149,7 +149,7 @@ jobs: dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" - name: Attest - uses: actions/attest-build-provenance@v3 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 with: subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" - id: cargo-dist @@ -166,7 +166,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: artifacts-build-local-${{ join(matrix.targets, '_') }} path: | @@ -178,24 +178,24 @@ jobs: needs: - plan - build-local-artifacts - runs-on: "ubuntu-22.04" + runs-on: "ubuntu-24.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: artifacts-* path: target/distrib/ @@ -213,7 +213,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: artifacts-build-global path: | @@ -226,7 +226,7 @@ jobs: - build-local-artifacts - build-global-artifacts if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} - runs-on: "ubuntu-22.04" + runs-on: "ubuntu-24.04" steps: - name: Install minisign env: @@ -241,7 +241,7 @@ jobs: rm -rf /tmp/minisign.tar.gz /tmp/minisign-linux minisign -v - name: Fetch artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: artifacts-* path: artifacts/ @@ -286,7 +286,7 @@ jobs: echo "All artifacts signed successfully" ls -la *.minisig SHA256SUMS.txt - name: Upload signed artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: artifacts-signed path: | @@ -305,23 +305,23 @@ jobs: if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && needs.sign-artifacts.result == 'success' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: "ubuntu-22.04" + runs-on: "ubuntu-24.04" outputs: val: ${{ steps.host.outputs.manifest }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: artifacts-* path: target/distrib/ @@ -334,14 +334,14 @@ jobs: cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: artifacts-* path: artifacts @@ -370,11 +370,11 @@ jobs: # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! if: ${{ always() && needs.host.result == 'success' }} - runs-on: "ubuntu-22.04" + runs-on: "ubuntu-24.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false submodules: recursive diff --git a/.gitignore b/.gitignore index 990253e..a8f3cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,11 @@ dist/ task.md .kai/ +.agents/ +.codex/ .cursor/ .idea/ +.vscode/ docs/changelog/ changelog/ @@ -12,11 +15,23 @@ changelog/ .env.* .env.local config.toml +# Keep the committed cargo build config (sets RUST_MIN_STACK for tests/CI) +!.cargo/config.toml *.pem *.key *.crt *.p12 *.pfx +# Claude Code (dev-only, not shared with the team) +.claude/skills/ +.claude/hooks/ +.claude/settings.local.json + +# Codex MR review local output +.codex-home/ +.codex-review/ +codex-review/ + # OS .DS_Store diff --git a/.mcp.json b/.mcp.json index db16e05..86934df 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "kraken": { "command": "kraken", - "args": ["mcp", "-s", "market,paper"] + "args": ["mcp", "-s", "market,paper,feedback"] } } } diff --git a/AGENTS.md b/AGENTS.md index b634dae..9011cbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,9 @@ kraken [args...] -o json 2>/dev/null Rules: - Always pass `-o json` for structured output. -- Redirect stderr: `2>/dev/null` or `2>debug.log`. -- stdout contains only valid JSON (success) or a JSON error envelope (failure). -- Exit code 0 = success, non-zero = failure. +- Redirect stderr: `2>/dev/null` or `2>debug.log`. Diagnostics there are structured JSON (JSONL), off by default bar warnings/errors; enable detail with `-v` or `RUST_LOG`. +- stdout contains only valid JSON (success) or a JSON error envelope (runtime failure). +- Exit code 0 = success, 1 = runtime failure with a JSON envelope on stdout, 2 = argument/usage error from the parser. With `-o json`, exit 2 puts a `{"error":"validation"}` envelope on stdout (stderr stays empty); without it, clap's usage text goes to stderr. Validate flag values against the `values` arrays in `agents/tool-catalog.json` before invoking. ### Basic examples @@ -90,9 +90,9 @@ kraken order buy BTCUSD 0.001 --type limit --price 50000 -o json kraken order cancel TXID123 -o json # Spot paper trading (no auth) -kraken paper init --balance 10000 -o json +kraken workspace create sandbox --capital 10000 --mode paper -o json kraken paper buy BTCUSD 0.01 -o json -kraken paper status -o json +kraken workspace status sandbox -o json # Futures paper trading (no auth) kraken futures paper init --balance 10000 -o json @@ -164,7 +164,7 @@ stdout contains the API response as JSON. Structure varies by command. ### Error response -On non-zero exit, stdout contains a JSON error envelope: +On exit 1, stdout contains a JSON error envelope: ```json {"error": "auth", "message": "Authentication failed: EAPI:Invalid key"} @@ -172,9 +172,11 @@ On non-zero exit, stdout contains a JSON error envelope: The `error` field is a stable category code. The `message` field is human-readable and not stable. +Argument and usage errors exit 2. When `-o json` is requested they render the same `{"error":"validation"}` envelope on stdout; otherwise the parser prints usage text on stderr. Catch them up front by checking values against the `values` arrays in `agents/tool-catalog.json`. + ### WebSocket streaming -WebSocket commands emit NDJSON (one JSON object per line): +WebSocket commands emit JSONL (one JSON object per line): ```bash kraken ws ticker BTC/USD -o json | while read -r line; do @@ -194,7 +196,7 @@ Route on the `error` field, not on message strings. | `rate_limit` | Yes | Error returned immediately (no CLI retry). Includes `suggestion` (tier-specific guidance), `docs_url` (Kraken documentation link), and `retryable` flag. Read `suggestion` and `docs_url` to understand the limit and adapt your strategy. | | `validation` | No | Fix input. Check types, required fields, allowed values. | | `config` | No | Check config file or environment variables. | -| `websocket` | Yes | Reconnect with paced exponential backoff and reconnect safety budget. CLI retries up to 12 times per stream lifecycle. | +| `websocket` | Yes | Streams reconnect with paced exponential backoff (capped at 30s, plus jitter) until interrupted; a subscription rejected again on a fresh token is dropped from the replay so the rest keeps streaming. One-shot methods do not retry past their own timeout. | | `io` | No | Check file paths and permissions. | | `parse` | No | Log raw response. Possible API maintenance. | @@ -224,6 +226,18 @@ fi For the full error catalog with backoff strategies and example envelopes, see `agents/error-catalog.json`. +## Feedback + +When the CLI's interface or responses do not match what you are trying to do — including a mismatch between a skill and this CLI version's contract — submit feedback. Submitting uploads the text (and metadata such as CLI version, instance id, and optional `--llm-model`) to Kraken over HTTPS. A local DuckDB copy under the Kraken config directory is written only after a successful upload. A failed upload fails the command and writes nothing locally. Do not include API keys, secrets, passwords, or personal data in the body. Architecture, limits, and retention: `docs/feedback-ingest.md`. + +```bash +kraken feedback bug --input-string "description; steps; versions" --llm-model -o json +kraken feedback friction --input-file ./notes.txt -o json +kraken feedback feature-request --input-string "I need X because Y" -o json +``` + +Types: `feature-request`, `friction`, `bug`. Soft recommendations for body content (and the 8192-byte limit) are documented in `kraken feedback --help`. + ## Rate Limiting The CLI does not pre-throttle or retry rate-limited requests. The Kraken API server enforces rate limits, and the CLI returns the error immediately with structured fields that agents should read: @@ -254,18 +268,19 @@ Two independent paper engines cover spot and futures. No API keys, no account, n ### Spot Paper ```bash -kraken paper init --balance 10000 -o json # Create paper account +kraken workspace create sandbox --capital 10000 --mode paper --slippage-rate 0.001 -o json # Create AND fund the account +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 -o json # Market buy kraken paper sell BTCUSD 0.005 --type limit --price 70000 -o json # Limit sell -kraken paper status -o json # Check portfolio +kraken workspace status -o json # Check portfolio kraken paper orders -o json # Check open orders kraken paper history -o json # Trade history -kraken paper reset -o json # Reset account +kraken workspace reset -o json --yes # Return to starting capital ``` Switch between paper and live by changing the prefix: `kraken paper buy` vs `kraken order buy`. -A 0.26% taker fee is applied to all fills (Kraken Starter tier default). Limit orders fill at the limit price when the live market crosses the order price. +A 0.26% taker fee is applied to all fills (Kraken Starter tier default, configurable with `--fee-rate`). Optional slippage simulation is available via `--slippage-rate` (default 0.0). Market buys fill at `ask * (1 + slippage_rate)`, sells at `bid * (1 - slippage_rate)`. Limit orders fill at the limit price when the live market crosses the order price. Use `kraken workspace status -o json` to check current fee and slippage rates; `paper init/reset/status/balance` remain as aliases of the workspace commands. ### Futures Paper @@ -288,11 +303,33 @@ Switch between paper and live by replacing `futures paper` with `futures order`: Spot paper and futures paper are fully independent. Resetting one does not affect the other. +## Market Memory & Stream Reliability + +Recording and streaming are CLI-only surfaces — a request/response MCP tool cannot host a live stream (see [Integration](#integration)). Agents run them as background processes and read the lifecycle from stderr; `kraken tape list` is a regular MCP tool in the market service. + +```bash +kraken record --symbols BTC/USD --channels trades,book --tape my-tape --to duckdb,jsonl -o json & +RECORDER=$! +# ... let the window run ... +kill -TERM $RECORDER # graceful stop: every backend finalizes +``` + +The lifecycle contract, as stderr JSONL (stdout stays empty unless `--to` includes `stdout`): + +- `{"event":"record","type":"started","tape":"","format":"duckdb",...}` — one line per backend, emitted after locks and schema are ensured, before any socket opens. +- `{"event":"record","type":"summary","tapes":[...]}` — only for a capture that actually finalized. SIGINT and SIGTERM both finalize: the tape metadata gains `window_end`, `events_dropped`, `frames_unparsed`, and `reconnect_count`. +- One writer per tape: a second `record` on the same tape exits 1 with a `validation` envelope (`tape '' is in use by another process`). Readers coexist; `kraken tape list -o json` reports `in_use` and `state` per backend. +- Re-recording an existing tape appends to it and extends its window. + +Add `--monitor` to `ws`, `streamd`, or `record` for reliability diagnostics as stderr JSONL, routable by `type`: periodic `health` (connection status, per-channel latency percentiles, trade-gap counters, staleness ages), `alert` (stale data, heartbeat timeout), and a final `summary`. Tune with `--monitor-health-interval`, `--monitor-stale-after`, and `--monitor-heartbeat-timeout` (seconds). + +A finalized tape is a replay source: `kraken session start --from tape:my-tape --speed 10` replays it into a paper session. The [market memory walkthrough](examples/03-market-memory.md) covers the interactive loop. + ## Command Groups Overview | Group | Auth | Commands | Description | |-------|------|----------|-------------| -| market | No | 10 | Public market data: ticker, orderbook, OHLC, trades, spreads | +| market | No | 11 | Public market data plus the local tape-library catalog | | account | Yes | 18 | Balances, orders, trades, ledgers, positions, export, L3 orderbook | | trade | Yes | 9 | Order placement, amendment, cancellation (spot, xStocks, forex) | | funding | Yes | 10 | Deposits, withdrawals, wallet transfers | @@ -302,17 +339,18 @@ Spot paper and futures paper are fully independent. Resetting one does not affec | futures-paper | No | 17 | Futures paper trading: all 8 order types, leverage, margin, liquidation | | futures-ws | Mixed | 9 | Futures WebSocket streaming | | websocket | Mixed | 15 | Spot WebSocket v2 streaming and request/response | -| paper | No | 10 | Spot paper trading simulation | +| paper | No | 16 | Spot paper trading simulation, P&L explanation, and the Autoresearch Lab | +| workspace | No | 15 | Strategy workspaces and recorded sessions: isolated accounts, session windows, decision logs, reports, promotion | | auth | No | 4 | Credential management (set, show, test, reset) | | utility | No | 2 | Interactive setup and REPL shell | -Total: 151 commands. For the full machine-readable catalog, see `agents/tool-catalog.json`. +Catalog: 174 request/response commands. See `agents/tool-catalog.json`; CLI-only streams are listed under [Integration](#integration). `kraken mcp` is a runtime mode that starts an MCP server, not a tool-callable command. It is not included in the catalog. ## Dangerous Commands -The catalog marks 34 commands as `dangerous: true`. These move real money, cancel real orders, or mutate account state. Gate every one with confirmation logic. +The catalog marks 41 commands as `dangerous: true`. Four of them (`order buy/sell/cancel/cancel-all`) also carry `paper_safe: true` — inside a paper workspace (KRAKEN_WORKSPACE set to a paper account) they execute simulated fills and need no confirmation; against the real account they stay gated. These move real money, cancel real orders, or mutate account state. Gate every one with confirmation logic. The authoritative source is the `dangerous` field in `agents/tool-catalog.json`. @@ -323,8 +361,9 @@ Common dangerous command groups: | Spot orders | `order buy`, `order sell`, `order batch`, `order amend`, `order edit`, `order cancel`, `order cancel-batch`, `order cancel-all`, `order cancel-after` | | Funding | `withdraw`, `withdrawal cancel`, `wallet-transfer` | | Earn | `earn allocate`, `earn deallocate` | -| Subaccounts | `subaccount transfer` | -| Futures | `futures order buy/sell`, `futures edit-order`, `futures cancel`, `futures cancel-all`, `futures cancel-after`, `futures batch-order`, `futures transfer`, `futures wallet-transfer`, `futures set-subaccount-status` | +| Subaccounts | `subaccount transfer`, `subaccount create` | +| Futures | `futures order buy/sell`, `futures edit-order`, `futures cancel`, `futures cancel-all`, `futures cancel-after`, `futures batch-order`, `futures transfer`, `futures wallet-transfer`, `futures set-subaccount-status`, `futures set-leverage`, `futures set-pnl-preference` | +| Account data | `export-retrieve`, `export-delete` | | WebSocket | `ws add-order`, `ws amend-order`, `ws cancel-order`, `ws cancel-all`, `ws cancel-after`, `ws batch-add`, `ws batch-cancel` | ### Agent Autonomy Levels @@ -384,7 +423,7 @@ import json with open("agents/tool-catalog.json") as f: catalog = json.load(f) -# All 151 commands with full parameter schemas +# All 174 commands with full parameter schemas commands = catalog["commands"] # Filter to safe read-only commands @@ -429,7 +468,9 @@ Configure your MCP client: } ``` -**Service groups**: `market`, `account`, `trade`, `funding`, `earn`, `subaccount`, `futures`, `futures-paper`, `paper`, `auth`, or `all`. Default exposed groups are `market,account,paper`. +**Service groups**: `market`, `account`, `trade`, `funding`, `earn`, `subaccount`, `futures`, `futures-paper`, `paper`, `auth`, `feedback`, or `all`. Default exposed groups are `market,account,paper,feedback`. + +**Streaming commands are CLI-only.** `session start` (and bare `kraken playground`, its demo composition), `record`, `replay`, the `streamd` verbs, and `ws ping` never appear as MCP tools — a request/response tool cannot host a live stream. An MCP-only agent gets the whole loop *around* a session (order, note, state, stop, show, score, explain), but opening one takes a shell: background `kraken session start … -o json &` and parse the `session_started` line for the handle. **Behavior:** - Streaming groups (`websocket`, `futures-ws`) are excluded in MCP v1 (REST-only tools). @@ -462,7 +503,7 @@ Configure your MCP client: | File | Format | Description | |------|--------|-------------| -| `agents/tool-catalog.json` | JSON | All 151 commands with parameters, types, safety flags, and examples | -| `agents/error-catalog.json` | JSON | 9 error categories with retry guidance | +| `agents/tool-catalog.json` | JSON | All 174 commands with parameters, types, safety flags, and examples | +| `agents/error-catalog.json` | JSON | 10 error categories with retry guidance | | `agents/examples/` | Shell | Runnable workflow examples | | `skills/` | SKILL.md | Goal-oriented workflow skills for agents | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..922c9ff --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +User-visible changes are recorded here. kraken-cli follows +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.4.0] - 2026-07-27 + +Version 0.4.0 adds a repeatable research loop around the trading CLI. Users can +isolate a paper account, record a market window, preserve decisions, replay the +same observations, and grade the result against criteria fixed in advance. + +The release exposes 173 request/response commands in 14 groups and ships 59 +agent skills. Streaming and recording remain CLI-only. The project is still +alpha software: test with paper trading and use least-privilege credentials. + +### Research workflow + +**Workspaces** are durable account scopes. Creation also funds the paper +account, eliminating half-created states. Each workspace records its capital, +currency, fees, slippage, and optional pair restrictions. Resets open a new +journal epoch without deleting history. Reports separate session activity, +manual trades, resets, reconciliation drift, and current equity. + +The familiar spot order verbs route to simulated execution inside a paper +workspace and retain live behavior outside one. Named live workspaces are +blocked until credentials can be scoped safely. + +**Sessions** are recorded windows over a workspace journal. They preserve +opening and closing valuation, market data, provenance, fills, and decision +notes. A separate typed state cell lets an agent resume without mixing its +cursor into the evidence. Sessions distinguish recording, stopped, aborted, +and damaged states; repeated stops return the existing result. + +**Tapes** store WebSocket data in DuckDB, JSONL, or both. They carry symbols, +channels, time bounds, integrity counters, and completion state. SIGINT and +SIGTERM finalize every backend. Concurrent writers are refused, readers can +coexist with a writer, and finalized tapes can be appended or replayed. +Incomplete captures cannot masquerade as replayable evidence. + +`--monitor` adds JSONL diagnostics on stderr for latency, throughput, gaps, +staleness, heartbeats, reconnects, dropped events, and parse failures. Stdout +remains reserved for command data. + +**Lab experiments** seal a hypothesis, thresholds, and optional live/replay +plan before results exist. The lab verifies the seal, identifies the next run, +checks tape hashes, scores stopped sessions, and compares every planned result. +Failures remain visible rather than disappearing from a comparison. + +Scoring covers return, drawdown, fees and execution friction, turnover, hit +rate, profit factor, trade counts, and slippage sensitivity. `explain pnl` +reconciles the stop total across price movement, fees, spread, slippage, +residual differences, missed-fill counterfactuals, and recorded decisions. + +### Automation contracts + +The public catalog describes parameters, allowed values, authentication, +examples, and safety annotations for all request/response commands. MCP exposes +those commands through the same parser and dispatcher as the CLI. Unknown MCP +arguments now fail with the accepted names instead of being dropped. + +JSON-formatted parser failures use the stable error envelope. Rate-limit errors +include retry guidance, a retryable flag, and the relevant Kraken documentation +URL. Workspace, session, tape, experiment, and P&L request/response operations +are available through MCP; long-running streams are not. + +Financial values in workspace and session contracts use exact decimal strings. +Pair restrictions are enforced before price lookup or journal mutation. +Damaged manifests, journals, tapes, and seals fail closed. Destructive resets +require confirmation, and futures-paper mutations are refused inside a +workspace because that simulator still has one global account. + +Release archives are built with cargo-dist for Apple Silicon and Intel macOS +plus ARM64 and x86-64 Linux. Archives include SHA-256 checksums, provenance +attestations, and Minisign signatures. + +### Changes requiring migration + +- `paper init`, `paper reset`, `paper status`, and `paper balance` remain as + compatibility aliases, but lifecycle responses now use the workspace + contract. Amounts are decimal strings. +- `paper reset` can change capital, currency, fee, and slippage for the new + epoch. Unattended resets require the global `--yes` flag. +- Recording vocabulary now uses **tape**. `record --dataset` remains a CLI + alias, but output and stored metadata use `tape`. +- MCP tool `kraken_dataset_list` is replaced by `kraken_tape_list`; its replay + reference field is `tape`, not `dataset`. +- Playground lifecycle commands moved to `session start`, `stop`, `list`, + `show`, and `note`. Bare `kraken playground` remains the guided demo. +- `--session` and session references now accept `latest`, `s`, or a unique + label. Reusable recordings use `tape:`; session recordings use + `session:s`. +- MCP argument names follow the CLI. Existing aliases still work, but unknown + names are rejected. + +### Upgrade from 0.3.2 + +1. Re-list MCP tools and refresh cached schemas. +2. Replace `kraken_dataset_list` with `kraken_tape_list`. +3. Read paper lifecycle responses as workspace contracts with decimal strings. +4. Add `--yes` to unattended resets. +5. Select paper accounts with `--workspace ` or + `KRAKEN_WORKSPACE=`. Empty, omitted, and `default` select the flat + real-account layout. +6. Replace playground lifecycle calls with session commands. +7. Update recording references to `tape:` or `session:s`. + +### Release facts + +- Version: 0.4.0 +- Minimum Rust version: 1.97 +- Catalog: 173 request/response commands in 14 groups +- Skills: 59 +- Previous release: 0.3.2 + +## [0.3.2] - 2026-04-20 + +Version 0.3.2 is the previous public release. + +[0.4.0]: https://github.com/krakenfx/kraken-cli/compare/v0.3.2...v0.4.0 +[0.3.2]: https://github.com/krakenfx/kraken-cli/releases/tag/v0.3.2 diff --git a/CLAUDE.md b/CLAUDE.md index b06a768..1f06e71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,15 +33,15 @@ Public market data commands (ticker, orderbook, ohlc, trades, spreads, status) r ## Key Conventions -- stdout is always valid JSON on success, or a JSON error envelope on failure. -- The `error` field in error envelopes is a stable category code: `api`, `auth`, `network`, `rate_limit`, `validation`, `config`, `websocket`, `io`, `parse`. -- WebSocket commands emit NDJSON (one JSON object per line). +- stdout is valid JSON on success (exit 0), or a JSON error envelope on runtime failure (exit 1). +- The `error` field in error envelopes is a stable category code: `api`, `auth`, `network`, `rate_limit`, `validation`, `config`, `websocket`, `io`, `timeout`, `parse`. +- WebSocket commands emit JSONL (one JSON object per line). - Paper trading commands (`kraken paper ...` for spot, `kraken futures paper ...` for futures) use live prices but no real money. No auth needed. -- Exit code 0 = success, non-zero = failure. +- Exit code 0 = success, 1 = runtime failure (JSON envelope on stdout), 2 = argument/usage error from the parser. With `-o json`, exit 2 puts a `{"error":"validation"}` envelope on stdout (stderr stays empty); without it, clap's usage text goes to stderr. Validate argument values against the `values` arrays in `agents/tool-catalog.json` before invoking. ## Safety Rules -1. Never execute any command marked `dangerous` without explicit user confirmation. The `dangerous` field in `agents/tool-catalog.json` is the authoritative list (34 commands). +1. Never execute any command marked `dangerous` without explicit user confirmation. The `dangerous` field in `agents/tool-catalog.json` is the authoritative list (41 commands). Four of them (`order buy/sell/cancel/cancel-all`) also carry `paper_safe: true`: inside a paper workspace they execute simulated fills and need no confirmation. 2. Use `--validate` flag to dry-run order commands before submitting. 3. Use `kraken paper` (spot) or `kraken futures paper` (futures) commands for testing strategies safely. 4. Gate all order placement, cancellation, withdrawal, transfer, and staking operations behind user approval. @@ -58,6 +58,16 @@ kraken ohlc BTCUSD --interval 60 -o json kraken trades BTCUSD --count 20 -o json ``` +### Record market data (no auth) + +```bash +kraken record --symbols BTC/USD --channels trades,book --tape my-tape -o json # runs until stopped; SIGTERM/Ctrl-C finalizes +kraken tape list -o json # library: refs, state, in_use per backend +kraken session start --from tape:my-tape --speed 10 -o json # replay a finalized tape into a paper session +``` + +Run `record` as a background process; lifecycle and `--monitor` health lines are stderr JSONL. One writer per tape: a concurrent recorder gets a `validation` envelope. + ### Check account (auth required) ```bash @@ -97,12 +107,15 @@ kraken futures order buy PF_XBTUSD 1 --type limit --price 50000 -o json ### Spot paper trading (no auth, safe) ```bash -kraken paper init --balance 10000 -o json +kraken workspace create sandbox --capital 10000 --mode paper --slippage-rate 0.001 -o json +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 -o json -kraken paper status -o json -kraken paper reset -o json +kraken workspace status -o json +kraken workspace reset -o json --yes ``` +Fee rate (default 0.26%) and slippage rate (default 0.0) are set at `workspace create` and fixed for the account's life; `workspace reset` returns to the created capital. The `paper init/reset/status/balance` verbs still exist as aliases of the workspace commands (one implementation). + ### Futures paper trading (no auth, safe) ```bash @@ -132,5 +145,5 @@ Load `agents/tool-catalog.json` for the full machine-readable command contract. - `AGENTS.md`: Complete agent integration guide - `CONTEXT.md`: Runtime-optimized context for tool-using agents -- `agents/tool-catalog.json`: All 151 commands with parameters +- `agents/tool-catalog.json`: All 174 commands with parameters - `agents/error-catalog.json`: Error categories with retry guidance diff --git a/CONTEXT.md b/CONTEXT.md index aa9f44a..a0e7ddd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,9 +15,10 @@ kraken [args...] -o json 2>/dev/null Rules: - Always pass `-o json`. - Treat `stdout` as the only machine data channel. -- Treat `stderr` as diagnostics only. +- Treat `stderr` as diagnostics only (structured JSON logs; off by default bar warnings, `-v`/`RUST_LOG` for more). - Exit code `0` means success. -- Non-zero exit means failure and `stdout` should contain a JSON error envelope. +- Exit code `1` means runtime failure and `stdout` contains a JSON error envelope. +- Exit code `2` means the parser rejected the arguments (unknown flag, value outside a documented set). With `-o json`, `stdout` carries a `{"error":"validation"}` envelope and `stderr` is empty; without it, usage text goes to `stderr`. Check values against the `values` arrays in `agents/tool-catalog.json` before invoking. ## Authentication @@ -68,7 +69,8 @@ The authoritative list of dangerous commands is in `agents/tool-catalog.json` (e - `kraken withdraw`, `kraken wallet-transfer` - `kraken order cancel-all`, `kraken futures cancel-all` - `kraken earn allocate/deallocate` -- `kraken subaccount transfer` +- `kraken subaccount transfer`, `kraken subaccount create` +- `kraken futures set-leverage`, `kraken futures set-pnl-preference` (margin and payout settings) - All WebSocket order mutations (`ws add-order`, `ws amend-order`, `ws cancel-order`, `ws batch-add`, `ws batch-cancel`) This list is non-exhaustive. Always check the `dangerous` field in `tool-catalog.json`. @@ -92,12 +94,22 @@ Common categories: For full envelopes and retry guidance: `agents/error-catalog.json`. +## Feedback + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a skill/contract mismatch — submit feedback: + +```bash +kraken feedback bug --input-string "..." --llm-model -o json +``` + +Types: `feature-request`, `friction`, `bug`. Soft body recommendations are in `kraken feedback --help`. Submitting uploads feedback to Kraken over HTTPS; a local DuckDB copy is written only on success. Do not include API keys, secrets, or personal data. See `docs/feedback-ingest.md`. + ## Context Window Efficiency Prefer narrower responses: - Use limits like `--count`, `--depth`, `--offset`, `--limit` where available. - Prefer pair-scoped calls over broad list calls. -- Prefer WebSocket NDJSON for streaming instead of high-frequency polling. +- Prefer WebSocket JSONL for streaming instead of high-frequency polling. ## High-Value Patterns @@ -118,11 +130,14 @@ kraken order buy BTCUSD 0.001 --type limit --price 50000 -o json Spot paper trading loop: ```bash -kraken paper init --balance 10000 -o json +kraken workspace create sandbox --capital 10000 --mode paper --slippage-rate 0.001 -o json +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 -o json -kraken paper status -o json +kraken workspace status -o json ``` +Fee rate (default 0.26%) and slippage rate (default 0.0) are set at `workspace create` and fixed for the account's life. `paper init/reset/status/balance` are aliases of the workspace commands. + Futures paper trading loop: ```bash @@ -132,12 +147,22 @@ kraken futures paper positions -o json kraken futures paper status -o json ``` +Market memory capture (no auth): + +```bash +kraken record --symbols BTC/USD --channels trades,book --tape my-tape -o json +# Ctrl-C / SIGTERM finalizes the tape: window_end + integrity counters land in its metadata +kraken tape list -o json +``` + +Tapes live in the shared library (`tapes/` under the config dir); one writer per tape — a concurrent recorder gets a `validation` envelope. Replay a finalized tape into a paper session with `kraken session start --from tape:my-tape --speed 10`. Add `--monitor` to `ws`/`streamd`/`record` for stderr JSONL health lines (latency, trade gaps, staleness), routable by `type`. + ## MCP Server For MCP-compatible clients (Claude Desktop, ChatGPT, Codex, Gemini CLI, Cursor, VS Code, Windsurf), use the built-in MCP server: ```bash -kraken mcp -s market,account,paper,futures-paper +kraken mcp -s market,account,paper,feedback,futures-paper ``` This exposes CLI commands as structured MCP tools over stdio. No subprocess wrappers needed. @@ -148,8 +173,10 @@ Security note: - Do not expose or share this server outside systems you control. - Always use `https://` and `wss://` endpoints. +Streaming commands (`session start`, bare `kraken playground`, `record`, `replay`, `streamd`, `ws ping`) are CLI-only and never appear as MCP tools. Every other session verb (order, note, state, stop, show, score, explain) is a tool; opening a session takes a shell. + ## Tool Discovery Use these machine-readable files: -- `agents/tool-catalog.json`: full command catalog (151 commands with parameter schemas and `dangerous` flags) +- `agents/tool-catalog.json`: full command catalog (174 commands with parameter schemas and `dangerous` flags) - `agents/error-catalog.json`: error categories and retry policy diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index acef965..0a53943 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,8 +52,12 @@ Use [GitHub Issues](https://github.com/krakenfx/kraken-cli/issues) for bug repor ## Code Style - Follow existing patterns in the codebase. -- Run `cargo clippy -- -D warnings` before submitting. -- No nightly-only features. The crate must build on stable Rust. +- Before submitting, run the CI gates locally: + - `cargo clippy --all-targets -- -D warnings` + - `cargo +nightly fmt` (formatting uses nightly rustfmt for import grouping; see `rustfmt.toml`) + - `cargo sort` (keeps `Cargo.toml` tables alphabetized) + - `cargo test` +- No nightly-only language features. The crate itself must build on stable Rust (MSRV in `Cargo.toml`); only `rustfmt` runs on nightly. ## License diff --git a/Cargo.lock b/Cargo.lock index 27a761a..9927f07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,37 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -22,9 +53,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -37,15 +68,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -72,9 +103,187 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "arrow" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +dependencies = [ + "bitflags", +] + +[[package]] +name = "arrow-select" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] [[package]] name = "assert-json-diff" @@ -88,9 +297,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.1.2" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -109,7 +318,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", ] [[package]] @@ -120,15 +338,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -136,14 +354,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -152,55 +371,143 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ - "generic-array", + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", ] [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "byteorder" -version = "1.5.0" +name = "bytecheck" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.56" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -208,12 +515,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -226,11 +527,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -242,9 +554,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", "clap_derive", @@ -252,9 +564,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -264,21 +576,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.2", ] [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -291,18 +603,24 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -325,9 +643,9 @@ dependencies = [ [[package]] name = "comfy-table" -version = "7.2.2" +version = "7.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" dependencies = [ "crossterm", "unicode-segmentation", @@ -336,17 +654,42 @@ dependencies = [ [[package]] name = "console" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "once_cell", "unicode-width", "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -365,24 +708,32 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "document-features", "parking_lot", - "rustix", + "rustix 0.38.44", "winapi", ] @@ -395,21 +746,69 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", ] [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deadpool" @@ -429,6 +828,26 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -449,13 +868,14 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", - "subtle", + "ctutils", ] [[package]] @@ -481,22 +901,30 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] -name = "document-features" -version = "0.2.12" +name = "duckdb" +version = "1.10505.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +checksum = "970e05eedd3f55c435194d9104f90a9b4a79a80d6e73251bc9ff43e178130c4e" dependencies = [ - "litrs", + "arrow", + "cast", + "comfy-table", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libduckdb-sys", + "num-integer", + "strum 0.27.2", ] [[package]] @@ -511,6 +939,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -519,9 +953,21 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "endian-type" -version = "0.1.2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "equivalent" @@ -545,21 +991,32 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "fd-lock" -version = "4.0.4" +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", - "rustix", - "windows-sys 0.59.0", + "libc", ] [[package]] @@ -568,6 +1025,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -604,6 +1072,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -621,9 +1095,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -631,9 +1105,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" @@ -648,38 +1122,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -692,16 +1166,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -722,31 +1186,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi", - "wasip2", - "wasip3", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -754,13 +1217,34 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -772,9 +1256,18 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] [[package]] name = "heck" @@ -788,11 +1281,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ "digest", ] @@ -808,9 +1307,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -851,11 +1350,26 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -868,7 +1382,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -876,15 +1389,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -896,7 +1408,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -939,12 +1451,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -952,9 +1465,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -965,9 +1478,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -979,15 +1492,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -999,15 +1512,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1019,10 +1532,10 @@ dependencies = [ ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" @@ -1037,187 +1550,488 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kraken-cli" +version = "0.4.0" +dependencies = [ + "anyhow", + "assert_cmd", + "base64 0.23.1", + "chrono", + "clap", + "colored", + "comfy-table", + "dialoguer", + "dirs", + "duckdb", + "enum_dispatch", + "futures-util", + "hmac", + "humantime", + "indicatif", + "itertools", + "kraken-core", + "kraken-lab", + "kraken-paper", + "kraken-recording", + "kraken-replay", + "kraken-session", + "kraken-workspace", + "kraken-ws", + "libc", + "predicates", + "proptest", + "reqwest", + "rmcp", + "rust_decimal", + "rust_decimal_macros", + "rustyline", + "secrecy", + "serde", + "serde_json", + "serde_with", + "sha2", + "strum 0.28.0", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "wiremock", +] + +[[package]] +name = "kraken-core" +version = "0.4.0" +dependencies = [ + "clap", + "proptest", + "rust_decimal", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "thiserror", +] + +[[package]] +name = "kraken-lab" +version = "0.4.0" +dependencies = [ + "chrono", + "humantime", + "insta", + "kraken-core", + "kraken-paper", + "kraken-recording", + "kraken-replay", + "kraken-session", + "proptest", + "rust_decimal", + "rust_decimal_macros", + "serde", + "serde_json", + "serde_with", + "sha2", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "kraken-paper" +version = "0.4.0" +dependencies = [ + "chrono", + "kraken-core", + "kraken-recording", + "rust_decimal", + "rust_decimal_macros", + "serde", + "serde_json", + "serde_with", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "kraken-recording" +version = "0.4.0" +dependencies = [ + "chrono", + "duckdb", + "kraken-core", + "proptest", + "rust_decimal", + "serde", + "serde_json", + "strum 0.28.0", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "kraken-replay" +version = "0.4.0" dependencies = [ - "equivalent", - "hashbrown 0.16.1", + "chrono", + "kraken-core", + "kraken-paper", + "kraken-recording", + "kraken-session", + "proptest", + "rust_decimal", + "rust_decimal_macros", "serde", - "serde_core", + "serde_json", + "serde_with", + "strum 0.28.0", + "tempfile", + "thiserror", + "tokio", + "tracing", ] [[package]] -name = "indicatif" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +name = "kraken-session" +version = "0.4.0" dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", + "chrono", + "clap", + "kraken-core", + "kraken-paper", + "kraken-recording", + "proptest", + "rust_decimal", + "rust_decimal_macros", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tempfile", + "thiserror", + "tracing", ] [[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +name = "kraken-workspace" +version = "0.4.0" +dependencies = [ + "chrono", + "insta", + "kraken-core", + "kraken-paper", + "kraken-recording", + "kraken-session", + "proptest", + "rust_decimal", + "rust_decimal_macros", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tempfile", + "thiserror", + "tracing", +] [[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +name = "kraken-ws" +version = "0.4.0" dependencies = [ - "memchr", + "futures-util", + "kraken-core", + "proptest", + "rustls", "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tracing", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "itoa" -version = "1.0.17" +name = "lexical-core" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] [[package]] -name = "jni" -version = "0.21.1" +name = "lexical-parse-float" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", + "lexical-parse-integer", + "lexical-util", ] [[package]] -name = "jni-sys" -version = "0.3.0" +name = "lexical-parse-integer" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] [[package]] -name = "jobserver" -version = "0.1.34" +name = "lexical-util" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" [[package]] -name = "js-sys" -version = "0.3.89" +name = "lexical-write-float" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4eacb0641a310445a4c513f2a5e23e19952e269c6a38887254d5f837a305506" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" dependencies = [ - "once_cell", - "wasm-bindgen", + "lexical-util", + "lexical-write-integer", ] [[package]] -name = "kraken-cli" -version = "0.3.2" +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" dependencies = [ - "anyhow", - "assert_cmd", - "base64", - "chrono", - "clap", - "colored", - "comfy-table", - "dialoguer", - "dirs", - "futures-util", - "hmac", - "indicatif", - "predicates", - "reqwest", - "rmcp", - "rustls", - "rustyline", - "serde", - "serde_json", - "sha2", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "toml", - "url", - "wiremock", + "lexical-util", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "libduckdb-sys" +version = "1.10505.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "6cb514dab5e271e849235c1cb98bd65a2ae107fbd619a6740219319c54a71d95" +dependencies = [ + "cc", + "flate2", + "pkg-config", + "serde", + "serde_json", + "tar", + "ureq", + "vcpkg", + "zip", +] [[package]] -name = "libc" -version = "0.2.182" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags", "libc", ] [[package]] name = "linux-raw-sys" -version = "0.12.1" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] -name = "litemap" -version = "0.8.1" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "litrs" -version = "1.0.0" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1230,9 +2044,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -1240,17 +2054,36 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1268,9 +2101,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags", "cfg-if", @@ -1284,6 +2117,49 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1291,6 +2167,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1305,9 +2182,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1352,9 +2229,9 @@ dependencies = [ [[package]] name = "pastey" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "percent-encoding" @@ -1364,15 +2241,15 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" @@ -1382,13 +2259,19 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1429,13 +2312,12 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "proc-macro-crate" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "proc-macro2", - "syn", + "toml_edit", ] [[package]] @@ -1447,11 +2329,56 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -1461,7 +2388,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -1469,21 +2396,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1491,23 +2419,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1518,11 +2446,23 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "radix_trie" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" dependencies = [ "endian-type", "nibble_vec", @@ -1530,9 +2470,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1541,14 +2481,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -1587,6 +2538,30 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1604,7 +2579,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1624,14 +2599,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -1641,9 +2616,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -1652,17 +2627,26 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -1708,31 +2692,109 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rmcp" -version = "1.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cb14cb9278a12eae884c9f3c0cfeca2cc28f361211206424a1d7abed95f090" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" dependencies = [ "async-trait", "chrono", "futures", "pastey", "pin-project-lite", - "schemars", + "schemars 1.2.1", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] [[package]] name = "rustix" @@ -1743,18 +2805,20 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1763,9 +2827,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -1775,9 +2839,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -1785,9 +2849,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -1812,9 +2876,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -1824,20 +2888,31 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] [[package]] name = "rustyline" -version = "17.0.2" +version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ "bitflags", "cfg-if", "clipboard-win", - "fd-lock", "home", "libc", "log", @@ -1847,7 +2922,7 @@ dependencies = [ "unicode-segmentation", "unicode-width", "utf8parse", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1874,6 +2949,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -1897,7 +2984,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.118", ] [[package]] @@ -1906,6 +2993,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1931,15 +3034,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1947,22 +3050,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.2", ] [[package]] @@ -1973,14 +3076,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1991,9 +3094,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -2010,11 +3113,43 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures", @@ -2023,15 +3158,24 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures", "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -2040,9 +3184,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2054,6 +3198,34 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" @@ -2062,18 +3234,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2088,6 +3260,48 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2096,9 +3310,31 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -2122,19 +3358,36 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", ] [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -2146,49 +3399,77 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 1.0.69", + "thiserror-impl", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "thiserror-impl" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ - "thiserror-impl 2.0.18", + "proc-macro2", + "quote", + "syn 3.0.2", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "thread_local" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "proc-macro2", - "quote", - "syn", + "cfg-if", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "time" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ - "proc-macro2", - "quote", - "syn", + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", ] [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2196,9 +3477,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2211,14 +3492,13 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2228,13 +3508,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2247,11 +3527,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -2265,24 +3556,25 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "1.0.6+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -2293,27 +3585,39 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -2332,20 +3636,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2364,31 +3668,74 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "pin-project-lite", - "tracing-attributes", + "log", + "once_cell", "tracing-core", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "tracing-serde" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ - "proc-macro2", - "quote", - "syn", + "serde", + "tracing-core", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -2399,29 +3746,33 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.5", + "rand 0.10.2", "rustls", "rustls-pki-types", "sha1", - "thiserror 1.0.69", - "utf-8", + "thiserror", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" @@ -2431,9 +3782,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -2441,12 +3792,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unit-prefix" version = "0.5.2" @@ -2459,6 +3804,34 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.8", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2472,10 +3845,10 @@ dependencies = [ ] [[package]] -name = "utf-8" -version = "0.7.6" +name = "utf8-zero" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" [[package]] name = "utf8_iter" @@ -2489,6 +3862,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2531,54 +3927,42 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.112" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d7d0fce354c88b7982aec4400b3e7fcf723c32737cef571bd165f7613557ee" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.62" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee85afca410ac4abba5b584b12e77ea225db6ee5471d0aebaae0861166f9378a" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.112" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55839b71ba921e4f75b674cb16f843f4b1f3b26ddfcb3454de1cf65cc021ec0f" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2586,65 +3970,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.112" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf2e969c2d60ff52e7e98b7392ff1588bffdd1ccd4769eba27222fd3d621571" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.112" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0861f0dcdf46ea819407495634953cdcc8a8c7215ab799a7a7ce366be71c7b30" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.89" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10053fbf9a374174094915bbce141e87a6bf32ecd9a002980db4b638405e8962" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -2662,9 +4012,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -2675,14 +4025,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -2739,7 +4089,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2750,7 +4100,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2777,40 +4127,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2822,197 +4145,78 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "wiremock" @@ -3021,7 +4225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http", @@ -3039,103 +4243,40 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "wit-component" -version = "0.244.0" +name = "wyz" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "tap", ] [[package]] -name = "wit-parser" -version = "0.244.0" +name = "xattr" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "libc", + "rustix 1.1.4", ] -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3144,68 +4285,68 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3214,9 +4355,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3225,17 +4366,49 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 17dc00e..c893aaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,50 +1,196 @@ [package] name = "kraken-cli" -version = "0.3.2" -edition = "2021" +version.workspace = true +edition.workspace = true +rust-version.workspace = true description = "Kraken CLI — trade, query, and manage your Kraken account from the terminal" -license = "MIT" +license.workspace = true keywords = ["kraken", "crypto", "trading", "cli", "api"] categories = ["command-line-utilities", "finance"] -repository = "https://github.com/krakenfx/kraken-cli" +repository.workspace = true homepage = "https://github.com/krakenfx/kraken-cli" +include = [ + "/Cargo.lock", + "/Cargo.toml", + "/CHANGELOG.md", + "/DISCLAIMER.md", + "/LICENSE", + "/README.md", + # Compiled into the MCP registry via include_str! — a build input, not a doc. + "/agents/tool-catalog.json", + "/src/**", +] + +[workspace] +members = ["crates/*"] + +[workspace.package] +version = "0.4.0" +edition = "2024" +# Deliberate floor: bumped manually to current stable. Gated in CI by +# test-msrv, which reads the version from here alone. +rust-version = "1.97" +license = "MIT" +repository = "https://github.com/krakenfx/kraken-cli" + +[workspace.dependencies] +chrono = { version = "0.4.45", features = ["serde"] } +clap = { version = "4.6.5", features = ["derive"] } +duckdb = { version = "1.10505.0", features = ["bundled"] } +futures-util = "0.3.33" +humantime = "2.4.0" +kraken-core = { path = "crates/kraken-core" } +kraken-lab = { path = "crates/kraken-lab" } +kraken-paper = { path = "crates/kraken-paper" } +kraken-recording = { path = "crates/kraken-recording" } +kraken-replay = { path = "crates/kraken-replay" } +kraken-session = { path = "crates/kraken-session" } +kraken-workspace = { path = "crates/kraken-workspace" } +kraken-ws = { path = "crates/kraken-ws" } +proptest = "1.11.0" +rust_decimal = { version = "1.42.1", features = ["serde-float", "serde-with-str"] } +rust_decimal_macros = "1.40.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = { version = "1.0.151", features = ["raw_value"] } +serde_with = { version = "3.21.0", default-features = false, features = ["macros"] } +# Experiment freeze hashes (kraken-lab) and the binary's own digests. +sha2 = "0.11" +strum = { version = "0.28", features = ["derive"] } +tempfile = "3.27.0" +thiserror = "2.0.19" +tokio = "1.53.1" +tokio-stream = "0.1.19" +tokio-tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] } +tokio-util = "0.7.19" +tracing = "0.1.44" + +# No missing_debug_implementations: `Cli`/`AppContext` hold the OTP as a plain +# String, so a derived Debug would print a live credential. +[workspace.lints.rust] +unreachable_pub = "warn" +# Two per-site carve-outs, each with a SAFETY comment: libc::kill supervision +# and Rust 2024 env mutation in telemetry tests. +unsafe_code = "deny" + +# The docs gate documents private items, so pub-doc links to pub(crate) +# items resolve; a broken link is a real error. +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +private_intra_doc_links = "allow" + +# The panic tripwire the conventions demand: unwrap/panic/todo in non-test code +# fail the clippy gate. clippy.toml carves tests out of the first three; a +# production expect() needs a local #[expect] whose reason states the invariant. +[workspace.lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +dbg_macro = "deny" [[bin]] name = "kraken" path = "src/main.rs" +[features] +default = ["record-duckdb"] +# With this off, `record --to duckdb` reports a clean validation error and +# `--to jsonl` still works. A union, never a behavior toggle: every member +# that can read DuckDB gets the backend together. +record-duckdb = ["kraken-recording/duckdb", "kraken-session/duckdb", "dep:duckdb"] + [dependencies] -clap = { version = "4", features = ["derive"] } -reqwest = { version = "0.13", features = ["query", "json", "rustls"], default-features = false } -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "1" -hmac = "0.12" -sha2 = "0.10" -base64 = "0.22" -url = "2" -chrono = { version = "0.4", features = ["serde"] } -tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } -rustls = { version = "0.23", default-features = false } -futures-util = "0.3" -anyhow = "1" -thiserror = "2" -dirs = "6" -comfy-table = "7" +anyhow = "1.0.104" +base64 = "0.23.1" +chrono = { workspace = true } +clap = { workspace = true, features = ["env"] } +colored = "3.1.1" +# duckdb >=1.10503 pins `comfy-table ~7.1`; anything above 7.1.x is +# unresolvable until upstream relaxes it (duckdb-rs Cargo.toml, comfy-table dep). +# Renovate is capped to match — see renovate.json5. +comfy-table = "7.1.4" dialoguer = "0.12" -rustyline = "17" -colored = "3" -indicatif = "0.18" -rmcp = { version = "1.1", default-features = false, features = ["server", "transport-io"] } +dirs = "6" +duckdb = { workspace = true, optional = true } +enum_dispatch = "0.3.13" +futures-util = { workspace = true } +hmac = "0.13" +humantime = { workspace = true } +indicatif = "0.18.6" +itertools = "0.15.0" +kraken-core = { workspace = true, features = ["clap"] } +kraken-lab = { workspace = true } +kraken-paper = { workspace = true } +kraken-recording = { workspace = true } +kraken-replay = { workspace = true } +kraken-session = { workspace = true, features = ["clap"] } +kraken-workspace = { workspace = true } +kraken-ws = { path = "crates/kraken-ws" } +reqwest = { version = "0.13.4", features = [ + "query", + "json", + "rustls", +], default-features = false } +rmcp = { version = "2.2.0", default-features = false, features = [ + "server", + "transport-io", +] } +# serde-float: wire prices are JSON numbers, so Decimal must (de)serialize as a +# number, not rust_decimal's default string form. +rust_decimal = { workspace = true } +rust_decimal_macros = { workspace = true } +rustyline = "18.0.1" +secrecy = { version = "0.10.3", features = ["serde"] } +serde = { workspace = true } +serde_json = { workspace = true } +serde_with = { workspace = true } +sha2 = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = [ + "rt-multi-thread", + "macros", + "sync", + "time", + "net", + "signal", + "fs", +] } +tokio-stream = { workspace = true } +tokio-tungstenite = { workspace = true } +tokio-util = { workspace = true } +toml = "1.1.4" +tracing = { workspace = true } +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } +url = "2.5.8" +uuid = { version = "1.24.0", features = ["v4"] } + +[target.'cfg(unix)'.dependencies] +# `kill(pid, 0)` liveness probe and SIGTERM delivery for playground's recorder +# supervision (`process::process_alive` / `process::terminate`). +libc = "0.2.189" [dev-dependencies] -wiremock = "0.6" -assert_cmd = "2" -predicates = "3" -tempfile = "3" +assert_cmd = "2.2.2" +# Journal-accurate fixture sessions for the timeline consumers' tests; +# feature unification turns fixtures on for every root test target. +kraken-session = { workspace = true, features = ["fixtures"] } +predicates = "3.1.4" +# Pins the P&L decomposition identity (explain pnl) over random fill +# sequences, not just example inputs. +proptest = "1.11.0" +tempfile = { workspace = true } +# test-util's paused clock keeps time-based tests (WS read-idle timeout) +# instant and deterministic. +tokio = { version = "1.53.1", features = ["test-util"] } +wiremock = "0.6.5" + +[lints] +workspace = true -# The profile that 'dist' will build with [profile.dist] inherits = "release" +codegen-units = 1 lto = "thin" +strip = "symbols" diff --git a/DISCLAIMER.md b/DISCLAIMER.md index 35621bf..fb630c6 100644 --- a/DISCLAIMER.md +++ b/DISCLAIMER.md @@ -44,3 +44,8 @@ Your API key and secret grant access to your Kraken account. Treat them like pas - Never pass `--api-secret` on the command line in shared environments (use environment variables or `--api-secret-stdin`). - Rotate keys regularly. - Use the most restrictive permissions possible for your use case. +- Expose the CLI and its MCP tools only to the people (and agents) who should be able to operate the Kraken account. A shared machine, multi-user server, or shared agent session with credentials in the environment gives everyone on it full account access. + +## Data Collection + +`kraken feedback` uploads the feedback body you provide, plus metadata (`instance_id`, `agent_client`, CLI version, and related fields such as OS/arch and optional `--llm-model`), to Kraken over HTTPS so the product can be improved. Do not put API keys, secrets, passwords, or personal data in the feedback body. Separately, client-identity headers (`instance_id`, agent client, CLI version) already accompany ordinary Kraken API calls made by this CLI; they are not unique to feedback. Not running `kraken feedback` is the opt-out for uploading free-form feedback text. See `docs/feedback-ingest.md` for retention and limits. diff --git a/README.md b/README.md index 0df1265..6299994 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Try these with your AI agent: - [What You Can Trade](#what-you-can-trade) - [Quick Start](#quick-start) - [MCP Server](#mcp-server) +- [Workspaces, Sessions, and Research](#workspaces-sessions-and-research) - [Paper Trading](#paper-trading) - [Commands](#commands) - [API Keys & Configuration](#api-keys--configuration) @@ -101,9 +102,10 @@ If you're an AI agent or building one, start here: |----------|-------------| | [CONTEXT.md](CONTEXT.md) | Runtime context for tool-using agents | | [AGENTS.md](AGENTS.md) | Full integration guide: auth, invocation, errors, rate limits | -| [agents/tool-catalog.json](agents/tool-catalog.json) | 151 commands with parameter schemas, types, and safety flags | -| [agents/error-catalog.json](agents/error-catalog.json) | 9 error categories with retry guidance | -| [skills/](skills/) | 50 goal-oriented SKILL.md workflow packages | +| [docs/feedback-ingest.md](docs/feedback-ingest.md) | How `kraken feedback` uploads to Kraken (limits, retention) | +| [agents/tool-catalog.json](agents/tool-catalog.json) | 174 commands with parameter schemas, types, and safety flags | +| [agents/error-catalog.json](agents/error-catalog.json) | 10 error categories with retry guidance | +| [skills/](skills/) | Goal-oriented SKILL.md workflow packages | | [CLAUDE.md](CLAUDE.md) | Claude-specific integration guidance | Core invocation pattern: @@ -114,7 +116,7 @@ kraken [args...] -o json 2>/dev/null - stdout is always valid JSON on success, or a JSON error envelope on failure. - Exit code 0 means success. Non-zero means failure. -- stderr carries diagnostics only (enabled with `-v`). Never parse stderr for data. +- stderr carries diagnostics only — structured JSON logs (warnings/errors by default, more with `-v` or `RUST_LOG`). Never parse stderr for data.
Why Agent-First? @@ -125,7 +127,8 @@ Most CLIs are built for humans at a terminal. This one is built for LLM-based ag - **Consistent error envelopes.** Errors are JSON objects with a stable `error` field (`auth`, `rate_limit`, `validation`, `api`, `network`). Agents route on `error` without parsing human sentences. - **Predictable exit codes.** Success is 0, failure is non-zero. Combined with JSON errors on stdout, agents detect and classify failures programmatically. - **Paper trading for safe iteration.** Test strategies against live prices with `kraken paper` (spot) and `kraken futures paper` (perpetual futures) commands. No API keys, no real money, same interface as live trading. -- **Full API surface.** 151 commands covering Spot, Futures, xStocks, Forex, Funding, Earn, Subaccounts, WebSocket streaming, and paper trading for both spot and futures. +- **Market memory.** `kraken record` captures live streams into a local DuckDB/JSONL tape library with integrity counters; finalized tapes replay into paper sessions and feed the autoresearch lab. +- **Full API surface.** The catalog describes 174 request/response commands covering Spot, Futures, xStocks, Forex, Funding, Earn, Subaccounts, and paper trading. Long-running WebSocket, recording, replay, session-start, and stream-daemon surfaces remain CLI-only. - **Built-in MCP server.** Native Model Context Protocol support over stdio. No subprocess wrappers needed. - **Rate-limit aware.** No client-side throttling. When the Kraken API rejects a request due to rate limits, the CLI returns an enriched error with `suggestion`, `retryable`, and `docs_url` fields so agents can read the documentation and adapt their strategy. @@ -245,6 +248,7 @@ Highest precedence first: | `KRAKEN_WS_AUTH_URL` | `wss://ws-auth.kraken.com/v2` | Authenticated Spot WebSocket endpoint | | `KRAKEN_WS_L3_URL` | `wss://ws-l3.kraken.com/v2` | Level 3 order book WebSocket endpoint | | `KRAKEN_FUTURES_WS_URL` | `wss://futures.kraken.com/ws/v1` | Futures WebSocket endpoint | +| `KRAKEN_FEEDBACK_ENDPOINT` | `https://cli-feedback.kraken.com/v1/feedback` | Product feedback ingest URL (trusted hosts only; see [docs/feedback-ingest.md](docs/feedback-ingest.md)) | Resolution: CLI flag > environment variable > default. Only `https://` and `wss://` schemes are accepted. @@ -260,7 +264,7 @@ MCP tool calls run through the same command path as CLI commands and inherit the > MCP is local-first and designed for your own machine. Any agent connected to this MCP server uses the same configured Kraken account and API key permissions. Do not expose, tunnel, or share this server outside systems you control. Always use `https://` and `wss://` endpoints. Treat this integration as alpha and use least-privilege API keys. ```bash -kraken mcp # read-only (market, account, paper) +kraken mcp # default: market, account, paper, workspace, feedback kraken mcp -s all # all services, dangerous calls require acknowledged=true kraken mcp -s all --allow-dangerous # all services, no per-call confirmation kraken mcp -s market,trade,paper # specific services @@ -294,9 +298,11 @@ Gemini CLI users can install directly: `gemini extensions install https://github | futures | Mixed | Orders (dangerous) | | futures-paper | No | None (simulation) | | paper | No | None (simulation) | +| workspace | No | None (paper workspaces); live workspaces gate like the real account | +| feedback | No | Uploads text to Kraken (see [docs/feedback-ingest.md](docs/feedback-ingest.md)) | | auth | No | Read-only | -Default: `market,account,paper`. Streaming groups (`websocket`, `futures-ws`) are excluded in MCP v1. +Default: `market,account,paper,workspace,feedback`. The `feedback` MCP tools call the same upload path as `kraken feedback`. Streaming groups (`websocket`, `futures-ws`) are excluded in MCP v1. Dangerous tools carry the `destructive_hint` annotation and include `[DANGEROUS: requires human confirmation]` in the description. In guarded mode (default), dangerous calls must include `acknowledged=true`. In autonomous mode (`--allow-dangerous`), this requirement is disabled. @@ -312,7 +318,7 @@ kraken ticker BTCUSD -o json # {"BTCUSD":{"a":["67234.10","1","1.000"],"b":["67234.00","1","1.000"],...}} ``` -WebSocket commands emit NDJSON (one JSON object per line) for streaming. +WebSocket commands emit JSONL (one JSON object per line) for streaming. ### Table (`-o table`) @@ -350,23 +356,87 @@ Route on `error`, not on `message`. The `message` field is human-readable and no Full error catalog with envelopes and backoff strategies: [agents/error-catalog.json](agents/error-catalog.json). -### Verbose (`-v`) +### Logging (`-v`, `--log-format`, `RUST_LOG`) -Request/response diagnostics go to stderr, keeping stdout clean: +Diagnostics are emitted via `tracing` to **stderr only** — stdout stays the pure result/JSON channel. Logging is **off by default** except warnings and errors, so the `2>/dev/null` pattern is unaffected. Secrets, credentials, and financial data are never logged. + +**Format** — structured JSON (JSONL, one object per line) by default, so logs are machine-parseable out of the box. A human-readable format is opt-in: + +```bash +kraken balance -o json -v 2>/dev/null | jq . # result on stdout; logs (JSON) on stderr +kraken balance -v --log-format pretty # human-readable logs instead +export KRAKEN_LOG_FORMAT=pretty # ...or via env (e.g. a dev shell profile) +``` + +`--log-format` accepts `json` (default), `pretty`, or `compact`, and is independent of `-o`/`--output` (which formats the command result on stdout). + +**Level** — controlled by `RUST_LOG` (the standard `tracing` filter); `-v`/`--verbose` is a shortcut for debug-level kraken-cli logs: ```bash -kraken balance -o json -v 2>/dev/null | jq . +kraken balance -v # kraken-cli at debug (dependencies stay quiet) +RUST_LOG=kraken_cli=debug kraken balance # same, explicit +RUST_LOG=kraken_cli::client=trace kraken ticker BTCUSD # HTTP request/response detail ``` +Note: a bare `RUST_LOG=debug` enables debug for *every* crate (including dependencies like `tungstenite`); scope it to `kraken_cli` — or just use `-v` — for our diagnostics alone. +
+## Workspaces, Sessions, and Research + +Version 0.4.0 adds a durable paper-research loop: + +1. A **workspace** isolates the account. +2. A **session** records an evaluation window. +3. A **tape** preserves replayable market data. +4. A **lab experiment** seals the hypothesis and pass criteria. + +```bash +# Create an isolated account. +kraken workspace create momentum --capital 10000 --mode paper -o json +export KRAKEN_WORKSPACE=momentum + +# Record in one terminal. +kraken session start --symbols BTC/USD --channels ticker,trade \ + --label baseline --to jsonl -o json + +# Trade in another terminal and preserve the rationale. +kraken paper buy BTC/USD 0.01 -o json +kraken session note --kind buy --symbol BTC/USD \ + --reason "breakout held above the prior high" -o json +kraken session stop -o json + +# Review the closed window. +kraken session show --session baseline -o json +kraken explain pnl --session baseline -o json +kraken workspace report momentum -o json +``` + +For repeatable experiments, freeze the plan before the first session: + +```bash +kraken lab new breakout-v1 \ + --hypothesis "a confirmed breakout produces positive net return" \ + --min-return-pct 0 --max-drawdown-pct 2 --min-fills 1 \ + --session live:5m --session replay:tape:baseline -o json + +kraken lab next breakout-v1 -o json +kraken lab compare breakout-v1 -o json +``` + +The command tables below cover each surface. Machine-readable contracts live in +[agents/tool-catalog.json](agents/tool-catalog.json). + ## Commands -151 commands across 13 groups. For machine-readable parameter schemas, load [agents/tool-catalog.json](agents/tool-catalog.json). +The public request/response catalog contains 174 commands across 15 groups. +Parameter schemas live in +[agents/tool-catalog.json](agents/tool-catalog.json). Long-running streams are +documented below but excluded from the catalog. | Group | Commands | Auth | Description | |-------|----------|------|-------------| -| market | 10 | No | Ticker, orderbook, OHLC, trades, spreads, asset info | +| market | 11 | No | Ticker, orderbook, OHLC, trades, spreads, asset info, tape library | | account | 18 | Yes | Balances, orders, trades, ledgers, positions, exports | | trade | 9 | Yes | Order placement, amendment, cancellation (spot, xStocks, forex) | | funding | 10 | Yes | Deposits, withdrawals, wallet transfers | @@ -376,11 +446,13 @@ kraken balance -o json -v 2>/dev/null | jq . | futures-paper | 17 | No | Futures paper trading simulation with live prices | | futures-ws | 9 | Mixed | Futures WebSocket streaming | | websocket | 15 | Mixed | Spot WebSocket v2 streaming and request/response | -| paper | 10 | No | Spot paper trading simulation with live prices | +| paper | 16 | No | Spot paper trading simulation with live prices, P&L explanation, and the Autoresearch Lab | +| workspace | 15 | No | Strategy workspaces and recorded sessions: isolated accounts, session windows, decision logs, reports, promotion | +| feedback | 1 | No | Product feedback upload to Kraken (local DuckDB mirror on success) | | auth | 4 | No | Credential management | | utility | 2 | No | Interactive setup and REPL shell | -34 commands are marked `dangerous` (orders, withdrawals, transfers, cancel-all, staking). The authoritative list is the `dangerous` field in [agents/tool-catalog.json](agents/tool-catalog.json). +41 commands are marked `dangerous` (orders, withdrawals, transfers, cancel-all, staking, account and margin settings). The authoritative list is the `dangerous` field in [agents/tool-catalog.json](agents/tool-catalog.json).
Full command reference @@ -399,6 +471,7 @@ kraken balance -o json -v 2>/dev/null | jq . | `kraken orderbook-grouped [--depth 10] [--grouping 1]` | Grouped order book | | `kraken trades [--count 1000] [--asset-class tokenized_asset]` | Recent trades | | `kraken spreads [--since TS] [--asset-class tokenized_asset]` | Recent spreads | +| `kraken tape list` | Every recorded tape in the library: ref, backend, state, in use | ### Account Data (Private) @@ -583,6 +656,70 @@ kraken balance -o json -v 2>/dev/null | jq . | `kraken ws batch-add` | Batch add orders via WebSocket (auth) | | `kraken ws batch-cancel` | Batch cancel orders via WebSocket (auth) | +### Market Memory (CLI-only, no auth) + +| Command | Description | +|---------|-------------| +| `kraken record --symbols --channels [--tape NAME] [--to duckdb,jsonl]` | Capture live market data into a local tape; Ctrl-C/SIGTERM finalizes | +| `kraken streamd start --symbols --channels ` | Multi-symbol, multi-channel JSONL stream to stdout | + +Add `--monitor` to `ws`, `streamd`, or `record` for stderr JSONL health lines (latency, trade gaps, staleness, heartbeats). See the [market memory walkthrough](examples/03-market-memory.md). + +### Strategy Workspaces + +| Command | Description | +|---------|-------------| +| `kraken workspace create --capital --mode paper [--currency USD] [--fee-rate R] [--slippage-rate R] [--allow-pairs PAIR...]` | Create and fund an isolated paper account | +| `kraken workspace list` | List paper workspaces plus the real-account `default` workspace | +| `kraken workspace show [NAME]` | Show the durable workspace contract | +| `kraken workspace status [NAME]` | Mark account equity and P&L | +| `kraken workspace balance [NAME]` | Fold balances from the local journal; `default` delegates to Kraken | +| `kraken workspace reset [NAME] [--yes]` | Start a new capital epoch while retaining history | +| `kraken workspace report [NAME]` | Report session, manual-trade, reset, drift, and total account results | +| `kraken workspace promote [NAME]` | Evaluate and return the paper-to-live safety checklist | + +Select a workspace with `--workspace ` or +`KRAKEN_WORKSPACE=`. Spot order verbs simulate inside a paper workspace; +without one they retain real-account behavior and confirmation gates. Named +live workspaces remain blocked until credentials can be scoped safely. + +### Recorded Sessions + +| Command | Description | +|---------|-------------| +| `kraken session start --symbols [--label LABEL] [--experiment NAME]` | Start a live recorded evaluation window | +| `kraken session start --from tape: [--speed N] [--label LABEL] [--experiment NAME]` | Start a replay-backed paper session | +| `kraken session stop` | Finalize the active session and its account window | +| `kraken session list` | List sessions and their derived state | +| `kraken session show [--session latest\|s\|LABEL]` | Inspect the window, provenance, and valuation | +| `kraken session note --kind buy\|sell\|skip\|alert --reason ` | Append the reasoning behind a decision | +| `kraken session decisions [--session REF]` | Read the stable decision-log contract | +| `kraken session state get [--session REF]` | Read an agent's typed resumption cursor | +| `kraken session state set [FIELDS...]` | Replace the active session's resumption cursor | + +`session start`, `record`, `replay`, and `streamd` are CLI-only streams. +Request/response operations around them remain available through MCP. + +### Autoresearch Lab and P&L + +| Command | Description | +|---------|-------------| +| `kraken explain pnl [--session REF]` | Decompose a stopped session's P&L and attribute decisions | +| `kraken lab new [EXPERIMENT OPTIONS...]` | Freeze a hypothesis, criteria, and session plan | +| `kraken lab show ` | Verify and show a sealed experiment | +| `kraken lab next ` | Return the next planned session command | +| `kraken lab score [--session REF] [--experiment NAME]` | Score a stopped session mechanically | +| `kraken lab compare ` | Compare every planned session, including named failures | + +`session state` is operational; decisions, fills, marks, and sealed criteria +remain the evidence used for replay and scoring. + +### Feedback + +| Command | Description | +|---------|-------------| +| `kraken feedback (--input-string STR \| --input-file PATH) [--llm-model MODEL]` | Submit product feedback (`feature-request`, `friction`, or `bug`). Uploads to Kraken over HTTPS; mirrors locally on success. Do not include secrets or PII. See [docs/feedback-ingest.md](docs/feedback-ingest.md). | + ### Utility | Command | Description | @@ -599,34 +736,36 @@ Paper trading provides a safe sandbox for testing trading logic against live Kra ### Spot Paper Trading -The command interface has near-parity with live spot trading. Switch between paper and live by changing the prefix: `kraken paper buy` vs `kraken order buy`. Known differences: paper supports `market` and `limit` order types only; slippage and partial fills are not modeled. +The command interface has near-parity with live spot trading. Switch between paper and live by changing the prefix: `kraken paper buy` vs `kraken order buy`. Paper supports `market` and `limit` order types, a configurable flat slippage model, and no partial fills. **Agent pattern:** ```bash -kraken paper init --balance 10000 -o json +kraken workspace create sandbox --capital 10000 --mode paper -o json +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 -o json kraken paper sell BTCUSD 0.005 --type limit --price 70000 -o json -kraken paper status -o json -kraken paper reset -o json +kraken workspace status -o json +kraken workspace reset --yes -o json ``` **Human pattern:** ```bash -kraken paper init --balance 5000 --currency EUR +kraken workspace create sandbox --capital 5000 --currency EUR --mode paper +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 kraken paper balance kraken paper status kraken paper orders kraken paper history -kraken paper reset +kraken workspace reset ``` | Command | Description | |---------|-------------| -| `kraken paper init [--balance 10000] [--currency USD]` | Initialize paper account | -| `kraken paper reset` | Reconcile pending orders, then reset | +| `kraken paper init [--balance 10000] [--currency USD] [--fee-rate 0.0026] [--slippage-rate 0.001]` | Compatibility alias that creates the selected paper workspace | +| `kraken paper reset [--balance B] [--currency C] [--fee-rate R] [--slippage-rate S] [--yes]` | Compatibility alias that reconciles pending orders, then resets | | `kraken paper balance` | All balances (total, reserved, available) | | `kraken paper buy [--type limit --price P]` | Paper buy (market or limit) | | `kraken paper sell [--type limit --price P]` | Paper sell (market or limit) | @@ -636,7 +775,7 @@ kraken paper reset | `kraken paper history` | Filled trade history | | `kraken paper status` | Portfolio value, unrealized P&L, trade count | -Prices come from the public Kraken Ticker API (no auth needed). A 0.26% taker fee (Kraken Starter tier default) is applied to all fills. Limit orders fill at the limit price when the live market crosses the order price. +Prices come from the public Kraken Ticker API (no auth needed). A 0.26% taker fee (Kraken Starter tier default) is applied to all fills. Optional slippage simulation applies a flat rate to market orders: buys fill at `ask * (1 + slippage_rate)`, sells at `bid * (1 - slippage_rate)`. Default slippage is 0.0 (no simulation). Limit orders fill at the limit price when the live market crosses the order price. All output is labeled `[PAPER]` in table mode and includes `"mode": "paper"` in JSON mode. @@ -694,10 +833,12 @@ Prices come from the public Kraken Futures Ticker API (no auth needed). Taker fe ## Examples +Quick one-liners below. For guided walkthroughs of the bigger features — the playground, paper workspaces, market recording, session replay with P&L explain, the autoresearch lab, and driving all of it with agent skills — see [examples/](examples/README.md). None of them need a Kraken account. + ### Conditional order based on live price ```bash -PRICE=$(kraken ticker BTCUSD -o json | jq -r '.[].c[0]') +PRICE=$(kraken ticker BTCUSD -o json | jq -r '.[].last_price') kraken order buy BTCUSD 0.001 --type limit --price "$PRICE" -o json ``` @@ -736,7 +877,7 @@ Cancels all open orders if the CLI does not reset the timer within 60 seconds. ## Agent Skills -Ships with 50 agent skills covering trading strategies, market analysis, and portfolio management. See the full [Skills Index](skills/INDEX.md). +Ships with 59 agent skills covering trading strategies, market analysis, and portfolio management. See the full [Skills Index](skills/INDEX.md).
Troubleshooting @@ -785,8 +926,8 @@ src/ shell.rs -- Interactive REPL with rustyline telemetry.rs -- Agent client identification, instance tracking, user-agent commands/ -- One module per command group - mod.rs -- Module re-exports - helpers.rs -- Shared utilities (JSON extraction, output formatting) + mod.rs -- The command tree and the Execute contract + executor.rs -- Entry points: run, dispatch, shared render-free executor market.rs -- Public market data account.rs -- Private account data trade.rs -- Spot trading @@ -796,10 +937,17 @@ src/ futures.rs -- Futures trading and market data futures_paper.rs -- Futures paper trading commands futures_ws.rs -- Futures WebSocket v1 streaming - websocket.rs -- Spot WebSocket v2 streaming + ws/ -- Spot WebSocket v2 streaming + streaming.rs -- Inline-rendering commands and the Run contract + streamd.rs -- Multi-channel streaming daemon + record.rs -- Market data capture + subscription.rs -- Shared subscribe planning (streamd, record, session) + session.rs -- Recorded session windows over the account journal + playground.rs -- One-command demo over workspaces and runs paper.rs -- Spot paper trading commands auth.rs -- Credential management commands - utility.rs -- Setup wizard + setup.rs -- Setup wizard + value_types.rs -- Shared CLI value enums (tokens double as wire strings) mcp/ -- Built-in MCP server mod.rs -- Service group parsing, streaming exclusion filters server.rs -- ServerHandler impl, tool dispatch, env-based URL resolution @@ -808,7 +956,7 @@ src/ output/ -- Rendering layer mod.rs -- Format dispatch, CommandOutput type table.rs -- Human-readable table output - json.rs -- JSON output and NDJSON streaming + json.rs -- JSON output and JSONL streaming ```
diff --git a/agents/README.md b/agents/README.md index 20d234f..560c14a 100644 --- a/agents/README.md +++ b/agents/README.md @@ -6,19 +6,19 @@ Machine-readable contracts for building AI agents on top of `kraken-cli`. `tool-catalog.json` is the canonical command contract. -- Coverage: **151 commands** with parameters, types, defaults, and examples +- Coverage: **174 commands** with parameters, types, defaults, and examples - Every command includes: group, description, auth requirement, `dangerous` flag, and parameter schemas -- 34 commands are marked `dangerous: true` (orders, withdrawals, transfers, cancel-all, staking) +- 41 commands are marked `dangerous: true` (orders, withdrawals, transfers, cancel-all, staking, account and margin settings) `error-catalog.json` is the error routing contract. -- 9 error categories with retry/backoff guidance +- 10 error categories with retry/backoff guidance ## Contents | File | Purpose | |------|---------| -| `tool-catalog.json` | Canonical command catalog (151 commands) | +| `tool-catalog.json` | Canonical command catalog (174 commands) | | `error-catalog.json` | Error categories with retry guidance | | `examples/` | Runnable shell examples | diff --git a/agents/error-catalog.json b/agents/error-catalog.json index 10da956..8723567 100644 --- a/agents/error-catalog.json +++ b/agents/error-catalog.json @@ -1,7 +1,12 @@ { - "schema_version": "1.0.0", - "cli_version": "0.1.0", + "schema_version": "1.1.0", + "cli_version": "0.4.0", "description": "Error categories emitted by kraken-cli. Agents should route on the error field, not message strings.", + "usage_errors": { + "exit_code": 2, + "description": "Argument and flag errors caught by the CLI parser (unknown flags, values outside a parameter's documented set) exit 2. When -o json was requested, stdout carries a validation error envelope and stderr is empty; otherwise usage text goes to stderr.", + "agent_action": "Validate argument values against the values arrays in tool-catalog.json before invoking. On exit 2 with -o json, parse the validation envelope on stdout, fix the arguments, and re-run; without -o json, read stderr." + }, "envelope_format": { "fields": { "error": "Error category string (matches a category below)", @@ -13,8 +18,17 @@ "retryable": "Boolean indicating the agent may retry after adjusting behavior", "docs_url": "Link to the relevant Kraken rate limit documentation page" }, - "example": {"error": "auth", "message": "Authentication failed: EAPI:Invalid key"}, - "example_rate_limit": {"error": "rate_limit", "message": "Spot REST API rate limit exceeded (EAPI:Rate limit exceeded).", "suggestion": "Wait 5-15 seconds...", "retryable": true, "docs_url": "https://docs.kraken.com/api/docs/guides/spot-rest-ratelimits/"} + "example": { + "error": "auth", + "message": "Authentication failed: EAPI:Invalid key" + }, + "example_rate_limit": { + "error": "rate_limit", + "message": "Spot REST API rate limit exceeded (EAPI:Rate limit exceeded).", + "suggestion": "Wait 5-15 seconds...", + "retryable": true, + "docs_url": "https://docs.kraken.com/api/docs/guides/spot-rest-ratelimits/" + } }, "categories": [ { @@ -23,8 +37,15 @@ "retryable": false, "agent_action": "Log the error. Fix request parameters. Do not retry the same request.", "backoff_strategy": null, - "common_causes": ["Insufficient funds", "Unknown trading pair", "Order volume below minimum"], - "example_envelope": {"error": "api", "message": "EOrder:Insufficient funds"} + "common_causes": [ + "Insufficient funds", + "Unknown trading pair", + "Order volume below minimum" + ], + "example_envelope": { + "error": "api", + "message": "EOrder:Insufficient funds" + } }, { "category": "auth", @@ -32,26 +53,63 @@ "retryable": false, "agent_action": "Re-authenticate. Verify KRAKEN_API_KEY and KRAKEN_API_SECRET.", "backoff_strategy": null, - "common_causes": ["Invalid API key", "Wrong secret", "Key lacks endpoint permission", "OTP required", "Futures authenticationError", "Futures insufficientPrivileges"], - "example_envelope": {"error": "auth", "message": "Authentication failed: EAPI:Invalid key"} + "common_causes": [ + "Invalid API key", + "Wrong secret", + "Key lacks endpoint permission", + "OTP required", + "Futures authenticationError", + "Futures insufficientPrivileges" + ], + "example_envelope": { + "error": "auth", + "message": "Authentication failed: EAPI:Invalid key" + } }, { "category": "network", "description": "Network-level failure. Request could not reach the Kraken API.", "retryable": true, "agent_action": "Retry with exponential backoff. Check connectivity.", - "backoff_strategy": {"type": "exponential", "initial_delay_ms": 1000, "max_delay_ms": 30000, "max_retries": 5}, - "common_causes": ["DNS failure", "Connection timeout", "TLS handshake failure", "Connection reset"], - "example_envelope": {"error": "network", "message": "Network error: error sending request"} + "backoff_strategy": { + "type": "exponential", + "initial_delay_ms": 1000, + "max_delay_ms": 30000, + "max_retries": 5 + }, + "common_causes": [ + "DNS failure", + "Connection timeout", + "TLS handshake failure", + "Connection reset" + ], + "example_envelope": { + "error": "network", + "message": "Network error: error sending request" + } }, { "category": "rate_limit", "description": "Throttled by Kraken's server-side rate limiter. The CLI returns this error immediately with no internal retry. The error includes a suggestion field with specific guidance and a docs_url linking to the relevant Kraken documentation.", "retryable": true, "agent_action": "Read the suggestion field for tier-specific limits and recommended changes. Read docs_url for full documentation. Decide whether to retry, reduce request frequency, or switch to WebSocket streaming.", - "backoff_strategy": {"type": "agent_controlled", "note": "CLI does not retry rate limit errors. The agent decides retry timing based on the suggestion and docs_url fields."}, - "common_causes": ["Too many requests (Spot REST counter)", "Short-lived orders triggering trading engine penalty", "Futures endpoint cost budget exhausted", "Too many concurrent requests (throttle)"], - "example_envelope": {"error": "rate_limit", "message": "Spot REST API rate limit exceeded (EAPI:Rate limit exceeded).", "suggestion": "Wait 5-15 seconds before retrying. Reduce request frequency. Use WebSocket streaming instead of REST polling for real-time data.", "retryable": true, "docs_url": "https://docs.kraken.com/api/docs/guides/spot-rest-ratelimits/"} + "backoff_strategy": { + "type": "agent_controlled", + "note": "CLI does not retry rate limit errors. The agent decides retry timing based on the suggestion and docs_url fields." + }, + "common_causes": [ + "Too many requests (Spot REST counter)", + "Short-lived orders triggering trading engine penalty", + "Futures endpoint cost budget exhausted", + "Too many concurrent requests (throttle)" + ], + "example_envelope": { + "error": "rate_limit", + "message": "Spot REST API rate limit exceeded (EAPI:Rate limit exceeded).", + "suggestion": "Wait 5-15 seconds before retrying. Reduce request frequency. Use WebSocket streaming instead of REST polling for real-time data.", + "retryable": true, + "docs_url": "https://docs.kraken.com/api/docs/guides/spot-rest-ratelimits/" + } }, { "category": "validation", @@ -59,8 +117,15 @@ "retryable": false, "agent_action": "Fix input parameters. Check types, required fields, allowed values.", "backoff_strategy": null, - "common_causes": ["Missing required argument", "Invalid pair format", "Invalid number"], - "example_envelope": {"error": "validation", "message": "Validation error: Invalid URL: invalid authority"} + "common_causes": [ + "Missing required argument", + "Invalid pair format", + "Invalid number" + ], + "example_envelope": { + "error": "validation", + "message": "Validation error: Invalid URL: invalid authority" + } }, { "category": "config", @@ -68,17 +133,39 @@ "retryable": false, "agent_action": "Check config file. For agents, prefer environment variables.", "backoff_strategy": null, - "common_causes": ["Invalid TOML syntax", "Missing config fields", "Missing or invalid credential values"], - "example_envelope": {"error": "config", "message": "Configuration error: TOML parse error"} + "common_causes": [ + "Invalid TOML syntax", + "Missing config fields", + "Missing or invalid credential values" + ], + "example_envelope": { + "error": "config", + "message": "Configuration error: TOML parse error" + } }, { "category": "websocket", "description": "WebSocket connection or protocol error.", "retryable": true, - "agent_action": "Reconnect with backoff. CLI retries up to 12 times per stream lifecycle with paced exponential backoff and reconnect safety budgeting.", - "backoff_strategy": {"type": "exponential", "initial_delay_ms": 1000, "max_delay_ms": 30000, "max_retries": 12}, - "common_causes": ["Connection dropped", "Server closed connection", "Invalid subscription", "Auth failed on private feed"], - "example_envelope": {"error": "websocket", "message": "WebSocket error: Connection closed unexpectedly"} + "agent_action": "Streams self-heal: the CLI reconnects with paced exponential backoff (capped, jittered) until interrupted, and drops a subscription rejected again on a fresh token. For one-shot order methods the envelope's `verify_first` field is the verdict: `\"verify_first\": true` means the request may have reached the exchange — confirm via open-orders/query-orders before resubmitting. Envelopes without `verify_first` failed before anything was sent and are safe to retry. The message prose restates the verdict ('verify its state before retrying') for older parsers.", + "backoff_strategy": { + "type": "exponential", + "initial_delay_ms": 250, + "max_delay_ms": 30000, + "max_retries": null, + "note": "Streams reconnect until interrupted; there is no terminal retry cap. One-shot ws methods do not retry past their own timeout." + }, + "common_causes": [ + "Connection dropped", + "Server closed without replying (one-shot: verify order state before retrying)", + "Invalid subscription", + "Auth failed on private feed" + ], + "example_envelope": { + "error": "websocket", + "message": "WebSocket error: no reply arrived; the request may have reached the exchange — verify its state before retrying", + "verify_first": true + } }, { "category": "io", @@ -86,8 +173,35 @@ "retryable": false, "agent_action": "Check file paths and permissions.", "backoff_strategy": null, - "common_causes": ["File not readable", "Directory does not exist", "Disk full", "State file corrupted"], - "example_envelope": {"error": "io", "message": "I/O error: Permission denied (os error 13)"} + "common_causes": [ + "File not readable", + "Directory does not exist", + "Disk full", + "State file corrupted" + ], + "example_envelope": { + "error": "io", + "message": "I/O error: Permission denied (os error 13)" + } + }, + { + "category": "timeout", + "description": "A local operation did not complete within its time budget (e.g. `kraken session stop` waited for the recorder to release its recording lock after SIGTERM).", + "retryable": true, + "agent_action": "Re-run the command. If it persists, check `kraken session show` — the recorder may still be flushing a large dataset, or may have ignored the signal.", + "backoff_strategy": { + "type": "agent_controlled", + "note": "The CLI does not auto-retry. The agent decides when to re-run based on whether the underlying operation is still making progress." + }, + "common_causes": [ + "Recorder still checkpointing a large dataset", + "Signalled process ignored SIGTERM", + "Slow disk" + ], + "example_envelope": { + "error": "timeout", + "message": "Timeout: session s3 recorder (pid 4242) was signalled but still holds its recording lock after 10s; it may still be flushing." + } }, { "category": "parse", @@ -95,8 +209,15 @@ "retryable": false, "agent_action": "Log raw response. Possible API change or maintenance. Do not retry.", "backoff_strategy": null, - "common_causes": ["Unexpected response format", "HTML error page during maintenance", "Truncated response"], - "example_envelope": {"error": "parse", "message": "Parse error: expected value at line 1 column 1"} + "common_causes": [ + "Unexpected response format", + "HTML error page during maintenance", + "Truncated response" + ], + "example_envelope": { + "error": "parse", + "message": "Parse error: expected value at line 1 column 1" + } } ] } diff --git a/agents/examples/market-monitor.sh b/agents/examples/market-monitor.sh index 7bedeab..45cf75c 100755 --- a/agents/examples/market-monitor.sh +++ b/agents/examples/market-monitor.sh @@ -21,10 +21,10 @@ echo "" echo "--- Ticker Data ---" for PAIR in $PAIRS; do TICKER=$(kraken ticker "$PAIR" -o json 2>/dev/null) - ASK=$(echo "$TICKER" | jq -r '.[].a[0] // "N/A"') - BID=$(echo "$TICKER" | jq -r '.[].b[0] // "N/A"') - LAST=$(echo "$TICKER" | jq -r '.[].c[0] // "N/A"') - VOL=$(echo "$TICKER" | jq -r '.[].v[1] // "N/A"') + ASK=$(echo "$TICKER" | jq -r '.[].ask_price // "N/A"') + BID=$(echo "$TICKER" | jq -r '.[].bid_price // "N/A"') + LAST=$(echo "$TICKER" | jq -r '.[].last_price // "N/A"') + VOL=$(echo "$TICKER" | jq -r '.[].volume_24h // "N/A"') echo "$PAIR: Ask=$ASK Bid=$BID Last=$LAST Vol24h=$VOL" if command -v bc >/dev/null 2>&1 && [ "$ASK" != "N/A" ] && [ "$BID" != "N/A" ]; then SPREAD=$(echo "scale=4; ($ASK - $BID) / $BID * 100" | bc -l 2>/dev/null || echo "N/A") @@ -38,20 +38,20 @@ echo "" # Order book depth echo "--- BTCUSD Order Book (top 5) ---" BOOK=$(kraken orderbook BTCUSD --count 5 -o json 2>/dev/null) -echo "Asks:" && echo "$BOOK" | jq -r '.[].asks[:5][] | " \(.[0]) @ \(.[1])"' 2>/dev/null || echo " (error)" -echo "Bids:" && echo "$BOOK" | jq -r '.[].bids[:5][] | " \(.[0]) @ \(.[1])"' 2>/dev/null || echo " (error)" +echo "Asks:" && echo "$BOOK" | jq -r '.asks[:5][] | " \(.price) @ \(.volume)"' 2>/dev/null || echo " (error)" +echo "Bids:" && echo "$BOOK" | jq -r '.bids[:5][] | " \(.price) @ \(.volume)"' 2>/dev/null || echo " (error)" echo "" # Recent trades echo "--- BTCUSD Recent Trades (last 5) ---" TRADES=$(kraken trades BTCUSD --count 5 -o json 2>/dev/null) -echo "$TRADES" | jq -r '.[keys[0]][:5][] | "\(.[3]) \(if .[4] == "b" then "BUY " else "SELL" end) \(.[1]) @ \(.[0])"' 2>/dev/null || echo " (error)" +echo "$TRADES" | jq -r '.trades[:5][] | "\(.time) \(if .side == "buy" then "BUY " else "SELL" end) \(.volume) @ \(.price)"' 2>/dev/null || echo " (error)" echo "" # OHLC echo "--- BTCUSD Hourly OHLC (last 3) ---" OHLC=$(kraken ohlc BTCUSD --interval 60 -o json 2>/dev/null) -echo "$OHLC" | jq -r '.[keys[0]][-3:][] | " O:\(.[1]) H:\(.[2]) L:\(.[3]) C:\(.[4]) V:\(.[6])"' 2>/dev/null || echo " (error)" +echo "$OHLC" | jq -r '.candles[-3:][] | " O:\(.open) H:\(.high) L:\(.low) C:\(.close) V:\(.volume)"' 2>/dev/null || echo " (error)" echo "" echo "=== Monitor complete ===" diff --git a/agents/examples/paper-trading-workflow.sh b/agents/examples/paper-trading-workflow.sh index fb431cc..5eb6276 100755 --- a/agents/examples/paper-trading-workflow.sh +++ b/agents/examples/paper-trading-workflow.sh @@ -18,7 +18,7 @@ echo "$INIT" | jq '.' # Step 2: Get current price echo "Step 2: Get current $PAIR price" TICKER=$(kraken ticker "$PAIR" -o json 2>/dev/null) -PRICE=$(echo "$TICKER" | jq -r ".\"$PAIR\".c[0] // .\"$PAIR\".last // \"unknown\"") +PRICE=$(echo "$TICKER" | jq -r '.[].last_price // "unknown"') echo " Current price: \$$PRICE" # Step 3: Market buy diff --git a/agents/examples/portfolio-rebalance.sh b/agents/examples/portfolio-rebalance.sh index ad8013e..6c6b63c 100755 --- a/agents/examples/portfolio-rebalance.sh +++ b/agents/examples/portfolio-rebalance.sh @@ -21,9 +21,9 @@ echo "" # Get prices echo "Step 2: Fetch prices" -BTC_PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0] // "0"') -ETH_PRICE=$(kraken ticker ETHUSD -o json 2>/dev/null | jq -r '.[].c[0] // "0"') -SOL_PRICE=$(kraken ticker SOLUSD -o json 2>/dev/null | jq -r '.[].c[0] // "0"') +BTC_PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price // "0"') +ETH_PRICE=$(kraken ticker ETHUSD -o json 2>/dev/null | jq -r '.[].last_price // "0"') +SOL_PRICE=$(kraken ticker SOLUSD -o json 2>/dev/null | jq -r '.[].last_price // "0"') echo " BTC: \$$BTC_PRICE ETH: \$$ETH_PRICE SOL: \$$SOL_PRICE" echo "" diff --git a/agents/tool-catalog.json b/agents/tool-catalog.json index 5c540e6..765d591 100644 --- a/agents/tool-catalog.json +++ b/agents/tool-catalog.json @@ -1,14 +1,17 @@ { - "schema_version": "1.0.0", - "cli_version": "0.1.0", + "schema_version": "1.1.0", + "cli_version": "0.4.0", "description": "Machine-readable command catalog for kraken-cli.", "global_flags": { - "--output / -o": "Output format: json or table (default table)", - "--verbose / -v": "Print request/response diagnostics to stderr", + "--output / -o": "Output format for the command RESULT on stdout: json or table (default table)", + "--verbose / -v": "Raise diagnostic log level to debug for kraken-cli (logs go to stderr; RUST_LOG overrides)", + "--log-format": "Diagnostic log format on stderr: json (default; JSONL), pretty, or compact. Also settable via KRAKEN_LOG_FORMAT. Independent of --output.", "--api-key": "Override API key", "--api-secret": "Override API secret (use --api-secret-stdin or --api-secret-file instead)", "--api-url": "Override Spot API base URL", - "--futures-url": "Override Futures API base URL" + "--futures-url": "Override Futures API base URL", + "--yes / --force": "Skip confirmation prompts for destructive operations (reset, promote, cancel-all against the real account). Required for non-interactive use of any dangerous command; the MCP server passes it implicitly behind its own acknowledgment gate.", + "--workspace / KRAKEN_WORKSPACE": "Scope the paper account, its sessions, and lab experiments to an isolated workspaces// tree; recorded market data stays global. The workspace must already exist (kraken workspace create). Name must be a single path-safe segment (letters, digits, '.', '-', '_'). Blank, unset, or 'default' addresses the flat layout - the real Kraken account." }, "groups": { "market": "Public market data (no auth required)", @@ -23,7 +26,9 @@ "futures-paper": "Futures paper trading simulation with leverage, margin, and liquidation (no auth required)", "paper": "Spot paper trading simulation (no auth required)", "auth": "Credential management", - "utility": "Setup and interactive shell" + "feedback": "Product feedback upload to Kraken (no auth required)", + "utility": "Setup and interactive shell", + "workspace": "Strategy workspaces: the account that unifies paper and live trading. Created and funded once; no auth for paper accounts." }, "commands": [ { @@ -63,6 +68,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" } @@ -83,9 +89,16 @@ "required": false, "description": "Comma-delimited list of pairs to query" }, + { + "name": "--info", + "type": "string", + "required": false, + "description": "Information level: info, leverage, fees, or margin" + }, { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" } @@ -110,8 +123,9 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, - "description": "Asset class filter (tokenized_asset, forex)" + "description": "Asset class filter" } ], "example": "kraken ticker BTCUSD ETHUSD -o json" @@ -146,6 +160,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" } @@ -176,6 +191,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" } @@ -200,12 +216,14 @@ { "name": "--depth", "type": "integer", + "values": ["10", "25", "100", "250", "1000"], "required": false, "description": "Order book depth" }, { "name": "--grouping", "type": "integer", + "values": ["1", "5", "10", "25", "50", "100", "250", "500", "1000"], "required": false, "description": "Price grouping level" } @@ -242,6 +260,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" } @@ -272,6 +291,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" } @@ -296,6 +316,7 @@ { "name": "--depth", "type": "integer", + "values": ["0", "10", "25", "100", "250", "1000"], "required": false, "description": "Order book depth" } @@ -306,13 +327,14 @@ "name": "balance", "command": "kraken balance", "group": "account", - "description": "Get all cash balances.", + "description": "Get all cash balances. Inside a workspace, shows the workspace account instead of the venue.", "auth_required": true, "dangerous": false, "parameters": [ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier for balance display" } @@ -340,6 +362,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -363,6 +386,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -373,7 +397,7 @@ "name": "open-orders", "command": "kraken open-orders", "group": "account", - "description": "Get currently open orders.", + "description": "Get currently open orders. Inside a workspace, shows the workspace account instead of the venue.", "auth_required": true, "dangerous": false, "parameters": [ @@ -383,6 +407,12 @@ "required": false, "description": "Include trades in output" }, + { + "name": "--userref", + "type": "integer", + "required": false, + "description": "Filter by user reference ID" + }, { "name": "--cl-ord-id", "type": "string", @@ -392,6 +422,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -406,6 +437,12 @@ "auth_required": true, "dangerous": false, "parameters": [ + { + "name": "--trades", + "type": "boolean", + "required": false, + "description": "Include trade details" + }, { "name": "--start", "type": "string", @@ -426,7 +463,7 @@ }, { "name": "--userref", - "type": "string", + "type": "integer", "required": false, "description": "User reference ID filter" }, @@ -439,6 +476,7 @@ { "name": "--closetime", "type": "string", + "values": ["open", "close", "both"], "required": false, "description": "Which time to use: open, close, both" }, @@ -457,6 +495,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -486,7 +525,7 @@ }, { "name": "--userref", - "type": "string", + "type": "integer", "required": false, "description": "User reference ID filter" }, @@ -499,6 +538,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -509,15 +549,16 @@ "name": "trades-history", "command": "kraken trades-history", "group": "account", - "description": "Get trade execution history.", + "description": "Get trade execution history. Inside a workspace, shows the workspace account instead of the venue.", "auth_required": true, "dangerous": false, "parameters": [ { "name": "--type", "type": "string", + "values": ["all", "any position", "closed position", "closing position", "no position"], "required": false, - "description": "Trade type filter" + "description": "Trade type filter. Multi-word values also accept kebab-case aliases (any-position, closed-position, closing-position, no-position)." }, { "name": "--trades", @@ -531,6 +572,18 @@ "required": false, "description": "Result offset for pagination" }, + { + "name": "--start", + "type": "string", + "required": false, + "description": "Start timestamp or transaction ID" + }, + { + "name": "--end", + "type": "string", + "required": false, + "description": "End timestamp or transaction ID" + }, { "name": "--consolidate-taker", "type": "boolean", @@ -552,6 +605,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -582,6 +636,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -617,6 +672,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -646,6 +702,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, @@ -676,6 +733,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -706,6 +764,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -729,6 +788,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -746,6 +806,7 @@ { "name": "--report", "type": "string", + "values": ["trades", "ledgers"], "required": true, "description": "Report type (trades, ledgers)" }, @@ -758,6 +819,7 @@ { "name": "--format", "type": "string", + "values": ["CSV", "TSV"], "required": false, "description": "Export format (CSV, TSV)" }, @@ -793,6 +855,7 @@ { "name": "--report", "type": "string", + "values": ["trades", "ledgers"], "required": true, "description": "Report type (trades, ledgers)" } @@ -813,6 +876,12 @@ "required": true, "positional": true, "description": "Export report ID" + }, + { + "name": "--output-file", + "type": "string", + "required": false, + "description": "Destination path for the downloaded report" } ], "example": "kraken export-retrieve ABCD" @@ -823,7 +892,7 @@ "group": "account", "description": "Delete export report.", "auth_required": true, - "dangerous": false, + "dangerous": true, "parameters": [ { "name": "id", @@ -839,9 +908,10 @@ "name": "order-buy", "command": "kraken order buy ", "group": "trade", - "description": "Place a buy order.", + "description": "Place a buy order. Routed by workspace mode: fills on the paper journal inside a paper workspace, trades the real account otherwise.", "auth_required": true, "dangerous": true, + "paper_safe": true, "parameters": [ { "name": "pair", @@ -912,13 +982,13 @@ "description": "Time in force: GTC, IOC, GTD" }, { - "name": "--starttm", + "name": "--start-time", "type": "string", "required": false, "description": "Scheduled start time" }, { - "name": "--expiretm", + "name": "--expire-time", "type": "string", "required": false, "description": "Expiration time" @@ -954,9 +1024,15 @@ "description": "Validate only, do not submit" }, { - "name": "--userref", + "name": "--reason", "type": "string", "required": false, + "description": "Record why this order was placed in the workspace decision log" + }, + { + "name": "--userref", + "type": "integer", + "required": false, "description": "User reference ID" }, { @@ -984,9 +1060,10 @@ "name": "order-sell", "command": "kraken order sell ", "group": "trade", - "description": "Place a sell order.", + "description": "Place a sell order. Routed by workspace mode: fills on the paper journal inside a paper workspace, trades the real account otherwise.", "auth_required": true, "dangerous": true, + "paper_safe": true, "parameters": [ { "name": "pair", @@ -1057,13 +1134,13 @@ "description": "Time in force: GTC, IOC, GTD" }, { - "name": "--starttm", + "name": "--start-time", "type": "string", "required": false, "description": "Scheduled start time" }, { - "name": "--expiretm", + "name": "--expire-time", "type": "string", "required": false, "description": "Expiration time" @@ -1099,9 +1176,15 @@ "description": "Validate only, do not submit" }, { - "name": "--userref", + "name": "--reason", "type": "string", "required": false, + "description": "Record why this order was placed in the workspace decision log" + }, + { + "name": "--userref", + "type": "integer", + "required": false, "description": "User reference ID" }, { @@ -1211,6 +1294,12 @@ "required": false, "description": "New trigger price" }, + { + "name": "--pair", + "type": "string", + "required": false, + "description": "Trading pair, required for non-crypto assets" + }, { "name": "--post-only", "type": "boolean", @@ -1254,51 +1343,22 @@ "description": "New price" }, { - "name": "--price2", - "type": "string", - "required": false, - "description": "New secondary price" - }, - { - "name": "--oflags", - "type": "string", - "required": false, - "description": "New order flags" - }, - { - "name": "--deadline", - "type": "string", - "required": false, - "description": "RFC3339 deadline" - }, - { - "name": "--cancel-response", - "type": "boolean", - "required": false, - "description": "Include cancel response details" - }, - { - "name": "--validate", - "type": "boolean", - "required": false, - "description": "Validate only, do not submit" - }, - { - "name": "--userref", + "name": "--pair", "type": "string", "required": false, - "description": "New user reference ID" + "description": "Trading pair" } ], - "example": "kraken order edit OABC12-DEFG3-HIJKL4 --price 52000 --validate" + "example": "kraken order edit OABC12-DEFG3-HIJKL4 --price 52000" }, { "name": "order-cancel", "command": "kraken order cancel ", "group": "trade", - "description": "Cancel one or more orders.", + "description": "Cancel one or more orders. Routed by workspace mode: inside a paper workspace, cancels on the workspace journal (single txid only).", "auth_required": true, "dangerous": true, + "paper_safe": true, "parameters": [ { "name": "txid", @@ -1312,6 +1372,12 @@ "type": "string", "required": false, "description": "Client order ID to cancel" + }, + { + "name": "--reason", + "type": "string", + "required": false, + "description": "Record why this order was cancelled in the workspace decision log" } ], "example": "kraken order cancel OABC12-DEFG3-HIJKL4" @@ -1344,9 +1410,10 @@ "name": "order-cancel-all", "command": "kraken order cancel-all", "group": "trade", - "description": "Cancel all open orders.", + "description": "Cancel all open orders. Routed by workspace mode: inside a paper workspace, cancels on the workspace journal.", "auth_required": true, "dangerous": true, + "paper_safe": true, "parameters": [ { "name": "--yes", @@ -1393,12 +1460,14 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -1436,6 +1505,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, @@ -1465,6 +1535,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, @@ -1501,6 +1572,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -1539,6 +1611,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, @@ -1557,6 +1630,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -1580,6 +1654,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, @@ -1592,6 +1667,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -1615,6 +1691,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, @@ -1688,6 +1765,7 @@ { "name": "--asset-class", "type": "string", + "values": ["currency", "tokenized_asset", "forex"], "required": false, "description": "Asset class filter" }, @@ -1724,6 +1802,7 @@ { "name": "--rebase-multiplier", "type": "string", + "values": ["rebased", "base"], "required": false, "description": "Rebase multiplier" } @@ -1954,7 +2033,7 @@ "group": "subaccount", "description": "Create subaccount.", "auth_required": true, - "dangerous": false, + "dangerous": true, "parameters": [ { "name": "username", @@ -2154,6 +2233,7 @@ { "name": "--type", "type": "string", + "values": ["market", "limit", "post", "stop", "take-profit", "ioc", "trailing-stop", "fok"], "required": false, "description": "Order type: market, limit, post, stop, take-profit, ioc, trailing-stop, fok (default: limit)" }, @@ -2172,6 +2252,7 @@ { "name": "--trigger-signal", "type": "string", + "values": ["mark", "index", "last"], "required": false, "description": "Trigger signal type" }, @@ -2196,6 +2277,7 @@ { "name": "--trailing-stop-deviation-unit", "type": "string", + "values": ["percent", "quote_currency"], "required": false, "description": "Trailing stop deviation unit" } @@ -2227,6 +2309,7 @@ { "name": "--type", "type": "string", + "values": ["market", "limit", "post", "stop", "take-profit", "ioc", "trailing-stop", "fok"], "required": false, "description": "Order type: market, limit, post, stop, take-profit, ioc, trailing-stop, fok (default: limit)" }, @@ -2245,6 +2328,7 @@ { "name": "--trigger-signal", "type": "string", + "values": ["mark", "index", "last"], "required": false, "description": "Trigger signal type" }, @@ -2269,6 +2353,7 @@ { "name": "--trailing-stop-deviation-unit", "type": "string", + "values": ["percent", "quote_currency"], "required": false, "description": "Trailing stop deviation unit" } @@ -2462,7 +2547,7 @@ "group": "futures", "description": "Set leverage.", "auth_required": true, - "dangerous": false, + "dangerous": true, "parameters": [ { "name": "symbol", @@ -2647,6 +2732,7 @@ { "name": "--contract-type", "type": "string", + "values": ["futures_inverse", "futures_vanilla", "flexible_futures"], "required": false, "description": "Filter by contract type: futures_inverse, futures_vanilla, flexible_futures" } @@ -2673,7 +2759,7 @@ }, { "name": "futures-order-status", - "command": "kraken futures order-status", + "command": "kraken futures order-status ", "group": "futures", "description": "Get status of specific orders by ID.", "auth_required": true, @@ -2705,7 +2791,7 @@ "group": "futures", "description": "Set PnL preference for a symbol.", "auth_required": true, - "dangerous": false, + "dangerous": true, "parameters": [ { "name": "symbol", @@ -2980,6 +3066,12 @@ "type": "string", "required": false, "description": "Event trigger filter" + }, + { + "name": "--snapshot", + "type": "boolean", + "required": false, + "description": "Request a snapshot when subscribing" } ], "example": "kraken ws ticker BTC/USD ETH/USD" @@ -2998,6 +3090,12 @@ "required": true, "positional": true, "description": "One or more trading pairs" + }, + { + "name": "--snapshot", + "type": "boolean", + "required": false, + "description": "Request a snapshot when subscribing" } ], "example": "kraken ws trades BTC/USD" @@ -3022,6 +3120,12 @@ "type": "integer", "required": false, "description": "Order book depth (10, 25, 100, 500, 1000)" + }, + { + "name": "--snapshot", + "type": "boolean", + "required": false, + "description": "Request a snapshot when subscribing" } ], "example": "kraken ws book BTC/USD --depth 25" @@ -3046,6 +3150,12 @@ "type": "integer", "required": false, "description": "Candle interval in minutes" + }, + { + "name": "--snapshot", + "type": "boolean", + "required": false, + "description": "Request a snapshot when subscribing" } ], "example": "kraken ws ohlc BTC/USD --interval 5" @@ -3064,6 +3174,12 @@ "required": false, "positional": true, "description": "Trading pairs (optional, streams all if omitted)" + }, + { + "name": "--snapshot", + "type": "boolean", + "required": false, + "description": "Request a snapshot when subscribing" } ], "example": "kraken ws instrument BTC/USD" @@ -3075,7 +3191,14 @@ "description": "Stream trade executions.", "auth_required": true, "dangerous": false, - "parameters": [], + "parameters": [ + { "name": "--snap-trades", "type": "boolean", "required": false, "description": "Include the most recent fills in the snapshot" }, + { "name": "--snap-orders", "type": "boolean", "required": false, "description": "Include open orders in the snapshot" }, + { "name": "--order-status", "type": "boolean", "required": false, "description": "Stream all order status transitions" }, + { "name": "--rebased", "type": "boolean", "required": false, "description": "Display tokenized assets in underlying-equity terms" }, + { "name": "--ratecounter", "type": "boolean", "required": false, "description": "Include the rate-limit counter" }, + { "name": "--users", "type": "string", "required": false, "description": "Stream master and subaccount events" } + ], "example": "kraken ws executions" }, { @@ -3085,7 +3208,11 @@ "description": "Stream balance updates.", "auth_required": true, "dangerous": false, - "parameters": [], + "parameters": [ + { "name": "--snapshot", "type": "boolean", "required": false, "description": "Request a snapshot when subscribing" }, + { "name": "--rebased", "type": "boolean", "required": false, "description": "Display tokenized assets in underlying-equity terms" }, + { "name": "--users", "type": "string", "required": false, "description": "Stream master and subaccount events" } + ], "example": "kraken ws balances" }, { @@ -3102,6 +3229,18 @@ "required": true, "positional": true, "description": "One or more trading pairs" + }, + { + "name": "--depth", + "type": "integer", + "required": false, + "description": "Order book depth: 10, 100, or 1000" + }, + { + "name": "--snapshot", + "type": "boolean", + "required": false, + "description": "Request a snapshot when subscribing" } ], "example": "kraken ws level3 BTC/USD" @@ -3478,49 +3617,6 @@ ], "example": "kraken ws batch-cancel --orders OABC12 ODEF34" }, - { - "name": "paper-init", - "command": "kraken paper init", - "group": "paper", - "description": "Initialize paper trading account.", - "auth_required": false, - "dangerous": false, - "parameters": [ - { - "name": "--balance", - "type": "number", - "required": false, - "description": "Starting balance (default 10000)" - }, - { - "name": "--currency", - "type": "string", - "required": false, - "description": "Base currency (default USD)" - } - ], - "example": "kraken paper init --balance 50000 --currency USD" - }, - { - "name": "paper-reset", - "command": "kraken paper reset", - "group": "paper", - "description": "Reset paper account.", - "auth_required": false, - "dangerous": false, - "parameters": [], - "example": "kraken paper reset" - }, - { - "name": "paper-balance", - "command": "kraken paper balance", - "group": "paper", - "description": "Show paper balances.", - "auth_required": false, - "dangerous": false, - "parameters": [], - "example": "kraken paper balance -o json" - }, { "name": "paper-buy", "command": "kraken paper buy ", @@ -3554,6 +3650,12 @@ "type": "string", "required": false, "description": "Limit price" + }, + { + "name": "--reason", + "type": "string", + "required": false, + "description": "Record why this simulated order was placed" } ], "example": "kraken paper buy BTCUSD 0.01 --type limit --price 50000" @@ -3591,6 +3693,12 @@ "type": "string", "required": false, "description": "Limit price" + }, + { + "name": "--reason", + "type": "string", + "required": false, + "description": "Record why this simulated order was placed" } ], "example": "kraken paper sell BTCUSD 0.01 --type market" @@ -3644,14 +3752,157 @@ "example": "kraken paper history -o json" }, { - "name": "paper-status", - "command": "kraken paper status", + "name": "explain-pnl", + "command": "kraken explain pnl", "group": "paper", - "description": "Paper portfolio status.", + "description": "Decompose a stopped session's P&L into plain-terms components (price movement, fees, spread, slippage, residual), with missed-fill counterfactuals and per-origin attribution. Requires 'kraken session stop' to have run first.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "--session", "type": "string", "required": false, "description": "Which session: latest (default), an ordinal (s3), or a label" } + ], + "example": "kraken explain pnl --session latest -o json" + }, + { + "name": "lab-score", + "command": "kraken lab score", + "group": "paper", + "description": "Score a stopped session into an honest scorecard: a first-class source block naming the session's provenance (replay tape + speed, or live + symbols), the explain-pnl waterfall carried verbatim, plus mechanical metrics (return, max drawdown, fees, friction, turnover, hit rate, profit factor, slippage sensitivity, trade counts) over the session's own recorded marks. Hit rate and profit factor grade closed FIFO round trips only and read null when no lot closed. Requires 'kraken session stop' to have run first. With --experiment, judges the scorecard against a frozen experiment's criteria and adds a mechanical pass/fail verdict.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "--session", + "type": "string", + "required": false, + "description": "Which session: latest (default), an ordinal (s3), or a label" + }, + { + "name": "--experiment", + "type": "string", + "required": false, + "description": "Judge against this frozen experiment's criteria (hash-verified)" + } + ], + "example": "kraken lab score --session latest --experiment momentum-1 -o json" + }, + { + "name": "lab-new", + "command": "kraken lab new ", + "group": "paper", + "description": "Pre-register an experiment: freeze a hypothesis and its mechanical success criteria into a hash-sealed spec before any session. The seal makes post-hoc goalpost moves detectable; a frozen spec is immutable.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "description": "Experiment name (path-safe segment; becomes session labels)" + }, + { + "name": "--hypothesis", + "type": "string", + "required": true, + "description": "The falsifiable claim being tested" + }, + { + "name": "--strategy", + "type": "string", + "required": false, + "description": "The strategy under test, if one is named" + }, + { + "name": "--min-return-pct", + "type": "number", + "required": false, + "description": "Success: return of at least this percent" + }, + { + "name": "--max-drawdown-pct", + "type": "number", + "required": false, + "description": "Success: worst drop relative to its peak (max percentage drawdown) of at most this percent" + }, + { + "name": "--min-fills", + "type": "integer", + "required": false, + "description": "Success: at least this many fills" + }, + { + "name": "--session", + "type": "string[]", + "required": false, + "description": "Pre-register an ordered session plan entry (repeatable): live: or replay:" + } + ], + "example": "kraken lab new momentum-1 --hypothesis 'buying strength beats the tape' --min-return-pct 1 --min-fills 2 -o json" + }, + { + "name": "lab-show", + "command": "kraken lab show ", + "group": "paper", + "description": "Show a frozen experiment's sealed spec, verifying its sha256 seal; a spec modified after freezing reads as a parse error, never as a quietly moved goalpost.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "description": "Experiment name" + } + ], + "example": "kraken lab show momentum-1 -o json" + }, + { + "name": "lab-compare", + "command": "kraken lab compare ", + "group": "paper", + "description": "Compare every session of a frozen experiment side by side: each session is re-scored from its recorded window and judged against the sealed criteria. Per-session verdicts are the only cross-session comparable numbers (sessions cover different market windows); a session that cannot be scored appears as a named error column, never an omission. pass_count counts only mechanically scored passing verdicts.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "description": "Experiment name" + } + ], + "example": "kraken lab compare momentum-1 -o json" + }, + { + "name": "lab-next", + "command": "kraken lab next ", + "group": "paper", + "description": "What a frozen experiment's sealed session plan says to do now: the exact 'kraken session start' command for the next planned session (live or replay), the session still in progress, or plan_complete pointing at 'kraken lab compare'. Progress is re-derived from the sessions tree on every call; aborted sessions keep their ordinals and never count toward the plan. Refuses to hand out a replay session whose tape no longer matches its sealed content hash.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "description": "Experiment name" + } + ], + "example": "kraken lab next dca-robust-1 -o json" + }, + { + "name": "tape-list", + "command": "kraken tape list", + "group": "market", + "description": "List every replayable tape: the global shared library (tape:) plus the active scope's session tapes (session:s), with backend, capture state, symbols, channels, and whether a writer holds it now.", "auth_required": false, "dangerous": false, "parameters": [], - "example": "kraken paper status" + "example": "kraken tape list -o json" }, { "name": "futures-paper-init", @@ -3712,14 +3963,14 @@ { "name": "SYMBOL", "type": "string", "required": true, "positional": true, "description": "Futures symbol (e.g. PF_XBTUSD)" }, { "name": "SIZE", "type": "number", "required": true, "positional": true, "description": "Contract size" }, { "name": "--leverage", "type": "number", "required": false, "description": "Leverage multiplier" }, - { "name": "--type", "type": "string", "required": false, "description": "Order type: market, limit, post, stop, take-profit, ioc, trailing-stop, fok (default: limit)" }, + { "name": "--type", "type": "string", "values": ["market", "limit", "post", "stop", "take-profit", "ioc", "trailing-stop", "fok"], "required": false, "description": "Order type: market, limit, post, stop, take-profit, ioc, trailing-stop, fok (default: limit)" }, { "name": "--price", "type": "number", "required": false, "description": "Limit price (required for limit, post, ioc, fok)" }, { "name": "--stop-price", "type": "number", "required": false, "description": "Trigger price (for stop, take-profit, trailing-stop)" }, - { "name": "--trigger-signal", "type": "string", "required": false, "description": "Trigger signal: mark, index, last" }, + { "name": "--trigger-signal", "type": "string", "values": ["mark", "index", "last"], "required": false, "description": "Trigger signal: mark, index, last" }, { "name": "--client-order-id", "type": "string", "required": false, "description": "Correlation ID" }, { "name": "--reduce-only", "type": "boolean", "required": false, "description": "Only reduce existing position" }, { "name": "--trailing-stop-max-deviation", "type": "number", "required": false, "description": "Trailing stop deviation value" }, - { "name": "--trailing-stop-deviation-unit", "type": "string", "required": false, "description": "percent or quote_currency" } + { "name": "--trailing-stop-deviation-unit", "type": "string", "values": ["percent", "quote_currency"], "required": false, "description": "percent or quote_currency" } ], "example": "kraken futures paper buy PF_XBTUSD 1 --leverage 10 --type market" }, @@ -3734,14 +3985,14 @@ { "name": "SYMBOL", "type": "string", "required": true, "positional": true, "description": "Futures symbol (e.g. PF_XBTUSD)" }, { "name": "SIZE", "type": "number", "required": true, "positional": true, "description": "Contract size" }, { "name": "--leverage", "type": "number", "required": false, "description": "Leverage multiplier" }, - { "name": "--type", "type": "string", "required": false, "description": "Order type: market, limit, post, stop, take-profit, ioc, trailing-stop, fok (default: limit)" }, + { "name": "--type", "type": "string", "values": ["market", "limit", "post", "stop", "take-profit", "ioc", "trailing-stop", "fok"], "required": false, "description": "Order type: market, limit, post, stop, take-profit, ioc, trailing-stop, fok (default: limit)" }, { "name": "--price", "type": "number", "required": false, "description": "Limit price (required for limit, post, ioc, fok)" }, { "name": "--stop-price", "type": "number", "required": false, "description": "Trigger price (for stop, take-profit, trailing-stop)" }, - { "name": "--trigger-signal", "type": "string", "required": false, "description": "Trigger signal: mark, index, last" }, + { "name": "--trigger-signal", "type": "string", "values": ["mark", "index", "last"], "required": false, "description": "Trigger signal: mark, index, last" }, { "name": "--client-order-id", "type": "string", "required": false, "description": "Correlation ID" }, { "name": "--reduce-only", "type": "boolean", "required": false, "description": "Only reduce existing position" }, { "name": "--trailing-stop-max-deviation", "type": "number", "required": false, "description": "Trailing stop deviation value" }, - { "name": "--trailing-stop-deviation-unit", "type": "string", "required": false, "description": "percent or quote_currency" } + { "name": "--trailing-stop-deviation-unit", "type": "string", "values": ["percent", "quote_currency"], "required": false, "description": "percent or quote_currency" } ], "example": "kraken futures paper sell PF_ETHUSD 5 --leverage 20 --type market" }, @@ -3836,9 +4087,7 @@ "description": "Show futures paper fill history.", "auth_required": false, "dangerous": false, - "parameters": [ - { "name": "--since", "type": "string", "required": false, "description": "Filter fills since timestamp" } - ], + "parameters": [], "example": "kraken futures paper fills" }, { @@ -3883,8 +4132,15 @@ "description": "Set API credentials.", "auth_required": false, "dangerous": false, - "parameters": [], - "example": "kraken auth set" + "parameters": [ + { "name": "--api-key", "type": "string", "required": true, "description": "Kraken API key" }, + { "name": "--api-secret", "type": "string", "required": false, "description": "Kraken API secret; prefer a protected stdin or file source" }, + { "name": "--futures-api-key", "type": "string", "required": false, "description": "Kraken Futures API key" }, + { "name": "--futures-api-secret", "type": "string", "required": false, "description": "Kraken Futures API secret; prefer a protected stdin or file source" }, + { "name": "--futures-api-secret-stdin", "type": "boolean", "required": false, "description": "Read the Futures API secret from stdin" }, + { "name": "--futures-api-secret-file", "type": "string", "required": false, "description": "Read the Futures API secret from a file" } + ], + "example": "kraken auth set --api-key --api-secret-stdin" }, { "name": "auth-show", @@ -3916,6 +4172,42 @@ "parameters": [], "example": "kraken auth reset" }, + { + "name": "feedback", + "command": "kraken feedback ", + "group": "feedback", + "description": "Submit product feedback (feature-request, friction, or bug) to the local DuckDB store. Body via --input-string or --input-file (max 8192 UTF-8 bytes). Soft body structure recommendations are in `kraken feedback --help`.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "TYPE", + "type": "string", + "values": ["feature-request", "friction", "bug"], + "required": true, + "description": "Feedback category" + }, + { + "name": "--input-string", + "type": "string", + "required": false, + "description": "Inline feedback body (mutually exclusive with --input-file; max 8192 bytes)" + }, + { + "name": "--input-file", + "type": "string", + "required": false, + "description": "Path to UTF-8 file with feedback body (mutually exclusive with --input-string)" + }, + { + "name": "--llm-model", + "type": "string", + "required": false, + "description": "Optional LLM / agent model identity" + } + ], + "example": "kraken feedback bug --input-string \"order buy rejected with unclear error\" --llm-model claude-opus-4 -o json" + }, { "name": "setup", "command": "kraken setup", @@ -3935,6 +4227,347 @@ "dangerous": false, "parameters": [], "example": "kraken shell" + }, + { + "name": "workspace-create", + "command": "kraken workspace create ", + "group": "workspace", + "description": "Create AND fund a workspace account in one step: writes the contract (workspace.json) and seeds the journal with the capital epoch. Capital is fixed at create; 'workspace reset' returns to it. Refuses the name 'default' (the real Kraken account) and --mode live while workspace-scoped credentials are unavailable. --mode is required.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "description": "Workspace name (one path-safe segment)" + }, + { + "name": "--capital", + "type": "decimal", + "required": true, + "description": "Starting capital, denominated in --currency" + }, + { + "name": "--currency", + "type": "string", + "required": false, + "description": "Capital currency (default: USD)" + }, + { + "name": "--mode", + "type": "string", + "required": true, + "description": "Execution mode: paper or live (live refused until scoped credentials land). Required - an account's mode is never implied." + }, + { + "name": "--fee-rate", + "type": "decimal", + "required": false, + "description": "Fee rate as a decimal (default: 0.0026 = 0.26%)" + }, + { + "name": "--slippage-rate", + "type": "decimal", + "required": false, + "description": "Slippage rate as a decimal (default: 0.0)" + }, + { + "name": "--allow-pairs", + "type": "string[]", + "required": false, + "description": "Restrict trading to these pairs (comma-separated or repeatable); omit for unrestricted, pass an empty value to deny all" + } + ], + "example": "kraken workspace create btc-momentum --capital 10000 --mode paper -o json" + }, + { + "name": "workspace-list", + "command": "kraken workspace list", + "group": "workspace", + "description": "List every account; the synthesized 'default' row is the real Kraken account (venue-held capital).", + "auth_required": false, + "dangerous": false, + "parameters": [], + "example": "kraken workspace list -o json" + }, + { + "name": "workspace-show", + "command": "kraken workspace show [NAME]", + "group": "workspace", + "description": "Show a workspace's contract: capital, currency, mode, fee and slippage rates.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": false, + "positional": true, + "description": "Workspace name (defaults to the active workspace, else 'default')" + } + ], + "example": "kraken workspace show btc-momentum -o json" + }, + { + "name": "workspace-status", + "command": "kraken workspace status [NAME]", + "group": "workspace", + "description": "Account summary with marked equity and P&L. Paper workspaces mark off live prices (no auth); 'default' reports the venue trade balance (auth required).", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": false, + "positional": true, + "description": "Workspace name (defaults to the active workspace, else 'default')" + } + ], + "example": "kraken workspace status btc-momentum -o json" + }, + { + "name": "workspace-balance", + "command": "kraken workspace balance [NAME]", + "group": "workspace", + "description": "Account balances folded from the workspace journal (offline). For 'default' delegates to the venue Balance call (auth required).", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": false, + "positional": true, + "description": "Workspace name (defaults to the active workspace, else 'default')" + } + ], + "example": "kraken workspace balance btc-momentum -o json" + }, + { + "name": "workspace-reset", + "command": "kraken workspace reset [NAME]", + "group": "workspace", + "description": "Return a paper workspace to its starting capital: a new Reset epoch on the journal. History survives, but open orders and balances are superseded - destructive, requires confirmation.", + "auth_required": false, + "dangerous": true, + "parameters": [ + { "name": "name", "type": "string", "required": false, "positional": true, "description": "Workspace name (defaults to the active workspace)" }, + { "name": "--capital", "type": "string", "required": false, "description": "Re-parameterize on reset: new starting capital, updating the contract (alias: --balance). Omit to return to the created capital" }, + { "name": "--currency", "type": "string", "required": false, "description": "New capital currency" }, + { "name": "--fee-rate", "type": "string", "required": false, "description": "New fee rate as a decimal" }, + { "name": "--slippage-rate", "type": "string", "required": false, "description": "New slippage rate as a decimal (alias: --slippage)" } + ], + "example": "kraken workspace reset btc-momentum -o json" + }, + { + "name": "workspace-promote", + "command": "kraken workspace promote [NAME]", + "group": "workspace", + "description": "Evaluate the paper-to-live promotion checklist for a workspace: sealed experiment, at least 2 passing sessions with a passing live session among them, manual trades disclosed, scoped credentials. Promotion remains refused while workspace-scoped credentials are unavailable; the validation envelope carries the full checklist in a 'checklist' field.", + "auth_required": false, + "dangerous": true, + "parameters": [ + { "name": "name", "type": "string", "required": false, "positional": true, "description": "Workspace name (defaults to the active workspace)" } + ], + "example": "kraken workspace promote btc-momentum -o json" + }, + { + "name": "workspace-report", + "command": "kraken workspace report [NAME]", + "group": "workspace", + "description": "Account-first report: one row per session with opening/closing equity, P&L, fills, and verdict taken verbatim from the scorecard anchor (never recomputed), plus the manual segment, reset epochs, venue drift, and digit-exact totals. A session that cannot be scored is a named error row, never an omission; a still-recording session is disclosed and excluded from every sum.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "name", "type": "string", "required": false, "positional": true, "description": "Workspace name (defaults to the active workspace)" } + ], + "example": "kraken workspace report btc-momentum -o json" + }, + { + "name": "session-stop", + "command": "kraken session stop", + "group": "workspace", + "description": "Stop a session's recorder and close its window. Against the real account, first reconciles the journal from venue history (fills the mirror missed enter under their venue txids, idempotently) and adopts the venue balance snapshot. Idempotent on an already-stopped session.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "--session", "type": "string", "required": false, "description": "Which session: latest (default), an ordinal (s3), or a label" } + ], + "example": "kraken session stop -o json" + }, + { + "name": "session-list", + "command": "kraken session list", + "group": "workspace", + "description": "List the scope's sessions with derived status (recording, stopped, aborted, damaged), label, experiment, and window times. Offline: no prices fetched.", + "auth_required": false, + "dangerous": false, + "parameters": [], + "example": "kraken session list -o json" + }, + { + "name": "session-show", + "command": "kraken session show", + "group": "workspace", + "description": "Show one session: its window (opening equity anchor, start/end), derived status, and a valuation — the stored summary for a closed window, a live valuation for an open one.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "--session", "type": "string", "required": false, "description": "Which session: latest (default), an ordinal (s3), or a label" } + ], + "example": "kraken session show -o json" + }, + { + "name": "session-note", + "command": "kraken session note", + "group": "workspace", + "description": "Append a decision (the why) to the ACTIVE session's decision log — the log explain-pnl joins to fills. Refuses when no session is recording.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "--kind", "type": "string", "required": true, "values": ["buy", "sell", "skip", "alert"], "description": "What kind of decision this records" }, + { "name": "--symbol", "type": "string", "required": false, "description": "The trading pair the decision concerns" }, + { "name": "--reason", "type": "string", "required": true, "description": "Free-text rationale for the decision" }, + { "name": "--order-id", "type": "string", "required": false, "description": "An order this decision references" } + ], + "example": "kraken session note --kind skip --reason \"spread too wide\" -o json" + }, + { + "name": "session-decisions", + "command": "kraken session decisions", + "group": "workspace", + "description": "Read a session's decision log verbatim, with a count — the counter a looping agent gates its rounds on without knowing the on-disk layout. Offline; an empty log is count 0, never an error.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "--session", "type": "string", "required": false, "description": "Which session: latest (default), an ordinal (s3), or a label" } + ], + "example": "kraken session decisions --session latest -o json" + }, + { + "name": "session-state-get", + "command": "kraken session state get", + "group": "workspace", + "description": "Read a session's typed state cell — the durable cursor a long-running agent loop grounds on each firing (round, legs_done, zone, last_action_at, note). A resumption aid, not history: deliberately outside the replay/scoring evidence, so it never appears in replay or explain pnl. An unset cell reads as an empty cursor with a null updated_at, never an error.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "--session", "type": "string", "required": false, "description": "Which session: latest (default), an ordinal (s3), or a label" } + ], + "example": "kraken session state get -o json" + }, + { + "name": "session-state-set", + "command": "kraken session state set", + "group": "workspace", + "description": "Replace the ACTIVE session's state cell with the given typed fields (unset flags clear their cases). Refuses when no session is recording; a typo'd field or zone refuses instead of steering the loop silently. This is a resumption cursor, not history — it is deliberately outside the replay/scoring evidence, so a set never appears in replay or explain pnl; record a transition worth a post-mortem with 'kraken session note'.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "--round", "type": "integer", "required": false, "description": "The last COMPLETED round; a firing acts as round+1 and writes it back" }, + { "name": "--legs-done", "type": "string", "required": false, "description": "Pairs already executed in the round in flight (comma-separated)" }, + { "name": "--zone", "type": "string", "required": false, "values": ["above", "within", "below"], "description": "Price zone for crossing-based strategies" }, + { "name": "--last-action-at", "type": "string", "required": false, "description": "Instant of the loop's last order (RFC 3339)" }, + { "name": "--note", "type": "string", "required": false, "description": "Free-text context for a human reading the artifact" } + ], + "example": "kraken session state set --round 3 --zone above -o json" + }, + { + "name": "paper-init", + "command": "kraken paper init [NAME]", + "group": "paper", + "description": "Create AND fund a paper workspace - 'kraken workspace create --mode paper' under another name (one implementation). Takes the workspace name positionally or from KRAKEN_WORKSPACE; with neither it funds the global paper account ('global') and says so on stderr. --balance is an alias for --capital.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": false, + "positional": true, + "description": "Workspace name to create (defaults to the active workspace, else the global paper account)" + }, + { + "name": "--capital", + "type": "decimal", + "required": false, + "description": "Starting capital (default: 10000; alias: --balance)" + }, + { + "name": "--currency", + "type": "string", + "required": false, + "description": "Capital currency (default: USD)" + }, + { + "name": "--fee-rate", + "type": "decimal", + "required": false, + "description": "Fee rate as a decimal (default: 0.0026 = 0.26%)" + }, + { + "name": "--slippage-rate", + "type": "decimal", + "required": false, + "description": "Slippage rate as a decimal (default: 0.0)" + } + ], + "example": "kraken paper init sandbox --capital 10000 -o json" + }, + { + "name": "paper-reset", + "command": "kraken paper reset [NAME]", + "group": "paper", + "description": "Return the paper workspace to its starting capital - 'kraken workspace reset' under another name (one implementation). Destructive, requires confirmation.", + "auth_required": false, + "dangerous": true, + "parameters": [ + { "name": "name", "type": "string", "required": false, "positional": true, "description": "Workspace name (defaults to the active workspace)" }, + { "name": "--capital", "type": "string", "required": false, "description": "Re-parameterize on reset: new starting capital, updating the contract (alias: --balance). Omit to return to the created capital" }, + { "name": "--currency", "type": "string", "required": false, "description": "New capital currency" }, + { "name": "--fee-rate", "type": "string", "required": false, "description": "New fee rate as a decimal" }, + { "name": "--slippage-rate", "type": "string", "required": false, "description": "New slippage rate as a decimal (alias: --slippage)" } + ], + "example": "kraken paper reset sandbox -o json" + }, + { + "name": "paper-balance", + "command": "kraken paper balance [NAME]", + "group": "paper", + "description": "Workspace balances folded from the journal - 'kraken workspace balance' under another name (one implementation). Always addresses a paper workspace, never the real account.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": false, + "positional": true, + "description": "Workspace name (defaults to the active workspace, else the global paper account)" + } + ], + "example": "kraken paper balance sandbox -o json" + }, + { + "name": "paper-status", + "command": "kraken paper status [NAME]", + "group": "paper", + "description": "Workspace summary with marked equity and P&L - 'kraken workspace status' under another name (one implementation). Always addresses a paper workspace, never the real account.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { + "name": "name", + "type": "string", + "required": false, + "positional": true, + "description": "Workspace name (defaults to the active workspace, else the global paper account)" + } + ], + "example": "kraken paper status sandbox -o json" } ] } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..7bada54 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true diff --git a/crates/kraken-core/Cargo.toml b/crates/kraken-core/Cargo.toml new file mode 100644 index 0000000..20915b6 --- /dev/null +++ b/crates/kraken-core/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "kraken-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Typed wire model and error taxonomy for the Kraken Spot WebSocket v2 protocol" +license.workspace = true +repository.workspace = true +publish = false + +[features] +# Derive `clap::ValueEnum` on the CLI-facing enums (SubscribableChannel, EventTrigger) +# so a binary can accept them as arguments; off by default to keep layer 0 CLI-free. +clap = ["dep:clap"] + +# Layer 0: pure data — parsing, validation, serialization. No I/O, no tokio, no +# transport crates; that boundary is the point of the crate. +[dependencies] +clap = { workspace = true, optional = true } +rust_decimal = { workspace = true } +serde = { workspace = true } +# `arbitrary_precision` must stay off, here and anywhere in the workspace tree: +# `ChannelMessage`'s flattened envelope buffers payloads through serde's `Content` +# type, which cannot carry that feature's number representation — every WS frame +# would fail to decode. The captured-payload round-trip tests are the tripwire. +serde_json = { workspace = true } +serde_with = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } + +[lints] +workspace = true diff --git a/crates/kraken-core/src/endpoint.rs b/crates/kraken-core/src/endpoint.rs new file mode 100644 index 0000000..4448767 --- /dev/null +++ b/crates/kraken-core/src/endpoint.rs @@ -0,0 +1,81 @@ +//! Which socket a channel or method rides, and how that resolves to a URL. +//! +//! [`Endpoint`] is the pure classifier — public market data, authenticated +//! account/trading, or the Level 3 order book — and the single source of truth for +//! whether a token is required. Mapping an endpoint to a concrete URL is the only +//! transport concern here: [`Endpoint::default_url`] gives the production host, and +//! the client's per-endpoint overrides (used for beta/UAT) take precedence. + +use strum::Display; + +/// Production Kraken v2 WebSocket hosts, used when no override is configured. +const DEFAULT_PUBLIC_URL: &str = "wss://ws.kraken.com/v2"; +const DEFAULT_AUTH_URL: &str = "wss://ws-auth.kraken.com/v2"; +const DEFAULT_L3_URL: &str = "wss://ws-l3.kraken.com/v2"; + +/// Which socket endpoint a channel or method is served on. Doubles as the "which +/// socket" identity reported on lifecycle events, and decides whether an auth token +/// is required (every endpoint except [`Endpoint::Public`] needs one). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display)] +#[strum(serialize_all = "lowercase")] +pub enum Endpoint { + /// Public market data — no credentials. + Public, + /// Authenticated account/trading traffic. + Auth, + /// Level 3 order book — authenticated, on its own host. + L3, +} + +impl Endpoint { + /// Whether this endpoint requires an auth token. Only the public market-data + /// socket does not. + pub const fn requires_token(self) -> bool { + !matches!(self, Endpoint::Public) + } + + /// Whether frames on this endpoint carry the account's own data (balances, order + /// flow), so raw payloads must never be logged — even debug-gated. Narrower than + /// [`requires_token`](Self::requires_token): L3 authenticates, but its frames are + /// venue-wide book data, not the account's. + pub const fn carries_account_data(self) -> bool { + matches!(self, Endpoint::Auth) + } + + /// The production host for this endpoint, used when the client has no override. + pub const fn default_url(self) -> &'static str { + match self { + Endpoint::Public => DEFAULT_PUBLIC_URL, + Endpoint::Auth => DEFAULT_AUTH_URL, + Endpoint::L3 => DEFAULT_L3_URL, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_public_is_tokenless() { + assert!(!Endpoint::Public.requires_token()); + assert!(Endpoint::Auth.requires_token()); + assert!(Endpoint::L3.requires_token()); + } + + /// The raw-payload logging scrub keys on this: a new private/account endpoint must + /// classify itself here or its frames may be logged raw. + #[test] + fn only_auth_frames_carry_account_data() { + assert!(Endpoint::Auth.carries_account_data()); + assert!(!Endpoint::Public.carries_account_data()); + assert!(!Endpoint::L3.carries_account_data()); + } + + #[test] + fn default_urls_are_the_production_hosts() { + assert_eq!(Endpoint::Public.default_url(), "wss://ws.kraken.com/v2"); + assert_eq!(Endpoint::Auth.default_url(), "wss://ws-auth.kraken.com/v2"); + assert_eq!(Endpoint::L3.default_url(), "wss://ws-l3.kraken.com/v2"); + } +} diff --git a/crates/kraken-core/src/error.rs b/crates/kraken-core/src/error.rs new file mode 100644 index 0000000..d6a4bd6 --- /dev/null +++ b/crates/kraken-core/src/error.rs @@ -0,0 +1,174 @@ +//! The protocol layer's error type. +//! +//! Every layer up to the application boundary speaks [`Error`]; the binary maps it +//! into its own edge taxonomy (envelope categories, exit codes) there. + +use crate::endpoint::Endpoint; + +/// A protocol-layer result. +pub type Result = std::result::Result; + +/// Everything that can go wrong inside the WebSocket client. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The endpoint's session ended without a terminal event. On the request path this + /// always surfaces wrapped in [`ReplyLost`](Self::ReplyLost) — it is a cause, not a verdict. + #[error("{0} connection closed")] + ConnectionClosed(Endpoint), + + /// The dial budget elapsed before a request could be sent — named for the phase + /// that timed out, not the missing response. Retry-safe: nothing reached the wire. + #[error("timed out before the request was sent; nothing reached the exchange")] + DialTimeout, + + /// The frame was written but no reply ever arrived — the reply window elapsed, or + /// the server closed without answering. Distinct from + /// [`DialTimeout`](Self::DialTimeout), which covers the phases *before* the send: here the + /// request may have reached the exchange and executed, so a caller must verify its + /// state before retrying — a blind retry can double-submit an order. + #[error( + "no reply arrived; the request may have reached the exchange — verify \ + its state before retrying" + )] + Unacknowledged, + + /// The session failed *after* the request frame was sent; any reply was lost with + /// it. Carries the underlying failure, with the same verify-before-retrying + /// semantics as [`Unacknowledged`](Self::Unacknowledged). + #[error( + "the session failed after the request was sent; the request may have reached \ + the exchange — verify its state before retrying" + )] + ReplyLost(#[source] Box), + + /// A private endpoint was used without a configured token source. + #[error("credentials required for the {0} endpoint")] + NoTokenSource(Endpoint), + + /// Minting an auth token was rejected (a credentials problem); the app-layer wiring + /// maps a REST auth failure into this. + #[error("token fetch failed: {0}")] + TokenRejected(String), + + /// Minting an auth token failed for a non-credentials reason (rate limit, network, + /// IO, parse). Carries the application's original error opaquely — this crate is a + /// pipe for it, not a classifier — so the failure keeps its full class through to + /// the caller's envelope: a retryable network blip during a mint must not surface + /// as a non-retryable `{"error":"auth"}` telling the user to re-key. + #[error("token mint failed")] + MintFailed(#[source] Box), + + /// The token mint outlived its budget — a wedged REST endpoint, not a credentials + /// problem; surfaced as a timeout so the operator isn't misdirected to a socket + /// that was never dialed. + #[error("the token mint did not complete within {0:?}")] + MintTimeout(std::time::Duration), + + /// The engine rejected a method (`success: false`); carries the server's error + /// code so the host can classify it. + #[error("{0}")] + ServerRejected(String), + + /// The engine rejected the request frame as a whole: one id-less `success: false` + /// reply, no `req_id` echo. Distinct from [`Self::ServerRejected`] so a batch + /// caller can tell "the one reply that failed" from "the only reply there will + /// ever be" — the per-id replies a whole-frame rejection leaves unanswered were + /// never owed. + #[error("{0}")] + FrameRejected(String), + + /// A value failed validation against its allow-list (book depth, OHLC interval, …). + #[error("{0}")] + Validation(String), + + /// JSON (de)serialization failed (also covers a malformed frame). + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// A transport-level failure (connect, send, or stream error). + #[error("transport error: {0}")] + Transport(String), + + /// One dial attempt (TCP + TLS + WebSocket handshake) outlived the transport's + /// per-attempt bound. Retry-safe: nothing was sent, and the dial-retry loops treat + /// it like any other failed attempt. + #[error("connect attempt timed out after {0:?}")] + ConnectTimeout(std::time::Duration), + + /// A frame write outlived the transport's send bound — a wedged socket. Bytes may + /// have partially left the process, so this carries the same verify-before-retrying + /// semantics as [`Unacknowledged`](Self::Unacknowledged): the frame may still have + /// reached the exchange, and a blind retry can double-submit an order. + #[error( + "send timed out after {0:?}; the frame may have reached the exchange — verify \ + its state before retrying" + )] + SendTimeout(std::time::Duration), + + /// The close handshake outlived the transport's bound. Dropping the writer after this + /// closes the TCP abruptly, so callers tearing down anyway trace it and move on. + #[error("close handshake timed out after {0:?}")] + CloseTimeout(std::time::Duration), +} + +impl Error { + /// Whether the request may have reached the exchange: a blind retry risks a + /// double submit, so order state must be verified first. The typed form of the + /// "verify its state before retrying" prose these variants' `Display` carries. + pub fn is_verify_first(&self) -> bool { + matches!( + self, + Self::Unacknowledged | Self::SendTimeout(_) | Self::ReplyLost(_) + ) + } + + /// Whether the venue rejected the session token itself (`EAPI:Invalid token`) — as + /// opposed to rejecting the request the token authorized. Distinct from + /// [`TokenRejected`](Self::TokenRejected), which is a mint-time credentials failure: + /// this is a live token going dead, so callers drop their cached token and re-mint. + pub fn is_token_rejection(&self) -> bool { + matches!( + self, + Self::ServerRejected(message) | Self::FrameRejected(message) + if message.contains("EAPI:Invalid token") + ) + } + + /// A frame that violates the protocol's shape: a result where none belongs, a result + /// from a different method, a frame with no discriminant. Classified as a parse + /// error, the category every shape mismatch carries. + pub fn protocol(message: impl std::fmt::Display) -> Self { + Self::Json(::custom(message)) + } + + /// A socket-level failure, stringified. The constructor the transport crate uses + /// where a `From` impl would be an orphan (its error types are foreign here). + pub fn transport(err: impl std::fmt::Display) -> Self { + Self::Transport(err.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_first_marks_exactly_the_sent_but_unconfirmed_variants() { + assert!(Error::Unacknowledged.is_verify_first()); + assert!(Error::SendTimeout(std::time::Duration::from_secs(5)).is_verify_first()); + assert!(Error::ReplyLost(Box::new(Error::Transport("boom".into()))).is_verify_first()); + // Pre-send failures must stay blindly retryable. + assert!(!Error::DialTimeout.is_verify_first()); + assert!(!Error::Transport("refused".into()).is_verify_first()); + assert!(!Error::ConnectionClosed(Endpoint::Auth).is_verify_first()); + } + + #[test] + fn only_the_venues_invalid_token_reply_is_a_token_rejection() { + assert!(Error::FrameRejected("EAPI:Invalid token".into()).is_token_rejection()); + assert!(Error::ServerRejected("EAPI:Invalid token".into()).is_token_rejection()); + // An order rejection keeps the token; a mint failure never had a live one. + assert!(!Error::ServerRejected("EOrder:Unknown order".into()).is_token_rejection()); + assert!(!Error::TokenRejected("EAPI:Invalid key".into()).is_token_rejection()); + } +} diff --git a/crates/kraken-core/src/inbound.rs b/crates/kraken-core/src/inbound.rs new file mode 100644 index 0000000..3b65bc0 --- /dev/null +++ b/crates/kraken-core/src/inbound.rs @@ -0,0 +1,114 @@ +//! Inbound frame classification: one text frame in, one typed [`Inbound`] out. + +use serde::Deserialize; + +use super::response::{Method, MethodResponse}; +use super::subscribe::{Channel, ChannelMessage}; +use crate::error::{Error, Result}; + +/// What a decoded inbound frame is. Exhaustive over the wire: a frame that fits none of +/// these is a parse error, never silently dropped. +#[derive(Debug)] +pub enum Inbound { + /// A channel-data frame (market data or an account stream). + Channel(ChannelMessage), + /// A method reply: an order reply, a subscribe/unsubscribe ack, an error, or + /// `pong`. Routed by `req_id` to an awaiting caller, or surfaced as an event. + Method(MethodResponse), + /// A server keepalive (`{"channel":"heartbeat"}`). Deliberately payload-less on the + /// wire (), so a unit + /// variant *is* its full type; its arrival is the connection-level liveness signal + /// the live monitor watches to tell a stalled socket from a merely quiet market. + Heartbeat, +} + +impl std::str::FromStr for Inbound { + type Err = Error; + + /// Classify one inbound text frame — the single source of truth for what a frame *is*. + /// Deliberately strict: an unknown discriminant, a payload off its schema, or a frame + /// with neither key fails, so a surprise on the wire is a logged error to fix, never + /// silently reinterpreted. The one tolerance is vocabulary inside a payload: an + /// unrecognized enum token decodes to that enum's `Unknown` variant, degrading one + /// field, not the frame. + fn from_str(text: &str) -> Result { + /// A channel frame carries `channel`, a method reply carries `method`. Both parse + /// straight into their typed vocabularies; a name outside either catalogue fails + /// the frame here. + #[derive(Deserialize)] + struct Routing { + channel: Option, + method: Option, + } + + // The probe skips every non-discriminant field, and the winning side decodes + // straight from the text: two linear scans, no intermediate + // `serde_json::Value` DOM and none of its per-key/value String clones. (The + // flattened envelope still buffers through serde's internal `Content`; what + // this removes is the extra Value tree on top of it, once per frame.) + let Routing { channel, method } = serde_json::from_str(text)?; + match (channel, method) { + // A frame claiming to be both is outside the protocol; guessing a side + // would mask the drift. + (Some(_), Some(_)) => Err(Error::protocol("frame carries both `channel` and `method`")), + (Some(Channel::Heartbeat), None) => Ok(Self::Heartbeat), + (Some(_), None) => Ok(Self::Channel(serde_json::from_str::(text)?)), + (None, Some(_)) => Ok(Self::Method(serde_json::from_str::(text)?)), + (None, None) => Err(Error::protocol( + "frame carries neither `channel` nor `method`", + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_channel_data() { + let frame = r#"{"channel":"trade","type":"update","data":[{ + "symbol":"BTC/USD","side":"buy","price":1.0,"qty":1.0, + "ord_type":"market","trade_id":1,"timestamp":"TS"}]}"#; + assert!(matches!( + frame.parse::().unwrap(), + Inbound::Channel(_) + )); + } + + #[test] + fn heartbeat_is_classified_for_liveness() { + assert!(matches!( + r#"{"channel":"heartbeat"}"#.parse::().unwrap(), + Inbound::Heartbeat + )); + } + + #[test] + fn a_frame_with_no_discriminant_is_a_hard_error() { + assert!(r#"{"unexpected":"shape"}"#.parse::().is_err()); + } + + #[test] + fn a_frame_with_both_discriminants_is_a_hard_error() { + assert!(r#"{"channel":"trade","method":"pong"}"#.parse::().is_err()); + } + + #[test] + fn a_drifted_method_result_is_a_hard_error() { + let reply = r#"{"method":"add_order","success":true,"result":{"order_id":42},"req_id":9}"#; + assert!(reply.parse::().is_err()); + } + + #[test] + fn an_unknown_channel_is_a_hard_error() { + let frame = r#"{"channel":"brand_new_feed","type":"update","data":[{"x":1}]}"#; + assert!(frame.parse::().is_err()); + } + + #[test] + fn an_unknown_method_is_a_hard_error() { + let reply = r#"{"method":"brand_new_method","success":true}"#; + assert!(reply.parse::().is_err()); + } +} diff --git a/crates/kraken-core/src/lib.rs b/crates/kraken-core/src/lib.rs new file mode 100644 index 0000000..732b7c4 --- /dev/null +++ b/crates/kraken-core/src/lib.rs @@ -0,0 +1,49 @@ +//! Typed wire model and error taxonomy for the Kraken Spot WebSocket v2 protocol. +//! +//! Source: . Split into the outgoing +//! `request` methods and their `wire` framing, the incoming `response` reply model +//! and `inbound` frame classification, the [`subscribe`] channel catalogue and +//! payloads, the [`endpoint`] socket catalogue, the [`error`] type, and the +//! `primitives` shared by all of them. +//! +//! Layer 0 by construction: pure data — parsing, validation, serialization — with no +//! I/O, no async runtime, and no transport dependencies. +//! +//! Precision bound: inbound JSON numbers decode into `Decimal` through `f64` (~17 +//! significant digits) — `arbitrary_precision` cannot ride the flattened channel +//! envelope (see the manifest note). Outgoing request params do not share the bound: +//! their `Decimal` fields serialize digit-exact. +//! +//! The root re-exports below are the public surface; only the modules external code +//! actually paths into stay `pub`. + +#![warn(missing_debug_implementations)] +// Tests assert with unwrap/panic by design; the workspace lints deny them elsewhere. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::panic))] + +pub mod endpoint; +pub mod error; +mod inbound; +pub mod name; +mod primitives; +mod request; +mod response; +pub mod subscribe; +mod wire; + +pub use endpoint::Endpoint; +pub use error::Error; +pub use inbound::Inbound; +pub use primitives::{OrderSide, OrderType}; +pub use request::{ + AddOrderParams, AddOrderResult, AmendOrderParams, AmendOrderResult, BatchAddParams, + BatchCancelParams, BatchOrderParams, CancelAllOrdersAfterParams, CancelAllOrdersAfterResult, + CancelAllParams, CancelOrderParams, CancelOrderResult, ConditionalClose, CountResult, + FeePreference, PingParams, Pong, PriceType, StpType, TimeInForce, TriggerReference, Triggers, +}; +pub use response::{Method, MethodReply, MethodResponse}; +pub use subscribe::{ + BookDepth, Channel, ChannelData, ChannelMessage, EventTrigger, Level3Depth, OhlcInterval, + SubscribableChannel, Users, WsSubscription, +}; +pub use wire::{Request, Wire}; diff --git a/crates/kraken-core/src/name.rs b/crates/kraken-core/src/name.rs new file mode 100644 index 0000000..d7232b6 --- /dev/null +++ b/crates/kraken-core/src/name.rs @@ -0,0 +1,36 @@ +//! Shared validation for names that become filesystem paths. + +/// Accepts one `[A-Za-z0-9._-]` path segment, excluding dot-only names. +/// +/// Rejection preserves an injective, traversal-free name-to-path mapping. +pub fn is_safe_name_segment(s: &str) -> bool { + let safe_bytes = !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')); + safe_bytes && !s.bytes().all(|b| b == b'.') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_single_safe_segments_and_rejects_traversal_shapes() { + for good in ["btc-dip", "lab-m1-r2", "a.b_c-9", "v1.2"] { + assert!(is_safe_name_segment(good), "{good}"); + } + for bad in [ + "", + ".", + "..", + "...", + "a/b", + "a b", + "a\\b", + "ünïcode", + "a\0b", + ] { + assert!(!is_safe_name_segment(bad), "{bad:?}"); + } + } +} diff --git a/crates/kraken-core/src/primitives.rs b/crates/kraken-core/src/primitives.rs new file mode 100644 index 0000000..d4389c0 --- /dev/null +++ b/crates/kraken-core/src/primitives.rs @@ -0,0 +1,32 @@ +//! The order primitives shared by the request and subscribe sides. + +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +/// Order side. Shared by order requests and the `trade` channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum OrderSide { + Buy, + Sell, +} + +/// Order type — the ten execution models the `add_order` schema accepts +/// (). Also decodes the `trade` +/// channel's taker `ord_type`, where only `limit`/`market` occur. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case", ascii_case_insensitive)] +pub enum OrderType { + Limit, + Market, + Iceberg, + StopLoss, + StopLossLimit, + TakeProfit, + TakeProfitLimit, + TrailingStop, + TrailingStopLimit, + SettlePosition, +} diff --git a/crates/kraken-core/src/request/add_order.rs b/crates/kraken-core/src/request/add_order.rs new file mode 100644 index 0000000..5cc5a96 --- /dev/null +++ b/crates/kraken-core/src/request/add_order.rs @@ -0,0 +1,171 @@ +//! `add_order` — place a single order. Owns the trigger and conditional-close +//! templates, plus [`AddOrderResult`], the per-order reply shared with +//! [`batch_add`](super::batch_add). +//! +//! Source: . +//! +//! Required: `order_type`, `side`, `symbol`; everything else optional and omitted when +//! unset. Quantity is `order_qty` (base) *or* `cash_order_qty` (quote, market buys), +//! hence both optional; `validate: true` dry-runs. Deprecated wire fields superseded by +//! `triggers` are omitted. The result carries `order_id` (absent in `validate` mode), +//! the echoed client ids, and optional `warnings`. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::decimal_wire; +use super::value::{FeePreference, PriceType, StpType, TimeInForce, TriggerReference}; +use crate::{MethodReply, OrderSide, OrderType, Request}; + +/// Trigger price parameters for triggered order types. `price` is required by the type +/// — a priceless trigger is unrepresentable; `reference` defaults to `last` and +/// `price_type` to `static` server-side. Deserialized from user-supplied `batch_add` +/// JSON, where an unknown key is a typo that must fail loud. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Triggers { + pub reference: Option, + #[serde(with = "decimal_wire")] + pub price: Decimal, + pub price_type: Option, +} + +/// The close-order template spawned once the primary order fills — the request-side +/// twin of the executions channel's `ContingentOrder` echo. `order_type` is required: +/// a template with no type is unrepresentable. The docs list only the trigger-capable +/// subset of order types; decoding with the full [`OrderType`] keeps one order-type +/// vocabulary. Deserialized from user-supplied `batch_add` JSON, where an unknown key +/// is a typo that must fail loud. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConditionalClose { + pub order_type: OrderType, + #[serde( + default, + serialize_with = "decimal_wire::opt", + deserialize_with = "decimal_wire::de_opt" + )] + pub limit_price: Option, + pub limit_price_type: Option, + #[serde( + default, + serialize_with = "decimal_wire::opt", + deserialize_with = "decimal_wire::de_opt" + )] + pub trigger_price: Option, + pub trigger_price_type: Option, +} + +/// `add_order` request params. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct AddOrderParams { + pub order_type: OrderType, + pub side: OrderSide, + pub symbol: String, + #[serde(serialize_with = "decimal_wire::opt")] + pub order_qty: Option, + #[serde(serialize_with = "decimal_wire::opt")] + pub cash_order_qty: Option, + #[serde(serialize_with = "decimal_wire::opt")] + pub limit_price: Option, + pub limit_price_type: Option, + pub time_in_force: Option, + pub margin: Option, + pub post_only: Option, + pub reduce_only: Option, + /// Disable market-price protection on a market order. + pub no_mpp: Option, + pub triggers: Option, + pub conditional: Option, + pub effective_time: Option, + pub expire_time: Option, + pub deadline: Option, + pub cl_ord_id: Option, + /// `i32` across every path (requests, acks, executions ingest): the API defines a + /// userref as a signed int32, so negatives set by other clients stay addressable. + pub order_userref: Option, + #[serde(serialize_with = "decimal_wire::opt")] + pub display_qty: Option, + pub fee_preference: Option, + pub stp_type: Option, + pub sender_sub_id: Option, + pub validate: Option, +} + +impl Request for AddOrderParams { + const METHOD: &'static str = "add_order"; + type Response = AddOrderResult; + + fn extract(reply: MethodReply) -> Option { + reply.try_as_add_order().flatten() + } +} + +/// `add_order` / `batch_add` result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AddOrderResult { + pub order_id: Option, + pub cl_ord_id: Option, + pub order_userref: Option, + /// Present only on `validate`-mode responses, in place of a real `order_id`. + pub validation: Option, + pub warnings: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Wire; + + /// Pins the full outgoing frame: unset options stay off the wire, the token flattens + /// in first, money serializes digit-exact past f64 precision, and a negative userref + /// (legal int32 per the API) survives. + #[test] + fn add_order_frame_pins_the_wire_shape_with_exact_decimals() { + let params = AddOrderParams { + order_type: OrderType::Limit, + side: OrderSide::Buy, + symbol: "BTC/USD".into(), + order_qty: Some("0.123456789012345678".parse().unwrap()), + cash_order_qty: None, + limit_price: Some("50000.1".parse().unwrap()), + limit_price_type: None, + time_in_force: Some(TimeInForce::Gtc), + margin: None, + post_only: Some(true), + reduce_only: None, + no_mpp: None, + triggers: Some(Triggers { + reference: None, + price: "49000.5".parse().unwrap(), + price_type: None, + }), + conditional: None, + effective_time: None, + expire_time: None, + deadline: None, + cl_ord_id: None, + order_userref: Some(-7), + display_qty: None, + fee_preference: None, + stp_type: None, + sender_sub_id: None, + validate: Some(true), + }; + let json = serde_json::to_string(&Wire::new( + AddOrderParams::METHOD, + Some("tok-1"), + ¶ms, + Some(5), + )) + .unwrap(); + assert_eq!( + json, + r#"{"method":"add_order","params":{"token":"tok-1","order_type":"limit","side":"buy","symbol":"BTC/USD","order_qty":0.123456789012345678,"limit_price":50000.1,"time_in_force":"gtc","post_only":true,"triggers":{"price":49000.5},"order_userref":-7,"validate":true},"req_id":5}"# + ); + } +} diff --git a/crates/kraken-core/src/request/amend_order.rs b/crates/kraken-core/src/request/amend_order.rs new file mode 100644 index 0000000..4ba449a --- /dev/null +++ b/crates/kraken-core/src/request/amend_order.rs @@ -0,0 +1,103 @@ +//! `amend_order` — modify a resting order in place. +//! +//! Source: . The order is +//! addressed by `order_id` *or* `cl_ord_id` (hence both optional); the remaining params +//! are the fields being changed — `order_qty`, `display_qty`, `limit_price` +//! (`limit_price_type`), `post_only`, `trigger_price` (`trigger_price_type`) — plus +//! `deadline` and `symbol` (required for non-crypto pairs). The reply's `amend_id` is +//! the only always-present field. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::decimal_wire; +use super::value::PriceType; +use crate::{MethodReply, Request}; + +/// `amend_order` request params (see the module schema). +#[skip_serializing_none] +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct AmendOrderParams { + pub order_id: Option, + pub cl_ord_id: Option, + #[serde(serialize_with = "decimal_wire::opt")] + pub order_qty: Option, + #[serde(serialize_with = "decimal_wire::opt")] + pub display_qty: Option, + #[serde(serialize_with = "decimal_wire::opt")] + pub limit_price: Option, + pub limit_price_type: Option, + pub post_only: Option, + #[serde(serialize_with = "decimal_wire::opt")] + pub trigger_price: Option, + pub trigger_price_type: Option, + pub deadline: Option, + pub symbol: Option, +} + +impl Request for AmendOrderParams { + const METHOD: &'static str = "amend_order"; + type Response = AmendOrderResult; + + fn extract(reply: MethodReply) -> Option { + reply.try_as_amend_order().flatten() + } +} + +/// `amend_order` result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AmendOrderResult { + pub amend_id: String, + pub order_id: Option, + pub cl_ord_id: Option, + pub warnings: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Inbound, Wire}; + + /// Pins the full outgoing frame: unset options stay off the wire and money + /// serializes digit-exact past f64 precision — this is an order-mutating frame. + #[test] + fn amend_order_frame_pins_the_wire_shape_with_exact_decimals() { + let params = AmendOrderParams { + order_id: Some("OU22CG-KOAF2-D5B7YK".into()), + order_qty: Some("0.123456789012345678".parse().expect("a valid decimal")), + limit_price: Some("50000.1".parse().expect("a valid decimal")), + post_only: Some(true), + ..AmendOrderParams::default() + }; + let json = serde_json::to_string(&Wire::new( + AmendOrderParams::METHOD, + Some("tok-1"), + ¶ms, + Some(6), + )) + .expect("serializes"); + assert_eq!( + json, + r#"{"method":"amend_order","params":{"token":"tok-1","order_id":"OU22CG-KOAF2-D5B7YK","order_qty":0.123456789012345678,"limit_price":50000.1,"post_only":true},"req_id":6}"# + ); + } + + /// The docs' success reply (2024-07 example): `amend_id` plus the echoed + /// `cl_ord_id`, under `result`. + #[test] + fn a_docs_shaped_amend_reply_decodes_typed() { + let reply = r#"{"method":"amend_order","result":{"amend_id":"TTW6PD-RC36L-ZZSWNU","cl_ord_id":"2c6be801-1f53-4f79-a0bb-4ea1c95dfae9"},"success":true,"time_in":"2024-07-26T13:39:04.922699Z","time_out":"2024-07-26T13:39:04.924912Z"}"#; + let Inbound::Method(resp) = reply.parse::().expect("the frame decodes") else { + panic!("expected a method reply"); + }; + let result = resp + .into_result::() + .expect("the reply projects into the amend result"); + assert_eq!(result.amend_id, "TTW6PD-RC36L-ZZSWNU"); + assert_eq!( + result.cl_ord_id.as_deref(), + Some("2c6be801-1f53-4f79-a0bb-4ea1c95dfae9") + ); + } +} diff --git a/crates/kraken-core/src/request/batch_add.rs b/crates/kraken-core/src/request/batch_add.rs new file mode 100644 index 0000000..849de74 --- /dev/null +++ b/crates/kraken-core/src/request/batch_add.rs @@ -0,0 +1,169 @@ +//! `batch_add` — submit many orders for one symbol in a single frame. Each entry is +//! a [`BatchOrderParams`]; the reply is a `Vec`. +//! +//! Source: . Top level: `symbol`, +//! `orders` (the docs allow 2–15 per batch, enforced server-side), and the optional +//! `validate`/`deadline`. Each order entry mirrors the [`add_order`](super::add_order) +//! params for a single pair. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::add_order::{AddOrderResult, ConditionalClose, Triggers}; +use super::decimal_wire; +use super::value::{FeePreference, PriceType, StpType, TimeInForce}; +use crate::Request; +use crate::{MethodReply, OrderSide, OrderType}; + +/// A single order within a `batch_add` request: the [`AddOrderParams`](super::AddOrderParams) +/// fields for one pair, minus the batch-level `symbol`/`validate`/`deadline`. Deserialized +/// from the user-supplied `--orders` JSON, so an unknown key is a typo that must fail +/// loud, never pass through unvalidated. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BatchOrderParams { + pub order_type: OrderType, + pub side: OrderSide, + #[serde( + default, + serialize_with = "decimal_wire::opt", + deserialize_with = "decimal_wire::de_opt" + )] + pub order_qty: Option, + #[serde( + default, + serialize_with = "decimal_wire::opt", + deserialize_with = "decimal_wire::de_opt" + )] + pub cash_order_qty: Option, + #[serde( + default, + serialize_with = "decimal_wire::opt", + deserialize_with = "decimal_wire::de_opt" + )] + pub limit_price: Option, + pub limit_price_type: Option, + pub time_in_force: Option, + pub margin: Option, + pub post_only: Option, + pub reduce_only: Option, + /// Disable market-price protection on a market order. + pub no_mpp: Option, + pub triggers: Option, + pub conditional: Option, + pub effective_time: Option, + pub expire_time: Option, + pub cl_ord_id: Option, + pub order_userref: Option, + #[serde( + default, + serialize_with = "decimal_wire::opt", + deserialize_with = "decimal_wire::de_opt" + )] + pub display_qty: Option, + pub fee_preference: Option, + pub stp_type: Option, + pub sender_sub_id: Option, +} + +/// `batch_add` request params. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct BatchAddParams { + pub symbol: String, + pub orders: Vec, + pub validate: Option, + pub deadline: Option, +} + +impl Request for BatchAddParams { + const METHOD: &'static str = "batch_add"; + type Response = Vec; + + fn extract(reply: MethodReply) -> Option> { + reply.try_as_batch_add().flatten() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_typo_in_a_batch_order_key_fails_loud() { + let err = serde_json::from_str::( + r#"{"order_type":"limit","side":"buy","order_qty":1,"limit_pricee":50000}"#, + ) + .unwrap_err(); + assert!(err.to_string().contains("limit_pricee"), "got: {err}"); + } + + #[test] + fn a_conditional_close_batch_entry_decodes_and_keeps_its_wire_shape() { + // The docs' close-order template plus no_mpp: previously-valid --orders JSON + // that the typed field set must accept (deny_unknown_fields rejected + // `conditional` outright before these fields existed). + let order: BatchOrderParams = serde_json::from_str( + r#"{"order_type":"limit","side":"buy","order_qty":1,"limit_price":50000, + "no_mpp":true, + "conditional":{"order_type":"stop-loss-limit","trigger_price":48000.5, + "limit_price":47900.1}}"#, + ) + .expect("the documented conditional-close fields decode"); + assert_eq!(order.no_mpp, Some(true)); + let conditional = order.conditional.as_ref().expect("the template decodes"); + assert_eq!(conditional.order_type, OrderType::StopLossLimit); + let json = serde_json::to_string(&order).expect("serializes"); + assert!( + json.contains( + r#""conditional":{"order_type":"stop-loss-limit","limit_price":47900.1,"trigger_price":48000.5}"# + ), + "the template must reach the wire in the documented shape: {json}" + ); + } + + #[test] + fn a_typo_inside_a_conditional_template_fails_loud() { + let err = serde_json::from_str::( + r#"{"order_type":"limit","side":"buy", + "conditional":{"order_type":"stop-loss","trigger_pricee":48000}}"#, + ) + .unwrap_err(); + assert!(err.to_string().contains("trigger_pricee"), "got: {err}"); + } + + #[test] + fn batch_order_money_decodes_exactly_never_through_f64() { + // The exact-outbound-decimals guarantee must hold for the --orders JSON path + // too: this quantity is the very value decimal_wire's serializer test pins, + // and the f64 decode route would corrupt it to 0.12345678901234568 before the + // exact serializer ever ran. + let order: BatchOrderParams = serde_json::from_str( + r#"{"order_type":"limit","side":"buy","order_qty":0.123456789012345678,"limit_price":1}"#, + ) + .expect("a bare JSON number decodes"); + let json = serde_json::to_string(&order).expect("serializes"); + assert!( + json.contains(r#""order_qty":0.123456789012345678"#), + "user digits must reach the wire unaltered: {json}" + ); + } + + #[test] + fn a_full_batch_order_entry_deserializes_into_typed_fields() { + let order: BatchOrderParams = serde_json::from_str( + r#"{"order_type":"limit","side":"buy","order_qty":0.5,"limit_price":50000.1, + "time_in_force":"gtc","post_only":true,"triggers":{"price":49000.5}, + "order_userref":-7}"#, + ) + .expect("every documented field decodes typed"); + assert_eq!(order.time_in_force, Some(TimeInForce::Gtc)); + assert_eq!(order.order_userref, Some(-7)); + assert_eq!( + order.triggers.map(|t| t.price), + Some("49000.5".parse().unwrap()) + ); + } +} diff --git a/crates/kraken-core/src/request/batch_cancel.rs b/crates/kraken-core/src/request/batch_cancel.rs new file mode 100644 index 0000000..02a0fd8 --- /dev/null +++ b/crates/kraken-core/src/request/batch_cancel.rs @@ -0,0 +1,66 @@ +//! `batch_cancel` — cancel many orders by id in a single frame. Replies with the +//! shared [`CountResult`]. +//! +//! Source: . `orders` holds +//! Kraken `order_id`s or numeric `order_userref`s (the docs allow 2–50 per batch); the +//! optional `cl_ord_id` list cancels by client id. + +use serde::Serialize; + +use super::cancel_all::CountResult; +use crate::{MethodReply, Request}; + +/// `batch_cancel` request params (see the module schema). +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct BatchCancelParams { + pub orders: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub cl_ord_id: Vec, +} + +impl Request for BatchCancelParams { + const METHOD: &'static str = "batch_cancel"; + type Response = CountResult; + + fn extract(reply: MethodReply) -> Option { + reply.try_as_batch_cancel().flatten() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Inbound, Wire}; + + #[test] + fn batch_cancel_frame_pins_the_wire_shape() { + let params = BatchCancelParams { + orders: vec!["OZQNQ1-ABC12-XYZ789".into(), "42".into()], + cl_ord_id: vec![], + }; + let json = serde_json::to_string(&Wire::new( + BatchCancelParams::METHOD, + Some("tok-1"), + ¶ms, + Some(3), + )) + .expect("serializes"); + assert_eq!( + json, + r#"{"method":"batch_cancel","params":{"token":"tok-1","orders":["OZQNQ1-ABC12-XYZ789","42"]},"req_id":3}"# + ); + } + + /// The docs' success reply: the cancelled count nested under `result`. + #[test] + fn a_docs_shaped_batch_cancel_reply_decodes_its_count() { + let reply = r#"{"method":"batch_cancel","req_id":1234567890,"result":{"count":3},"success":true,"time_in":"2022-06-13T08:09:10.123456Z","time_out":"2022-06-13T08:09:10.789012Z"}"#; + let Inbound::Method(resp) = reply.parse::().expect("the frame decodes") else { + panic!("expected a method reply"); + }; + let result = resp + .into_result::() + .expect("the reply projects into the count result"); + assert_eq!(result.count, 3); + } +} diff --git a/crates/kraken-core/src/request/cancel_all.rs b/crates/kraken-core/src/request/cancel_all.rs new file mode 100644 index 0000000..2043b25 --- /dev/null +++ b/crates/kraken-core/src/request/cancel_all.rs @@ -0,0 +1,30 @@ +//! `cancel_all` — cancel every open order. Owns [`CountResult`], the count-only +//! reply shared with [`batch_cancel`](super::batch_cancel). +//! +//! Source: . The request carries +//! only the auth token (flattened in by [`Wire`](crate::Wire)), so its params +//! struct is empty; the reply is a `count` of orders cancelled plus optional `warnings`. + +use serde::{Deserialize, Serialize}; + +use crate::{MethodReply, Request}; + +/// `cancel_all` request params (only the token, flattened in at send time). +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct CancelAllParams {} + +impl Request for CancelAllParams { + const METHOD: &'static str = "cancel_all"; + type Response = CountResult; + + fn extract(reply: MethodReply) -> Option { + reply.try_as_cancel_all().flatten() + } +} + +/// `cancel_all` / `batch_cancel` result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CountResult { + pub count: u64, + pub warnings: Option>, +} diff --git a/crates/kraken-core/src/request/cancel_all_orders_after.rs b/crates/kraken-core/src/request/cancel_all_orders_after.rs new file mode 100644 index 0000000..a50bd00 --- /dev/null +++ b/crates/kraken-core/src/request/cancel_all_orders_after.rs @@ -0,0 +1,60 @@ +//! `cancel_all_orders_after` — the dead-man's switch. +//! +//! Source: (the doc page +//! slug is `cancel_after`, though the wire method is `cancel_all_orders_after`). The reply +//! echoes the engine's `currentTime` and the `triggerTime` at which the sweep will fire. + +use serde::{Deserialize, Serialize}; + +use crate::{MethodReply, Request}; + +/// `cancel_all_orders_after` request params (the dead-man's switch). `timeout` is in +/// seconds (max 86400); `0` cancels the timer without touching any orders. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CancelAllOrdersAfterParams { + pub timeout: u64, +} + +impl Request for CancelAllOrdersAfterParams { + const METHOD: &'static str = "cancel_all_orders_after"; + type Response = CancelAllOrdersAfterResult; + + fn extract(reply: MethodReply) -> Option { + reply.try_as_cancel_all_orders_after().flatten() + } +} + +/// `cancel_all_orders_after` result. Both timestamps are RFC3339. Unusually for v2 the +/// wire keys are camelCase (`currentTime`/`triggerTime`), hence the explicit renames. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CancelAllOrdersAfterResult { + #[serde(rename = "currentTime")] + pub current_time: String, + #[serde(rename = "triggerTime")] + pub trigger_time: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Inbound; + + /// The crate's only camelCase renames — its most drift-prone serde spot — pinned + /// against the docs' reply shape in both directions. + #[test] + fn a_docs_shaped_reply_round_trips_its_camel_case_keys() { + let frame = r#"{"method":"cancel_all_orders_after","req_id":6,"result":{"currentTime":"2023-09-21T12:03:14Z","triggerTime":"2023-09-21T12:04:14Z"},"success":true}"#; + let Inbound::Method(resp) = frame.parse::().unwrap() else { + panic!("a cancel_all_orders_after ack classifies as a method reply"); + }; + let result = resp + .into_result::() + .expect("the docs-shaped result decodes"); + assert_eq!(result.current_time, "2023-09-21T12:03:14Z"); + assert_eq!(result.trigger_time, "2023-09-21T12:04:14Z"); + + let out = serde_json::to_value(&result).unwrap(); + assert_eq!(out["currentTime"], "2023-09-21T12:03:14Z"); + assert_eq!(out["triggerTime"], "2023-09-21T12:04:14Z"); + } +} diff --git a/crates/kraken-core/src/request/cancel_order.rs b/crates/kraken-core/src/request/cancel_order.rs new file mode 100644 index 0000000..0ddbf5c --- /dev/null +++ b/crates/kraken-core/src/request/cancel_order.rs @@ -0,0 +1,83 @@ +//! `cancel_order` — cancel by order id, client order id, or user reference. +//! +//! Source: . Exactly one +//! identifier kind per request; the reply carries `order_id` (and echoed `cl_ord_id`) +//! per cancelled order. + +use std::num::NonZeroUsize; + +use serde::{Deserialize, Serialize}; + +use crate::{MethodReply, Request}; + +/// `cancel_order` request params: one identifier list per the v2 contract, so the +/// mixed-kind state is unrepresentable. When several ids are given the engine streams +/// one reply per id, all sharing the request id; the request path gathers them all +/// before the server closes the socket — see +/// [`expected_replies`](CancelOrderParams::expected_replies). Untagged: each variant +/// serializes as its own wire key (`order_id`, `cl_ord_id`, `order_userref`). +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(untagged)] +pub enum CancelOrderParams { + OrderIds { order_id: Vec }, + ClOrdIds { cl_ord_id: Vec }, + UserRefs { order_userref: Vec }, +} + +impl CancelOrderParams { + /// Identifiers in the populated list — the number of individual replies Kraken + /// streams back for this cancel. + fn id_count(&self) -> usize { + match self { + Self::OrderIds { order_id } => order_id.len(), + Self::ClOrdIds { cl_ord_id } => cl_ord_id.len(), + Self::UserRefs { order_userref } => order_userref.len(), + } + } +} + +impl Request for CancelOrderParams { + const METHOD: &'static str = "cancel_order"; + type Response = CancelOrderResult; + + fn extract(reply: MethodReply) -> Option { + reply.try_as_cancel_order().flatten() + } + + /// One reply per identifier. The CLI guarantees a non-empty list; fall back to one + /// defensively rather than panicking on a hand-built empty variant. + fn expected_replies(&self) -> NonZeroUsize { + NonZeroUsize::new(self.id_count()).unwrap_or(NonZeroUsize::MIN) + } +} + +/// `cancel_order` result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CancelOrderResult { + pub order_id: Option, + pub cl_ord_id: Option, + pub warnings: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_identifier_kind_serializes_as_its_own_wire_key() { + let ids = CancelOrderParams::OrderIds { + order_id: vec!["O1".into(), "O2".into()], + }; + assert_eq!( + serde_json::to_value(&ids).unwrap(), + serde_json::json!({"order_id": ["O1", "O2"]}) + ); + let refs = CancelOrderParams::UserRefs { + order_userref: vec![-7, 9], + }; + assert_eq!( + serde_json::to_value(&refs).unwrap(), + serde_json::json!({"order_userref": [-7, 9]}) + ); + } +} diff --git a/crates/kraken-core/src/request/decimal_wire.rs b/crates/kraken-core/src/request/decimal_wire.rs new file mode 100644 index 0000000..23dc7ca --- /dev/null +++ b/crates/kraken-core/src/request/decimal_wire.rs @@ -0,0 +1,140 @@ +//! Exact (de)serialization for `Decimal` fields that cross the wire boundary as money. +//! +//! The crate-wide `serde-float` default routes `Decimal` through `f64`, which silently +//! rounds past ~17 significant digits — tolerable on ingest (forced by the flattened +//! envelope, see the manifest note on `arbitrary_precision`), never for money we place +//! on the wire. The serializers emit the decimal's own digits as a raw JSON number; the +//! deserializers capture the raw token and let `Decimal` parse its own digits, so +//! user-supplied JSON (`--orders`) is never rounded before the exact serializers run. +//! +//! The deserializers require serde_json's native str/slice deserializer (`RawValue` +//! does not survive `serde_json::Value` or a `#[serde(flatten)]` envelope) — fine for +//! the request types, which are parsed straight from user JSON text. + +use rust_decimal::Decimal; +use serde::de::Error as _; +use serde::ser::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +pub(super) fn serialize(value: &Decimal, serializer: S) -> Result { + // `Decimal`'s `Display` form is a plain decimal literal, i.e. a valid JSON number. + serde_json::value::RawValue::from_string(value.to_string()) + .map_err(S::Error::custom)? + .serialize(serializer) +} + +pub(super) fn opt( + value: &Option, + serializer: S, +) -> Result { + match value { + Some(decimal) => serialize(decimal, serializer), + None => serializer.serialize_none(), + } +} + +pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let raw = Box::::deserialize(deserializer)?; + parse_token(raw.get()).map_err(D::Error::custom) +} + +pub(super) fn de_opt<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let raw = Option::>::deserialize(deserializer)?; + raw.map(|raw| parse_token(raw.get()).map_err(D::Error::custom)) + .transpose() +} + +/// Parse one raw JSON token as a decimal: a bare number (plain or scientific +/// notation, which `from_str` alone rejects) or the same value quoted as a string. +fn parse_token(token: &str) -> Result { + let digits = if token.starts_with('"') { + serde_json::from_str::(token).map_err(|e| format!("invalid string: {e}"))? + } else { + token.to_string() + }; + Decimal::from_str_exact(&digits) + .or_else(|_| Decimal::from_scientific(&digits)) + .map_err(|e| format!("`{digits}` is not a decimal: {e}")) +} + +#[cfg(test)] +mod tests { + use rust_decimal::Decimal; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize)] + struct Order { + #[serde(serialize_with = "super::serialize")] + qty: Decimal, + #[serde(serialize_with = "super::opt")] + price: Option, + } + + #[derive(Debug, Deserialize)] + struct Incoming { + #[serde(deserialize_with = "super::deserialize")] + qty: Decimal, + #[serde(default, deserialize_with = "super::de_opt")] + price: Option, + } + + #[test] + fn a_qty_beyond_f64_precision_serializes_its_exact_digits() { + // 18 significant digits: the f64 route would emit 0.12345678901234568. + let order = Order { + qty: "0.123456789012345678".parse().expect("a valid decimal"), + price: Some("1234.567890123456789".parse().expect("a valid decimal")), + }; + assert_eq!( + serde_json::to_string(&order).expect("serializes"), + r#"{"qty":0.123456789012345678,"price":1234.567890123456789}"# + ); + } + + #[test] + fn a_qty_beyond_f64_precision_deserializes_its_exact_digits() { + // The decode-side mirror: a bare number and a quoted string both keep all 18/19 + // digits (the f64 route would decode 0.12345678901234568). + let incoming: Incoming = + serde_json::from_str(r#"{"qty":0.123456789012345678,"price":"1234.567890123456789"}"#) + .expect("both token forms decode"); + assert_eq!(incoming.qty.to_string(), "0.123456789012345678"); + assert_eq!( + incoming.price.expect("present").to_string(), + "1234.567890123456789" + ); + } + + #[test] + fn a_scientific_notation_token_still_decodes() { + // JSON permits exponent form; `Decimal::from_str` alone rejects it. + let incoming: Incoming = serde_json::from_str(r#"{"qty":1.5e2}"#).expect("decodes"); + assert_eq!(incoming.qty, Decimal::from(150)); + assert!(incoming.price.is_none(), "a missing field stays None"); + } + + #[test] + fn a_non_decimal_token_names_itself_in_the_error() { + let err = serde_json::from_str::(r#"{"qty":"not-money"}"#).unwrap_err(); + assert!(err.to_string().contains("not-money"), "got: {err}"); + } + + proptest::proptest! { + /// The exactness invariant for ANY representable Decimal, not just the pinned + /// examples: serialize -> raw JSON number -> deserialize preserves every digit. + #[test] + fn any_decimal_round_trips_digit_exact( + mantissa in -79_228_162_514_264_337_593_543_950_335i128 + ..=79_228_162_514_264_337_593_543_950_335i128, + scale in 0u32..=28, + ) { + let value = Decimal::from_i128_with_scale(mantissa, scale); + let json = serde_json::to_string(&Order { qty: value, price: None }) + .expect("serializes"); + let back: Incoming = serde_json::from_str(&json).expect("round-trips"); + proptest::prop_assert_eq!(back.qty.to_string(), value.to_string()); + } + } +} diff --git a/crates/kraken-core/src/request/mod.rs b/crates/kraken-core/src/request/mod.rs new file mode 100644 index 0000000..ab51b97 --- /dev/null +++ b/crates/kraken-core/src/request/mod.rs @@ -0,0 +1,40 @@ +//! Strictly-typed params and results for the request/response methods — everything +//! that implements [`Request`](crate::Request). +//! +//! Source: the order-management methods under +//! (each submodule links its own page). +//! +//! Each method lives in its own submodule, pairing the request params with the result +//! it decodes into: +//! +//! - [`add_order`] / [`amend_order`] / [`cancel_order`] +//! - [`cancel_all`] / [`cancel_all_orders_after`] +//! - [`batch_add`] / [`batch_cancel`] +//! - [`ping`] +//! +//! Params serialize via serde with `skip_serializing_if`, so the command layer +//! builds typed structs rather than hand-built maps; the auth token is flattened in +//! at send time by [`Wire`](crate::Wire). Each params type implements +//! [`Request`](crate::Request) to bind its wire `method`. Order methods ride the +//! authenticated endpoint; `ping` overrides the defaults to ride the public one. + +mod add_order; +mod amend_order; +mod batch_add; +mod batch_cancel; +mod cancel_all; +mod cancel_all_orders_after; +mod cancel_order; +mod decimal_wire; +mod ping; +mod value; + +pub use add_order::{AddOrderParams, AddOrderResult, ConditionalClose, Triggers}; +pub use amend_order::{AmendOrderParams, AmendOrderResult}; +pub use batch_add::{BatchAddParams, BatchOrderParams}; +pub use batch_cancel::BatchCancelParams; +pub use cancel_all::{CancelAllParams, CountResult}; +pub use cancel_all_orders_after::{CancelAllOrdersAfterParams, CancelAllOrdersAfterResult}; +pub use cancel_order::{CancelOrderParams, CancelOrderResult}; +pub use ping::{PingParams, Pong}; +pub use value::{FeePreference, PriceType, StpType, TimeInForce, TriggerReference}; diff --git a/crates/kraken-core/src/request/ping.rs b/crates/kraken-core/src/request/ping.rs new file mode 100644 index 0000000..fb17041 --- /dev/null +++ b/crates/kraken-core/src/request/ping.rs @@ -0,0 +1,35 @@ +//! `ping` — application-level keepalive / latency probe. +//! +//! Source: . The request is just +//! `{method: "ping"}` (no params); the server answers `{method: "pong"}`, echoing `req_id` +//! when one was sent. Unlike other acks, `pong` omits `success` — absence reads as success. + +use serde::Serialize; + +use crate::endpoint::Endpoint; +use crate::{MethodReply, Request}; + +/// `ping` request params — none; the field-less struct serializes to an omitted `params`. +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct PingParams {} + +impl Request for PingParams { + const METHOD: &'static str = "ping"; + type Response = Pong; + + fn endpoint(&self) -> Endpoint { + Endpoint::Public + } + + /// `pong` carries no result body on the wire + /// () — the variant itself is + /// the whole reply. + fn extract(reply: MethodReply) -> Option { + matches!(reply, MethodReply::Pong).then_some(Pong {}) + } +} + +/// The `pong` reply. Carries no fields; serializes to `{"method":"pong"}`. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "method", rename = "pong")] +pub struct Pong {} diff --git a/crates/kraken-core/src/request/value.rs b/crates/kraken-core/src/request/value.rs new file mode 100644 index 0000000..f76e818 --- /dev/null +++ b/crates/kraken-core/src/request/value.rs @@ -0,0 +1,65 @@ +//! Scalar value objects for order requests — the typed enums that parameterize +//! `add_order` / `amend_order` / `batch_add`. The `executions` channel echoes +//! [`PriceType`] and [`TriggerReference`] back with the same tokens, so the +//! subscribe side decodes with these rather than duplicate enums. +//! +//! Source: the enum value sets are the ones the `add_order` params accept +//! (). +//! +//! Each serializes to its exact wire token (`serde`) and parses the same token +//! case-insensitively (`strum`), so an invalid value is rejected at the command +//! boundary rather than sent on the wire. Primitives shared with the subscribe side +//! ([`OrderSide`](crate::OrderSide), [`OrderType`](crate::OrderType)) live in +//! [`crate`]. + +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +/// How a price is interpreted (absolute, percentage, or quote-relative). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum PriceType { + Static, + Pct, + Quote, +} + +/// Which reference price a trigger watches. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum TriggerReference { + Index, + Last, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum TimeInForce { + Gtc, + Gtd, + Ioc, + Fok, +} + +/// Self-trade-prevention mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case", ascii_case_insensitive)] +#[allow(clippy::enum_variant_names)] +pub enum StpType { + CancelNewest, + CancelOldest, + CancelBoth, +} + +/// Fee currency preference. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum FeePreference { + Base, + Quote, +} diff --git a/crates/kraken-core/src/response/envelope.rs b/crates/kraken-core/src/response/envelope.rs new file mode 100644 index 0000000..638d7b0 --- /dev/null +++ b/crates/kraken-core/src/response/envelope.rs @@ -0,0 +1,178 @@ +//! The method-reply envelope every reply shares, mirroring the channel side's +//! `DataMessage` envelope. + +use serde::Deserialize; + +use super::result::{Method, MethodReply}; +use crate::error::{Error, Result}; +use crate::wire::Request; + +/// A method reply envelope — order replies, `pong`, and subscribe/unsubscribe acks all +/// share this shape. The method-tagged result body is flattened in, so serde decodes it +/// straight into its typed [`MethodReply`] at ingest; [`Self::into_result`] extracts the +/// one a request statically expects. +#[derive(Debug, Clone, Deserialize)] +pub struct MethodResponse { + #[serde(flatten)] + pub reply: MethodReply, + /// Some replies (e.g. `pong`) omit `success`; absent reads as success. + pub success: Option, + pub error: Option, + /// On a *rejected* subscribe, the server names the offending pair at the top level + /// (captured live: `{"error":"Currency pair not supported BOGUS/NOPE","method": + /// "subscribe","success":false,"symbol":"BOGUS/NOPE",...}`) — a successful ack nests + /// its echo under `result` instead. The connection actor evicts by this. + pub symbol: Option, + pub req_id: Option, + /// When the server received the request on the wire (RFC3339). Present on + /// subscribe/method acks; absent on `pong`. Surfaced for the live monitor's + /// server-side latency and clock-offset estimate — never required by the trade path. + pub time_in: Option, + /// When the server sent the acknowledgement on the wire (RFC3339). See [`Self::time_in`]. + pub time_out: Option, +} + +impl MethodResponse { + pub fn method(&self) -> Method { + self.reply.method() + } + + /// A reply is a failure only if it explicitly carries `success: false`. + pub fn is_success(&self) -> bool { + self.success != Some(false) + } + + /// Extract the typed result a request statically expects. A correlated server + /// rejection becomes [`Error::ServerRejected`]; an id-less one — the server refused + /// the whole frame before correlating — becomes [`Error::FrameRejected`], so batch + /// callers can tell the two apart. Anything but the expected result variant — + /// absent when one is required, or a different method's — is a parse error. + pub fn into_result(self) -> Result { + if self.success == Some(false) { + let message = self.error.unwrap_or_else(|| "unknown error".to_string()); + return Err(match self.req_id { + None => Error::FrameRejected(message), + Some(_) => Error::ServerRejected(message), + }); + } + P::extract(self.reply) + .ok_or_else(|| Error::protocol(format!("reply did not carry a {} result", P::METHOD))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AddOrderParams; + use crate::Inbound; + + #[test] + fn a_captured_subscribe_rejection_names_its_symbol_at_the_top_level() { + let frame = r#"{"error":"Currency pair not supported BOGUS/NOPE","method":"subscribe","req_id":7,"success":false,"symbol":"BOGUS/NOPE","time_in":"2026-07-07T17:23:59.172238Z","time_out":"2026-07-07T17:23:59.172284Z"}"#; + let Inbound::Method(resp) = frame.parse::().unwrap() else { + panic!("a subscribe ack classifies as a method reply"); + }; + assert!(!resp.is_success()); + assert_eq!(resp.symbol.as_deref(), Some("BOGUS/NOPE")); + assert!(matches!(resp.reply, MethodReply::Subscribe(None))); + assert_eq!(resp.req_id, Some(7)); + } + + #[test] + fn a_captured_subscribe_success_nests_its_echo_under_result() { + let frame = r#"{"method":"subscribe","req_id":7,"result":{"channel":"ticker","event_trigger":"trades","snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-07-07T17:23:59.172238Z","time_out":"2026-07-07T17:23:59.172284Z"}"#; + let Inbound::Method(resp) = frame.parse::().unwrap() else { + panic!("a subscribe ack classifies as a method reply"); + }; + assert!(resp.is_success()); + assert!(resp.symbol.is_none()); + assert!(matches!(resp.reply, MethodReply::Subscribe(Some(_)))); + } + + #[test] + fn classifies_method_reply_and_projects_result() { + let reply = + r#"{"method":"add_order","success":true,"result":{"order_id":"OXXX"},"req_id":9}"#; + match reply.parse::().unwrap() { + Inbound::Method(resp) => { + assert_eq!(resp.req_id, Some(9)); + assert!(resp.is_success()); + let r = resp.into_result::().unwrap(); + assert_eq!(r.order_id.as_deref(), Some("OXXX")); + } + _ => panic!("expected a method reply"), + } + } + + #[test] + fn server_rejection_becomes_a_server_error() { + let reply = r#"{"method":"add_order","success":false,"error":"EOrder:Bad","req_id":9}"#; + let Inbound::Method(resp) = reply.parse::().unwrap() else { + panic!("expected a method reply"); + }; + let err = resp.into_result::().unwrap_err(); + assert!(matches!(err, Error::ServerRejected(m) if m == "EOrder:Bad")); + } + + #[test] + fn an_id_less_rejection_brands_as_a_frame_rejection() { + // No `req_id` echo: the server refused the frame before correlating, so the + // rejection answers the whole request, not one id — batch callers key on this. + let reply = r#"{"method":"add_order","success":false,"error":"EOrder:Bad"}"#; + let Inbound::Method(resp) = reply.parse::().unwrap() else { + panic!("expected a method reply"); + }; + let err = resp.into_result::().unwrap_err(); + assert!(matches!(err, Error::FrameRejected(m) if m == "EOrder:Bad")); + } + + #[test] + fn subscribe_ack_parses_wire_timestamps() { + let ack = r#"{"method":"subscribe","success":true,"result":{"channel":"ticker"}, + "time_in":"2026-06-27T17:26:08.596889Z","time_out":"2026-06-27T17:26:08.596933Z"}"#; + let Inbound::Method(resp) = ack.parse::().unwrap() else { + panic!("expected a method reply"); + }; + assert_eq!(resp.time_in.as_deref(), Some("2026-06-27T17:26:08.596889Z")); + assert_eq!( + resp.time_out.as_deref(), + Some("2026-06-27T17:26:08.596933Z") + ); + } + + #[test] + fn an_absent_result_decodes_to_none_not_an_error() { + let reply = r#"{"method":"cancel_order","success":false,"error":"EOrder:Unknown order"}"#; + let Inbound::Method(resp) = reply.parse::().unwrap() else { + panic!("expected a method reply"); + }; + assert!(matches!(resp.reply, MethodReply::CancelOrder(None))); + } + + #[test] + fn a_result_body_on_pong_is_a_hard_error() { + let reply = r#"{"method":"pong","result":{"unexpected":true},"req_id":1}"#; + assert!(reply.parse::().is_err()); + } + + /// [`Method`] is derived from [`MethodReply`], but its wire strings come from + /// strum's snake_case while the reply tags come from serde's — two casing + /// algorithms that could diverge on a future name. Pin their agreement here. A bare + /// `{method, req_id}` frame is every method's minimal reply (rejections omit + /// `result`; `ping`/`pong` never carry one), so all eleven must decode from it. + #[test] + fn every_method_in_the_catalogue_dispatches_to_a_reply() { + use strum::IntoEnumIterator; + for method in Method::iter() { + let raw = format!(r#"{{"method":"{method}","req_id":1}}"#); + let Ok(Inbound::Method(resp)) = raw.parse::() else { + panic!("method `{method}` has no MethodReply counterpart"); + }; + assert_eq!( + resp.method(), + method, + "reply → method mapping must round-trip for `{method}`" + ); + } + } +} diff --git a/crates/kraken-core/src/response/mod.rs b/crates/kraken-core/src/response/mod.rs new file mode 100644 index 0000000..a7db98d --- /dev/null +++ b/crates/kraken-core/src/response/mod.rs @@ -0,0 +1,10 @@ +//! The incoming method-reply model, mirroring the [`subscribe`](super::subscribe) side: +//! [`result`] holds the vocabulary and typed bodies (as `channel` and `message`'s +//! payloads do there), and [`envelope`] the shared reply frame. Frames are routed +//! here by [`Inbound`](crate::Inbound)'s `FromStr`. + +mod envelope; +mod result; + +pub use envelope::MethodResponse; +pub use result::{Method, MethodReply}; diff --git a/crates/kraken-core/src/response/result.rs b/crates/kraken-core/src/response/result.rs new file mode 100644 index 0000000..aef5342 --- /dev/null +++ b/crates/kraken-core/src/response/result.rs @@ -0,0 +1,63 @@ +//! Typed method-reply bodies, dispatched by serde on the wire `method` tag — the +//! request/response mirror of the channel side's [`ChannelData`](crate::ChannelData). + +use serde::Deserialize; +use serde_with::DeserializeFromStr; +use strum::{Display, EnumDiscriminants, EnumIter, EnumString, EnumTryAs}; + +use crate::request::{ + AddOrderResult, AmendOrderResult, CancelAllOrdersAfterResult, CancelOrderResult, CountResult, +}; + +/// The wire `method` name paired with its typed `result` body — serde dispatches on the +/// frame's own `method` field, so the method names its result schema and a body that no +/// longer deserializes is a hard error; there is no verbatim fallback. `result` is +/// legitimately absent on rejections, so each variant wraps an `Option`; `ping`/`pong` +/// carry no body at all, so they are unit variants and a body on one fails the frame. +/// +/// [`Method`] — the v2 `method` vocabulary, the reply side's discriminant as +/// [`Channel`](crate::Channel) is the channel side's — is derived from this enum, so the +/// two can never drift. It is deliberately strict: a name outside the catalogue fails +/// the frame, so a new server method surfaces as a logged error to fix, never as +/// silently reinterpreted data. +#[derive(Debug, Clone, Deserialize, EnumDiscriminants, EnumTryAs)] +#[serde(tag = "method", content = "result", rename_all = "snake_case")] +#[strum_discriminants( + name(Method), + vis(pub), + derive(Display, EnumIter, EnumString, DeserializeFromStr), + strum(serialize_all = "snake_case") +)] +pub enum MethodReply { + AddOrder(Option), + AmendOrder(Option), + BatchAdd(Option>), + BatchCancel(Option), + CancelAll(Option), + CancelAllOrdersAfter(Option), + CancelOrder(Option), + /// Inbound only as a rejection: the server echoes the *request's* method on + /// `success: false`, so a refused ping answers as `ping` — success answers as [`Self::Pong`]. + Ping, + Pong, + Subscribe(Option), + Unsubscribe(Option), +} + +/// The typed body of a subscribe/unsubscribe ack: the granted subscription's echo. Only +/// `warnings` is read by consumers; the remaining echo fields are tolerated and dropped. +#[derive(Debug, Clone, Deserialize)] +pub struct SubscribeAck { + /// The acked pair, echoed under `result` — closes that symbol's rejection + /// incident on the streaming path. + pub symbol: Option, + pub warnings: Option>, +} + +impl MethodReply { + /// The [`Method`] this reply answers — mirroring + /// [`ChannelData::channel`](crate::ChannelData::channel). + pub fn method(&self) -> Method { + self.into() + } +} diff --git a/crates/kraken-core/src/subscribe/channel.rs b/crates/kraken-core/src/subscribe/channel.rs new file mode 100644 index 0000000..f1e8bfc --- /dev/null +++ b/crates/kraken-core/src/subscribe/channel.rs @@ -0,0 +1,371 @@ +//! The channel catalogue and its validated wire parameters +//! (). +//! +//! [`SubscribableChannel`] is what a client can `subscribe` to (the CLI surface); +//! [`Channel`] is the full incoming-frame discriminant — subscribables plus the +//! server-pushed system feeds. The [`BookDepth`] / [`OhlcInterval`] / [`Level3Depth`] +//! newtypes are validated at construction, so a held value is always wire-valid. + +use std::str::FromStr; + +use serde::Serialize; +use serde_with::{DeserializeFromStr, SerializeDisplay}; +use strum::{Display, EnumIter, EnumString}; + +use crate::endpoint::Endpoint; +use crate::error::{Error, Result}; + +/// These are **only the subscribable channels** — the ones a client can `subscribe` to, +/// and the `--channels` value type for `kraken streamd` / `kraken record`. The +/// server-pushed system feeds (`status`, `heartbeat`) are deliberately absent, so the +/// CLI cannot accept them; the full wire set lives in [`Channel`]. `Display`/`FromStr` +/// yield/accept the v2 wire name, plus common aliases (`trades`, `l3`, …). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumIter, EnumString)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum SubscribableChannel { + Ticker, + Book, + Ohlc, + #[strum(to_string = "trade", serialize = "trades")] + #[cfg_attr(feature = "clap", value(alias = "trades"))] + Trade, + #[strum(to_string = "instrument", serialize = "instruments")] + #[cfg_attr(feature = "clap", value(alias = "instruments"))] + Instrument, + #[strum(to_string = "executions", serialize = "execution")] + #[cfg_attr(feature = "clap", value(alias = "execution"))] + Executions, + #[strum(to_string = "balances", serialize = "balance")] + #[cfg_attr(feature = "clap", value(alias = "balance"))] + Balances, + #[strum(to_string = "level3", serialize = "l3")] + #[cfg_attr(feature = "clap", value(name = "level3", alias = "l3"))] + Level3, +} + +impl SubscribableChannel { + /// Which socket endpoint serves this channel. + pub const fn endpoint(self) -> Endpoint { + match self { + Self::Ticker | Self::Trade | Self::Book | Self::Ohlc | Self::Instrument => { + Endpoint::Public + } + Self::Executions | Self::Balances => Endpoint::Auth, + Self::Level3 => Endpoint::L3, + } + } +} + +/// Widen a [`SubscribableChannel`] to the full [`Channel`] catalogue. Total and trivial: +/// every subscribable channel *is* a channel, carried directly in [`Channel::Subscribable`]. +/// The reverse is partial — the system feeds have no subscribable form. +impl From for Channel { + fn from(channel: SubscribableChannel) -> Self { + Self::Subscribable(channel) + } +} + +/// The **full** set of Kraken v2 `channel` values, the discriminant for *incoming* +/// frames: every [`SubscribableChannel`] (carried by [`Channel::Subscribable`]) plus the +/// server-pushed system feeds [`Channel::Status`] and [`Channel::Heartbeat`]. Subscribe +/// planning uses [`SubscribableChannel`] directly. Deliberately strict: a name outside +/// this catalogue fails the frame, so a new server channel surfaces as a logged error to +/// fix, never as silently reinterpreted data. +/// +/// `Channel` *embeds* [`SubscribableChannel`] rather than re-listing its variants, so +/// "every subscribable channel is a channel" is a type-level fact and the two can never +/// drift. The flat v2 wire-name round-trip is all derived: `Display` (`#[strum(transparent)]` +/// forwards the subscribable variant to [`SubscribableChannel`]; the system feeds use their +/// lowercased names), and serde via [`SerializeDisplay`]/[`DeserializeFromStr`] over that +/// `Display` and the [`FromStr`] below. Only [`FromStr`] is hand-written — it routes the +/// system feeds, which no derive expresses. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Display, SerializeDisplay, DeserializeFromStr, +)] +#[strum(serialize_all = "lowercase")] +pub enum Channel { + /// A channel a client can subscribe to — the bare channel identity, whose wire name + /// (and so its serde form) comes straight from [`SubscribableChannel`]. + #[strum(transparent)] + Subscribable(SubscribableChannel), + /// Server-pushed trading-engine status, generated automatically on a successful + /// connection. Carries data but is **not subscribable**. + Status, + /// Server-pushed connection heartbeat. **Not subscribable** and payload-less. + Heartbeat, +} + +impl FromStr for Channel { + type Err = strum::ParseError; + + /// Parse a v2 wire `channel` name. The system feeds are matched first (case- + /// insensitively, mirroring [`SubscribableChannel`]); every other name delegates to + /// [`SubscribableChannel`], keeping a single source of truth for the subscribable names. + fn from_str(s: &str) -> std::result::Result { + if s.eq_ignore_ascii_case("status") { + Ok(Self::Status) + } else if s.eq_ignore_ascii_case("heartbeat") { + Ok(Self::Heartbeat) + } else { + s.parse().map(Self::Subscribable) + } + } +} + +// Allowed values per the `book`, `ohlc`, and `level3` subscribe schemas +// (docs.kraken.com/api/docs/websocket-v2/{book,ohlc,level3}). +const VALID_BOOK_DEPTHS: &[u32] = &[10, 25, 100, 500, 1000]; +const VALID_OHLC_INTERVALS: &[u32] = &[1, 5, 15, 30, 60, 240, 1440, 10080, 21600]; +const VALID_L3_DEPTHS: &[u32] = &[10, 100, 1000]; + +/// The "not one of the allowed values" error shared by the depth/interval newtypes. +fn invalid_one_of(value: impl std::fmt::Display, valid: &[u32], label: &str) -> Error { + Error::Validation(format!( + "invalid {label} '{value}' (valid: {})", + valid + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + )) +} + +/// Parse `s` as a `u32`, mapping a non-numeric input onto the shared allow-list error. +fn parse_u32(s: &str, valid: &[u32], label: &str) -> Result { + s.parse().map_err(|_| invalid_one_of(s, valid, label)) +} + +/// Order-book depth for the `book` channel: one of {10, 25, 100, 500, 1000}. Its +/// validating [`TryFrom`] is the canonical constructor ([`FromStr`] delegates), +/// and it serializes transparently as the wire integer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct BookDepth(u32); + +/// Book depth when the caller gives none. +const DEFAULT_BOOK_DEPTH: u32 = 10; + +impl BookDepth { + /// The wire depth value. + pub const fn depth(self) -> u32 { + self.0 + } +} + +impl Default for BookDepth { + fn default() -> Self { + Self(DEFAULT_BOOK_DEPTH) + } +} + +impl TryFrom for BookDepth { + type Error = Error; + fn try_from(depth: u32) -> Result { + VALID_BOOK_DEPTHS + .contains(&depth) + .then_some(Self(depth)) + .ok_or_else(|| invalid_one_of(depth, VALID_BOOK_DEPTHS, "book depth")) + } +} + +impl std::str::FromStr for BookDepth { + type Err = Error; + fn from_str(s: &str) -> Result { + parse_u32(s, VALID_BOOK_DEPTHS, "book depth")?.try_into() + } +} + +impl std::fmt::Display for BookDepth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// Candle interval in minutes for the `ohlc` channel: one of the fixed Kraken set. +/// Its validating [`TryFrom`] is the canonical constructor ([`FromStr`] delegates). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct OhlcInterval(u32); + +/// Candle interval (minutes) when the caller gives none. +const DEFAULT_OHLC_INTERVAL: u32 = 1; + +impl OhlcInterval { + /// The wire interval in minutes. + pub const fn minutes(self) -> u32 { + self.0 + } +} + +impl Default for OhlcInterval { + fn default() -> Self { + Self(DEFAULT_OHLC_INTERVAL) + } +} + +impl TryFrom for OhlcInterval { + type Error = Error; + fn try_from(minutes: u32) -> Result { + VALID_OHLC_INTERVALS + .contains(&minutes) + .then_some(Self(minutes)) + .ok_or_else(|| invalid_one_of(minutes, VALID_OHLC_INTERVALS, "OHLC interval")) + } +} + +impl std::str::FromStr for OhlcInterval { + type Err = Error; + fn from_str(s: &str) -> Result { + parse_u32(s, VALID_OHLC_INTERVALS, "OHLC interval")?.try_into() + } +} + +impl std::fmt::Display for OhlcInterval { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// Price levels per side for the `level3` channel: one of {10, 100, 1000}. Optional +/// on the wire (absent means the server default), so unlike [`BookDepth`] it has no +/// `Default`. Its validating [`TryFrom`] is the canonical constructor +/// ([`FromStr`] delegates). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct Level3Depth(u32); + +impl Level3Depth { + /// The wire depth value. + pub const fn depth(self) -> u32 { + self.0 + } +} + +impl TryFrom for Level3Depth { + type Error = Error; + fn try_from(depth: u32) -> Result { + VALID_L3_DEPTHS + .contains(&depth) + .then_some(Self(depth)) + .ok_or_else(|| invalid_one_of(depth, VALID_L3_DEPTHS, "level3 depth")) + } +} + +impl std::str::FromStr for Level3Depth { + type Err = Error; + fn from_str(s: &str) -> Result { + parse_u32(s, VALID_L3_DEPTHS, "level3 depth")?.try_into() + } +} + +impl std::fmt::Display for Level3Depth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "clap")] + fn subscribable_cli_values_parse_aliases_case_insensitively() { + use clap::ValueEnum; + let parse = |s| ::from_str(s, true); + assert_eq!(parse("trades").unwrap(), SubscribableChannel::Trade); + assert_eq!( + parse("EXECUTIONS").unwrap(), + SubscribableChannel::Executions + ); + assert_eq!(parse("l3").unwrap(), SubscribableChannel::Level3); + assert!(parse("nope").is_err()); + } + + #[test] + #[cfg(feature = "clap")] + fn system_feeds_are_not_subscribable_cli_values() { + use clap::ValueEnum; + let parse = |s| ::from_str(s, true); + assert!(parse("status").is_err()); + assert!(parse("heartbeat").is_err()); + } + + #[test] + fn channel_parses_every_wire_value_including_system_feeds() { + assert_eq!( + "trade".parse::().unwrap(), + Channel::Subscribable(SubscribableChannel::Trade) + ); + assert_eq!("status".parse::().unwrap(), Channel::Status); + assert_eq!("heartbeat".parse::().unwrap(), Channel::Heartbeat); + assert!("nope".parse::().is_err()); + } + + #[test] + fn subscribable_endpoint_partitions_sockets() { + assert_eq!(SubscribableChannel::Ticker.endpoint(), Endpoint::Public); + assert_eq!(SubscribableChannel::Executions.endpoint(), Endpoint::Auth); + assert_eq!(SubscribableChannel::Level3.endpoint(), Endpoint::L3); + } + + #[test] + fn subscribable_widens_to_the_matching_channel() { + for (sub, name) in [ + (SubscribableChannel::Ticker, "ticker"), + (SubscribableChannel::Trade, "trade"), + (SubscribableChannel::Level3, "level3"), + (SubscribableChannel::Executions, "executions"), + ] { + let channel: Channel = sub.into(); + assert_eq!(channel.to_string(), name); + assert_eq!(sub.to_string(), name); + } + } + + #[test] + fn book_depth_validates_its_allowed_set() { + assert_eq!(BookDepth::default().0, 10); + assert!("25".parse::().is_ok()); + assert!("7".parse::().is_err()); + } + + #[test] + fn ohlc_interval_and_l3_depth_validate_their_allowed_sets() { + assert!("60".parse::().is_ok()); + assert!("2".parse::().is_err()); + assert!("100".parse::().is_ok()); + assert!("25".parse::().is_err()); + } + + #[test] + fn book_depth_try_from_accepts_exactly_the_allowed_set() { + for &depth in VALID_BOOK_DEPTHS { + assert_eq!(BookDepth::try_from(depth).unwrap().depth(), depth); + } + assert!(BookDepth::try_from(0).is_err()); + assert!(BookDepth::try_from(7).is_err()); + assert_eq!(BookDepth::default().to_string(), "10"); + } + + #[test] + fn ohlc_interval_try_from_accepts_exactly_the_allowed_set() { + for &minutes in VALID_OHLC_INTERVALS { + assert_eq!(OhlcInterval::try_from(minutes).unwrap().minutes(), minutes); + } + assert!(OhlcInterval::try_from(0).is_err()); + assert!(OhlcInterval::try_from(2).is_err()); + assert_eq!(OhlcInterval::default().to_string(), "1"); + } + + #[test] + fn level3_depth_try_from_accepts_exactly_the_allowed_set() { + for &depth in VALID_L3_DEPTHS { + assert_eq!(Level3Depth::try_from(depth).unwrap().depth(), depth); + } + assert!(Level3Depth::try_from(0).is_err()); + assert!(Level3Depth::try_from(25).is_err()); + assert_eq!(Level3Depth::try_from(1000).unwrap().to_string(), "1000"); + } +} diff --git a/crates/kraken-core/src/subscribe/message/balances.rs b/crates/kraken-core/src/subscribe/message/balances.rs new file mode 100644 index 0000000..84cfefd --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/balances.rs @@ -0,0 +1,214 @@ +//! The `balances` channel: asset balances and account-ledger transactions. +//! +//! Source: . A snapshot entry +//! carries the per-wallet breakdown (`wallets`); an update entry carries the ledger +//! transaction (`amount`, `fee`, `ledger_id`, …). Both share `asset`, `asset_class` +//! and the resulting total `balance`; each shape's exclusive fields are `Option`s. +//! +//! The ledger vocabulary fields decode into open enums: documented tokens are typed +//! variants, and anything newer lands in that enum's `Unknown(String)` — the token +//! preserved and re-serialized verbatim (a `#[serde(untagged)]` fallback variant) — +//! while tripping the unknown-vocabulary warn. Drift on the account's own money +//! records is observed, never a hard error and never a narrowed record. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use strum::Display; + +/// One balance snapshot entry or ledger transaction (see the module schema). +/// +/// No `deny_unknown_fields`: an extensible inbound payload — a field Kraken adds later is +/// ignored rather than failing the decode, which would drop every frame on the channel. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BalanceData { + pub asset: String, + /// The docs pin only the placeholder `"currency"` and keep the set open, so this + /// stays a plain string — unlike the closed ledger vocabularies below, typed with + /// a verbatim `Unknown` fallback. Identifiers (`wallet_id`, `user`) stay strings. + pub asset_class: String, + /// The total held across all wallets after this event. + pub balance: Decimal, + /// Snapshot only: the per-wallet breakdown. + pub wallets: Option>, + /// Update only: the signed change of this transaction. + pub amount: Option, + pub fee: Option, + pub ledger_id: Option, + pub ref_id: Option, + pub timestamp: Option, + /// The broad ledger event type (`deposit`, `trade`, `transfer`, …). + #[serde(rename = "type")] + pub event_type: Option, + pub subtype: Option, + pub category: Option, + pub wallet_type: Option, + pub wallet_id: Option, + pub user: Option, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +/// One wallet's share of an asset in a snapshot entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WalletBalance { + #[serde(rename = "type")] + pub wallet_type: WalletType, + pub id: String, + pub balance: Decimal, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +impl BalanceData { + /// Whether any ledger vocabulary field decoded through its `Unknown` fallback. + pub(crate) fn has_unknown_vocabulary(&self) -> bool { + matches!(self.event_type, Some(LedgerEntryType::Unknown(_))) + || matches!(self.subtype, Some(LedgerSubtype::Unknown(_))) + || matches!(self.category, Some(LedgerCategory::Unknown(_))) + || matches!(self.wallet_type, Some(WalletType::Unknown(_))) + || self.wallets.as_ref().is_some_and(|wallets| { + wallets + .iter() + .any(|w| matches!(w.wallet_type, WalletType::Unknown(_))) + }) + } +} + +/// The broad ledger event a balance update records. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum LedgerEntryType { + Deposit, + Withdrawal, + Trade, + Margin, + Adjustment, + Rollover, + Credit, + Transfer, + Settled, + Staking, + Sale, + Reserve, + Conversion, + Dividend, + Reward, + CreatorFee, + /// Vocabulary Kraken adds later; the token is preserved and re-serialized verbatim. + #[serde(untagged)] + #[strum(to_string = "{0}")] + Unknown(String), +} + +/// The transfer flavour of a `transfer`-type ledger entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +#[non_exhaustive] +pub enum LedgerSubtype { + SpotFromFutures, + SpotToFutures, + StakingFromSpot, + SpotFromStaking, + StakingToSpot, + SpotToStaking, + /// Vocabulary Kraken adds later; the token is preserved and re-serialized verbatim. + #[serde(untagged)] + #[strum(to_string = "{0}")] + Unknown(String), +} + +/// The fine-grained ledger category of an update entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +#[non_exhaustive] +pub enum LedgerCategory { + Deposit, + Withdrawal, + Trade, + MarginTrade, + MarginSettle, + MarginConversion, + Conversion, + Credit, + /// One word on the wire, unlike its kebab-case siblings. + #[serde(rename = "marginrollover")] + #[strum(serialize = "marginrollover")] + MarginRollover, + StakingRewards, + Instant, + EquityTrade, + Airdrop, + EquityDividend, + RewardBonus, + Nft, + BlockTrade, + /// Vocabulary Kraken adds later; the token is preserved and re-serialized verbatim. + #[serde(untagged)] + #[strum(to_string = "{0}")] + Unknown(String), +} + +/// Which wallet flavour holds a balance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +#[non_exhaustive] +pub enum WalletType { + Spot, + Earn, + /// Vocabulary Kraken adds later; the token is preserved and re-serialized verbatim. + #[serde(untagged)] + #[strum(to_string = "{0}")] + Unknown(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_ledger_vocabulary_decodes_typed() { + let entry: BalanceData = serde_json::from_str( + r#"{"asset":"BTC","asset_class":"currency","balance":1.0,"type":"creator_fee", + "category":"margin-trade","subtype":"spottofutures","wallet_type":"earn"}"#, + ) + .expect("documented tokens decode"); + assert_eq!(entry.event_type, Some(LedgerEntryType::CreatorFee)); + assert_eq!(entry.category, Some(LedgerCategory::MarginTrade)); + assert_eq!(entry.subtype, Some(LedgerSubtype::SpotToFutures)); + assert_eq!(entry.wallet_type, Some(WalletType::Earn)); + assert!(!entry.has_unknown_vocabulary()); + } + + #[test] + fn an_undocumented_ledger_token_survives_verbatim_and_is_flagged() { + // The account's own money record: an unrecognized token must neither fail the + // frame (the pre-typing risk) nor be replaced by a placeholder on output — it + // decodes as Unknown, re-serializes byte-equal, and trips the drift warn. + let entry: BalanceData = serde_json::from_str( + r#"{"asset":"BTC","asset_class":"currency","balance":1.0, + "type":"yield_farming","category":"marginrollover"}"#, + ) + .expect("an undocumented token still decodes"); + assert_eq!( + entry.event_type, + Some(LedgerEntryType::Unknown("yield_farming".into())) + ); + assert_eq!(entry.category, Some(LedgerCategory::MarginRollover)); + assert!(entry.has_unknown_vocabulary()); + let out = serde_json::to_value(&entry).expect("serializes"); + assert_eq!(out["type"], "yield_farming", "the original token survives"); + assert_eq!( + out["category"], "marginrollover", + "the one-word rename holds" + ); + } +} diff --git a/crates/kraken-core/src/subscribe/message/book.rs b/crates/kraken-core/src/subscribe/message/book.rs new file mode 100644 index 0000000..ce4abc9 --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/book.rs @@ -0,0 +1,29 @@ +//! The `book` channel: order-book snapshots and incremental updates. +//! +//! Source: . A `snapshot` carries +//! the full requested depth; an `update` carries only the changed levels — a level +//! with `qty == 0` means "remove this price". `checksum` is the CRC32 of the top 10 +//! levels per side. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// One price level in a `book` frame: `price` and the aggregated `qty` at it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PriceLevel { + pub price: Decimal, + pub qty: Decimal, +} + +/// One `book` snapshot/update entry. +/// +/// No `deny_unknown_fields`: an extensible inbound payload (see the `ticker` rationale) +/// — an exchange-added field is ignored, not a frame-dropping decode error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookData { + pub symbol: String, + pub bids: Vec, + pub asks: Vec, + pub checksum: u32, + pub timestamp: String, +} diff --git a/crates/kraken-core/src/subscribe/message/channel_message.rs b/crates/kraken-core/src/subscribe/message/channel_message.rs new file mode 100644 index 0000000..dd3a6f2 --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/channel_message.rs @@ -0,0 +1,437 @@ +//! The typed channel-data frame: one shared envelope around a channel-tagged payload, +//! mirroring the reply side's `MethodResult`. + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::super::channel::{Channel, SubscribableChannel}; +use super::balances::BalanceData; +use super::book::BookData; +use super::envelope::MessageType; +use super::executions::ExecutionData; +use super::instrument::InstrumentData; +use super::level3::Level3Data; +use super::ohlc::OhlcData; +use super::status::StatusData; +use super::ticker::TickerData; +use super::trade::TradeData; + +/// A strictly-typed, parsed channel-data frame: the shared `{channel, type, data, +/// sequence?}` envelope, with the channel-tagged payload flattened in so a frame +/// serializes back to its natural wire shape. Account channels carry a `sequence`; +/// market channels do not. Unknown top-level fields are ignored so the payload stays +/// the single source of truth. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChannelMessage { + #[serde(flatten)] + pub body: ChannelData, + #[serde(rename = "type")] + pub message_type: MessageType, + pub sequence: Option, +} + +/// The wire `channel` name paired with its `data` payload — serde dispatches on the +/// frame's own `channel` field, so the channel names its payload schema and a payload +/// that no longer deserializes is a hard error: protocol drift surfaces loud, per +/// frame, instead of flowing on mistyped. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "channel", content = "data", rename_all = "lowercase")] +pub enum ChannelData { + Ticker(Vec), + Trade(Vec), + Book(Vec), + Ohlc(Vec), + /// The one object-shaped payload: a single reference-data catalogue, not entries. + Instrument(InstrumentData), + Executions(Vec), + Balances(Vec), + Level3(Vec), + Status(Vec), +} + +impl ChannelMessage { + /// Decode a raw frame string — a test/diagnostic convenience over the full + /// classifier ([`Inbound`](crate::Inbound)'s `FromStr`). `None` for + /// anything that is not a channel-data frame. + pub fn parse(raw: &str) -> Option { + match raw.parse::() { + Ok(crate::Inbound::Channel(message)) => Some(message), + _ => None, + } + } + + /// The wire channel this frame belongs to. + pub fn channel(&self) -> Channel { + self.body.channel() + } + + /// Whether this is a `snapshot` frame — the initial backfill the server replays on + /// subscribe and on every reconnect — rather than a live `update`. Snapshot timestamps + /// describe historical data, so the live monitor excludes them from latency measurement. + pub fn is_snapshot(&self) -> bool { + self.message_type == MessageType::Snapshot + } + + /// One representative event instant for the frame — see + /// [`ChannelData::event_ts`] for the rule. `None` when the payload + /// carries no per-entry event time. + pub fn event_ts(&self) -> Option<&str> { + self.body.event_ts() + } +} + +impl ChannelData { + /// Whether any enum-typed field in this payload decoded through an `Unknown` + /// fallback — vocabulary Kraken added after this crate's catalogue was pinned. + /// The drift-observability hook: the connection actor warns on it, so tolerated + /// growth is still logged, never silent. Each payload walks its own vocabulary; + /// a new `Unknown` fallback must join its payload's walk. + pub fn has_unknown_vocabulary(&self) -> bool { + match self { + Self::Ticker(_) | Self::Trade(_) | Self::Book(_) | Self::Ohlc(_) => false, + Self::Instrument(data) => data.has_unknown_vocabulary(), + Self::Balances(entries) => entries.iter().any(BalanceData::has_unknown_vocabulary), + Self::Executions(entries) => entries.iter().any(ExecutionData::has_unknown_vocabulary), + Self::Level3(entries) => entries.iter().any(Level3Data::has_unknown_vocabulary), + Self::Status(entries) => entries.iter().any(StatusData::has_unknown_vocabulary), + } + } + + /// The wire channel this payload belongs to — the one payload → channel mapping. + pub fn channel(&self) -> Channel { + use SubscribableChannel as Sub; + match self { + Self::Ticker(_) => Channel::Subscribable(Sub::Ticker), + Self::Trade(_) => Channel::Subscribable(Sub::Trade), + Self::Book(_) => Channel::Subscribable(Sub::Book), + Self::Ohlc(_) => Channel::Subscribable(Sub::Ohlc), + Self::Instrument(_) => Channel::Subscribable(Sub::Instrument), + Self::Executions(_) => Channel::Subscribable(Sub::Executions), + Self::Balances(_) => Channel::Subscribable(Sub::Balances), + Self::Level3(_) => Channel::Subscribable(Sub::Level3), + Self::Status(_) => Channel::Status, + } + } + + /// One representative event instant for a payload whose entries each carry + /// their own: the *last* entry's timestamp, RFC3339 as delivered (candles + /// fall back per [`OhlcData::event_ts`]). The choice of entry is this + /// crate's convention, not a wire guarantee — defined once so every + /// consumer that needs a single instant per frame agrees on it. Only the + /// four market channels stamp per-entry times; every other payload is `None`. + pub fn event_ts(&self) -> Option<&str> { + match self { + Self::Ticker(entries) => entries.last().map(|t| t.timestamp.as_str()), + Self::Trade(entries) => entries.last().map(|t| t.timestamp.as_str()), + Self::Book(entries) => entries.last().map(|b| b.timestamp.as_str()), + Self::Ohlc(entries) => entries.last().map(OhlcData::event_ts), + Self::Instrument(_) + | Self::Executions(_) + | Self::Balances(_) + | Self::Level3(_) + | Self::Status(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::super::executions::{ExecType, LiquidityIndicator, OrderStatus}; + use super::super::level3::Level3Event; + use super::super::status::SystemStatus; + use super::*; + use crate::PriceType; + + /// Decode `raw` and pin the round trip: the typed frame must re-serialize to + /// the captured wire value. + fn round_trips(raw: &str) -> ChannelMessage { + let msg = ChannelMessage::parse(raw).expect("frame decodes"); + assert_eq!( + serde_json::to_value(&msg).unwrap(), + serde_json::from_str::(raw).unwrap(), + "typed frame must re-serialize to the captured wire value" + ); + msg + } + + #[test] + fn parses_trade_into_typed_variant() { + let raw = r#"{"channel":"trade","type":"update","data":[{ + "symbol":"BTC/USD","side":"buy","price":50000.1,"qty":0.5, + "ord_type":"market","trade_id":42,"timestamp":"2026-01-01T00:00:00.000000Z"}]}"#; + let ChannelData::Trade(data) = round_trips(raw).body else { + panic!("expected Trade"); + }; + assert_eq!(data[0].price, "50000.1".parse().unwrap()); + } + + #[test] + fn ticker_round_trips_the_captured_wire_shape() { + // Field set as delivered live by wss://ws.kraken.com/v2 (2026-07). + let raw = r#"{"channel":"ticker","type":"update","data":[{ + "symbol":"BTC/USD","bid":62710.4,"bid_qty":0.01,"ask":62710.5, + "ask_qty":0.93111634,"last":62710.9,"volume":1691.31962925,"vwap":62538.9, + "low":61700.0,"high":63200.0,"change":220.1,"change_pct":0.35, + "timestamp":"2026-07-09T17:20:01.123456Z"}]}"#; + let ChannelData::Ticker(data) = round_trips(raw).body else { + panic!("expected Ticker"); + }; + assert_eq!(data[0].bid, "62710.4".parse().unwrap()); + assert_eq!(data[0].change_pct, "0.35".parse().unwrap()); + } + + #[test] + fn book_update_round_trips_including_the_checksum() { + // A checksummed update with a qty-0 removal level — both must survive the + // round trip byte-exactly or checksum verification breaks downstream. + let raw = r#"{"channel":"book","type":"update","data":[{ + "symbol":"BTC/USD", + "bids":[{"price":62710.4,"qty":0.01},{"price":62709.1,"qty":0.0}], + "asks":[{"price":62710.5,"qty":0.93111634}], + "checksum":1361442827,"timestamp":"2026-07-09T17:20:01.123456Z"}]}"#; + let ChannelData::Book(data) = round_trips(raw).body else { + panic!("expected Book"); + }; + assert_eq!(data[0].checksum, 1_361_442_827); + assert_eq!(data[0].bids[1].qty, "0".parse().unwrap()); + } + + #[test] + fn ohlc_round_trips_with_and_without_the_deprecated_timestamp() { + // https://docs.kraken.com/api/docs/websocket-v2/ohlc — `timestamp` is + // deprecated wire-side; both shapes must round-trip. + let with = r#"{"channel":"ohlc","type":"update","data":[{ + "symbol":"BTC/USD","open":62500.0,"high":62800.5,"low":62400.1, + "close":62710.9,"vwap":62538.9,"trades":314,"volume":42.5, + "interval_begin":"2026-07-09T17:15:00.000000Z","interval":5, + "timestamp":"2026-07-09T17:20:00.000000Z"}]}"#; + let ChannelData::Ohlc(data) = round_trips(with).body else { + panic!("expected Ohlc"); + }; + assert_eq!(data[0].trades, 314); + + let without = r#"{"channel":"ohlc","type":"update","data":[{ + "symbol":"BTC/USD","open":62500.0,"high":62800.5,"low":62400.1, + "close":62710.9,"vwap":62538.9,"trades":314,"volume":42.5, + "interval_begin":"2026-07-09T17:15:00.000000Z","interval":5}]}"#; + let ChannelData::Ohlc(data) = round_trips(without).body else { + panic!("expected Ohlc"); + }; + assert_eq!(data[0].timestamp, None); + } + + #[test] + fn decodes_instrument_reference_data() { + let raw = r#"{"channel":"instrument","type":"snapshot","data":{ + "assets":[{"id":"USD","status":"enabled","precision":4,"precision_display":2, + "borrowable":true,"collateral_value":1.0,"margin_rate":0.015}], + "pairs":[{"symbol":"BTC/USD","base":"BTC","quote":"USD","status":"online", + "qty_precision":8,"qty_increment":1e-08,"price_precision":1,"cost_precision":5, + "marginable":true,"has_index":true,"cost_min":0.5,"margin_initial":0.2, + "position_limit_long":250,"position_limit_short":200,"tick_size":0.1, + "price_increment":0.1,"qty_min":0.0001}]}}"#; + let ChannelData::Instrument(data) = round_trips(raw).body else { + panic!("expected Instrument"); + }; + assert_eq!(data.assets[0].id, "USD"); + assert_eq!(data.pairs[0].symbol, "BTC/USD"); + } + + #[test] + fn decodes_execution_trade_event() { + let raw = r#"{"channel":"executions","type":"update","data":[{ + "order_id":"OK4GJX-KSTLS-7DZZO5","order_userref":3,"exec_id":"TGBB7L-HT5LX-J3BZ4A", + "exec_type":"trade","trade_id":62887576,"symbol":"BTC/USD","side":"sell", + "last_qty":0.005,"last_price":26599.9,"liquidity_ind":"t","cost":132.9995, + "order_type":"limit","timestamp":"2023-09-22T10:33:05.709993Z", + "order_status":"partially_filled","cum_qty":0.005,"cum_cost":132.9995, + "avg_price":26599.9,"fee_usd_equiv":0.3458, + "fees":[{"asset":"USD","qty":0.3458}]}],"sequence":10}"#; + let msg = round_trips(raw); + assert_eq!(msg.sequence, Some(10)); + let ChannelData::Executions(data) = msg.body else { + panic!("expected Executions"); + }; + assert_eq!(data[0].exec_type, ExecType::Trade); + assert_eq!(data[0].order_status, Some(OrderStatus::PartiallyFilled)); + assert_eq!(data[0].liquidity_ind, Some(LiquidityIndicator::Taker)); + assert_eq!(data[0].last_price, Some("26599.9".parse().unwrap())); + } + + #[test] + fn decodes_execution_pending_new_event() { + let raw = r#"{"channel":"executions","type":"update","data":[{ + "order_id":"OK4GJX-KSTLS-7DZZO5","order_userref":3,"symbol":"BTC/USD", + "order_qty":0.005,"cum_cost":0.0,"time_in_force":"GTC", + "exec_type":"pending_new","side":"sell","order_type":"limit", + "limit_price_type":"static","limit_price":26500.0,"stop_price":0.0, + "order_status":"pending_new","fee_usd_equiv":0.0,"fee_ccy_pref":"fciq", + "timestamp":"2023-09-22T10:33:05.709950Z"}],"sequence":8}"#; + let ChannelData::Executions(data) = round_trips(raw).body else { + panic!("expected Executions"); + }; + assert_eq!(data[0].exec_type, ExecType::PendingNew); + assert_eq!(data[0].order_status, Some(OrderStatus::PendingNew)); + assert_eq!(data[0].limit_price_type, Some(PriceType::Static)); + } + + #[test] + fn decodes_balance_ledger_update() { + let raw = r#"{"channel":"balances","type":"update","data":[{ + "ledger_id":"AAICKV-NMQSR-ZO5IJD","ref_id":"AGBB7L-HT5LX-J3BB4A", + "timestamp":"2023-09-22T10:33:05.710082Z","type":"trade","asset":"BTC", + "asset_class":"currency","category":"trade","wallet_type":"spot", + "wallet_id":"main","amount":-0.005,"fee":0.0,"balance":0.005}],"sequence":9}"#; + let ChannelData::Balances(data) = round_trips(raw).body else { + panic!("expected Balances"); + }; + assert_eq!(data[0].asset, "BTC"); + assert_eq!(data[0].amount, Some("-0.005".parse().unwrap())); + assert_eq!( + data[0].event_type, + Some(super::super::balances::LedgerEntryType::Trade) + ); + } + + #[test] + fn decodes_level3_order_events() { + let raw = r#"{"channel":"level3","type":"update","data":[{ + "checksum":2841398499,"symbol":"MATIC/USD","bids":[], + "asks":[{"event":"delete","order_id":"OOIATY-6EIWY-ACVIUN", + "limit_price":0.5636,"order_qty":302.89736033, + "timestamp":"2023-10-06T18:21:00.097010033Z"}]}]}"#; + let ChannelData::Level3(data) = round_trips(raw).body else { + panic!("expected Level3"); + }; + assert_eq!(data[0].asks[0].event, Some(Level3Event::Delete)); + assert!(data[0].bids.is_empty()); + } + + #[test] + fn decodes_status_announcement() { + let raw = r#"{"channel":"status","type":"update","data":[{ + "system":"online","api_version":"v2","connection_id":1,"version":"2.0"}]}"#; + let ChannelData::Status(data) = round_trips(raw).body else { + panic!("expected Status"); + }; + assert_eq!(data[0].system, SystemStatus::Online); + } + + #[test] + fn extra_field_on_a_typed_channel_is_not_dropped() { + // Market payloads tolerate unknown fields on decode but do not carry them + // (pinned hot-path schemas, no `extra` catch-all — see `ExtraFields`); + // account/reference payloads preserve them, pinned below. + let raw = r#"{"channel":"trade","type":"update","data":[{ + "symbol":"BTC/USD","side":"buy","price":50000.1,"qty":0.5, + "ord_type":"market","trade_id":42,"timestamp":"TS","new_exchange_field":true}]}"#; + assert!(matches!( + ChannelMessage::parse(raw), + Some(ChannelMessage { + body: ChannelData::Trade(_), + .. + }) + )); + } + + #[test] + fn unknown_wire_fields_survive_to_the_output_on_account_channels() { + // The verbatim guarantee main gave these channels, kept under typing: a field + // Kraken adds decodes into the `extra` catch-all and re-serializes byte-equal + // (`round_trips` pins that), at the entry level and nested alike — an agent + // tailing `-o json` never sees a silently narrowed record. + let raw = r#"{"channel":"executions","type":"update","data":[{ + "order_id":"O1","exec_type":"trade","timestamp":"TS", + "new_fee_model":{"tier":1}, + "fees":[{"asset":"USD","qty":0.34,"rebate":true}]}],"sequence":3}"#; + let msg = round_trips(raw); + let ChannelData::Executions(data) = msg.body else { + panic!("expected Executions"); + }; + assert_eq!(data[0].extra["new_fee_model"]["tier"], 1); + assert_eq!(data[0].fees.as_ref().unwrap()[0].extra["rebate"], true); + } + + #[test] + fn extra_top_level_field_is_tolerated() { + let raw = r#"{"channel":"status","type":"update","new_envelope_field":1,"data":[{ + "system":"online","api_version":"v2","connection_id":1,"version":"2.0"}]}"#; + assert!(ChannelMessage::parse(raw).is_some()); + } + + #[test] + fn unknown_vocabulary_in_one_entry_keeps_the_whole_frame() { + let msg = ChannelMessage::parse( + r#"{"channel":"executions","type":"update","data":[ + {"order_id":"O1","exec_type":"settled","timestamp":"T1"}, + {"order_id":"O2","exec_type":"trade","timestamp":"T2"}]}"#, + ) + .expect("an unknown enum token degrades one field, never the frame"); + let ChannelData::Executions(entries) = msg.body else { + panic!("expected Executions"); + }; + assert_eq!(entries[0].exec_type, ExecType::Unknown); + assert_eq!( + entries[1].exec_type, + ExecType::Trade, + "sibling entries survive typed" + ); + } + + #[test] + fn flags_only_frames_carrying_unknown_vocabulary() { + let drifted = ChannelMessage::parse( + r#"{"channel":"executions","type":"update","data":[ + {"order_id":"O1","exec_type":"settled","timestamp":"T1"}]}"#, + ) + .expect("frame decodes"); + assert!(drifted.body.has_unknown_vocabulary()); + + let pinned = ChannelMessage::parse( + r#"{"channel":"executions","type":"update","data":[ + {"order_id":"O1","exec_type":"trade","timestamp":"T1"}]}"#, + ) + .expect("frame decodes"); + assert!(!pinned.body.has_unknown_vocabulary()); + } + + #[test] + fn schema_mismatch_on_a_typed_channel_is_a_hard_error() { + let raw = r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD", + "last":"not-a-number"}]}"#; + assert!(raw.parse::().is_err()); + } + + #[test] + fn skips_non_data_frames() { + assert!(ChannelMessage::parse(r#"{"channel":"heartbeat"}"#).is_none()); + assert!(ChannelMessage::parse(r#"{"method":"subscribe","success":true}"#).is_none()); + } + + /// [`Channel`] and [`ChannelData`]'s tag strings are two vocabularies synced only by + /// name: a channel without a payload counterpart would compile and first fail on a + /// live frame. Pin the pairing here instead. + #[test] + fn every_channel_in_the_catalogue_dispatches_to_a_payload() { + use strum::IntoEnumIterator; + let subscribables = SubscribableChannel::iter().map(|sub| { + let data = match sub { + SubscribableChannel::Instrument => r#"{"assets":[],"pairs":[]}"#, + _ => "[]", + }; + (sub.to_string(), data) + }); + // `status` carries data but is not subscribable; `heartbeat` is payload-less + // and routed before decode. + for (channel, data) in subscribables.chain([("status".into(), "[]")]) { + let raw = format!(r#"{{"channel":"{channel}","type":"update","data":{data}}}"#); + assert!( + ChannelMessage::parse(&raw).is_some(), + "channel `{channel}` has no ChannelData counterpart" + ); + } + } +} diff --git a/crates/kraken-core/src/subscribe/message/envelope.rs b/crates/kraken-core/src/subscribe/message/envelope.rs new file mode 100644 index 0000000..3575ecc --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/envelope.rs @@ -0,0 +1,17 @@ +//! The snapshot/update discriminant every channel-data frame carries. + +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +/// Whether a frame carries a full snapshot or an incremental update. +/// +/// `Display` and `FromStr` speak the same lowercase wire form as serde (matching +/// [`OrderSide`](crate::OrderSide)/[`OrderType`](crate::OrderType)), so a consumer that +/// stores the rendered tag can parse it back without a second vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum MessageType { + Snapshot, + Update, +} diff --git a/crates/kraken-core/src/subscribe/message/executions.rs b/crates/kraken-core/src/subscribe/message/executions.rs new file mode 100644 index 0000000..1638487 --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/executions.rs @@ -0,0 +1,298 @@ +//! The `executions` channel: order status and execution events for the account +//! (the v2 successor of the v1 `openOrders` + `ownTrades` channels). +//! +//! Source: . The fields an +//! entry carries depend on its `exec_type` (`pending_new`, `new`, `trade`, `filled`, +//! `canceled`, …), so only the identity trio — `order_id`, `exec_type`, `timestamp` — +//! is required and everything conditional is an `Option`. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use strum::Display; + +use crate::{OrderSide, OrderType, PriceType, TimeInForce, TriggerReference}; + +/// This channel emits `time_in_force` UPPERCASE (`"GTC"`), unlike the lowercase +/// request-side tokens. Decode case-insensitively into the shared enum and re-emit the +/// channel's own casing, so a round-trip is byte-stable. +mod tif_channel_case { + use serde::de::Error as _; + use serde::{Deserialize, Deserializer, Serializer}; + + use crate::TimeInForce; + + pub(super) fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(tif) => serializer.serialize_str(&tif.to_string().to_uppercase()), + None => serializer.serialize_none(), + } + } + + pub(super) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let token = Option::::deserialize(deserializer)?; + token + .map(|t| t.parse::().map_err(D::Error::custom)) + .transpose() + } +} + +/// One execution report (see the module schema). +/// +/// No `deny_unknown_fields`: an extensible inbound payload — a field Kraken adds later is +/// ignored rather than failing the decode, which would drop every frame on the channel. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ExecutionData { + pub order_id: String, + pub exec_type: ExecType, + pub timestamp: String, + pub exec_id: Option, + pub order_status: Option, + pub order_userref: Option, + pub cl_ord_id: Option, + pub symbol: Option, + pub side: Option, + pub order_type: Option, + pub order_qty: Option, + pub cash_order_qty: Option, + pub display_qty: Option, + pub display_qty_remain: Option, + pub limit_price: Option, + pub limit_price_type: Option, + #[serde(default, with = "tif_channel_case")] + pub time_in_force: Option, + pub effective_time: Option, + pub expire_time: Option, + pub last_qty: Option, + pub last_price: Option, + pub cost: Option, + pub cum_qty: Option, + pub cum_cost: Option, + pub avg_price: Option, + pub liquidity_ind: Option, + pub trade_id: Option, + pub fees: Option>, + pub fee_usd_equiv: Option, + pub fee_ccy_pref: Option, + pub liquidated: Option, + pub margin: Option, + pub margin_borrow: Option, + pub no_mpp: Option, + pub post_only: Option, + pub reduce_only: Option, + pub position_status: Option, + pub amended: Option, + pub reason: Option, + pub ord_ref_id: Option, + pub ext_ord_id: Option, + pub ext_exec_id: Option, + pub sender_sub_id: Option, + pub user: Option, + pub contingent: Option, + pub triggers: Option, + /// Deprecated on the wire (use `triggers`); still sent, so still decoded. + pub stop_price: Option, + /// Deprecated on the wire (use `triggers`); still sent, so still decoded. + pub trigger: Option, + /// Deprecated on the wire (use `triggers`); still sent, so still decoded. + pub triggered_price: Option, + /// Deprecated on the wire (use `reason`); still sent, so still decoded. + pub cancel_reason: Option, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +impl ExecutionData { + /// Whether any channel-local enum field decoded through its `Unknown` fallback. + /// Shared request-side vocabularies (`side`, `order_type`, `time_in_force`, …) + /// stay strict and are deliberately not walked here. + pub(crate) fn has_unknown_vocabulary(&self) -> bool { + self.exec_type == ExecType::Unknown + || self.order_status == Some(OrderStatus::Unknown) + || self.liquidity_ind == Some(LiquidityIndicator::Unknown) + || self.fee_ccy_pref == Some(FeeCurrencyPreference::Unknown) + || self.position_status == Some(PositionStatus::Unknown) + || self + .triggers + .as_ref() + .is_some_and(|t| t.status == Some(TriggerState::Unknown)) + } +} + +/// One fee paid on a trade event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Fee { + pub asset: String, + pub qty: Decimal, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +/// Fee currency preference as this channel spells it — the REST-era flags (`fcib` = +/// fee in base, `fciq` = fee in quote), not the request side's `base`/`quote`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +#[non_exhaustive] +pub enum FeeCurrencyPreference { + Fcib, + Fciq, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +/// The template for the secondary close orders a primary fill spawns. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ContingentOrder { + /// The docs list only the trigger-capable subset of [`OrderType`] here; decoding + /// with the full enum keeps one order-type vocabulary. + pub order_type: Option, + pub trigger_price: Option, + pub trigger_price_type: Option, + pub limit_price: Option, + pub limit_price_type: Option, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +/// The parameters and live state of a triggered order type's price trigger. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TriggerStatus { + pub reference: Option, + pub price: Option, + pub price_type: Option, + pub actual_price: Option, + pub peak_price: Option, + pub last_price: Option, + pub status: Option, + pub timestamp: Option, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +/// The lifecycle event an execution report describes; decides which conditional +/// fields are present. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum ExecType { + PendingNew, + New, + Trade, + Filled, + IcebergRefill, + Canceled, + Expired, + Amended, + Restated, + Status, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +/// The order's state after the event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum OrderStatus { + PendingNew, + New, + PartiallyFilled, + Filled, + Canceled, + Expired, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +/// Whether the fill added (maker) or removed (taker) book liquidity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum LiquidityIndicator { + #[serde(rename = "m")] + Maker, + #[serde(rename = "t")] + Taker, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other, rename = "unknown")] + Unknown, +} + +/// The margin position's state on a margin order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum PositionStatus { + Opened, + Closing, + Closed, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +/// Whether the price trigger has fired. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum TriggerState { + Triggered, + Untriggered, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn time_in_force_round_trips_the_channels_uppercase_casing() { + let entry: ExecutionData = serde_json::from_str( + r#"{"order_id":"O1","exec_type":"new","timestamp":"TS","time_in_force":"GTC"}"#, + ) + .expect("the channel's UPPERCASE token decodes into the shared enum"); + assert_eq!(entry.time_in_force, Some(TimeInForce::Gtc)); + let out = serde_json::to_value(&entry).unwrap(); + assert_eq!(out["time_in_force"], "GTC", "re-emits the channel's casing"); + } + + #[test] + fn an_unknown_time_in_force_token_is_a_hard_error() { + let result = serde_json::from_str::( + r#"{"order_id":"O1","exec_type":"new","timestamp":"TS","time_in_force":"WEEKLY"}"#, + ); + assert!(result.is_err(), "vocabulary drift must fail loud"); + } + + #[test] + fn an_unknown_exec_type_token_falls_back_to_unknown() { + let entry: ExecutionData = + serde_json::from_str(r#"{"order_id":"O1","exec_type":"settled","timestamp":"TS"}"#) + .expect("a token outside the pinned vocabulary degrades the field, not the entry"); + assert_eq!(entry.exec_type, ExecType::Unknown); + let out = serde_json::to_value(&entry).unwrap(); + assert_eq!( + out["exec_type"], "unknown", + "the original token is not preserved" + ); + } +} diff --git a/crates/kraken-core/src/subscribe/message/instrument.rs b/crates/kraken-core/src/subscribe/message/instrument.rs new file mode 100644 index 0000000..2e97ba6 --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/instrument.rs @@ -0,0 +1,118 @@ +//! The `instrument` channel: reference data for all active assets and tradeable pairs — +//! symbol identifiers, precisions, trading parameters and rules. +//! +//! Source: . Unlike the other +//! channels, `data` is a single object holding two catalogues, not an array of entries. +//! Fields the docs mark conditional (margin parameters, tokenized-asset extras) are +//! `Option`s; everything else is required. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use strum::Display; + +/// One asset in the reference catalogue. +/// +/// No `deny_unknown_fields`: an extensible inbound payload — a field Kraken adds later is +/// ignored rather than failing the decode, which would drop every frame on the channel. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AssetInfo { + pub id: String, + pub status: AssetStatus, + pub precision: u32, + pub precision_display: u32, + pub borrowable: Option, + pub collateral_value: Option, + pub margin_rate: Option, + /// Fixed conversion rate of a tokenized asset. + pub multiplier: Option, + pub class: Option, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +/// One tradeable pair in the reference catalogue. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PairInfo { + pub symbol: String, + pub base: String, + pub quote: String, + pub status: PairStatus, + pub qty_precision: u32, + pub price_precision: u32, + pub cost_precision: u32, + pub qty_increment: Decimal, + pub price_increment: Decimal, + pub qty_min: Decimal, + pub marginable: Option, + pub has_index: Option, + pub cost_min: Option, + pub margin_initial: Option, + pub position_limit_long: Option, + pub position_limit_short: Option, + pub ws_display_price_precision: Option, + /// Deprecated on the wire (use `price_increment`); still sent, so still decoded. + pub tick_size: Option, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +/// An asset's tradability state. Unseparated lowercase tokens on the wire +/// (`depositonly`), unlike the pair statuses' snake_case. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +#[non_exhaustive] +pub enum AssetStatus { + DepositOnly, + Disabled, + Enabled, + FundingTemporarilyDisabled, + WithdrawalOnly, + WorkInProgress, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +/// A pair's trading state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum PairStatus { + CancelOnly, + Delisted, + LimitOnly, + Maintenance, + Online, + PostOnly, + ReduceOnly, + WorkInProgress, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +/// The `data` object: the full asset and pair catalogues (snapshot), or the changed +/// subset (update). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InstrumentData { + pub assets: Vec, + pub pairs: Vec, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +impl InstrumentData { + /// Whether any channel-local enum field decoded through its `Unknown` fallback. + pub(crate) fn has_unknown_vocabulary(&self) -> bool { + self.assets.iter().any(|a| a.status == AssetStatus::Unknown) + || self.pairs.iter().any(|p| p.status == PairStatus::Unknown) + } +} diff --git a/crates/kraken-core/src/subscribe/message/level3.rs b/crates/kraken-core/src/subscribe/message/level3.rs new file mode 100644 index 0000000..e44eebc --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/level3.rs @@ -0,0 +1,66 @@ +//! The `level3` channel: the full order-by-order book. +//! +//! Source: . A snapshot lists the +//! resting orders per side; an update lists order events (`add`/`modify`/`delete`), so +//! `event` is absent on snapshot orders. The entry-level `timestamp` the schema +//! describes is absent in captured payloads, hence optional. + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// The book mutation an update entry describes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum Level3Event { + Add, + Modify, + Delete, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} + +/// One book side's resting order (snapshot) or order event (update). +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Level3Order { + /// Update only; absent on snapshot orders. + pub event: Option, + pub order_id: String, + pub limit_price: Decimal, + pub order_qty: Decimal, + pub timestamp: String, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +/// One `level3` entry: a symbol's per-order book state or change (see the module schema). +/// +/// No `deny_unknown_fields`: an extensible inbound payload — a field Kraken adds later is +/// ignored rather than failing the decode, which would drop every frame on the channel. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Level3Data { + pub symbol: String, + /// CRC32 over the top price levels, for client-side book verification. + pub checksum: u32, + pub timestamp: Option, + pub bids: Vec, + pub asks: Vec, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +impl Level3Data { + /// Whether any channel-local enum field decoded through its `Unknown` fallback. + pub(crate) fn has_unknown_vocabulary(&self) -> bool { + self.bids + .iter() + .chain(&self.asks) + .any(|order| order.event == Some(Level3Event::Unknown)) + } +} diff --git a/crates/kraken-core/src/subscribe/message/mod.rs b/crates/kraken-core/src/subscribe/message/mod.rs new file mode 100644 index 0000000..28e127d --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/mod.rs @@ -0,0 +1,45 @@ +//! Incoming channel-data frames. +//! +//! Source: . Every frame is a +//! [`ChannelMessage`]: the shared `{channel, type, data, sequence?}` envelope around a +//! [`ChannelData`] payload, dispatched by serde on the frame's own `channel` field. +//! Every channel in the catalogue decodes into its own typed payload — [`ticker`], +//! [`trade`], [`book`], [`ohlc`], [`instrument`], [`executions`], [`balances`], +//! [`level3`], [`status`]. Strictly: there is no verbatim fallback; a payload parses +//! to its channel's schema or the frame fails. + +mod balances; +mod book; +mod channel_message; +mod envelope; +mod executions; +mod instrument; +mod level3; +mod ohlc; +mod status; +mod ticker; +mod trade; + +/// Catch-all for wire fields Kraken adds after a payload schema was pinned: captured +/// on decode and re-emitted on serialize, so account and reference records are never +/// silently narrowed to the pinned schema. Market payloads (`ticker`/`trade`/`book`/ +/// `ohlc`) deliberately omit it: stable pinned schemas on the hot path, where +/// `flatten`'s content buffering has a real per-entry cost. +pub type ExtraFields = serde_json::Map; + +pub use balances::{ + BalanceData, LedgerCategory, LedgerEntryType, LedgerSubtype, WalletBalance, WalletType, +}; +pub use book::{BookData, PriceLevel}; +pub use channel_message::{ChannelData, ChannelMessage}; +pub use envelope::MessageType; +pub use executions::{ + ContingentOrder, ExecType, ExecutionData, Fee, FeeCurrencyPreference, LiquidityIndicator, + OrderStatus, PositionStatus, TriggerState, TriggerStatus, +}; +pub use instrument::{AssetInfo, AssetStatus, InstrumentData, PairInfo, PairStatus}; +pub use level3::{Level3Data, Level3Event, Level3Order}; +pub use ohlc::OhlcData; +pub use status::{StatusData, SystemStatus}; +pub use ticker::TickerData; +pub use trade::TradeData; diff --git a/crates/kraken-core/src/subscribe/message/ohlc.rs b/crates/kraken-core/src/subscribe/message/ohlc.rs new file mode 100644 index 0000000..2316fed --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/ohlc.rs @@ -0,0 +1,39 @@ +//! The `ohlc` channel: candles at a fixed interval. +//! +//! Source: . + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// One `ohlc` candle. +/// +/// `timestamp` is deprecated on the wire (superseded by `interval_begin`) and so +/// optional; the recorder falls back to `interval_begin` when it is absent. +/// +/// No `deny_unknown_fields`: an extensible inbound payload (see the `ticker` rationale) +/// — an exchange-added field is ignored, not a frame-dropping decode error. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OhlcData { + pub symbol: String, + pub open: Decimal, + pub high: Decimal, + pub low: Decimal, + pub close: Decimal, + pub vwap: Decimal, + pub trades: u64, + pub volume: Decimal, + pub interval_begin: String, + pub interval: u32, + pub timestamp: Option, +} + +impl OhlcData { + /// The deprecated wire `timestamp` when present, else `interval_begin` — + /// the one event-time rule every consumer shares, so a recording's write + /// and read sides can't disagree. + pub fn event_ts(&self) -> &str { + self.timestamp.as_deref().unwrap_or(&self.interval_begin) + } +} diff --git a/crates/kraken-core/src/subscribe/message/status.rs b/crates/kraken-core/src/subscribe/message/status.rs new file mode 100644 index 0000000..a50a5cb --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/status.rs @@ -0,0 +1,45 @@ +//! The `status` channel: the engine's identity/health announcement, pushed unsolicited +//! on every (re)connect. +//! +//! Source: . + +use serde::{Deserialize, Serialize}; +use strum::Display; + +/// One `status` entry. +/// +/// No `deny_unknown_fields`: an extensible inbound payload — a field Kraken adds later is +/// ignored rather than failing the decode, which would drop every frame on the channel. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StatusData { + pub api_version: String, + pub connection_id: u64, + pub system: SystemStatus, + pub version: String, + /// Wire fields outside the pinned schema, preserved verbatim (see [`ExtraFields`](super::ExtraFields)). + #[serde(flatten)] + pub extra: super::ExtraFields, +} + +impl StatusData { + /// Whether any channel-local enum field decoded through its `Unknown` fallback. + pub(crate) fn has_unknown_vocabulary(&self) -> bool { + self.system == SystemStatus::Unknown + } +} + +/// The trading engine's operating mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum SystemStatus { + Online, + Maintenance, + CancelOnly, + PostOnly, + LimitOnly, + /// Vocabulary Kraken adds later; re-serializes as `"unknown"`, not the original token. + #[serde(other)] + Unknown, +} diff --git a/crates/kraken-core/src/subscribe/message/ticker.rs b/crates/kraken-core/src/subscribe/message/ticker.rs new file mode 100644 index 0000000..eb2fbfe --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/ticker.rs @@ -0,0 +1,27 @@ +//! The `ticker` channel: best bid/ask plus rolling 24h stats. +//! +//! Source: . + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// One `ticker` snapshot/update entry. +/// +/// No `deny_unknown_fields`: an extensible inbound payload — a field Kraken adds later is +/// ignored rather than failing the decode, which would drop every frame on the channel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TickerData { + pub symbol: String, + pub bid: Decimal, + pub bid_qty: Decimal, + pub ask: Decimal, + pub ask_qty: Decimal, + pub last: Decimal, + pub volume: Decimal, + pub vwap: Decimal, + pub low: Decimal, + pub high: Decimal, + pub change: Decimal, + pub change_pct: Decimal, + pub timestamp: String, +} diff --git a/crates/kraken-core/src/subscribe/message/trade.rs b/crates/kraken-core/src/subscribe/message/trade.rs new file mode 100644 index 0000000..4b8dda0 --- /dev/null +++ b/crates/kraken-core/src/subscribe/message/trade.rs @@ -0,0 +1,26 @@ +//! The `trade` channel: individual executions. +//! +//! Source: . + +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use crate::{OrderSide, OrderType}; + +/// One `trade` execution entry. +/// +/// `ord_type` decodes into the full [`OrderType`] — a lenient superset of the docs' +/// `limit`/`market` taker types, so an unexpected value never drops the frame. +/// +/// No `deny_unknown_fields`: an extensible inbound payload (see the `ticker` rationale) +/// — an exchange-added field is ignored, not a frame-dropping decode error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TradeData { + pub symbol: String, + pub side: OrderSide, + pub price: Decimal, + pub qty: Decimal, + pub ord_type: OrderType, + pub trade_id: u64, + pub timestamp: String, +} diff --git a/crates/kraken-core/src/subscribe/mod.rs b/crates/kraken-core/src/subscribe/mod.rs new file mode 100644 index 0000000..8c5c023 --- /dev/null +++ b/crates/kraken-core/src/subscribe/mod.rs @@ -0,0 +1,22 @@ +//! Everything on the publish/subscribe side of the v2 protocol. +//! +//! Source: . +//! +//! - [`channel`] — the channel catalogue ([`SubscribableChannel`] / `Channel`) and the +//! validated depth/interval newtypes that parameterize a subscription. +//! - [`subscription`] — [`WsSubscription`], the strictly-typed `subscribe` params. +//! - [`message`] — [`ChannelMessage`], the typed inbound channel-data frames. +//! +//! Outgoing subscribe frames are wrapped by [`Wire`](crate::Wire); inbound +//! frames are classified by [`Inbound`](crate::Inbound)'s `FromStr`. + +mod channel; +mod subscription; +// `pub` so the feature-gated recorder can reach the typed payload entries by path +// (`subscribe::message::TickerData`, …) without flattening record-only types into +// the shared `domain` namespace. +pub mod message; + +pub use channel::{BookDepth, Channel, Level3Depth, OhlcInterval, SubscribableChannel}; +pub use message::{ChannelData, ChannelMessage}; +pub use subscription::{EventTrigger, Users, WsSubscription}; diff --git a/crates/kraken-core/src/subscribe/subscription.rs b/crates/kraken-core/src/subscribe/subscription.rs new file mode 100644 index 0000000..e12078c --- /dev/null +++ b/crates/kraken-core/src/subscribe/subscription.rs @@ -0,0 +1,310 @@ +//! [`WsSubscription`] — the strictly-typed `subscribe` params, one variant per channel. +//! +//! Source: the per-channel `subscribe` schemas under +//! . Params by channel: +//! +//! | channel | params | +//! |--------------|-------------------------------------------------------------------| +//! | `ticker` | `symbol[]`, `event_trigger` (`bbo`/`trades`), `snapshot` | +//! | `trade` | `symbol[]`, `snapshot` | +//! | `book` | `symbol[]`, `depth` (10/25/100/500/1000), `snapshot` | +//! | `ohlc` | `symbol[]`, `interval` (minutes), `snapshot` | +//! | `instrument` | `snapshot` (not symbol-scoped) | +//! | `executions` | `snap_trades`, `snap_orders`, `order_status`, `rebased`, `ratecounter`, `users` | +//! | `balances` | `snapshot`, `rebased`, `users` | +//! | `level3` | `symbol[]`, `depth` (10/100/1000), `snapshot` | +//! +//! Optional fields are `Option<_>` and omitted when unset, so the server applies its own +//! defaults. The auth token is not modelled here — it is flattened into `params` at send +//! time by [`Wire`](crate::Wire). + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use strum::{Display, EnumString}; + +use super::channel::{BookDepth, Level3Depth, OhlcInterval, SubscribableChannel}; +use crate::endpoint::Endpoint; + +/// What change re-publishes a `ticker` frame: every book event (`bbo`) or only a +/// trade (`trades`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum EventTrigger { + Bbo, + Trades, +} + +/// The `users` param on the account channels. `all` — the only value the wire +/// accepts — streams subaccount events alongside the master's (master accounts only). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum Users { + All, +} + +/// Channel-specific subscribe params, serialized with a `"channel"` tag. `None` +/// options are omitted, matching the v2 contract exactly. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "channel", rename_all = "lowercase")] +pub enum WsSubscription { + Ticker { + symbol: Vec, + event_trigger: Option, + snapshot: Option, + }, + Trade { + symbol: Vec, + snapshot: Option, + }, + Book { + symbol: Vec, + depth: BookDepth, + snapshot: Option, + }, + Ohlc { + symbol: Vec, + interval: OhlcInterval, + snapshot: Option, + }, + Instrument { + // The instrument channel is not symbol-scoped in the v2 docs; `symbol` is kept + // only so a caller *may* narrow the feed, and is dropped from the wire when empty. + #[serde(skip_serializing_if = "Vec::is_empty")] + symbol: Vec, + snapshot: Option, + }, + Executions { + snap_trades: Option, + snap_orders: Option, + order_status: Option, + rebased: Option, + ratecounter: Option, + users: Option, + }, + Balances { + snapshot: Option, + rebased: Option, + users: Option, + }, + Level3 { + symbol: Vec, + depth: Option, + snapshot: Option, + }, +} + +impl WsSubscription { + /// The wire `method` every subscription frame carries — the subscribe side's + /// counterpart of [`Request::METHOD`](crate::Request::METHOD). + pub const METHOD: &'static str = "subscribe"; + + /// Force the `snapshot` flag on — the connection does this when replaying a + /// subscription after a reconnect, so local state is rebuilt cleanly. A no-op for + /// `Executions`, which has no `snapshot` field (`snap_trades`/`snap_orders` instead). + pub fn force_snapshot(&mut self) { + match self { + Self::Ticker { snapshot: s, .. } + | Self::Trade { snapshot: s, .. } + | Self::Book { snapshot: s, .. } + | Self::Ohlc { snapshot: s, .. } + | Self::Instrument { snapshot: s, .. } + | Self::Balances { snapshot: s, .. } + | Self::Level3 { snapshot: s, .. } => *s = Some(true), + Self::Executions { .. } => {} + } + } + + /// The channel these params subscribe to. + pub fn channel(&self) -> SubscribableChannel { + match self { + Self::Ticker { .. } => SubscribableChannel::Ticker, + Self::Trade { .. } => SubscribableChannel::Trade, + Self::Book { .. } => SubscribableChannel::Book, + Self::Ohlc { .. } => SubscribableChannel::Ohlc, + Self::Instrument { .. } => SubscribableChannel::Instrument, + Self::Executions { .. } => SubscribableChannel::Executions, + Self::Balances { .. } => SubscribableChannel::Balances, + Self::Level3 { .. } => SubscribableChannel::Level3, + } + } + + /// The subscribed symbols (empty for account channels with no symbol). + pub fn symbols(&self) -> &[String] { + match self { + Self::Ticker { symbol, .. } + | Self::Trade { symbol, .. } + | Self::Book { symbol, .. } + | Self::Ohlc { symbol, .. } + | Self::Instrument { symbol, .. } + | Self::Level3 { symbol, .. } => symbol, + Self::Executions { .. } | Self::Balances { .. } => &[], + } + } + + /// Drop the symbols `keep` rejects, in place. Returns whether the subscription still + /// subscribes to anything: a symbol-scoped channel emptied out subscribes to nothing, + /// while the account channels always do. A narrowed `instrument` list emptied out also + /// counts as nothing — an empty list on the wire would *widen* to the whole feed. + #[must_use = "a dropped verdict replays an emptied subscription, which for `instrument` widens to the whole feed"] + pub fn retain_symbols(&mut self, mut keep: impl FnMut(&str) -> bool) -> bool { + match self { + Self::Ticker { symbol, .. } + | Self::Trade { symbol, .. } + | Self::Book { symbol, .. } + | Self::Ohlc { symbol, .. } + | Self::Level3 { symbol, .. } => { + symbol.retain(|s| keep(s)); + !symbol.is_empty() + } + Self::Instrument { symbol, .. } => { + if symbol.is_empty() { + return true; + } + symbol.retain(|s| keep(s)); + !symbol.is_empty() + } + Self::Executions { .. } | Self::Balances { .. } => true, + } + } + + /// The socket endpoint this subscription rides. + pub fn endpoint(&self) -> Endpoint { + self.channel().endpoint() + } + + /// Whether this subscription needs an auth token. + pub fn requires_token(&self) -> bool { + self.endpoint().requires_token() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Wire; + + fn frame(params: WsSubscription) -> serde_json::Value { + serde_json::to_value(Wire::new("subscribe", None, ¶ms, Some(1))).unwrap() + } + + fn executions() -> WsSubscription { + WsSubscription::Executions { + snap_trades: None, + snap_orders: None, + order_status: None, + rebased: None, + ratecounter: None, + users: None, + } + } + + fn ticker(symbol: Vec, event_trigger: Option) -> WsSubscription { + WsSubscription::Ticker { + symbol, + event_trigger, + snapshot: None, + } + } + + #[test] + fn ticker_frame_has_expected_shape() { + let v = frame(ticker( + vec!["BTC/USD".into(), "ETH/USD".into()], + Some(EventTrigger::Bbo), + )); + assert_eq!( + v, + serde_json::json!({ + "method": "subscribe", + "req_id": 1, + "params": { + "channel": "ticker", + "symbol": ["BTC/USD", "ETH/USD"], + "event_trigger": "bbo", + }, + }) + ); + } + + #[test] + fn users_all_serializes_as_the_wire_string_and_round_trips() { + let v = frame(WsSubscription::Executions { + snap_trades: None, + snap_orders: None, + order_status: None, + rebased: None, + ratecounter: None, + users: Some(Users::All), + }); + assert_eq!(v["params"]["users"], "all"); + // `users` is omitted entirely when unset — never `null`. + assert!(frame(executions())["params"].get("users").is_none()); + let raw = serde_json::to_string(&Users::All).unwrap(); + assert_eq!(raw, r#""all""#); + assert_eq!(serde_json::from_str::(&raw).unwrap(), Users::All); + } + + #[test] + fn private_subscribe_flattens_token_into_params() { + let v = serde_json::to_value(Wire::new("subscribe", Some("tok-1"), &executions(), None)) + .unwrap(); + assert_eq!(v["params"]["channel"], "executions"); + assert_eq!(v["params"]["token"], "tok-1"); + assert!(v["params"].get("symbol").is_none()); + assert!(v.get("req_id").is_none()); + } + + #[test] + fn endpoint_and_token_follow_the_channel() { + let ticker = ticker(vec!["X".into()], None); + assert_eq!(ticker.endpoint(), Endpoint::Public); + assert!(!ticker.requires_token()); + assert!(executions().requires_token()); + let level3 = WsSubscription::Level3 { + symbol: vec!["X".into()], + depth: None, + snapshot: None, + }; + assert_eq!(level3.endpoint(), Endpoint::L3); + } +} + +#[cfg(test)] +mod retain_properties { + use super::*; + + proptest::proptest! { + /// `retain_symbols` on a symbol-scoped channel reports exactly whether + /// anything remains, and only ever narrows the list — the invariant the + /// replay/eviction machinery leans on. + #[test] + fn retain_reports_exactly_whether_anything_remains( + symbols in proptest::collection::vec("[A-Z]{2,4}/USD", 0..6), + keep_mask in proptest::collection::vec(proptest::bool::ANY, 6), + ) { + let original = symbols.clone(); + let keep = |s: &str| { + original + .iter() + .position(|o| o == s) + .is_some_and(|i| keep_mask[i]) + }; + let mut sub = WsSubscription::Ticker { + symbol: symbols, + event_trigger: None, + snapshot: None, + }; + let anything_left = sub.retain_symbols(keep); + let WsSubscription::Ticker { symbol, .. } = &sub else { + unreachable!("retain never changes the variant"); + }; + proptest::prop_assert_eq!(anything_left, !symbol.is_empty()); + proptest::prop_assert!(symbol.iter().all(|s| keep(s) && original.contains(s))); + } + } +} diff --git a/crates/kraken-core/src/wire.rs b/crates/kraken-core/src/wire.rs new file mode 100644 index 0000000..a5ea5f4 --- /dev/null +++ b/crates/kraken-core/src/wire.rs @@ -0,0 +1,143 @@ +//! Outgoing framing: the [`Wire`] envelope every method rides, and the [`Request`] +//! contract binding typed params to their method name and typed response. + +use std::num::NonZeroUsize; + +use serde::Serialize; +use serde_with::skip_serializing_none; + +use super::response::MethodReply; +use crate::endpoint::Endpoint; + +pub trait Request: Serialize { + type Response: Serialize; + + /// The wire `method` name for a request carrying these params. + const METHOD: &'static str; + + /// This request's typed result, or `None` when the reply answers a different + /// method or omits a required body — the caller turns that into a protocol error. + fn extract(reply: MethodReply) -> Option; + + /// How many correlated replies the server streams for this request. One for every + /// method except multi-id `cancel_order` (one per identifier); the caller compares + /// it against what arrived, so a truncated batch fails instead of passing silently. + fn expected_replies(&self) -> NonZeroUsize { + NonZeroUsize::MIN + } + + /// The socket this request rides. Defaults to the authenticated endpoint, where + /// every order method lives. + fn endpoint(&self) -> Endpoint { + Endpoint::Auth + } + + /// Whether the frame must carry an auth token. Derived from [`endpoint`](Self::endpoint) + /// — only the public socket is tokenless — so a request can never declare an endpoint + /// and a contradicting token requirement. (Mirrors `WsSubscription::requires_token`.) + fn requires_token(&self) -> bool { + self.endpoint().requires_token() + } +} + +/// A params object with the auth `token` flattened alongside its own fields — the +/// shape v2 expects (the token sits *inside* `params`). The `token` key is dropped when +/// absent (every public request: market-data subscriptions, `ping`). +#[skip_serializing_none] +#[derive(Serialize)] +struct Authenticated<'a, P: Serialize> { + token: Option<&'a str>, + #[serde(flatten)] + params: P, +} + +/// The `{method, params, req_id}` frame shared by every outgoing method — orders and +/// channel subscriptions. The token (when any) is flattened into `params`. +/// +/// An empty `params` object is never emitted: the v2 server rejects `params: {}` on +/// paramless methods like `ping`, and a token-less, field-less method has nothing to +/// send. This is enforced once, structurally, in [`Serialize`](Wire#impl-Serialize) — +/// no method opts out, so `ping` needs no special case. +pub struct Wire<'a, P: Serialize> { + method: &'static str, + params: Authenticated<'a, P>, + req_id: Option, +} + +/// Hand-written: `params` flattens the auth token in; a derived `Debug` would print +/// a live credential. +impl std::fmt::Debug for Wire<'_, P> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Wire") + .field("method", &self.method) + .field("req_id", &self.req_id) + .finish_non_exhaustive() + } +} + +impl<'a, P: Serialize> Wire<'a, P> { + pub fn new( + method: &'static str, + token: Option<&'a str>, + params: P, + req_id: Option, + ) -> Self { + Self { + method, + params: Authenticated { token, params }, + req_id, + } + } +} + +impl Serialize for Wire<'_, P> { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + use serde::ser::{Error, SerializeMap}; + + // Serialize the params (token already flattened in) up front so we can drop the + // key entirely when it collapses to an empty object. `RawValue` preserves the + // params' own field order, so every other method's wire bytes are unchanged. + let params = serde_json::value::to_raw_value(&self.params).map_err(Error::custom)?; + let has_params = params.get() != "{}"; + + let len = 1 + usize::from(has_params) + usize::from(self.req_id.is_some()); + let mut map = serializer.serialize_map(Some(len))?; + map.serialize_entry("method", self.method)?; + if has_params { + map.serialize_entry("params", ¶ms)?; + } + if let Some(req_id) = self.req_id { + map.serialize_entry("req_id", &req_id)?; + } + map.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CancelAllParams, PingParams}; + + #[test] + fn empty_params_object_is_dropped_from_the_frame() { + let v = serde_json::to_value(Wire::new("ping", None, &PingParams {}, Some(7))).unwrap(); + assert_eq!(v["method"], "ping"); + assert!(v.get("params").is_none()); + assert_eq!(v["req_id"], 7); + } + + #[test] + fn token_only_params_object_is_kept() { + let v = serde_json::to_value(Wire::new( + "cancel_all", + Some("tok-1"), + &CancelAllParams {}, + Some(3), + )) + .unwrap(); + assert_eq!(v["params"]["token"], "tok-1"); + } +} diff --git a/crates/kraken-lab/Cargo.toml b/crates/kraken-lab/Cargo.toml new file mode 100644 index 0000000..6752d4a --- /dev/null +++ b/crates/kraken-lab/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "kraken-lab" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Autoresearch Lab read models for kraken-cli: marked equity curve, fill ledger, scorecards" +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +chrono = { workspace = true } +humantime = { workspace = true } +kraken-core = { workspace = true } +kraken-paper = { workspace = true } +kraken-recording = { workspace = true } +kraken-replay = { workspace = true } +kraken-session = { workspace = true } +rust_decimal = { workspace = true } +rust_decimal_macros = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_with = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# Snapshot-pins the serialized command contracts (scorecard, curve, verdict), +# so an unintended shape change surfaces as a reviewable diff, not a silent one. +insta = { version = "1.48.0", features = ["json"] } +kraken-session = { workspace = true, features = ["fixtures"] } +# Pins fold determinism and prefix/no-lookahead over random inputs. +proptest = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/kraken-lab/README.md b/crates/kraken-lab/README.md new file mode 100644 index 0000000..88e1257 --- /dev/null +++ b/crates/kraken-lab/README.md @@ -0,0 +1,95 @@ +# kraken-lab + +The Autoresearch Lab's application layer: the [`Lab`] facade plus the read +models and experiment store beneath it. + +## Purpose + +Turn a recorded session into evidence: fold it into the two intermediates +every lab metric derives from — the marked equity curve and the fill ledger +(`replay()` → `Replay`) — score a stopped session against them (`score()` → +`Scorecard`), and judge that scorecard against a hypothesis frozen *before* +the run (`Criteria::evaluate` → `Verdict`). Valuation is against the session's +own recorded market track via `mark::Table`, never live prices and never the +stop anchor, so the same session folds to the same numbers forever. + +## Architecture + +One entry point, the **`Lab` facade** — the binary dispatches to it and does +nothing else lab-related. Each command resolves the config dir and session, +calls one `Lab` method (`freeze`/`show`/`score`/`compare`/`next`), and renders +the returned model; all orchestration lives behind the facade. Beneath it is a +**functional core, imperative shell**: the core computes, the shell is the +only code that touches the disk or the clock. `store` and the planner are +`pub(crate)`, so the seam is enforced by the compiler — the binary reaches them +only through `Lab`. + +**Functional core — pure, deterministic, no I/O, no clock:** + +- `mark` — the rolling mark `Table` (`observe`, `to_prices`): a left fold with + per-symbol median robustness, so a timeline prefix always yields a curve + prefix. +- `replay` — the curve fold: `replay()`, `Replay`, `Curve`, `Point`. +- `score` — the honest scorecard: carries the explain-pnl anchor and waterfall + **verbatim** (the can't-disagree contract) plus mechanical metrics (return, + drawdown, trade counts). +- `trades` — FIFO round-trip attribution over the fill ledger. +- `experiment` — the sealed-spec domain: `Experiment`/`Criteria`/`Verdict`, + the run plan (`RunPlan`/`PlannedRun`), `seal`/`verify` (hash a spec's + canonical bytes; recompute on load so a post-freeze edit reads as damage), + and `Criteria::evaluate`. +- `naming` — the run-session grammar: `run_id`/`is_run_of`/`parse_run` and + `RunRef`. +- `plan` — the pure planner: `next_action` derives the next step from a sealed + plan and the facade's `RunObservation`s. Never stored — always re-derived. +- `window` — the `20s`/`5m`/`1h` run-window grammar, shared with the + playground's `--duration` so a sealed live leg can never be one it rejects. +- `hash` — the `sha256:` vocabulary shared by the seal and the tape pin. + +**Imperative shell — the one filesystem-and-clock seam:** + +- `store` — `freeze` (stamps the clock, seals, writes atomically), `load` + (reads, then delegates the seal check to `experiment::verify`), and + `discover_runs` (the run-discovery seam with manifest-link validation). + File-backed state makes every verdict reproducible from the store. + +The core depends only on `kraken-session`/`kraken-replay` types; the shell adds +`std::fs` and `chrono::Utc::now`. `sha256_file` stays a free function: it +hashes arbitrary replay tapes under `recordings/`, which the store does not +own. + +## Interface + +The command seam is `Lab` — `new`, `freeze`, `show`, `score`, `compare`, +`next`, `ensure_planned_source` — returning `ScoredRun`/`Comparison`/ +`NextAction`/`Experiment`. Its result and domain types cross upward too: +`Scorecard`/`Metrics`/`TradeStats`/`CurveSummary`, `Experiment`/`Criteria`/ +`Verdict`/`Check`/`RunPlan`/`PlannedRun`, `RunOutcome`/`OutcomeResult`/`Judged`, +`RunRef`/`RunObservation`/`RunStatus`/`NextState`, and `LabError`. The read +models stay public for reuse (`replay()`/`Replay`/`Curve`/`Point`, +`mark::Table`, `score()`) alongside `run_id`/`is_run_of`/`parse_run`, +`parse_window`, `sha256_file`, and `experiments_root`; the store and planner +functions are `pub(crate)` behind the facade. The serde response structs are +the command contract — clap-free by construction. + +## Errors + +`LabError` — `NoEpoch`/`ExperimentNotFound`/`ExperimentExists`/`Spec` +(→ envelope `validation`), `Damaged` (a tampered or undecodable frozen spec; +→ `parse`), `Store` (→ `io`), and the transparent `Recording`/`Session`/`Replay` +bridges over the lower crates' errors. `LabError::category()` mirrors the wire +codes so a failed `compare` column can label itself; the binary's `errors` +module owns the authoritative envelope mapping. + +## Testing + +Fold determinism (byte-identical on a re-fold) and prefix/no-lookahead +proptests; the mark table's median, latch, and canonicalization pins; the +golden spec-hash pin (a serde drift breaks every seal on disk, and fails here +first); the seal/`verify` round-trip and tamper/rename refusals; the run-name +grammar round-trip; and `tests/*.rs` driving fixture sessions through the +public API only. + +```text +cargo test -p kraken-lab +``` diff --git a/crates/kraken-lab/src/app.rs b/crates/kraken-lab/src/app.rs new file mode 100644 index 0000000..e953ce5 --- /dev/null +++ b/crates/kraken-lab/src/app.rs @@ -0,0 +1,516 @@ +//! Application facade for experiment planning, scoring, and comparison. +//! +//! Orchestration lives here; the binary only resolves scope and renders models. + +use std::path::PathBuf; + +use kraken_recording::{Source, TapeBackend, TapeRef, TapeSource, TapeState, describe_tape}; +use kraken_session::manifest::SessionManifest; +use kraken_session::session::{SessionTracks, read_window, session_dir, session_file_path}; + +use crate::experiment::{Criteria, Experiment, PlannedSession, SessionPlan}; +use crate::plan::{NextAction, NextState, SessionObservation, SessionStatus}; +use crate::{LabError, Result, Scorecard, SessionRef, Verdict, sha256_file}; + +/// Local copy of the scope contract; this lower layer cannot depend on workspaces. +const JOURNAL_FILE: &str = "journal.jsonl"; + +/// The lab's entry point. Owns the scope root, so the binary threads it once +/// at construction rather than into every call. +pub struct Lab { + base: PathBuf, + /// Global tape library, distinct from workspace-local experiment state. + library: PathBuf, +} + +/// A scored run, optionally judged against a frozen experiment. +#[derive(Debug)] +pub struct ScoredSession { + pub card: Scorecard, + /// Present only when [`Lab::score`] was given an experiment to judge + /// against; carries the seal so the CLI can attribute the verdict. + pub judged: Option, +} + +/// A scorecard's verdict against a named, sealed experiment. +#[derive(Debug)] +pub struct Judged { + pub experiment: String, + pub frozen: String, + pub verdict: Verdict, +} + +/// Every discovered run of an experiment, scored and judged side by side. +#[derive(Debug)] +pub struct Comparison { + pub experiment: String, + pub frozen: String, + pub outcomes: Vec, +} + +/// One session's column: the session it names plus its mechanical result. +#[derive(Debug)] +pub struct SessionResult { + pub session: SessionRef, + pub result: OutcomeResult, +} + +/// A run either scored-and-judged, or a named failure — never an omission. +#[derive(Debug)] +pub enum OutcomeResult { + Scored { + card: Box, + verdict: Verdict, + }, + /// `category: detail`, pre-formatted so the CLI renders a named failure + /// column verbatim without knowing the crate's error taxonomy. + Failed { error: String }, +} + +impl Comparison { + /// Mechanically-scored, passing sessions. Aborted or unscoreable runs never + /// count — the promotion gate's direct input. + pub fn pass_count(&self) -> usize { + self.outcomes + .iter() + .filter(|outcome| { + matches!(&outcome.result, OutcomeResult::Scored { verdict, .. } if verdict.pass) + }) + .count() + } + + pub fn total(&self) -> usize { + self.outcomes.len() + } +} + +impl Lab { + pub fn new(base: impl Into) -> Self { + let base = base.into(); + Self { + library: base.clone(), + base, + } + } + + /// Point `tape:` resolution at the global shared library — the + /// binary passes the config root whenever the lab runs inside a + /// workspace scope. + #[must_use] + pub fn with_library(mut self, library: impl Into) -> Self { + self.library = library.into(); + self + } + + /// Freeze a pre-registered experiment: resolve and validate the session plan + /// (binding each replay leg to its sealed bytes), then seal and store it. + pub fn freeze( + &self, + name: &str, + hypothesis: &str, + strategy: Option, + criteria: Criteria, + runs: &[String], + ) -> Result { + let session_plan = self.resolve_session_plan(runs)?; + crate::store::freeze( + &self.base, + name, + hypothesis, + strategy, + criteria, + session_plan, + ) + } + + /// Load a frozen experiment, verifying its seal. + /// Every sealed experiment in this scope — the promotion checklist's + /// evidence walk starts here. + pub fn experiments(&self) -> Result> { + crate::store::experiments(&self.base) + } + + pub fn show(&self, name: &str) -> Result { + crate::store::load(&self.base, name) + } + + /// Scores a stopped session, verifying any experiment seal before folding. + pub fn score(&self, ordinal: u32, judge_against: Option<&str>) -> Result { + let experiment = judge_against + .map(|name| crate::store::load(&self.base, name)) + .transpose()?; + let card = self.read_and_score(ordinal)?; + let judged = experiment.map(|experiment| Judged { + verdict: experiment.criteria.evaluate(&card), + experiment: experiment.name, + frozen: experiment.frozen, + }); + Ok(ScoredSession { card, judged }) + } + + /// Compare every discovered run of an experiment: each re-scored from disk + /// and judged against the sealed criteria, failures kept as named columns. + /// Runs attribute to the sealed name, so discovery keys off that identity. + pub fn compare(&self, name: &str) -> Result { + let experiment = crate::store::load(&self.base, name)?; + let outcomes = crate::store::discover_sessions(&self.base, &experiment.name)? + .into_iter() + .map(|run| { + let result = self.score_and_judge(run.ordinal, &experiment); + SessionResult { + session: run, + result, + } + }) + .collect(); + Ok(Comparison { + experiment: experiment.name, + frozen: experiment.frozen, + outcomes, + }) + } + + /// What the sealed plan says to do now. When it proposes starting a replay + /// leg, the sealed tape is re-checked here too — so `lab next` never hands + /// out a start command for a tape that has drifted from its seal. + pub fn next(&self, name: &str) -> Result { + let experiment = crate::store::load(&self.base, name)?; + let observations = self.observe_sessions(&experiment.name)?; + let action = crate::plan::next_action(&experiment, &observations)?; + if let (NextState::Start, Some(source)) = (action.state, action.source.as_deref()) { + self.ensure_planned_source(&experiment, source)?; + } + Ok(action) + } + + /// Refuse a replay source whose bytes no longer match the plan's sealed + /// `content_hash`; a no-op (→ `None`) for an unplanned source. On success + /// it returns the verified hash so `run start` can stamp the + /// manifest without re-hashing the same tape. Public because a manual + /// `run start` enforces the seal through it too. + pub fn ensure_planned_source( + &self, + experiment: &Experiment, + source: &str, + ) -> Result> { + let Some(plan) = &experiment.session_plan else { + return Ok(None); + }; + // The plan owns how a replay leg is identified; the guard only hashes + // and compares, so start- and completion-time enforcement can't drift. + let Some(sealed) = plan.sealed_replay_hash(source) else { + return Ok(None); + }; + let tape: TapeRef = source.parse()?; + let path = kraken_replay::resolve_source_path(&self.library, &self.base, &tape)?; + let current = sha256_file(&path)?; + if current != sealed { + return Err(LabError::Spec(format!( + "tape '{source}' no longer matches the frozen plan (sealed {sealed}, current {current}); a re-recorded tape must not satisfy a frozen session plan — register a new experiment" + ))); + } + Ok(Some(current)) + } + + /// The single scoring seam: read a session's window of the shared journal and + /// fold it into a scorecard. A future pre-score gate or provenance check + /// lands here. + fn read_and_score(&self, ordinal: u32) -> Result { + let manifest = SessionManifest::load(&session_file_path(&self.base, ordinal))?; + let timeline = read_window(SessionTracks { + journal: self.base.join(JOURNAL_FILE), + session_dir: session_dir(&self.base, ordinal), + manifest, + })?; + crate::score(&timeline) + } + + fn score_and_judge(&self, ordinal: u32, experiment: &Experiment) -> OutcomeResult { + match self.read_and_score(ordinal) { + Ok(card) => OutcomeResult::Scored { + verdict: experiment.criteria.evaluate(&card), + card: Box::new(card), + }, + Err(err) => OutcomeResult::Failed { + error: format!("{}: {err}", err.category()), + }, + } + } + + /// Observe every discovered session's lifecycle from its `session.json` and + /// recorder lock. Completed = finalized with a summary; Running = the + /// recorder still holds a lock on a tape; anything else (crashed, damaged, + /// summaryless) is Aborted — it can never count toward the plan. + fn observe_sessions(&self, experiment: &str) -> Result> { + let mut observations = Vec::new(); + for run in crate::store::discover_sessions(&self.base, experiment)? { + let run_file = session_file_path(&self.base, run.ordinal); + let (status, source, content_hash) = match SessionManifest::load(&run_file) { + Ok(manifest) => { + let src = manifest.source.as_ref(); + let source = src.map(|source| source.tape.clone()); + let content_hash = src.and_then(|source| source.content_hash.clone()); + let status = if manifest.summary.is_some() { + SessionStatus::Completed + } else if self.recorder_alive(run.ordinal, &manifest) { + SessionStatus::Running + } else { + SessionStatus::Aborted + }; + (status, source, content_hash) + } + // Discovery already skips a damaged session.json; this arm covers + // the race where it broke between the two reads. + Err(err) => { + tracing::warn!(session = %run.session, error = %err, + "session.json unreadable; treating the session as aborted"); + (SessionStatus::Aborted, None, None) + } + }; + observations.push(SessionObservation { + ordinal: run.ordinal, + session: run.session, + status, + source, + content_hash, + }); + } + Ok(observations) + } + + /// Whether a session's recorder still holds a recording lock on a tape in the + /// session directory — the kernel drops it the instant the process dies, a + /// signal a recycled pid can't spoof. A lab run always records, so this is + /// exactly its liveness. + fn recorder_alive(&self, ordinal: u32, manifest: &SessionManifest) -> bool { + let dir = session_dir(&self.base, ordinal); + manifest + .recordings + .iter() + .any(|recording| kraken_recording::is_lock_held(&dir.join(&recording.file))) + } + + /// Parse and resolve `--session` entries. Replay entries bind name → bytes + /// here, at freeze time — the only moment that binding is made: the tape + /// must exist and be finalized, and its content hash goes into the seal so + /// a re-recorded tape can never satisfy the frozen plan. + fn resolve_session_plan(&self, runs: &[String]) -> Result> { + if runs.is_empty() { + return Ok(None); + } + let plan = runs + .iter() + .map(|run| self.resolve_planned_run(run)) + .collect::>>()?; + Ok(Some(SessionPlan(plan))) + } + + fn resolve_planned_run(&self, run: &str) -> Result { + if let Some(window) = run.strip_prefix("live:") { + let window = window.trim(); + if window.is_empty() { + return Err(LabError::Spec( + "--session live: needs a window (e.g. live:24h)".into(), + )); + } + // The seal is immutable, so a window `run start --for` can't run + // would brick the plan. Validate it now with the exact parser + // that command uses, not just a non-empty check. + crate::window::parse_window(window).map_err(|why| { + LabError::Spec(format!( + "--session live:{window}: invalid window ({why}) — e.g. live:24h or live:2h 30m" + )) + })?; + return Ok(PlannedSession::Live { + window: window.to_string(), + }); + } + if let Some(tape) = run.strip_prefix("replay:") { + let tape: TapeRef = tape.parse()?; + let path = kraken_replay::resolve_source_path(&self.library, &self.base, &tape)?; + // Only a finalized tape can be sealed: hashing a still-growing file + // would brick the plan the moment more bytes land. + let entry = describe_tape(&tape, &path, TapeBackend::Jsonl); + if entry.state != TapeState::Finalized { + return Err(LabError::Spec(format!( + "tape '{tape}' is not finalized (still recording, or crashed mid-capture); \ + a sealed session plan needs a finalized tape — see 'kraken tape list'" + ))); + } + // Finalized only means a sidecar summary exists, not that any frames + // were captured. An empty tape hashes and seals fine, but `run start` + // refuses it ("holds no frames to replay"), so the sealed leg + // would strand the plan with an un-runnable next step. Require here + // what the runtime requires: at least one frame. + let source = TapeSource::open(&path)?; + if source.read()?.next().is_none() { + return Err(LabError::Spec(format!( + "tape '{tape}' holds no frames to replay; a sealed session plan needs a \ + non-empty tape — see 'kraken tape list'" + ))); + } + return Ok(PlannedSession::Replay { + tape: tape.to_string(), + content_hash: sha256_file(&path)?, + }); + } + Err(LabError::Spec(format!( + "invalid --session entry {run:?}: expected live: or replay:" + ))) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use rust_decimal_macros::dec; + + use super::*; + + fn criteria() -> Criteria { + Criteria { + min_return_pct: Some(dec!(1.0)), + max_drawdown_pct: None, + min_fills: Some(1), + } + } + + /// Write a finalized JSONL tape under `tapes/`; an empty + /// `frames` slice models a tape that finalized having captured nothing. + fn write_finalized_recording(base: &Path, name: &str, frames: &[&str]) { + let recordings = kraken_recording::tapes_root(base); + std::fs::create_dir_all(&recordings).unwrap(); + let tape = frames.iter().map(|f| format!("{f}\n")).collect::(); + std::fs::write(recordings.join(format!("{name}.jsonl")), tape).unwrap(); + std::fs::write( + recordings.join(format!("{name}.jsonl.meta.json")), + serde_json::json!({ + "schema_version": "1.0", + "source": "kraken-spot-ws-v2", + "symbols": ["BTC/USD"], + "channels": ["ticker"], + "window_start": "2026-01-01T00:00:00Z", + "cli_version": "0.0.0-test", + "window_end": "2026-01-01T00:00:20Z", + "events_dropped": 0, + "frames_unparsed": 0, + "reconnect_count": 0 + }) + .to_string(), + ) + .unwrap(); + } + + #[test] + fn freeze_rejects_a_live_window_the_duration_parser_cannot_run() { + let base = tempfile::tempdir().unwrap(); + let lab = Lab::new(base.path()); + for bad in ["live:10", "live:soon", "live:10x", "live:2 fortnights"] { + let err = lab + .freeze("m1", "h", None, criteria(), &[bad.to_string()]) + .unwrap_err(); + assert!(matches!(err, LabError::Spec(_)), "{bad}: got {err}"); + assert!(err.to_string().contains("invalid window"), "{bad}: {err}"); + } + } + + #[test] + fn freeze_accepts_the_windows_the_duration_parser_accepts() { + let base = tempfile::tempdir().unwrap(); + // Includes a compound span to pin that the seal path carries the space + // humantime allows straight through to the stored window. + for (n, good) in ["live:20s", "live:5m", "live:1h", "live:24h", "live:2h 30m"] + .into_iter() + .enumerate() + { + let lab = Lab::new(base.path()); + let experiment = lab + .freeze(&format!("m{n}"), "h", None, criteria(), &[good.to_string()]) + .unwrap(); + let plan = experiment.session_plan.expect("a plan was frozen"); + assert!( + matches!(plan.0.as_slice(), [PlannedSession::Live { .. }]), + "{good}" + ); + } + } + + #[test] + fn ensure_planned_source_refuses_a_drifted_replay_tape() { + let base = tempfile::tempdir().unwrap(); + let recordings = kraken_recording::tapes_root(base.path()); + std::fs::create_dir_all(&recordings).unwrap(); + let tape = recordings.join("crash.jsonl"); + std::fs::write(&tape, "the sealed frames\n").unwrap(); + let sealed = sha256_file(&tape).unwrap(); + let experiment = Experiment { + name: "m1".into(), + hypothesis: "h".into(), + strategy: None, + criteria: Criteria::default(), + created_at: "2026-01-01T00:00:00Z".parse().unwrap(), + session_plan: Some(SessionPlan(vec![PlannedSession::Replay { + tape: "tape:crash".into(), + content_hash: sealed, + }])), + frozen: "sha256:test".into(), + }; + let lab = Lab::new(base.path()); + + // Untouched bytes still satisfy the seal, and the verified hash comes + // back for the caller to reuse. + assert!( + lab.ensure_planned_source(&experiment, "tape:crash") + .unwrap() + .is_some() + ); + + // Re-recorded under the same ref: refused. + std::fs::write(&tape, "re-recorded, different bytes\n").unwrap(); + let err = lab + .ensure_planned_source(&experiment, "tape:crash") + .unwrap_err(); + assert!(matches!(err, LabError::Spec(_)), "got: {err}"); + assert!( + err.to_string() + .contains("no longer matches the frozen plan"), + "got: {err}" + ); + } + + #[test] + fn freeze_rejects_an_empty_finalized_replay_tape() { + let base = tempfile::tempdir().unwrap(); + write_finalized_recording(base.path(), "empty", &[]); + let err = Lab::new(base.path()) + .freeze( + "m1", + "h", + None, + criteria(), + &["replay:tape:empty".to_string()], + ) + .unwrap_err(); + assert!(matches!(err, LabError::Spec(_)), "got: {err}"); + assert!(err.to_string().contains("holds no frames"), "got: {err}"); + } + + #[test] + fn freeze_accepts_a_finalized_replay_tape_with_frames() { + let base = tempfile::tempdir().unwrap(); + let frame = r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD","bid":50000.0,"bid_qty":1.0,"ask":50000.0,"ask_qty":1.0,"last":50000.0,"volume":100.0,"vwap":50000.0,"low":50000.0,"high":50000.0,"change":0.0,"change_pct":0.0,"timestamp":"2026-01-01T00:00:00Z"}]}"#; + write_finalized_recording(base.path(), "full", &[frame]); + let experiment = Lab::new(base.path()) + .freeze( + "m1", + "h", + None, + criteria(), + &["replay:tape:full".to_string()], + ) + .unwrap(); + let plan = experiment.session_plan.expect("a plan was frozen"); + assert!(matches!(plan.0.as_slice(), [PlannedSession::Replay { .. }])); + } +} diff --git a/crates/kraken-lab/src/experiment.rs b/crates/kraken-lab/src/experiment.rs new file mode 100644 index 0000000..b3b1f8c --- /dev/null +++ b/crates/kraken-lab/src/experiment.rs @@ -0,0 +1,389 @@ +//! Immutable experiment specifications and mechanical verdicts. +//! +//! [`seal`] hashes canonical JSON before a run; [`verify`] treats later edits +//! as damage. Storage and clocks remain in [`store`](crate::store). + +use chrono::{DateTime, Utc}; +use kraken_session::SessionName; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::hash::sha256_hex; +use crate::{LabError, Result, Scorecard}; + +/// A frozen experiment: the spec plus the hash that seals it. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Experiment { + pub name: String, + /// The falsifiable claim, in the author's words. + pub hypothesis: String, + /// The strategy under test, if one is named. + pub strategy: Option, + pub criteria: Criteria, + /// Time included in the sealed specification. + pub created_at: DateTime, + /// Optional run plan sealed before results exist. + pub session_plan: Option, + /// `sha256:` over the spec's canonical JSON bytes (everything but + /// this field). + pub frozen: String, +} + +/// Mechanical success thresholds over a [`Scorecard`]. Every populated +/// criterion must pass; an unpopulated one is simply not checked. +#[skip_serializing_none] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Criteria { + /// Minimum `metrics.return_pct`, in percent. + pub min_return_pct: Option, + /// Maximum `metrics.max_drawdown_pct`, in percent. + pub max_drawdown_pct: Option, + /// Minimum number of fills — guards against a "pass" earned by never + /// trading. + pub min_fills: Option, +} + +impl Criteria { + fn is_empty(&self) -> bool { + self.min_return_pct.is_none() && self.max_drawdown_pct.is_none() && self.min_fills.is_none() + } + + /// Evaluates every configured threshold; missing observations fail closed. + pub fn evaluate(&self, card: &Scorecard) -> Verdict { + let mut checks = Vec::new(); + if let Some(threshold) = self.min_return_pct { + let observed = card.metrics.return_pct; + checks.push(Check { + criterion: "min_return_pct", + threshold, + observed, + pass: observed.is_some_and(|value| value >= threshold), + }); + } + if let Some(threshold) = self.max_drawdown_pct { + let observed = card.metrics.max_drawdown_pct; + checks.push(Check { + criterion: "max_drawdown_pct", + threshold, + observed, + pass: observed.is_some_and(|value| value <= threshold), + }); + } + if let Some(threshold) = self.min_fills { + let observed = Decimal::from(card.trades.fills as u64); + checks.push(Check { + criterion: "min_fills", + threshold: Decimal::from(threshold), + observed: Some(observed), + pass: observed >= Decimal::from(threshold), + }); + } + Verdict { + pass: checks.iter().all(|check| check.pass), + checks, + } + } +} + +/// The mechanical outcome: pass iff every check passed. +#[derive(Debug, Serialize)] +pub struct Verdict { + pub pass: bool, + pub checks: Vec, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct Check { + pub criterion: &'static str, + pub threshold: Decimal, + /// `None` when the scorecard could not derive the number — an honest + /// fail, never treated as met. + pub observed: Option, + pub pass: bool, +} + +/// Pre-registered runs in execution order, included in the sealed bytes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionPlan(pub Vec); + +/// A planned session whose serialized form is part of the permanent seal. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PlannedSession { + /// A live paper run over roughly this wall-clock window (e.g. "24h"). + Live { window: String }, + /// Seals the tape hash so a re-recording cannot satisfy the plan. + Replay { tape: String, content_hash: String }, +} + +impl PlannedSession { + /// The human handle `lab next` reports (`live:24h` / `replay:`). + pub fn label(&self) -> String { + match self { + Self::Live { window } => format!("live:{window}"), + Self::Replay { tape, .. } => format!("replay:{tape}"), + } + } +} + +impl SessionPlan { + /// Returns the hash bound to a replay leg, if present. + pub fn sealed_replay_hash(&self, tape: &str) -> Option<&str> { + self.0.iter().find_map(|entry| match entry { + PlannedSession::Replay { + tape: sealed, + content_hash, + } if sealed == tape => Some(content_hash.as_str()), + _ => None, + }) + } +} + +/// Canonical hash input: every contract field except `frozen`. +#[skip_serializing_none] +#[derive(Serialize)] +struct SpecView<'a> { + name: &'a str, + hypothesis: &'a str, + strategy: Option<&'a str>, + criteria: &'a Criteria, + created_at: &'a DateTime, + // Keeping this optional field last preserves hashes written before it existed. + session_plan: Option<&'a SessionPlan>, +} + +impl<'a> SpecView<'a> { + fn of(experiment: &'a Experiment) -> Self { + SpecView { + name: &experiment.name, + hypothesis: &experiment.hypothesis, + strategy: experiment.strategy.as_deref(), + criteria: &experiment.criteria, + created_at: &experiment.created_at, + session_plan: experiment.session_plan.as_ref(), + } + } +} + +fn spec_hash(view: &SpecView<'_>) -> Result { + let bytes = + serde_json::to_vec(view).map_err(|e| LabError::Damaged(format!("unhashable spec: {e}")))?; + Ok(sha256_hex(&bytes)) +} + +/// Validates and seals a specification without consulting storage or a clock. +pub(crate) fn seal( + name: &str, + hypothesis: &str, + strategy: Option, + criteria: Criteria, + session_plan: Option, + created_at: DateTime, +) -> Result { + // Experiment names later become session names, so both share one grammar. + if let Err(e) = name.parse::() { + return Err(LabError::Spec(e.to_string())); + } + if hypothesis.trim().is_empty() { + return Err(LabError::Spec( + "an experiment needs a hypothesis; pass --hypothesis".into(), + )); + } + if criteria.is_empty() { + return Err(LabError::Spec( + "an experiment needs at least one success criterion; pass --min-return-pct, \ + --max-drawdown-pct, or --min-fills" + .into(), + )); + } + if let Some(plan) = &session_plan + && plan.0.is_empty() + { + return Err(LabError::Spec( + "a session plan needs at least one --session entry (live: or replay:)" + .into(), + )); + } + let frozen = spec_hash(&SpecView { + name, + hypothesis, + strategy: strategy.as_deref(), + criteria: &criteria, + created_at: &created_at, + session_plan: session_plan.as_ref(), + })?; + Ok(Experiment { + name: name.to_string(), + hypothesis: hypothesis.to_string(), + strategy, + criteria, + created_at, + session_plan, + frozen, + }) +} + +/// Recompute the seal over a loaded spec and bind it to the name it was +/// requested under. A hash mismatch means the spec was edited after freezing; +/// a name mismatch means it was renamed (or resolved on a case-insensitive +/// filesystem) — both are [`LabError::Damaged`], so discovery can never key +/// off a tampered or foreign identity. +pub(crate) fn verify(experiment: &Experiment, requested_name: &str) -> Result<()> { + let expected = spec_hash(&SpecView::of(experiment))?; + if experiment.frozen != expected { + return Err(LabError::Damaged(format!( + "experiment '{requested_name}' does not match its frozen hash; the spec was modified \ + after freezing (recorded {}, recomputed {expected})", + experiment.frozen + ))); + } + if experiment.name != requested_name { + return Err(LabError::Damaged(format!( + "experiment '{requested_name}' resolves to a spec sealed under the name '{}'; a frozen \ + spec's filename must match its sealed name (names are case-sensitive)", + experiment.name + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + fn criteria() -> Criteria { + Criteria { + min_return_pct: Some(dec!(1.5)), + max_drawdown_pct: None, + min_fills: Some(3), + } + } + + fn plan() -> SessionPlan { + SessionPlan(vec![ + PlannedSession::Replay { + tape: "tape:jun-crash".to_string(), + content_hash: "sha256:abc".to_string(), + }, + PlannedSession::Live { + window: "24h".to_string(), + }, + ]) + } + + #[test] + fn seal_then_verify_round_trips_and_tamper_reads_as_damage() { + let created_at = "2026-01-01T00:00:00Z".parse().unwrap(); + let mut sealed = seal("m1", "h", None, criteria(), None, created_at).unwrap(); + assert!(sealed.frozen.starts_with("sha256:")); + verify(&sealed, "m1").unwrap(); + + // Move a goalpost by hand: the recomputed hash no longer matches. + sealed.criteria.min_return_pct = Some(dec!(0.0)); + let err = verify(&sealed, "m1").unwrap_err(); + assert!(matches!(err, LabError::Damaged(_)), "got: {err}"); + assert!(err.to_string().contains("modified after"), "got: {err}"); + } + + #[test] + fn verify_binds_the_sealed_name_to_the_requested_one() { + let created_at = "2026-01-01T00:00:00Z".parse().unwrap(); + let sealed = seal("m1", "h", None, criteria(), None, created_at).unwrap(); + let err = verify(&sealed, "m2").unwrap_err(); + assert!(matches!(err, LabError::Damaged(_)), "got: {err}"); + assert!( + err.to_string().contains("sealed under the name 'm1'"), + "got: {err}" + ); + } + + #[test] + fn seal_gates_reject_empty_criteria_bad_names_and_blank_hypotheses() { + let created_at = "2026-01-01T00:00:00Z".parse().unwrap(); + for (name, hypothesis, criteria) in [ + ("m1", "h", Criteria::default()), + ("bad/name", "h", criteria()), + ("m1", " ", criteria()), + ] { + let err = seal(name, hypothesis, None, criteria, None, created_at).unwrap_err(); + assert!(matches!(err, LabError::Spec(_)), "got: {err}"); + } + } + + #[test] + fn seal_rejects_an_empty_run_plan() { + let created_at = "2026-01-01T00:00:00Z".parse().unwrap(); + let err = seal( + "m1", + "h", + None, + criteria(), + Some(SessionPlan(Vec::new())), + created_at, + ) + .unwrap_err(); + assert!(matches!(err, LabError::Spec(_)), "got: {err}"); + assert!(err.to_string().contains("--session entry"), "got: {err}"); + } + + #[test] + fn planned_run_serializes_with_the_pinned_kind_tag() { + // This exact JSON is hashed into seals — a repr change would orphan + // every frozen spec that carries a plan. + let json = serde_json::to_value(plan()).unwrap(); + let pinned = serde_json::json!([ + {"kind": "replay", "tape": "tape:jun-crash", "content_hash": "sha256:abc"}, + {"kind": "live", "window": "24h"} + ]); + assert_eq!(json, pinned); + let back: SessionPlan = serde_json::from_value(json).unwrap(); + assert_eq!(back, plan()); + } + + #[test] + fn sealed_replay_hash_finds_the_leg_by_tape_ref() { + let plan = plan(); + assert_eq!( + plan.sealed_replay_hash("tape:jun-crash"), + Some("sha256:abc") + ); + assert_eq!(plan.sealed_replay_hash("tape:other"), None); + // A live leg has no sealed tape, so its window never resolves a hash. + assert_eq!(plan.sealed_replay_hash("24h"), None); + } + + /// Golden hash pin: the canonical spec bytes are a contract — a serde + /// rename, field reorder, or representation change breaks every frozen + /// experiment on disk, and must fail here first. + #[test] + fn spec_hash_is_pinned_for_a_golden_spec() { + let criteria = Criteria { + min_return_pct: Some(dec!(1.5)), + max_drawdown_pct: None, + min_fills: Some(3), + }; + let created_at = "2026-01-01T00:00:00Z".parse().unwrap(); + let view = SpecView { + name: "golden", + hypothesis: "the golden hypothesis", + strategy: Some("recipe-golden"), + criteria: &criteria, + created_at: &created_at, + session_plan: None, + }; + let bytes = serde_json::to_string(&view).unwrap(); + assert_eq!( + bytes, + r#"{"name":"golden","hypothesis":"the golden hypothesis","strategy":"recipe-golden","criteria":{"min_return_pct":1.5,"min_fills":3},"created_at":"2026-01-01T00:00:00Z"}"# + ); + assert_eq!( + spec_hash(&view).unwrap(), + "sha256:243b1f1ab33343cd33f2245d43b54fec4d8d572438898af2982e4ac3aac8115f" + ); + } +} diff --git a/crates/kraken-lab/src/hash.rs b/crates/kraken-lab/src/hash.rs new file mode 100644 index 0000000..d2226c6 --- /dev/null +++ b/crates/kraken-lab/src/hash.rs @@ -0,0 +1,45 @@ +//! The lab's SHA-256 vocabulary: the `sha256:` content identity that +//! seals a spec (over its canonical JSON bytes) and pins a replay tape (over +//! its file bytes). One home for the primitive so the domain and the store +//! hash identically. + +use std::path::Path; + +use sha2::{Digest, Sha256}; + +use crate::Result; + +/// `sha256:` over a file's bytes — the content identity a sealed run +/// plan pins, so a re-recorded tape never satisfies a frozen plan. +/// Streamed: recorded tapes can be large, and hashing must not hold one in +/// memory. +pub fn sha256_file(path: &Path) -> Result { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex_prefixed(&hasher.finalize())) +} + +/// `sha256:` over in-memory bytes — the spec seal's primitive. +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + hex_prefixed(&Sha256::digest(bytes)) +} + +fn hex_prefixed(digest: &[u8]) -> String { + let mut hex = String::with_capacity(7 + digest.len() * 2); + hex.push_str("sha256:"); + for byte in digest { + use std::fmt::Write; + // Infallible: writing hex pairs into a String cannot fail. + let _ = write!(hex, "{byte:02x}"); + } + hex +} diff --git a/crates/kraken-lab/src/lib.rs b/crates/kraken-lab/src/lib.rs new file mode 100644 index 0000000..e2d0ee5 --- /dev/null +++ b/crates/kraken-lab/src/lib.rs @@ -0,0 +1,112 @@ +#![doc = include_str!("../README.md")] + +mod app; +mod experiment; +mod hash; +pub mod mark; +mod plan; +mod replay; +mod score; +mod store; +mod trades; +mod window; + +pub use app::{Comparison, Judged, Lab, OutcomeResult, ScoredSession, SessionResult}; +pub use experiment::{Check, Criteria, Experiment, PlannedSession, SessionPlan, Verdict}; +pub use hash::sha256_file; +// The planner types cross upward (the CLI renders `NextAction`), but the +// planner and the store are orchestration primitives the `Lab` facade owns: +// the binary reaches them only through `Lab`, so `next_action`/`freeze`/`load`/ +// `discover_sessions` stay crate-internal and the seam is enforced by the compiler. +pub use plan::{NextAction, NextState, SessionObservation, SessionStatus}; +pub use replay::{Curve, Point, Replay, replay}; +pub use score::{CurveSummary, Metrics, ScoreSource, Scorecard, TradeStats, score}; +pub use store::SessionRef; +pub use store::experiments_root; +pub use window::parse_window; + +pub type Result = std::result::Result; + +/// The lab layer's failure modes. The binary maps each variant onto the JSON +/// envelope exactly once, in its `errors` module. +#[derive(Debug, thiserror::Error)] +pub enum LabError { + /// A window with no account epoch — folding from a default account would + /// fabricate an equity line for money that never existed. + /// → envelope `validation`. + #[error("session '{session}' has no paper account epoch in its window; nothing to replay")] + NoEpoch { session: String }, + /// A frozen experiment that doesn't exist. → envelope `validation`. + #[error("experiment '{0}' not found; run 'kraken lab new {0}' first")] + ExperimentNotFound(String), + /// A freeze under a name already sealed — a spec is immutable by design. + /// → envelope `validation`. + #[error( + "experiment '{0}' already exists; a frozen spec is immutable — choose a new name or \ + show it with 'kraken lab show {0}'" + )] + ExperimentExists(String), + /// A spec the lab refuses to freeze (bad name, no hypothesis, no + /// criteria). → envelope `validation`. + #[error("{0}")] + Spec(String), + /// Stored lab data (an experiment file) the schema can't decode — or + /// whose frozen hash no longer matches. → envelope `parse`. + #[error("{0}")] + Damaged(String), + /// Filesystem failure around the lab's store. → envelope `io`. + #[error(transparent)] + Store(#[from] std::io::Error), + /// A recording the plan resolution touched (a replay tape ref, its + /// catalog entry, or its frames) could not be read; the envelope mapping + /// delegates to the recording bridge. + #[error(transparent)] + Recording(#[from] kraken_recording::Error), + /// Reading a session's session timeline failed; the envelope mapping delegates + /// to the session bridge. The crate now reads sessions itself (the facade + /// scores and compares from disk), so this failure mode is first-class. + #[error(transparent)] + Session(#[from] kraken_session::SessionError), + /// A scorecard's explain-pnl input failed; the envelope mapping delegates + /// to the replay bridge (NotStopped→validation, Emit→io, Render→parse). + #[error(transparent)] + Replay(#[from] kraken_replay::ReplayError), +} + +impl LabError { + /// The stable envelope category this failure maps to. Kept in sync with + /// the binary's error bridge so the crate can label a failed comparison + /// column ("validation: …") without a round-trip through the CLI error + /// type. The binary's `category_codes_match_the_envelope_contract` test + /// pins the wire strings. + pub fn category(&self) -> &'static str { + use kraken_replay::ReplayError; + use kraken_session::SessionError; + match self { + Self::NoEpoch { .. } + | Self::ExperimentNotFound(_) + | Self::ExperimentExists(_) + | Self::Spec(_) => "validation", + Self::Damaged(_) => "parse", + // Recording failures only surface during plan resolution (the New + // command's top-level error, mapped precisely by the binary's + // recording bridge), never in a compare column, so a coarse "io" + // here is a label of last resort, not a contract. + Self::Store(_) | Self::Recording(_) => "io", + Self::Session(e) => match e { + SessionError::Rejected(_) => "validation", + SessionError::IncompatibleManifest { .. } => "config", + SessionError::Damaged(_) => "parse", + SessionError::Recording(_) | SessionError::Io(_) => "io", + }, + Self::Replay(e) => match e { + ReplayError::NotStopped { .. } + | ReplayError::NoEpoch { .. } + | ReplayError::Source(_) => "validation", + ReplayError::NewerJournal { .. } => "config", + ReplayError::Emit(_) => "io", + ReplayError::Render(_) => "parse", + }, + } + } +} diff --git a/crates/kraken-lab/src/mark.rs b/crates/kraken-lab/src/mark.rs new file mode 100644 index 0000000..f50a20e --- /dev/null +++ b/crates/kraken-lab/src/mark.rs @@ -0,0 +1,350 @@ +//! Recorded-market marks for deterministic replay. +//! +//! Tickers use mid, trades use print price, and OHLC uses close. Book deltas +//! cannot yield top-of-book without reconstruction, so book-only assets remain +//! unmarked. Mid avoids charging spread again after fills already paid it. + +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet, VecDeque}; + +use kraken_core::{ChannelData, ChannelMessage}; +use kraken_paper::{canonical_key, valid_quote}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +/// The smallest odd window that prevents one observation from becoming a majority. +const MEDIAN_WINDOW: usize = 3; + +/// Rolling median marks per canonical `(base, quote)`. +/// +/// The left fold remains prefix-stable. Before the third observation, one +/// sample is used directly and two are averaged. +#[derive(Default)] +pub struct Table { + marks: HashMap<(String, String), SymbolMark>, + /// Warn-once memory for symbols `parse_pair` cannot split. + unparseable: HashSet, +} + +impl Table { + /// Absorb one recorded frame, entry by entry in wire order; `true` when + /// any mark moved (the replay's re-value signal). + pub fn observe(&mut self, frame: &ChannelMessage) -> bool { + let mut changed = false; + match &frame.body { + ChannelData::Ticker(entries) => { + for ticker in entries { + if valid_quote(ticker.ask, ticker.bid) { + let mid = (ticker.ask + ticker.bid) / dec!(2); + changed |= self.accept(&ticker.symbol, Channel::Ticker, mid); + } + } + } + ChannelData::Trade(entries) => { + for trade in entries { + if trade.price > Decimal::ZERO { + changed |= self.accept(&trade.symbol, Channel::Trade, trade.price); + } + } + } + ChannelData::Ohlc(entries) => { + for candle in entries { + if candle.close > Decimal::ZERO { + changed |= self.accept(&candle.symbol, Channel::Ohlc, candle.close); + } + } + } + // Incremental book deltas cannot produce a trustworthy top-of-book mark. + ChannelData::Book(_) + | ChannelData::Instrument(_) + | ChannelData::Executions(_) + | ChannelData::Balances(_) + | ChannelData::Level3(_) + | ChannelData::Status(_) => {} + } + changed + } + + /// Adapts marks to [`kraken_paper::PaperState::valuation`] without cross rates. + pub fn to_prices(&self, currency: &str) -> HashMap { + self.marks + .iter() + .filter(|((_, quote), _)| quote == currency) + .map(|((base, _), symbol)| { + let mark = symbol.mark(); + (format!("{base}{currency}"), (mark, mark)) + }) + .collect() + } + + fn accept(&mut self, symbol: &str, channel: Channel, observation: Decimal) -> bool { + let Some(key) = self.canonical(symbol) else { + return false; + }; + match self.marks.entry(key) { + Entry::Occupied(mut entry) => entry.get_mut().observe(channel, observation), + Entry::Vacant(entry) => { + entry.insert(SymbolMark::first(channel, observation)); + true + } + } + } + + fn canonical(&mut self, symbol: &str) -> Option<(String, String)> { + match canonical_key(symbol) { + Some(key) => Some(key), + None => { + if self.unparseable.insert(symbol.to_string()) { + tracing::warn!(symbol, "skipping market frames for unparseable symbol"); + } + None + } + } + } +} + +/// Mark-bearing channels ordered by trust. +/// +/// Declaration order is load-bearing: mixing lower-priority prints with +/// higher-priority mids would corrupt the rolling distribution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Channel { + Ohlc, + Trade, + Ticker, +} + +/// One symbol's elected channel and median window. Holds at least one +/// observation from construction on. +struct SymbolMark { + channel: Channel, + window: VecDeque, +} + +impl SymbolMark { + fn first(channel: Channel, observation: Decimal) -> Self { + let mut window = VecDeque::with_capacity(MEDIAN_WINDOW); + window.push_back(observation); + Self { channel, window } + } + + /// Preserves the window across channel upgrades to avoid restarting warm-up. + fn observe(&mut self, channel: Channel, observation: Decimal) -> bool { + if channel < self.channel { + return false; + } + self.channel = channel; + let before = self.mark(); + if self.window.len() == MEDIAN_WINDOW { + self.window.pop_front(); + } + self.window.push_back(observation); + self.mark() != before + } + + /// The window's median; the two-observation warm-up averages. + fn mark(&self) -> Decimal { + let mut sorted: Vec = self.window.iter().copied().collect(); + sorted.sort_unstable(); + let mid = sorted.len() / 2; + if sorted.len().is_multiple_of(2) { + (sorted[mid - 1] + sorted[mid]) / dec!(2) + } else { + sorted[mid] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ticker(symbol: &str, bid: Decimal, ask: Decimal) -> ChannelMessage { + ChannelMessage::parse(&format!( + r#"{{"channel":"ticker","type":"update","data":[{{"symbol":"{symbol}", + "bid":{bid},"bid_qty":1.0,"ask":{ask},"ask_qty":1.0,"last":{bid}, + "volume":10.0,"vwap":{bid},"low":{bid},"high":{ask},"change":0.0, + "change_pct":0.0,"timestamp":"2026-01-01T00:00:00Z"}}]}}"# + )) + .unwrap() + } + + fn trades(symbol: &str, prices: &[Decimal]) -> ChannelMessage { + let entries: Vec = prices + .iter() + .enumerate() + .map(|(i, price)| { + format!( + r#"{{"symbol":"{symbol}","side":"buy","price":{price},"qty":1.0, + "ord_type":"market","trade_id":{i},"timestamp":"2026-01-01T00:00:00Z"}}"# + ) + }) + .collect(); + ChannelMessage::parse(&format!( + r#"{{"channel":"trade","type":"update","data":[{}]}}"#, + entries.join(",") + )) + .unwrap() + } + + fn ohlc(symbol: &str, close: Decimal) -> ChannelMessage { + ChannelMessage::parse(&format!( + r#"{{"channel":"ohlc","type":"update","data":[{{"symbol":"{symbol}","open":{close}, + "high":{close},"low":{close},"close":{close},"vwap":{close},"trades":1, + "volume":1.0,"interval_begin":"2026-01-01T00:00:00Z","interval":1}}]}}"# + )) + .unwrap() + } + + fn book(symbol: &str) -> ChannelMessage { + ChannelMessage::parse(&format!( + r#"{{"channel":"book","type":"snapshot","data":[{{"symbol":"{symbol}", + "bids":[{{"price":50000.0,"qty":1.0}}],"asks":[{{"price":50010.0,"qty":1.0}}], + "checksum":1,"timestamp":"2026-01-01T00:00:00Z"}}]}}"# + )) + .unwrap() + } + + fn mark_of(table: &Table, base: &str, currency: &str) -> Option { + table + .to_prices(currency) + .get(&format!("{base}{currency}")) + .map(|&(_, bid)| bid) + } + + #[test] + fn ticker_marks_at_mid_not_bid() { + let mut table = Table::default(); + table.observe(&ticker("BTC/USD", dec!(50_000.0), dec!(50_100.0))); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(50_050))); + } + + #[test] + fn one_outlier_after_warmup_never_becomes_the_mark() { + let mut table = Table::default(); + table.observe(&trades( + "BTC/USD", + &[dec!(60_000.0), dec!(60_000.0), dec!(60_000.0)], + )); + assert!( + !table.observe(&trades("BTC/USD", &[dec!(6.0)])), + "the $6 tick must not move the median" + ); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(60_000))); + } + + #[test] + fn bad_first_frame_self_heals_by_the_third_observation() { + // Warm-up contamination is documented, not hidden: the bad first + // observation *is* the mark, the second averages it, the third + // restores the median guarantee. + let mut table = Table::default(); + table.observe(&trades("BTC/USD", &[dec!(6.0)])); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(6))); + table.observe(&trades("BTC/USD", &[dec!(60_000.0)])); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(30_003))); + table.observe(&trades("BTC/USD", &[dec!(60_000.0)])); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(60_000))); + } + + #[test] + fn genuine_jump_is_adopted_after_one_confirming_observation() { + let mut table = Table::default(); + table.observe(&trades( + "BTC/USD", + &[dec!(50_000.0), dec!(50_000.0), dec!(50_000.0)], + )); + table.observe(&trades("BTC/USD", &[dec!(55_000.0)])); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(50_000))); + table.observe(&trades("BTC/USD", &[dec!(55_000.0)])); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(55_000))); + } + + #[test] + fn trade_prints_stop_marking_once_ticker_arrives() { + let mut table = Table::default(); + table.observe(&trades("BTC/USD", &[dec!(50_000.0)])); + table.observe(&ticker("BTC/USD", dec!(51_000.0), dec!(51_100.0))); + assert!( + !table.observe(&trades( + "BTC/USD", + &[dec!(1_000.0), dec!(1_000.0), dec!(1_000.0)] + )), + "trade prints are below the ticker latch" + ); + // Window kept across the upgrade: trade 50_000 + ticker mid 51_050. + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(50_525))); + } + + #[test] + fn ohlc_close_marks_a_symbol_with_no_ticker_or_trade() { + let mut table = Table::default(); + table.observe(&ohlc("SOL/USD", dec!(150.0))); + assert_eq!(mark_of(&table, "SOL", "USD"), Some(dec!(150))); + } + + #[test] + fn book_frames_derive_no_marks() { + let mut table = Table::default(); + assert!(!table.observe(&book("BTC/USD"))); + assert!(table.to_prices("USD").is_empty()); + } + + #[test] + fn multi_entry_frame_feeds_the_window_per_entry_in_order() { + // Three goods then one outlier inside a single frame: the window has + // warmed up within the same vec, so the outlier is already absorbed. + let mut table = Table::default(); + table.observe(&trades( + "BTC/USD", + &[dec!(60_000.0), dec!(60_000.0), dec!(60_000.0), dec!(6.0)], + )); + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(60_000))); + } + + #[test] + fn nonpositive_and_invalid_observations_are_rejected() { + let mut table = Table::default(); + assert!(!table.observe(&trades("BTC/USD", &[dec!(0.0)]))); + assert!(!table.observe(&trades("BTC/USD", &[-dec!(1.0)]))); + assert!(!table.observe(&ticker("BTC/USD", dec!(0.0), dec!(50_000.0)))); + assert!(table.to_prices("USD").is_empty()); + } + + #[test] + fn to_prices_filters_to_the_requested_quote_currency() { + let mut table = Table::default(); + table.observe(&trades("ETH/BTC", &[dec!(0.05)])); + table.observe(&trades("BTC/USD", &[dec!(60_000.0)])); + let usd = table.to_prices("USD"); + assert_eq!(usd.len(), 1, "the ETH/BTC cross must not leak into USD"); + assert!(usd.contains_key("BTCUSD")); + } + + #[test] + fn xbt_and_btc_symbols_collide_on_one_canonical_mark() { + let mut table = Table::default(); + table.observe(&trades("XBT/USD", &[dec!(60_000.0)])); + table.observe(&trades("BTC/USD", &[dec!(61_000.0)])); + assert_eq!(table.to_prices("USD").len(), 1); + // The warm-up mean proves both observations share one window — a + // silently dropped XBT frame would mark 61_000 instead. + assert_eq!(mark_of(&table, "BTC", "USD"), Some(dec!(60_500))); + } + + #[test] + fn trade_print_outranks_a_prior_ohlc_close() { + let mut table = Table::default(); + table.observe(&ohlc("SOL/USD", dec!(150.0))); + assert!( + table.observe(&trades("SOL/USD", &[dec!(155.0)])), + "a trade print upgrades the latch" + ); + assert!( + !table.observe(&ohlc("SOL/USD", dec!(140.0))), + "ohlc closes are below the trade latch" + ); + // Window kept across the upgrade: ohlc 150 + trade 155. + assert_eq!(mark_of(&table, "SOL", "USD"), Some(dec!(152.5))); + } +} diff --git a/crates/kraken-lab/src/plan.rs b/crates/kraken-lab/src/plan.rs new file mode 100644 index 0000000..1a4cf1c --- /dev/null +++ b/crates/kraken-lab/src/plan.rs @@ -0,0 +1,396 @@ +//! The derived — never stored — next-action machine over a frozen session plan. +//! +//! Pre-registering *which* runs (and how many) closes the last goalpost: +//! without a sealed plan, an agent can keep running until a pass appears and +//! stop there. The plan itself is sealed-spec data and lives in +//! [`experiment`](crate::experiment); progress is always re-derived here from +//! the session directories, so there is no state file to drift. + +use serde::Serialize; + +use crate::experiment::{Experiment, PlannedSession}; +use crate::{LabError, Result}; + +/// One discovered session's observed lifecycle, derived by the caller from its +/// `session.json` and the recorder lock — never persisted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionObservation { + pub ordinal: u32, + /// The session's id, `s`. + pub session: String, + pub status: SessionStatus, + /// The replay source ref the session's manifest records; `None` = live. + pub source: Option, + /// The sha256 of the tape the session actually replayed; `None` for a live session + /// or a manifest written before hashes were recorded. Together with + /// `source` this binds a session to a *sealed* replay entry — a re-recorded + /// tape has a different hash and so cannot satisfy the frozen leg. + pub content_hash: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStatus { + /// The recorder still holds its locks. + Running, + /// Finalized with a summary — scoreable evidence. + Completed, + /// Stopped or crashed without a summary; never counts toward the plan. + /// Its ordinal stays claimed forever, so evidence never gets renamed. + Aborted, +} + +/// What the plan says to do now. +#[derive(Debug, Serialize)] +pub struct NextAction { + pub state: NextState, + /// The label of the session to start, or the id (`s`) of the one running. + pub session: Option, + /// The `--from` value for a replay entry; `None` for live runs. + pub source: Option, + /// A ready-to-run `kraken session start` line, where derivable. + pub command: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NextState { + Start, + Running, + PlanComplete, +} + +impl NextState { + /// The lowercase tag, identical to the serde `snake_case` repr — the + /// table renderer wants the string directly, not via a JSON round-trip. + pub fn as_str(self) -> &'static str { + match self { + Self::Start => "start", + Self::Running => "running", + Self::PlanComplete => "plan_complete", + } + } +} + +/// The speed `lab next` proposes for replay entries: fast enough that a +/// recorded day screens in an hour or two, disclosed by the reaction-time +/// caveat; the skill names adjusting it as the one permitted edit. +const PROPOSED_REPLAY_SPEED: f64 = 10.0; + +/// Derive the next action from the sealed plan and the observed runs. +/// Entries are consumed by *matching* completed runs, in order — a replay +/// entry only by a session of that tape, a live entry only by a sourceless +/// run — so a crashed run finalized later, or a replay of the wrong tape, +/// can never stand in for the live session the plan still owes. Aborted runs +/// consume nothing but keep their ordinals claimed. +pub(crate) fn next_action( + experiment: &Experiment, + runs: &[SessionObservation], +) -> Result { + let Some(plan) = &experiment.session_plan else { + return Err(LabError::Spec(format!( + "experiment '{}' has no session plan; a frozen spec is immutable, so register a new \ + experiment with --session entries to use lab next", + experiment.name + ))); + }; + if let Some(running) = runs.iter().find(|run| run.status == SessionStatus::Running) { + return Ok(NextAction { + state: NextState::Running, + session: Some(running.session.clone()), + source: None, + command: None, + reason: format!( + "session {} is still in progress; stop and score it, then ask again", + running.session + ), + }); + } + let mut unconsumed: Vec<&SessionObservation> = runs + .iter() + .filter(|run| run.status == SessionStatus::Completed) + .collect(); + let total = plan.0.len(); + let mut satisfied = 0usize; + let mut pending = None; + for entry in &plan.0 { + match unconsumed.iter().position(|run| entry_matches(entry, run)) { + Some(position) => { + unconsumed.remove(position); + satisfied += 1; + } + None => { + pending = Some(entry); + break; + } + } + } + let Some(entry) = pending else { + return Ok(NextAction { + state: NextState::PlanComplete, + session: None, + source: None, + command: None, + reason: format!( + "all {total} planned sessions are complete; run 'kraken lab compare {}'", + experiment.name + ), + }); + }; + // A prediction for the proposed label only: the workspace's run allocator + // is authoritative at start time (under the journal lock), so a + // concurrent start simply shifts the label — never the evidence. + let next_ordinal = runs.iter().map(|run| run.ordinal).max().unwrap_or(0) + 1; + let label = format!("{}-s{next_ordinal}", experiment.name); + let aborted = runs + .iter() + .filter(|run| run.status == SessionStatus::Aborted) + .count(); + let strategy = experiment + .strategy + .as_deref() + .map(|s| format!(" --strategy {s}")) + .unwrap_or_default(); + let (source, market) = match entry { + // The sealed `live:` is a real timer: --for makes the session + // stop and finalize itself after the window, so a live leg needs no + // manual `session stop`. A replay leg ends when its tape runs out. + // Quote the window: humantime accepts compound spans (`2h 30m`), and an + // unquoted space would split the copy-pasted command's argv. + PlannedSession::Live { window } => ( + None, + format!(" --symbols --channels ticker,trade --for '{window}'"), + ), + PlannedSession::Replay { tape, .. } => (Some(tape.clone()), String::new()), + }; + let source_flags = source + .as_deref() + .map(|tape| format!(" --from {tape} --speed {PROPOSED_REPLAY_SPEED}")) + .unwrap_or_default(); + let command = format!( + "kraken session start --label {label} --experiment {}{strategy}{source_flags}{market}", + experiment.name + ); + let mut reason = format!( + "start planned session {} of {total} ({})", + satisfied + 1, + entry.label() + ); + if aborted > 0 { + reason.push_str(&format!( + "; {aborted} aborted session(s) kept their ordinals and count for nothing" + )); + } + if !unconsumed.is_empty() { + reason.push_str(&format!( + "; {} completed session(s) match no pending plan entry and count for nothing", + unconsumed.len() + )); + } + Ok(NextAction { + state: NextState::Start, + session: Some(label), + source, + command: Some(command), + reason, + }) +} + +/// Whether a completed run can consume a plan entry: source *identity and +/// bytes*, not arrival order, is the binding. A replay entry needs both the +/// tape ref and the sealed `content_hash` — a session of the right ref but a +/// re-recorded tape carries a different hash and is refused (fail-closed: a +/// run with no recorded hash never satisfies a sealed replay leg). This is the +/// seal enforcement that holds regardless of how the session was started. +fn entry_matches(entry: &PlannedSession, run: &SessionObservation) -> bool { + match entry { + PlannedSession::Live { .. } => run.source.is_none(), + PlannedSession::Replay { tape, content_hash } => { + run.source.as_deref() == Some(tape) + && run.content_hash.as_deref() == Some(content_hash.as_str()) + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + use crate::experiment::{Criteria, SessionPlan}; + + fn experiment(plan: Option) -> Experiment { + Experiment { + name: "m1".to_string(), + hypothesis: "h".to_string(), + strategy: Some("recipe-dca".to_string()), + criteria: Criteria::default(), + created_at: Utc::now(), + session_plan: plan, + frozen: "sha256:test".to_string(), + } + } + + fn plan() -> SessionPlan { + SessionPlan(vec![ + PlannedSession::Replay { + tape: "tape:jun-crash".to_string(), + content_hash: "sha256:abc".to_string(), + }, + PlannedSession::Live { + window: "24h".to_string(), + }, + ]) + } + + /// The replay content_hash `plan()` seals — a session must carry it to consume + /// the replay leg. + const SEALED_HASH: &str = "sha256:abc"; + + fn observed(ordinal: u32, status: SessionStatus) -> SessionObservation { + SessionObservation { + ordinal, + session: format!("s{ordinal}"), + status, + source: None, + content_hash: None, + } + } + + fn observed_replay(ordinal: u32, status: SessionStatus, tape: &str) -> SessionObservation { + observed_replay_hashed(ordinal, status, tape, Some(SEALED_HASH)) + } + + fn observed_replay_hashed( + ordinal: u32, + status: SessionStatus, + tape: &str, + content_hash: Option<&str>, + ) -> SessionObservation { + SessionObservation { + source: Some(tape.to_string()), + content_hash: content_hash.map(str::to_string), + ..observed(ordinal, status) + } + } + + #[test] + fn plan_less_experiment_is_refused_with_the_flag_to_use() { + let err = next_action(&experiment(None), &[]).unwrap_err(); + assert!(err.to_string().contains("--session"), "{err}"); + } + + #[test] + fn fresh_plan_starts_r1_on_the_first_entry() { + let action = next_action(&experiment(Some(plan())), &[]).unwrap(); + assert_eq!(action.state, NextState::Start); + assert_eq!(action.session.as_deref(), Some("m1-s1")); + assert_eq!(action.source.as_deref(), Some("tape:jun-crash")); + // The full proposed line, pinned: the skill quotes it verbatim. + assert_eq!( + action.command.as_deref(), + Some( + "kraken session start --label m1-s1 --experiment m1 --strategy recipe-dca \ + --from tape:jun-crash --speed 10" + ) + ); + } + + #[test] + fn a_running_run_blocks_the_next_start() { + let action = next_action( + &experiment(Some(plan())), + &[observed(1, SessionStatus::Running)], + ) + .unwrap(); + assert_eq!(action.state, NextState::Running); + assert_eq!(action.session.as_deref(), Some("s1")); + assert!(action.command.is_none()); + } + + #[test] + fn a_sourceless_run_cannot_consume_a_replay_entry() { + let action = next_action( + &experiment(Some(plan())), + &[observed(1, SessionStatus::Completed)], + ) + .unwrap(); + assert_eq!(action.state, NextState::Start); + assert_eq!( + action.source.as_deref(), + Some("tape:jun-crash"), + "the replay entry is still pending" + ); + assert_eq!(action.session.as_deref(), Some("m1-s2")); + } + + #[test] + fn aborted_runs_claim_ordinals_but_consume_no_entry() { + let action = next_action( + &experiment(Some(plan())), + &[ + observed(1, SessionStatus::Aborted), + observed_replay(2, SessionStatus::Completed, "tape:jun-crash"), + ], + ) + .unwrap(); + assert_eq!(action.state, NextState::Start); + // Entry 2 of 2 (live), on a fresh ordinal — never a reused one. + assert_eq!(action.session.as_deref(), Some("m1-s3")); + assert!(action.reason.contains("aborted"), "{}", action.reason); + // The live leg carries its sealed window as a real --for timer, quoted + // so a compound span survives the copy-pasted argv. + let command = action.command.unwrap(); + assert!(command.contains("--for '24h'"), "{command}"); + assert!( + command.contains("--symbols --channels ticker,trade"), + "{command}" + ); + } + + #[test] + fn a_complete_plan_points_at_compare() { + let action = next_action( + &experiment(Some(plan())), + &[ + observed_replay(1, SessionStatus::Completed, "tape:jun-crash"), + observed(2, SessionStatus::Completed), + ], + ) + .unwrap(); + assert_eq!(action.state, NextState::PlanComplete); + assert!(action.reason.contains("lab compare m1")); + } + + #[test] + fn a_replay_run_of_the_wrong_bytes_does_not_consume_the_leg() { + // The seal's core promise: a completed run of the right tape ref but + // a re-recorded tape (different hash) — or one carrying no hash at all — + // must NOT satisfy the sealed replay leg. The plan still owes it, so the + // run counts for nothing regardless of how it was started. + for hash in [Some("sha256:tampered"), None] { + let action = next_action( + &experiment(Some(plan())), + &[observed_replay_hashed( + 1, + SessionStatus::Completed, + "tape:jun-crash", + hash, + )], + ) + .unwrap(); + assert_eq!(action.state, NextState::Start, "hash {hash:?}"); + assert_eq!( + action.source.as_deref(), + Some("tape:jun-crash"), + "the sealed replay leg is still owed (hash {hash:?})" + ); + assert_eq!(action.session.as_deref(), Some("m1-s2"), "hash {hash:?}"); + assert!( + action.reason.contains("count for nothing"), + "hash {hash:?}: {}", + action.reason + ); + } + } +} diff --git a/crates/kraken-lab/src/replay.rs b/crates/kraken-lab/src/replay.rs new file mode 100644 index 0000000..d4079d1 --- /dev/null +++ b/crates/kraken-lab/src/replay.rs @@ -0,0 +1,1003 @@ +//! Deterministic scorecard inputs derived from a session timeline. +//! +//! Values use recorded mid marks, never live prices or the stop anchor. The +//! final curve may therefore differ from bid-marked stop totals. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use kraken_paper::{PaperState, PaperTrade}; +use kraken_recording::{CaptureState, RecordingManifest}; +use kraken_session::timeline::SessionTimeline; +use kraken_session::timeline::event::{EventPayload, TimelineEvent, final_epoch_start}; +use rust_decimal::Decimal; +use serde::Serialize; + +use crate::{LabError, Result, mark}; + +/// Replay the journal's final epoch against the recorded marks. +/// +/// Market frames feed the mark table from the timeline's start — epochs +/// partition the account journal, not the market tape, so the first +/// post-epoch valuation gets warmed-up marks. `validation` when the journal +/// has no epoch: folding from a default account would fabricate an equity +/// line for money that never existed. +pub fn replay(session: &SessionTimeline) -> Result { + let epoch = final_epoch_start(&session.events); + let Some(start) = epoch.start else { + return Err(LabError::NoEpoch { + session: session.manifest.id.clone(), + }); + }; + + let mut table = mark::Table::default(); + // Placeholder until the epoch-start record replaces it wholesale; no + // point is emitted before then. + let mut state = PaperState::default(); + let mut points: Vec = Vec::new(); + let mut unmarked = BTreeSet::new(); + for (index, event) in session.events.iter().enumerate() { + match &event.payload { + EventPayload::Market(frame) => { + let moved = table.observe(frame); + if moved && index > start { + let point = sample(&state, &table, event, &mut unmarked); + let repeats = + |last: &Point| (last.value, last.complete) == (point.value, point.complete); + // A moved mark re-values holdings; unheld symbols and + // median-absorbed outliers land here as no-ops. + if !points.last().is_some_and(repeats) { + points.push(point); + } + } + } + // One point per journal line in the epoch, even for + // observational records — the predictable contract. Earlier + // epochs are skipped wholesale: the epoch-start record rebuilds + // state from its own config. + EventPayload::Account(record) if index >= start => { + state.apply(record.ts, &record.event); + points.push(sample(&state, &table, event, &mut unmarked)); + } + EventPayload::Account(_) | EventPayload::Decision(_) => {} + } + } + + let caveats = caveats(epoch.skipped, &points, &unmarked, session.capture.as_ref()); + Ok(Replay { + curve: Curve { + currency: state.starting_currency.clone(), + points, + unmarked, + }, + fills: state.filled_trades, + epochs_skipped: epoch.skipped, + caveats, + }) +} + +/// A session's final epoch, replayed and marked against its own recording. +#[derive(Debug, Clone, Serialize)] +pub struct Replay { + pub curve: Curve, + /// The fill ledger: the epoch's fills verbatim, in journal order — + /// price, volume, fee, and `reference_quote` (`None` on legacy fills). + pub fills: Vec, + pub epochs_skipped: usize, + pub caveats: Vec, +} + +/// The marked equity curve: portfolio value at every valuation-changing +/// instant of the final epoch. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Curve { + /// Valuation currency: the epoch's starting currency. + pub currency: String, + pub points: Vec, + /// Assets ever held with no usable recorded mark, named by the same + /// code path that skipped them in valuation (sorted, so serialized + /// output is deterministic). + pub unmarked: BTreeSet, +} + +/// One curve sample: emitted after every account record in the epoch and +/// after every recorded frame that moved the valuation. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Point { + pub at: DateTime, + /// The triggering event's within-track `seq`, tracing the point back to + /// its timeline event. + pub seq: i64, + pub value: Decimal, + /// `false` when a held asset had no usable recorded mark at this + /// instant: the value counts cash and marked assets only — flagged, + /// never a silent zero. + pub complete: bool, +} + +fn sample( + state: &PaperState, + table: &mark::Table, + event: &TimelineEvent, + ever_unmarked: &mut BTreeSet, +) -> Point { + let valuation = state.valuation(&table.to_prices(&state.starting_currency)); + let complete = valuation.is_complete(); + ever_unmarked.extend(valuation.unmarked); + Point { + at: event.at, + seq: event.seq, + value: valuation.value, + complete, + } +} + +fn caveats( + epochs_skipped: usize, + points: &[Point], + unmarked: &BTreeSet, + capture: Option<&RecordingManifest>, +) -> Vec { + let mut caveats = Vec::new(); + if epochs_skipped > 0 { + caveats.push(format!( + "{epochs_skipped} earlier account epoch(s) on the journal were skipped; the curve \ + and ledger describe the final epoch only" + )); + } + let incomplete = points.iter().filter(|point| !point.complete).count(); + if incomplete > 0 { + let assets: Vec<&str> = unmarked.iter().map(String::as_str).collect(); + caveats.push(format!( + "{incomplete} of {} curve point(s) could not mark every holding from the recording \ + (unmarked: {}); their values count cash and marked assets only", + points.len(), + assets.join(", ") + )); + } + match CaptureState::of(capture) { + // Absent needs no health caveat: valuation impact already surfaces + // through the incomplete-point disclosure above. + CaptureState::Absent => {} + CaptureState::Unfinalized(_) => caveats.push( + "the market recording never finalized (recorder still running or crashed); the \ + marks' completeness is unknown" + .to_string(), + ), + CaptureState::Finalized(_, summary) => { + // Reconnects are deliberately not caveated: each replays a + // snapshot of absolute values, which the mark table absorbs. + if summary.events_dropped > 0 || summary.frames_unparsed > 0 { + caveats.push(format!( + "the recording reports {} dropped and {} unparsed frame(s); marks may have \ + holes", + summary.events_dropped, summary.frames_unparsed + )); + } + } + } + caveats +} + +#[cfg(test)] +mod tests { + use kraken_core::OrderSide; + use kraken_paper::account::Origin; + use kraken_paper::{AccountEvent, CommandEntry, PaperConfig}; + use kraken_recording::{RecordingDeclaration, RecordingIntegrity}; + use kraken_session::timeline::fixtures::{self, SessionBuilder}; + use kraken_session::timeline::read; + use rust_decimal_macros::dec; + + use super::*; + + fn assert_close(actual: Decimal, expected: Decimal, what: &str) { + assert!( + (actual - expected).abs() < dec!(0.000000001), + "{what}: {actual} != {expected}" + ); + } + + fn shape(curve: &Curve) -> Vec<(DateTime, Decimal, bool)> { + // `seq` is deliberately excluded: twin sessions differing by one + // extra (absorbed) frame allocate different seqs downstream. + curve + .points + .iter() + .map(|point| (point.at, point.value, point.complete)) + .collect() + } + + fn unfinalized_capture() -> RecordingManifest { + RecordingManifest { + schema_version: "3.0".to_string(), + meta: RecordingDeclaration { + source: "test".to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["ticker".to_string()], + window_start: fixtures::T0.parse().unwrap(), + cli_version: "test".to_string(), + }, + summary: None, + } + } + + fn finalized_capture(summary: RecordingIntegrity) -> RecordingManifest { + RecordingManifest { + summary: Some(summary), + ..unfinalized_capture() + } + } + + // -- acceptance criteria ------------------------------------------------- + + #[test] + fn same_fixture_session_folds_to_byte_identical_output_twice() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![fixtures::jsonl_recording()]); + fixtures::write_market( + base.path(), + &name, + &["2026-01-01T00:00:01.000000Z", "2026-01-01T00:00:03.000000Z"], + ); + fixtures::write_account( + base.path(), + &name, + &[ + fixtures::account_record("2026-01-01T00:00:00+00:00"), + fixtures::fill_record("2026-01-01T00:00:02+00:00"), + ], + ); + + let first = replay(&read(base.path(), &name).unwrap()).unwrap(); + let second = replay(&read(base.path(), &name).unwrap()).unwrap(); + + assert_eq!( + serde_json::to_string(&first).unwrap(), + serde_json::to_string(&second).unwrap() + ); + assert!(!first.curve.points.is_empty()); + } + + #[test] + fn multi_asset_valuation_is_deterministic_across_replays() { + // Decimal sums at these scales are exact in any order, but the + // byte-identity contract must not rest on that: `valuation` iterates + // balances sorted so even a pathological 28-digit rounding case + // lands identically every replay. This pins the serialized bytes of + // a three-asset fold across repeated replays (fresh HashMaps each). + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.01), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.market_fill( + "2026-01-01T00:02:00Z", + Origin::Cli, + OrderSide::Buy, + "ETHUSD", + dec!(0.5), + (dec!(2_950.0), dec!(2_940.0)), + ); + builder.ticker( + "2026-01-01T00:03:00Z", + "BTC/USD", + dec!(50_000.0), + dec!(50_100.0), + ); + builder.ticker( + "2026-01-01T00:04:00Z", + "ETH/USD", + dec!(3_330.0), + dec!(3_339.3), + ); + let session = builder.session(None, fixtures::manifest(vec![])); + + let reference = serde_json::to_string(&replay(&session).unwrap()).unwrap(); + for _ in 0..8 { + assert_eq!( + serde_json::to_string(&replay(&session).unwrap()).unwrap(), + reference + ); + } + } + + #[test] + fn single_outlier_tick_between_good_ticks_leaves_the_curve_identical() { + let build = |with_outlier: bool| { + let mut builder = SessionBuilder::with_rates(dec!(100_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(60_000.0), dec!(59_900.0)), + ); + builder.ticker( + "2026-01-01T00:02:00Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + builder.ticker( + "2026-01-01T00:03:00Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + builder.ticker( + "2026-01-01T00:04:00Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + if with_outlier { + builder.ticker("2026-01-01T00:05:00Z", "BTC/USD", dec!(6.0), dec!(6.2)); + } + builder.ticker( + "2026-01-01T00:06:00Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap() + }; + + let clean = build(false); + let poisoned = build(true); + assert_eq!(shape(&poisoned.curve), shape(&clean.curve)); + } + + #[test] + fn unmarkable_held_asset_flags_every_point_and_names_it() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "ETHUSD", + dec!(1.0), + (dec!(3_000.0), dec!(2_990.0)), + ); + // Only BTC is recorded; the moved BTC mark values no holding, so it + // must not add a point either. + builder.ticker( + "2026-01-01T00:02:00Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + assert_eq!(replay.curve.points.len(), 2, "init + fill only"); + let fill_point = &replay.curve.points[1]; + assert!(!fill_point.complete); + assert_close( + fill_point.value, + dec!(10_000.0) - dec!(3_000.0) - dec!(7.8), + "cash remainder", + ); + assert_eq!( + replay.curve.unmarked, + BTreeSet::from(["ETH".to_string()]), + "the culprit is named" + ); + assert!( + replay.caveats.iter().any(|c| c.contains("ETH")), + "caveats: {:?}", + replay.caveats + ); + } + + #[test] + fn session_with_no_market_recording_yields_flagged_cash_only_curve() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![]); + fixtures::write_account( + base.path(), + &name, + &[ + fixtures::account_record("2026-01-01T00:00:00+00:00"), + fixtures::fill_record("2026-01-01T00:00:02+00:00"), + ], + ); + + let session = read(base.path(), &name).unwrap(); + assert!(session.capture.is_none()); + let replay = replay(&session).unwrap(); + + assert_eq!(replay.curve.points.len(), 2); + assert!(replay.curve.points[0].complete, "all-cash start values 1:1"); + assert!(!replay.curve.points[1].complete, "held BTC has no mark"); + assert_eq!(replay.curve.unmarked, BTreeSet::from(["BTC".to_string()])); + } + + // -- emission rule ------------------------------------------------------- + + #[test] + fn mark_moves_after_a_fill_extend_the_curve() { + let mut builder = SessionBuilder::with_rates(dec!(100_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.ticker( + "2026-01-01T00:02:00Z", + "BTC/USD", + dec!(50_000.0), + dec!(50_100.0), + ); + builder.ticker( + "2026-01-01T00:03:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + // init, fill (unmarked), first mark, warm-up mean move. + assert_eq!(replay.curve.points.len(), 4); + let cash = dec!(100_000.0) + - dec!(0.1) * dec!(50_000.0) + - dec!(0.1) * dec!(50_000.0) * dec!(0.0026); + assert_close( + replay.curve.points[2].value, + cash + dec!(0.1) * dec!(50_050.0), + "first mark", + ); + assert_close( + replay.curve.points[3].value, + cash + dec!(0.1) * (dec!(50_050.0) + dec!(51_050.0)) / dec!(2.0), + "two-observation warm-up mean", + ); + assert!(replay.curve.points[2].complete); + let seqs: Vec = replay.curve.points.iter().map(|p| p.seq).collect(); + assert_eq!( + seqs, + [1, 2, 3, 4], + "each point carries its triggering event's seq" + ); + } + + #[test] + fn decision_events_emit_no_curve_points() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.decision("2026-01-01T00:01:00Z", "PAPER-00001", "dip"); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + assert_eq!(replay.curve.points.len(), 1, "the init point only"); + } + + #[test] + fn every_account_record_in_the_epoch_emits_a_point() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.account( + "2026-01-01T00:01:00Z", + Origin::Cli, + AccountEvent::Command(CommandEntry::default()), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + // The observational no-op changes nothing, yet still lands a point: + // one point per journal line is the predictable contract. + assert_eq!(replay.curve.points.len(), 2); + assert_eq!(replay.curve.points[0].value, replay.curve.points[1].value); + } + + #[test] + fn first_mark_for_a_held_asset_emits_a_point_and_clears_the_flag() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "ETHUSD", + dec!(1.0), + (dec!(3_000.0), dec!(2_990.0)), + ); + builder.ticker( + "2026-01-01T00:02:00Z", + "ETH/USD", + dec!(3_000.0), + dec!(3_010.0), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + assert_eq!(replay.curve.points.len(), 3); + assert!(!replay.curve.points[1].complete); + let marked = &replay.curve.points[2]; + assert!(marked.complete); + assert_close( + marked.value, + dec!(10_000.0) - dec!(3_007.8) + dec!(3_005.0), + "ETH at mid", + ); + // Ever-unmarked is a history, not the final state. + assert_eq!(replay.curve.unmarked, BTreeSet::from(["ETH".to_string()])); + } + + #[test] + fn same_instant_frame_marks_before_the_fill_is_valued() { + let ts = "2026-01-01T00:01:00Z"; + let mut builder = SessionBuilder::with_rates(dec!(100_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + ts, + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + // Pushed after the fill but tied on the instant: the merge order + // (Market < Account) must mark the book before the fill is valued. + builder.ticker(ts, "BTC/USD", dec!(50_000.0), dec!(50_100.0)); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + assert_eq!( + replay.curve.points.len(), + 2, + "init + fill; no separate mark point" + ); + assert!(replay.curve.points[1].complete); + assert!(replay.curve.unmarked.is_empty()); + } + + // -- epochs --------------------------------------------------------------- + + #[test] + fn reset_mid_session_folds_only_the_final_epoch() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.account( + "2026-01-01T00:02:00Z", + Origin::Cli, + AccountEvent::Reset(PaperConfig { + balance: dec!(5_000.0), + currency: "USD".to_string(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.0), + }), + ); + let epoch_two_fill = builder.market_fill( + "2026-01-01T00:03:00Z", + Origin::Cli, + OrderSide::Buy, + "ETHUSD", + dec!(1.0), + (dec!(3_000.0), dec!(2_990.0)), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + assert_eq!(replay.epochs_skipped, 1); + assert!(replay.caveats.iter().any(|c| c.contains("skipped"))); + let first = &replay.curve.points[0]; + assert_close( + first.value, + dec!(5_000.0), + "curve starts at the reset balance", + ); + assert!(first.complete); + // Order ids restart per epoch (both epochs mint PAPER-00001), so the + // pair is what proves this is the epoch-2 fill. + assert_eq!(replay.fills.len(), 1, "only the final epoch's fills"); + assert_eq!(replay.fills[0].id, epoch_two_fill.id); + assert_eq!(replay.fills[0].pair, "ETHUSD"); + } + + #[test] + fn pre_epoch_frames_warm_marks_for_the_first_post_reset_point() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + // Three pre-epoch observations: the post-reset fill is valued with a + // fully warmed median, so the market tape is proven epoch-unscoped. + builder.ticker( + "2026-01-01T00:01:00Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + builder.ticker( + "2026-01-01T00:01:10Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + builder.ticker( + "2026-01-01T00:01:20Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + builder.account( + "2026-01-01T00:02:00Z", + Origin::Cli, + AccountEvent::Reset(PaperConfig { + balance: dec!(100_000.0), + currency: "USD".to_string(), + fee_rate: dec!(0.0), + slippage_rate: dec!(0.0), + }), + ); + builder.market_fill( + "2026-01-01T00:03:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(1.0), + (dec!(60_050.0), dec!(60_000.0)), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + // Exactly the reset point and the fill point: the pre-epoch frames + // must feed the table silently, never emit points of their own. + assert_eq!(replay.curve.points.len(), 2); + let reset_point = &replay.curve.points[0]; + assert_close( + reset_point.value, + dec!(100_000.0), + "curve starts at the reset", + ); + let fill_point = &replay.curve.points[1]; + assert!(fill_point.complete, "pre-epoch marks value the fill"); + assert_close( + fill_point.value, + dec!(100_000.0) - dec!(60_050.0) + dec!(60_050.0), + "cash after fill + 1 BTC at the warmed mid", + ); + } + + #[test] + fn market_only_session_is_a_validation_error_not_a_fabricated_curve() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![fixtures::jsonl_recording()]); + fixtures::write_market(base.path(), &name, &["2026-01-01T00:00:01.000000Z"]); + + let err = replay(&read(base.path(), &name).unwrap()).unwrap_err(); + assert!(matches!(err, LabError::NoEpoch { .. }), "got: {err}"); + assert!( + err.to_string().contains("no paper account epoch"), + "got: {err}" + ); + } + + // -- engine subtleties ---------------------------------------------------- + + #[test] + fn short_position_values_at_face_without_marks_or_flags() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + // Handcrafted oversell: `apply` trusts the journal, exactly like a + // replayed real journal would (decide_* would refuse this). + builder.account( + "2026-01-01T00:01:00Z", + Origin::Cli, + AccountEvent::OrderFilled { + trade: PaperTrade { + id: "PAPER-00001".to_string(), + order_id: "PAPER-00001".to_string(), + pair: "BTCUSD".to_string(), + base: "BTC".to_string(), + quote: "USD".to_string(), + side: OrderSide::Sell, + volume: dec!(2.0), + price: dec!(50_000.0), + fee: dec!(0.0), + cost: dec!(100_000.0), + filled_at: "2026-01-01T00:01:00Z".parse().unwrap(), + reference_quote: None, + }, + }, + ); + builder.ticker( + "2026-01-01T00:02:00Z", + "BTC/USD", + dec!(60_000.0), + dec!(60_100.0), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + // Pins compute_portfolio_value's pre-existing rule: a negative + // balance is added at face value (units, not marked) and never + // flagged — so the BTC mark arriving must not move the curve. + assert_eq!(replay.curve.points.len(), 2); + let short_point = &replay.curve.points[1]; + assert!(short_point.complete); + assert_close( + short_point.value, + dec!(10_000.0) + dec!(100_000.0) - dec!(2.0), + "face value", + ); + assert!(replay.curve.unmarked.is_empty()); + } + + #[test] + fn dust_balance_neither_values_nor_flags() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.account( + "2026-01-01T00:01:00Z", + Origin::Cli, + AccountEvent::OrderFilled { + trade: PaperTrade { + id: "PAPER-00001".to_string(), + order_id: "PAPER-00001".to_string(), + pair: "BTCUSD".to_string(), + base: "BTC".to_string(), + quote: "USD".to_string(), + side: OrderSide::Buy, + volume: dec!(0.0000000000001), + price: dec!(50_000.0), + fee: dec!(0.0), + cost: dec!(0.000000005), + filled_at: "2026-01-01T00:01:00Z".parse().unwrap(), + reference_quote: None, + }, + }, + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + let point = replay.curve.points.last().unwrap(); + assert!( + point.complete, + "dust is not a holding, so nothing is unmarked" + ); + assert!(replay.curve.unmarked.is_empty()); + } + + // -- ledger --------------------------------------------------------------- + + #[test] + fn ledger_rows_are_the_epoch_fills_verbatim_in_journal_order() { + let mut builder = SessionBuilder::with_rates(dec!(100_000.0), dec!(0.0026), dec!(0.001)); + let first = builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + let second = builder.market_fill( + "2026-01-01T00:02:00Z", + Origin::Mcp, + OrderSide::Sell, + "BTCUSD", + dec!(0.05), + (dec!(51_000.0), dec!(50_900.0)), + ); + let replay = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + assert_eq!(replay.fills.len(), 2); + assert_eq!(replay.fills[0].id, first.id); + assert_eq!(replay.fills[1].id, second.id); + assert_close(replay.fills[0].price, first.price, "price verbatim"); + assert_close(replay.fills[0].fee, first.fee, "fee verbatim"); + assert!( + replay.fills[0].reference_quote.is_some(), + "engine fills carry the quote" + ); + let quote = replay.fills[0].reference_quote.unwrap(); + assert_close(quote.ask, dec!(50_000.0), "raw pre-slippage top of book"); + } + + #[test] + fn legacy_fill_without_reference_quote_serializes_without_the_field() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![]); + fixtures::write_account( + base.path(), + &name, + &[ + fixtures::account_record("2026-01-01T00:00:00+00:00"), + fixtures::fill_record("2026-01-01T00:00:02+00:00"), + ], + ); + + let replay = replay(&read(base.path(), &name).unwrap()).unwrap(); + assert!(replay.fills[0].reference_quote.is_none()); + let row = serde_json::to_value(&replay.fills[0]).unwrap(); + assert!( + row.get("reference_quote").is_none(), + "absence is the signal" + ); + } + + // -- capture health --------------------------------------------------------- + + #[test] + fn unfinalized_capture_is_disclosed_as_a_caveat() { + let builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let session = builder.session(Some(unfinalized_capture()), fixtures::manifest(vec![])); + let replay = replay(&session).unwrap(); + assert!( + replay.caveats.iter().any(|c| c.contains("never finalized")), + "caveats: {:?}", + replay.caveats + ); + } + + #[test] + fn dropped_frames_are_disclosed_as_a_caveat() { + let builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let session = builder.session( + Some(finalized_capture(RecordingIntegrity { + window_end: "2026-01-01T01:00:00Z".parse().unwrap(), + events_dropped: 3, + ..RecordingIntegrity::now() + })), + fixtures::manifest(vec![]), + ); + let replay = replay(&session).unwrap(); + assert!( + replay.caveats.iter().any(|c| c.contains("3 dropped")), + "caveats: {:?}", + replay.caveats + ); + } + + #[test] + fn clean_finalized_capture_adds_no_caveat() { + let builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let session = builder.session( + Some(finalized_capture(RecordingIntegrity { + window_end: "2026-01-01T01:00:00Z".parse().unwrap(), + ..RecordingIntegrity::now() + })), + fixtures::manifest(vec![]), + ); + assert!(replay(&session).unwrap().caveats.is_empty()); + } + + #[test] + fn session_without_any_epoch_is_a_validation_error() { + let session = SessionTimeline { + events: Vec::new(), + capture: None, + manifest: fixtures::manifest(vec![]), + newer_records_skipped: 0, + }; + let err = replay(&session).unwrap_err(); + assert!(matches!(err, LabError::NoEpoch { .. })); + } + + // -- properties ------------------------------------------------------------- + + mod properties { + use proptest::prelude::*; + use rust_decimal::prelude::FromPrimitive; + + use super::*; + + #[derive(Debug, Clone)] + enum Op { + Ticker(Decimal), + Fill(Decimal), + Decision, + } + + /// Fuzzed f64 ranges, snapped to `Decimal` at the boundary so the + /// engine math stays float-free. + fn op() -> impl Strategy { + prop_oneof![ + (1_000.0..100_000.0_f64) + .prop_map(|price| Op::Ticker(Decimal::from_f64(price).unwrap())), + (0.001..0.1_f64).prop_map(|volume| Op::Fill(Decimal::from_f64(volume).unwrap())), + Just(Op::Decision), + ] + } + + /// Drive the real engine through a random single-epoch op sequence; + /// infeasible fills are skipped, exactly as `decide_*` rejects live. + fn random_events(ops: &[Op]) -> Vec { + let mut builder = + SessionBuilder::with_rates(dec!(1_000_000.0), dec!(0.0026), dec!(0.0)); + for (index, op) in ops.iter().enumerate() { + let ts = format!("2026-01-01T00:{:02}:{:02}Z", 10 + index / 60, index % 60); + match op { + Op::Ticker(price) => { + builder.ticker(&ts, "BTC/USD", *price, *price * dec!(1.001)); + } + Op::Fill(volume) => { + if builder + .state + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + *volume, + dec!(50_000.0), + dec!(49_900.0), + ) + .is_ok() + { + builder.market_fill( + &ts, + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + *volume, + (dec!(50_000.0), dec!(49_900.0)), + ); + } + } + Op::Decision => builder.decision(&ts, "PAPER-00001", "why"), + } + } + builder.events + } + + fn session_of(events: Vec) -> SessionTimeline { + SessionTimeline { + events, + capture: None, + manifest: fixtures::manifest(vec![]), + newer_records_skipped: 0, + } + } + + proptest! { + /// The fold is a pure left fold with no lookahead: replaying a + /// timeline prefix yields exactly a prefix of the full curve. + /// A lookahead-based outlier filter would fail this. + #[test] + fn curve_of_a_prefix_is_a_prefix_of_the_curve( + ops in prop::collection::vec(op(), 0..40), + cut in 1usize..41, + ) { + let events = random_events(&ops); + let cut = cut.min(events.len()); + let prefix = replay(&session_of(events[..cut].to_vec())).unwrap(); + let full = replay(&session_of(events)).unwrap(); + + let taken = prefix.curve.points.len(); + prop_assert!(taken <= full.curve.points.len()); + prop_assert_eq!(&full.curve.points[..taken], &prefix.curve.points[..]); + } + + /// After a symbol's warm-up, one arbitrary tick never pushes the + /// curve outside the clean observations' hull. + #[test] + fn one_tick_after_warmup_stays_within_the_good_range( + outlier in 0.0001..1_000_000.0_f64, + ) { + let outlier = Decimal::from_f64(outlier).unwrap(); + let mut builder = SessionBuilder::with_rates(dec!(100_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:01:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(1.0), + (dec!(60_000.0), dec!(59_900.0)), + ); + builder.ticker("2026-01-01T00:02:00Z", "BTC/USD", dec!(59_990.0), dec!(60_010.0)); + builder.ticker("2026-01-01T00:03:00Z", "BTC/USD", dec!(59_990.0), dec!(60_010.0)); + builder.ticker("2026-01-01T00:04:00Z", "BTC/USD", dec!(59_990.0), dec!(60_010.0)); + builder.ticker("2026-01-01T00:05:00Z", "BTC/USD", outlier, outlier * dec!(1.001)); + let replayed = replay(&builder.session(None, fixtures::manifest(vec![]))).unwrap(); + + let cash = dec!(100_000.0) - dec!(60_000.0) - dec!(60_000.0) * dec!(0.0026); + let good_mark = dec!(60_000.0); + // Points 0 and 1 (init, pre-mark fill) predate any mark. + for point in replayed.curve.points.iter().skip(2) { + prop_assert!( + (point.value - (cash + good_mark)).abs() <= dec!(1.0), + "point {} strayed from the good hull", + point.value + ); + } + } + } + } +} diff --git a/crates/kraken-lab/src/score.rs b/crates/kraken-lab/src/score.rs new file mode 100644 index 0000000..7653982 --- /dev/null +++ b/crates/kraken-lab/src/score.rs @@ -0,0 +1,946 @@ +//! Mechanical metrics for a stopped session. +//! +//! Shared P&L values are carried from the explanation report; added metrics +//! remain derivable from the recorded curve and ledger. + +use std::collections::BTreeSet; + +use kraken_paper::{OrderSide, PaperTrade}; +use kraken_replay::{Anchor, ComponentKind, ComponentLine, PnlReport, explain_pnl}; +use kraken_session::timeline::SessionTimeline; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use serde::Serialize; +use serde_with::skip_serializing_none; + +use crate::trades::RoundTrip; +use crate::{Curve, Result, replay}; + +/// Scores a stopped session, refusing incomplete anchors or account epochs. +pub fn score(session: &SessionTimeline) -> Result { + let report = explain_pnl(session)?; + let folded = replay(session)?; + + let source = source_of(&session.manifest); + let metrics = metrics(&report, &folded.curve, &folded.fills); + let trades = trade_stats(&report, &folded.fills); + let curve = curve_summary(&folded.curve); + let divergence = divergence_caveat(&report.anchor, &curve); + let caveats = merged_caveats( + report.caveats, + folded + .caveats + .into_iter() + .chain(divergence) + .chain(replay_caveats(&session.manifest)), + ); + + let PnlReport { + session, + strategy, + anchor, + components, + epochs_skipped, + .. + } = report; + Ok(Scorecard { + group: "lab", + kind: "scorecard", + session, + strategy, + anchor, + components, + metrics, + trades, + curve, + epochs_skipped, + source, + caveats, + }) +} + +/// A stopped session's honest scorecard. The serialized shape is a public +/// contract (the future kraken-app command surface); fields are +/// additive-only. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct Scorecard { + /// Output-envelope discriminators: `group` names the command surface + /// (distinct from an account's execution `mode` of paper/live), `type` the + /// result kind. + pub group: &'static str, + #[serde(rename = "type")] + pub kind: &'static str, + /// The session's id, verbatim from the explain-pnl report. + pub session: String, + pub strategy: Option, + /// The stop totals, verbatim from the explain-pnl report. + pub anchor: Anchor, + /// The additive waterfall, verbatim from the explain-pnl report — the + /// can't-disagree contract. + pub components: Vec, + pub metrics: Metrics, + pub trades: TradeStats, + pub curve: CurveSummary, + pub epochs_skipped: usize, + /// What the session traded against — derived from the session manifest, never + /// asserted. First-class so a reader (and the promotion gate) need not + /// parse provenance out of the caveat strings. + pub source: ScoreSource, + pub caveats: Vec, +} + +/// A scored session's market-data provenance. +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ScoreSource { + /// A live-market run over these symbols. + Live { + symbols: Vec, + /// Actual live exposure (`ended_at - started_at`), whole seconds; + /// `None` for a still-open or corrupt window. Surfaced because a + /// sealed `live:` is not enforced at plan-match time — the + /// evidence reader must see how long the run really was. + #[serde(skip_serializing_if = "Option::is_none")] + window_secs: Option, + }, + /// A replay of a recorded tape at this playback speed. + Replay { tape: String, speed: f64 }, +} + +/// The manifest's source block is the single authority: present ⇒ replay, +/// absent ⇒ live over whatever symbols the session recorded. +fn source_of(manifest: &kraken_session::manifest::SessionManifest) -> ScoreSource { + match &manifest.source { + Some(source) => ScoreSource::Replay { + tape: source.tape.clone(), + speed: source.speed, + }, + None => ScoreSource::Live { + symbols: manifest + .recordings + .iter() + .flat_map(|recording| recording.symbols.iter().cloned()) + .collect::>() + .into_iter() + .collect(), + window_secs: manifest.window.ended_at.and_then(|ended| { + u64::try_from((ended - manifest.window.started_at).num_seconds()).ok() + }), + }, + } +} + +/// The derived numbers. Every `None` is an honest "not derivable for this +/// session", never a fake zero. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct Metrics { + /// `total_pnl / starting_balance`, in percent. `None` only for a + /// zero-balance epoch (a hand-edited journal). + pub return_pct: Option, + /// Largest peak-to-trough drop on the marked curve (≥ 0); `None` when + /// the curve has no points. + pub max_drawdown: Option, + /// The worst drop relative to its peak at any point, in percent — the + /// conventional maximum drawdown. Zero when the curve never dropped; + /// `None` when a drop exists but no positive peak anchors a percent. + pub max_drawdown_pct: Option, + /// The waterfall's fees line, carried verbatim (typically negative). + pub fees: Option, + /// Spread + slippage from the waterfall — the friction the fills paid + /// beyond fees. `None` when either line is underivable. + pub friction: Option, + /// Total converted fill notional over the starting balance, in percent. + /// `None` when any fill lacks a conversion — a partial sum would + /// understate turnover, never report a lowball. + pub turnover_pct: Option, + /// Closed FIFO round trips won (P&L net of pro-rata fees > 0) over all + /// closed, 0..=1. Per-lot and unweighted by design: one sell closing N + /// lots contributes N same-sign observations. `None` when no round trip + /// closed — open positions are never graded (a buys-only DCA run must + /// not read as a 0% hit rate). + pub hit_rate: Option, + /// Gross profit over gross loss across closed round trips. `None` when + /// there is no loss to divide by, or when closed trips span multiple + /// quote currencies (their P&L must not be summed as one number). + pub profit_factor: Option, + /// d(P&L)/d(slippage) per basis point of extra slippage: + /// −(total converted notional) / 10 000, in the valuation currency. + /// Analytic and linear by assumption — orders never move the tape, so a + /// refold could not honestly do better. `None` whenever turnover is. + pub slippage_sensitivity: Option, +} + +/// Counts over the fill ledger and the report's joins — no notionals here: +/// fills can be quoted in different currencies, and the converted sums +/// already live in the report's attribution. +#[derive(Debug, Serialize)] +pub struct TradeStats { + pub fills: usize, + pub buys: usize, + pub sells: usize, + /// Fills whose order id joins a logged decision (`--reason`). + pub decided: usize, + /// Resting orders the counterfactual scan flagged (never in any sum). + pub missed_fills: usize, +} + +/// The marked curve, summarized — the full point series stays behind +/// [`fn@crate::replay`] for consumers that want to draw it. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct CurveSummary { + pub currency: String, + pub points: usize, + /// Whether every point marked every holding. + pub complete: bool, + pub first_value: Option, + pub last_value: Option, + pub peak: Option, + pub trough: Option, + /// Assets ever held with no usable recorded mark. + pub unmarked: BTreeSet, +} + +fn metrics(report: &PnlReport, curve: &Curve, fills: &[PaperTrade]) -> Metrics { + let component = |kind: ComponentKind| { + report + .components + .iter() + .find(|line| line.kind == kind) + .and_then(|line| line.amount) + }; + let spread = component(ComponentKind::Spread); + let slippage = component(ComponentKind::Slippage); + let (max_drawdown, max_drawdown_pct) = drawdown(curve); + // One missing conversion poisons the whole sum: turnover and the + // sensitivity derived from it go None together, never a partial figure. + let notional_total = report + .trades + .iter() + .try_fold(Decimal::ZERO, |sum, line| line.notional.map(|n| sum + n)); + let trips = crate::trades::round_trips(fills); + let turnover_pct = notional_total + .and_then(|total| total.checked_div(report.anchor.starting_balance)) + .map(|ratio| ratio * dec!(100)); + Metrics { + return_pct: report + .anchor + .total_pnl + .checked_div(report.anchor.starting_balance) + .map(|ratio| ratio * dec!(100)), + max_drawdown, + max_drawdown_pct, + fees: component(ComponentKind::Fees), + friction: spread.zip(slippage).map(|(s, l)| s + l), + turnover_pct, + hit_rate: hit_rate(&trips), + profit_factor: profit_factor(&trips), + // Tied to turnover by construction, so "None whenever turnover is" + // holds even on a zero-balance epoch where only the division fails. + slippage_sensitivity: notional_total + .filter(|_| turnover_pct.is_some()) + .map(|total| -total / dec!(10_000)), + } +} + +fn hit_rate(trips: &[RoundTrip]) -> Option { + if trips.is_empty() { + return None; + } + let wins = trips + .iter() + .filter(|trip| trip.net_pnl > Decimal::ZERO) + .count(); + Some(Decimal::from(wins) / Decimal::from(trips.len())) +} + +fn profit_factor(trips: &[RoundTrip]) -> Option { + let (first, rest) = trips.split_first()?; + // Round-trip P&L is quoted per pair; a cross-currency sum would be a + // number with no unit. One quote currency or no figure. + if rest.iter().any(|trip| trip.quote != first.quote) { + return None; + } + let gross = |sign: Decimal| -> Decimal { + trips + .iter() + .map(|trip| (trip.net_pnl * sign).max(Decimal::ZERO)) + .sum() + }; + let (profit, loss) = (gross(Decimal::ONE), gross(Decimal::NEGATIVE_ONE)); + (loss > Decimal::ZERO).then(|| profit / loss) +} + +/// The seam between deterministic tape marks and the stop snapshot's live +/// REST mark, surfaced when it is material relative to the account size. +const DIVERGENCE_CAVEAT_THRESHOLD_PCT: Decimal = dec!(1); + +fn divergence_caveat(anchor: &Anchor, curve: &CurveSummary) -> Option { + let last = curve.last_value?; + let divergence_pct = (last - anchor.final_value) + .abs() + .checked_div(anchor.starting_balance)? + * dec!(100); + (divergence_pct > DIVERGENCE_CAVEAT_THRESHOLD_PCT).then(|| { + format!( + "tape-marked end value {last} diverges from the stop anchor {} by \ + {divergence_pct:.2}% of the starting balance — recorded marks and the stop \ + snapshot's live mark disagree", + anchor.final_value + ) + }) +} + +/// Largest peak-to-trough drop, and the worst drop *relative to its peak* +/// at any point in percent (conventional maximum drawdown), one exact pass. +/// The two maxima can anchor on different peaks: a later, taller peak can +/// host the largest absolute drop while an earlier, smaller one hosted the +/// deepest relative one. Percent candidates need a positive peak — a drop +/// in negative equity gets no percent, never a fake (or negative) one — but +/// a curve with no drop at all is honestly 0%, no division needed. +fn drawdown(curve: &Curve) -> (Option, Option) { + let mut points = curve.points.iter(); + let Some(first) = points.next() else { + return (None, None); + }; + let mut peak = first.value; + let mut worst = Decimal::ZERO; + let mut worst_pct: Option = None; + for point in points { + if point.value > peak { + peak = point.value; + } + let drop = peak - point.value; + if drop > worst { + worst = drop; + } + // Checked: a hand-edited journal can pair a dust peak with a drop + // whose ratio overflows Decimal — skip the candidate, never panic. + if peak > Decimal::ZERO + && let Some(pct) = drop + .checked_div(peak) + .and_then(|ratio| ratio.checked_mul(dec!(100))) + && worst_pct.is_none_or(|current| pct > current) + { + worst_pct = Some(pct); + } + } + if worst == Decimal::ZERO { + worst_pct = Some(Decimal::ZERO); + } + (Some(worst), worst_pct) +} + +fn trade_stats(report: &PnlReport, fills: &[PaperTrade]) -> TradeStats { + let buys = fills + .iter() + .filter(|trade| trade.side == OrderSide::Buy) + .count(); + TradeStats { + fills: fills.len(), + buys, + sells: fills.len() - buys, + decided: report.attribution.iter().map(|slice| slice.decided).sum(), + missed_fills: report.missed_fills.orders.len(), + } +} + +fn curve_summary(curve: &Curve) -> CurveSummary { + let values = || curve.points.iter().map(|point| point.value); + CurveSummary { + currency: curve.currency.clone(), + points: curve.points.len(), + complete: curve.points.iter().all(|point| point.complete), + first_value: curve.points.first().map(|point| point.value), + last_value: curve.points.last().map(|point| point.value), + peak: values().max(), + trough: values().min(), + unmarked: curve.unmarked.clone(), + } +} + +/// Required disclosures that keep replay evidence below live promotion evidence. +fn replay_caveats(manifest: &kraken_session::manifest::SessionManifest) -> Vec { + let Some(source) = &manifest.source else { + return Vec::new(); + }; + let mut caveats = vec![ + format!( + "replayed from {} at {}x speed — a recorded-data session, not a live market", + source.tape, source.speed + ), + "lookahead not enforced: the agent can read the source tape; replay is screening \ + evidence, never promotion-grade alone" + .to_string(), + "fills simulate against top-of-book marks (no depth, queue, or partial fills) and \ + orders never move the tape" + .to_string(), + ]; + // At or below 1x the agent has live-or-better reaction time; the + // compression disclosure would be false there. + if source.speed > 1.0 { + caveats.push(format!( + "reaction-time compression: at {}x the agent has 1/{}th of live reaction time", + source.speed, source.speed + )); + } + caveats +} + +/// Report caveats first (the waterfall is the primary artifact), then the +/// fold's; exact duplicates collapse while first-seen order is kept. +fn merged_caveats(report: Vec, fold: impl IntoIterator) -> Vec { + let mut seen = BTreeSet::new(); + report + .into_iter() + .chain(fold) + .filter(|caveat| seen.insert(caveat.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use kraken_paper::account::Origin; + use kraken_session::manifest::SessionOutcome; + use kraken_session::timeline::fixtures::{self, SessionBuilder}; + + use super::*; + use crate::LabError; + + /// A stopped one-fill session: buy 0.1 BTC at 50k mid (no spread, no + /// slippage, 0.26% fee), mark moves to 52k. + fn stopped_session() -> SessionTimeline { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(52_000), + dec!(52_000), + ); + + let mut manifest = fixtures::manifest(vec![]); + // 10_000 − 5_000 cost − 13 fee + 0.1 × 52_000 end mark = 10_187. + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:04Z".parse().unwrap(), + final_value: dec!(10_187), + pnl: dec!(187), + }); + builder.session(None, manifest) + } + + #[test] + fn worked_example_scores_the_stop_anchor_exactly() { + let card = score(&stopped_session()).unwrap(); + assert_eq!(card.anchor.total_pnl, dec!(187)); + assert_eq!(card.metrics.return_pct, Some(dec!(1.87))); + assert_eq!(card.metrics.fees, Some(-dec!(13)), "the waterfall's line"); + assert_eq!(card.trades.fills, 1); + assert_eq!(card.trades.buys, 1); + assert_eq!(card.trades.sells, 0); + assert_eq!(card.curve.currency, "USD"); + assert!(card.curve.complete); + // 5_000 notional over 10_000 starting balance; the open lot is + // never graded. + assert_eq!(card.metrics.turnover_pct, Some(dec!(50))); + assert_eq!(card.metrics.slippage_sensitivity, Some(dec!(-0.5))); + assert_eq!(card.metrics.hit_rate, None, "no closed round trip"); + assert_eq!(card.metrics.profit_factor, None); + } + + /// A closed winning round trip: buy 0.1 @ 50k, sell 0.1 @ 52k, zero + /// rates. Hit rate grades the trip; profit factor stays honest — with + /// no loss there is nothing to divide by. + #[test] + fn closed_winning_round_trip_grades_hit_rate_but_not_profit_factor() { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(52_000), + dec!(52_000), + ); + builder.market_fill( + "2026-01-01T00:00:04Z", + Origin::Cli, + OrderSide::Sell, + "BTCUSD", + dec!(0.1), + (dec!(52_000), dec!(52_000)), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:05Z".parse().unwrap(), + final_value: dec!(10_200), + pnl: dec!(200), + }); + let card = score(&builder.session(None, manifest)).unwrap(); + + assert_eq!(card.metrics.hit_rate, Some(dec!(1))); + assert_eq!(card.metrics.profit_factor, None, "gross loss is zero"); + // 5_000 + 5_200 notional over 10_000. + assert_eq!(card.metrics.turnover_pct, Some(dec!(102))); + assert_eq!(card.metrics.slippage_sensitivity, Some(dec!(-1.02))); + } + + /// An all-losing session pins the zero boundaries: hit rate 0, profit + /// factor 0 — real zeros from graded trips, not absent values. + #[test] + fn all_losing_round_trips_score_zero_hit_rate_and_zero_profit_factor() { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(49_000), + dec!(49_000), + ); + builder.market_fill( + "2026-01-01T00:00:04Z", + Origin::Cli, + OrderSide::Sell, + "BTCUSD", + dec!(0.1), + (dec!(49_000), dec!(49_000)), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:05Z".parse().unwrap(), + final_value: dec!(9_900), + pnl: dec!(-100), + }); + let card = score(&builder.session(None, manifest)).unwrap(); + + assert_eq!(card.metrics.hit_rate, Some(dec!(0))); + assert_eq!(card.metrics.profit_factor, Some(dec!(0))); + } + + /// No fills at all: turnover is a real zero (derivable, and zero), while + /// the lot metrics are honestly absent. + #[test] + fn fill_less_session_has_zero_turnover_and_absent_lot_metrics() { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:02Z".parse().unwrap(), + final_value: dec!(10_000), + pnl: dec!(0), + }); + let card = score(&builder.session(None, manifest)).unwrap(); + + assert_eq!(card.metrics.turnover_pct, Some(dec!(0))); + assert_eq!(card.metrics.slippage_sensitivity, Some(dec!(0))); + assert_eq!(card.metrics.hit_rate, None); + assert_eq!(card.metrics.profit_factor, None); + } + + /// The can't-disagree pin: the scorecard's components serialize + /// byte-identically to the explain-pnl report's. + #[test] + fn components_are_byte_equal_to_the_pnl_report() { + let session = stopped_session(); + let card = score(&session).unwrap(); + let report = explain_pnl(&session).unwrap(); + assert_eq!( + serde_json::to_vec(&card.components).unwrap(), + serde_json::to_vec(&report.components).unwrap() + ); + assert_eq!( + serde_json::to_vec(&card.anchor).unwrap(), + serde_json::to_vec(&report.anchor).unwrap() + ); + } + + #[test] + fn drawdown_is_the_largest_peak_to_trough_drop() { + // Curve 10_000 → 10_400 → 10_100 → 10_500 → 10_200: the worst drop + // is 300 from either peak; the pct is the deeper of the two relative + // drops (300 of 10_400). + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0), dec!(0.0)); + builder.ticker("2026-01-01T00:00:01Z", "BTC/USD", dec!(100), dec!(100)); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(100), + (dec!(100), dec!(100)), + ); + for (ts, mark) in [ + ("2026-01-01T00:00:03Z", dec!(104)), + ("2026-01-01T00:00:04Z", dec!(101)), + ("2026-01-01T00:00:05Z", dec!(105)), + ("2026-01-01T00:00:06Z", dec!(102)), + ] { + // Three repeats warm the median window past the outlier guard. + builder.ticker(ts, "BTC/USD", mark, mark); + builder.ticker(ts, "BTC/USD", mark, mark); + builder.ticker(ts, "BTC/USD", mark, mark); + } + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:07Z".parse().unwrap(), + final_value: dec!(10_200), + pnl: dec!(200), + }); + let session = builder.session(None, manifest); + + let card = score(&session).unwrap(); + assert_eq!(card.metrics.max_drawdown, Some(dec!(300))); + let pct = card.metrics.max_drawdown_pct.expect("peak is nonzero"); + assert!( + pct > dec!(2.85) && pct < dec!(2.89), + "300 of ~10_400/10_500: {pct}" + ); + } + + #[test] + fn drawdown_pct_is_the_relative_worst_not_the_absolute_worst() { + // Curve 1_000 → 500 → 10_000 → 8_900: the account halves early + // (drop 500 of peak 1_000 = 50%), then a larger absolute drop + // (1_100 of peak 10_000 = 11%) follows. A frozen risk criterion + // must see the 50%, not the lenient 11% of the absolute worst. + let mut builder = SessionBuilder::with_rates(dec!(1_000), dec!(0.0), dec!(0.0)); + builder.ticker("2026-01-01T00:00:01Z", "BTC/USD", dec!(100), dec!(100)); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(10), + (dec!(100), dec!(100)), + ); + for (ts, mark) in [ + ("2026-01-01T00:00:03Z", dec!(50)), + ("2026-01-01T00:00:04Z", dec!(1_000)), + ("2026-01-01T00:00:05Z", dec!(890)), + ] { + // Three repeats warm the median window past the outlier guard. + builder.ticker(ts, "BTC/USD", mark, mark); + builder.ticker(ts, "BTC/USD", mark, mark); + builder.ticker(ts, "BTC/USD", mark, mark); + } + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:06Z".parse().unwrap(), + final_value: dec!(8_900), + pnl: dec!(7_900), + }); + let session = builder.session(None, manifest); + + let card = score(&session).unwrap(); + assert_eq!(card.metrics.max_drawdown, Some(dec!(1_100))); + assert_eq!(card.metrics.max_drawdown_pct, Some(dec!(50))); + } + + fn curve_of(values: &[Decimal]) -> crate::Curve { + crate::Curve { + currency: "USD".into(), + points: values + .iter() + .enumerate() + .map(|(i, &value)| crate::Point { + at: "2026-01-01T00:00:00Z".parse().unwrap(), + seq: i as i64, + value, + complete: true, + }) + .collect(), + unmarked: BTreeSet::new(), + } + } + + /// The no-drop boundary: max_drawdown and its pct agree on "nothing + /// dropped" (0, 0%) whatever the curve length or sign. + #[test] + fn dropless_curve_reports_zero_drawdown_and_zero_pct() { + for dropless in [&[dec!(10_000)][..], &[dec!(-500), dec!(-500)][..]] { + assert_eq!( + drawdown(&curve_of(dropless)), + (Some(Decimal::ZERO), Some(Decimal::ZERO)) + ); + } + } + + /// Negative equity dropping further: the drop is real, but the percent + /// has no positive peak to anchor on — None, never a negative percent. + #[test] + fn negative_equity_drop_has_no_pct() { + assert_eq!( + drawdown(&curve_of(&[dec!(-1_000), dec!(-1_500)])), + (Some(dec!(500)), None) + ); + } + + /// The replay honesty envelope rides any session whose manifest carries + /// a source block, and the compression line names the speed. + #[test] + fn replay_provenance_gains_the_honesty_envelope() { + let mut session = stopped_session(); + session.manifest.source = Some(kraken_session::manifest::SessionSource { + tape: "tape:jun-crash".to_string(), + speed: 5.0, + anchor: "2026-06-01T00:00:00Z".parse().unwrap(), + started_at: "2026-07-01T00:00:00Z".parse().unwrap(), + content_hash: None, + }); + let card = score(&session).unwrap(); + for needle in [ + "replayed from tape:jun-crash at 5x speed", + "lookahead not enforced", + "top-of-book marks", + "reaction-time compression: at 5x", + ] { + assert!( + card.caveats.iter().any(|caveat| caveat.contains(needle)), + "missing {needle:?} in {:?}", + card.caveats + ); + } + } + + /// At real-time speed the compression disclosure would be false — the + /// other three replay caveats still apply. + #[test] + fn real_time_replay_omits_the_reaction_time_caveat() { + let mut session = stopped_session(); + session.manifest.source = Some(kraken_session::manifest::SessionSource { + tape: "tape:jun-crash".to_string(), + speed: 1.0, + anchor: "2026-06-01T00:00:00Z".parse().unwrap(), + started_at: "2026-07-01T00:00:00Z".parse().unwrap(), + content_hash: None, + }); + let card = score(&session).unwrap(); + assert!( + !card + .caveats + .iter() + .any(|caveat| caveat.contains("reaction-time compression")), + "caveats: {:?}", + card.caveats + ); + assert!( + card.caveats + .iter() + .any(|caveat| caveat.contains("replayed from")), + ); + } + + /// Pins that score inherits explain-pnl's partial-journal refusal: a + /// timeline that skipped newer-version records must become a named + /// error (an error column in compare), never a confident scorecard. + #[test] + fn partial_journal_is_refused_not_scored() { + let mut session = stopped_session(); + session.newer_records_skipped = 1; + let err = score(&session).unwrap_err(); + assert!( + matches!( + err, + LabError::Replay(kraken_replay::ReplayError::NewerJournal { .. }) + ), + "got: {err}" + ); + } + + #[test] + fn unstopped_session_is_refused_through_the_replay_gate() { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + let session = builder.session(None, fixtures::manifest(vec![])); + + let err = score(&session).unwrap_err(); + assert!( + matches!( + err, + LabError::Replay(kraken_replay::ReplayError::NotStopped { .. }) + ), + "got: {err}" + ); + } + + /// A stop anchor 1.13% of the starting balance away from the tape-marked + /// end value must be called out. The fixture's curve ends at 10_087 — + /// the two-observation mark warm-up means (50k+52k)/2, not the last + /// frame's 52k. + #[test] + fn material_tape_vs_anchor_divergence_gains_a_caveat() { + let mut session = stopped_session(); + session.manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:04Z".parse().unwrap(), + final_value: dec!(10_200), + pnl: dec!(200), + }); + let card = score(&session).unwrap(); + assert!( + card.caveats + .iter() + .any(|caveat| caveat.contains("diverges from the stop anchor")), + "caveats: {:?}", + card.caveats + ); + } + + /// The threshold is strictly greater-than: a divergence of exactly 1% + /// of the starting balance (curve end 10_087 vs anchor 10_187) stays + /// silent. + #[test] + fn divergence_at_exactly_the_threshold_stays_silent() { + let mut session = stopped_session(); + session.manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:04Z".parse().unwrap(), + final_value: dec!(10_187), + pnl: dec!(187), + }); + let card = score(&session).unwrap(); + assert!( + !card + .caveats + .iter() + .any(|caveat| caveat.contains("diverges from the stop anchor")), + "caveats: {:?}", + card.caveats + ); + } + + /// Round-trip P&L is quoted per pair; summing across quote currencies + /// would be a number with no unit — the factor honestly refuses. + #[test] + fn profit_factor_refuses_mixed_quote_currencies() { + let trips = [ + RoundTrip { + quote: "USD".to_string(), + net_pnl: dec!(10), + }, + RoundTrip { + quote: "EUR".to_string(), + net_pnl: dec!(-5), + }, + ]; + assert_eq!(profit_factor(&trips), None); + } + + #[test] + fn caveat_merge_deduplicates_but_keeps_distinct_texts() { + let merged = merged_caveats( + vec!["a".into(), "b".into()], + vec!["b".into(), "c".into(), "a".into()], + ); + assert_eq!(merged, ["a", "b", "c"]); + } + + #[test] + fn scorecard_serializes_the_pinned_top_level_keys() { + let card = score(&stopped_session()).unwrap(); + let json = serde_json::to_value(&card).unwrap(); + assert_eq!(json["group"], "lab"); + assert_eq!(json["type"], "scorecard"); + assert_eq!(json["session"], "btc-dip"); + assert_eq!(json["anchor"]["total_pnl"], 187.0); + assert_eq!(json["metrics"]["return_pct"], 1.87); + assert_eq!(json["trades"]["fills"], 1); + assert_eq!(json["curve"]["currency"], "USD"); + // No source block on the fixture manifest ⇒ live, with the recorded + // symbols (none here). First-class provenance, not a caveat string. + assert_eq!(json["source"]["kind"], "live"); + assert!(json.get("strategy").is_none(), "absent optionals drop out"); + } + + #[test] + fn source_is_replay_when_the_manifest_carries_a_source_block() { + let mut session = stopped_session(); + session.manifest.source = Some(kraken_session::manifest::SessionSource { + tape: "tape:jun-crash".to_string(), + speed: 10.0, + anchor: "2026-06-01T00:00:00Z".parse().unwrap(), + started_at: "2026-07-01T00:00:00Z".parse().unwrap(), + content_hash: None, + }); + let card = score(&session).unwrap(); + match card.source { + ScoreSource::Replay { tape, speed } => { + assert_eq!(tape, "tape:jun-crash"); + assert_eq!(speed, 10.0); + } + other => panic!("expected replay source, got {other:?}"), + } + } + + #[test] + fn live_source_reports_the_actual_window_seconds() { + let mut session = stopped_session(); + session.manifest.window.ended_at = Some("2026-01-01T00:00:21Z".parse().unwrap()); + let card = score(&session).unwrap(); + match card.source { + ScoreSource::Live { window_secs, .. } => assert_eq!(window_secs, Some(21)), + other => panic!("expected live source, got {other:?}"), + } + } + + /// A window that ends before it starts is corrupt; the exposure is + /// honestly `None`, never a wrapped or clamped number. + #[test] + fn live_source_refuses_a_window_that_ends_before_it_starts() { + let mut session = stopped_session(); + session.manifest.window.ended_at = Some("2025-12-31T23:59:00Z".parse().unwrap()); + let card = score(&session).unwrap(); + match card.source { + ScoreSource::Live { window_secs, .. } => assert_eq!(window_secs, None), + other => panic!("expected live source, got {other:?}"), + } + } +} diff --git a/crates/kraken-lab/src/store.rs b/crates/kraken-lab/src/store.rs new file mode 100644 index 0000000..fa4ea25 --- /dev/null +++ b/crates/kraken-lab/src/store.rs @@ -0,0 +1,350 @@ +//! The experiment store: the crate's single filesystem-and-clock seam. +//! +//! Everything the read-model pipeline and the [`experiment`] +//! domain compute is pure; this module is where that purity ends. It writes a +//! sealed spec, reads and verifies one back, and discovers an experiment's runs +//! on disk. All state is files, so a fresh agent context reproduces any verdict +//! from the store alone — there is no in-memory cache and no daemon to trust. + +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use kraken_recording::write_json_atomic; +use kraken_session::manifest::SessionManifest; +use kraken_session::session::{parse_ordinal, session_file_path, sessions_root}; + +use crate::experiment::{self, Criteria, Experiment, SessionPlan}; +use crate::{LabError, Result}; + +/// One discovered run of an experiment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionRef { + pub ordinal: u32, + /// The session's id, `s` — its directory name under `runs/`. + pub session: String, +} + +/// The store all experiments live under: `/lab/experiments/`. `base` +/// is the workspace-scoped dir in production and a temp dir in tests. +pub fn experiments_root(base: &Path) -> PathBuf { + base.join("lab").join("experiments") +} + +fn experiment_path(base: &Path, name: &str) -> PathBuf { + experiments_root(base).join(format!("{name}.json")) +} + +/// Freeze a new experiment: seal the spec (validating it and stamping the +/// clock here, at the I/O boundary), refuse an existing name, and write the +/// sealed file atomically. +pub(crate) fn freeze( + base: &Path, + name: &str, + hypothesis: &str, + strategy: Option, + criteria: Criteria, + session_plan: Option, +) -> Result { + let experiment = experiment::seal( + name, + hypothesis, + strategy, + criteria, + session_plan, + Utc::now(), + )?; + let path = experiment_path(base, name); + if std::fs::exists(&path)? { + return Err(LabError::ExperimentExists(name.to_string())); + } + write_json_atomic(&path, &experiment).map_err(|e| match e { + kraken_recording::Error::Io(io) => LabError::Store(io), + other => LabError::Damaged(other.to_string()), + })?; + Ok(experiment) +} + +/// Load a frozen experiment and verify its seal. A missing file is +/// `ExperimentNotFound`; an undecodable or tampered file is `Damaged` (the +/// seal check lives in the domain — see [`experiment::verify`]). +/// Every sealed experiment name in this scope, sorted. A missing lab tree +/// is an empty list, never an error. +pub(crate) fn experiments(base: &Path) -> Result> { + let entries = match std::fs::read_dir(experiments_root(base)) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut names = Vec::new(); + for entry in entries { + let path = entry?.path(); + if path.extension().is_some_and(|ext| ext == "json") + && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + { + names.push(stem.to_string()); + } + } + names.sort(); + Ok(names) +} + +pub(crate) fn load(base: &Path, name: &str) -> Result { + let path = experiment_path(base, name); + if !std::fs::exists(&path)? { + return Err(LabError::ExperimentNotFound(name.to_string())); + } + let data = std::fs::read_to_string(&path)?; + let experiment: Experiment = serde_json::from_str(&data) + .map_err(|e| LabError::Damaged(format!("experiment '{name}' cannot be decoded: {e}")))?; + experiment::verify(&experiment, name)?; + Ok(experiment) +} + +/// All of `experiment`'s runs under `base`, ordinal-ascending. Membership is +/// the `session.json` experiment stamp alone — labels are display handles, never +/// a discovery key. A damaged `session.json` cannot be attributed to any +/// experiment, so the session is skipped with a warning. This stays the single +/// discovery seam — every consumer routes through it. +pub(crate) fn discover_sessions(base: &Path, experiment: &str) -> Result> { + let entries = match std::fs::read_dir(sessions_root(base)) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut runs = Vec::new(); + for entry in entries { + let entry = entry?; + // A racing delete between readdir and stat is "not a session directory + // anymore", not a reason to fail the whole discovery. + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + // Run ids are ASCII by grammar, so a non-UTF-8 entry cannot be one. + let Some(ordinal) = entry.file_name().to_str().and_then(parse_ordinal) else { + continue; + }; + match SessionManifest::load(&session_file_path(base, ordinal)) { + Ok(run) if run.experiment.as_deref() == Some(experiment) => { + runs.push(SessionRef { + ordinal, + session: format!("s{ordinal}"), + }); + } + Ok(_) => {} + Err(err) => { + tracing::warn!(run = format!("s{ordinal}"), error = %err, + "session.json unreadable; skipping — the session cannot be attributed to an experiment"); + } + } + } + // Canonical ordinals are unique per scope, so this order is total. + runs.sort_unstable_by_key(|run| run.ordinal); + Ok(runs) +} + +#[cfg(test)] +mod tests { + use kraken_session::manifest::{PaperRef, SessionManifest, SessionWindow}; + use kraken_session::session::session_dir; + use rust_decimal::Decimal; + use rust_decimal_macros::dec; + + use super::*; + + fn criteria() -> Criteria { + Criteria { + min_return_pct: Some(dec!(1.0)), + max_drawdown_pct: Some(dec!(5.0)), + min_fills: Some(2), + } + } + + #[test] + fn freeze_then_load_round_trips_the_sealed_spec() { + let base = tempfile::tempdir().unwrap(); + let frozen = freeze( + base.path(), + "momentum-1", + "buying strength beats the tape", + Some("recipe-momentum".into()), + criteria(), + None, + ) + .unwrap(); + assert!(frozen.frozen.starts_with("sha256:")); + + let loaded = load(base.path(), "momentum-1").unwrap(); + assert_eq!(loaded, frozen); + } + + #[test] + fn freezing_an_existing_name_is_refused() { + let base = tempfile::tempdir().unwrap(); + freeze(base.path(), "m1", "h", None, criteria(), None).unwrap(); + let err = freeze(base.path(), "m1", "h2", None, criteria(), None).unwrap_err(); + assert!(matches!(err, LabError::ExperimentExists(_)), "got: {err}"); + assert!(err.to_string().contains("already exists"), "got: {err}"); + } + + #[test] + fn loading_a_missing_experiment_names_the_new_command() { + let base = tempfile::tempdir().unwrap(); + let err = load(base.path(), "ghost").unwrap_err(); + assert!(matches!(err, LabError::ExperimentNotFound(_))); + assert_eq!( + err.to_string(), + "experiment 'ghost' not found; run 'kraken lab new ghost' first" + ); + } + + #[test] + fn tampering_after_freeze_reads_as_damage_not_rejection() { + let base = tempfile::tempdir().unwrap(); + freeze(base.path(), "m1", "h", None, criteria(), None).unwrap(); + + // Move the goalpost by hand: min_return_pct 1.0 → 0.0, through a + // JSON round-trip so the tamper is independent of file formatting. + let path = experiments_root(base.path()).join("m1.json"); + let mut doctored: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + doctored["criteria"]["min_return_pct"] = serde_json::json!(0.0); + std::fs::write(&path, serde_json::to_string(&doctored).unwrap()).unwrap(); + + let err = load(base.path(), "m1").unwrap_err(); + assert!(matches!(err, LabError::Damaged(_)), "got: {err}"); + assert!(err.to_string().contains("modified after"), "got: {err}"); + } + + /// A rename leaves the content — and thus the seal — intact, so only the + /// filename↔sealed-name binding catches it. This also stands in for the + /// in-band case-insensitive-filesystem route (`compare M1` resolving to + /// `m1.json`): both hand `load` a file whose sealed name is not the one + /// requested, and both must fail loud so discovery never keys off it. + #[test] + fn a_renamed_spec_is_refused_though_its_seal_still_verifies() { + let base = tempfile::tempdir().unwrap(); + freeze(base.path(), "m1", "h", None, criteria(), None).unwrap(); + let root = experiments_root(base.path()); + std::fs::rename(root.join("m1.json"), root.join("m2.json")).unwrap(); + + let err = load(base.path(), "m2").unwrap_err(); + assert!(matches!(err, LabError::Damaged(_)), "got: {err}"); + assert!( + err.to_string().contains("sealed under the name 'm1'"), + "got: {err}" + ); + } + + fn write_run(base: &Path, ordinal: u32, experiment: Option<&str>) { + let run = format!("s{ordinal}"); + let manifest = SessionManifest::new( + run.clone(), + "0.0.0-test", + 0, + Vec::new(), + PaperRef { + starting_balance: Decimal::ONE, + currency: "USD".to_string(), + }, + SessionWindow { + session: run, + started_at: "2026-01-01T00:00:00Z".parse().unwrap(), + opening_equity: Decimal::ONE, + opening_complete: true, + ended_at: None, + }, + ) + .with_experiment(experiment.map(str::to_string)); + manifest.save(&session_file_path(base, ordinal)).unwrap(); + } + + #[test] + fn discover_runs_orders_ordinals_numerically_not_lexicographically() { + let base = tempfile::tempdir().unwrap(); + for ordinal in [10, 2, 1] { + write_run(base.path(), ordinal, Some("m1")); + } + let ordinals: Vec = discover_sessions(base.path(), "m1") + .unwrap() + .into_iter() + .map(|run| run.ordinal) + .collect(); + assert_eq!(ordinals, [1, 2, 10]); + } + + #[test] + fn discover_runs_keys_on_the_experiment_stamp_alone() { + let base = tempfile::tempdir().unwrap(); + // Stamped for m1: the only membership rule. + write_run(base.path(), 1, Some("m1")); + // Stamped for another experiment: not a session of m1. + write_run(base.path(), 2, Some("m2")); + // No stamp at all: not an experiment run. + write_run(base.path(), 3, None); + + let runs = discover_sessions(base.path(), "m1").unwrap(); + assert_eq!( + runs, + [SessionRef { + ordinal: 1, + session: "s1".to_string(), + }] + ); + } + + #[test] + fn discover_runs_skips_non_run_entries() { + let base = tempfile::tempdir().unwrap(); + write_run(base.path(), 1, Some("m1")); + let root = sessions_root(base.path()); + // A leading-zero alias is not a session directory (see parse_ordinal). + std::fs::create_dir_all(root.join("r01")).unwrap(); + // Nor is a directory outside the s grammar. + std::fs::create_dir_all(root.join("btc-dip")).unwrap(); + // A stray file named like a session is not a session directory. + std::fs::write(root.join("s2"), "").unwrap(); + // A session directory without a session.json cannot be attributed. + std::fs::create_dir_all(root.join("s3")).unwrap(); + + let runs = discover_sessions(base.path(), "m1").unwrap(); + assert_eq!( + runs, + [SessionRef { + ordinal: 1, + session: "s1".to_string(), + }] + ); + } + + #[test] + fn discover_runs_without_a_runs_tree_is_empty_not_an_error() { + let base = tempfile::tempdir().unwrap(); + assert_eq!(discover_sessions(base.path(), "m1").unwrap(), []); + + std::fs::create_dir_all(sessions_root(base.path())).unwrap(); + assert_eq!(discover_sessions(base.path(), "m1").unwrap(), []); + } + + /// A session.json the schema cannot decode carries no readable experiment + /// stamp, so the session cannot be attributed — skipped (with a warning), + /// never surfaced under the wrong experiment. + #[test] + fn a_damaged_run_json_is_skipped_not_attributed() { + let base = tempfile::tempdir().unwrap(); + write_run(base.path(), 1, Some("m1")); + std::fs::create_dir_all(session_dir(base.path(), 2)).unwrap(); + std::fs::write(session_file_path(base.path(), 2), "not json").unwrap(); + + let runs = discover_sessions(base.path(), "m1").unwrap(); + assert_eq!( + runs, + [SessionRef { + ordinal: 1, + session: "s1".to_string(), + }] + ); + } +} diff --git a/crates/kraken-lab/src/trades.rs b/crates/kraken-lab/src/trades.rs new file mode 100644 index 0000000..1def0f2 --- /dev/null +++ b/crates/kraken-lab/src/trades.rs @@ -0,0 +1,156 @@ +//! FIFO round-trip attribution over replay fills. +//! +//! Fees are allocated pro rata and open lots are excluded. Sign-flip +//! heuristics misgrade scaled entries, so sells close the oldest buy lots. + +use std::collections::{HashMap, VecDeque}; + +use kraken_paper::{OrderSide, PaperTrade}; +use rust_decimal::Decimal; + +/// One FIFO-closed round trip. `net_pnl` is quoted in the pair's own quote +/// currency — cross-currency sums are the caller's judgment call. +#[derive(Debug)] +pub(crate) struct RoundTrip { + pub(crate) quote: String, + pub(crate) net_pnl: Decimal, +} + +/// Match `fills` (journal order) into closed round trips. Open remainders — +/// buy lots never sold — are not graded; spot paper cannot short, so a sell +/// tail without an open lot is ignored rather than fabricated into a trip. +pub(crate) fn round_trips(fills: &[PaperTrade]) -> Vec { + struct Lot { + volume: Decimal, + price: Decimal, + fee_per_unit: Decimal, + } + let mut open: HashMap<&str, VecDeque> = HashMap::new(); + let mut trips = Vec::new(); + for fill in fills { + if fill.volume <= Decimal::ZERO { + continue; + } + let fee_per_unit = fill.fee / fill.volume; + match fill.side { + OrderSide::Buy => open.entry(fill.pair.as_str()).or_default().push_back(Lot { + volume: fill.volume, + price: fill.price, + fee_per_unit, + }), + OrderSide::Sell => { + let lots = open.entry(fill.pair.as_str()).or_default(); + let mut remaining = fill.volume; + while remaining > Decimal::ZERO { + let Some(lot) = lots.front_mut() else { + break; + }; + let matched = remaining.min(lot.volume); + let net_pnl = + (fill.price - lot.price - lot.fee_per_unit - fee_per_unit) * matched; + trips.push(RoundTrip { + quote: fill.quote.clone(), + net_pnl, + }); + lot.volume -= matched; + remaining -= matched; + if lot.volume == Decimal::ZERO { + lots.pop_front(); + } + } + } + } + } + trips +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use rust_decimal_macros::dec; + + use super::*; + + fn fill(side: OrderSide, volume: Decimal, price: Decimal, fee: Decimal) -> PaperTrade { + PaperTrade { + id: "t".into(), + order_id: "o".into(), + pair: "BTC/USD".into(), + base: "BTC".into(), + quote: "USD".into(), + side, + volume, + price, + fee, + cost: volume * price, + filled_at: Utc::now(), + reference_quote: None, + } + } + + #[test] + fn partial_close_grades_only_the_sold_portion() { + // The DCA shape: two buys, one sell smaller than the first lot. + let fills = [ + fill(OrderSide::Buy, dec!(0.2), dec!(100), dec!(0)), + fill(OrderSide::Buy, dec!(0.1), dec!(110), dec!(0)), + fill(OrderSide::Sell, dec!(0.25), dec!(120), dec!(0)), + ]; + let trips = round_trips(&fills); + assert_eq!(trips.len(), 2, "one full lot + one partial lot closed"); + assert_eq!(trips[0].net_pnl, dec!(4)); // (120-100) × 0.2 + assert_eq!(trips[1].net_pnl, dec!(0.5)); // (120-110) × 0.05 + } + + #[test] + fn fees_allocate_pro_rata_and_can_flip_a_win_to_a_loss() { + // Price gain 1 × 0.1 = 0.1; fees 0.08 (buy, all matched) + 0.04 + // (sell) = 0.12 — a gross win graded as a net loss. + let fills = [ + fill(OrderSide::Buy, dec!(0.1), dec!(100), dec!(0.08)), + fill(OrderSide::Sell, dec!(0.1), dec!(101), dec!(0.04)), + ]; + let trips = round_trips(&fills); + assert_eq!(trips.len(), 1); + assert_eq!(trips[0].net_pnl, dec!(-0.02)); + } + + #[test] + fn buys_only_close_nothing() { + let fills = [ + fill(OrderSide::Buy, dec!(0.1), dec!(100), dec!(0)), + fill(OrderSide::Buy, dec!(0.1), dec!(90), dec!(0)), + ]; + assert!(round_trips(&fills).is_empty()); + } + + /// Spot paper cannot short, so a sell with no open lot (an epoch that + /// began holding assets it never bought) grades nothing rather than + /// fabricating a trip. + #[test] + fn sell_without_an_open_lot_grades_nothing() { + let fills = [ + fill(OrderSide::Sell, dec!(0.1), dec!(100), dec!(0)), + fill(OrderSide::Buy, dec!(0.2), dec!(90), dec!(0)), + // Closes the 0.2 lot fully; the 0.1 tail has nothing to match. + fill(OrderSide::Sell, dec!(0.3), dec!(95), dec!(0)), + ]; + let trips = round_trips(&fills); + assert_eq!(trips.len(), 1, "only the bought lot is graded"); + assert_eq!(trips[0].net_pnl, dec!(1)); // (95-90) × 0.2 + } + + #[test] + fn pairs_match_independently() { + let mut eth = fill(OrderSide::Buy, dec!(1), dec!(10), dec!(0)); + eth.pair = "ETH/USD".into(); + let fills = [ + fill(OrderSide::Buy, dec!(0.1), dec!(100), dec!(0)), + eth, + fill(OrderSide::Sell, dec!(0.1), dec!(120), dec!(0)), + ]; + let trips = round_trips(&fills); + assert_eq!(trips.len(), 1, "the ETH lot stays open"); + assert_eq!(trips[0].net_pnl, dec!(2)); + } +} diff --git a/crates/kraken-lab/src/window.rs b/crates/kraken-lab/src/window.rs new file mode 100644 index 0000000..a2ae8fa --- /dev/null +++ b/crates/kraken-lab/src/window.rs @@ -0,0 +1,49 @@ +//! The run-window grammar shared by lab plans (`live:`) and +//! `session start --for`. Pure grammar: it returns the failure *reason* +//! without a flag name, so each caller frames the error against its own flag. + +use std::time::Duration; + +/// Parse a human duration (`20s`, `5m`, `1h`, `2h 30m`) into a `Duration`, or +/// return the human reason it is malformed. Delegates to [`humantime`] so the +/// grammar is a maintained, documented spec rather than hand-rolled; a sealed +/// `live:` and a `--for` stay in lock-step because both route here. +/// Zero is rejected: a zero window seals a session that recorded nothing. +pub fn parse_window(window: &str) -> Result { + let window = humantime::parse_duration(window.trim()).map_err(|why| why.to_string())?; + if window.is_zero() { + return Err("a window must be longer than zero".to_string()); + } + Ok(window) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_the_units_humantime_documents() { + assert_eq!(parse_window("20s").unwrap(), Duration::from_secs(20)); + assert_eq!(parse_window("5m").unwrap(), Duration::from_secs(300)); + assert_eq!(parse_window("1h").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_window("24h").unwrap(), Duration::from_secs(86_400)); + assert_eq!(parse_window("1d").unwrap(), Duration::from_secs(86_400)); + // Compound spans are the reason to lean on the crate rather than a + // single number+unit split. + assert_eq!(parse_window("2h 30m").unwrap(), Duration::from_secs(9_000)); + } + + #[test] + fn rejects_junk_and_bare_numbers() { + for bad in ["24", "h", "", "soon", "10x"] { + assert!(parse_window(bad).is_err(), "{bad}"); + } + } + + #[test] + fn rejects_a_zero_window() { + for zero in ["0s", "0m", "0h 0m"] { + assert!(parse_window(zero).is_err(), "{zero}"); + } + } +} diff --git a/crates/kraken-lab/tests/experiment_lifecycle.rs b/crates/kraken-lab/tests/experiment_lifecycle.rs new file mode 100644 index 0000000..1f4e08a --- /dev/null +++ b/crates/kraken-lab/tests/experiment_lifecycle.rs @@ -0,0 +1,123 @@ +//! The experiment lifecycle through the public facade only: freeze → show → +//! evaluate on a real scorecard → on-disk tamper reads as damage. All state is +//! files, so this is exactly what a fresh agent context replays. + +use kraken_lab::{Criteria, Lab, LabError, experiments_root, score}; +use kraken_paper::OrderSide; +use kraken_paper::account::Origin; +use kraken_session::manifest::SessionOutcome; +use kraken_session::timeline::fixtures::{self, SessionBuilder}; +use rust_decimal_macros::dec; + +// Fixture misuse is a test bug; the parse target is a literal. +#[allow(clippy::unwrap_used)] +fn stopped_session() -> kraken_session::timeline::SessionTimeline { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:04Z".parse().unwrap(), + final_value: dec!(10_187), + pnl: dec!(187), + }); + builder.session(None, manifest) +} + +#[test] +fn freeze_show_evaluate_reaches_a_mechanical_verdict() { + let base = tempfile::tempdir().unwrap(); + let lab = Lab::new(base.path()); + lab.freeze( + "momentum-1", + "buying strength beats sitting in cash", + None, + Criteria { + min_return_pct: Some(dec!(1.0)), + max_drawdown_pct: None, + min_fills: Some(1), + }, + &[], + ) + .unwrap(); + + // A fresh load — as a cold context would do — then judge a real card. + let experiment = lab.show("momentum-1").unwrap(); + let card = score(&stopped_session()).unwrap(); + let verdict = experiment.criteria.evaluate(&card); + + assert!(verdict.pass, "1.87% return ≥ 1.0%, 1 fill ≥ 1"); + assert_eq!(verdict.checks.len(), 2); + assert!(verdict.checks.iter().all(|check| check.pass)); +} + +#[test] +fn failing_criteria_produce_a_failing_verdict_with_named_checks() { + let base = tempfile::tempdir().unwrap(); + let lab = Lab::new(base.path()); + lab.freeze( + "moonshot", + "this strategy doubles the account", + None, + Criteria { + min_return_pct: Some(dec!(100.0)), + max_drawdown_pct: None, + min_fills: Some(1), + }, + &[], + ) + .unwrap(); + + let experiment = lab.show("moonshot").unwrap(); + let verdict = experiment + .criteria + .evaluate(&score(&stopped_session()).unwrap()); + + assert!(!verdict.pass); + let failed: Vec<&str> = verdict + .checks + .iter() + .filter(|check| !check.pass) + .map(|check| check.criterion) + .collect(); + assert_eq!(failed, ["min_return_pct"]); +} + +#[test] +fn on_disk_tamper_is_damage_at_show() { + let base = tempfile::tempdir().unwrap(); + let lab = Lab::new(base.path()); + lab.freeze( + "sealed", + "the spec is immutable", + None, + Criteria { + min_return_pct: Some(dec!(2.0)), + ..Criteria::default() + }, + &[], + ) + .unwrap(); + + let path = experiments_root(base.path()).join("sealed.json"); + let mut doctored: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + doctored["criteria"]["min_return_pct"] = serde_json::json!(0.5); + std::fs::write(&path, serde_json::to_string(&doctored).unwrap()).unwrap(); + + let err = lab.show("sealed").unwrap_err(); + assert!(matches!(err, LabError::Damaged(_)), "got: {err}"); + assert!(err.to_string().contains("modified after"), "got: {err}"); +} diff --git a/crates/kraken-lab/tests/fold_pipeline.rs b/crates/kraken-lab/tests/fold_pipeline.rs new file mode 100644 index 0000000..d33eb6e --- /dev/null +++ b/crates/kraken-lab/tests/fold_pipeline.rs @@ -0,0 +1,88 @@ +//! The curve fold driven through the public API only — the boundary proof +//! for the scorecard and the binary's lab command above. + +use kraken_lab::{LabError, replay}; +use kraken_paper::OrderSide; +use kraken_paper::account::Origin; +use kraken_session::timeline::fixtures::{self, SessionBuilder}; +use rust_decimal_macros::dec; + +fn marked_session() -> kraken_session::timeline::SessionTimeline { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(52_000), + dec!(52_000), + ); + builder.session(None, fixtures::manifest(vec![])) +} + +/// Fold determinism restated from outside the crate: the same session folds +/// to a byte-identical curve and ledger, twice. +#[test] +fn folding_twice_is_byte_identical() { + let session = marked_session(); + let first = serde_json::to_vec(&replay(&session).unwrap()).unwrap(); + let second = serde_json::to_vec(&replay(&session).unwrap()).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn curve_and_ledger_reflect_the_fold() { + let session = marked_session(); + let folded = replay(&session).unwrap(); + + assert_eq!(folded.fills.len(), 1, "one fill on the ledger"); + assert_eq!(folded.fills[0].volume, dec!(0.1)); + assert_eq!(folded.curve.currency, "USD"); + assert!( + folded.curve.points.iter().all(|p| p.complete), + "every holding marked" + ); + // The mark move from 50k to 52k re-values the held 0.1 BTC upward. + let last = folded.curve.points.last().unwrap(); + let first = &folded.curve.points[0]; + assert!( + last.value > first.value - dec!(200), + "curve tracks the mark" + ); +} + +/// A journal with no epoch refuses the fold — visible to consumers as the +/// typed `NoEpoch`, never a fabricated equity line. +#[test] +fn epochless_journal_is_refused_as_no_epoch() { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + let mut session = builder.session(None, fixtures::manifest(vec![])); + // Strip the account track: only market frames remain. + session.events.retain(|event| { + matches!( + event.payload, + kraken_session::timeline::event::EventPayload::Market(_) + ) + }); + + let err = replay(&session).unwrap_err(); + assert!(matches!(err, LabError::NoEpoch { .. }), "got: {err}"); +} diff --git a/crates/kraken-lab/tests/score_pipeline.rs b/crates/kraken-lab/tests/score_pipeline.rs new file mode 100644 index 0000000..a1588b3 --- /dev/null +++ b/crates/kraken-lab/tests/score_pipeline.rs @@ -0,0 +1,72 @@ +//! The scorecard driven through the public API only: a stopped fixture +//! session scores, and its waterfall is byte-equal to `explain_pnl`'s — +//! the can't-disagree contract proven from outside the crate. + +use kraken_lab::score; +use kraken_paper::OrderSide; +use kraken_paper::account::Origin; +use kraken_replay::explain_pnl; +use kraken_session::manifest::SessionOutcome; +use kraken_session::timeline::SessionTimeline; +use kraken_session::timeline::fixtures::{self, SessionBuilder}; +use rust_decimal_macros::dec; + +// Fixture misuse is a test bug; the parse target is a literal. +#[allow(clippy::unwrap_used)] +fn stopped_session() -> SessionTimeline { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(52_000), + dec!(52_000), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:04Z".parse().unwrap(), + final_value: dec!(10_187), + pnl: dec!(187), + }); + builder.session(None, manifest) +} + +#[test] +fn scorecard_components_are_byte_equal_to_explain_pnl() { + let session = stopped_session(); + let card = score(&session).unwrap(); + let report = explain_pnl(&session).unwrap(); + + assert_eq!( + serde_json::to_vec(&card.components).unwrap(), + serde_json::to_vec(&report.components).unwrap(), + "the waterfall crosses the lab→replay edge verbatim" + ); + assert_eq!( + serde_json::to_vec(&card.anchor).unwrap(), + serde_json::to_vec(&report.anchor).unwrap() + ); +} + +#[test] +fn scorecard_metrics_derive_from_the_anchor_and_curve() { + let card = score(&stopped_session()).unwrap(); + assert_eq!(card.metrics.return_pct, Some(dec!(1.87))); + assert_eq!(card.trades.fills, 1); + // Two ticker observations: the mark table's warm-up averages 50k and + // 52k to a 51k mark — 4_987 cash + 0.1 × 51_000. + assert_eq!(card.curve.last_value, Some(dec!(10_087))); +} diff --git a/crates/kraken-lab/tests/snapshots.rs b/crates/kraken-lab/tests/snapshots.rs new file mode 100644 index 0000000..4ba83a2 --- /dev/null +++ b/crates/kraken-lab/tests/snapshots.rs @@ -0,0 +1,154 @@ +//! Snapshot pins for the crate's serialized command contracts. The shapes of +//! `Scorecard`, `Replay`, and `Verdict` are what agents (and the future SDK) +//! parse; a field rename, reorder, or added key is a contract change, so it +//! must land as a reviewable `.snap` diff rather than slip through unseen. +//! +//! Every fixture uses literal timestamps and fixed rates, so the serialized +//! output is deterministic — no redactions needed. Regenerate intentionally +//! with `INSTA_UPDATE=always cargo test -p kraken-lab --test snapshots` (or +//! `cargo insta review`) and commit the updated snapshots. + +// Fixtures build on known-good literals; a failed unwrap here is a test bug, +// so the workspace's `unwrap_used` deny is lifted for this test file (matching +// the sibling `*_pipeline` tests). +#![allow(clippy::unwrap_used)] + +use insta::assert_json_snapshot; +use kraken_lab::{Criteria, Replay, Scorecard, replay, score}; +use kraken_paper::OrderSide; +use kraken_paper::account::Origin; +use kraken_session::manifest::{SessionOutcome, SessionSource}; +use kraken_session::timeline::SessionTimeline; +use kraken_session::timeline::fixtures::{self, SessionBuilder}; +use rust_decimal_macros::dec; + +/// A stopped one-fill session: buy 0.1 BTC at 50k mid (0.26% fee), mark to 52k. +fn stopped_session() -> SessionTimeline { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(52_000), + dec!(52_000), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:04Z".parse().unwrap(), + final_value: dec!(10_187), + pnl: dec!(187), + }); + builder.session(None, manifest) +} + +/// A closed winning round trip (buy then sell), zero rates — exercises the +/// hit-rate and profit-factor metrics a fill-less scorecard leaves absent. +fn round_trip_session() -> SessionTimeline { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(52_000), + dec!(52_000), + ); + builder.market_fill( + "2026-01-01T00:00:04Z", + Origin::Cli, + OrderSide::Sell, + "BTCUSD", + dec!(0.1), + (dec!(52_000), dec!(52_000)), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:05Z".parse().unwrap(), + final_value: dec!(10_200), + pnl: dec!(200), + }); + builder.session(None, manifest) +} + +/// The worked example replayed against a recorded tape — pins the full +/// honesty envelope (replay + reaction-time caveats, replay `source` block). +fn replay_provenance_session() -> SessionTimeline { + let mut session = stopped_session(); + session.manifest.source = Some(SessionSource { + tape: "tape:jun-crash".to_string(), + speed: 10.0, + anchor: "2026-06-01T00:00:00Z".parse().unwrap(), + started_at: "2026-07-01T00:00:00Z".parse().unwrap(), + content_hash: None, + }); + session +} + +fn scorecard() -> Scorecard { + score(&stopped_session()).unwrap() +} + +#[test] +fn scorecard_of_the_worked_example() { + assert_json_snapshot!(scorecard()); +} + +#[test] +fn scorecard_of_a_closed_round_trip() { + assert_json_snapshot!(score(&round_trip_session()).unwrap()); +} + +#[test] +fn scorecard_of_a_replayed_session_carries_the_honesty_envelope() { + assert_json_snapshot!(score(&replay_provenance_session()).unwrap()); +} + +#[test] +fn replay_curve_and_ledger() { + let folded: Replay = replay(&stopped_session()).unwrap(); + assert_json_snapshot!(folded); +} + +#[test] +fn verdict_passes_and_fails_against_the_same_card() { + let card = scorecard(); + let pass = Criteria { + min_return_pct: Some(dec!(1.0)), + max_drawdown_pct: None, + min_fills: Some(1), + } + .evaluate(&card); + let fail = Criteria { + min_return_pct: Some(dec!(100.0)), + max_drawdown_pct: None, + min_fills: Some(5), + } + .evaluate(&card); + assert_json_snapshot!("verdict_pass", pass); + assert_json_snapshot!("verdict_fail", fail); +} diff --git a/crates/kraken-lab/tests/snapshots/snapshots__replay_curve_and_ledger.snap b/crates/kraken-lab/tests/snapshots/snapshots__replay_curve_and_ledger.snap new file mode 100644 index 0000000..85a8ca5 --- /dev/null +++ b/crates/kraken-lab/tests/snapshots/snapshots__replay_curve_and_ledger.snap @@ -0,0 +1,51 @@ +--- +source: crates/kraken-lab/tests/snapshots.rs +expression: folded +--- +{ + "curve": { + "currency": "USD", + "points": [ + { + "at": "2026-01-01T00:00:00Z", + "seq": 1, + "value": 10000.0, + "complete": true + }, + { + "at": "2026-01-01T00:00:02Z", + "seq": 3, + "value": 9987.0, + "complete": true + }, + { + "at": "2026-01-01T00:00:03Z", + "seq": 4, + "value": 10087.0, + "complete": true + } + ], + "unmarked": [] + }, + "fills": [ + { + "id": "PAPER-00002", + "order_id": "PAPER-00001", + "pair": "BTCUSD", + "base": "BTC", + "quote": "USD", + "side": "buy", + "volume": "0.1", + "price": "50000", + "fee": "13.00000", + "cost": "5000.0", + "filled_at": "2026-01-01T00:00:02Z", + "reference_quote": { + "ask": "50000", + "bid": "50000" + } + } + ], + "epochs_skipped": 0, + "caveats": [] +} diff --git a/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_closed_round_trip.snap b/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_closed_round_trip.snap new file mode 100644 index 0000000..28439a6 --- /dev/null +++ b/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_closed_round_trip.snap @@ -0,0 +1,76 @@ +--- +source: crates/kraken-lab/tests/snapshots.rs +expression: score(&round_trip_session()).unwrap() +--- +{ + "group": "lab", + "type": "scorecard", + "session": "btc-dip", + "anchor": { + "starting_balance": 10000.0, + "final_value": 10200.0, + "total_pnl": 200.0, + "ended_at": "2026-01-01T00:00:05Z", + "currency": "USD" + }, + "components": [ + { + "kind": "price_movement", + "amount": 200.0, + "explanation": "your position changes valued at mid-market when filled and marked at the recorded end, before any costs" + }, + { + "kind": "fees", + "amount": 0.0, + "explanation": "paid across 2 fill(s) at the account's 0.00% fee rate" + }, + { + "kind": "spread", + "amount": 0.0, + "explanation": "2 market fill(s) paid the half-spread (0.00); 0 limit fill(s) executed at their resting price vs mid (0.00) — a cost of late fill detection" + }, + { + "kind": "slippage", + "amount": 0.0, + "explanation": "simulated slippage on market fills (rate 0.00%)" + }, + { + "kind": "residual", + "amount": 0.0, + "explanation": "the gap between the live prices 'session stop' marked at and this session's recorded prices (plus anything the caveats folded in); components + residual sum to the total, exactly in the engine and to double precision on this JSON wire" + } + ], + "metrics": { + "return_pct": 2.0, + "max_drawdown": 0.0, + "max_drawdown_pct": 0.0, + "fees": 0.0, + "friction": 0.0, + "turnover_pct": 102.0, + "hit_rate": 1.0, + "slippage_sensitivity": -1.02 + }, + "trades": { + "fills": 2, + "buys": 1, + "sells": 1, + "decided": 0, + "missed_fills": 0 + }, + "curve": { + "currency": "USD", + "points": 4, + "complete": true, + "first_value": 10000.0, + "last_value": 10200.0, + "peak": 10200.0, + "trough": 10000.0, + "unmarked": [] + }, + "epochs_skipped": 0, + "source": { + "kind": "live", + "symbols": [] + }, + "caveats": [] +} diff --git a/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_replayed_session_carries_the_honesty_envelope.snap b/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_replayed_session_carries_the_honesty_envelope.snap new file mode 100644 index 0000000..e494f13 --- /dev/null +++ b/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_a_replayed_session_carries_the_honesty_envelope.snap @@ -0,0 +1,81 @@ +--- +source: crates/kraken-lab/tests/snapshots.rs +expression: score(&replay_provenance_session()).unwrap() +--- +{ + "group": "lab", + "type": "scorecard", + "session": "btc-dip", + "anchor": { + "starting_balance": 10000.0, + "final_value": 10187.0, + "total_pnl": 187.0, + "ended_at": "2026-01-01T00:00:04Z", + "currency": "USD" + }, + "components": [ + { + "kind": "price_movement", + "amount": 200.0, + "explanation": "your position changes valued at mid-market when filled and marked at the recorded end, before any costs" + }, + { + "kind": "fees", + "amount": -13.0, + "explanation": "paid across 1 fill(s) at the account's 0.26% fee rate" + }, + { + "kind": "spread", + "amount": 0.0, + "explanation": "1 market fill(s) paid the half-spread (0.00); 0 limit fill(s) executed at their resting price vs mid (0.00) — a cost of late fill detection" + }, + { + "kind": "slippage", + "amount": 0.0, + "explanation": "simulated slippage on market fills (rate 0.00%)" + }, + { + "kind": "residual", + "amount": 0.0, + "explanation": "the gap between the live prices 'session stop' marked at and this session's recorded prices (plus anything the caveats folded in); components + residual sum to the total, exactly in the engine and to double precision on this JSON wire" + } + ], + "metrics": { + "return_pct": 1.87, + "max_drawdown": 13.0, + "max_drawdown_pct": 0.13, + "fees": -13.0, + "friction": 0.0, + "turnover_pct": 50.0, + "slippage_sensitivity": -0.5 + }, + "trades": { + "fills": 1, + "buys": 1, + "sells": 0, + "decided": 0, + "missed_fills": 0 + }, + "curve": { + "currency": "USD", + "points": 3, + "complete": true, + "first_value": 10000.0, + "last_value": 10087.0, + "peak": 10087.0, + "trough": 9987.0, + "unmarked": [] + }, + "epochs_skipped": 0, + "source": { + "kind": "replay", + "tape": "tape:jun-crash", + "speed": 10.0 + }, + "caveats": [ + "replayed from tape:jun-crash at 10x speed — a recorded-data session, not a live market", + "lookahead not enforced: the agent can read the source tape; replay is screening evidence, never promotion-grade alone", + "fills simulate against top-of-book marks (no depth, queue, or partial fills) and orders never move the tape", + "reaction-time compression: at 10x the agent has 1/10th of live reaction time" + ] +} diff --git a/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_the_worked_example.snap b/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_the_worked_example.snap new file mode 100644 index 0000000..265385b --- /dev/null +++ b/crates/kraken-lab/tests/snapshots/snapshots__scorecard_of_the_worked_example.snap @@ -0,0 +1,75 @@ +--- +source: crates/kraken-lab/tests/snapshots.rs +expression: scorecard() +--- +{ + "group": "lab", + "type": "scorecard", + "session": "btc-dip", + "anchor": { + "starting_balance": 10000.0, + "final_value": 10187.0, + "total_pnl": 187.0, + "ended_at": "2026-01-01T00:00:04Z", + "currency": "USD" + }, + "components": [ + { + "kind": "price_movement", + "amount": 200.0, + "explanation": "your position changes valued at mid-market when filled and marked at the recorded end, before any costs" + }, + { + "kind": "fees", + "amount": -13.0, + "explanation": "paid across 1 fill(s) at the account's 0.26% fee rate" + }, + { + "kind": "spread", + "amount": 0.0, + "explanation": "1 market fill(s) paid the half-spread (0.00); 0 limit fill(s) executed at their resting price vs mid (0.00) — a cost of late fill detection" + }, + { + "kind": "slippage", + "amount": 0.0, + "explanation": "simulated slippage on market fills (rate 0.00%)" + }, + { + "kind": "residual", + "amount": 0.0, + "explanation": "the gap between the live prices 'session stop' marked at and this session's recorded prices (plus anything the caveats folded in); components + residual sum to the total, exactly in the engine and to double precision on this JSON wire" + } + ], + "metrics": { + "return_pct": 1.87, + "max_drawdown": 13.0, + "max_drawdown_pct": 0.13, + "fees": -13.0, + "friction": 0.0, + "turnover_pct": 50.0, + "slippage_sensitivity": -0.5 + }, + "trades": { + "fills": 1, + "buys": 1, + "sells": 0, + "decided": 0, + "missed_fills": 0 + }, + "curve": { + "currency": "USD", + "points": 3, + "complete": true, + "first_value": 10000.0, + "last_value": 10087.0, + "peak": 10087.0, + "trough": 9987.0, + "unmarked": [] + }, + "epochs_skipped": 0, + "source": { + "kind": "live", + "symbols": [] + }, + "caveats": [] +} diff --git a/crates/kraken-lab/tests/snapshots/snapshots__verdict_fail.snap b/crates/kraken-lab/tests/snapshots/snapshots__verdict_fail.snap new file mode 100644 index 0000000..69f9ae5 --- /dev/null +++ b/crates/kraken-lab/tests/snapshots/snapshots__verdict_fail.snap @@ -0,0 +1,21 @@ +--- +source: crates/kraken-lab/tests/snapshots.rs +expression: fail +--- +{ + "pass": false, + "checks": [ + { + "criterion": "min_return_pct", + "threshold": 100.0, + "observed": 1.87, + "pass": false + }, + { + "criterion": "min_fills", + "threshold": 5.0, + "observed": 1.0, + "pass": false + } + ] +} diff --git a/crates/kraken-lab/tests/snapshots/snapshots__verdict_pass.snap b/crates/kraken-lab/tests/snapshots/snapshots__verdict_pass.snap new file mode 100644 index 0000000..cf12850 --- /dev/null +++ b/crates/kraken-lab/tests/snapshots/snapshots__verdict_pass.snap @@ -0,0 +1,21 @@ +--- +source: crates/kraken-lab/tests/snapshots.rs +expression: pass +--- +{ + "pass": true, + "checks": [ + { + "criterion": "min_return_pct", + "threshold": 1.0, + "observed": 1.87, + "pass": true + }, + { + "criterion": "min_fills", + "threshold": 1.0, + "observed": 1.0, + "pass": true + } + ] +} diff --git a/crates/kraken-paper/Cargo.toml b/crates/kraken-paper/Cargo.toml new file mode 100644 index 0000000..75c5564 --- /dev/null +++ b/crates/kraken-paper/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "kraken-paper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Event-sourced spot paper-trading engine for kraken-cli: exact-Decimal money, journal-backed account" +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +chrono = { workspace = true } +kraken-core = { workspace = true } +kraken-recording = { workspace = true } +rust_decimal = { workspace = true } +# dec! money constants (DEFAULT_FEE_RATE, MAX_MONEY) — a real dep, not dev. +rust_decimal_macros = { workspace = true } +serde = { workspace = true } +serde_with = { workspace = true, features = ["std"] } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/kraken-paper/README.md b/crates/kraken-paper/README.md new file mode 100644 index 0000000..f5c2136 --- /dev/null +++ b/crates/kraken-paper/README.md @@ -0,0 +1,54 @@ +# kraken-paper + +The event-sourced spot paper-trading engine. + +## Purpose + +Owns the money rules of spot paper trading: order validation and the fill +state machine (`PaperState` and its `decide_*`/`apply` pair), exact-`Decimal` +valuation, and the journal-backed account store (`PaperAccount` over +`events.jsonl`). It defends one boundary: everything above it — CLI flags, +config directories, sessions, live prices — stays out. + +## Architecture + +Ports & Adapters. Prices are **arguments** (`decide_market_order(side, pair, +volume, ask, bid)`), never a market-data dependency; persistence is composed +from kraken-recording's `Sink`/`Source` pair; where a journal lives on disk is +the caller's policy (`PaperAccount::open_at` takes the path). State is a fold: +`decide_*` validates and computes without mutating, `apply` folds recorded +events back into state, so a replayed journal always reproduces the live +account exactly. + +- `lib.rs` — engine: money guards, pair parsing, order/trade vocabulary, + `PaperState` +- `account.rs` — the journal-backed store: locked open, durable-first appends, + the replay `Projection` + +## Interface + +What crosses upward: `PaperState` (`apply`, `valuation`, `decide_*`), +`PaperConfig`, `PaperTrade`, `PaperOrder`, `OrderSide`, `AccountEvent`, +`account::{PaperAccount, AccountRecord, Origin, RECORD_VERSION}`, +`ReferenceQuote`, `parse_pair`, `canonical_key`, `valid_quote`, the money +constants (`DEFAULT_FEE_RATE`, `DEFAULT_SLIPPAGE_RATE`, `BALANCE_DUST`, +`MAX_MONEY`), and `PaperError`. + +What never enters: clap, config-dir resolution, session naming, network I/O. + +## Errors + +`PaperError` — `Rejected` (refused up front; → envelope `validation`), +`Incompatible` (journal from a newer build; → `config`), `Journal` (storage +failure, transparent over `kraken_recording::Error`; → its categories). +The binary bridges these to the JSON envelope exactly once, in `src/errors.rs`. + +## Testing + +Exact-Decimal golden tests for every fee/fill/valuation rule, journal +round-trips on `tempfile`, and `tests/account_lifecycle.rs` driving the full +open → trade → cancel → reload flow through the public API only. + +```text +cargo test -p kraken-paper +``` diff --git a/crates/kraken-paper/src/account.rs b/crates/kraken-paper/src/account.rs new file mode 100644 index 0000000..e7866fb --- /dev/null +++ b/crates/kraken-paper/src/account.rs @@ -0,0 +1,1051 @@ +//! Event-sourced paper-account storage. +//! +//! The locked journal is authoritative. Projection changes follow durable +//! appends, keeping each read-decide-write command atomic across processes. + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use kraken_recording::JsonlSink; +use kraken_recording::Sink; +use kraken_recording::Source; +use kraken_recording::paper::{journal, replay}; +use serde::{Deserialize, Serialize}; + +use crate::{AccountEvent, CommandEntry, PaperError, PaperState, Result}; + +/// Version stamped on every record this build writes. A higher version aborts +/// the replay — skipping a newer build's state event would corrupt balances. +pub const RECORD_VERSION: u32 = 1; + +/// The storage layer owns the journal contract; re-exported for the +/// playground's lock-holder diagnostics. +pub use kraken_recording::paper::JOURNAL_PURPOSE; + +fn record_version_default() -> u32 { + RECORD_VERSION +} + +/// Where a command entered the system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Origin { + Cli, + Mcp, +} + +impl Origin { + pub fn from_mcp_mode(mcp_mode: bool) -> Self { + if mcp_mode { Self::Mcp } else { Self::Cli } + } +} + +/// One `events.jsonl` line: a versioned, stamped envelope around an event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + #[serde(default = "record_version_default")] + pub v: u32, + /// Append instant — the authoritative time of `Reset` epochs and + /// cancellations (submissions and fills carry their own stamps). + pub ts: DateTime, + pub origin: Origin, + #[serde(flatten)] + pub event: AccountEvent, +} + +/// A locked journal and its live projection. +/// +/// Initialization is journal state, not file existence. +pub struct PaperAccount { + origin: Origin, + sink: JsonlSink, + /// The live state: replayed once at open, advanced by every append. + projection: Projection, +} + +impl PaperAccount { + /// Locks before replay so corruption and version errors fail at open. + pub fn open_at(log_path: PathBuf, origin: Origin) -> Result { + let sink = journal(log_path.clone())?; + let mut projection = Projection::default(); + if let Some(source) = replay(log_path) { + for record in source.read()? { + projection.apply(&record)?; + } + } + Ok(Self { + origin, + sink, + projection, + }) + } + + /// Whether an `Initialized`/`Reset` epoch is on the record — an existing + /// but epoch-less journal (a crashed first init) reads as uninitialized. + pub fn is_initialized(&self) -> bool { + self.projection.state().is_some() + } + + /// The live state, or "not initialized" when no epoch is on the record. + /// Callers clone the borrow to draft decisions; the projection itself + /// advances only through appends. + pub fn state(&self) -> Result<&PaperState> { + self.projection.state().ok_or_else(|| { + PaperError::Rejected( + "Paper account not initialized. Create a workspace: 'kraken workspace create \ + --capital '." + .into(), + ) + }) + } + + /// Wrap events in records stamped with one append instant — one shared + /// instant per batch, so within-batch order is file position on replay. + #[must_use] + pub fn stamp(&self, events: Vec) -> Vec { + self.stamp_at(events, Utc::now()) + } + + /// [`stamp`](Self::stamp) with a caller-chosen instant. For fills folded + /// lazily from a recorded tape: the record must carry the fill's + /// session-time instant, not the fold's wall clock, or the timeline + /// would order every folded fill at command time. + #[must_use] + pub fn stamp_at(&self, events: Vec, ts: DateTime) -> Vec { + events + .into_iter() + .map(|event| AccountRecord { + v: RECORD_VERSION, + ts, + origin: self.origin, + event, + }) + .collect() + } + + /// Commits events and their post-apply audit as one durable batch. + pub fn commit(&mut self, events: Vec, mut entry: CommandEntry) -> Result<()> { + let mut records = self.stamp(events); + let mut next = self.projection.clone(); + for record in &records { + next.apply(record)?; + } + entry.balances = next.state().map(|state| state.balances.clone()); + let audit = self.stamp(vec![AccountEvent::Command(entry)]); + for record in &audit { + next.apply(record)?; + } + records.extend(audit); + self.sink.record(&records)?; + self.projection = next; + Ok(()) + } + + /// Append one fsync'd batch, then advance the live projection — an event + /// enters state only once it is on the record. The epoch gate is the + /// command layer's check, race-free because this handle holds the + /// journal's only lock. + pub fn append(&mut self, records: &[AccountRecord]) -> Result<()> { + self.sink.record(records)?; + for record in records { + self.projection.apply(record)?; + } + Ok(()) + } + + /// [`Self::append`] gated on no epoch being on the record — the account + /// owns the "one first init" invariant (provision's entry point). + pub fn append_if_uninitialized(&mut self, records: &[AccountRecord]) -> Result<()> { + self.ensure_uninitialized()?; + self.append(records) + } + + /// [`Self::commit`] under the same gate — `paper init`'s entry point. + pub fn commit_if_uninitialized( + &mut self, + events: Vec, + entry: CommandEntry, + ) -> Result<()> { + self.ensure_uninitialized()?; + self.commit(events, entry) + } + + fn ensure_uninitialized(&self) -> Result<()> { + if self.is_initialized() { + return Err(PaperError::Rejected( + "Paper account already initialized. Use 'kraken paper reset' to start over.".into(), + )); + } + Ok(()) + } + + /// Delete the journal at `path` under its writer lock; already absent + /// counts as success. Never replays: removal is `--force` recovery's + /// escape hatch, so it must work on exactly the journals an open refuses. + pub fn remove_at(path: PathBuf) -> Result<()> { + // `fs::exists`, not `Path::exists`: a stat failure must surface, not + // read as "absent". + if !std::fs::exists(&path)? { + return Ok(()); + } + Ok(journal::(path)?.remove()?) + } + + /// Append for observational entries: a failure is warned, never surfaced + /// — audit must not shadow the command's own result. + pub fn audit(&mut self, records: &[AccountRecord]) { + if let Err(err) = self.sink.record(records) { + tracing::warn!(error = %err, "failed to append account audit entry"); + return; + } + for record in records { + // Our own records carry a supported version, so apply can only + // refuse foreign input — warn, never surface. + if let Err(err) = self.projection.apply(record) { + tracing::warn!(error = %err, "audit record not applied to the live projection"); + } + } + } +} + +/// The journal's live read model: state replayed one record at a time. +/// +/// The open-time replay and every later append advance the same +/// [`Projection::apply`], so the two cannot drift. +#[derive(Debug, Default, Clone)] +struct Projection { + state: PaperState, + /// Set by an `Initialized`/`Reset` epoch — replaying nothing must not + /// conjure the default account. + initialized: bool, +} + +impl Projection { + /// Advance by one record: an unknown event kind is warned and skipped; a + /// newer-versioned record is a hard error, since skipping a state event + /// would corrupt balances. + fn apply(&mut self, record: &AccountRecord) -> Result<()> { + if record.v > RECORD_VERSION { + return Err(PaperError::Incompatible(format!( + "account log record version {} is newer than this build supports ({RECORD_VERSION}); upgrade kraken", + record.v + ))); + } + match &record.event { + // An attached journal is a usable account: the venue snapshot is + // its epoch. A mid-window `Reconciled` deliberately does NOT + // initialize — reconciliation refines an account, never opens one. + AccountEvent::Initialized(_) | AccountEvent::Reset(_) | AccountEvent::Attached(_) => { + self.initialized = true + } + AccountEvent::Unknown => { + tracing::warn!(ts = %record.ts, "skipping unknown account event kind (newer build?)"); + } + _ => {} + } + self.state.apply(record.ts, &record.event); + Ok(()) + } + + /// The replayed state, or `None` when no epoch is on the record. + fn state(&self) -> Option<&PaperState> { + self.initialized.then_some(&self.state) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use kraken_core::OrderSide; + use kraken_recording::JsonlSource; + use rust_decimal::Decimal; + use rust_decimal_macros::dec; + + use super::*; + use crate::{CommandEntry, PaperConfig}; + + fn config(balance: Decimal) -> PaperConfig { + PaperConfig { + balance, + currency: "USD".into(), + fee_rate: dec!(0.0), + slippage_rate: dec!(0.0), + } + } + + fn open(dir: &tempfile::TempDir) -> PaperAccount { + PaperAccount::open_at(dir.path().join("events.jsonl"), Origin::Cli).unwrap() + } + + #[test] + fn busy_account_rejects_a_second_open_with_in_use() { + // While a command's handle holds the account, a concurrent open is + // rejected outright — fail-fast at the account layer, not just the lock. + let dir = tempfile::tempdir().unwrap(); + let _held = open(&dir); // a command mid-flight, lock spanning its lifetime + + // `PaperAccount` is deliberately non-Debug, so match rather than unwrap_err. + let err = match PaperAccount::open_at(dir.path().join("events.jsonl"), Origin::Cli) { + Ok(_) => panic!("busy account must be rejected"), + Err(e) => e, + }; + // The lock refusal is the store's, surfaced transparently — pinned as + // `Journal(Rejected)` so the binary's bridge lands it on `validation`. + assert!(matches!( + err, + PaperError::Journal(kraken_recording::Error::Rejected(_)) + )); + assert!(err.to_string().contains("in use"), "got: {err}"); + } + + /// A fresh replay must reproduce the working state exactly, timestamps + /// included — the core event-sourcing invariant. + #[test] + fn replay_reproduces_committed_state_exactly() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + let mut state = PaperState::default(); + + let init = account.stamp(vec![AccountEvent::Initialized(config(dec!(10_000.0)))]); + for r in &init { + state.apply(r.ts, &r.event); + } + account.append(&init).unwrap(); + // Re-open as every real command does; drop first to release the lock. + drop(account); + let mut account = open(&dir); + + let order = state + .decide_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45_000.0)) + .unwrap(); + let submit = account.stamp(vec![AccountEvent::OrderSubmitted { order }]); + for r in &submit { + state.apply(r.ts, &r.event); + } + account.append(&submit).unwrap(); + + let trade = state + .decide_market_order( + OrderSide::Buy, + "ETHUSD", + dec!(1.0), + dec!(2_000.0), + dec!(1_999.0), + ) + .unwrap(); + let fill = account.stamp(vec![AccountEvent::OrderFilled { trade }]); + for r in &fill { + state.apply(r.ts, &r.event); + } + account.append(&fill).unwrap(); + + let cancel = state.decide_cancel("PAPER-00001").unwrap(); + let cancelled = account.stamp(vec![AccountEvent::OrderCancelled { order: cancel }]); + for r in &cancelled { + state.apply(r.ts, &r.event); + } + account.append(&cancelled).unwrap(); + + // Both the live projection and a fresh open's replay must equal the + // state the commands built. + let live = account.state().unwrap().clone(); + drop(account); // release the lock so the fresh open below succeeds + let reopened = open(&dir); + let fresh = reopened.state().unwrap().clone(); + for replayed in [live, fresh] { + assert_eq!( + serde_json::to_value(&replayed).unwrap(), + serde_json::to_value(&state).unwrap(), + "replay(journal) must equal the state the commands built" + ); + assert_eq!(replayed.filled_trades.len(), 1); + assert_eq!(replayed.cancelled_orders.len(), 1); + assert!(replayed.open_orders.is_empty()); + } + } + + /// The exactness invariant beyond the f64 wire: a fill whose money + /// carries more significant digits than a JSON double survives the + /// journal round-trip bit-for-bit. Regression for the `serde-float` + /// journal (cost 6179.0266686284136 used to reload as …628414). + #[test] + fn replay_stays_exact_beyond_f64_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + account + .append(&account.stamp(vec![AccountEvent::Initialized(PaperConfig { + balance: dec!(10_000.0), + currency: "USD".into(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.001), + })])) + .unwrap(); + drop(account); + let mut account = open(&dir); + + // 0.12345678 × 50_000.12 × 1.001 = 6179.0266686284136 — 17 + // significant digits, not representable as an f64. + let trade = account + .state() + .unwrap() + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.12345678), + dec!(50_000.12), + dec!(49_999.88), + ) + .unwrap(); + let expected_cost = trade.cost; + assert_eq!(expected_cost, dec!(6179.0266686284136)); + account + .append(&account.stamp(vec![AccountEvent::OrderFilled { trade }])) + .unwrap(); + + let live = account.state().unwrap().clone(); + drop(account); + let fresh = open(&dir).state().unwrap().clone(); + for state in [&live, &fresh] { + assert_eq!(state.filled_trades[0].cost, expected_cost); + } + assert_eq!( + serde_json::to_value(&fresh).unwrap(), + serde_json::to_value(&live).unwrap(), + "a fresh replay must equal the live projection, digits included" + ); + } + + #[test] + fn observational_events_do_not_change_the_replay() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + account + .append(&account.stamp(vec![AccountEvent::Initialized(config(dec!(500.0)))])) + .unwrap(); + drop(account); + let mut account = open(&dir); + let before = serde_json::to_value(account.state().unwrap()).unwrap(); + + account.audit(&account.stamp(vec![AccountEvent::Command(CommandEntry { + name: "status".into(), + ..CommandEntry::default() + })])); + let after = serde_json::to_value(account.state().unwrap()).unwrap(); + assert_eq!(before, after); + } + + #[test] + fn reset_starts_a_new_epoch_and_history_survives_in_the_log() { + let dir = tempfile::tempdir().unwrap(); + let mut state = PaperState::default(); + + // A fresh handle per commit, as every real command runs. + let commit = |state: &mut PaperState, event: AccountEvent| { + let mut account = open(&dir); + let records = account.stamp(vec![event]); + for r in &records { + state.apply(r.ts, &r.event); + } + account.append(&records).unwrap(); + }; + + commit( + &mut state, + AccountEvent::Initialized(config(dec!(10_000.0))), + ); + let trade = state + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50_000.0), + dec!(49_900.0), + ) + .unwrap(); + commit(&mut state, AccountEvent::OrderFilled { trade }); + commit(&mut state, AccountEvent::Reset(config(dec!(10_000.0)))); + + // Post-reset the replayed state is fresh — and order ids restart. + let account = open(&dir); + let replayed = account.state().unwrap(); + assert!(replayed.filled_trades.is_empty()); + let next = replayed + .decide_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.01), dec!(40_000.0)) + .unwrap(); + assert_eq!(next.id, "PAPER-00001", "reset restarts the id epoch"); + + // But the pre-reset fill is still on the record for replay. + let entries: Vec = JsonlSource::open(dir.path().join("events.jsonl")) + .unwrap() + .read() + .unwrap() + .collect(); + let fills = entries + .iter() + .filter(|r| matches!(r.event, AccountEvent::OrderFilled { .. })) + .count(); + assert_eq!(fills, 1, "history must survive reset in the log"); + } + + #[test] + fn empty_log_is_not_an_initialized_account() { + // A crash before init's first durable line leaves an empty log — that + // must read as uninitialized, never as a conjured default account. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + std::fs::write(&path, "").unwrap(); + let account = PaperAccount::open_at(path, Origin::Cli).unwrap(); + assert!(!account.is_initialized()); + let err = account.state().unwrap_err(); + assert!(err.to_string().contains("not initialized"), "got: {err}"); + } + + #[test] + fn crashed_first_init_recovers_on_reinit() { + // Torn-only fragment (crash mid first append): reads as uninitialized, + // and a re-run init appends a clean epoch over the repaired log. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + std::fs::write(&path, "{\"v\":1,\"ts\":\"2026").unwrap(); + let mut account = PaperAccount::open_at(path, Origin::Cli).unwrap(); + assert!(!account.is_initialized()); + + account + .append(&account.stamp(vec![AccountEvent::Initialized(config(dec!(5_000.0)))])) + .unwrap(); + let state = account.state().unwrap(); + assert_eq!(state.balances.get("USD"), Some(&dec!(5_000.0))); + } + + #[test] + fn concurrent_init_is_rejected_at_open_not_interleaved() { + // Two processes race `paper init`: the loser is rejected "in use" at + // open — no unlocked window in which to stack a second epoch. + let dir = tempfile::tempdir().unwrap(); + let mut winner = open(&dir); + let err = match PaperAccount::open_at(dir.path().join("events.jsonl"), Origin::Cli) { + Ok(_) => panic!("second open must be rejected while the first is held"), + Err(e) => e, + }; + assert!(err.to_string().contains("in use"), "got: {err}"); + + winner + .append(&winner.stamp(vec![AccountEvent::Initialized(config(dec!(5_000.0)))])) + .unwrap(); + drop(winner); + let survivor = open(&dir); + let state = survivor.state().unwrap(); + assert_eq!(state.balances.get("USD"), Some(&dec!(5_000.0))); + } + + #[test] + fn audit_only_log_stays_uninitialized() { + // Observational records appended to an epoch-less log (the failure + // audit path) must not flip the account to initialized. + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + account.audit(&account.stamp(vec![AccountEvent::Command(CommandEntry { + name: "status".into(), + ..CommandEntry::default() + })])); + assert!(!account.is_initialized()); + } + + #[test] + fn append_repairs_a_torn_tail_instead_of_gluing() { + // An append must not glue onto a crash fragment — that would turn a + // recoverable torn tail into a malformed interior line. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + let mut account = PaperAccount::open_at(path.clone(), Origin::Cli).unwrap(); + account + .append(&account.stamp(vec![AccountEvent::Initialized(config(dec!(500.0)))])) + .unwrap(); + drop(account); + let mut account = PaperAccount::open_at(path.clone(), Origin::Cli).unwrap(); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + file.write_all(b"{\"v\":1,\"ts\":\"2026").unwrap(); + drop(file); + + account.audit(&account.stamp(vec![AccountEvent::Command(CommandEntry { + name: "status".into(), + ..CommandEntry::default() + })])); + // Every line parses: the fragment was truncated, not glued onto. + let entries: Vec = JsonlSource::open(path.clone()) + .unwrap() + .read() + .unwrap() + .collect(); + assert_eq!(entries.len(), 2); + assert!(account.state().is_ok()); + } + + #[test] + fn torn_tail_does_not_corrupt_the_replay() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + account + .append(&account.stamp(vec![AccountEvent::Initialized(config(dec!(500.0)))])) + .unwrap(); + let path = dir.path().join("events.jsonl"); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + file.write_all(b"{\"v\":1,\"ts\":\"2026").unwrap(); + drop(file); + + // The fragment is never parsed: the replay sees only clean lines. + drop(account); + let account = open(&dir); + let state = account.state().unwrap(); + assert_eq!(state.balances.get("USD"), Some(&dec!(500.0))); + let entries: Vec = JsonlSource::open(path.clone()) + .unwrap() + .read() + .unwrap() + .collect(); + assert_eq!(entries.len(), 1); + } + + #[test] + fn unknown_event_kind_is_tolerated_by_the_replay() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + std::fs::write( + &path, + concat!( + r#"{"v":1,"ts":"2026-01-01T00:00:00Z","origin":"cli","event":"initialized","balance":"500.0","currency":"USD","fee_rate":"0.0","slippage_rate":"0.0"}"#, + "\n", + r#"{"v":1,"ts":"2026-01-01T00:00:01Z","origin":"mcp","event":"margin_call_simulated","severity":"high"}"#, + "\n" + ), + ) + .unwrap(); + let account = PaperAccount::open_at(path, Origin::Cli).unwrap(); + let state = account.state().unwrap(); + assert_eq!(state.balances.get("USD"), Some(&dec!(500.0))); + } + + #[test] + fn newer_record_version_is_rejected_at_open() { + // A newer build's journal refuses the whole handle at open — no + // command can half-use it. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + std::fs::write( + &path, + concat!( + r#"{"v":2,"ts":"2026-01-01T00:00:00Z","origin":"cli","event":"initialized","balance":"500.0","currency":"USD","fee_rate":"0.0","slippage_rate":"0.0"}"#, + "\n" + ), + ) + .unwrap(); + let err = match PaperAccount::open_at(path, Origin::Cli) { + Ok(_) => panic!("newer journal must be rejected at open"), + Err(e) => e, + }; + assert!(matches!(err, PaperError::Incompatible(_)), "got: {err:?}"); + } + + #[test] + fn remove_at_deletes_a_journal_this_build_cannot_replay() { + // Removal is `--force` recovery's escape hatch, so it must work on + // exactly the journals `open_at` refuses — no replay before unlink. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + std::fs::write(&path, "not json\n").unwrap(); + + PaperAccount::remove_at(path.clone()).unwrap(); + assert!(!path.exists()); + } + + #[test] + fn remove_at_is_rejected_while_the_journal_is_in_use() { + // The unlink is serialized under the writer lock — a `--force` reset + // must not delete the file under an in-flight command's handle. + let dir = tempfile::tempdir().unwrap(); + let _held = open(&dir); // a command mid-flight, lock spanning its lifetime + + let err = PaperAccount::remove_at(dir.path().join("events.jsonl")).unwrap_err(); + assert!(err.to_string().contains("in use"), "got: {err}"); + assert!( + dir.path().join("events.jsonl").exists(), + "a held journal survives" + ); + } + + #[test] + fn remove_at_of_a_missing_journal_is_success_and_leaves_no_trace() { + // Tolerating absence must not conjure what it removes: no journal, + // no lock sidecar, not even the parent directory. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("sub").join("events.jsonl"); + + PaperAccount::remove_at(path.clone()).unwrap(); + assert!(!path.parent().unwrap().exists(), "no directory created"); + } + + #[test] + fn init_on_an_initialized_account_is_rejected() { + // A gated append on an initialized journal refuses instead of stacking + // a second epoch; racing handles are already rejected at open. + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + account + .append_if_uninitialized( + &account.stamp(vec![AccountEvent::Initialized(config(dec!(1_000.0)))]), + ) + .unwrap(); + let err = account + .append_if_uninitialized( + &account.stamp(vec![AccountEvent::Initialized(config(dec!(2_000.0)))]), + ) + .unwrap_err(); + assert!( + err.to_string().contains("already initialized"), + "got: {err}" + ); + assert_eq!( + account.state().unwrap().balances.get("USD"), + Some(&dec!(1_000.0)) + ); + } + + #[test] + fn commit_stamps_audit_balances_and_advances_the_projection() { + // Commit appends the events plus a Command audit entry carrying + // post-apply balances; the live projection reflects the batch at once. + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + account + .commit( + vec![AccountEvent::Initialized(config(dec!(1_000.0)))], + CommandEntry { + name: "init".into(), + ..CommandEntry::default() + }, + ) + .unwrap(); + + assert_eq!( + account.state().unwrap().balances.get("USD"), + Some(&dec!(1_000.0)) + ); + let records: Vec = JsonlSource::open(dir.path().join("events.jsonl")) + .unwrap() + .read() + .unwrap() + .collect(); + assert_eq!(records.len(), 2, "events + one audit entry"); + match &records[1].event { + AccountEvent::Command(entry) => { + let balances = entry.balances.as_ref().expect("audit carries balances"); + assert_eq!(balances.get("USD"), Some(&dec!(1_000.0))); + } + other => panic!("last record must be the audit entry, got {other:?}"), + } + } + + #[test] + fn records_carry_no_secret_shaped_fields() { + let dir = tempfile::tempdir().unwrap(); + let account = open(&dir); + let records = account.stamp(vec![ + AccountEvent::Initialized(config(dec!(10_000.0))), + AccountEvent::Command(CommandEntry { + name: "buy".into(), + pair: Some("BTCUSD".into()), + ..CommandEntry::default() + }), + ]); + for record in &records { + let json = serde_json::to_string(record).unwrap(); + for needle in ["api_key", "api_secret", "password", "otp", "token"] { + assert!(!json.contains(needle), "{needle} in {json}"); + } + } + } + + #[test] + fn attached_snapshot_initializes_and_sets_balances() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + assert!(!account.is_initialized(), "no epoch yet"); + + let snapshot = crate::VenueSnapshot { + balances: [("USD".to_string(), dec!(123.45))].into_iter().collect(), + complete: true, + anchor: None, + }; + let records = account.stamp(vec![AccountEvent::Attached(snapshot)]); + account.append(&records).unwrap(); + + let state = account.state().expect("an attached journal is usable"); + assert_eq!(state.balances["USD"], dec!(123.45)); + } + + /// The window-anchor contract: a snapshot carrying `equity` + /// re-anchors P&L at that value; a plain mirror snapshot leaves the + /// anchor untouched. + #[test] + fn attached_equity_re_anchors_starting_balance_and_plain_attach_does_not() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + let records = account.stamp(vec![AccountEvent::Initialized(config(dec!(10_000.0)))]); + account.append(&records).unwrap(); + + let plain = crate::VenueSnapshot { + balances: [("USD".to_string(), dec!(9_000.0))].into_iter().collect(), + complete: true, + anchor: None, + }; + let records = account.stamp(vec![AccountEvent::Attached(plain)]); + account.append(&records).unwrap(); + assert_eq!( + account.state().unwrap().starting_balance, + dec!(10_000.0), + "a mirror attach observes balances, never the anchor" + ); + + let anchored = crate::VenueSnapshot { + balances: [("USD".to_string(), dec!(9_000.0))].into_iter().collect(), + complete: true, + anchor: Some(crate::WindowAnchor { + equity: dec!(9_123.45), + currency: "USD".to_string(), + fee_rate: dec!(0.0), + slippage_rate: dec!(0.0), + }), + }; + let records = account.stamp(vec![AccountEvent::Attached(anchored)]); + account.append(&records).unwrap(); + let state = account.state().unwrap(); + assert_eq!(state.starting_balance, dec!(9_123.45)); + assert_eq!(state.starting_currency, "USD"); + } + + /// A window-opening snapshot restores the account's rates, so a scoring + /// window folded from it reports them rather than the type defaults — the + /// `Initialized`/`Reset` that set them lies before the window. A plain + /// mirror attach carries no rates and must leave them untouched. + #[test] + fn attached_rates_restore_into_the_folded_state() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + let mut init = config(dec!(10_000.0)); + init.fee_rate = dec!(0.005); + init.slippage_rate = dec!(0.003); + account + .append(&account.stamp(vec![AccountEvent::Initialized(init)])) + .unwrap(); + + // A plain mirror attach (no rates) leaves the account's rates intact. + let plain = crate::VenueSnapshot { + balances: [("USD".to_string(), dec!(10_000.0))].into_iter().collect(), + complete: true, + anchor: None, + }; + account + .append(&account.stamp(vec![AccountEvent::Attached(plain)])) + .unwrap(); + let state = account.state().unwrap(); + assert_eq!( + state.fee_rate, + dec!(0.005), + "plain attach must not clear rates" + ); + assert_eq!(state.slippage_rate, dec!(0.003)); + + // A window opener carrying rates re-anchors them (a mid-window reset + // could have changed them; the opener re-establishes the window's). + let opener = crate::VenueSnapshot { + balances: [("USD".to_string(), dec!(10_000.0))].into_iter().collect(), + complete: true, + anchor: Some(crate::WindowAnchor { + equity: dec!(10_000.0), + currency: "USD".to_string(), + fee_rate: dec!(0.001), + slippage_rate: dec!(0.002), + }), + }; + account + .append(&account.stamp(vec![AccountEvent::Attached(opener)])) + .unwrap(); + let state = account.state().unwrap(); + assert_eq!(state.fee_rate, dec!(0.001)); + assert_eq!(state.slippage_rate, dec!(0.002)); + } + + #[test] + fn reconciled_overwrites_balances_without_clearing_history() { + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + let records = account.stamp(vec![AccountEvent::Initialized(config(dec!(10_000.0)))]); + account.append(&records).unwrap(); + let trade = account + .state() + .unwrap() + .clone() + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50_000.0), + dec!(49_900.0), + ) + .unwrap(); + account + .commit( + vec![AccountEvent::OrderFilled { trade }], + CommandEntry::default(), + ) + .unwrap(); + + // The venue is the truth: balances become the snapshot; the fill + // history stays — it is what the snapshot reconciles against. + let snapshot = crate::VenueSnapshot { + balances: [ + ("USD".to_string(), dec!(4_999.0)), + ("BTC".to_string(), dec!(0.1)), + ] + .into_iter() + .collect(), + complete: true, + anchor: None, + }; + let records = account.stamp(vec![AccountEvent::Reconciled(snapshot)]); + account.append(&records).unwrap(); + + let state = account.state().unwrap(); + assert_eq!(state.balances["USD"], dec!(4_999.0)); + assert_eq!(state.filled_trades.len(), 1, "history survives"); + } + + #[test] + fn reconciled_alone_never_initializes_an_account() { + // Reconciliation refines an account; only Attached (or an epoch) + // opens one. + let dir = tempfile::tempdir().unwrap(); + let mut account = open(&dir); + let snapshot = crate::VenueSnapshot { + balances: std::collections::BTreeMap::new(), + complete: false, + anchor: None, + }; + let records = account.stamp(vec![AccountEvent::Reconciled(snapshot)]); + account.append(&records).unwrap(); + assert!(!account.is_initialized()); + } + + #[test] + fn venue_snapshot_round_trips_digit_exact() { + let snapshot = crate::VenueSnapshot { + balances: [("USD".to_string(), dec!(9999.983091586))] + .into_iter() + .collect(), + complete: false, + // An anchor carrying digit-exact rates: the nested money must ride + // the wire as strings too, or a re-recorded rate loses precision. + anchor: Some(crate::WindowAnchor { + equity: dec!(9999.983091586), + currency: "USD".to_string(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.001), + }), + }; + let record = AccountRecord { + v: RECORD_VERSION, + ts: "2026-01-01T00:00:00Z".parse().unwrap(), + origin: Origin::Cli, + event: AccountEvent::Attached(snapshot.clone()), + }; + let json = serde_json::to_string(&record).unwrap(); + assert!( + json.contains("\"9999.983091586\""), + "money is a string on the wire: {json}" + ); + assert!( + json.contains("\"0.0026\"") && json.contains("\"0.001\""), + "anchor rates are strings on the wire: {json}" + ); + let back: AccountRecord = serde_json::from_str(&json).unwrap(); + let AccountEvent::Attached(decoded) = back.event else { + panic!("wrong event kind"); + }; + assert_eq!(decoded, snapshot); + } + + #[test] + fn record_round_trips_through_serde() { + let dir = tempfile::tempdir().unwrap(); + let account = open(&dir); + let mut state = PaperState::default(); + state.apply( + "2026-01-01T00:00:00Z".parse().unwrap(), + &AccountEvent::Initialized(config(dec!(10_000.0))), + ); + let trade = state + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50_000.0), + dec!(49_900.0), + ) + .unwrap(); + for record in account.stamp(vec![ + AccountEvent::OrderFilled { trade }, + AccountEvent::OrderRejected(crate::OrderRejection { + side: OrderSide::Sell, + pair: "BTCUSD".into(), + volume: dec!(5.0), + price: None, + category: "validation".into(), + }), + ]) { + let line = serde_json::to_string(&record).unwrap(); + assert!(!line.contains('\n')); + let back: AccountRecord = serde_json::from_str(&line).unwrap(); + assert_eq!( + serde_json::to_value(&back).unwrap(), + serde_json::to_value(&record).unwrap() + ); + } + } + + /// A session marker's `session` stamp must survive the wire, and an + /// unstamped entry must not serialize the key at all (old readers see no change). + #[test] + fn session_marker_command_entry_round_trips_and_stays_additive() { + let marker = CommandEntry { + name: "session start".into(), + session: Some("s3".into()), + ..CommandEntry::default() + }; + let json = serde_json::to_string(&AccountEvent::Command(marker)).unwrap(); + assert!( + json.contains("\"session\":\"s3\""), + "marker carries its session: {json}" + ); + let AccountEvent::Command(back) = serde_json::from_str(&json).unwrap() else { + panic!("wrong event kind"); + }; + assert_eq!(back.session.as_deref(), Some("s3")); + + let plain = serde_json::to_string(&CommandEntry { + name: "buy".into(), + ..CommandEntry::default() + }) + .unwrap(); + assert!( + !plain.contains("\"session\""), + "unstamped entries must not grow a key: {plain}" + ); + } +} diff --git a/crates/kraken-paper/src/lib.rs b/crates/kraken-paper/src/lib.rs new file mode 100644 index 0000000..c23e8aa --- /dev/null +++ b/crates/kraken-paper/src/lib.rs @@ -0,0 +1,1910 @@ +#![doc = include_str!("../README.md")] + +pub mod account; + +use std::collections::{BTreeSet, HashMap}; + +use chrono::{DateTime, Utc}; +pub use kraken_core::OrderSide; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +pub type Result = std::result::Result; + +/// The engine's failure modes. The binary maps each variant onto the JSON +/// envelope exactly once, in its `errors` module. +#[derive(Debug, thiserror::Error)] +pub enum PaperError { + /// An order, config, or account state the engine refuses up front. + /// Message text is a pinned agent-retry contract — user-facing sentences + /// verbatim, never re-cased. → envelope `validation`. + #[error("{0}")] + Rejected(String), + /// A journal written by a newer build (record v > `RECORD_VERSION`). + /// → envelope `config`. + #[error("{0}")] + Incompatible(String), + /// The journal store failed; the envelope mapping delegates to the + /// recording bridge (Rejected→validation, Damaged→parse, Io/Engine→io). + #[error(transparent)] + Journal(#[from] kraken_recording::Error), +} + +/// `remove_at`'s `std::fs::exists` is the one raw-io site; route it through +/// [`PaperError::Journal`] so `?` keeps working and the category stays `io`. +impl From for PaperError { + fn from(e: std::io::Error) -> Self { + Self::Journal(e.into()) + } +} + +pub const DEFAULT_FEE_RATE: Decimal = dec!(0.0026); + +pub const DEFAULT_SLIPPAGE_RATE: Decimal = Decimal::ZERO; + +/// Paper order/trade ids are `PAPER-{seq:05}`; the fold re-derives the id +/// counter by parsing this prefix back off folded ids. +const ORDER_ID_PREFIX: &str = "PAPER-"; + +/// Ignores floating-point residue from journals written by older builds. +pub const BALANCE_DUST: Decimal = dec!(0.000000000001); + +/// Caps operands so `Decimal` multiplication cannot overflow mid-command. +pub const MAX_MONEY: Decimal = dec!(1_000_000_000_000); + +/// Reject a user-supplied money value outside `(0, MAX_MONEY]` — the one +/// gate that keeps engine arithmetic panic-free (see [`MAX_MONEY`]). +pub fn validate_money(value: Decimal, what: &str) -> Result<()> { + if value <= Decimal::ZERO { + return Err(PaperError::Rejected(format!( + "{what} must be a positive number" + ))); + } + if value > MAX_MONEY { + return Err(PaperError::Rejected(format!( + "{what} must be at most {MAX_MONEY}" + ))); + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaperConfig { + #[serde(with = "rust_decimal::serde::str")] + pub balance: Decimal, + pub currency: String, + #[serde(default = "default_fee_rate", with = "rust_decimal::serde::str")] + pub fee_rate: Decimal, + #[serde(default = "default_slippage_rate", with = "rust_decimal::serde::str")] + pub slippage_rate: Decimal, +} + +const KNOWN_QUOTES: &[&str] = &[ + "USDT", "USDC", "USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CHF", "ETH", "BTC", "DAI", +]; + +const Z_FIAT_QUOTES: &[&str] = &["ZUSD", "ZEUR", "ZGBP", "ZCAD", "ZJPY", "ZAUD", "ZCHF"]; + +const CANON_MAP: &[(&str, &str)] = &[ + ("XBT", "BTC"), + ("XXBT", "BTC"), + ("XETH", "ETH"), + ("XLTC", "LTC"), + ("XXRP", "XRP"), + ("XDOGE", "DOGE"), + ("XXLM", "XLM"), +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaperState { + pub balances: HashMap, + pub reserved: HashMap, + pub open_orders: Vec, + pub filled_trades: Vec, + #[serde(default = "default_starting_balance")] + pub starting_balance: Decimal, + #[serde(default = "default_starting_currency")] + pub starting_currency: String, + #[serde(default = "default_fee_rate")] + pub fee_rate: Decimal, + #[serde(default = "default_slippage_rate")] + pub slippage_rate: Decimal, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default = "default_next_order_id")] + next_order_id: u64, + #[serde(default)] + pub cancelled_orders: Vec, +} + +fn default_next_order_id() -> u64 { + 1 +} + +fn default_fee_rate() -> Decimal { + DEFAULT_FEE_RATE +} + +fn default_slippage_rate() -> Decimal { + DEFAULT_SLIPPAGE_RATE +} + +fn default_starting_balance() -> Decimal { + dec!(10_000) +} + +fn default_starting_currency() -> String { + "USD".to_string() +} + +/// A portfolio marked against one price snapshot. `unmarked` names every +/// held asset the snapshot could not price (sorted, so serialized output is +/// deterministic); completeness is derived, never stored alongside. +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct Valuation { + pub value: Decimal, + pub unmarked: BTreeSet, +} + +impl Valuation { + pub fn is_complete(&self) -> bool { + self.unmarked.is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaperOrder { + pub id: String, + pub pair: String, + pub base: String, + pub quote: String, + pub side: OrderSide, + #[serde(with = "rust_decimal::serde::str")] + pub volume: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub price: Decimal, + pub order_type: PaperOrderType, + pub reserved_asset: String, + #[serde(with = "rust_decimal::serde::str")] + pub reserved_amount: Decimal, + pub created_at: DateTime, +} + +impl PaperOrder { + /// Whether `ask`/`bid` would fill this resting order — the engine's one + /// crossing rule, shared with explain-pnl's recorded-stream + /// counterfactual so the two can never drift. + pub fn crosses(&self, ask: Decimal, bid: Decimal) -> bool { + match self.side { + OrderSide::Buy => ask <= self.price, + OrderSide::Sell => bid >= self.price, + } + } +} + +/// Money rides the digit-exact string wire wherever this type serializes — +/// see [`AccountEvent`] for why the journal payloads carry it everywhere. +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaperTrade { + pub id: String, + pub order_id: String, + pub pair: String, + pub base: String, + pub quote: String, + pub side: OrderSide, + #[serde(with = "rust_decimal::serde::str")] + pub volume: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub price: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub fee: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub cost: Decimal, + pub filled_at: DateTime, + /// `None`: legacy trade, or no valid quote at fill time. + pub reference_quote: Option, +} + +/// Raw top-of-book at fill time, pre-slippage. For limit fills: the quote at +/// fill *detection*, which can lag the moment the market first crossed. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct ReferenceQuote { + #[serde(with = "rust_decimal::serde::str")] + pub ask: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub bid: Decimal, +} + +impl ReferenceQuote { + /// `None` unless both sides are positive: a zero or negative top-of-book + /// is a broken frame, not a market. + pub fn checked(ask: Decimal, bid: Decimal) -> Option { + (ask > Decimal::ZERO && bid > Decimal::ZERO).then_some(Self { ask, bid }) + } + + pub fn mid(&self) -> Decimal { + (self.ask + self.bid) / dec!(2) + } +} + +/// One event in an account's log (`events.jsonl`) — the account's single +/// source of truth: state is a fold of these via [`PaperState::apply`]. +/// +/// State-changing events record *outputs* (ids, prices, fees), not formulas, +/// so replaying an old log stays exact even after engine rules change. +/// `Command` and `OrderRejected` are observational: the fold ignores them. +/// +/// Money in the state-changing payloads serializes as digit-exact strings +/// (`rust_decimal::serde::str`), not the workspace's f64 number wire — the +/// replay-stays-exact invariant needs every folded `Decimal` to round-trip +/// bit-for-bit. The attribute rides the payload types into every other +/// context they serialize in (state snapshots, lab fill ledgers), not only +/// this log. Observational entries keep the number wire: nothing folds +/// them, and their figures are display-grade echoes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +#[non_exhaustive] +pub enum AccountEvent { + Initialized(PaperConfig), + Reset(PaperConfig), + OrderSubmitted { + order: PaperOrder, + }, + OrderFilled { + trade: PaperTrade, + }, + OrderCancelled { + order: PaperOrder, + }, + Command(CommandEntry), + OrderRejected(OrderRejection), + /// A live journal's anchor: the venue snapshot observed when the + /// mirror first attached. Initializes the account like an epoch. + Attached(VenueSnapshot), + /// A venue snapshot closing a reconciliation window: the mirror + /// adopts the venue's balances — the venue is the truth in live mode. + Reconciled(VenueSnapshot), + /// An event kind this build doesn't know — written by a newer build. + #[serde(other)] + Unknown, +} + +/// The fixed reference a scoring window opens against: the +/// account's equity, currency, and cost rates at window-open, captured once +/// and never recomputed so the window's P&L and cost decomposition can't drift +/// if the account changes later. Carried as one unit because the four are only +/// ever meaningful together — the `Initialized`/`Reset` that set the rates and +/// the funding that set the equity both lie *before* the window, so the +/// window-opening snapshot is the only place to recover them. +#[serde_with::serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WindowAnchor { + #[serde_as(as = "serde_with::DisplayFromStr")] + pub equity: Decimal, + pub currency: String, + #[serde_as(as = "serde_with::DisplayFromStr")] + pub fee_rate: Decimal, + #[serde_as(as = "serde_with::DisplayFromStr")] + pub slippage_rate: Decimal, +} + +/// Per-asset balances as the venue reported them. Digit-exact on the wire +/// (string amounts) like every journaled money value, in stable key order; +/// `complete: false` discloses a snapshot the venue fetch could not fill — +/// never guessed. +#[serde_with::serde_as] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VenueSnapshot { + #[serde_as(as = "std::collections::BTreeMap<_, serde_with::DisplayFromStr>")] + pub balances: std::collections::BTreeMap, + pub complete: bool, + /// `Some` when this snapshot opens a scoring window; `None` for a + /// live-venue mirror, which observes balances and has no paper anchor + /// (a real account has a fee *tier*, not a scalar rate). One `Option` + /// keeps the four anchor fields all-present-or-all-absent, so an + /// equity anchor can never ship without its rates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor: Option, +} + +/// Audit record of one session-scoped command: allowlisted typed args, the +/// outcome, and post-command balances. The closed field set keeps free-form +/// prose (`--reason`) out of the log; identifiers the user typed (`pair`, +/// `order_id`) are recorded verbatim. +#[skip_serializing_none] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CommandEntry { + pub name: String, + pub pair: Option, + pub side: Option, + pub volume: Option, + pub order_type: Option, + pub price: Option, + pub order_id: Option, + pub balance: Option, + pub currency: Option, + pub fee_rate: Option, + pub slippage_rate: Option, + /// Ids the command produced, or the stable error category it failed with. + pub outcome: CommandOutcome, + /// Balances after the command (unchanged balances on failure). + pub balances: Option>, + /// Run-window marker: set ONLY by the `run start` / `run + /// stop` entries that cut a window into the journal. Every trading + /// command leaves it `None` — trades attribute to runs by position + /// between markers, never by stamping. + pub session: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum CommandOutcome { + Ok { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + order_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + trade_ids: Vec, + }, + Err { + category: String, + }, +} + +impl Default for CommandOutcome { + fn default() -> Self { + Self::Ok { + order_ids: Vec::new(), + trade_ids: Vec::new(), + } + } +} + +/// An order the engine refused — the only durable trace a rejection leaves. +/// Carries the request shape and the error *category*, never the message +/// (engine messages embed balance figures as free text). +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderRejection { + pub side: OrderSide, + pub pair: String, + pub volume: Decimal, + pub price: Option, + pub category: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaperOrderType { + Market, + Limit, +} + +impl std::fmt::Display for PaperOrderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Market => f.write_str("market"), + Self::Limit => f.write_str("limit"), + } + } +} + +impl Default for PaperState { + fn default() -> Self { + Self::new(dec!(10_000), "USD") + } +} + +impl PaperState { + pub fn new(balance: Decimal, currency: &str) -> Self { + Self::with_config(PaperConfig { + balance, + currency: currency.to_string(), + fee_rate: DEFAULT_FEE_RATE, + slippage_rate: DEFAULT_SLIPPAGE_RATE, + }) + } + + pub fn with_config(config: PaperConfig) -> Self { + let now = Utc::now(); + let cur = config.currency.to_uppercase(); + let mut balances = HashMap::new(); + balances.insert(cur.clone(), config.balance); + Self { + balances, + reserved: HashMap::new(), + open_orders: Vec::new(), + filled_trades: Vec::new(), + starting_balance: config.balance, + starting_currency: cur, + fee_rate: config.fee_rate, + slippage_rate: config.slippage_rate, + created_at: now, + updated_at: now, + next_order_id: 1, + cancelled_orders: Vec::new(), + } + } + + #[cfg(test)] + pub fn reset(&mut self) { + let config = self.reset_config(None, None, None, None); + self.apply(Utc::now(), &AccountEvent::Reset(config)); + } + + /// The config a reset would start the next epoch with: explicit overrides + /// where given, the current account's parameters otherwise. + pub fn reset_config( + &self, + balance: Option, + currency: Option<&str>, + fee_rate: Option, + slippage_rate: Option, + ) -> PaperConfig { + PaperConfig { + balance: balance.unwrap_or(self.starting_balance), + currency: currency + .map(|c| c.to_uppercase()) + .unwrap_or_else(|| self.starting_currency.clone()), + fee_rate: fee_rate.unwrap_or(self.fee_rate), + slippage_rate: slippage_rate.unwrap_or(self.slippage_rate), + } + } + + #[cfg(test)] + pub fn reset_with( + &mut self, + balance: Option, + currency: Option<&str>, + fee_rate: Option, + slippage_rate: Option, + ) { + let config = self.reset_config(balance, currency, fee_rate, slippage_rate); + self.apply(Utc::now(), &AccountEvent::Reset(config)); + } + + pub fn available_balance(&self, asset: &str) -> Decimal { + let total = self.balances.get(asset).copied().unwrap_or(Decimal::ZERO); + let reserved = self.reserved.get(asset).copied().unwrap_or(Decimal::ZERO); + (total - reserved).max(Decimal::ZERO) + } + + /// Business rules for a market order: validate, compute the fill. + /// Mutates nothing — the returned trade becomes an [`AccountEvent::OrderFilled`] + /// that [`Self::apply`] folds into state. + pub fn decide_market_order( + &self, + side: OrderSide, + pair: &str, + volume: Decimal, + ask: Decimal, + bid: Decimal, + ) -> Result { + validate_money(volume, "Volume")?; + validate_money(ask, "Ask")?; + validate_money(bid, "Bid")?; + + let (normalized, base, quote) = parse_pair(pair)?; + + let (fill_price, cost, fee) = match side { + OrderSide::Buy => { + let fill_price = ask * (Decimal::ONE + self.slippage_rate); + let cost = volume * fill_price; + let fee = cost * self.fee_rate; + let total_cost = cost + fee; + let available = self.available_balance("e); + if available < total_cost { + return Err(PaperError::Rejected(format!( + "Insufficient {quote} balance. Available: {available:.2}, Required: {total_cost:.2}" + ))); + } + (fill_price, cost, fee) + } + OrderSide::Sell => { + let available = self.available_balance(&base); + if available < volume { + return Err(PaperError::Rejected(format!( + "Insufficient {base} balance. Available: {available:.8}, Required: {volume:.8}" + ))); + } + let fill_price = bid * (Decimal::ONE - self.slippage_rate); + let proceeds = volume * fill_price; + let fee = proceeds * self.fee_rate; + (fill_price, proceeds, fee) + } + }; + + Ok(PaperTrade { + id: self.peek_order_id(1), + order_id: self.peek_order_id(0), + pair: normalized, + base, + quote, + side, + volume, + price: fill_price, + fee, + cost, + filled_at: Utc::now(), + reference_quote: Some(ReferenceQuote { ask, bid }), + }) + } + + #[cfg(test)] + pub fn place_market_order( + &mut self, + side: OrderSide, + pair: &str, + volume: Decimal, + ask: Decimal, + bid: Decimal, + ) -> Result { + let trade = self.decide_market_order(side, pair, volume, ask, bid)?; + self.apply( + trade.filled_at, + &AccountEvent::OrderFilled { + trade: trade.clone(), + }, + ); + Ok(trade) + } + + /// Business rules for a limit placement: validate, size the reservation. + /// Mutates nothing — the returned order becomes an + /// [`AccountEvent::OrderSubmitted`] that [`Self::apply`] folds into state. + pub fn decide_limit_order( + &self, + side: OrderSide, + pair: &str, + volume: Decimal, + price: Decimal, + ) -> Result { + validate_money(volume, "Volume")?; + validate_money(price, "Price")?; + + let (normalized, base, quote) = parse_pair(pair)?; + + let (reserved_asset, reserved_amount) = match side { + OrderSide::Buy => { + let amount = volume * price * (Decimal::ONE + self.fee_rate); + (quote.clone(), amount) + } + OrderSide::Sell => (base.clone(), volume), + }; + + let available = self.available_balance(&reserved_asset); + if available < reserved_amount { + return Err(PaperError::Rejected(format!( + "Insufficient {reserved_asset} balance. Available: {available:.8}, Required: {reserved_amount:.8}" + ))); + } + + Ok(PaperOrder { + id: self.peek_order_id(0), + pair: normalized, + base, + quote, + side, + volume, + price, + order_type: PaperOrderType::Limit, + reserved_asset, + reserved_amount, + created_at: Utc::now(), + }) + } + + #[cfg(test)] + pub fn place_limit_order( + &mut self, + side: OrderSide, + pair: &str, + volume: Decimal, + price: Decimal, + ) -> Result { + let order = self.decide_limit_order(side, pair, volume, price)?; + let id = order.id.clone(); + self.apply(order.created_at, &AccountEvent::OrderSubmitted { order }); + Ok(id) + } + + /// The open order a cancel would remove, or a validation error. + pub fn decide_cancel(&self, order_id: &str) -> Result { + self.open_orders + .iter() + .find(|o| o.id == order_id) + .cloned() + .ok_or_else(|| { + PaperError::Rejected(format!("Order {order_id} not found in open orders")) + }) + } + + #[cfg(test)] + pub fn cancel_order(&mut self, order_id: &str) -> Result { + let order = self.decide_cancel(order_id)?; + self.apply( + Utc::now(), + &AccountEvent::OrderCancelled { + order: order.clone(), + }, + ); + Ok(order) + } + + #[cfg(test)] + pub fn cancel_all_orders(&mut self) -> Vec { + let orders = self.open_orders.clone(); + let ts = Utc::now(); + for order in &orders { + self.apply( + ts, + &AccountEvent::OrderCancelled { + order: order.clone(), + }, + ); + } + orders + } + + /// The fills the given quotes would trigger, in FIFO submission order. + /// Mutates nothing — each trade becomes an [`AccountEvent::OrderFilled`]. + pub fn decide_pending_fills( + &self, + prices: &HashMap, + ) -> Vec { + let mut fills = Vec::new(); + for order in &self.open_orders { + let Some(&(ask, bid)) = prices.get(&order.pair) else { + continue; + }; + if !order.crosses(ask, bid) { + continue; + } + let fill_price = order.price; + let cost = order.volume * fill_price; + let fee = cost * self.fee_rate; + fills.push(PaperTrade { + id: self.peek_order_id(fills.len() as u64), + order_id: order.id.clone(), + pair: order.pair.clone(), + base: order.base.clone(), + quote: order.quote.clone(), + side: order.side, + volume: order.volume, + price: fill_price, + fee, + cost, + filled_at: Utc::now(), + reference_quote: ReferenceQuote::checked(ask, bid), + }); + } + fills + } + + #[cfg(test)] + pub fn check_pending_orders( + &mut self, + prices: &HashMap, + ) -> Vec { + let fills = self.decide_pending_fills(prices); + for trade in &fills { + self.apply( + trade.filled_at, + &AccountEvent::OrderFilled { + trade: trade.clone(), + }, + ); + } + fills + } + + /// Fold one event into state — the engine's only mutation path. + /// + /// Infallible pure bookkeeping from the event's recorded outputs; all + /// validation happened in the `decide_*` that produced the event. + /// Observational events (`Command`, `OrderRejected`, `Unknown`) are no-ops. + pub fn apply(&mut self, ts: DateTime, event: &AccountEvent) { + match event { + AccountEvent::Initialized(config) | AccountEvent::Reset(config) => { + *self = Self::with_config(config.clone()); + self.created_at = ts; + self.updated_at = ts; + } + AccountEvent::OrderSubmitted { order } => { + *self + .reserved + .entry(order.reserved_asset.clone()) + .or_insert(Decimal::ZERO) += order.reserved_amount; + self.advance_order_seq(&order.id); + self.updated_at = order.created_at; + self.open_orders.push(order.clone()); + } + AccountEvent::OrderFilled { trade } => { + if let Some(pos) = self.open_orders.iter().position(|o| o.id == trade.order_id) { + let order = self.open_orders.remove(pos); + self.release_reservation(&order); + } + match trade.side { + OrderSide::Buy => { + *self + .balances + .entry(trade.quote.clone()) + .or_insert(Decimal::ZERO) -= trade.cost + trade.fee; + *self + .balances + .entry(trade.base.clone()) + .or_insert(Decimal::ZERO) += trade.volume; + } + OrderSide::Sell => { + *self + .balances + .entry(trade.base.clone()) + .or_insert(Decimal::ZERO) -= trade.volume; + *self + .balances + .entry(trade.quote.clone()) + .or_insert(Decimal::ZERO) += trade.cost - trade.fee; + } + } + self.advance_order_seq(&trade.order_id); + self.advance_order_seq(&trade.id); + self.updated_at = trade.filled_at; + self.filled_trades.push(trade.clone()); + } + AccountEvent::OrderCancelled { order } => { + let Some(pos) = self.open_orders.iter().position(|o| o.id == order.id) else { + return; + }; + let removed = self.open_orders.remove(pos); + self.release_reservation(&removed); + self.cancelled_orders.push(removed); + self.updated_at = ts; + } + // The venue is the truth in live mode: the mirror adopts its + // balances wholesale. Open orders and fill history are untouched — + // they are the local record the snapshot reconciles against. + AccountEvent::Attached(snapshot) => { + self.balances = snapshot + .balances + .iter() + .map(|(asset, amount)| (asset.clone(), *amount)) + .collect(); + // A window-opening snapshot re-anchors P&L at its own equity + // and restores the account's rates so a folded scoring window + // reports them (not the type defaults); a plain mirror attach + // carries no anchor and leaves all of it alone. + if let Some(anchor) = &snapshot.anchor { + self.starting_balance = anchor.equity; + self.starting_currency = anchor.currency.clone(); + self.fee_rate = anchor.fee_rate; + self.slippage_rate = anchor.slippage_rate; + } + self.created_at = ts; + self.updated_at = ts; + } + AccountEvent::Reconciled(snapshot) => { + self.balances = snapshot + .balances + .iter() + .map(|(asset, amount)| (asset.clone(), *amount)) + .collect(); + self.updated_at = ts; + } + AccountEvent::Command(_) | AccountEvent::OrderRejected(_) | AccountEvent::Unknown => {} + } + } + + /// The id `offset` allocations ahead of the counter, without advancing it. + /// `decide_*` peeks ids; [`Self::apply`] advances the counter past any id + /// it folds, so decided-then-applied ids never collide. + fn peek_order_id(&self, offset: u64) -> String { + format!("{ORDER_ID_PREFIX}{:05}", self.next_order_id + offset) + } + + /// Advance the id counter past a folded id (`PAPER-{n}` → at least `n + 1`). + fn advance_order_seq(&mut self, id: &str) { + if let Some(seq) = id + .strip_prefix(ORDER_ID_PREFIX) + .and_then(|n| n.parse::().ok()) + { + self.next_order_id = self.next_order_id.max(seq + 1); + } + } + + /// [`Self::valuation`] reduced to its historical `(value, complete)` + /// shape for callers that don't need the unmarked assets by name. + pub fn compute_portfolio_value( + &self, + prices: &HashMap, + ) -> (Decimal, bool) { + let valuation = self.valuation(prices); + let complete = valuation.is_complete(); + (valuation.value, complete) + } + + /// Mark every balance from `prices` (keyed `"{asset}{starting_currency}"`, + /// valued at the tuple's bid element). An asset with no usable mark is + /// skipped — never a silent zero — and reported in + /// [`Valuation::unmarked`]; the skip rules (dust, negative-at-face, cash + /// 1:1) live only here. + pub fn valuation(&self, prices: &HashMap) -> Valuation { + let mut valuation = Valuation::default(); + let sc = &self.starting_currency; + // Summed in sorted key order. Decimal addition is exact for sane + // magnitudes, but a 28-digit coefficient can still round at + // pathological magnitude spreads — sorting keeps even that last + // place identical every session (the Lab replay's byte-identity AC). + let mut balances: Vec<(&String, &Decimal)> = self.balances.iter().collect(); + balances.sort_unstable_by(|a, b| a.0.cmp(b.0)); + for (asset, &amount) in balances { + if amount.abs() < BALANCE_DUST { + continue; + } + // Checked arithmetic throughout: [`validate_money`] bounds every + // engine-produced value, but a journal is just a file — an + // f64-era or hand-edited record can carry magnitudes whose + // product overflows, and a read path must report an unvaluable + // holding as unmarked, never panic. + let contribution = if amount < Decimal::ZERO || asset == sc { + Some(amount) + } else { + prices + .get(&format!("{asset}{sc}")) + .and_then(|&(_ask, bid)| amount.checked_mul(bid)) + }; + match contribution.and_then(|held| valuation.value.checked_add(held)) { + Some(total) => valuation.value = total, + None => { + valuation.unmarked.insert(asset.clone()); + } + } + } + valuation + } + + fn release_reservation(&mut self, order: &PaperOrder) { + if let Some(reserved) = self.reserved.get_mut(&order.reserved_asset) { + *reserved = (*reserved - order.reserved_amount).max(Decimal::ZERO); + } + } +} + +fn canonicalize(symbol: &str) -> String { + for &(from, to) in CANON_MAP { + if symbol.eq_ignore_ascii_case(from) { + return to.to_string(); + } + } + symbol.to_string() +} + +fn normalize_kraken_pair(s: &str) -> String { + for &zq in Z_FIAT_QUOTES { + if let Some(pos) = s.rfind(zq) { + if pos == 0 { + continue; + } + let raw_base = &s[..pos]; + let raw_quote = &s[pos + 1..]; // strip the leading 'Z' + let stripped_base = raw_base.strip_prefix('X').unwrap_or(raw_base); + return format!("{stripped_base}{raw_quote}"); + } + } + s.to_string() +} + +pub fn parse_pair(pair: &str) -> Result<(String, String, String)> { + let upper = pair.to_uppercase(); + let api_pair = upper.replace('/', ""); + let extraction = normalize_kraken_pair(&api_pair); + for &q in KNOWN_QUOTES { + if extraction.ends_with(q) && extraction.len() > q.len() { + let base_raw = &extraction[..extraction.len() - q.len()]; + let base = canonicalize(base_raw); + let quote = canonicalize(q); + return Ok((api_pair, base, quote)); + } + } + Err(PaperError::Rejected(format!( + "Unknown pair '{pair}'. Use slash format (e.g., BTC/USD) for uncommon pairs." + ))) +} + +pub fn valid_quote(ask: Decimal, bid: Decimal) -> bool { + ReferenceQuote::checked(ask, bid).is_some() +} + +/// The canonical `(base, quote)` join key for a recorded symbol — never the +/// raw string: [`parse_pair`] canonicalizes XBT→BTC only in its base/quote +/// outputs, so string keys would not collide `XBTUSD` with `BTC/USD`. +pub fn canonical_key(symbol: &str) -> Option<(String, String)> { + parse_pair(symbol) + .ok() + .map(|(_, base, quote)| (base, quote)) +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn test_new_state_custom_balance() { + let state = PaperState::new(dec!(5000.0), "EUR"); + assert_eq!(state.balances.get("EUR"), Some(&dec!(5000.0))); + assert_eq!(state.starting_balance, dec!(5000.0)); + assert_eq!(state.starting_currency, "EUR"); + assert!(state.open_orders.is_empty()); + assert!(state.filled_trades.is_empty()); + } + + #[test] + fn test_new_state_default() { + let state = PaperState::default(); + assert_eq!(state.balances.get("USD"), Some(&dec!(10000.0))); + assert_eq!(state.starting_balance, dec!(10000.0)); + assert_eq!(state.starting_currency, "USD"); + } + + #[test] + fn test_reset_restores_init_params() { + let mut state = PaperState::new(dec!(5000.0), "EUR"); + state + .place_market_order( + OrderSide::Buy, + "BTCEUR", + dec!(0.01), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + assert!(state.balances.contains_key("BTC")); + assert!(!state.filled_trades.is_empty()); + + state.reset(); + assert_eq!(state.balances.get("EUR"), Some(&dec!(5000.0))); + assert_eq!(state.starting_balance, dec!(5000.0)); + assert_eq!(state.starting_currency, "EUR"); + assert!(state.open_orders.is_empty()); + assert!(state.filled_trades.is_empty()); + assert!(!state.balances.contains_key("BTC")); + } + + #[test] + fn test_available_balance_with_reservation() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state.reserved.insert("USD".to_string(), dec!(3000.0)); + assert!((state.available_balance("USD") - dec!(7000.0)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_market_buy() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let trade = state + .place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + assert_eq!(trade.side, OrderSide::Buy); + assert_eq!(trade.volume, dec!(0.1)); + assert_eq!(trade.price, dec!(50000.0)); + let expected_cost = dec!(0.1) * dec!(50000.0); + let expected_fee = expected_cost * DEFAULT_FEE_RATE; + assert!((trade.fee - expected_fee).abs() < dec!(0.0000000001)); + assert!((trade.cost - expected_cost).abs() < dec!(0.0000000001)); + + let usd = *state.balances.get("USD").unwrap(); + assert!((usd - (dec!(10000.0) - expected_cost - expected_fee)).abs() < dec!(0.0000000001)); + assert_eq!(*state.balances.get("BTC").unwrap(), dec!(0.1)); + } + + #[test] + fn test_market_sell() { + let mut state = PaperState::new(dec!(0.0), "USD"); + state.balances.insert("BTC".to_string(), dec!(1.0)); + let trade = state + .place_market_order( + OrderSide::Sell, + "BTCUSD", + dec!(0.5), + dec!(50000.0), + dec!(48000.0), + ) + .unwrap(); + assert_eq!(trade.side, OrderSide::Sell); + assert_eq!(trade.volume, dec!(0.5)); + assert_eq!(trade.price, dec!(48000.0)); + let expected_proceeds = dec!(0.5) * dec!(48000.0); + let expected_fee = expected_proceeds * DEFAULT_FEE_RATE; + assert!((trade.fee - expected_fee).abs() < dec!(0.0000000001)); + + assert_eq!(*state.balances.get("BTC").unwrap(), dec!(0.5)); + let usd = *state.balances.get("USD").unwrap(); + assert!((usd - (expected_proceeds - expected_fee)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_market_buy_insufficient_balance() { + let mut state = PaperState::new(dec!(100.0), "USD"); + let result = state.place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Insufficient USD")); + assert_eq!(*state.balances.get("USD").unwrap(), dec!(100.0)); + } + + #[test] + fn test_market_sell_insufficient_balance() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let result = state.place_market_order( + OrderSide::Sell, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(48000.0), + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Insufficient BTC")); + } + + #[test] + fn test_fee_calculation() { + let fee = dec!(1.0) * dec!(50_000.0) * DEFAULT_FEE_RATE; + assert!((fee - dec!(130.0)).abs() < dec!(0.0000000001)); + } + + #[test] + fn market_buy_applies_slippage() { + let mut state = PaperState::with_config(PaperConfig { + balance: dec!(10000.0), + currency: "USD".into(), + fee_rate: dec!(0.0), + slippage_rate: dec!(0.001), + }); + let trade = state + .place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + // 50000 * 1.001 = 50050.0, volume 0.1 => cost 5005.0 + assert!((trade.price - dec!(50050.0)).abs() < dec!(0.000001)); + } + + #[test] + fn market_sell_applies_slippage() { + let mut state = PaperState::with_config(PaperConfig { + balance: dec!(0.0), + currency: "USD".into(), + fee_rate: dec!(0.0), + slippage_rate: dec!(0.001), + }); + state.balances.insert("BTC".into(), dec!(1.0)); + let trade = state + .place_market_order( + OrderSide::Sell, + "BTCUSD", + dec!(1.0), + dec!(50000.0), + dec!(50000.0), + ) + .unwrap(); + // 50000 * 0.999 = 49950.0 — clean number, no precision issue + assert!((trade.price - dec!(49950.0)).abs() < dec!(0.000001)); + } + + #[test] + fn zero_slippage_is_backward_compatible() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let trade = state + .place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + // no slippage, fill at exact ask + assert!((trade.price - dec!(50000.0)).abs() < dec!(0.000001)); + } + + #[test] + fn reset_with_preserves_slippage() { + let mut state = PaperState::with_config(PaperConfig { + balance: dec!(10000.0), + currency: "USD".into(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.001), + }); + state.reset_with(Some(dec!(5000.0)), None, None, None); + assert!((state.slippage_rate - dec!(0.001)).abs() < dec!(0.0000000001)); + } + + #[test] + fn reset_with_updates_slippage() { + let mut state = PaperState::with_config(PaperConfig { + balance: dec!(10000.0), + currency: "USD".into(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.001), + }); + state.reset_with(None, None, None, Some(dec!(0.002))); + assert!((state.slippage_rate - dec!(0.002)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_limit_buy_reserves_quote() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let id = state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + assert!(id.starts_with("PAPER-")); + let expected_reserved = dec!(0.1) * dec!(45000.0) * (dec!(1.0) + DEFAULT_FEE_RATE); + let reserved = state.reserved.get("USD").copied().unwrap_or(dec!(0.0)); + assert!((reserved - expected_reserved).abs() < dec!(0.0000000001)); + assert!( + (state.available_balance("USD") - (dec!(10000.0) - expected_reserved)).abs() + < dec!(0.0000000001) + ); + } + + #[test] + fn test_limit_sell_reserves_base() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state.balances.insert("BTC".to_string(), dec!(1.0)); + let id = state + .place_limit_order(OrderSide::Sell, "BTCUSD", dec!(0.5), dec!(55000.0)) + .unwrap(); + assert!(id.starts_with("PAPER-")); + let reserved = state.reserved.get("BTC").copied().unwrap_or(dec!(0.0)); + assert!((reserved - dec!(0.5)).abs() < dec!(0.0000000001)); + assert!((state.available_balance("BTC") - dec!(0.5)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_limit_over_commit_rejected() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + let result = state.place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.2), dec!(45000.0)); + assert!(result.is_err()); + assert_eq!(state.open_orders.len(), 1); + } + + #[test] + fn test_cancel_order_releases_reservation() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let id = state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + assert!(state.reserved.get("USD").copied().unwrap_or(dec!(0.0)) > dec!(0.0)); + + state.cancel_order(&id).unwrap(); + assert!(state.open_orders.is_empty()); + assert!(state.reserved.get("USD").copied().unwrap_or(dec!(0.0)) < dec!(0.0000000001)); + assert!((state.available_balance("USD") - dec!(10000.0)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_cancel_nonexistent_order() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let result = state.cancel_order("PAPER-99999"); + assert!(result.is_err()); + } + + #[test] + fn test_cancel_all_releases_all() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.01), dec!(40000.0)) + .unwrap(); + state + .place_limit_order(OrderSide::Buy, "ETHUSD", dec!(0.1), dec!(3000.0)) + .unwrap(); + assert_eq!(state.open_orders.len(), 2); + + let cancelled = state.cancel_all_orders(); + assert_eq!(cancelled.len(), 2); + assert!(state.open_orders.is_empty()); + assert!((state.available_balance("USD") - dec!(10000.0)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_check_pending_buy_fills() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(44500.0), dec!(44400.0))); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].price, dec!(45000.0)); + assert!(state.open_orders.is_empty()); + assert!(*state.balances.get("BTC").unwrap() > dec!(0.0)); + } + + #[test] + fn test_check_pending_sell_fills() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state.balances.insert("BTC".to_string(), dec!(1.0)); + state + .place_limit_order(OrderSide::Sell, "BTCUSD", dec!(0.5), dec!(55000.0)) + .unwrap(); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(55500.0), dec!(55500.0))); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].price, dec!(55000.0)); + assert!(state.open_orders.is_empty()); + } + + #[test] + fn test_check_pending_no_fill() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(46000.0), dec!(45900.0))); + let fills = state.check_pending_orders(&prices); + assert!(fills.is_empty()); + assert_eq!(state.open_orders.len(), 1); + } + + #[test] + fn test_check_pending_fifo_order() { + let mut state = PaperState::new(dec!(100000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.2), dec!(44000.0)) + .unwrap(); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(43000.0), dec!(42900.0))); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 2); + assert_eq!(fills[0].volume, dec!(0.1)); + assert_eq!(fills[1].volume, dec!(0.2)); + } + + #[test] + fn test_reconcile_then_cancel() { + let mut state = PaperState::new(dec!(100000.0), "USD"); + let _id_a = state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + let id_b = state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(30000.0)) + .unwrap(); + + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(44000.0), dec!(43900.0))); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 1); + + let cancelled = state.cancel_order(&id_b).unwrap(); + assert_eq!(cancelled.id, id_b); + assert!(state.open_orders.is_empty()); + } + + #[test] + fn test_reconcile_then_cancel_all() { + let mut state = PaperState::new(dec!(100000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(30000.0)) + .unwrap(); + + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(44000.0), dec!(43900.0))); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 1); + assert_eq!(state.open_orders.len(), 1); + + let cancelled = state.cancel_all_orders(); + assert_eq!(cancelled.len(), 1); + assert!(state.open_orders.is_empty()); + } + + #[test] + fn test_parse_pair_standard() { + let (pair, base, quote) = parse_pair("BTCUSD").unwrap(); + assert_eq!(pair, "BTCUSD"); + assert_eq!(base, "BTC"); + assert_eq!(quote, "USD"); + } + + #[test] + fn test_parse_pair_slash() { + let (pair, base, quote) = parse_pair("ETH/USD").unwrap(); + assert_eq!(pair, "ETHUSD"); + assert_eq!(base, "ETH"); + assert_eq!(quote, "USD"); + } + + #[test] + fn test_parse_pair_lowercase() { + let (pair, base, quote) = parse_pair("solusd").unwrap(); + assert_eq!(pair, "SOLUSD"); + assert_eq!(base, "SOL"); + assert_eq!(quote, "USD"); + } + + #[test] + fn test_parse_pair_usdt() { + let (pair, base, quote) = parse_pair("BTCUSDT").unwrap(); + assert_eq!(pair, "BTCUSDT"); + assert_eq!(base, "BTC"); + assert_eq!(quote, "USDT"); + } + + #[test] + fn test_parse_pair_usdc_usd() { + let (pair, base, quote) = parse_pair("USDCUSD").unwrap(); + assert_eq!(pair, "USDCUSD"); + assert_eq!(base, "USDC"); + assert_eq!(quote, "USD"); + } + + #[test] + fn test_parse_pair_eth_quote() { + let (pair, base, quote) = parse_pair("SOLETH").unwrap(); + assert_eq!(pair, "SOLETH"); + assert_eq!(base, "SOL"); + assert_eq!(quote, "ETH"); + } + + #[test] + fn test_parse_pair_unknown() { + let result = parse_pair("XYZABC"); + assert!(result.is_err()); + } + + #[test] + fn test_order_id_format() { + let mut state = PaperState::new(dec!(100000.0), "USD"); + let id1 = state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.01), dec!(40000.0)) + .unwrap(); + let id2 = state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.01), dec!(39000.0)) + .unwrap(); + assert_eq!(id1, "PAPER-00001"); + assert_eq!(id2, "PAPER-00002"); + } + + #[test] + fn test_compute_portfolio_value_complete() { + let mut state = PaperState::new(dec!(5000.0), "USD"); + state.balances.insert("BTC".to_string(), dec!(0.1)); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(50100.0), dec!(50000.0))); + let (value, complete) = state.compute_portfolio_value(&prices); + assert!(complete); + assert!((value - dec!(10000.0)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_compute_portfolio_value_partial() { + let mut state = PaperState::new(dec!(5000.0), "USD"); + state.balances.insert("BTC".to_string(), dec!(0.1)); + state.balances.insert("ETH".to_string(), dec!(1.0)); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(50100.0), dec!(50000.0))); + let (value, complete) = state.compute_portfolio_value(&prices); + assert!(!complete); + assert!((value - dec!(10000.0)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_state_serialization_no_secrets() { + let state = PaperState::new(dec!(10000.0), "USD"); + let json = serde_json::to_string(&state).unwrap(); + assert!(!json.contains("api_key")); + assert!(!json.contains("api_secret")); + assert!(!json.contains("password")); + } + + #[test] + fn test_canonicalize_xbt_to_btc() { + assert_eq!(canonicalize("XBT"), "BTC"); + } + + #[test] + fn test_canonicalize_passthrough() { + assert_eq!(canonicalize("SOL"), "SOL"); + } + + #[test] + fn test_normalize_kraken_pair_xxbtzusd() { + assert_eq!(normalize_kraken_pair("XXBTZUSD"), "XBTUSD"); + } + + #[test] + fn test_normalize_kraken_pair_xethzeur() { + assert_eq!(normalize_kraken_pair("XETHZEUR"), "ETHEUR"); + } + + #[test] + fn test_normalize_kraken_pair_passthrough() { + assert_eq!(normalize_kraken_pair("BTCUSD"), "BTCUSD"); + } + + #[test] + fn test_parse_pair_xbt_yields_btc() { + let (api, base, quote) = parse_pair("XBTUSD").unwrap(); + assert_eq!(api, "XBTUSD"); + assert_eq!(base, "BTC"); + assert_eq!(quote, "USD"); + } + + #[test] + fn test_parse_pair_xxbtzusd() { + let (api, base, quote) = parse_pair("XXBTZUSD").unwrap(); + assert_eq!(api, "XXBTZUSD"); + assert_eq!(base, "BTC"); + assert_eq!(quote, "USD"); + } + + #[test] + fn test_parse_pair_xethzeur() { + let (api, base, quote) = parse_pair("XETHZEUR").unwrap(); + assert_eq!(api, "XETHZEUR"); + assert_eq!(base, "ETH"); + assert_eq!(quote, "EUR"); + } + + #[test] + fn test_parse_pair_xxrpzusd() { + let (api, base, quote) = parse_pair("XXRPZUSD").unwrap(); + assert_eq!(api, "XXRPZUSD"); + assert_eq!(base, "XRP"); + assert_eq!(quote, "USD"); + } + + #[test] + fn test_canonicalization_buy_sell_same_key() { + let mut state = PaperState::new(dec!(100000.0), "USD"); + state + .place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + assert!(state.balances.contains_key("BTC")); + assert!(!state.balances.contains_key("XBT")); + + state + .place_market_order( + OrderSide::Sell, + "XBTUSD", + dec!(0.05), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + assert!(state.balances.contains_key("BTC")); + assert!(!state.balances.contains_key("XBT")); + } + + #[test] + fn test_canonicalization_xxbt_buy_btc_sell() { + let mut state = PaperState::new(dec!(100000.0), "USD"); + state + .place_market_order( + OrderSide::Buy, + "XXBTZUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + assert!(state.balances.contains_key("BTC")); + assert!(!state.balances.contains_key("XBT")); + assert!(!state.balances.contains_key("XXBT")); + + let btc_before = *state.balances.get("BTC").unwrap(); + state + .place_market_order( + OrderSide::Sell, + "BTCUSD", + dec!(0.05), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + let btc_after = *state.balances.get("BTC").unwrap(); + assert!((btc_before - btc_after - dec!(0.05)).abs() < dec!(0.0000000001)); + } + + #[test] + fn test_canonicalization_slash_and_xbt() { + let (_, base1, _) = parse_pair("BTC/USD").unwrap(); + let (_, base2, _) = parse_pair("XBTUSD").unwrap(); + assert_eq!(base1, "BTC"); + assert_eq!(base2, "BTC"); + } + + #[test] + fn test_check_pending_empty_prices_no_fill() { + let mut state = PaperState::new(dec!(100000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + let prices: HashMap = HashMap::new(); + let fills = state.check_pending_orders(&prices); + assert!(fills.is_empty()); + assert_eq!(state.open_orders.len(), 1); + } + + #[test] + fn test_normalize_kraken_pair_xxrpzusd() { + assert_eq!(normalize_kraken_pair("XXRPZUSD"), "XRPUSD"); + } + + #[test] + fn test_normalize_kraken_pair_zchf() { + assert_eq!(normalize_kraken_pair("XXBTZCHF"), "XBTCHF"); + } + + #[test] + fn test_parse_pair_btcusdt() { + let (api, base, quote) = parse_pair("BTCUSDT").unwrap(); + assert_eq!(api, "BTCUSDT"); + assert_eq!(base, "BTC"); + assert_eq!(quote, "USDT"); + } + + #[test] + fn test_market_order_zero_volume_rejected() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let result = state.place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.0), + dec!(50000.0), + dec!(49900.0), + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("positive")); + } + + #[test] + fn test_market_order_negative_volume_rejected() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let result = state.place_market_order( + OrderSide::Buy, + "BTCUSD", + -dec!(1.0), + dec!(50000.0), + dec!(49900.0), + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("positive")); + } + + #[test] + fn test_limit_order_zero_volume_rejected() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let result = state.place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.0), dec!(50000.0)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("positive")); + } + + #[test] + fn test_limit_order_zero_price_rejected() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let result = state.place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(0.0)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("positive")); + } + + #[test] + fn test_parse_pair_alias_mapping_for_ticker() { + let (_, base1, quote1) = parse_pair("BTCUSD").unwrap(); + let (_, base2, quote2) = parse_pair("XXBTZUSD").unwrap(); + assert_eq!( + base1, base2, + "BTCUSD and XXBTZUSD must canonicalize to same base" + ); + assert_eq!( + quote1, quote2, + "BTCUSD and XXBTZUSD must canonicalize to same quote" + ); + assert_eq!(base1, "BTC"); + assert_eq!(quote1, "USD"); + + let (_, base3, quote3) = parse_pair("ETHUSD").unwrap(); + let (_, base4, quote4) = parse_pair("XETHZUSD").unwrap(); + assert_eq!( + base3, base4, + "ETHUSD and XETHZUSD must canonicalize to same base" + ); + assert_eq!( + quote3, quote4, + "ETHUSD and XETHZUSD must canonicalize to same quote" + ); + assert_eq!(base3, "ETH"); + assert_eq!(quote3, "USD"); + } + + #[test] + fn test_status_degraded_valuation_components() { + let mut state = PaperState::new(dec!(5000.0), "USD"); + state.balances.insert("BTC".to_string(), dec!(0.1)); + let empty_prices: HashMap = HashMap::new(); + let (value, complete) = state.compute_portfolio_value(&empty_prices); + assert!( + !complete, + "valuation must be incomplete when prices are missing" + ); + assert!( + (value - dec!(5000.0)).abs() < dec!(0.0000000001), + "value must be quote-currency only" + ); + } + + // The old NaN/infinity rejection tests are gone with f64: `Decimal` + // cannot represent either, so the boundary parser enforces what the + // engine used to guard. Zero/negative gates remain testable. + + #[test] + fn market_order_rejects_zero_bid() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state.balances.insert("BTC".into(), dec!(1.0)); + let err = state.place_market_order( + OrderSide::Sell, + "BTCUSD", + dec!(0.001), + dec!(50000.0), + dec!(0.0), + ); + assert!(err.is_err()); + } + + #[test] + fn market_order_rejects_zero_ask() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + let err = state.place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.001), + dec!(0.0), + dec!(49990.0), + ); + assert!(err.is_err()); + } + + // Regression pins for the Decimal-overflow panics: a magnitude past + // MAX_MONEY must reject at the gate, never reach a multiplication. + + #[test] + fn market_order_rejects_volume_above_max_money() { + let state = PaperState::new(dec!(1000.0), "USD"); + let err = state + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(9_000_000_000_000_000_000_000_000_000), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap_err(); + assert!(err.to_string().contains("at most"), "got: {err}"); + } + + #[test] + fn limit_order_rejects_price_above_max_money() { + let state = PaperState::new(dec!(1000.0), "USD"); + let err = state + .decide_limit_order( + OrderSide::Buy, + "BTCUSD", + dec!(10_000_000_000_000_000), + dec!(10_000_000_000_000_000), + ) + .unwrap_err(); + assert!(err.to_string().contains("at most"), "got: {err}"); + } + + #[test] + fn valuation_reports_an_overflowing_holding_as_unmarked_not_panic() { + // A journal is just a file: an f64-era or hand-edited record can + // hold magnitudes the gates never saw. Valuing it must degrade to + // an unmarked holding, never kill a read path. + let mut state = PaperState::new(dec!(1000.0), "USD"); + state + .balances + .insert("BTC".into(), dec!(60_000_000_000_000_000_000_000_000)); + let prices = HashMap::from([( + "BTCUSD".to_string(), + (dec!(2_000_000_000_000), dec!(2_000_000_000_000)), + )]); + let valuation = state.valuation(&prices); + assert!(!valuation.is_complete()); + assert!(valuation.unmarked.contains("BTC")); + assert_eq!(valuation.value, dec!(1000.0), "cash still counted"); + } + + #[test] + fn market_fill_records_reference_quote_before_slippage() { + let mut state = PaperState::with_config(PaperConfig { + balance: dec!(10000.0), + currency: "USD".into(), + fee_rate: dec!(0.0), + slippage_rate: dec!(0.001), + }); + let trade = state + .place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + // nonzero slippage: a blended value leaking into the quote would differ + // from the raw book, so the equality below pins the raw capture + let quote = trade + .reference_quote + .expect("market fill records the quote"); + assert_eq!(quote.ask, dec!(50000.0)); + assert_eq!(quote.bid, dec!(49900.0)); + } + + #[test] + fn market_sell_records_reference_quote_before_slippage() { + let mut state = PaperState::with_config(PaperConfig { + balance: dec!(0.0), + currency: "USD".into(), + fee_rate: dec!(0.0), + slippage_rate: dec!(0.001), + }); + state.balances.insert("BTC".to_string(), dec!(1.0)); + let trade = state + .place_market_order( + OrderSide::Sell, + "BTCUSD", + dec!(0.5), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + let quote = trade + .reference_quote + .expect("market fill records the quote"); + assert_eq!(quote.ask, dec!(50000.0)); + assert_eq!(quote.bid, dec!(49900.0)); + } + + #[test] + fn limit_fill_records_market_quote_not_limit_price() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(44500.0), dec!(44400.0))); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].price, dec!(45000.0)); + let quote = fills[0] + .reference_quote + .expect("limit fill records the quote"); + assert_eq!(quote.ask, dec!(44500.0)); + assert_eq!(quote.bid, dec!(44400.0)); + } + + #[test] + fn limit_sell_fill_records_market_quote() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state.balances.insert("BTC".to_string(), dec!(1.0)); + state + .place_limit_order(OrderSide::Sell, "BTCUSD", dec!(0.5), dec!(55000.0)) + .unwrap(); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(55500.0), dec!(55450.0))); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].price, dec!(55000.0)); + let quote = fills[0] + .reference_quote + .expect("limit fill records the quote"); + assert_eq!(quote.ask, dec!(55500.0)); + assert_eq!(quote.bid, dec!(55450.0)); + } + + #[test] + fn limit_fill_with_degenerate_uncompared_side_records_no_quote() { + // A buy's crossing predicate only inspects the ask; a zero bid is a + // broken frame, and `ReferenceQuote::checked` must refuse to stamp it + // on the fill even though the fill itself is legitimate. + let mut state = PaperState::new(dec!(10000.0), "USD"); + state + .place_limit_order(OrderSide::Buy, "BTCUSD", dec!(0.1), dec!(45000.0)) + .unwrap(); + let mut prices = HashMap::new(); + prices.insert("BTCUSD".to_string(), (dec!(44500.0), Decimal::ZERO)); + let fills = state.check_pending_orders(&prices); + assert_eq!(fills.len(), 1); + assert!(fills[0].reference_quote.is_none()); + let json = serde_json::to_string(&state).unwrap(); + let reloaded: PaperState = + serde_json::from_str(&json).expect("state with an unquoted fill must round-trip"); + assert!(reloaded.filled_trades[0].reference_quote.is_none()); + } + + #[test] + fn deserialize_trade_missing_reference_quote_defaults_to_none() { + let json = r#"{ + "balances": {"USD": 5000.0, "BTC": 0.1}, + "reserved": {}, + "open_orders": [], + "filled_trades": [{ + "id": "PAPER-00002", + "order_id": "PAPER-00001", + "pair": "BTCUSD", + "base": "BTC", + "quote": "USD", + "side": "buy", + "volume": "0.1", + "price": "50000.0", + "fee": "13.0", + "cost": "5000.0", + "filled_at": "2026-01-01T00:00:00Z" + }], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#; + let state: PaperState = + serde_json::from_str(json).expect("a trade without reference_quote must keep loading"); + assert!(state.filled_trades[0].reference_quote.is_none()); + } + + #[test] + fn reference_quote_survives_serde_roundtrip() { + let mut state = PaperState::new(dec!(10000.0), "USD"); + state + .place_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50000.0), + dec!(49900.0), + ) + .unwrap(); + let json = serde_json::to_string(&state).unwrap(); + let loaded: PaperState = serde_json::from_str(&json).unwrap(); + let quote = loaded.filled_trades[0] + .reference_quote + .expect("quote survives persistence"); + assert_eq!(quote.ask, dec!(50000.0)); + assert_eq!(quote.bid, dec!(49900.0)); + } +} diff --git a/crates/kraken-paper/tests/account_lifecycle.rs b/crates/kraken-paper/tests/account_lifecycle.rs new file mode 100644 index 0000000..bcea393 --- /dev/null +++ b/crates/kraken-paper/tests/account_lifecycle.rs @@ -0,0 +1,131 @@ +//! The full journal lifecycle driven through the public API only — the +//! boundary proof: if this flow cannot be written against `pub` items, the +//! next crate up cannot write it either. + +use kraken_paper::account::{Origin, PaperAccount}; +use kraken_paper::{AccountEvent, CommandEntry, OrderSide, PaperConfig, PaperError}; +use rust_decimal_macros::dec; + +fn config(balance: rust_decimal::Decimal) -> PaperConfig { + PaperConfig { + balance, + currency: "USD".into(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.0), + } +} + +fn entry(name: &str) -> CommandEntry { + CommandEntry { + name: name.into(), + ..CommandEntry::default() + } +} + +/// Journal-is-the-source-of-truth: a lived sequence of init, market fill, +/// limit submit, and cancel folds back — on a fresh open — into exactly the +/// live state the commands built. +#[test] +fn replayed_journal_equals_the_lived_account() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + + let mut account = PaperAccount::open_at(path.clone(), Origin::Cli).unwrap(); + assert!(!account.is_initialized()); + account + .commit_if_uninitialized( + vec![AccountEvent::Initialized(config(dec!(10_000)))], + entry("init"), + ) + .unwrap(); + + let trade = account + .state() + .unwrap() + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50_000), + dec!(49_900), + ) + .unwrap(); + account + .commit(vec![AccountEvent::OrderFilled { trade }], entry("buy")) + .unwrap(); + + let order = account + .state() + .unwrap() + .decide_limit_order(OrderSide::Sell, "BTCUSD", dec!(0.05), dec!(60_000)) + .unwrap(); + let order_id = order.id.clone(); + account + .commit(vec![AccountEvent::OrderSubmitted { order }], entry("sell")) + .unwrap(); + + let cancelled = account.state().unwrap().decide_cancel(&order_id).unwrap(); + account + .commit( + vec![AccountEvent::OrderCancelled { order: cancelled }], + entry("cancel"), + ) + .unwrap(); + + let live = serde_json::to_value(account.state().unwrap()).unwrap(); + drop(account); // release the writer lock for the fresh open + + let reopened = PaperAccount::open_at(path, Origin::Cli).unwrap(); + let folded = reopened.state().unwrap(); + assert_eq!(serde_json::to_value(folded).unwrap(), live); + assert_eq!(folded.filled_trades.len(), 1); + assert_eq!(folded.cancelled_orders.len(), 1); + assert!(folded.open_orders.is_empty()); +} + +/// A journal written by a newer build refuses the whole handle at open, as +/// `Incompatible` — visible to an external consumer as its own variant. +#[test] +fn newer_version_record_is_refused_as_incompatible() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + std::fs::write( + &path, + concat!( + r#"{"v":99,"ts":"2026-01-01T00:00:00Z","origin":"cli","event":"initialized","balance":"500.0","currency":"USD","fee_rate":"0.0","slippage_rate":"0.0"}"#, + "\n" + ), + ) + .unwrap(); + let err = match PaperAccount::open_at(path, Origin::Cli) { + Ok(_) => panic!("newer journal must be refused at open"), + Err(e) => e, + }; + assert!(matches!(err, PaperError::Incompatible(_)), "got: {err}"); +} + +/// Observational audit entries land on the record but never change the fold, +/// across a reload. +#[test] +fn audit_entries_round_trip_without_changing_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + + let mut account = PaperAccount::open_at(path.clone(), Origin::Cli).unwrap(); + account + .commit_if_uninitialized( + vec![AccountEvent::Initialized(config(dec!(500)))], + entry("init"), + ) + .unwrap(); + let before = serde_json::to_value(account.state().unwrap()).unwrap(); + + account.audit(&account.stamp(vec![AccountEvent::Command(entry("status"))])); + drop(account); + + let reopened = PaperAccount::open_at(path, Origin::Cli).unwrap(); + assert_eq!( + serde_json::to_value(reopened.state().unwrap()).unwrap(), + before + ); +} diff --git a/crates/kraken-recording/ARCHITECTURE.md b/crates/kraken-recording/ARCHITECTURE.md new file mode 100644 index 0000000..0f597dc --- /dev/null +++ b/crates/kraken-recording/ARCHITECTURE.md @@ -0,0 +1,97 @@ +# kraken-recording Architecture + +Durable persistence for recorded events: market frames today, the paper-trading journal, and (planned) trade actions. + +## Principles + +1. **Symmetric** — every domain pairs a `Sink` (write) with a `Source` (read). What you write is exactly what you read back. +2. **Domain-free core** — the `Sink`/`Source` traits and the JSONL log engine know nothing about markets. Domains build on the core; the core never imports a domain. +3. **One name per type** — `JsonlSink`/`JsonlSource` are the generic pair; `DatasetSink`/`DatasetSource` the market JSONL backend; `DuckdbSink`/`DuckdbSource` the market columnar backend. +4. **Fail loud on damage** — an undecodable committed line or row fails the read. The only tolerated states are a crash's torn tail and a newer-minor writer's vocabulary. + +## Module Layout + +```text +kraken-recording/ +├── sink.rs ← Sink trait + JsonlSink (any Serialize type → JSONL) +├── source.rs ← Source trait + JsonlSource (JSONL → any Deserialize type) +├── jsonl.rs ← Log engine: locked appends, torn-tail repair, committed-line reads +├── lock.rs ← Advisory file locking (exclusive writers, shared readers) +├── schema.rs ← Version stamp and MAJOR/MINOR compatibility rule +├── error.rs ← Rejected / Damaged / Engine / Io failure taxonomy +├── fsync.rs ← Directory-entry durability for creates and renames +├── time.rs ← RFC3339 instant parsing shared by everything that orders events +│ +├── frames/ ← Market data (ticker, trade, book, ohlc) +│ ├── mod.rs ← CaptureSink/CaptureSource, MarketEvent, merge-instant rules +│ ├── capture.rs ← RecordingManifest/Declaration/Integrity, sidecar I/O, frame gate +│ ├── jsonl/ ← DatasetSink/DatasetSource: frames + .meta.json sidecar +│ └── duckdb/ ← DuckdbSink/DuckdbSource (feature `duckdb`) +│ ├── table.rs ← TableSpec: DDL/INSERT/SELECT generated from one column list +│ └── tables/ ← Recordable codec per channel (ticker, trade, book, ohlc) +│ +├── paper/ ← Paper-trading account journal: journal()/replay() over the core +└── actions/ ← Trade actions (placements, fills) — planned, unimplemented +``` + +## Event Domains + +### frames: Market Data — complete + +- **Write**: `DatasetSink` (frames → JSONL + sidecar) or `DuckdbSink` (→ columnar tables) +- **Read**: `DatasetSource` / `DuckdbSource`, both yielding `MarketEvent`s in capture order +- **Contract**: `CaptureSink::finalize` stamps the completeness summary; `CaptureSource::capture` hands it back before any frame decodes +- Both backends must yield identical event sequences for one capture, pinned by backend-equivalence tests (including across a crash-and-resume) + +### paper: Account Journal — in use + +The paper account is its append-only journal; state is derived by folding events in order. The event types live with the simulator, so the domain exposes the generic pair under the journal's contract: `paper::journal()` (locked writer) and `paper::replay()`. A columnar backend for querying trading history is planned. + +### actions: Trade Actions — planned + +Order placements, cancellations, and fills. Will mirror the `frames/` shape once the event types are settled. + +## Symmetry Contract + +```rust +// Write +let mut sink = DatasetSink::open(&path, &declaration)?; +sink.record(&frames)?; +sink.finalize(summary)?; + +// Read (exact same data) +let source = DatasetSource::open(&path)?; +let report = source.capture()?; // version-gated self-description +let events: Vec<_> = source.read()?.collect(); +``` + +`Source::read` validates the whole track before yielding the first item, which deliberately costs O(track) memory — no consumer ever observes a partial stream. + +## Schema & Versioning + +- **Version**: `MAJOR.MINOR` (`schema::SCHEMA_VERSION`), stamped by the first writer; a same-MAJOR reopen appends without downgrading the stamp +- **MAJOR mismatch**: rejected at open, for reads and appends, in both backends +- **Newer MINOR**: readable. Vocabulary this build can't decode (an unknown channel line, an unknown enum value) is skipped with a warning — whole frames, never partial ones. Under the build's own stamp the same content is damage and fails loud. One rule, both backends. +- **Location**: the `.meta.json` sidecar (JSONL) and the `_meta` table (DuckDB). A partial completeness stamp — `window_end` without its loss counts, or the reverse — is damage in both. + +## Crash Contract + +Stated honestly; the tests pin exactly this, no more: + +1. **Durability on return**: an append is one `write_all` + fsync; when `record` returns, the batch is on disk. File creation and sidecar renames also fsync the parent directory, so a power loss cannot roll them back. +2. **Torn tail**: a crash mid-append can leave a fragment after the last newline; the next append truncates it, readers ignore it. +3. **No batch atomicity**: a crash mid-append may persist any prefix of a multi-line batch (and, on out-of-order page writeback, a hole inside one — the reader fails loud on that). Consumers needing multi-event atomicity encode the transaction as a single line. +4. **Unfinalized runs**: a reopen clears the previous summary; a crash reads back as `summary: None` (completeness unknown), never as the old run's finalized stamp. +5. **Empty log, whatever the sidecar says**: zero committed bytes means zero recorded frames — the dataset bootstraps fresh rather than adopting an orphaned sidecar's stale identity. + +## Locking + +Writers hold an exclusive advisory lock on a `.lock` sidecar for the sink's lifetime; readers hold it shared. A second writer, or a reader racing a live writer, is rejected up front ("in use"), never queued. `is_lock_held` probes recorder liveness without ever reading a reader as a recorder. See `lock.rs` for the honestly-documented inode race and why it is accepted. + +## Testing Strategy + +- **Unit tests** per module: table codecs, frame gating, sidecar strictness, lock semantics +- **Round-trip tests**: write→finalize→read symmetry per backend, pinned on-disk shapes guarding serde drift +- **Backend equivalence**: DuckDB and JSONL yield identical events and capture reports, including across a crash +- **Crash artifacts**: torn tails (also spanning the scan window), unfinalized reopens, orphaned sidecars, half stamps +- **Property tests**: torn-tail repair truncates exactly to the committed prefix; the merge-instant clamp never reorders or rewinds diff --git a/crates/kraken-recording/Cargo.toml b/crates/kraken-recording/Cargo.toml new file mode 100644 index 0000000..84405c3 --- /dev/null +++ b/crates/kraken-recording/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "kraken-recording" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Unified event recording: durable persistence for market frames, actions, simulations with JSONL and DuckDB backends" +license.workspace = true +repository.workspace = true +publish = false + +[features] +# The columnar backend. Off by default so a dependant stays free of the +# bundled libduckdb C++ build; the JSONL backend is always available. +duckdb = ["dep:duckdb", "dep:rust_decimal", "dep:strum"] + +[dependencies] +chrono = { workspace = true } +duckdb = { workspace = true, optional = true } +kraken-core = { workspace = true } +rust_decimal = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +strum = { workspace = true, optional = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/kraken-recording/README.md b/crates/kraken-recording/README.md new file mode 100644 index 0000000..84781f5 --- /dev/null +++ b/crates/kraken-recording/README.md @@ -0,0 +1,81 @@ +# kraken-recording + +Durable persistence for recorded events: market frames, the paper-trading journal, and (planned) trade actions. + +## Overview + +A symmetric storage layer: every domain pairs a `Sink` (write) with a `Source` (read), and what was written is exactly what reads back. The core is generic — any `Serialize`/`Deserialize` type records through `JsonlSink`/`JsonlSource` — and the market domain adds capture contracts over two backends, JSONL and DuckDB. + +## Quick Start + +### Recording market frames + +```rust +use kraken_recording::{CaptureSink, DatasetSink, Sink}; + +let mut sink = DatasetSink::open(&path, &declaration)?; +sink.record(&frames)?; +sink.finalize(summary)?; +``` + +### Reading them back + +```rust +use kraken_recording::{CaptureSource, DatasetSource, Source}; + +let source = DatasetSource::open(&path)?; +let report = source.capture()?; // version-gated self-description +for event in source.read()? { + println!("{:?}", event.frame); +} +``` + +With the `duckdb` feature, `DuckdbSink`/`DuckdbSource` drive the columnar backend through the same `CaptureSink`/`CaptureSource` contracts — the two backends yield identical event sequences for one capture. + +### A generic event log + +```rust +use kraken_recording::{JsonlSink, JsonlSource, Sink, Source}; + +let mut sink: JsonlSink = JsonlSink::create(path.clone(), "my event log")?; +sink.record(&events)?; +drop(sink); // releases the writer lock + +let replayed: Vec = JsonlSource::open(path) + .expect("log exists") + .read()? + .collect(); +``` + +## Architecture + +Three layers, dependency arrows pointing down only: + +1. **Domains** — `frames/` (market data, complete — including the capture contracts `CaptureSink`/`CaptureSource`), `paper/` (the account journal, in use), `actions/` (planned) +2. **Contracts** — the generic `Sink`/`Source` pair every domain builds on +3. **Engine** — the locked JSONL log (torn-tail repair, fsync'd appends), advisory locks, schema versioning + +See [ARCHITECTURE.md](ARCHITECTURE.md) for the full design, including the honestly-stated crash contract. + +## Guarantees + +- **Symmetric**: write→finalize→read round-trips byte-identically (JSONL) or semantically identically (DuckDB) +- **Fail loud**: an undecodable committed line or row fails the read; only a crash's torn tail and a newer-minor writer's vocabulary are tolerated +- **Single writer**: writers hold an exclusive advisory lock; concurrent opens are rejected "in use", never interleaved +- **Versioned**: `MAJOR.MINOR` stamps; MAJOR mismatches are rejected at open in both backends + +## DuckDB Backend + +Enable with the `duckdb` feature (off by default; it pulls in the bundled libduckdb C++ build): + +```toml +kraken-recording = { version = "0.3", features = ["duckdb"] } +``` + +## Testing + +```bash +cargo nextest run -p kraken-recording --all-features +``` + +Unit, round-trip, backend-equivalence, crash-artifact, and property tests; on-disk shapes are pinned so protocol drift surfaces as a failing test. diff --git a/crates/kraken-recording/src/actions/mod.rs b/crates/kraken-recording/src/actions/mod.rs new file mode 100644 index 0000000..48ba7b5 --- /dev/null +++ b/crates/kraken-recording/src/actions/mod.rs @@ -0,0 +1,6 @@ +//! The trade-action domain: order placements, cancellations, and fills. +//! +//! Planned, unimplemented. It will mirror the [`frames`](crate::frames) +//! shape — a domain contract over the generic [`Sink`](crate::sink::Sink)/ +//! [`Source`](crate::source::Source) core, with JSONL and DuckDB backends — +//! once the action event types are settled. diff --git a/crates/kraken-recording/src/error.rs b/crates/kraken-recording/src/error.rs new file mode 100644 index 0000000..5f1397d --- /dev/null +++ b/crates/kraken-recording/src/error.rs @@ -0,0 +1,29 @@ +//! The crate's failure modes. Deliberately three-way — "you may not" +//! (`Rejected`), "your data is broken" (`Damaged`), "the machine failed" +//! (`Engine`/`Io`) — because that is the distinction consumers route on: +//! retry policy, user messaging, and the CLI's stable envelope categories +//! all map onto it one-to-one. (Plain names, not links: `Engine` only +//! exists under the `duckdb` feature, and a doc link to a gated variant +//! breaks the doc build without it.) + +/// Alias for the recording layer, like `io::Result`. +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A store that can't be opened or written as asked: held by another + /// process, missing, foreign, or version-incompatible. Refused up front — + /// nothing was read or written. + #[error("{0}")] + Rejected(String), + /// Content the schema contract can't decode — or a value it can't store. + #[error("{0}")] + Damaged(String), + /// The embedded engine failed — local I/O from the caller's perspective. + #[cfg(feature = "duckdb")] + #[error(transparent)] + Engine(#[from] duckdb::Error), + /// Filesystem failure around the store (parent dirs, locks, sidecars). + #[error(transparent)] + Io(#[from] std::io::Error), +} diff --git a/crates/kraken-recording/src/frames/capture.rs b/crates/kraken-recording/src/frames/capture.rs new file mode 100644 index 0000000..4596751 --- /dev/null +++ b/crates/kraken-recording/src/frames/capture.rs @@ -0,0 +1,683 @@ +//! The capture contract: what a recording says about itself. +//! +//! One vocabulary shared by the write and read sides — identity +//! ([`RecordingDeclaration`]), completeness ([`RecordingIntegrity`]), the combined +//! self-description ([`RecordingManifest`], the `.meta.json` sidecar mirrored in +//! DuckDB `_meta`), which frames a capture persists (`is_recordable`), and +//! the on-disk path rules. A sink stamps these; a source hands the exact same +//! types back. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use kraken_core::{Channel, ChannelData, ChannelMessage, SubscribableChannel}; +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::fsync::fsync_parent; + +/// Summary of a finished capture, stamped into the tape metadata at finalize +/// so a tape self-describes when it was captured and whether it is complete. +/// Serde: the sidecar's [`RecordingManifest`] embeds it verbatim and readers decode +/// the same type back. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RecordingIntegrity { + /// Instant the capture ended. + pub window_end: DateTime, + /// Frames the delivery channel dropped before this recorder read them. + /// Persisted because a gap is otherwise invisible — `seq`/`last_seq` stay + /// contiguous across drops. 0 = clean. + pub events_dropped: u64, + /// Inbound frames that failed the pinned schema and were dropped at the + /// connection, before reaching any sink. Persisted for the same reason as + /// `events_dropped`: without the count, schema drift reads as a clean + /// capture with silent holes. 0 = clean. + pub frames_unparsed: u64, + /// Socket reconnects mid-capture. Each replays a forced snapshot, so a + /// reader treats a mid-stream snapshot as a state reset, not duplicates. + pub reconnect_count: u64, +} + +/// Metadata describing a recording session, stamped into the tape on open and +/// completed on close. Built by the command from the validated args. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RecordingDeclaration { + /// Capture source (e.g. [`schema::SOURCE`](crate::schema::SOURCE)). + pub source: String, + /// The recorded trading pairs (the human symbols, e.g. `["BTC/USD","ETH/USD"]`) + /// — one tape can hold several, distinguished by each row's `symbol`. + pub symbols: Vec, + /// Channels captured into this tape (e.g. `["trades","book","ohlc"]`). + pub channels: Vec, + /// Instant the session started. + pub window_start: DateTime, + /// CLI version that wrote the tape. + pub cli_version: String, +} + +/// A capture's self-description (the `.meta.json` sidecar; mirrored in DuckDB +/// `_meta`): stamped without a summary at every (re)open and completed at +/// finalize, so a crashed run still has a checkable identity and schema version. +/// On a reopened tape the summary covers the last run only, not all sessions. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RecordingManifest { + /// [`schema::SCHEMA_VERSION`](crate::schema::SCHEMA_VERSION) of the first + /// writer — a same-MAJOR reopen appends without downgrading the stamp. + pub schema_version: String, + #[serde(flatten)] + pub meta: RecordingDeclaration, + /// `None` until finalize: the session is live or crashed and its completeness + /// unknown — consumers must surface that, not replay holes silently. + #[serde(flatten)] + pub summary: Option, +} + +/// The three answers a session's market capture can give about completeness. +/// Consumers must say which — never pretend a hole-free record. One shared +/// view, so no caller re-derives the tri-state and gets a branch wrong. +pub enum CaptureState<'report> { + /// The session has no market recording at all. + Absent, + /// The capture is running or crashed mid-capture; completeness is unknown. + Unfinalized(&'report RecordingManifest), + /// Finalized, with its gap counters. + Finalized(&'report RecordingManifest, &'report RecordingIntegrity), +} + +impl<'report> CaptureState<'report> { + pub fn of(capture: Option<&'report RecordingManifest>) -> Self { + match capture { + None => Self::Absent, + Some(report) => match &report.summary { + None => Self::Unfinalized(report), + Some(summary) => Self::Finalized(report, summary), + }, + } + } +} + +impl RecordingIntegrity { + /// A clean summary stamped at the current instant: no drops, no + /// reconnects. For streams that end before any socket work, and fixtures. + pub fn now() -> Self { + Self { + window_end: Utc::now(), + events_dropped: 0, + frames_unparsed: 0, + reconnect_count: 0, + } + } +} + +impl RecordingDeclaration { + /// Fold a reopened run into the stored identity: `symbols`/`channels` union + /// in first-seen order, every other field keeps its first-open value. + pub(crate) fn absorb(&mut self, run: &RecordingDeclaration) { + merge_unique(&mut self.symbols, &run.symbols); + merge_unique(&mut self.channels, &run.channels); + } +} + +/// The channels a recording persists — the one canonical listing; +/// [`ensure_recordable`] derives its gate and message from it, and +/// `is_recordable`'s per-payload match must mirror it (pinned by test). +pub(crate) const RECORDABLE_CHANNELS: [SubscribableChannel; 4] = [ + SubscribableChannel::Ticker, + SubscribableChannel::Trade, + SubscribableChannel::Book, + SubscribableChannel::Ohlc, +]; + +/// Whether the recording schema persists this frame. One predicate for both sinks, so an +/// unsolicited `status` or a data-less frame (zero DuckDB rows) is skipped by jsonl too. +/// Book frames expand per price level, so one whose entries all carry zero levels is +/// data-less in the recording sense even with a non-empty `data` array. +pub(crate) fn is_recordable(frame: &ChannelMessage) -> bool { + match &frame.body { + ChannelData::Ticker(data) => !data.is_empty(), + ChannelData::Trade(data) => !data.is_empty(), + ChannelData::Book(data) => data + .iter() + .any(|b| !b.bids.is_empty() || !b.asks.is_empty()), + ChannelData::Ohlc(data) => !data.is_empty(), + _ => false, + } +} + +/// This session's declared channels, parsed once from the tape meta. An +/// unparseable name contributes nothing — a sink records what it knows — +/// but is warned, so a typo'd declaration doesn't silently record nothing. +pub(crate) fn declared_channels(meta: &RecordingDeclaration) -> Vec { + meta.channels + .iter() + .filter_map(|name| match name.parse() { + Ok(channel) => Some(channel), + Err(_) => { + tracing::warn!(channel = %name, "ignoring an unrecognized declared channel"); + None + } + }) + .collect() +} + +/// The per-frame capture gate, one owned by each sink: a frame is admitted +/// iff it is recordable and among this session's declared channels. Both backends +/// build their gate from the same meta, so they skip identical frame sets; +/// an undeclared channel is warned once per gate, not once per frame. +#[derive(Debug)] +pub(crate) struct RecordingFilter { + declared: Vec, + warned: Vec, +} + +impl RecordingFilter { + pub(crate) fn new(declared: Vec) -> Self { + Self { + declared, + warned: Vec::new(), + } + } + + pub(crate) fn admits(&mut self, frame: &ChannelMessage) -> bool { + if !is_recordable(frame) { + return false; + } + // `is_recordable` admits market-data payloads only, and every one of + // those channels is subscribable. + let Channel::Subscribable(channel) = frame.channel() else { + return false; + }; + if self.declared.contains(&channel) { + return true; + } + if !self.warned.contains(&channel) { + self.warned.push(channel); + tracing::warn!( + %channel, + "skipping frames for a channel this tape does not record" + ); + } + false + } +} + +/// Reject any requested channel a recording can't persist, naming them. +/// Empty input passes: "no channels at all" belongs to the caller's own +/// planning, so two rejections never fire on one invocation. +pub fn ensure_recordable(channels: &[SubscribableChannel]) -> Result<()> { + let rejected: Vec = channels + .iter() + .filter(|c| !RECORDABLE_CHANNELS.contains(c)) + .map(SubscribableChannel::to_string) + .collect(); + if rejected.is_empty() { + return Ok(()); + } + Err(Error::Rejected(format!( + "record captures market-data channels only ({}); cannot record: {}", + RECORDABLE_CHANNELS.map(|c| c.to_string()).join(", "), + rejected.join(", ") + ))) +} + +/// Atomically write `value` as pretty JSON to `path`: parent dirs are created, +/// the bytes land in an fsync'd temp file *beside the target* (rename is only +/// atomic within one filesystem), then a rename swaps it into place, so a torn +/// file can never be observed. +pub fn write_json_atomic(path: &Path, value: &T) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(value) + .map_err(|e| Error::Damaged(format!("unencodable json document: {e}")))?; + let tmp = path.with_extension("json.tmp"); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(data.as_bytes())?; + f.sync_all()?; + } + std::fs::rename(&tmp, path)?; + // The rename is only durable once the directory entry is: without this, + // a power loss can resurrect the previous stamp — e.g. an old session's + // *finalized* summary under a log that already holds this session's frames. + fsync_parent(path)?; + Ok(()) +} + +fn merge_unique(into: &mut Vec, from: &[String]) { + for item in from { + if !into.contains(item) { + into.push(item.clone()); + } + } +} + +/// The sidecar's read shape: every completeness field individually optional. +/// The derived flatten-`Option` on [`RecordingManifest`] cannot distinguish +/// a *partial* stamp from an absent one — any missing count would read as +/// `summary: None` — so reads go through this wire type and the all-or-none +/// rule below, matching the DuckDB `_meta` reader's strictness. +#[derive(Deserialize)] +struct SidecarWire { + schema_version: String, + #[serde(flatten)] + meta: RecordingDeclaration, + window_end: Option>, + events_dropped: Option, + frames_unparsed: Option, + reconnect_count: Option, +} + +/// All-or-none: a partial completeness stamp is a torn or hand-edited +/// sidecar. Reading it as merely unfinalized would silently discard the +/// loss counters, so it is damage. +impl TryFrom for RecordingManifest { + type Error = &'static str; + + fn try_from(wire: SidecarWire) -> std::result::Result { + let summary = match ( + wire.window_end, + wire.events_dropped, + wire.frames_unparsed, + wire.reconnect_count, + ) { + (None, None, None, None) => None, + ( + Some(window_end), + Some(events_dropped), + Some(frames_unparsed), + Some(reconnect_count), + ) => Some(RecordingIntegrity { + window_end, + events_dropped, + frames_unparsed, + reconnect_count, + }), + _ => return Err("partial completeness stamp (torn or hand-edited)"), + }; + Ok(RecordingManifest { + schema_version: wire.schema_version, + meta: wire.meta, + summary, + }) + } +} + +/// Load and version-gate an existing tape's sidecar — the one JSONL stamp +/// check, shared by the tape sink and source. `action` names the refused +/// operation in the rejection ("append" for the sink, "read" for the +/// source). An unparseable or partially-stamped sidecar is damage: the +/// atomic sidecar write makes a torn file unobservable, so neither can come +/// from a healthy writer. +pub(crate) fn load_sidecar(tape: &Path, action: &str) -> Result { + let meta_path = sidecar_path(tape); + let data = match std::fs::read_to_string(&meta_path) { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(Error::Rejected(format!( + "tape '{}' has no metadata sidecar, so its schema version \ + cannot be verified (unstamped or foreign); refusing to {action} — \ + re-record it or use a new file", + tape.display() + ))); + } + Err(e) => return Err(e.into()), + }; + let unreadable = |what: &str| { + Error::Damaged(format!( + "unreadable tape sidecar '{}': {what}", + meta_path.display() + )) + }; + let wire = + serde_json::from_str::(&data).map_err(|e| unreadable(&e.to_string()))?; + let report = RecordingManifest::try_from(wire).map_err(unreadable)?; + crate::schema::ensure_compatible(&report.schema_version, action)?; + Ok(report) +} + +/// Sidecar path: `BTC-USD.jsonl` -> `BTC-USD.jsonl.meta.json`. Appended rather +/// than `with_extension`, which would collapse sibling tapes onto one sidecar. +pub(crate) fn sidecar_path(tape: &Path) -> PathBuf { + let mut raw = tape.as_os_str().to_owned(); + raw.push(".meta.json"); + PathBuf::from(raw) +} + +/// Turn a symbol into a filesystem-safe tape id: `BTC/USD` -> `BTC-USD`. +/// Any character outside `[A-Za-z0-9._-]` becomes `-` so the id is a single safe +/// path segment. +pub fn sanitize_id(symbol: &str) -> String { + symbol + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c, + _ => '-', + }) + .collect() +} + +/// The global market-memory library every standalone tape lives in: +/// `/tapes/`. +pub fn tapes_root(base: &Path) -> PathBuf { + base.join("tapes") +} + +/// Resolve the tape file path: `/tapes/.`. `base` is the +/// config dir in production and a temp dir in tests, so the store is test-isolable +/// without touching the real config directory. +pub fn resolve(base: &Path, id: &str, ext: &str) -> PathBuf { + tapes_root(base).join(format!("{id}.{ext}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capture_report_matches_the_pinned_sidecar_shape() { + let report = RecordingManifest { + schema_version: "1.0".to_string(), + meta: RecordingDeclaration { + source: "kraken-spot-ws-v2".to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trade".to_string(), "book".to_string()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "0.3.2".to_string(), + }, + summary: Some(RecordingIntegrity { + window_end: "2026-01-01T01:00:00Z".parse().unwrap(), + events_dropped: 7, + frames_unparsed: 3, + reconnect_count: 2, + }), + }; + let pinned = serde_json::json!({ + "schema_version": "1.0", + "source": "kraken-spot-ws-v2", + "symbols": ["BTC/USD"], + "channels": ["trade", "book"], + "window_start": "2026-01-01T00:00:00Z", + "cli_version": "0.3.2", + "window_end": "2026-01-01T01:00:00Z", + "events_dropped": 7, + "frames_unparsed": 3, + "reconnect_count": 2 + }); + assert_eq!(serde_json::to_value(&report).unwrap(), pinned); + assert_eq!( + serde_json::from_value::(pinned).unwrap(), + report + ); + } + + #[test] + fn unfinalized_report_pins_to_the_summaryless_shape() { + let report = RecordingManifest { + schema_version: "1.0".to_string(), + meta: RecordingDeclaration { + source: "kraken-spot-ws-v2".to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trade".to_string()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "0.3.2".to_string(), + }, + summary: None, + }; + let pinned = serde_json::json!({ + "schema_version": "1.0", + "source": "kraken-spot-ws-v2", + "symbols": ["BTC/USD"], + "channels": ["trade"], + "window_start": "2026-01-01T00:00:00Z", + "cli_version": "0.3.2" + }); + assert_eq!(serde_json::to_value(&report).unwrap(), pinned); + assert_eq!( + serde_json::from_value::(pinned).unwrap(), + report + ); + } + + #[test] + fn absorb_unions_symbols_and_channels_keeping_first_run_identity() { + let mut stored = RecordingDeclaration { + source: "kraken-spot-ws-v2".to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["ticker".to_string()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "0.3.2".to_string(), + }; + stored.absorb(&RecordingDeclaration { + source: "other-source".to_string(), + symbols: vec!["ETH/USD".to_string(), "BTC/USD".to_string()], + channels: vec!["ticker".to_string(), "trade".to_string()], + window_start: "2027-01-01T00:00:00Z".parse().unwrap(), + cli_version: "9.9.9".to_string(), + }); + assert_eq!(stored.symbols, ["BTC/USD", "ETH/USD"]); + assert_eq!(stored.channels, ["ticker", "trade"]); + assert_eq!(stored.source, "kraken-spot-ws-v2"); + assert_eq!( + stored.window_start, + "2026-01-01T00:00:00Z".parse::>().unwrap() + ); + assert_eq!(stored.cli_version, "0.3.2"); + } + + #[test] + fn data_less_frames_are_not_recordable() { + let empty = ChannelMessage::parse(r#"{"channel":"trade","type":"update","data":[]}"#) + .expect("empty-data frame parses"); + assert!(!is_recordable(&empty)); + let full = ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":1,"timestamp":"TS"}]}"#, + ) + .expect("trade frame parses"); + assert!(is_recordable(&full)); + } + + #[test] + fn zero_level_book_frames_are_not_recordable() { + // A book frame's row count is entries × levels, not `data.len()`: all-empty + // levels would write a JSONL line but zero DuckDB rows, splitting the two + // backends for the same capture. + let zero_level = ChannelMessage::parse( + r#"{"channel":"book","type":"update","data":[{"symbol":"BTC/USD", + "bids":[],"asks":[],"checksum":1,"timestamp":"TS"}]}"#, + ) + .expect("zero-level book frame parses"); + assert!(!is_recordable(&zero_level)); + let one_sided = ChannelMessage::parse( + r#"{"channel":"book","type":"update","data":[{"symbol":"BTC/USD", + "bids":[{"price":1.0,"qty":2.0}],"asks":[],"checksum":1,"timestamp":"TS"}]}"#, + ) + .expect("one-sided book frame parses"); + assert!( + is_recordable(&one_sided), + "ordinary one-sided updates persist" + ); + } + + #[test] + fn recordable_channels_are_exactly_the_market_data_set() { + for c in [ + SubscribableChannel::Ticker, + SubscribableChannel::Trade, + SubscribableChannel::Book, + SubscribableChannel::Ohlc, + ] { + assert!(ensure_recordable(&[c]).is_ok(), "{c} should be recordable"); + } + for c in [ + SubscribableChannel::Instrument, + SubscribableChannel::Level3, + SubscribableChannel::Executions, + SubscribableChannel::Balances, + ] { + assert!( + ensure_recordable(&[c]).is_err(), + "{c} should not be recordable" + ); + } + } + + #[test] + fn every_recordable_channel_has_recordable_frames() { + // Ties is_recordable's per-payload match to RECORDABLE_CHANNELS: a + // channel added to the const without a match arm panics here (no + // fixture) instead of silently dropping every frame in both sinks. + for channel in RECORDABLE_CHANNELS { + let raw = match channel { + SubscribableChannel::Ticker => { + r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD", + "bid":1.0,"bid_qty":2.0,"ask":3.0,"ask_qty":4.0,"last":5.0, + "volume":5.0,"vwap":6.0,"low":0.5,"high":9.0,"change":-1.0, + "change_pct":-2.5,"timestamp":"TS"}]}"# + } + SubscribableChannel::Trade => { + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":1,"timestamp":"TS"}]}"# + } + SubscribableChannel::Book => { + r#"{"channel":"book","type":"update","data":[{"symbol":"BTC/USD", + "bids":[{"price":1.0,"qty":2.0}],"asks":[],"checksum":1,"timestamp":"TS"}]}"# + } + SubscribableChannel::Ohlc => { + r#"{"channel":"ohlc","type":"update","data":[{"symbol":"BTC/USD", + "open":1.0,"high":2.0,"low":0.5,"close":1.5,"vwap":1.2,"trades":7, + "volume":1.0,"interval_begin":"TS","interval":1}]}"# + } + other => panic!("no recordable fixture for {other}: add one with its match arm"), + }; + let frame = ChannelMessage::parse(raw).expect("fixture parses"); + assert!(is_recordable(&frame), "{channel} frames must be recordable"); + } + } + + #[test] + fn gate_keeps_skipping_undeclared_frames_after_the_first_warn() { + let trade = ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":1,"timestamp":"TS"}]}"#, + ) + .expect("trade frame parses"); + let ticker = ChannelMessage::parse( + r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD", + "bid":1.0,"bid_qty":2.0,"ask":3.0,"ask_qty":4.0,"last":5.0, + "volume":5.0,"vwap":6.0,"low":0.5,"high":9.0,"change":-1.0, + "change_pct":-2.5,"timestamp":"TS"}]}"#, + ) + .expect("ticker frame parses"); + + let mut gate = RecordingFilter::new(vec![SubscribableChannel::Trade]); + assert!(gate.admits(&trade)); + // The warn-once bookkeeping must not change the verdict: every + // undeclared frame stays skipped, every declared one admitted. + assert!(!gate.admits(&ticker)); + assert!(!gate.admits(&ticker)); + assert!(gate.admits(&trade)); + } + + #[test] + fn ensure_recordable_rejects_account_feeds_and_names_them() { + let Error::Rejected(msg) = + ensure_recordable(&[SubscribableChannel::Trade, SubscribableChannel::Executions]) + .unwrap_err() + else { + panic!("a non-recordable channel must be rejected"); + }; + assert!( + msg.contains("executions"), + "error names the bad channel: {msg}" + ); + } + + #[test] + fn ensure_recordable_accepts_recordable_channels_and_empty_input() { + // Empty is deferred to the caller's own planning, so it passes this guard. + assert!(ensure_recordable(&[]).is_ok()); + assert!( + ensure_recordable(&[SubscribableChannel::Trade, SubscribableChannel::Book]).is_ok() + ); + } + + #[test] + fn write_json_atomic_replaces_content_and_leaves_no_temp_behind() { + // The mechanism finalize's correctness rests on: the swap is total + // (old content fully replaced) and the same-directory temp — the + // rename-atomicity precondition — never outlives the write. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("report.meta.json"); + write_json_atomic(&path, &serde_json::json!({"session": 1})).unwrap(); + write_json_atomic(&path, &serde_json::json!({"session": 2})).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("\"session\": 2")); + assert!(!content.contains("\"session\": 1")); + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .filter(|n| n != "report.meta.json") + .collect(); + assert!( + leftovers.is_empty(), + "temp files left behind: {leftovers:?}" + ); + } + + fn wire_manifest(json: serde_json::Value) -> std::result::Result { + let wire = serde_json::from_value::(json).map_err(|e| e.to_string())?; + RecordingManifest::try_from(wire).map_err(str::to_string) + } + + #[test] + fn partial_completeness_stamp_is_damage_in_both_directions() { + // window_end without its counts, and counts without a window_end: + // both are torn stamps, neither may read as merely unfinalized. + let base = serde_json::json!({ + "schema_version": "1.0", + "source": "kraken-spot-ws-v2", + "symbols": ["BTC/USD"], + "channels": ["trade"], + "window_start": "2026-01-01T00:00:00Z", + "cli_version": "0.3.2" + }); + let mut with_end = base.clone(); + with_end["window_end"] = "2026-01-01T01:00:00Z".into(); + assert!( + wire_manifest(with_end).is_err(), + "window_end without counts" + ); + + let mut with_counts = base.clone(); + with_counts["events_dropped"] = 1.into(); + with_counts["frames_unparsed"] = 0.into(); + with_counts["reconnect_count"] = 0.into(); + assert!( + wire_manifest(with_counts).is_err(), + "counts without window_end" + ); + + assert_eq!( + wire_manifest(base).unwrap().summary, + None, + "an all-absent stamp is a legitimate unfinalized capture" + ); + } + + #[test] + fn sanitizes_symbol_into_safe_id() { + assert_eq!(sanitize_id("BTC/USD"), "BTC-USD"); + assert_eq!(sanitize_id("AAPLx/USD"), "AAPLx-USD"); + assert_eq!(sanitize_id("ETH_USD.1"), "ETH_USD.1"); + } + + #[test] + fn resolves_under_recordings_dir() { + let p = resolve(Path::new("/cfg"), "BTC-USD", "duckdb"); + assert!(p.ends_with("tapes/BTC-USD.duckdb")); + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/mod.rs b/crates/kraken-recording/src/frames/duckdb/mod.rs new file mode 100644 index 0000000..58cd196 --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/mod.rs @@ -0,0 +1,191 @@ +//! The DuckDB market tape: the columnar recording backend, behind the +//! `duckdb` feature. +//! +//! Writer and reader live side by side — [`DuckdbSink`] persists what +//! [`DuckdbSource`] yields back — over the shared per-channel row codecs in +//! [`tables`] and the data-driven schema in [`table`]. This module itself +//! hosts [`Db`]: a thin, schema-agnostic wrapper over a file-backed `duckdb` +//! connection that bundles the cross-cutting concerns every consumer needs — +//! an advisory [`FileLock`] for a clean single-writer rejection (DuckDB +//! itself permits only one read-write process per file; the lock turns the +//! second process's failure into a friendly rejection before the engine +//! opens), `CREATE TABLE IF NOT EXISTS` schema application, a key/value +//! `_meta` upsert/read, and small scalar-read helpers. + +mod sink; +mod source; +pub(crate) mod table; +pub(crate) mod tables; + +use std::path::Path; + +use duckdb::{Connection, params}; +pub use sink::DuckdbSink; +pub use source::DuckdbSource; + +use crate::error::{Error, Result}; +use crate::lock::FileLock; +use crate::schema; + +/// One version gate for both DuckDB halves: `_meta` must exist as a table +/// (probed here — a foreign DuckDB file has none, and querying it would +/// surface a raw catalog error) and carry a stamp sharing this build's +/// MAJOR. Returns the validated stamp — the source's drift policy derives +/// from it. `action` names the refused operation ("append" for the sink, +/// "read" for the source). +pub(crate) fn ensure_recording_stamp(db: &Db, action: &str) -> Result { + let not_a_recording = || { + Error::Rejected(format!( + "existing file is not a kraken tape (no schema metadata); \ + refusing to {action}" + )) + }; + if !table::table_exists(db.conn(), "_meta")? { + return Err(not_a_recording()); + } + match db.meta_get("schema_version")? { + Some(found) => { + schema::ensure_compatible(&found, action)?; + Ok(found) + } + None => Err(not_a_recording()), + } +} + +/// A file-backed DuckDB connection: locked and writable for sinks, lock-free +/// and read-only for sources. +pub struct Db { + conn: Connection, + // Held for its `Drop`, which releases the advisory lock when the `Db` (and so + // the connection) is dropped. + _lock: Option, +} + +impl Db { + /// Open (creating if absent) the DuckDB file at `path`, after acquiring + /// the advisory lock at `.lock` — so a second writer is rejected + /// here, cleanly, not by the engine. + pub fn open(path: &Path) -> Result { + let resource = format!("tape '{}'", path.display()); + let lock = FileLock::acquire(path, resource)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(path)?; + Ok(Self { + conn, + _lock: Some(lock), + }) + } + + /// Open an existing database read-only, without its own advisory lock, so + /// lock policy stays with the caller. A live writer still excludes readers + /// at the engine level; callers acquire a *shared* [`FileLock`] first to + /// turn that into a clean "in use" rejection before the engine's raw error + /// (and to keep concurrent readers from excluding one another). + pub fn open_read_only(path: &Path) -> Result { + let config = duckdb::Config::default().access_mode(duckdb::AccessMode::ReadOnly)?; + let conn = Connection::open_with_flags(path, config)?; + Ok(Self { conn, _lock: None }) + } + + /// Borrow the connection for prepared statements (callers outside this + /// crate, e.g. `kraken feedback`, insert through the same locked handle). + pub fn conn(&self) -> &Connection { + &self.conn + } + + pub fn execute_batch(&self, sql: &str) -> Result<()> { + self.conn.execute_batch(sql)?; + Ok(()) + } + + /// Roll back the open transaction on an error path. A failure is logged, not + /// returned: the caller is already propagating the original error, and the + /// engine discards the transaction with the connection anyway. + pub(crate) fn rollback(&self) { + if let Err(err) = self.execute_batch("ROLLBACK") { + tracing::warn!(%err, "ROLLBACK failed; the transaction dies with the connection"); + } + } + + /// Each statement must be idempotent (`CREATE TABLE IF NOT EXISTS` style): + /// reopen re-applies the whole list. + pub fn ensure_schema(&self, ddl: &[&str]) -> Result<()> { + for stmt in ddl { + self.conn.execute_batch(stmt)?; + } + Ok(()) + } + + /// Upsert a `_meta` key/value pair (the `_meta` table must already exist). + pub fn meta_set(&self, key: &str, value: &str) -> Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", + params![key, value], + )?; + Ok(()) + } + + pub fn meta_get(&self, key: &str) -> Result> { + let mut stmt = self.conn.prepare("SELECT value FROM _meta WHERE key = ?")?; + let mut rows = stmt.query(params![key])?; + match rows.next()? { + Some(row) => Ok(Some(row.get::<_, String>(0)?)), + None => Ok(None), + } + } + + /// Run a single-column, single-row query and return it as a `u64` (counts). + /// A test/diagnostic convenience: production readers go through the + /// generated per-table queries in [`table`]. + pub fn scalar_u64(&self, sql: &str) -> Result { + let mut stmt = self.conn.prepare(sql)?; + let mut rows = stmt.query([])?; + let row = rows + .next()? + .ok_or_else(|| Error::Damaged("query returned no rows".into()))?; + let value: i64 = row.get(0)?; + u64::try_from(value) + .map_err(|_| Error::Damaged(format!("query returned a negative count: {value}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn meta_roundtrip_and_scalar() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("t.duckdb"); + let db = Db::open(&path).unwrap(); + db.ensure_schema(&[ + "CREATE TABLE IF NOT EXISTS _meta (key VARCHAR PRIMARY KEY, value VARCHAR)", + ]) + .unwrap(); + assert_eq!(db.meta_get("schema_version").unwrap(), None); + db.meta_set("schema_version", "1.0").unwrap(); + db.meta_set("schema_version", "1.0").unwrap(); // upsert is idempotent + assert_eq!( + db.meta_get("schema_version").unwrap().as_deref(), + Some("1.0") + ); + assert_eq!(db.scalar_u64("SELECT count(*) FROM _meta").unwrap(), 1); + } + + #[test] + fn second_open_of_same_tape_is_rejected() { + // AC#1 (clean single-writer rejection): while one Db holds the tape, + // a second open of the same path fails with a `validation` error. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + let _first = Db::open(&path).unwrap(); + // `Db` holds a non-Debug duckdb connection, so match rather than unwrap_err. + // Pinned through the envelope mapping: a held tape must stay `validation`. + match Db::open(&path) { + Ok(_) => panic!("second open of a held tape must be rejected"), + Err(e) => assert!(matches!(e, Error::Rejected(_))), + } + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/sink.rs b/crates/kraken-recording/src/frames/duckdb/sink.rs new file mode 100644 index 0000000..4651e89 --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/sink.rs @@ -0,0 +1,716 @@ +//! DuckDB-backed recording sink. +//! +//! Each parsed [`ChannelMessage`] frame is dispatched to its channel's table via +//! [`crate::frames::duckdb::tables`] — one transaction per drained batch over the reusable +//! [`Db`]. The schema is generated from the per-channel +//! [`TableSpec`](crate::frames::duckdb::table::TableSpec)s, so DDL and inserts can't drift. A +//! monotonic per-frame `seq`, persisted in `_meta.last_seq`, resumes on reopen so a +//! frame's rows stay groupable across sessions. Opening enforces the version +//! contract — a fresh file is stamped `schema_version`; an existing tape is +//! appended to when compatible and cleanly rejected when not. + +use chrono::Utc; +use duckdb::params; +use kraken_core::ChannelMessage; + +use crate::error::{Error, Result}; +use crate::frames::CaptureSink; +use crate::frames::capture::{RecordingDeclaration, RecordingFilter, RecordingIntegrity}; +use crate::frames::duckdb::Db; +use crate::frames::duckdb::tables; +use crate::schema; +use crate::sink::Sink; + +/// Union a reopened session's list into a comma-joined `_meta` value, +/// first-seen order preserved. +fn absorb_meta_list(db: &Db, key: &str, run: &[String]) -> Result<()> { + let stored = db.meta_get(key)?.unwrap_or_default(); + let mut merged: Vec = stored + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + for item in run { + if !merged.contains(item) { + merged.push(item.clone()); + } + } + db.meta_set(key, &merged.join(",")) +} + +pub struct DuckdbSink { + db: Db, + // Monotonic per-frame id shared by every row of a frame, resumed from + // `_meta.last_seq` on reopen so two sessions never collide a `seq`. + seq: i64, + /// The shared per-frame capture gate ([`RecordingFilter`]); a frame outside + /// this session's declared channels has no table to land in and is skipped, + /// not an error. + gate: RecordingFilter, +} + +impl DuckdbSink { + /// Open (creating if absent) the tape at `path`, acquire its advisory lock, + /// ensure the schema, and apply the version contract. Errors before returning if + /// another process holds the tape, or if an existing tape's schema is + /// incompatible / not a kraken recording (so the command fails before opening + /// any socket). + pub fn open(path: &std::path::Path, meta: &RecordingDeclaration) -> Result { + let db = Db::open(path)?; + // An empty database (no tables at all) cannot hold recorded data: it + // is either brand new or the artifact of a crash inside a previous + // open(), between the engine creating the file and the bootstrap + // transaction committing. Either way, bootstrap it; a file with + // tables but no stamp stays refused below. + let is_new = db.scalar_u64( + "SELECT count(*) FROM information_schema.tables \ + WHERE table_catalog = current_database() AND table_schema = 'main'", + )? == 0; + // Generate the DDL for `_meta` plus a table for each channel this session records, + // and apply it idempotently — so a tape carries only the tables it uses, not an + // empty table for every channel. Reopening to record an additional channel adds its + // table via the same `CREATE TABLE IF NOT EXISTS`; existing tables are left intact. + let declared = crate::frames::capture::declared_channels(meta); + let specs = tables::schema_tables_for(&declared); + let mut ddl = vec![schema::META_DDL.to_string()]; + ddl.extend(specs.iter().map(|spec| spec.create_sql())); + // CREATE TABLE IF NOT EXISTS never alters an existing table, so a + // tape written before a spec gained a nullable column migrates here. + ddl.extend(specs.iter().flat_map(|spec| spec.add_column_sql())); + let stmts: Vec<&str> = ddl.iter().map(String::as_str).collect(); + // Bootstrap atomically: the DDL plus the version stamp/validation run in one + // transaction, so a crash during open() leaves the tape either fully + // stamped or absent — never a table set with no `schema_version`. + db.execute_batch("BEGIN TRANSACTION")?; + let bootstrap = (|| -> Result<()> { + db.ensure_schema(&stmts)?; + if is_new { + db.meta_set("schema_version", schema::SCHEMA_VERSION)?; + db.meta_set("source", &meta.source)?; + db.meta_set("symbols", &meta.symbols.join(","))?; + db.meta_set("channels", &meta.channels.join(","))?; + db.meta_set( + "window_start", + &crate::time::format_instant(meta.window_start), + )?; + db.meta_set("cli_version", &meta.cli_version)?; + } else { + crate::frames::duckdb::ensure_recording_stamp(&db, "append")?; + // A reopen restarts the capture: clear the finalize stamp so a crash + // reads back unfinalized, and absorb this session's identity lists. + db.execute_batch( + "DELETE FROM _meta \ + WHERE key IN ('window_end', 'events_dropped', 'frames_unparsed', \ + 'reconnect_count')", + )?; + absorb_meta_list(&db, "symbols", &meta.symbols)?; + absorb_meta_list(&db, "channels", &meta.channels)?; + } + Ok(()) + })(); + if let Err(e) = bootstrap { + db.rollback(); + return Err(e); + } + db.execute_batch("COMMIT")?; + + // Resume the per-frame seq so a reopened session continues the sequence + // rather than restarting at 0 and colliding with an earlier session's ids. + // A present-but-unparseable value means a corrupt tape — refuse rather + // than silently reset to 0 (which would collide new rows with existing ones). + let seq = match db.meta_get("last_seq")? { + None => 0, + Some(s) => s.parse::().map_err(|_| { + Error::Damaged(format!( + "tape has a corrupt last_seq metadata value '{s}'; refusing to write" + )) + })?, + }; + + Ok(Self { + db, + seq, + gate: RecordingFilter::new(declared), + }) + } + + /// Write one batch's frames in the open transaction: advance the per-frame seq, + /// dispatch each frame to its channel table, then persist the seq high-water + /// mark in the same transaction so a crash mid-session still resumes cleanly. + /// Borrows `self.db` (the connection), `self.gate`, and `self.seq` as + /// disjoint fields. + fn write_batch(&mut self, batch: &[ChannelMessage], recv_ts: &str) -> Result<()> { + let conn = self.db.conn(); + for frame in batch { + // Only frames that persist rows advance the seq, so numbering + // matches the JSONL backend's line numbers and `_meta.last_seq` + // never counts phantom frames. + if !self.gate.admits(frame) { + continue; + } + self.seq += 1; + tables::record_frame(conn, frame, self.seq, recv_ts)?; + } + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('last_seq', ?)", + params![self.seq.to_string()], + ) + .map_err(Error::from)?; + Ok(()) + } +} + +impl Sink for DuckdbSink { + type Item = ChannelMessage; + + fn record(&mut self, batch: &[ChannelMessage]) -> Result<()> { + let recv_ts = crate::time::format_instant(Utc::now()); + // A failed batch rolls back and ends the session, so advancing the seq on + // the error path is harmless (no later batch observes it). + self.db.execute_batch("BEGIN TRANSACTION")?; + match self.write_batch(batch, &recv_ts) { + Ok(()) => { + self.db.execute_batch("COMMIT")?; + Ok(()) + } + Err(e) => { + self.db.rollback(); + Err(e) + } + } + } +} + +impl CaptureSink for DuckdbSink { + fn finalize(self, summary: RecordingIntegrity) -> Result<()> { + // One transaction: `window_end` is the completeness signal, so it must + // never be observable without its loss/reconnect counts. + self.db.execute_batch("BEGIN TRANSACTION")?; + let stamp = (|| -> Result<()> { + self.db + .meta_set("events_dropped", &summary.events_dropped.to_string())?; + self.db + .meta_set("frames_unparsed", &summary.frames_unparsed.to_string())?; + self.db + .meta_set("reconnect_count", &summary.reconnect_count.to_string())?; + self.db.meta_set( + "window_end", + &crate::time::format_instant(summary.window_end), + ) + })(); + if let Err(e) = stamp { + self.db.rollback(); + return Err(e); + } + self.db.execute_batch("COMMIT")?; + // Fold the WAL into the main file so a later reader sees everything. + self.db.execute_batch("CHECKPOINT")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + use crate::frames::capture::RecordingIntegrity; + use crate::frames::duckdb::Db; + + fn meta() -> RecordingDeclaration { + RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec![ + "ticker".into(), + "trades".into(), + "book".into(), + "ohlc".into(), + ], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".to_string(), + } + } + + fn ticker_frame(last: f64) -> ChannelMessage { + let raw = format!( + r#"{{"channel":"ticker","type":"update","data":[{{"symbol":"BTC/USD", + "bid":1.0,"bid_qty":2.0,"ask":3.0,"ask_qty":4.0,"last":{last}, + "volume":5.0,"vwap":6.0,"low":0.5,"high":9.0,"change":-1.0,"change_pct":-2.5, + "timestamp":"2026-01-01T00:00:00.000000Z"}}]}}"# + ); + ChannelMessage::parse(&raw).expect("ticker frame parses") + } + + fn trade_frame(symbol: &str, price: f64, qty: f64, id: u64) -> ChannelMessage { + let raw = format!( + r#"{{"channel":"trade","type":"update","data":[{{"symbol":"{symbol}","side":"buy", + "price":{price},"qty":{qty},"ord_type":"market","trade_id":{id}, + "timestamp":"2026-01-01T00:00:00.000000Z"}}]}}"# + ); + ChannelMessage::parse(&raw).expect("trade frame parses") + } + + fn book_snapshot() -> ChannelMessage { + let raw = r#"{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD", + "bids":[{"price":100.0,"qty":1.0},{"price":99.5,"qty":2.0}], + "asks":[{"price":100.5,"qty":3.0}],"checksum":4242, + "timestamp":"2026-01-01T00:00:00.000000Z"}]}"#; + ChannelMessage::parse(raw).expect("book frame parses") + } + + fn ohlc_frame(timestamp: Option<&str>) -> ChannelMessage { + let timestamp = timestamp + .map(|ts| format!(r#","timestamp":"{ts}""#)) + .unwrap_or_default(); + let raw = format!( + r#"{{"channel":"ohlc","type":"update","data":[{{"symbol":"BTC/USD", + "open":1.0,"high":2.0,"low":0.5,"close":1.5,"vwap":1.2,"trades":7,"volume":123.0, + "interval_begin":"2026-01-01T00:00:00.000000000Z","interval":1{timestamp}}}]}}"# + ); + ChannelMessage::parse(&raw).expect("ohlc frame parses") + } + + fn count(path: &Path, table: &str) -> u64 { + let db = Db::open(path).unwrap(); + db.scalar_u64(&format!("SELECT count(*) FROM {table}")) + .unwrap() + } + + #[test] + fn captures_all_channels_and_reads_back() { + // The core capture-and-read proof: one frame per channel, then reopen the + // file and assert the rows landed with the expected shape and exact decimals. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&[ + ticker_frame(62590.8), + trade_frame("BTC/USD", 50000.1, 0.5, 1), + book_snapshot(), + ohlc_frame(None), + ]) + .unwrap(); + sink.finalize(RecordingIntegrity { + window_end: "2026-01-01T00:01:00Z".parse().unwrap(), + ..RecordingIntegrity::now() + }) + .unwrap(); + + assert_eq!(count(&path, "ticker"), 1); + assert_eq!(count(&path, "trades"), 1); + // 2 bids + 1 ask = 3 book level rows, all sharing one seq. + assert_eq!(count(&path, "book"), 3); + assert_eq!(count(&path, "ohlc"), 1); + + let db = Db::open(&path).unwrap(); + assert_eq!( + db.meta_get("schema_version").unwrap().as_deref(), + Some(schema::SCHEMA_VERSION) + ); + // Decimals stored exactly (no float drift), across ticker and trades. + assert_eq!( + db.scalar_u64("SELECT count(*) FROM ticker WHERE last = 62590.8") + .unwrap(), + 1 + ); + assert_eq!( + db.scalar_u64("SELECT count(*) FROM trades WHERE price = 50000.1") + .unwrap(), + 1 + ); + // The book frame's levels group under a single seq. + assert_eq!( + db.scalar_u64("SELECT count(DISTINCT seq) FROM book") + .unwrap(), + 1 + ); + } + + #[test] + fn creates_only_the_recorded_channel_tables() { + // A session recording one channel must not create empty tables for the others — + // the tape carries only `_meta` plus the channels it actually used. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + let meta = RecordingDeclaration { + channels: vec!["ticker".into()], + ..meta() + }; + + let mut sink = DuckdbSink::open(&path, &meta).unwrap(); + sink.record(&[ticker_frame(1.0)]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let db = Db::open(&path).unwrap(); + let exists = |t: &str| { + db.scalar_u64(&format!( + "SELECT count(*) FROM information_schema.tables WHERE table_name = '{t}'" + )) + .unwrap() + }; + assert_eq!( + exists("ticker"), + 1, + "the recorded channel's table is created" + ); + assert_eq!(exists("_meta"), 1, "_meta is always present"); + assert_eq!(exists("trades"), 0, "an unused channel gets no table"); + assert_eq!(exists("book"), 0, "an unused channel gets no table"); + assert_eq!(exists("ohlc"), 0, "an unused channel gets no table"); + } + + #[test] + fn reopening_to_add_a_channel_creates_the_new_table_and_keeps_the_old() { + // Reopening the same tape to record a *different* channel adds that table while + // leaving the first channel's data intact (CREATE TABLE IF NOT EXISTS). + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + + let mut s1 = DuckdbSink::open( + &path, + &RecordingDeclaration { + channels: vec!["ticker".into()], + ..meta() + }, + ) + .unwrap(); + s1.record(&[ticker_frame(1.0)]).unwrap(); + s1.finalize(RecordingIntegrity::now()).unwrap(); + + let mut s2 = DuckdbSink::open( + &path, + &RecordingDeclaration { + channels: vec!["trade".into()], + ..meta() + }, + ) + .unwrap(); + s2.record(&[trade_frame("BTC/USD", 1.0, 1.0, 1)]).unwrap(); + s2.finalize(RecordingIntegrity::now()).unwrap(); + + assert_eq!(count(&path, "ticker"), 1, "first session's rows survive"); + assert_eq!( + count(&path, "trades"), + 1, + "second session's table was added" + ); + } + + #[test] + fn one_tape_holds_multiple_symbols() { + // All symbols land in one file, distinguished by the `symbol` column. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("majors.duckdb"); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&[ + trade_frame("BTC/USD", 50000.0, 0.5, 1), + trade_frame("ETH/USD", 3000.0, 2.0, 2), + ]) + .unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let db = Db::open(&path).unwrap(); + assert_eq!( + db.scalar_u64("SELECT count(DISTINCT symbol) FROM trades") + .unwrap(), + 2 + ); + } + + #[test] + fn reopen_appends_without_loss() { + // AC#2: a tape reopens and appends; no rows lost across sessions. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + + let mut s1 = DuckdbSink::open(&path, &meta()).unwrap(); + s1.record(&[ + trade_frame("BTC/USD", 1.0, 1.0, 1), + trade_frame("BTC/USD", 2.0, 1.0, 2), + ]) + .unwrap(); + s1.finalize(RecordingIntegrity::now()).unwrap(); + + let mut s2 = DuckdbSink::open(&path, &meta()).unwrap(); + s2.record(&[trade_frame("BTC/USD", 3.0, 1.0, 3)]).unwrap(); + s2.finalize(RecordingIntegrity::now()).unwrap(); + + assert_eq!(count(&path, "trades"), 3); + } + + #[test] + fn reopen_continues_seq_so_frames_stay_distinct() { + // Reopening must not reset `seq`: a book frame written in a second session + // gets a fresh seq, so grouping by seq never merges it with an earlier + // session's frame (the read contract). Without the resume, both + // frames would share seq=1. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + + let mut s1 = DuckdbSink::open(&path, &meta()).unwrap(); + s1.record(&[book_snapshot()]).unwrap(); + s1.finalize(RecordingIntegrity::now()).unwrap(); + + let mut s2 = DuckdbSink::open(&path, &meta()).unwrap(); + s2.record(&[book_snapshot()]).unwrap(); + s2.finalize(RecordingIntegrity::now()).unwrap(); + + let db = Db::open(&path).unwrap(); + assert_eq!( + db.scalar_u64("SELECT count(DISTINCT seq) FROM book") + .unwrap(), + 2, + "each session's frame must get its own seq" + ); + } + + #[test] + fn frame_for_an_undeclared_channel_is_skipped_not_fatal() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + let ticker_only = RecordingDeclaration { + channels: vec!["ticker".into()], + ..meta() + }; + + let mut sink = DuckdbSink::open(&path, &ticker_only).unwrap(); + sink.record(&[trade_frame("BTC/USD", 1.0, 1.0, 1), ticker_frame(2.0)]) + .unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + assert_eq!(count(&path, "ticker"), 1, "declared channel lands"); + let db = Db::open(&path).unwrap(); + assert_eq!( + db.scalar_u64( + "SELECT count(*) FROM information_schema.tables WHERE table_name = 'trades'" + ) + .unwrap(), + 0, + "the undeclared channel's table is never created" + ); + } + + #[test] + fn empty_unstamped_file_is_rescued_as_new() { + // The crash artifact: Db::open created the physical file, then the + // process died before the bootstrap transaction committed. An empty + // database holds no recorded data, so reopening bootstraps it. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + drop(Db::open(&path).unwrap()); // file exists, zero tables, no stamp + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame("BTC/USD", 1.0, 1.0, 1)]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + assert_eq!(count(&path, "trades"), 1); + let db = Db::open(&path).unwrap(); + assert_eq!( + db.meta_get("schema_version").unwrap().as_deref(), + Some(schema::SCHEMA_VERSION) + ); + } + + #[test] + fn unstamped_file_with_tables_stays_refused() { + // Only the empty crash artifact is rescued: a file that holds tables + // could hold data this build knows nothing about. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + { + let db = Db::open(&path).unwrap(); + db.ensure_schema(&["CREATE TABLE IF NOT EXISTS foreign_data (x INTEGER)"]) + .unwrap(); + } + + let err = match DuckdbSink::open(&path, &meta()) { + Ok(_) => panic!("an unstamped file with tables must be refused"), + Err(e) => e, + }; + assert!(matches!(err, Error::Rejected(_))); + assert!(err.to_string().contains("not a kraken tape"), "got: {err}"); + } + + #[test] + fn ohlc_table_from_before_the_timestamp_column_is_migrated_on_open() { + // Appending to a same-stamp tape written before the ohlc + // `timestamp` column existed must not fail mid-capture: open migrates + // the table (ADD COLUMN IF NOT EXISTS) before any frame binds to it. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + let sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + Db::open(&path) + .unwrap() + .execute_batch("ALTER TABLE ohlc DROP COLUMN timestamp") + .unwrap(); + + let candle = ohlc_frame(Some("2026-01-01T00:00:04.000000Z")); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(std::slice::from_ref(&candle)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + assert_eq!(count(&path, "ohlc"), 1); + } + + #[test] + fn frames_that_persist_no_rows_do_not_advance_seq() { + // A spec-less status frame writes nothing; burning a seq for it would + // desynchronize numbering from the JSONL backend and make `last_seq` + // count phantom frames. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + let status = ChannelMessage::parse( + r#"{"channel":"status","type":"update","data":[{"system":"online", + "api_version":"v2","connection_id":1,"version":"2.0"}]}"#, + ) + .unwrap(); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&[status, trade_frame("BTC/USD", 1.0, 1.0, 1)]) + .unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let db = Db::open(&path).unwrap(); + assert_eq!(db.meta_get("last_seq").unwrap().as_deref(), Some("1")); + assert_eq!( + db.scalar_u64("SELECT count(*) FROM trades WHERE seq = 1") + .unwrap(), + 1, + "the persisting frame takes the first seq" + ); + } + + #[test] + fn newer_minor_tape_appends_without_downgrading_the_stamp() { + // Same MAJOR, newer MINOR: this build may append, and the stamp keeps + // naming the newer vocabulary — the JSONL sidecar twin of + // finalize_keeps_the_first_runs_version_stamp. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + { + let mut s = DuckdbSink::open(&path, &meta()).unwrap(); + s.record(&[trade_frame("BTC/USD", 1.0, 1.0, 1)]).unwrap(); + s.finalize(RecordingIntegrity::now()).unwrap(); + } + { + let db = Db::open(&path).unwrap(); + db.meta_set("schema_version", "1.7").unwrap(); + } + + let mut sink = DuckdbSink::open(&path, &meta()).expect("newer minor appends"); + sink.record(&[trade_frame("BTC/USD", 2.0, 1.0, 2)]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + assert_eq!(count(&path, "trades"), 2); + let db = Db::open(&path).unwrap(); + assert_eq!( + db.meta_get("schema_version").unwrap().as_deref(), + Some("1.7") + ); + } + + #[test] + fn incompatible_schema_is_reported_not_mishandled() { + // AC#2: an incompatible on-disk schema version is a clean validation error. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + + { + let mut s = DuckdbSink::open(&path, &meta()).unwrap(); + s.record(&[trade_frame("BTC/USD", 1.0, 1.0, 1)]).unwrap(); + s.finalize(RecordingIntegrity::now()).unwrap(); + } + // Simulate a future, breaking (different MAJOR) schema version on disk. + { + let db = Db::open(&path).unwrap(); + db.meta_set("schema_version", "2.0").unwrap(); + } + + // `DuckdbSink` holds a non-Debug connection, so match rather than unwrap_err. + let err = match DuckdbSink::open(&path, &meta()) { + Ok(_) => panic!("incompatible schema must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Rejected(_))); + assert!(err.to_string().contains("incompatible")); + } + + #[test] + fn corrupt_last_seq_is_rejected_not_silently_reset() { + // A present-but-unparseable last_seq means a corrupt tape; reopening must + // refuse rather than reset to 0 and collide new rows with the existing ones. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + { + let mut s = DuckdbSink::open(&path, &meta()).unwrap(); + s.record(&[trade_frame("BTC/USD", 1.0, 1.0, 1)]).unwrap(); + s.finalize(RecordingIntegrity::now()).unwrap(); + } + { + let db = Db::open(&path).unwrap(); + db.meta_set("last_seq", "not-a-number").unwrap(); + } + + let err = match DuckdbSink::open(&path, &meta()) { + Ok(_) => panic!("corrupt last_seq must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_))); + } + + #[test] + fn finalize_persists_loss_and_reconnect_metadata() { + // A session that dropped events or reconnected stamps those counts into `_meta` + // so a reader can tell the tape has holes / snapshot resets. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame("BTC/USD", 1.0, 1.0, 1)]).unwrap(); + sink.finalize(RecordingIntegrity { + window_end: "2026-01-01T00:01:00Z".parse().unwrap(), + events_dropped: 7, + frames_unparsed: 3, + reconnect_count: 2, + }) + .unwrap(); + + let db = Db::open(&path).unwrap(); + assert_eq!(db.meta_get("events_dropped").unwrap().as_deref(), Some("7")); + assert_eq!( + db.meta_get("frames_unparsed").unwrap().as_deref(), + Some("3") + ); + assert_eq!( + db.meta_get("reconnect_count").unwrap().as_deref(), + Some("2") + ); + } + + #[test] + fn large_trade_id_round_trips_through_ubigint() { + // A trade_id beyond i64::MAX would wrap to negative in a signed column; the + // UBIGINT column stores it losslessly. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.duckdb"); + let big = u64::MAX - 1; + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame("BTC/USD", 1.0, 1.0, big)]) + .unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let db = Db::open(&path).unwrap(); + assert_eq!( + db.scalar_u64(&format!( + "SELECT count(*) FROM trades WHERE trade_id = {big}" + )) + .unwrap(), + 1 + ); + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/source.rs b/crates/kraken-recording/src/frames/duckdb/source.rs new file mode 100644 index 0000000..3fac150 --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/source.rs @@ -0,0 +1,854 @@ +//! The DuckDB market-frame source — read pendant of +//! [`DuckdbSink`](crate::frames::duckdb::DuckdbSink). Yields the recorded +//! frames ordered by the writer's own `seq`, each channel table +//! read back through its [`Recordable::frames`] inverse. + +use std::path::Path; + +use kraken_core::subscribe::message::{BookData, OhlcData, TickerData, TradeData}; + +use crate::error::{Error, Result}; +use crate::frames::capture::{RecordingDeclaration, RecordingIntegrity, RecordingManifest}; +use crate::frames::duckdb::Db; +use crate::frames::duckdb::table::{DriftPolicy, Recordable}; +use crate::frames::{ + CaptureSource, MarketEvent, clamp_non_decreasing, event_instant, shared_capture_lock, +}; +use crate::lock::FileLock; +use crate::schema; +use crate::source::Source; + +pub struct DuckdbSource { + db: Db, + /// Decided once at open from the recording stamp: a newer-minor writer's + /// unknown vocabulary is skipped, anything else fails loud — the same + /// rule as the JSONL source's drift gate. + drift: DriftPolicy, + /// Held shared for the source's lifetime: a starting recorder gets the + /// lock's clean "in use" rejection instead of the engine's raw error, + /// other readers coexist, and the exclusive-only recorder-liveness probe + /// never mistakes a long read for a live recorder. + _lock: FileLock, +} + +impl DuckdbSource { + /// Rejects up front — before any decode — when the file is missing, held + /// by a live writer, not a kraken recording, or a different schema MAJOR. + pub fn open(path: &Path) -> Result { + if !path.exists() { + return Err(Error::Rejected(format!( + "tape '{}' not found", + path.display() + ))); + } + // Acquiring — not probing — closes the check-then-read gap, and the + // guard must exist before the engine opens so a racing writer is + // rejected cleanly here rather than by DuckDB's raw engine error. + let lock = shared_capture_lock(path)?; + let db = Db::open_read_only(path)?; + let stamp = crate::frames::duckdb::ensure_recording_stamp(&db, "read")?; + let drift = if schema::is_newer_minor(&stamp) { + DriftPolicy::TolerateNewerVocabulary + } else { + DriftPolicy::FailLoud + }; + Ok(Self { + db, + drift, + _lock: lock, + }) + } + + /// Frames this tape holds, from the persisted `_meta.last_seq` high-water + /// mark (the monotonic per-frame id). `None` on a tape written before the + /// stamp existed. + pub fn frame_count(&self) -> Result> { + Ok(self + .db + .meta_get("last_seq")? + .and_then(|value| value.parse::().ok())) + } + + /// Read one channel's frames back as market events; a channel this + /// tape never recorded has no table and reads back empty. The merge + /// instant is [`event_instant`] — the same rule as the JSONL backend, so + /// the two yield one `at` sequence for one capture. + fn collect(&self, events: &mut Vec) -> Result<()> { + let conn = self.db.conn(); + for stored in M::frames(conn, self.drift)? { + let Some(at) = event_instant(&stored.frame) else { + return Err(Error::Damaged(format!( + "damaged recording: no parseable event timestamp in table {} at seq {}", + M::table().name, + stored.seq + ))); + }; + events.push(MarketEvent { + at, + seq: stored.seq, + frame: stored.frame, + }); + } + Ok(()) + } +} + +impl Source for DuckdbSource { + type Item = MarketEvent; + + /// All recorded frames in write order: the writer's `seq` is monotonic + /// per frame across channels, so one sort by it restores capture order. + /// `event_ts` is not monotone in that order (OHLC can report + /// `interval_begin`), so the merge instant is then clamped non-decreasing + /// — the composer's `(at, track, seq)` sort must not reorder replay + /// against capture (the JSONL backend clamps identically). + fn read(&self) -> Result> { + // One collect per recordable channel (`capture::RECORDABLE_CHANNELS`); + // the generic parameter is compile-time dispatch a value list can't + // provide. + let mut events = Vec::new(); + self.collect::(&mut events)?; + self.collect::(&mut events)?; + self.collect::(&mut events)?; + self.collect::(&mut events)?; + events.sort_by_key(|event| event.seq); + clamp_non_decreasing(&mut events); + Ok(events.into_iter()) + } +} + +impl CaptureSource for DuckdbSource { + /// The report rebuilt from `_meta`. Reopen clears the summary keys and + /// finalize stamps them in one transaction, so any missing count next to + /// a `window_end` is damage. + fn capture(&self) -> Result { + let text = |key: &str| -> Result { + self.db + .meta_get(key)? + .ok_or_else(|| Error::Damaged(format!("damaged recording: missing _meta {key}"))) + }; + let count = |key: &str| -> Result { + let raw = text(key)?; + raw.parse().map_err(|_| { + Error::Damaged(format!("damaged recording: unreadable _meta {key} '{raw}'")) + }) + }; + let list = |key: &str| -> Result> { + Ok(text(key)? + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect()) + }; + let instant = |key: &str, raw: &str| { + crate::time::parse_instant(raw).ok_or_else(|| { + Error::Damaged(format!("damaged recording: unreadable _meta {key} '{raw}'")) + }) + }; + let summary = match self.db.meta_get("window_end")? { + None => None, + Some(raw) => Some(RecordingIntegrity { + window_end: instant("window_end", &raw)?, + events_dropped: count("events_dropped")?, + frames_unparsed: count("frames_unparsed")?, + reconnect_count: count("reconnect_count")?, + }), + }; + Ok(RecordingManifest { + schema_version: text("schema_version")?, + meta: RecordingDeclaration { + source: text("source")?, + symbols: list("symbols")?, + channels: list("channels")?, + window_start: instant("window_start", &text("window_start")?)?, + cli_version: text("cli_version")?, + }, + summary, + }) + } +} + +#[cfg(test)] +mod tests { + use kraken_core::{ChannelData, ChannelMessage}; + + use super::*; + use crate::frames::CaptureSink; + use crate::frames::duckdb::DuckdbSink; + use crate::frames::jsonl::{TapeSink, TapeSource}; + use crate::sink::Sink; + + fn meta() -> RecordingDeclaration { + RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into()], + channels: vec![ + "ticker".into(), + "trade".into(), + "book".into(), + "ohlc".into(), + ], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + } + } + + /// One frame per channel, including the shapes that stress the inverse: + /// a multi-entry trade frame and a book snapshot with both sides. + fn frames() -> Vec { + [ + r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD", + "bid":1.0,"bid_qty":2.0,"ask":3.0,"ask_qty":4.0,"last":62590.8, + "volume":5.0,"vwap":6.0,"low":0.5,"high":9.0,"change":-1.0,"change_pct":-2.5, + "timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + r#"{"channel":"trade","type":"update","data":[ + {"symbol":"BTC/USD","side":"buy","price":50000.1,"qty":0.5,"ord_type":"market", + "trade_id":1,"timestamp":"2026-01-01T00:00:02.000000Z"}, + {"symbol":"BTC/USD","side":"sell","price":50000.2,"qty":0.25,"ord_type":"limit", + "trade_id":2,"timestamp":"2026-01-01T00:00:02.500000Z"}]}"#, + r#"{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD", + "bids":[{"price":100.0,"qty":1.0},{"price":99.5,"qty":2.0}], + "asks":[{"price":100.5,"qty":3.0}],"checksum":4242, + "timestamp":"2026-01-01T00:00:03.000000Z"}]}"#, + r#"{"channel":"ohlc","type":"update","data":[{"symbol":"BTC/USD", + "open":1.0,"high":2.0,"low":0.5,"close":1.5,"vwap":1.2,"trades":7,"volume":123.0, + "interval_begin":"2026-01-01T00:00:00.000000000Z","interval":1, + "timestamp":"2026-01-01T00:00:04.000000Z"}]}"#, + ] + .iter() + .map(|raw| ChannelMessage::parse(raw).expect("fixture frame parses")) + .collect() + } + + /// One OHLC candle; the deprecated wire `timestamp` is the fixture's + /// variable, so its round-trip cases share one body. + fn candle(timestamp: Option<&str>) -> ChannelMessage { + let timestamp = timestamp + .map(|ts| format!(r#","timestamp":"{ts}""#)) + .unwrap_or_default(); + let raw = format!( + r#"{{"channel":"ohlc","type":"update","data":[{{"symbol":"BTC/USD", + "open":1.0,"high":2.0,"low":0.5,"close":1.5,"vwap":1.2,"trades":7,"volume":123.0, + "interval_begin":"2026-01-01T00:00:00.000000000Z","interval":1{timestamp}}}]}}"# + ); + ChannelMessage::parse(&raw).expect("candle parses") + } + + fn market_payloads(events: &[MarketEvent]) -> Vec { + events.iter().map(|e| e.frame.clone()).collect() + } + + #[test] + fn reads_back_what_the_sink_wrote_in_write_order() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + // Two batches of the same channels: adjacent same-channel frames with + // distinct seqs must regroup as separate frames, not merge. + let written: Vec<_> = [frames(), frames()].concat(); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&written).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let source = DuckdbSource::open(&path).unwrap(); + let events: Vec<_> = source.read().unwrap().collect(); + assert_eq!(market_payloads(&events), written); + assert!( + events + .windows(2) + .all(|w| (w[0].at, w[0].seq) < (w[1].at, w[1].seq)), + "recv_ts/seq order is strictly increasing" + ); + } + + #[test] + fn both_backends_yield_the_same_event_sequence() { + // One capture, two backends, one read-back contract: the same + // payloads under the same merge instants (`at` is event_ts in both) + // and the same frame numbering (`seq`). + let dir = tempfile::tempdir().unwrap(); + let duck_path = dir.path().join("market.duckdb"); + let jsonl_path = dir.path().join("market.jsonl"); + let status = ChannelMessage::parse( + r#"{"channel":"status","type":"update","data":[{"system":"online", + "api_version":"v2","connection_id":1,"version":"2.0"}]}"#, + ) + .unwrap(); + // The status frame persists in neither backend and must shift the + // numbering in neither. + let written: Vec<_> = [vec![status], frames()].concat(); + + let mut duck = DuckdbSink::open(&duck_path, &meta()).unwrap(); + duck.record(&written).unwrap(); + duck.finalize(RecordingIntegrity::now()).unwrap(); + let mut jsonl = TapeSink::open(&jsonl_path, &meta()).unwrap(); + jsonl.record(&written).unwrap(); + jsonl.finalize(RecordingIntegrity::now()).unwrap(); + + let from_duck: Vec<_> = DuckdbSource::open(&duck_path) + .unwrap() + .read() + .unwrap() + .collect(); + let from_jsonl: Vec<_> = TapeSource::open(&jsonl_path) + .expect("tape exists") + .read() + .unwrap() + .collect(); + assert_eq!(market_payloads(&from_duck), market_payloads(&from_jsonl)); + assert_eq!( + from_duck.iter().map(|e| e.at).collect::>(), + from_jsonl.iter().map(|e| e.at).collect::>(), + "one merge-instant sequence regardless of storage backend" + ); + assert_eq!( + from_duck.iter().map(|e| e.seq).collect::>(), + from_jsonl.iter().map(|e| e.seq).collect::>(), + "one frame numbering regardless of storage backend" + ); + } + + #[test] + fn capture_reports_the_same_stamp_as_the_sidecar_model() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let summary = RecordingIntegrity { + window_end: "2026-01-01T00:01:00Z".parse().unwrap(), + events_dropped: 7, + frames_unparsed: 3, + reconnect_count: 2, + }; + let sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.finalize(summary.clone()).unwrap(); + + let report = DuckdbSource::open(&path).unwrap().capture().unwrap(); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION); + assert_eq!(report.meta, meta()); + assert_eq!(report.summary, Some(summary)); + } + + #[test] + fn unfinalized_capture_reads_with_no_summary() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + drop(DuckdbSink::open(&path, &meta()).unwrap()); // crash: no finalize + + let report = DuckdbSource::open(&path).unwrap().capture().unwrap(); + assert_eq!(report.summary, None, "unfinalized: completeness unknown"); + assert_eq!(report.meta, meta(), "identity is stamped at open"); + } + + #[test] + fn reopened_then_crashed_capture_reads_as_unfinalized() { + // Reopen clears the stale finalize stamp — the old session's summary + // would describe frames it knows nothing about. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let mut first = DuckdbSink::open(&path, &meta()).unwrap(); + first.record(&frames()).unwrap(); + first + .finalize(RecordingIntegrity { + window_end: "2026-01-01T00:01:00Z".parse().unwrap(), + ..RecordingIntegrity::now() + }) + .unwrap(); + + let mut second = DuckdbSink::open(&path, &meta()).unwrap(); + second.record(&frames()).unwrap(); + drop(second); // crash: no finalize + + let report = DuckdbSource::open(&path).unwrap().capture().unwrap(); + assert_eq!(report.summary, None, "stale finalize stamp cleared"); + } + + #[test] + fn resumed_capture_reads_all_runs_frames_with_the_last_summary() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let first_run = frames(); + let mut first = DuckdbSink::open(&path, &meta()).unwrap(); + first.record(&first_run).unwrap(); + first + .finalize(RecordingIntegrity { + window_end: "2026-01-01T00:01:00Z".parse().unwrap(), + events_dropped: 1, + ..RecordingIntegrity::now() + }) + .unwrap(); + + let second_run = frames(); + let mut second = DuckdbSink::open(&path, &meta()).unwrap(); + second.record(&second_run).unwrap(); + let last_summary = RecordingIntegrity { + window_end: "2026-01-01T00:02:00Z".parse().unwrap(), + events_dropped: 9, + frames_unparsed: 4, + reconnect_count: 2, + }; + second.finalize(last_summary.clone()).unwrap(); + + let source = DuckdbSource::open(&path).unwrap(); + let events: Vec<_> = source.read().unwrap().collect(); + assert_eq!( + market_payloads(&events), + [first_run, second_run].concat(), + "frames from all sessions, in write order" + ); + assert!( + events + .windows(2) + .all(|w| (w[0].at, w[0].seq) < (w[1].at, w[1].seq)) + ); + assert_eq!(source.capture().unwrap().summary, Some(last_summary)); + } + + #[test] + fn reopen_with_new_channels_unions_the_reported_lists() { + // The report must describe every frame the file holds, not just the + // last session's subscription. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let ticker_only = RecordingDeclaration { + channels: vec!["ticker".into()], + ..meta() + }; + let mut first = DuckdbSink::open(&path, &ticker_only).unwrap(); + first + .record(&[ChannelMessage::parse( + r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD", + "bid":1.0,"bid_qty":2.0,"ask":3.0,"ask_qty":4.0,"last":5.0, + "volume":5.0,"vwap":6.0,"low":0.5,"high":9.0,"change":-1.0,"change_pct":-2.5, + "timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + ) + .unwrap()]) + .unwrap(); + first.finalize(RecordingIntegrity::now()).unwrap(); + + let trade_only = RecordingDeclaration { + symbols: vec!["ETH/USD".into()], + channels: vec!["trade".into()], + ..meta() + }; + let mut second = DuckdbSink::open(&path, &trade_only).unwrap(); + second + .record(&[ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"ETH/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":1, + "timestamp":"2026-01-01T00:00:02.000000Z"}]}"#, + ) + .unwrap()]) + .unwrap(); + second.finalize(RecordingIntegrity::now()).unwrap(); + + let report = DuckdbSource::open(&path).unwrap().capture().unwrap(); + assert_eq!(report.meta.channels, ["ticker", "trade"]); + assert_eq!(report.meta.symbols, ["BTC/USD", "ETH/USD"]); + } + + #[test] + fn ohlc_without_the_deprecated_timestamp_round_trips() { + // Must read back as `None`, not a phantom `timestamp` the wire never sent. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let written = candle(None); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(std::slice::from_ref(&written)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let events: Vec<_> = DuckdbSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!(market_payloads(&events), [written]); + } + + #[test] + fn ohlc_tape_from_before_the_timestamp_column_reads_back() { + // A reader never migrates: a same-stamp tape written before the + // ohlc `timestamp` column existed must read back with the field + // absent, not fail with a binder error after the version gate + // approved the file. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&frames()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + crate::frames::duckdb::Db::open(&path) + .unwrap() + .execute_batch("ALTER TABLE ohlc DROP COLUMN timestamp") + .unwrap(); + + let events: Vec<_> = DuckdbSource::open(&path).unwrap().read().unwrap().collect(); + let ohlc = events + .iter() + .find_map(|e| match &e.frame.body { + ChannelData::Ohlc(entries) => Some(&entries[0]), + _ => None, + }) + .expect("the ohlc frame reads back"); + assert_eq!( + ohlc.timestamp, None, + "the missing column is the absent wire field" + ); + } + + #[test] + fn ohlc_timestamp_equal_to_interval_begin_round_trips_byte_identical() { + // The deprecated wire timestamp is stored verbatim: reconstructing it + // by comparison against interval_begin would read this frame back + // without it, byte-different from the capture. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let written = candle(Some("2026-01-01T00:00:00.000000000Z")); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(std::slice::from_ref(&written)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let read = market_payloads( + &DuckdbSource::open(&path) + .unwrap() + .read() + .unwrap() + .collect::>(), + ); + assert_eq!( + serde_json::to_string(&read[0]).unwrap(), + serde_json::to_string(&written).unwrap(), + "the wire timestamp must survive even when it equals interval_begin" + ); + } + + #[test] + fn multi_symbol_book_frame_regroups_per_symbol_run() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let written = ChannelMessage::parse( + r#"{"channel":"book","type":"update","data":[ + {"symbol":"BTC/USD","bids":[{"price":100.0,"qty":1.0}], + "asks":[{"price":100.5,"qty":3.0}],"checksum":1, + "timestamp":"2026-01-01T00:00:01.000000Z"}, + {"symbol":"ETH/USD","bids":[{"price":10.0,"qty":2.0}], + "asks":[{"price":10.5,"qty":4.0}],"checksum":2, + "timestamp":"2026-01-01T00:00:01.500000Z"}]}"#, + ) + .unwrap(); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(std::slice::from_ref(&written)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let events: Vec<_> = DuckdbSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!(market_payloads(&events), [written]); + } + + #[test] + fn high_precision_decimal_round_trips_bit_identical() { + // Pins read_decimal's exactness: 17 significant digits survive the + // DECIMAL(38,18)->VARCHAR->parse path; a DOUBLE cast double-rounds 1 ulp. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let written = ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":0.30000000000000004,"qty":0.1,"ord_type":"market","trade_id":1, + "timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + ) + .unwrap(); + + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(std::slice::from_ref(&written)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let events: Vec<_> = DuckdbSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!(market_payloads(&events), [written]); + } + + #[test] + fn half_stamped_meta_is_damage_not_unfinalized() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + { + let db = Db::open(&path).unwrap(); + db.execute_batch("DELETE FROM _meta WHERE key = 'events_dropped'") + .unwrap(); + } + + let err = DuckdbSource::open(&path).unwrap().capture().unwrap_err(); + assert!(matches!(err, Error::Damaged(_))); + } + + #[test] + fn missing_tape_and_foreign_file_are_validation_errors() { + // `DuckdbSource` holds a non-Debug duckdb connection, so match rather + // than unwrap_err. + let dir = tempfile::tempdir().unwrap(); + match DuckdbSource::open(&dir.path().join("absent.duckdb")) { + Ok(_) => panic!("a missing tape must be rejected"), + Err(e) => assert!(matches!(e, Error::Rejected(_))), + } + + let foreign = dir.path().join("foreign.duckdb"); + { + let db = Db::open(&foreign).unwrap(); + db.ensure_schema(&[schema::META_DDL]).unwrap(); + } + match DuckdbSource::open(&foreign) { + Ok(_) => panic!("a file without schema metadata must be rejected"), + Err(e) => assert!(matches!(e, Error::Rejected(_))), + } + } + + #[test] + fn read_back_frames_serialize_byte_identical_to_the_written_ones() { + // The replay NDJSON contract. DuckDB renders DECIMAL(38,18) at full + // scale on read ("13588.200000000000000000"); un-normalized, that + // scale-18 Decimal double-rounds through serde-float and 13588.2 + // replays as 13588.199999999999 — numerically equal (so frame + // equality can't catch it), byte-different on every consumer surface. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let written = ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":13588.2,"qty":0.5,"ord_type":"market","trade_id":1, + "timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + ) + .expect("trade frame parses"); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(std::slice::from_ref(&written)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let read = market_payloads( + &DuckdbSource::open(&path) + .unwrap() + .read() + .unwrap() + .collect::>(), + ); + assert_eq!( + serde_json::to_string(&read[0]).unwrap(), + serde_json::to_string(&written).unwrap(), + "a replayed frame must serialize exactly like the captured one" + ); + } + + #[test] + fn different_major_tape_is_rejected_cleanly() { + // Breaking bump, no migration path: rejection, not a decode attempt. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + { + let db = Db::open(&path).unwrap(); + db.ensure_schema(&[schema::META_DDL]).unwrap(); + db.meta_set("schema_version", "2.0").unwrap(); + } + match DuckdbSource::open(&path) { + Ok(_) => panic!("a different-major tape must be rejected"), + Err(e) => { + assert!(matches!(e, Error::Rejected(_))); + assert!(e.to_string().contains("incompatible"), "got: {e}"); + } + } + } + + #[test] + fn unknown_vocabulary_row_in_current_schema_fails_loud() { + // A corrupted current-schema recording (unknown enum value) must fail + // the read, not silently skip the row: under this build's own stamp + // there is no newer writer whose vocabulary it could be. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&frames()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + { + let db = Db::open(&path).unwrap(); + db.execute_batch("UPDATE trades SET side = 'hedge' WHERE trade_id = 1") + .unwrap(); + } + + // The Ok side (an iterator) isn't Debug, so match rather than unwrap_err. + let err = match DuckdbSource::open(&path).unwrap().read() { + Ok(_) => panic!("a damaged recording must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_)), "got: {err}"); + } + + #[test] + fn unknown_vocabulary_under_a_newer_minor_stamp_skips_the_whole_frame() { + // The JSONL drift twin: a 1.7 writer's unknown values poison their + // *frames* — both entries of the two-entry trade frame go, partial + // frames never surface — while every other frame reads back intact. + // Two distinct Vocabulary producers fire (trade side, book side), so + // the policy is pinned per producer, not just per table. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let written = frames(); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&written).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + { + let db = Db::open(&path).unwrap(); + db.execute_batch( + "UPDATE trades SET side = 'hedge' WHERE trade_id = 1; \ + UPDATE book SET side = 'mid' WHERE price = 100.0", + ) + .unwrap(); + db.meta_set("schema_version", "1.7").unwrap(); + } + + let source = DuckdbSource::open(&path).expect("newer minor is compatible"); + let events: Vec<_> = source.read().unwrap().collect(); + assert_eq!( + market_payloads(&events), + [written[0].clone(), written[3].clone()], + "both poisoned frames are skipped whole, the rest survive" + ); + assert_eq!( + events.iter().map(|e| e.seq).collect::>(), + [1, 4], + "surviving frames keep their original seqs" + ); + } + + #[test] + fn tolerated_vocabulary_retroactively_removes_the_frames_earlier_rows() { + // The skip must poison rows decoded *before* the unknown value too: + // trade_id 2 is the frame's second row, so its sibling was already + // collected when the poison fires — a row-local skip would leak it + // back as a partial frame. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let written = frames(); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&written).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + { + let db = Db::open(&path).unwrap(); + db.execute_batch("UPDATE trades SET side = 'hedge' WHERE trade_id = 2") + .unwrap(); + db.meta_set("schema_version", "1.7").unwrap(); + } + + let events: Vec<_> = DuckdbSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!( + market_payloads(&events), + [written[0].clone(), written[2].clone(), written[3].clone()], + "the already-decoded first entry leaves with its frame" + ); + } + + #[test] + fn damaged_row_fails_loud_not_skipped() { + // Same-MAJOR structural drift (a retyped column) is damage, not newer + // vocabulary: the read must fail rather than return a shorter stream. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&frames()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + { + let db = Db::open(&path).unwrap(); + db.execute_batch( + "ALTER TABLE trades ALTER COLUMN price SET DATA TYPE VARCHAR; \ + UPDATE trades SET price = 'oops'", + ) + .unwrap(); + } + + match DuckdbSource::open(&path).unwrap().read() { + Ok(_) => panic!("a damaged row must fail the read"), + Err(e) => { + assert!(matches!(e, Error::Damaged(_)), "got: {e}"); + assert!(e.to_string().contains("damaged"), "got: {e}"); + } + } + } + + #[test] + fn backward_event_ts_is_clamped_to_capture_order() { + // event_ts is not monotone in capture order: a candle without the + // deprecated wire timestamp reports interval_begin, up to a whole + // interval in the past. The clamp must keep the composer's (at, + // track, seq) sort from reordering replay against capture order. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let late_candle = candle(None); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&[frames(), vec![late_candle]].concat()) + .unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let events: Vec<_> = DuckdbSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!(events.len(), 5); + assert!( + events.windows(2).all(|w| w[0].seq < w[1].seq), + "read order is capture order" + ); + assert!( + events + .windows(2) + .all(|w| (w[0].at, w[0].seq) <= (w[1].at, w[1].seq)), + "the merge key must preserve capture order, got: {:?}", + events.iter().map(|e| (e.seq, e.at)).collect::>() + ); + assert_eq!( + events[4].at, events[3].at, + "the candle's interval_begin clamps up to the previous frame's instant" + ); + } + + #[test] + fn open_is_rejected_while_a_recorder_is_live() { + // The shared guard, not the engine, must reject: the advisory lock + // fires before open_read_only, so the raw engine error never leaks. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let _recorder = DuckdbSink::open(&path, &meta()).unwrap(); + + match DuckdbSource::open(&path) { + Ok(_) => panic!("a live recorder must reject the read"), + Err(e) => { + assert!(matches!(e, Error::Rejected(_)), "got: {e}"); + assert!(e.to_string().contains("in use"), "got: {e}"); + } + } + } + + #[test] + fn live_reader_rejects_a_starting_recorder() { + // The reverse direction: a running read holds the lock shared, so a + // recorder starting mid-read fails its exclusive acquire cleanly + // instead of dying on the engine's file lock. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&frames()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + let _reader = DuckdbSource::open(&path).unwrap(); + + match DuckdbSink::open(&path, &meta()) { + Ok(_) => panic!("a live reader must reject a starting recorder"), + Err(e) => { + assert!(matches!(e, Error::Rejected(_)), "got: {e}"); + assert!(e.to_string().contains("in use"), "got: {e}"); + } + } + } + + #[test] + fn concurrent_readers_do_not_exclude_each_other() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.duckdb"); + let mut sink = DuckdbSink::open(&path, &meta()).unwrap(); + sink.record(&frames()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let first = DuckdbSource::open(&path).unwrap(); + let second = DuckdbSource::open(&path).unwrap(); + assert_eq!( + market_payloads(&first.read().unwrap().collect::>()), + market_payloads(&second.read().unwrap().collect::>()) + ); + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/table.rs b/crates/kraken-recording/src/frames/duckdb/table.rs new file mode 100644 index 0000000..b8c51e6 --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/table.rs @@ -0,0 +1,471 @@ +//! Data-driven recording schema: a table described as data ([`TableSpec`]), with +//! its `CREATE TABLE` and `INSERT` SQL *generated from one column list* so the two +//! can never drift out of sync (the bug class a hand-written DDL/INSERT pair +//! invites). +//! +//! A channel persists itself by implementing [`Recordable`] in its channel submodule +//! of [`crate::frames::duckdb::tables`]: it declares its [`TableSpec`] and maps a frame to +//! [`Row`]s of [`Cell`]s — and back. The DuckDB sink consumes only this trait — it +//! generates SQL from the spec and binds the cells, knowing nothing about any one +//! channel's columns. [`Cell`] carries the storage type so unsigned wire ids land in +//! `UBIGINT`/`UINTEGER` (lossless, unlike a narrowing cast) and decimals bind as +//! their exact string form cast into `DECIMAL(38,18)`. +//! +//! The market-data tables are `NOT NULL` except where the wire field itself +//! is optional (OHLC's deprecated `timestamp`), declared via [`Column::opt`]. + +use std::collections::HashSet; +use std::sync::OnceLock; + +use duckdb::types::{ToSql, ToSqlOutput, Value, ValueRef}; +use duckdb::{Connection, params}; +use kraken_core::ChannelMessage; +use kraken_core::subscribe::message::MessageType; +use rust_decimal::Decimal; + +use crate::error::{Error, Result}; + +/// The storage type of a column: its DuckDB column type plus the `INSERT` +/// placeholder it needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ColType { + /// `VARCHAR` — symbols, sides, RFC3339 timestamps (lossless; a reader casts). + Text, + /// `DECIMAL(38,18)` — prices/quantities, fed as an exact decimal string and cast. + Decimal, + /// `BIGINT` — the signed frame `seq`. + Big, + /// `UINTEGER` — small unsigned wire values (`checksum`, `interval`). + UInt, + /// `UBIGINT` — large unsigned wire ids/counts (`trade_id`, `trades`) — stored + /// losslessly rather than narrowed into a signed `BIGINT`. + UBig, +} + +impl ColType { + fn sql_type(self) -> &'static str { + match self { + ColType::Text => "VARCHAR", + ColType::Decimal => "DECIMAL(38,18)", + ColType::Big => "BIGINT", + ColType::UInt => "UINTEGER", + ColType::UBig => "UBIGINT", + } + } + + /// A decimal binds as its exact string form and casts inside the engine, + /// so it lands in the `DECIMAL` column without a float on the way. + fn placeholder(self) -> &'static str { + match self { + ColType::Decimal => "CAST(? AS DECIMAL(38,18))", + _ => "?", + } + } +} + +/// One column of a recording table. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Column { + pub name: &'static str, + pub ty: ColType, + nullable: bool, +} + +impl Column { + /// A required (`NOT NULL`) column. + pub(crate) const fn req(name: &'static str, ty: ColType) -> Self { + Self { + name, + ty, + nullable: false, + } + } + + /// A nullable column, for a wire field that is itself optional. + pub(crate) const fn opt(name: &'static str, ty: ColType) -> Self { + Self { + name, + ty, + nullable: true, + } + } +} + +/// A table described as data: its name and ordered columns. The DDL and the +/// `INSERT` are both generated from this one definition, so they cannot disagree. +#[derive(Debug)] +pub(crate) struct TableSpec { + pub name: &'static str, + pub columns: &'static [Column], + /// Generated once per process: `prepare_cached` keys on this string, so + /// rebuilding it per frame would pay the formatting on every insert. + insert_sql: OnceLock, +} + +impl TableSpec { + pub(crate) const fn new(name: &'static str, columns: &'static [Column]) -> Self { + Self { + name, + columns, + insert_sql: OnceLock::new(), + } + } + + pub(crate) fn create_sql(&self) -> String { + let cols = self + .columns + .iter() + .map(|c| { + let null = if c.nullable { "" } else { " NOT NULL" }; + format!("{} {}{null}", c.name, c.ty.sql_type()) + }) + .collect::>() + .join(", "); + format!("CREATE TABLE IF NOT EXISTS {} ({cols})", self.name) + } + + pub(crate) fn insert_sql(&self) -> &str { + self.insert_sql.get_or_init(|| { + let names = self + .columns + .iter() + .map(|c| c.name) + .collect::>() + .join(", "); + let holes = self + .columns + .iter() + .map(|c| c.ty.placeholder()) + .collect::>() + .join(", "); + format!("INSERT INTO {} ({names}) VALUES ({holes})", self.name) + }) + } + + /// Migrations for columns the spec gained after tapes already existed: + /// `CREATE TABLE IF NOT EXISTS` never alters an existing table, so each + /// nullable column also gets an idempotent `ADD COLUMN IF NOT EXISTS`. + /// Only nullable columns can appear retroactively — existing rows read + /// them back as NULL, which is what the wire's absent field means. + pub(crate) fn add_column_sql(&self) -> impl Iterator + '_ { + self.columns.iter().filter(|c| c.nullable).map(|c| { + format!( + "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}", + self.name, + c.name, + c.ty.sql_type() + ) + }) + } +} + +/// One bound value, tagged by how it stores. A decimal carries the payload's +/// `Decimal`, rendered via `Display` at bind time (exact, non-finite unrepresentable). +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Cell { + Text(String), + Decimal(Decimal), + Big(i64), + UInt(u32), + UBig(u64), + /// An absent optional wire field, bound into a [`Column::opt`] column. + Null, +} + +/// One table row: cells positionally matching a [`TableSpec`]'s columns. +pub(crate) type Row = Vec; + +impl ToSql for Cell { + fn to_sql(&self) -> duckdb::Result> { + let out = match self { + // Borrow text straight from the cell — no clone on the bind path. + Cell::Text(s) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())), + Cell::Big(i) => ToSqlOutput::Owned(Value::BigInt(*i)), + Cell::UInt(u) => ToSqlOutput::Owned(Value::UInt(*u)), + Cell::UBig(u) => ToSqlOutput::Owned(Value::UBigInt(*u)), + // The column is `DECIMAL`; feed the exact decimal string for the cast. + Cell::Decimal(d) => ToSqlOutput::Owned(Value::Text(d.to_string())), + Cell::Null => ToSqlOutput::Owned(Value::Null), + }; + Ok(out) + } +} + +/// A channel payload that persists itself into the recording schema, data-driven: +/// it declares its [`TableSpec`] and maps a frame's entries to [`Row`]s — and back. +/// Implemented on each channel's entry struct in one submodule per channel in +/// [`crate::frames::duckdb::tables`], so adding a channel is a self-contained table module +/// rather than an edit to a central SQL block, and a table's spec, encode, and +/// decode live side by side where they can't drift apart. +pub(crate) trait Recordable: Sized { + /// The table this payload writes (its schema, as data). An associated function + /// so the sink can gather every channel's schema without an instance. + fn table() -> &'static TableSpec; + + /// Expand one frame's entries into rows. The envelope fields a table persists are + /// passed explicitly: `message_type` distinguishes snapshot from update rows, `seq` + /// is the per-frame id shared by every row of the frame (so a multi-row frame stays + /// groupable), and `recv_ts` is the local capture instant. + fn rows(entries: &[Self], message_type: MessageType, seq: i64, recv_ts: &str) -> Vec; + + /// Read every stored frame back in write order, regrouped into whole frames — + /// the inverse of [`rows`](Self::rows). An absent table reads back empty; + /// unknown vocabulary follows `drift` (the recording stamp's verdict). + fn frames(conn: &Connection, drift: DriftPolicy) -> Result>; +} + +/// One frame read back from a channel table: [`Recordable::frames`]'s unit, +/// carrying the write-time frame order the timeline sorts on. +#[derive(Debug)] +pub(crate) struct Stored { + pub seq: i64, + pub frame: ChannelMessage, +} + +/// The read query for a spec: `DECIMAL`s selected as VARCHAR for [`read_decimal`], +/// ordered by `(seq, rowid)` so a frame's rows regroup in insertion order. +/// A nullable column missing from `physical` — a reader never migrates, so a +/// tape written before the column existed still lacks it — is selected as +/// a typed NULL, reading back as the absent wire field it stands for. +pub(crate) fn select_sql(spec: &TableSpec, physical: &HashSet) -> String { + let cols = spec + .columns + .iter() + .map(|c| match (physical.contains(c.name), &c.ty) { + (true, ColType::Decimal) => { + format!("CAST({name} AS VARCHAR) AS {name}", name = c.name) + } + (true, _) => c.name.to_string(), + // Decimals read back through VARCHAR, so their NULL matches. + (false, ColType::Decimal) => format!("CAST(NULL AS VARCHAR) AS {}", c.name), + (false, ty) => format!("CAST(NULL AS {}) AS {}", ty.sql_type(), c.name), + }) + .collect::>() + .join(", "); + format!("SELECT {cols} FROM {} ORDER BY seq, rowid", spec.name) +} + +/// Why one row failed to decode. +#[derive(Debug)] +pub(crate) enum RowError { + /// An enum value this build doesn't recognize (e.g., a newer trade side): + /// a newer same-MAJOR writer's vocabulary — or corruption. Which one is + /// decided by the recording's stamp ([`DriftPolicy`]). + Vocabulary(String), + /// A structural value the current schema says must decode but didn't: + /// corruption under any stamp, always fails the read. + Damage(Error), +} + +/// How a read treats vocabulary this build doesn't know — the DuckDB half of +/// the one drift rule (the JSONL source's `ensure_tolerable_drift`): only a +/// *newer-minor* recording stamp can legitimately carry unknown values, so +/// only there is skipping tolerance rather than swallowed damage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DriftPolicy { + /// Same-or-older stamp: an unknown value is a corrupted row. + FailLoud, + /// Newer-minor stamp: skip the whole frame carrying the unknown value. + TolerateNewerVocabulary, +} + +/// A missing column, type mismatch, or `NULL` is structural drift, never +/// newer vocabulary. +impl From for RowError { + fn from(err: duckdb::Error) -> Self { + RowError::Damage(Error::Engine(err)) + } +} + +/// Parse a `DECIMAL`'s VARCHAR selection ([`select_sql`]) back to the writer's +/// `Decimal` exactly; a DuckDB `DOUBLE` cast would round through a float. +pub(crate) fn read_decimal( + row: &duckdb::Row<'_>, + column: &str, +) -> std::result::Result { + let raw: String = row.get(column)?; + // DuckDB renders the column's full 18-digit scale ("13588.200000000000000000"). + // Plain `from_str`, not `from_str_exact`: a wide integral part can push those + // trailing zeros past Decimal's 96-bit coefficient — the value fits, its + // rendering rounds. `normalize` then strips the scale padding: serde-float + // double-rounds an oversized scale-18 mantissa (13588.2 would re-serialize as + // 13588.199999999999), and wire Decimals arrive through f64, which carries no + // trailing zeros to preserve — so the normalized value is the written one. + raw.parse::().map(|d| d.normalize()).map_err(|_| { + RowError::Damage(Error::Damaged(format!( + "unreadable decimal '{raw}' in column {column}" + ))) + }) +} + +/// Run the spec's read query, decoding each row through `entry`. +/// [`Vocabulary`](RowError::Vocabulary) failures follow `drift`; +/// [`Damage`](RowError::Damage) always fails the read before any consumer +/// sees a partial stream (the [`Source::read`](crate::source::Source::read) +/// contract); query failures propagate. +pub(crate) fn read_rows( + conn: &Connection, + spec: &'static TableSpec, + drift: DriftPolicy, + entry: impl Fn(&duckdb::Row<'_>) -> std::result::Result, +) -> Result> { + let physical = physical_columns(conn, spec.name)?; + if physical.is_empty() { + // No columns means no table: a channel this tape never recorded + // reads back empty (one catalog probe serves existence and shape). + return Ok(Vec::new()); + } + if let Some(missing) = spec + .columns + .iter() + .find(|c| !c.nullable && !physical.contains(c.name)) + { + // A compatible stamp guarantees the required columns; surface the + // impossibility as damage, not a raw engine binder error. + return Err(Error::Damaged(format!( + "recording table '{}' lacks required column '{}'", + spec.name, missing.name + ))); + } + let mut stmt = conn.prepare(&select_sql(spec, &physical))?; + let mut rows = stmt.query([])?; + // Entries carry their frame's seq so a tolerated unknown value can + // poison its *whole* frame — rows of that seq decoded before or after + // the bad one must go with it, or a multi-entry frame would read back + // partially (the JSONL skip drops whole lines, i.e. whole frames). + let mut entries: Vec<(i64, E)> = Vec::new(); + let mut skipped: HashSet = HashSet::new(); + while let Some(row) = rows.next()? { + let seq: i64 = row.get("seq")?; + match entry(row) { + Ok(e) => entries.push((seq, e)), + Err(RowError::Vocabulary(what)) => match drift { + DriftPolicy::TolerateNewerVocabulary => { + if skipped.insert(seq) { + tracing::warn!( + table = spec.name, + seq, + what, + "skipping a newer-minor writer's frame outside this build's vocabulary" + ); + } + } + DriftPolicy::FailLoud => { + return Err(Error::Damaged(format!( + "damaged recording table '{}': undecodable row ({what})", + spec.name + ))); + } + }, + Err(RowError::Damage(err)) => { + return Err(Error::Damaged(format!( + "damaged recording table '{}': {err}", + spec.name + ))); + } + } + } + Ok(entries + .into_iter() + .filter(|(seq, _)| !skipped.contains(seq)) + .map(|(_, e)| e) + .collect()) +} + +pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result { + let mut stmt = + conn.prepare("SELECT count(*) FROM information_schema.tables WHERE table_name = ?")?; + let count: i64 = stmt.query_row(params![table], |row| row.get(0))?; + Ok(count > 0) +} + +/// The columns `table` physically has, for [`select_sql`]'s missing-column +/// tolerance. Scoped to the tape's own catalog and schema, so a same-named +/// table elsewhere can't leak columns in. +fn physical_columns(conn: &Connection, table: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT column_name FROM information_schema.columns \ + WHERE table_catalog = current_database() AND table_schema = 'main' \ + AND table_name = ?", + )?; + let cols = stmt + .query_map(params![table], |row| row.get(0))? + .collect::, _>>()?; + Ok(cols) +} + +#[cfg(test)] +mod tests { + use super::*; + + static SPEC: TableSpec = TableSpec::new( + "demo", + &[ + Column::req("symbol", ColType::Text), + Column::req("price", ColType::Decimal), + Column::req("seq", ColType::Big), + Column::opt("note", ColType::Text), + ], + ); + + #[test] + fn create_sql_renders_types_and_nullability() { + assert_eq!( + SPEC.create_sql(), + "CREATE TABLE IF NOT EXISTS demo (symbol VARCHAR NOT NULL, \ + price DECIMAL(38,18) NOT NULL, seq BIGINT NOT NULL, note VARCHAR)" + ); + } + + #[test] + fn insert_sql_casts_decimals_only() { + assert_eq!( + SPEC.insert_sql(), + "INSERT INTO demo (symbol, price, seq, note) \ + VALUES (?, CAST(? AS DECIMAL(38,18)), ?, ?)" + ); + } + + #[test] + fn insert_placeholder_count_matches_columns() { + // The generated INSERT must bind exactly as many values as the table has + // columns — the property a hand-written DDL/INSERT pair can silently break. + assert_eq!(SPEC.insert_sql().matches('?').count(), SPEC.columns.len()); + } + + fn all_columns() -> HashSet { + SPEC.columns.iter().map(|c| c.name.to_string()).collect() + } + + #[test] + fn select_sql_casts_decimals_to_varchar_in_spec_order() { + assert_eq!( + select_sql(&SPEC, &all_columns()), + "SELECT symbol, CAST(price AS VARCHAR) AS price, seq, note \ + FROM demo ORDER BY seq, rowid" + ); + } + + #[test] + fn select_sql_projects_null_for_a_missing_nullable_column() { + // A reader never migrates: a tape from before `note` existed must + // still read, with the field absent rather than a binder error. + let mut physical = all_columns(); + physical.remove("note"); + assert_eq!( + select_sql(&SPEC, &physical), + "SELECT symbol, CAST(price AS VARCHAR) AS price, seq, \ + CAST(NULL AS VARCHAR) AS note FROM demo ORDER BY seq, rowid" + ); + } + + #[test] + fn add_column_sql_migrates_only_nullable_columns() { + // Required columns existed from the table's birth; only retroactive + // nullable columns need the idempotent ALTER. + assert_eq!( + SPEC.add_column_sql().collect::>(), + ["ALTER TABLE demo ADD COLUMN IF NOT EXISTS note VARCHAR"] + ); + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/tables.rs b/crates/kraken-recording/src/frames/duckdb/tables.rs new file mode 100644 index 0000000..7cafdcd --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/tables.rs @@ -0,0 +1,178 @@ +//! Channel-table dispatch for the recording schema. +//! +//! Each recordable channel declares its [`TableSpec`], row mapping, and read-back +//! by implementing [`Recordable`] on its payload entry type in its own submodule +//! here (one per channel), so a table's spec, encode, and decode live in one file +//! where they can't drift apart — and the wire types stay free of persistence +//! code: `record` depends on `kraken-core`, never the reverse. This module +//! gathers, dispatches, and hosts the read half the entry-per-row tables share: +//! +//! [`schema_tables_for`] collects every table's spec for the up-front `CREATE TABLE`; +//! [`record_frame`] routes a parsed frame to its channel's rows and writes them +//! through one cached prepared statement per table; [`group_frames`] regroups read +//! rows into the frames the writer expanded. Only the subscribeable market-data +//! channels are recorded — account feeds (executions, balances) are out of scope +//! and skipped without error. + +mod book; +mod ohlc; +mod ticker; +mod trade; + +use duckdb::Connection; +use kraken_core::subscribe::message::{BookData, MessageType, OhlcData, TickerData, TradeData}; +use kraken_core::{ChannelData, ChannelMessage, SubscribableChannel}; + +use crate::error::Result; +use crate::frames::capture; +use crate::frames::duckdb::table::{Recordable, Row, RowError, Stored, TableSpec}; + +/// The table a subscribeable channel records into, or `None` for the channels +/// that aren't persisted (`instrument`, `executions`, `balances`, `level3` — the same set +/// [`record_frame`] skips). Single source of truth for the channel → table mapping. +pub(crate) fn table_for(channel: SubscribableChannel) -> Option<&'static TableSpec> { + use SubscribableChannel as Sub; + Some(match channel { + Sub::Ticker => TickerData::table(), + Sub::Trade => TradeData::table(), + Sub::Book => BookData::table(), + Sub::Ohlc => OhlcData::table(), + Sub::Instrument | Sub::Executions | Sub::Balances | Sub::Level3 => return None, + }) +} + +/// The tables to create for a session recording `channels` (the declared set, +/// [`crate::frames::capture::declared_channels`]): the spec for each recordable +/// channel, de-duplicated and order-stable. A channel that isn't persisted +/// contributes no table, so a tape carries only the tables it actually +/// uses rather than every channel's empty table. +pub(crate) fn schema_tables_for(channels: &[SubscribableChannel]) -> Vec<&'static TableSpec> { + let mut specs: Vec<&'static TableSpec> = Vec::new(); + for &channel in channels { + if let Some(spec) = table_for(channel) + && !specs.iter().any(|s| s.name == spec.name) + { + specs.push(spec); + } + } + specs +} + +/// Persist one parsed frame into its channel table. Returns the number of rows +/// written — `0` for a frame the schema does not record (skipped, not an +/// error), so a stray `status` or account frame that reaches the sink never +/// aborts a capture. +pub(crate) fn record_frame( + conn: &Connection, + frame: &ChannelMessage, + seq: i64, + recv_ts: &str, +) -> Result { + // `capture::is_recordable` is the single source of truth for which + // frames persist; the match below is only how each is written. + if !capture::is_recordable(frame) { + return Ok(0); + } + let message_type = frame.message_type; + match &frame.body { + ChannelData::Ticker(entries) => insert( + conn, + TickerData::table(), + TickerData::rows(entries, message_type, seq, recv_ts), + ), + ChannelData::Trade(entries) => insert( + conn, + TradeData::table(), + TradeData::rows(entries, message_type, seq, recv_ts), + ), + ChannelData::Book(entries) => insert( + conn, + BookData::table(), + BookData::rows(entries, message_type, seq, recv_ts), + ), + ChannelData::Ohlc(entries) => insert( + conn, + OhlcData::table(), + OhlcData::rows(entries, message_type, seq, recv_ts), + ), + _ => Ok(0), + } +} + +/// Decode a row's `event_type` column back into the envelope's type tag. An +/// unrecognized tag is newer vocabulary — a same-MAJOR writer may add one. +pub(crate) fn message_type(row: &duckdb::Row<'_>) -> std::result::Result { + let raw: String = row.get("event_type")?; + raw.parse() + .map_err(|_| RowError::Vocabulary(format!("unrecognized event_type '{raw}'"))) +} + +/// One decoded row of a multi-entry channel table: the frame's key columns +/// plus the single data entry the row stored. +pub(crate) struct RowEntry { + pub seq: i64, + pub message_type: MessageType, + pub entry: T, +} + +/// Regroup `(seq, rowid)`-ordered rows into the frames [`Recordable::rows`] expanded: +/// same-`seq` runs are one frame, rebuilt through `make` (the channel's [`ChannelData`] +/// variant constructor). The wire `sequence` was never stored; it reads back `None`. +pub(crate) fn group_frames( + make: fn(Vec) -> ChannelData, + rows: Vec>, +) -> Vec { + struct Open { + seq: i64, + message_type: MessageType, + data: Vec, + } + fn close(make: fn(Vec) -> ChannelData, open: Open) -> Stored { + Stored { + seq: open.seq, + frame: ChannelMessage { + body: make(open.data), + message_type: open.message_type, + sequence: None, + }, + } + } + let mut frames: Vec = Vec::new(); + let mut current: Option> = None; + for row in rows { + match &mut current { + Some(open) if open.seq == row.seq => open.data.push(row.entry), + _ => { + if let Some(done) = current.take() { + frames.push(close(make, done)); + } + current = Some(Open { + seq: row.seq, + message_type: row.message_type, + data: vec![row.entry], + }); + } + } + } + frames.extend(current.map(|open| close(make, open))); + frames +} + +/// Bind and execute the generated `INSERT` for each row, reusing one cached +/// prepared statement per table across the whole session. +fn insert(conn: &Connection, spec: &TableSpec, rows: Vec) -> Result { + if rows.is_empty() { + return Ok(0); + } + let mut stmt = conn.prepare_cached(spec.insert_sql())?; + for row in &rows { + debug_assert_eq!( + row.len(), + spec.columns.len(), + "row arity must match the column count for table '{}'", + spec.name + ); + stmt.execute(duckdb::params_from_iter(row.iter()))?; + } + Ok(rows.len()) +} diff --git a/crates/kraken-recording/src/frames/duckdb/tables/book.rs b/crates/kraken-recording/src/frames/duckdb/tables/book.rs new file mode 100644 index 0000000..91a1353 --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/tables/book.rs @@ -0,0 +1,160 @@ +//! The `book` table: one row per price level, regrouped into frames on read. + +use kraken_core::ChannelData; +use kraken_core::subscribe::message::{BookData, ChannelMessage, MessageType, PriceLevel}; +use strum::{Display, EnumString}; + +use crate::error::Result; +use crate::frames::duckdb::table::{ + Cell, ColType, Column, DriftPolicy, Recordable, Row, RowError, Stored, TableSpec, read_decimal, + read_rows, +}; +use crate::frames::duckdb::tables::message_type; + +/// Which side of the book a level row belongs to. `Display`/`FromStr` share one +/// lowercase vocabulary, so the `side` column parses back exactly as written. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)] +#[strum(serialize_all = "lowercase")] +enum LevelSide { + Bid, + Ask, +} + +static BOOK: TableSpec = TableSpec::new( + "book", + &[ + Column::req("symbol", ColType::Text), + Column::req("seq", ColType::Big), + Column::req("side", ColType::Text), + Column::req("price", ColType::Decimal), + Column::req("qty", ColType::Decimal), + Column::req("event_type", ColType::Text), + Column::req("checksum", ColType::UInt), + Column::req("event_ts", ColType::Text), + Column::req("recv_ts", ColType::Text), + ], +); + +impl Recordable for BookData { + fn table() -> &'static TableSpec { + &BOOK + } + + fn rows(entries: &[Self], message_type: MessageType, seq: i64, recv_ts: &str) -> Vec { + let event_type = message_type.to_string(); + let mut rows = Vec::new(); + for b in entries { + for (side, levels) in [(LevelSide::Bid, &b.bids), (LevelSide::Ask, &b.asks)] { + for level in levels { + rows.push(vec![ + Cell::Text(b.symbol.clone()), + Cell::Big(seq), + Cell::Text(side.to_string()), + Cell::Decimal(level.price), + Cell::Decimal(level.qty), + Cell::Text(event_type.clone()), + Cell::UInt(b.checksum), + Cell::Text(b.timestamp.clone()), + Cell::Text(recv_ts.to_string()), + ]); + } + } + } + rows + } + + /// Level rows regroup by shared `seq` (row order is write order, so + /// sides and level order survive), then one [`BookData`] per session of + /// equal symbols. An all-zero-level frame is never persisted + /// (`is_recordable` drops it for both sinks); a persisted entry with + /// no levels, and adjacent same-symbol entries — neither seen on the + /// v2 feed — don't round-trip. + fn frames(conn: &duckdb::Connection, drift: DriftPolicy) -> Result> { + struct LevelRow { + seq: i64, + message_type: MessageType, + symbol: String, + side: LevelSide, + level: PriceLevel, + checksum: u32, + timestamp: String, + } + let rows = read_rows(conn, &BOOK, drift, |row| { + let side: String = row.get("side")?; + Ok(LevelRow { + seq: row.get("seq")?, + message_type: message_type(row)?, + symbol: row.get("symbol")?, + side: side.parse().map_err(|_| { + RowError::Vocabulary(format!("unrecognized book side '{side}'")) + })?, + level: PriceLevel { + price: read_decimal(row, "price")?, + qty: read_decimal(row, "qty")?, + }, + checksum: row.get("checksum")?, + timestamp: row.get("event_ts")?, + }) + })?; + + struct Open { + seq: i64, + message_type: MessageType, + data: Vec, + } + + fn open_frame(row: &LevelRow) -> Open { + Open { + seq: row.seq, + message_type: row.message_type, + data: Vec::new(), + } + } + + fn close(open: Open) -> Stored { + Stored { + seq: open.seq, + frame: ChannelMessage { + body: ChannelData::Book(open.data), + message_type: open.message_type, + sequence: None, + }, + } + } + + fn push_level(data: &mut Vec, row: LevelRow) { + if data.last().is_none_or(|b| b.symbol != row.symbol) { + data.push(BookData { + symbol: row.symbol, + bids: Vec::new(), + asks: Vec::new(), + checksum: row.checksum, + timestamp: row.timestamp, + }); + } + if let Some(entry) = data.last_mut() { + match row.side { + LevelSide::Bid => entry.bids.push(row.level), + LevelSide::Ask => entry.asks.push(row.level), + } + } + } + + let mut frames: Vec = Vec::new(); + let mut current: Option = None; + for row in rows { + let mut frame = match current.take() { + Some(open) if open.seq == row.seq => open, + Some(done) => { + frames.push(close(done)); + open_frame(&row) + } + None => open_frame(&row), + }; + push_level(&mut frame.data, row); + current = Some(frame); + } + frames.extend(current.map(close)); + Ok(frames) + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/tables/ohlc.rs b/crates/kraken-recording/src/frames/duckdb/tables/ohlc.rs new file mode 100644 index 0000000..fedf6f9 --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/tables/ohlc.rs @@ -0,0 +1,89 @@ +//! The `ohlc` table: one row per candle. + +use kraken_core::ChannelData; +use kraken_core::subscribe::message::{MessageType, OhlcData}; + +use crate::error::Result; +use crate::frames::duckdb::table::{ + Cell, ColType, Column, DriftPolicy, Recordable, Row, Stored, TableSpec, read_decimal, read_rows, +}; +use crate::frames::duckdb::tables::{RowEntry, group_frames, message_type}; + +static OHLC: TableSpec = TableSpec::new( + "ohlc", + &[ + Column::req("symbol", ColType::Text), + Column::req("open", ColType::Decimal), + Column::req("high", ColType::Decimal), + Column::req("low", ColType::Decimal), + Column::req("close", ColType::Decimal), + Column::req("vwap", ColType::Decimal), + Column::req("volume", ColType::Decimal), + Column::req("trades", ColType::UBig), + Column::req("interval", ColType::UInt), + Column::req("interval_begin", ColType::Text), + // The deprecated wire `timestamp`, verbatim: reconstructing it from + // `event_ts` would erase whether the wire sent it, breaking the + // byte-identical round-trip when it equals `interval_begin`. + Column::opt("timestamp", ColType::Text), + Column::req("event_type", ColType::Text), + Column::req("event_ts", ColType::Text), + Column::req("recv_ts", ColType::Text), + Column::req("seq", ColType::Big), + ], +); + +impl Recordable for OhlcData { + fn table() -> &'static TableSpec { + &OHLC + } + + fn rows(entries: &[Self], message_type: MessageType, seq: i64, recv_ts: &str) -> Vec { + let event_type = message_type.to_string(); + entries + .iter() + .map(|c| { + vec![ + Cell::Text(c.symbol.clone()), + Cell::Decimal(c.open), + Cell::Decimal(c.high), + Cell::Decimal(c.low), + Cell::Decimal(c.close), + Cell::Decimal(c.vwap), + Cell::Decimal(c.volume), + Cell::UBig(c.trades), + Cell::UInt(c.interval), + Cell::Text(c.interval_begin.clone()), + c.timestamp.clone().map_or(Cell::Null, Cell::Text), + Cell::Text(event_type.clone()), + Cell::Text(c.event_ts().to_string()), + Cell::Text(recv_ts.to_string()), + Cell::Big(seq), + ] + }) + .collect() + } + + fn frames(conn: &duckdb::Connection, drift: DriftPolicy) -> Result> { + let rows = read_rows(conn, &OHLC, drift, |row| { + Ok(RowEntry { + entry: OhlcData { + symbol: row.get("symbol")?, + open: read_decimal(row, "open")?, + high: read_decimal(row, "high")?, + low: read_decimal(row, "low")?, + close: read_decimal(row, "close")?, + vwap: read_decimal(row, "vwap")?, + trades: row.get("trades")?, + volume: read_decimal(row, "volume")?, + timestamp: row.get("timestamp")?, + interval_begin: row.get("interval_begin")?, + interval: row.get("interval")?, + }, + message_type: message_type(row)?, + seq: row.get("seq")?, + }) + })?; + Ok(group_frames(ChannelData::Ohlc, rows)) + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/tables/ticker.rs b/crates/kraken-recording/src/frames/duckdb/tables/ticker.rs new file mode 100644 index 0000000..8082171 --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/tables/ticker.rs @@ -0,0 +1,90 @@ +//! The `ticker` table: one row per ticker entry. + +use kraken_core::ChannelData; +use kraken_core::subscribe::message::{MessageType, TickerData}; + +use crate::error::Result; +use crate::frames::duckdb::table::{ + Cell, ColType, Column, DriftPolicy, Recordable, Row, Stored, TableSpec, read_decimal, read_rows, +}; +use crate::frames::duckdb::tables::{RowEntry, group_frames, message_type}; + +static TICKER: TableSpec = TableSpec::new( + "ticker", + &[ + Column::req("symbol", ColType::Text), + Column::req("bid", ColType::Decimal), + Column::req("bid_qty", ColType::Decimal), + Column::req("ask", ColType::Decimal), + Column::req("ask_qty", ColType::Decimal), + Column::req("last", ColType::Decimal), + Column::req("volume", ColType::Decimal), + Column::req("vwap", ColType::Decimal), + Column::req("low", ColType::Decimal), + Column::req("high", ColType::Decimal), + Column::req("change", ColType::Decimal), + Column::req("change_pct", ColType::Decimal), + Column::req("event_type", ColType::Text), + Column::req("event_ts", ColType::Text), + Column::req("recv_ts", ColType::Text), + Column::req("seq", ColType::Big), + ], +); + +impl Recordable for TickerData { + fn table() -> &'static TableSpec { + &TICKER + } + + fn rows(entries: &[Self], message_type: MessageType, seq: i64, recv_ts: &str) -> Vec { + let event_type = message_type.to_string(); + entries + .iter() + .map(|t| { + vec![ + Cell::Text(t.symbol.clone()), + Cell::Decimal(t.bid), + Cell::Decimal(t.bid_qty), + Cell::Decimal(t.ask), + Cell::Decimal(t.ask_qty), + Cell::Decimal(t.last), + Cell::Decimal(t.volume), + Cell::Decimal(t.vwap), + Cell::Decimal(t.low), + Cell::Decimal(t.high), + Cell::Decimal(t.change), + Cell::Decimal(t.change_pct), + Cell::Text(event_type.clone()), + Cell::Text(t.timestamp.clone()), + Cell::Text(recv_ts.to_string()), + Cell::Big(seq), + ] + }) + .collect() + } + + fn frames(conn: &duckdb::Connection, drift: DriftPolicy) -> Result> { + let rows = read_rows(conn, &TICKER, drift, |row| { + Ok(RowEntry { + entry: TickerData { + symbol: row.get("symbol")?, + bid: read_decimal(row, "bid")?, + bid_qty: read_decimal(row, "bid_qty")?, + ask: read_decimal(row, "ask")?, + ask_qty: read_decimal(row, "ask_qty")?, + last: read_decimal(row, "last")?, + volume: read_decimal(row, "volume")?, + vwap: read_decimal(row, "vwap")?, + low: read_decimal(row, "low")?, + high: read_decimal(row, "high")?, + change: read_decimal(row, "change")?, + change_pct: read_decimal(row, "change_pct")?, + timestamp: row.get("event_ts")?, + }, + message_type: message_type(row)?, + seq: row.get("seq")?, + }) + })?; + Ok(group_frames(ChannelData::Ticker, rows)) + } +} diff --git a/crates/kraken-recording/src/frames/duckdb/tables/trade.rs b/crates/kraken-recording/src/frames/duckdb/tables/trade.rs new file mode 100644 index 0000000..4e34ecb --- /dev/null +++ b/crates/kraken-recording/src/frames/duckdb/tables/trade.rs @@ -0,0 +1,81 @@ +//! The `trades` table: one row per execution. + +use kraken_core::ChannelData; +use kraken_core::subscribe::message::{MessageType, TradeData}; + +use crate::error::Result; +use crate::frames::duckdb::table::{ + Cell, ColType, Column, DriftPolicy, Recordable, Row, RowError, Stored, TableSpec, read_decimal, + read_rows, +}; +use crate::frames::duckdb::tables::{RowEntry, group_frames, message_type}; + +static TRADES: TableSpec = TableSpec::new( + "trades", + &[ + Column::req("symbol", ColType::Text), + Column::req("side", ColType::Text), + Column::req("price", ColType::Decimal), + Column::req("qty", ColType::Decimal), + Column::req("ord_type", ColType::Text), + Column::req("trade_id", ColType::UBig), + Column::req("event_type", ColType::Text), + Column::req("event_ts", ColType::Text), + Column::req("recv_ts", ColType::Text), + Column::req("seq", ColType::Big), + ], +); + +impl Recordable for TradeData { + fn table() -> &'static TableSpec { + &TRADES + } + + fn rows(entries: &[Self], message_type: MessageType, seq: i64, recv_ts: &str) -> Vec { + let event_type = message_type.to_string(); + entries + .iter() + .map(|t| { + vec![ + Cell::Text(t.symbol.clone()), + Cell::Text(t.side.to_string()), + Cell::Decimal(t.price), + Cell::Decimal(t.qty), + Cell::Text(t.ord_type.to_string()), + Cell::UBig(t.trade_id), + Cell::Text(event_type.clone()), + Cell::Text(t.timestamp.clone()), + Cell::Text(recv_ts.to_string()), + Cell::Big(seq), + ] + }) + .collect() + } + + /// `side`/`ord_type` parse back through the same `strum` vocabulary that + /// rendered them, so write and read can't disagree. + fn frames(conn: &duckdb::Connection, drift: DriftPolicy) -> Result> { + let rows = read_rows(conn, &TRADES, drift, |row| { + let side: String = row.get("side")?; + let ord_type: String = row.get("ord_type")?; + Ok(RowEntry { + entry: TradeData { + symbol: row.get("symbol")?, + side: side.parse().map_err(|_| { + RowError::Vocabulary(format!("unrecognized trade side '{side}'")) + })?, + price: read_decimal(row, "price")?, + qty: read_decimal(row, "qty")?, + ord_type: ord_type.parse().map_err(|_| { + RowError::Vocabulary(format!("unrecognized ord_type '{ord_type}'")) + })?, + trade_id: row.get("trade_id")?, + timestamp: row.get("event_ts")?, + }, + message_type: message_type(row)?, + seq: row.get("seq")?, + }) + })?; + Ok(group_frames(ChannelData::Trade, rows)) + } +} diff --git a/crates/kraken-recording/src/frames/jsonl/mod.rs b/crates/kraken-recording/src/frames/jsonl/mod.rs new file mode 100644 index 0000000..8a6aba8 --- /dev/null +++ b/crates/kraken-recording/src/frames/jsonl/mod.rs @@ -0,0 +1,7 @@ +//! JSONL storage backend for market-data frames. + +mod sink; +mod source; + +pub use sink::TapeSink; +pub use source::TapeSource; diff --git a/crates/kraken-recording/src/frames/jsonl/sink.rs b/crates/kraken-recording/src/frames/jsonl/sink.rs new file mode 100644 index 0000000..3103450 --- /dev/null +++ b/crates/kraken-recording/src/frames/jsonl/sink.rs @@ -0,0 +1,417 @@ +//! The JSONL market-frame sink (the DuckDB counterpart is the feature-gated +//! `DuckdbSink`). +//! +//! Writes each captured frame as one JSONL line to `.jsonl`, plus a +//! `.jsonl.meta.json` sidecar: identity and schema version stamped +//! at open (so a crashed run stays version-checkable), the lag summary added +//! at finalize. Dependency-free, so it is always available and doubles as the +//! DuckDB sink's content oracle in tests. Reopen appends, matching DuckDB. +//! +//! Composes the shared [`Log`] engine and adds only what is market-specific: +//! the recordable-frame filter and the metadata sidecar. The domain-free +//! sibling is [`JsonlSink`](crate::sink::JsonlSink). + +use std::path::{Path, PathBuf}; + +use kraken_core::ChannelMessage; + +use crate::error::Result; +use crate::frames::CaptureSink; +use crate::frames::capture::{ + RecordingDeclaration, RecordingFilter, RecordingIntegrity, RecordingManifest, sidecar_path, +}; +use crate::jsonl::Log; +use crate::schema; +use crate::sink::Sink; + +#[derive(Debug)] +pub struct TapeSink { + log: Log, + meta_path: PathBuf, + /// Carried to finalize: the open-time stamp (first session's version, + /// absorbed identity) with `summary: None`. + report: RecordingManifest, + /// The shared per-frame capture gate ([`RecordingFilter`]). + gate: RecordingFilter, +} + +impl TapeSink { + pub fn open(path: &Path, meta: &RecordingDeclaration) -> Result { + // `open` takes the single-writer lock up front, so a second recorder + // is rejected before it can interleave the tape — and holds it for + // the sink's lifetime, which is what `playground` probes for liveness. + let log = Log::open(path.to_path_buf(), format!("tape '{}'", path.display()))?; + // Probed under the writer lock (a probe before it could go stale + // against a concurrent bootstrap and silently rewrite the sidecar). + // `open` is append-only — it never truncates — so the length still + // reads the pre-open state. Zero committed bytes means zero recorded + // frames, whatever the sidecar says: brand new, the artifact of a + // crash before the first append, or a log deleted by hand next to a + // surviving sidecar. Bootstrapping fresh in every one of those keeps + // an orphaned sidecar's stale identity and version stamp from being + // adopted by a new capture. (JSONL-only hazard: the sidecar and log + // are separable files; DuckDB's `_meta` shares the data's file and + // cannot be orphaned, so its structural is-new probe needs no twin.) + let is_new = std::fs::metadata(path)?.len() == 0; + let meta_path = sidecar_path(path); + // Reopen mirrors the DuckDB `_meta` contract: keep the first session's + // version stamp, absorb this session's identity. + let report = if is_new { + RecordingManifest { + schema_version: schema::SCHEMA_VERSION.to_string(), + meta: meta.clone(), + summary: None, + } + } else { + let mut existing = crate::frames::capture::load_sidecar(path, "append")?; + existing.meta.absorb(meta); + // A reopen restarts the capture: clear the old summary so a crash + // mid-capture reads back as unfinalized, not as the old session's stamp. + existing.summary = None; + existing + }; + crate::frames::capture::write_json_atomic(&meta_path, &report)?; + Ok(Self { + log, + meta_path, + report, + gate: RecordingFilter::new(crate::frames::capture::declared_channels(meta)), + }) + } +} + +impl Sink for TapeSink { + type Item = ChannelMessage; + + fn record(&mut self, batch: &[ChannelMessage]) -> Result<()> { + let gate = &mut self.gate; + let frames: Vec<&ChannelMessage> = + batch.iter().filter(|frame| gate.admits(frame)).collect(); + self.log.append(&frames) + } +} + +impl CaptureSink for TapeSink { + /// Written atomically: the summary's presence is the "finalized" marker, + /// so a torn half-write must never be observable. + fn finalize(self, summary: RecordingIntegrity) -> Result<()> { + let report = RecordingManifest { + summary: Some(summary), + ..self.report + }; + crate::frames::capture::write_json_atomic(&self.meta_path, &report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + + fn meta() -> crate::frames::capture::RecordingDeclaration { + crate::frames::capture::RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into()], + channels: vec!["trades".into()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + } + } + + #[test] + fn second_open_of_same_tape_is_rejected() { + // While one sink holds the tape, a second open fails with + // `validation` rather than letting two writers interleave one file. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let _first = TapeSink::open(&path, &meta()).unwrap(); + let err = TapeSink::open(&path, &meta()).unwrap_err(); + assert!(matches!(err, crate::error::Error::Rejected(_))); + } + + fn trade_frame() -> ChannelMessage { + ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":1,"timestamp":"TS"}]}"#, + ) + .expect("trade frame parses") + } + + #[test] + fn records_frames_and_writes_meta_sidecar() { + // Capture-and-read for the jsonl backend (the DuckDB content oracle): a frame + // round-trips via ChannelMessage, and finalize writes the metadata sidecar. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let frame = ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":50000.1,"qty":0.5,"ord_type":"market","trade_id":1, + "timestamp":"2026-01-01T00:00:00.000000Z"}]}"#, + ) + .unwrap(); + + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(std::slice::from_ref(&frame)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!(lines.len(), 1); + assert_eq!(ChannelMessage::parse(lines[0]).unwrap(), frame); + + let sidecar = std::fs::read_to_string(sidecar_path(&path)).unwrap(); + assert!(sidecar.contains("\"schema_version\"")); + assert!(sidecar.contains("BTC/USD")); + // The completeness markers are always present (0 when clean). + assert!(sidecar.contains("\"events_dropped\"")); + assert!(sidecar.contains("\"frames_unparsed\"")); + assert!(sidecar.contains("\"reconnect_count\"")); + } + + #[test] + fn skips_non_recordable_status_frames() { + // The server pushes `status` unsolicited; the jsonl backend must drop it just + // like duckdb, so both backends capture the same channels. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let status = ChannelMessage::parse( + r#"{"channel":"status","type":"update","data":[{"system":"online", + "api_version":"v2","connection_id":1,"version":"2.0"}]}"#, + ) + .unwrap(); + let trade = trade_frame(); + + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[status, trade]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + assert_eq!(body.lines().count(), 1, "status skipped, trade kept"); + assert!(body.contains("\"trade\"")); + assert!( + !body.contains("connection_id"), + "status frame must not be written" + ); + } + + #[test] + fn reopen_keeps_first_identity_and_absorbs_new_channels() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame()]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let changed = RecordingDeclaration { + window_start: "2027-01-01T00:00:00Z".parse().unwrap(), + channels: vec!["book".into()], + ..meta() + }; + TapeSink::open(&path, &changed) + .unwrap() + .finalize(RecordingIntegrity::now()) + .unwrap(); + + let report: RecordingManifest = + serde_json::from_str(&std::fs::read_to_string(sidecar_path(&path)).unwrap()).unwrap(); + assert_eq!( + report.meta.window_start, + "2026-01-01T00:00:00Z" + .parse::>() + .unwrap(), + "window_start stays from the first open" + ); + assert_eq!( + report.meta.channels, + ["trades", "book"], + "channels absorb the reopen's additions, so the report covers every frame" + ); + } + + #[test] + fn open_stamps_an_unfinalized_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let _sink = TapeSink::open(&path, &meta()).unwrap(); + + let report: RecordingManifest = + serde_json::from_str(&std::fs::read_to_string(sidecar_path(&path)).unwrap()).unwrap(); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION); + assert_eq!(report.meta, meta()); + assert_eq!(report.summary, None, "no summary until finalize"); + } + + #[test] + fn reopen_resets_the_summary_until_this_runs_finalize() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame()]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let _reopened = TapeSink::open(&path, &meta()).unwrap(); + let report: RecordingManifest = + serde_json::from_str(&std::fs::read_to_string(sidecar_path(&path)).unwrap()).unwrap(); + assert_eq!(report.summary, None, "reopen clears the finalize stamp"); + } + + #[test] + fn existing_tape_without_a_sidecar_is_refused() { + // Removing the sidecar simulates an unstamped or foreign tape. The + // recorded frame matters: only an *empty* sidecar-less file is the + // known crash artifact — one with content stays refused. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame()]).unwrap(); + drop(sink); + std::fs::remove_file(sidecar_path(&path)).unwrap(); + + let err = TapeSink::open(&path, &meta()).unwrap_err(); + assert!(matches!(err, Error::Rejected(_))); + assert!( + err.to_string().contains("no metadata sidecar"), + "got: {err}" + ); + } + + #[test] + fn empty_sidecar_less_file_is_rescued_as_new() { + // The crash artifact: `Log::create` touched the tape file, then the + // process died before the sidecar's first write. An empty file holds + // no frames, so a later open bootstraps it instead of refusing forever. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + std::fs::write(&path, "").unwrap(); + + let sink = TapeSink::open(&path, &meta()).unwrap(); + drop(sink); + let report: RecordingManifest = + serde_json::from_str(&std::fs::read_to_string(sidecar_path(&path)).unwrap()).unwrap(); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION); + assert_eq!(report.meta, meta()); + } + + #[test] + fn orphaned_sidecar_next_to_an_empty_log_is_not_adopted() { + // A log deleted by hand leaves its sidecar behind (nothing removes + // the sidecar). The next capture at that path records new content: + // adopting the orphan would stamp the old session's window_start — and a + // foreign version — onto frames it never described. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame()]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + std::fs::remove_file(&path).unwrap(); + let doctored = std::fs::read_to_string(sidecar_path(&path)) + .unwrap() + .replace(schema::SCHEMA_VERSION, "1.7"); + std::fs::write(sidecar_path(&path), doctored).unwrap(); + + let fresh = RecordingDeclaration { + window_start: "2027-01-01T00:00:00Z".parse().unwrap(), + ..meta() + }; + drop(TapeSink::open(&path, &fresh).unwrap()); + let report: RecordingManifest = + serde_json::from_str(&std::fs::read_to_string(sidecar_path(&path)).unwrap()).unwrap(); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION, "fresh stamp"); + assert_eq!( + report.meta.window_start, + "2027-01-01T00:00:00Z" + .parse::>() + .unwrap() + ); + assert_eq!(report.summary, None, "the orphan's finalize stamp is gone"); + } + + #[test] + fn incompatible_orphan_sidecar_is_restamped_not_rejected() { + // Deliberate divergence from the non-empty path (which rejects a 2.0 + // stamp): with zero committed bytes there is no content the foreign + // stamp could describe, so refusing would only force a manual delete. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame()]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + std::fs::remove_file(&path).unwrap(); + let doctored = std::fs::read_to_string(sidecar_path(&path)) + .unwrap() + .replace(schema::SCHEMA_VERSION, "2.0"); + std::fs::write(sidecar_path(&path), doctored).unwrap(); + + drop(TapeSink::open(&path, &meta()).expect("empty log bootstraps fresh")); + let report: RecordingManifest = + serde_json::from_str(&std::fs::read_to_string(sidecar_path(&path)).unwrap()).unwrap(); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION); + } + + #[test] + fn undeclared_recordable_frame_is_skipped() { + // Mirrors the DuckDB sink's declared-channel filter: a session that + // declared only `trades` must not persist a stray ticker frame. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let ticker = ChannelMessage::parse( + r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD", + "bid":1.0,"bid_qty":2.0,"ask":3.0,"ask_qty":4.0,"last":5.0, + "volume":5.0,"vwap":6.0,"low":0.5,"high":9.0,"change":-1.0,"change_pct":-2.5, + "timestamp":"TS"}]}"#, + ) + .unwrap(); + let trade = trade_frame(); + + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[ticker, trade]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + assert_eq!(body.lines().count(), 1, "ticker skipped, trade kept"); + assert!(body.contains("\"trade\"")); + assert!(!body.contains("\"ticker\"")); + } + + #[test] + fn finalize_keeps_the_first_runs_version_stamp() { + // The stamp names the content's vocabulary: a 1.7 tape finalized + // by a 1.0 build must still say 1.7, not downgrade. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame()]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + let sidecar = sidecar_path(&path); + let doctored = std::fs::read_to_string(&sidecar) + .unwrap() + .replace(schema::SCHEMA_VERSION, "1.7"); + std::fs::write(&sidecar, doctored).unwrap(); + + TapeSink::open(&path, &meta()) + .unwrap() + .finalize(RecordingIntegrity::now()) + .unwrap(); + let report: RecordingManifest = + serde_json::from_str(&std::fs::read_to_string(&sidecar).unwrap()).unwrap(); + assert_eq!(report.schema_version, "1.7", "stamp survives the reopen"); + } + + #[test] + fn reopen_refuses_an_incompatible_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[trade_frame()]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + let sidecar = sidecar_path(&path); + let doctored = std::fs::read_to_string(&sidecar) + .unwrap() + .replace(schema::SCHEMA_VERSION, "2.0"); + std::fs::write(&sidecar, doctored).unwrap(); + + let err = TapeSink::open(&path, &meta()).unwrap_err(); + assert!(matches!(err, Error::Rejected(_))); + assert!(err.to_string().contains("refusing to append"), "got: {err}"); + } +} diff --git a/crates/kraken-recording/src/frames/jsonl/source.rs b/crates/kraken-recording/src/frames/jsonl/source.rs new file mode 100644 index 0000000..1dbb7b3 --- /dev/null +++ b/crates/kraken-recording/src/frames/jsonl/source.rs @@ -0,0 +1,579 @@ +//! The JSONL market-frame source — read pendant of +//! [`TapeSink`](crate::frames::jsonl::TapeSink). Lines carry no +//! `recv_ts`/`seq`, so the merge instant is the exchange `event_ts`, clamped +//! non-decreasing in line (capture) order. Lock-free; the composer holds the +//! file's [`FileLock`] for writer quiescence. + +use std::path::{Path, PathBuf}; + +use kraken_core::ChannelMessage; +use serde_json::Value; + +use crate::error::{Error, Result}; +use crate::frames::capture::RecordingManifest; +use crate::frames::{ + CaptureSource, MarketEvent, clamp_non_decreasing, event_instant, shared_capture_lock, +}; +use crate::jsonl::{committed_lines, damaged, line_seq}; +use crate::lock::FileLock; +use crate::schema; +use crate::source::Source; + +pub struct TapeSource { + path: PathBuf, + /// Loaded and version-gated at open, so no frame is ever decoded under + /// the wrong contract; [`CaptureSource::capture`] hands it back. + report: RecordingManifest, + /// Held shared for the source's lifetime — the reader posture + /// ([`shared_capture_lock`]), mirroring the DuckDB source. + _lock: FileLock, +} + +impl TapeSource { + /// Fails with [`Rejected`](crate::Error::Rejected) when the tape is + /// missing, a writer still holds it, or its sidecar stamp is absent or + /// a different schema MAJOR — the same up-front contract as the + /// feature-gated `DuckdbSource::open`. + pub fn open(path: &Path) -> Result { + if !path.exists() { + return Err(Error::Rejected(format!( + "tape '{}' not found", + path.display() + ))); + } + let lock = shared_capture_lock(path)?; + let report = crate::frames::capture::load_sidecar(path, "read")?; + Ok(Self { + path: path.to_path_buf(), + report, + _lock: lock, + }) + } + + /// The damage-vs-drift decision for a committed line that failed to + /// decode. `Ok(())` guarantees the line is tolerable drift — the + /// capture's stamp is a *newer minor* than this build, the one case + /// where the content can be a newer writer's vocabulary — warned here + /// and safe for the caller to skip. Anything else is damage: `Err`, + /// fails loud. + fn ensure_tolerable_drift( + &self, + line_no: usize, + raw: &[u8], + err: &serde_json::Error, + ) -> Result<()> { + let Ok(value) = serde_json::from_slice::(raw) else { + return Err(damaged( + &self.path, + line_no, + &format!("malformed json: {err}"), + )); + }; + if value.get("channel").and_then(Value::as_str).is_none() { + // Valid JSON without a channel key is nothing any sink ever + // writes — damage, not a newer writer's vocabulary. + return Err(damaged(&self.path, line_no, "no channel key")); + } + if !schema::is_newer_minor(&self.report.schema_version) { + return Err(damaged(&self.path, line_no, "undecodable frame")); + } + tracing::warn!( + path = %self.path.display(), + line = line_no, + "skipping a newer-minor writer's frame outside this build's channel catalogue" + ); + Ok(()) + } +} + +impl Source for TapeSource { + type Item = MarketEvent; + + /// A committed line that fails to decode is damage and fails loud (the + /// DuckDB backend's contract too) — unless the capture's stamp is a + /// *newer minor* than this build, the one case where the content can be + /// a newer writer's vocabulary, warned and skipped. The merge instant is + /// the exchange `event_ts`, clamped non-decreasing in line (capture) + /// order; `seq` is the physical line number. + fn read(&self) -> Result> { + let mut events = Vec::new(); + for line in committed_lines(&self.path)? { + let (line_no, raw) = line?; + let frame = match serde_json::from_slice::(&raw) { + Ok(frame) => frame, + // The happy path decoded straight to the frame; only a failure + // pays for the probe that classifies damage vs drift. + Err(err) => { + self.ensure_tolerable_drift(line_no, &raw, &err)?; + continue; + } + }; + // A decodable frame without a per-entry event time (a reference or + // account channel) is equally nothing the sink ever writes. + let Some(at) = event_instant(&frame) else { + return Err(damaged(&self.path, line_no, "no parseable event timestamp")); + }; + events.push(MarketEvent { + at, + seq: line_seq(line_no), + frame, + }); + } + clamp_non_decreasing(&mut events); + Ok(events.into_iter()) + } +} + +impl CaptureSource for TapeSource { + /// The report stamped at open and completed at finalize, already loaded + /// and version-gated by [`TapeSource::open`]. + fn capture(&self) -> Result { + Ok(self.report.clone()) + } +} + +#[cfg(test)] +mod tests { + use kraken_core::ChannelData; + + use super::*; + use crate::frames::CaptureSink; + use crate::frames::capture::{RecordingDeclaration, RecordingIntegrity, sidecar_path}; + use crate::frames::jsonl::TapeSink; + use crate::sink::Sink; + + fn meta() -> RecordingDeclaration { + RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into()], + channels: vec![ + "ticker".into(), + "trade".into(), + "book".into(), + "ohlc".into(), + ], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + } + } + + /// Stamp a sidecar for a hand-written tape file, so `open`'s version + /// gate passes (or observes `version`, for the drift tests). + fn stamp(path: &Path, version: &str) { + let report = RecordingManifest { + schema_version: version.to_string(), + meta: meta(), + summary: None, + }; + crate::frames::capture::write_json_atomic(&sidecar_path(path), &report).unwrap(); + } + + fn trade_frame(ts: &str, id: u64) -> ChannelMessage { + ChannelMessage::parse(&format!( + r#"{{"channel":"trade","type":"update","data":[{{"symbol":"BTC/USD","side":"buy", + "price":50000.1,"qty":0.5,"ord_type":"market","trade_id":{id}, + "timestamp":"{ts}"}}]}}"# + )) + .expect("trade frame parses") + } + + fn frames_of(events: &[MarketEvent]) -> Vec<&ChannelMessage> { + events.iter().map(|e| &e.frame).collect() + } + + #[test] + fn reads_back_what_the_sink_wrote_in_event_ts_order() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let first = trade_frame("2026-01-01T00:00:01.000000Z", 1); + let second = trade_frame("2026-01-01T00:00:02.000000Z", 2); + + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&[first.clone(), second.clone()]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let source = TapeSource::open(&path).expect("tape exists"); + let events: Vec<_> = source.read().unwrap().collect(); + assert_eq!(frames_of(&events), [&first, &second]); + assert!((events[0].at, events[0].seq) < (events[1].at, events[1].seq)); + } + + #[test] + fn all_channels_round_trip_in_event_ts_order() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let written: Vec = [ + r#"{"channel":"ticker","type":"update","data":[{"symbol":"BTC/USD", + "bid":1.0,"bid_qty":2.0,"ask":3.0,"ask_qty":4.0,"last":62590.8, + "volume":5.0,"vwap":6.0,"low":0.5,"high":9.0,"change":-1.0,"change_pct":-2.5, + "timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + r#"{"channel":"trade","type":"update","data":[ + {"symbol":"BTC/USD","side":"buy","price":50000.1,"qty":0.5,"ord_type":"market", + "trade_id":1,"timestamp":"2026-01-01T00:00:02.000000Z"}, + {"symbol":"BTC/USD","side":"sell","price":50000.2,"qty":0.25,"ord_type":"limit", + "trade_id":2,"timestamp":"2026-01-01T00:00:02.500000Z"}]}"#, + r#"{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD", + "bids":[{"price":100.0,"qty":1.0}],"asks":[{"price":100.5,"qty":3.0}], + "checksum":4242,"timestamp":"2026-01-01T00:00:03.000000Z"}]}"#, + r#"{"channel":"ohlc","type":"update","data":[{"symbol":"BTC/USD", + "open":1.0,"high":2.0,"low":0.5,"close":1.5,"vwap":1.2,"trades":7,"volume":123.0, + "interval_begin":"2026-01-01T00:00:00.000000000Z","interval":1, + "timestamp":"2026-01-01T00:00:04.000000Z"}]}"#, + ] + .iter() + .map(|raw| ChannelMessage::parse(raw).expect("fixture frame parses")) + .collect(); + + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&written).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let events: Vec<_> = TapeSource::open(&path) + .expect("tape exists") + .read() + .unwrap() + .collect(); + assert_eq!( + events.iter().map(|e| e.frame.clone()).collect::>(), + written + ); + assert!( + events + .windows(2) + .all(|w| (w[0].at, w[0].seq) < (w[1].at, w[1].seq)) + ); + } + + #[test] + fn ohlc_interval_begin_fallback_does_not_reorder_the_capture() { + // A candle without the wire `timestamp` reports interval_begin, up + // to a whole interval before receipt; the clamp keeps line order. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let trade = trade_frame("2026-01-01T00:00:30.000000Z", 1); + let candle = r#"{"channel":"ohlc","type":"update","data":[{"symbol":"BTC/USD", + "open":1.0,"high":2.0,"low":0.5,"close":1.5,"vwap":1.2,"trades":7,"volume":123.0, + "interval_begin":"2026-01-01T00:00:00.000000000Z","interval":1}]}"#; + std::fs::write( + &path, + format!( + "{}\n{}\n", + serde_json::to_string(&trade).unwrap(), + ChannelMessage::parse(candle) + .map(|f| serde_json::to_string(&f).unwrap()) + .unwrap() + ), + ) + .unwrap(); + stamp(&path, schema::SCHEMA_VERSION); + + let events: Vec<_> = TapeSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!(events.len(), 2); + assert!( + matches!(&events[1].frame.body, ChannelData::Ohlc(_)), + "file order preserved" + ); + assert!( + (events[0].at, events[0].seq) < (events[1].at, events[1].seq), + "the candle's clamped key sorts after the earlier trade" + ); + } + + #[test] + fn unknown_channel_line_under_a_newer_minor_stamp_is_skipped() { + // Only a newer-minor writer's capture may carry vocabulary this build + // lacks; there the skip is drift tolerance, not swallowed damage. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let keep = trade_frame("2026-01-01T00:00:01.000000Z", 1); + std::fs::write( + &path, + format!( + "{}\n{}\n", + r#"{"channel":"margin_rates","type":"update","data":[{"rate":0.01}]}"#, + serde_json::to_string(&keep).unwrap() + ), + ) + .unwrap(); + stamp(&path, "1.7"); + + let events: Vec<_> = TapeSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!(frames_of(&events), [&keep]); + } + + #[test] + fn unknown_channel_line_under_this_builds_stamp_is_damage() { + // The same line under this build's own stamp cannot be newer + // vocabulary — it is a corrupted committed line and must fail loud, + // exactly like the DuckDB backend. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + std::fs::write( + &path, + format!( + "{}\n{}\n", + r#"{"channel":"margin_rates","type":"update","data":[{"rate":0.01}]}"#, + serde_json::to_string(&trade_frame("2026-01-01T00:00:01.000000Z", 1)).unwrap() + ), + ) + .unwrap(); + stamp(&path, schema::SCHEMA_VERSION); + + let err = match TapeSource::open(&path).unwrap().read() { + Ok(_) => panic!("a damaged recording must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_))); + } + + #[test] + fn damaged_committed_line_fails_loud_not_skipped() { + // The JSONL twin of the DuckDB damaged-row test: valid JSON whose + // payload no longer decodes ("price":"oops") is damage under a + // same-version stamp, never a skippable diagnostic. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + std::fs::write( + &path, + concat!( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy","#, + r#""price":"oops","qty":0.5,"ord_type":"market","trade_id":1,"#, + r#""timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + "\n" + ), + ) + .unwrap(); + stamp(&path, schema::SCHEMA_VERSION); + + let err = match TapeSource::open(&path).unwrap().read() { + Ok(_) => panic!("a damaged recording must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_)), "got: {err}"); + } + + #[test] + fn malformed_interior_line_fails_loud() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let keep = trade_frame("2026-01-01T00:00:01.000000Z", 1); + std::fs::write( + &path, + format!("not json\n{}\n", serde_json::to_string(&keep).unwrap()), + ) + .unwrap(); + stamp(&path, schema::SCHEMA_VERSION); + + // The Ok side (an iterator) isn't Debug, so match rather than unwrap_err. + let err = match TapeSource::open(&path).unwrap().read() { + Ok(_) => panic!("a damaged recording must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_))); + } + + #[test] + fn damage_reports_the_physical_line_number_across_blank_lines() { + // A blank line is skipped but still counted, so the report names the + // on-disk line — the number a text editor or `sed -n` agrees with. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + std::fs::write( + &path, + format!( + "{}\n\nnot json\n", + serde_json::to_string(&trade_frame("2026-01-01T00:00:01.000000Z", 1)).unwrap() + ), + ) + .unwrap(); + stamp(&path, schema::SCHEMA_VERSION); + + let err = match TapeSource::open(&path).unwrap().read() { + Ok(_) => panic!("a damaged recording must be rejected"), + Err(e) => e, + }; + assert!(err.to_string().contains("line 3"), "got: {err}"); + } + + #[test] + fn seq_is_the_physical_line_number() { + // Blank lines never come from the sink, but a hand-edited log must + // still yield seqs that match the on-disk line numbers. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + std::fs::write( + &path, + format!( + "{}\n\n{}\n", + serde_json::to_string(&trade_frame("2026-01-01T00:00:01.000000Z", 1)).unwrap(), + serde_json::to_string(&trade_frame("2026-01-01T00:00:02.000000Z", 2)).unwrap() + ), + ) + .unwrap(); + stamp(&path, schema::SCHEMA_VERSION); + + let events: Vec<_> = TapeSource::open(&path).unwrap().read().unwrap().collect(); + assert_eq!( + events.iter().map(|e| e.seq).collect::>(), + [1, 3], + "seq skips the blank physical line" + ); + } + + #[test] + fn frame_without_a_parseable_event_ts_fails_loud() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let damaged = trade_frame("not-a-timestamp", 1); + std::fs::write( + &path, + format!("{}\n", serde_json::to_string(&damaged).unwrap()), + ) + .unwrap(); + stamp(&path, schema::SCHEMA_VERSION); + + let err = match TapeSource::open(&path).unwrap().read() { + Ok(_) => panic!("a damaged recording must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_))); + } + + /// Verbatim on-disk line pinning the read/write contract; bump + /// [`schema::SCHEMA_VERSION`] deliberately instead of editing the pin. + const PINNED_MARKET_LINE: &str = r#"{"channel":"trade","data":[{"symbol":"BTC/USD","side":"buy","price":50000.1,"qty":0.5,"ord_type":"market","trade_id":1,"timestamp":"2026-01-01T00:00:00.000000Z"}],"type":"update"}"#; + + #[test] + fn pinned_market_line_still_decodes() { + let frame = ChannelMessage::parse(PINNED_MARKET_LINE).expect("pinned line decodes"); + assert!( + matches!( + &frame.body, + ChannelData::Trade(data) if data[0].price == "50000.1".parse().unwrap() + ), + "pinned line must decode to its typed variant, got {frame:?}" + ); + } + + #[test] + fn writer_output_still_matches_the_pinned_market_line() { + let frame = ChannelMessage::parse(PINNED_MARKET_LINE).expect("pinned line decodes"); + assert_eq!(serde_json::to_string(&frame).unwrap(), PINNED_MARKET_LINE); + } + + #[test] + fn capture_round_trips_the_report_the_sink_stamped() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let summary = RecordingIntegrity { + window_end: "2026-01-01T00:01:00Z".parse().unwrap(), + events_dropped: 7, + frames_unparsed: 3, + reconnect_count: 2, + }; + let sink = TapeSink::open(&path, &meta()).unwrap(); + sink.finalize(summary.clone()).unwrap(); + + let report = TapeSource::open(&path).unwrap().capture().unwrap(); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION); + assert_eq!(report.meta, meta()); + assert_eq!(report.summary, Some(summary)); + } + + #[test] + fn crashed_capture_reads_as_unfinalized_but_version_checked() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + drop(TapeSink::open(&path, &meta()).unwrap()); // no finalize + + let report = TapeSource::open(&path).unwrap().capture().unwrap(); + assert_eq!(report.summary, None, "unfinalized: completeness unknown"); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION); + assert_eq!(report.meta, meta()); + } + + #[test] + fn missing_sidecar_is_rejected_at_open_not_trusted() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + drop(TapeSink::open(&path, &meta()).unwrap()); + std::fs::remove_file(sidecar_path(&path)).unwrap(); + + // The gate fires at open — before any read — like the DuckDB source. + let err = match TapeSource::open(&path) { + Ok(_) => panic!("a sidecar-less tape must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Rejected(_))); + assert!( + err.to_string().contains("no metadata sidecar"), + "got: {err}" + ); + } + + #[test] + fn corrupt_sidecar_fails_loud_at_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + drop(TapeSink::open(&path, &meta()).unwrap()); + std::fs::write(sidecar_path(&path), "{ torn").unwrap(); + + let err = match TapeSource::open(&path) { + Ok(_) => panic!("a corrupt sidecar must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_))); + } + + #[test] + fn half_stamped_sidecar_is_damage_not_unfinalized() { + // The DuckDB `_meta` twin: a sidecar carrying `window_end` without + // its loss counts is a torn or hand-edited stamp. Reading it as + // merely unfinalized would silently discard the completeness signal + // — and classify the same state differently per backend. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let sink = TapeSink::open(&path, &meta()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + let sidecar = sidecar_path(&path); + let doctored: String = std::fs::read_to_string(&sidecar) + .unwrap() + .lines() + .filter(|line| !line.contains("events_dropped")) + .collect::>() + .join("\n"); + std::fs::write(&sidecar, doctored).unwrap(); + + let err = match TapeSource::open(&path) { + Ok(_) => panic!("a half-stamped sidecar must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Damaged(_)), "got: {err}"); + // The message pins WHICH rejection fired: were the doctoring to break + // the JSON instead, the parse error would also be Damaged and the + // test would pass for the wrong reason. + assert!( + err.to_string().contains("partial completeness stamp"), + "got: {err}" + ); + } + + #[test] + fn incompatible_sidecar_schema_major_is_rejected_at_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("market.jsonl"); + let sink = TapeSink::open(&path, &meta()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + let sidecar = sidecar_path(&path); + let doctored = std::fs::read_to_string(&sidecar) + .unwrap() + .replace(schema::SCHEMA_VERSION, "2.0"); + std::fs::write(&sidecar, doctored).unwrap(); + + let err = match TapeSource::open(&path) { + Ok(_) => panic!("a different-major tape must be rejected"), + Err(e) => e, + }; + assert!(matches!(err, Error::Rejected(_))); + } +} diff --git a/crates/kraken-recording/src/frames/mod.rs b/crates/kraken-recording/src/frames/mod.rs new file mode 100644 index 0000000..be5a3fa --- /dev/null +++ b/crates/kraken-recording/src/frames/mod.rs @@ -0,0 +1,159 @@ +//! The market-frame domain: capture contracts and storage backends. +//! +//! Extends the domain-free core with what a market capture adds on top of a +//! plain log: the finalize stamp ([`CaptureSink`]), the self-description +//! handed back before any frame decodes ([`CaptureSource`]), and the merged +//! read unit ([`MarketEvent`]). Two backends implement the pair — +//! [`jsonl`] ([`TapeSink`]/[`TapeSource`]) and the feature-gated +//! `duckdb` (plain name: a link would break the doc build without the +//! feature) — and must yield identical event sequences for one capture +//! (pinned by the backend-equivalence tests). + +use std::path::Path; + +use chrono::{DateTime, Utc}; +use kraken_core::ChannelMessage; + +use crate::error::{Error, Result}; +use crate::frames::capture::{RecordingIntegrity, RecordingManifest}; +use crate::lock::FileLock; +use crate::sink::Sink; +use crate::source::Source; +use crate::time::parse_instant; + +pub mod capture; +pub mod jsonl; + +#[cfg(feature = "duckdb")] +pub mod duckdb; + +#[cfg(feature = "duckdb")] +pub use duckdb::{DuckdbSink, DuckdbSource}; +pub use jsonl::{TapeSink, TapeSource}; + +/// A market-stream destination: a frame [`Sink`] a driver can finalize with +/// the capture summary. Consumed by value — drivers are generic over the +/// concrete sink, never `Box`. +pub trait CaptureSink: Sink { + /// End the stream: stamp `summary` into the destination's metadata and + /// release the destination. The default is for destinations with no + /// metadata to stamp (a live echo): the summary is dropped and only the + /// classical [`close`](Sink::close) runs. + fn finalize(self, _summary: RecordingIntegrity) -> Result<()> + where + Self: Sized, + { + self.close() + } +} + +/// The read side of a market capture — the mirror of [`CaptureSink`]: where +/// the sink ends a stream by stamping the capture's self-description, the +/// source starts a read by handing the same description back. Callers check +/// it (version, summary) before consuming [`read`](Source::read), so no +/// frame is decoded under the wrong contract. +pub trait CaptureSource: Source { + /// The capture's self-description, exactly as stamped. `summary: None` + /// means the last run never finalized (completeness unknown); a missing + /// or incompatible stamp is an error, damage fails loud. + fn capture(&self) -> Result; +} + +/// One market frame read back from a recording — the storage layer's read +/// unit, wrapped into its consumer's vocabulary (e.g. a session timeline). +/// `at` is the merge instant — the exchange `event_ts`, in every backend — +/// already clamped non-decreasing in capture order by the producing source +/// ([`clamp_non_decreasing`]); `seq` is the writer's frame order, the sort +/// and tiebreak within the track. +#[derive(Debug)] +pub struct MarketEvent { + pub at: DateTime, + pub seq: i64, + pub frame: ChannelMessage, +} + +/// A market frame's merge instant — its exchange `event_ts`, the one rule +/// behind [`MarketEvent::at`]. Shared so no backend can drift to a different +/// timestamp field. +pub(crate) fn event_instant(frame: &ChannelMessage) -> Option> { + frame.event_ts().and_then(parse_instant) +} + +/// Clamp merge instants non-decreasing in capture order: wire `event_ts` is +/// not monotone (OHLC can report `interval_begin`, wall clocks step back), +/// and replay must never reorder against capture. Shared by both market +/// sources so the merged timeline orders identically per backend. +pub(crate) fn clamp_non_decreasing(events: &mut [MarketEvent]) { + let mut last: Option> = None; + for event in events { + if let Some(prev) = last { + event.at = event.at.max(prev); + } + last = Some(event.at); + } +} + +/// Take the reader posture on a tape: a *shared* lock, so a starting +/// recorder gets a clean "in use" rejection instead of an engine error, +/// concurrent readers coexist, and the exclusive-only recorder-liveness +/// probe never mistakes a long read for a live recorder. +pub(crate) fn shared_capture_lock(path: &Path) -> Result { + FileLock::acquire_shared(path, "tape").map_err(|e| match e { + Error::Rejected(_) => Error::Rejected( + "the tape is in use — the recorder is still running; \ + stop the session first" + .into(), + ), + other => other, + }) +} + +#[cfg(test)] +mod tests { + use kraken_core::ChannelData; + use kraken_core::subscribe::message::MessageType; + use proptest::prelude::*; + + use super::*; + + fn event_at(at: DateTime, seq: i64) -> MarketEvent { + MarketEvent { + at, + seq, + frame: ChannelMessage { + body: ChannelData::Trade(vec![]), + message_type: MessageType::Update, + sequence: None, + }, + } + } + + proptest! { + /// The clamp is exactly the running maximum: `out[i] == max(in[..=i])`. + /// This single postcondition subsumes non-decreasing, never-rewinding, + /// and identity-on-ordered-input — and rules out an over-clamping + /// implementation (e.g. `prev + ε`) the weaker properties would pass. + #[test] + fn clamp_is_the_running_maximum_of_its_input( + offsets in proptest::collection::vec(0i64..=100_000, 0..32), + ) { + let base = DateTime::::from_timestamp(1_760_000_000, 0).unwrap(); + let inputs: Vec<_> = offsets + .iter() + .map(|&s| base + chrono::Duration::seconds(s)) + .collect(); + let mut events: Vec<_> = inputs + .iter() + .enumerate() + .map(|(i, &at)| event_at(at, i as i64)) + .collect(); + + clamp_non_decreasing(&mut events); + let mut running_max: Option> = None; + for (event, &input) in events.iter().zip(&inputs) { + running_max = Some(running_max.map_or(input, |m| m.max(input))); + prop_assert_eq!(Some(event.at), running_max); + } + } + } +} diff --git a/crates/kraken-recording/src/fsync.rs b/crates/kraken-recording/src/fsync.rs new file mode 100644 index 0000000..bbc1517 --- /dev/null +++ b/crates/kraken-recording/src/fsync.rs @@ -0,0 +1,25 @@ +//! Directory-entry durability. `File::sync_all` makes a file's *content* +//! durable; a create or rename is only durable once the parent directory's +//! entry is fsync'd too — otherwise a power loss can legally roll the +//! operation back (POSIX leaves rename/create durability to the directory). + +use std::path::Path; + +/// Fsync `path`'s parent directory, making a just-created or just-renamed +/// entry durable. No-op on non-unix: Windows directory handles don't support +/// flush semantics, and NTFS journals metadata itself. +pub(crate) fn fsync_parent(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + if let Some(parent) = path.parent() { + // An empty parent means a bare relative filename: the CWD, "." opens it. + let dir = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + std::fs::File::open(dir)?.sync_all()?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} diff --git a/crates/kraken-recording/src/jsonl.rs b/crates/kraken-recording/src/jsonl.rs new file mode 100644 index 0000000..0290944 --- /dev/null +++ b/crates/kraken-recording/src/jsonl.rs @@ -0,0 +1,363 @@ +//! JSONL logs: one compact JSON line per entry, one writer at a time. +//! +//! [`Log`] is the write engine — constructing one *is* acquiring the file's +//! writer lock, so appends, tail repair, and removal are serialized by the +//! type, not by convention. +//! +//! Crash contract, honestly stated: an append is one `write_all` + fsync of +//! the whole batch, so a *clean return* means the batch is durable. A crash +//! mid-append offers no such atomicity — the filesystem may persist any +//! prefix of the batch's lines (and, on out-of-order page writeback, even a +//! hole inside one). The trailing newline is each line's commit marker: +//! a torn final line is silently repaired before the next append and ignored +//! by readers, while an interior hole fails the read loud. Consumers that +//! need multi-event atomicity must encode the transaction as a single line. + +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::{Error, Result}; +use crate::fsync::fsync_parent; +use crate::lock::FileLock; + +/// An append-only JSONL log with its writer lock held for the handle's +/// lifetime, so a caller's whole read→decide→append sequence is one +/// serialized transaction. +#[derive(Debug)] +#[must_use = "the log's writer lock is released as soon as this handle is dropped"] +pub(crate) struct Log { + path: PathBuf, + _lock: FileLock, +} + +impl Log { + /// Open the log for appending, creating it if absent. Lock first, then + /// touch the file: exclusivity (and the recording sink's liveness beacon) + /// starts before the first append, and the file is visible from creation, + /// which `playground`'s presence probes rely on. Parent directories come + /// from acquiring the sibling lock. + pub(crate) fn open(path: PathBuf, purpose: impl Into) -> Result { + let lock = FileLock::acquire(&path, purpose)?; + OpenOptions::new().create(true).append(true).open(&path)?; + // Without the directory fsync a crash could roll the creation back, + // erasing a name that liveness probes already observed. + fsync_parent(&path)?; + Ok(Self { path, _lock: lock }) + } + + /// Append the batch as one fsync'd `write_all`, repairing any torn tail + /// first. Durable on return; on a crash mid-append only a prefix of the + /// batch may survive (see the module contract). + pub(crate) fn append(&self, entries: &[T]) -> Result<()> { + repair_torn_tail(&self.path)?; + let mut buf = Vec::new(); + for entry in entries { + serde_json::to_writer(&mut buf, entry) + .map_err(|e| Error::Damaged(format!("unencodable log entry: {e}")))?; + buf.push(b'\n'); + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + file.write_all(&buf)?; + file.sync_all()?; + Ok(()) + } + + /// Delete the log under its lock, consuming the handle. Already + /// absent counts as success. + pub(crate) fn remove(self) -> Result<()> { + match std::fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } +} + +/// A JSONL entry's `seq` from `line_no`, its **1-based** physical line number +/// (writers emit no blank lines, so that is also its 1-based position). +/// Saturates — a silent wrap would corrupt the order. +pub fn line_seq(line_no: usize) -> i64 { + i64::try_from(line_no).unwrap_or(i64::MAX) +} + +/// Damage: content the reader can't interpret — never a skippable diagnostic. +pub fn damaged(path: &Path, line: usize, what: &str) -> Error { + Error::Damaged(format!( + "damaged log {}: {what} on line {line}", + path.display() + )) +} + +/// Stream a log's committed, non-blank lines as raw bytes, each with its +/// 1-indexed *physical* line number (blank lines are skipped but still +/// counted, so diagnostics name the on-disk line). Buffers one line at a +/// time (recordings run to GBs). Lock-free: the writer-quiescence contract +/// it requires is stated on [`JsonlSource`](crate::source::JsonlSource). +/// +/// The trailing newline is a line's commit marker: bytes after the last one +/// are a crash's torn tail, not yielded. +pub(crate) fn committed_lines( + path: &Path, +) -> Result)>>> { + let mut reader = BufReader::new(File::open(path)?); + let mut line_no = 0usize; + // Lines are yielded owned, so a buffer can't be reused outright — but + // seeding each with the previous line's capacity keeps the read_until + // growth (alloc-and-copy doublings from empty) off the per-line path. + let mut capacity = 0usize; + Ok(std::iter::from_fn(move || { + loop { + let mut buf = Vec::with_capacity(capacity); + match reader.read_until(b'\n', &mut buf) { + Err(e) => return Some(Err(e.into())), + Ok(0) => return None, // clean end of log + Ok(_) => {} + } + if !buf.ends_with(b"\n") { + return None; // no commit marker: a crash's torn tail + } + buf.pop(); + if buf.ends_with(b"\r") { + buf.pop(); + } + line_no += 1; + if buf.is_empty() { + continue; + } + capacity = buf.capacity(); + return Some(Ok((line_no, buf))); + } + })) +} + +/// Stream a log's committed lines, each deserialized as `T` and paired with +/// its 1-based physical line number — [`committed_lines`] with decoding. A +/// malformed committed line is a hard error naming its physical line — +/// skipping one would silently corrupt replayed state. +pub(crate) fn numbered_json_lines( + path: &Path, +) -> Result>> { + let lines = committed_lines(path)?; + let path = path.to_path_buf(); + Ok(lines.map(move |line| { + let (line_no, raw) = line?; + serde_json::from_slice(&raw) + .map(|entry| (line_no, entry)) + .map_err(|err| { + Error::Damaged(format!( + "malformed log line {line_no} in {}: {err}", + path.display() + )) + }) + })) +} + +/// All committed lines of the log, eagerly decoded: +/// [`numbered_json_lines`] collected, line numbers dropped. +pub(crate) fn read_strict(path: &Path) -> Result> { + numbered_json_lines(path)? + .map(|entry| Ok(entry?.1)) + .collect() +} + +/// Backward-scan window for locating the last committed newline: a torn +/// fragment is at most one line, so the first window almost always hits. +const TAIL_SCAN_CHUNK: usize = 64 * 1024; + +/// Truncate to the last newline when a torn fragment follows it, so an +/// append can't glue onto the fragment and turn a recoverable torn *tail* +/// into a malformed *interior* line. No-op for a missing, empty, or clean +/// log. The clean-case probe reads one byte, and the repair scans backwards +/// in [`TAIL_SCAN_CHUNK`] windows rather than reading the whole file — +/// appends are frequent and logs grow to GBs. +fn repair_torn_tail(path: &Path) -> Result<()> { + use std::io::{Read, Seek, SeekFrom}; + let mut file = match std::fs::File::open(path) { + Ok(file) => file, + // Absent: nothing to repair. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + let len = file.metadata()?.len(); + if len == 0 { + return Ok(()); + } + file.seek(SeekFrom::End(-1))?; + let mut last = [0u8; 1]; + file.read_exact(&mut last)?; + if last[0] == b'\n' { + return Ok(()); + } + let mut buf = vec![0u8; TAIL_SCAN_CHUNK]; + let mut end = len; + let clean_len = loop { + if end == 0 { + break 0; // no newline anywhere: the whole file is the fragment + } + let start = end.saturating_sub(TAIL_SCAN_CHUNK as u64); + let window = &mut buf[..(end - start) as usize]; + file.seek(SeekFrom::Start(start))?; + file.read_exact(window)?; + if let Some(i) = window.iter().rposition(|&b| b == b'\n') { + break start + i as u64 + 1; + } + end = start; + }; + drop(file); + tracing::warn!(path = %path.display(), "truncating torn tail before append"); + let file = OpenOptions::new().write(true).open(path)?; + file.set_len(clean_len)?; + file.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use serde::Deserialize; + + use super::*; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Entry { + n: u32, + } + + /// The setup operation, as `init`/provision/the sink run it. + fn create(path: &Path) -> Log { + Log::open(path.to_path_buf(), "test log").unwrap() + } + + /// A later command's handle: `open` on an existing log locks and appends. + fn open(path: &Path) -> Log { + create(path) + } + + #[test] + fn open_locks_before_touching_and_is_visible_from_creation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + let held = create(&path); + assert!( + path.exists(), + "the setup operation touches the file eagerly" + ); + // While the setup handle lives, a second handle cannot be opened. + let err = Log::open(path.clone(), "test log"); + assert!(err.is_err(), "contender must be rejected while held"); + drop(held); + assert!(read_strict::(&path).unwrap().is_empty()); + } + + #[test] + fn torn_tail_is_repaired_not_glued_onto() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + create(&path).append(&[Entry { n: 1 }]).unwrap(); + // Simulate a crash mid-append: a partial line with no trailing newline. + let mut file = OpenOptions::new().append(true).open(&path).unwrap(); + file.write_all(b"{\"n\": 2").unwrap(); + drop(file); + + open(&path).append(&[Entry { n: 3 }]).unwrap(); + let entries: Vec = read_strict(&path).unwrap(); + assert_eq!(entries, [Entry { n: 1 }, Entry { n: 3 }]); + } + + #[test] + fn torn_tail_spanning_the_scan_window_is_repaired() { + // A fragment longer than one backward-scan window forces the repair + // to cross a chunk boundary before it finds the last newline. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + create(&path).append(&[Entry { n: 1 }]).unwrap(); + let mut file = OpenOptions::new().append(true).open(&path).unwrap(); + file.write_all(&vec![b'x'; TAIL_SCAN_CHUNK + 1024]).unwrap(); + drop(file); + + open(&path).append(&[Entry { n: 3 }]).unwrap(); + let entries: Vec = read_strict(&path).unwrap(); + assert_eq!(entries, [Entry { n: 1 }, Entry { n: 3 }]); + } + + #[test] + fn read_ignores_a_torn_tail_without_repairing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + create(&path).append(&[Entry { n: 1 }]).unwrap(); + let mut file = OpenOptions::new().append(true).open(&path).unwrap(); + file.write_all(b"{\"n\": 2").unwrap(); + drop(file); + + let entries: Vec = read_strict(&path).unwrap(); + assert_eq!(entries, [Entry { n: 1 }]); + // Reads are read-only: the fragment stays until the next append + // repairs it (pinned by torn_tail_is_repaired_not_glued_onto). + let raw = std::fs::read_to_string(&path).unwrap(); + assert!(raw.ends_with("{\"n\": 2"), "fragment left in place"); + } + + #[test] + fn malformed_interior_line_fails_loud() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + std::fs::write(&path, "{\"n\": 1}\nnot json\n{\"n\": 2}\n").unwrap(); + let err = read_strict::(&path).unwrap_err(); + assert!(matches!(err, Error::Damaged(_))); + assert!(err.to_string().contains("line 2"), "got: {err}"); + } + + #[test] + fn blank_lines_are_skipped_but_keep_line_numbering() { + // `Log` never writes blank lines; reads tolerate them anyway. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + std::fs::write(&path, "{\"n\": 1}\n\nnot json\n").unwrap(); + let err = read_strict::(&path).unwrap_err(); + assert!(err.to_string().contains("line 3"), "got: {err}"); + } + + #[test] + fn remove_deletes_the_log_under_its_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + create(&path).append(&[Entry { n: 1 }]).unwrap(); + open(&path).remove().unwrap(); + assert!(!path.exists()); + // Dropping the handle release-unlinks the lock sibling too: a + // removed log leaves nothing behind. + assert!( + !crate::lock::lock_path(&path).exists(), + "release unlinks the lock sibling" + ); + } + + proptest! { + /// The repair invariant: truncation lands exactly on the last commit + /// marker — every committed byte survives, no fragment byte does. + #[test] + fn repair_truncates_exactly_to_the_committed_prefix( + lines in proptest::collection::vec("[ -~]{0,16}", 0..8), + // Sized, not regex-generated: fragments must reach past + // TAIL_SCAN_CHUNK to exercise the window-crossing scan. + fragment_len in 1usize..(TAIL_SCAN_CHUNK * 2), + ) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + let committed: String = lines.iter().map(|l| format!("{l}\n")).collect(); + let mut raw = committed.clone().into_bytes(); + raw.extend(std::iter::repeat_n(b'x', fragment_len)); + std::fs::write(&path, raw).unwrap(); + + repair_torn_tail(&path).unwrap(); + prop_assert_eq!(std::fs::read_to_string(&path).unwrap(), committed); + } + } +} diff --git a/crates/kraken-recording/src/lib.rs b/crates/kraken-recording/src/lib.rs new file mode 100644 index 0000000..b49b408 --- /dev/null +++ b/crates/kraken-recording/src/lib.rs @@ -0,0 +1,48 @@ +//! Event recording storage: durable persistence of frames, actions, and simulations. +//! +//! Layered bottom-up: a domain-free core — [`Sink`]/[`Source`] plus the +//! generic JSONL pair ([`JsonlSink`]/[`JsonlSource`]) — that any +//! `Serialize`/`Deserialize` type records through, and domain modules built +//! on top of it: +//! - [`frames`] — market data (ticker, trade, book, ohlc): the capture +//! contracts ([`CaptureSink`]/[`CaptureSource`]) over two backends, +//! JSONL ([`TapeSink`]/[`TapeSource`]) and feature-gated DuckDB +//! - [`paper`] — the paper-trading account journal +//! - [`actions`] — order placements and fills (planned) +//! +//! Every domain pairs write with read under one vocabulary — schema +//! versioning, metadata stamps, crash recovery — and the symmetric +//! round-trip contract: what was written is exactly what reads back. + +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::panic))] + +mod error; +mod fsync; +mod jsonl; +mod lock; +mod sink; +mod source; +mod time; + +pub mod actions; +pub mod frames; +pub mod paper; +pub mod schema; +pub mod tape; + +pub use error::{Error, Result}; +pub use frames::capture::{ + CaptureState, RecordingDeclaration, RecordingIntegrity, RecordingManifest, ensure_recordable, + resolve, sanitize_id, tapes_root, write_json_atomic, +}; +pub use frames::{CaptureSink, CaptureSource, MarketEvent, TapeSink, TapeSource}; +#[cfg(feature = "duckdb")] +pub use frames::{DuckdbSink, DuckdbSource}; +pub use jsonl::{damaged, line_seq}; +pub use lock::{FileLock, is_lock_held, lock_path}; +pub use sink::{JsonlSink, Sink}; +pub use source::{JsonlSource, Source}; +pub use tape::{ + TapeBackend, TapeEntry, TapeRef, TapeState, describe_tape, frame_count, list_recordings, +}; +pub use time::{format_instant, parse_instant}; diff --git a/crates/kraken-recording/src/lock.rs b/crates/kraken-recording/src/lock.rs new file mode 100644 index 0000000..6f91cbe --- /dev/null +++ b/crates/kraken-recording/src/lock.rs @@ -0,0 +1,282 @@ +//! Cross-process advisory file locks (`std::fs::File::try_lock`, flock +//! semantics). +//! +//! Locks live on a sidecar `.lock` ([`lock_path`]), never on the +//! guarded file itself: an OS lock there would collide with the engine that +//! owns it (Windows file locks are mandatory and fail the engine's I/O; on +//! unix, closing any fd drops the process's POSIX locks, so even a probe +//! could release DuckDB's own lock). +//! +//! Acquisition is fail-fast: in use means no — overlapping commands are a +//! conflict, never a queue. The kernel releases a lock the moment its holder +//! dies, so there is no stale state and no reclaim logic; liveness is probed +//! by try-locking ([`is_lock_held`]), which a recycled PID cannot spoof. +//! +//! Writers hold the lock exclusive; readers hold it shared +//! ([`FileLock::acquire_shared`]) — mutually exclusive with writers, but +//! invisible to the liveness probe and to each other. Only an exclusive +//! `Drop` unlinks the sidecar, while still holding the lock: any other +//! unlink splits the name from the held inode and breaks exclusion. A crash +//! skips the unlink; the leftover 0-byte file is inert and reads as free. +//! +//! KNOWN RACE (accepted): locks bind to inodes, unlink retires the *name*. A +//! contender that opens just before a release and locks just after holds a +//! nameless inode while the next contender locks a fresh file — two holders +//! on one resource; the same window can make [`is_lock_held`] read a live +//! holder as free. Requires microsecond interleaving; accepted over carrying +//! the fd-vs-path inode recheck that would close it (in git history if it +//! ever bites). + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::{Error, Result}; + +/// Writer (exclusive) vs reader (shared) posture; only exclusive holders +/// read as held to [`is_lock_held`]. +#[derive(Debug, PartialEq)] +enum LockMode { + Exclusive, + Shared, +} + +/// Advisory lock guard: the open fd *is* the lock. Dropping releases (so does +/// process death); an exclusive guard's drop also unlinks the sidecar. +#[derive(Debug)] +#[must_use = "the lock is released as soon as this guard is dropped"] +pub struct FileLock { + _file: fs::File, + /// The sidecar itself (`.lock`), not the guarded resource. + path: PathBuf, + /// Names the guarded resource in the release-failure warning. + resource: String, + mode: LockMode, +} + +/// Unlink-then-close: the fd (and so the lock) outlives the unlink because +/// fields drop after this body — narrowing, not closing, the accepted race. +impl Drop for FileLock { + fn drop(&mut self) { + // A shared holder must not unlink: other readers may still hold the + // inode, and a writer could then lock a fresh file mid-read. + if self.mode == LockMode::Shared { + return; + } + if let Err(e) = fs::remove_file(&self.path) { + // Already gone (e.g. removed by hand) is the desired end state. + if e.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + resource = %self.resource, + path = %self.path.display(), + error = %e, + "could not unlink lock file on release; the leftover is inert" + ); + } + } + } +} + +impl FileLock { + /// Acquire the (exclusive, writer) lock guarding `path`, or reject when + /// held — in use means no. `resource` names the contended thing in the + /// rejection message. + /// + /// # Errors + /// [`Rejected`](Error::Rejected) when another process holds the lock — + /// rarely also when a racing [`is_lock_held`] probe transiently holds it + /// (rerunning recovers); [`Io`](Error::Io) on filesystem failure. + pub fn acquire(path: &Path, resource: impl Into) -> Result { + Self::acquire_mode(path, resource.into(), LockMode::Exclusive) + } + + /// Reader counterpart of [`acquire`](Self::acquire): mutually exclusive + /// with writers, coexists with readers, reads as free to [`is_lock_held`]. + pub fn acquire_shared(path: &Path, resource: impl Into) -> Result { + Self::acquire_mode(path, resource.into(), LockMode::Shared) + } + + fn acquire_mode(path: &Path, resource: String, mode: LockMode) -> Result { + let lock_file = lock_path(path); + let file = open_lock_file(&lock_file)?; + let locked = match mode { + LockMode::Exclusive => file.try_lock(), + LockMode::Shared => file.try_lock_shared(), + }; + match locked { + Ok(()) => Ok(Self { + _file: file, + path: lock_file, + resource, + mode, + }), + Err(fs::TryLockError::WouldBlock) => Err(Error::Rejected(format!( + "{resource} is in use by another process." + ))), + Err(fs::TryLockError::Error(e)) => Err(Error::Io(e)), + } + } +} + +/// The sidecar `.lock` — appended, not `with_extension`, so sibling +/// tapes (`BTC-USD.duckdb`, `BTC-USD.jsonl`) get distinct locks. `path` +/// is the resource, never a lock file itself. +pub fn lock_path(path: &Path) -> PathBuf { + debug_assert!( + path.extension() != Some(std::ffi::OsStr::new("lock")), + "expected a resource path, got a lock file path: {}", + path.display() + ); + let mut raw = path.as_os_str().to_owned(); + raw.push(".lock"); + PathBuf::from(raw) +} + +/// Open (creating if absent, never truncating) the lock file, with its parent +/// directories in place. +fn open_lock_file(lock_path: &Path) -> Result { + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent)?; + } + Ok(fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(lock_path)?) +} + +/// Whether a live process holds the lock guarding `path`, probed with a +/// *shared* lock: a holder's exclusive lock reads held, while concurrent +/// probes never see one another. A missing file means no holder; an +/// unopenable one conservatively reads held, so a live holder is never +/// concluded gone. +pub fn is_lock_held(path: &Path) -> bool { + let file = match fs::File::open(lock_path(path)) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false, + Err(_) => return true, + }; + match file.try_lock_shared() { + // Acquired shared: nobody holds it exclusively. The probe's own + // shared lock dies with `file`. + Ok(()) => false, + Err(fs::TryLockError::WouldBlock) => true, + Err(fs::TryLockError::Error(_)) => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lock_is_released_on_drop_and_reacquirable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + { + let _lock = FileLock::acquire(&path, "Test resource").unwrap(); + assert!(is_lock_held(&path), "held while the guard lives"); + assert!(lock_path(&path).exists(), "sidecar exists while held"); + } + // Release unlinks the sidecar again and frees the lock; the guarded + // resource itself is never created. + assert!(!lock_path(&path).exists(), "release removes the sidecar"); + assert!(!path.exists()); + assert!(!is_lock_held(&path)); + drop(FileLock::acquire(&path, "Test resource").expect("reacquire after drop")); + } + + #[test] + fn second_acquire_is_rejected_while_held() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + let _held = FileLock::acquire(&path, "Test resource").unwrap(); + + let err = FileLock::acquire(&path, "Test resource").unwrap_err(); + assert!(matches!(err, Error::Rejected(_))); + assert!(err.to_string().contains("in use by another process")); + } + + #[test] + fn probe_does_not_steal_or_keep_the_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + let held = FileLock::acquire(&path, "Test resource").unwrap(); + assert!(is_lock_held(&path)); + drop(held); + // Probe a crash-leftover sidecar (release unlinks, so seed one): the + // probe transiently acquires and must not still hold afterwards. + drop(open_lock_file(&lock_path(&path)).unwrap()); + assert!(!is_lock_held(&path)); + assert!(!is_lock_held(&path)); + drop(FileLock::acquire(&path, "Test resource").expect("probe left the lock free")); + } + + #[test] + fn missing_lock_file_reads_as_not_held() { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_lock_held(&dir.path().join("never-created"))); + } + + #[test] + fn shared_holder_excludes_writers_but_not_probes_or_readers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + let reader = FileLock::acquire_shared(&path, "Test resource").unwrap(); + + assert!(!is_lock_held(&path), "a reader must not read as a writer"); + let err = FileLock::acquire(&path, "Test resource").unwrap_err(); + assert!(matches!(err, Error::Rejected(_))); + drop(FileLock::acquire_shared(&path, "Test resource").expect("readers coexist")); + + drop(reader); + drop(FileLock::acquire(&path, "Test resource").expect("writer acquires after the read")); + } + + #[test] + fn exclusive_holder_rejects_shared_acquisition() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + let _writer = FileLock::acquire(&path, "Test resource").unwrap(); + + let err = FileLock::acquire_shared(&path, "Test resource").unwrap_err(); + assert!(matches!(err, Error::Rejected(_))); + assert!(err.to_string().contains("in use"), "got: {err}"); + } + + #[test] + fn shared_release_leaves_the_sidecar_in_place() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + let first = FileLock::acquire_shared(&path, "Test resource").unwrap(); + let second = FileLock::acquire_shared(&path, "Test resource").unwrap(); + + drop(first); + assert!( + lock_path(&path).exists(), + "sidecar survives a shared release" + ); + let err = FileLock::acquire(&path, "Test resource").unwrap_err(); + assert!( + matches!(err, Error::Rejected(_)), + "the remaining reader still excludes writers through the same inode" + ); + drop(second); + } + + #[test] + fn probes_do_not_see_each_other() { + // Probes take shared locks: with another probe mid-flight on the + // same free lock, is_lock_held must still read it as free — only a + // real (exclusive) holder reads as held. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state"); + // A crash-leftover sidecar nobody holds (release would unlink it). + drop(open_lock_file(&lock_path(&path)).unwrap()); + let probe_like = fs::File::open(lock_path(&path)).unwrap(); + probe_like.try_lock_shared().unwrap(); + assert!( + !is_lock_held(&path), + "a concurrent probe must not read as a holder" + ); + } +} diff --git a/crates/kraken-recording/src/paper/mod.rs b/crates/kraken-recording/src/paper/mod.rs new file mode 100644 index 0000000..350f0ce --- /dev/null +++ b/crates/kraken-recording/src/paper/mod.rs @@ -0,0 +1,89 @@ +//! The paper-trading domain: the account's event journal. +//! +//! A paper account **is** its append-only journal — state is derived by +//! folding events in order, never snapshotted — so the domain's storage +//! contract is exactly [`journal`]/[`replay`]: the generic JSONL pair under +//! this domain's lock-holder name. The event type stays with the simulator +//! (the crate persists any `Serialize` envelope), so the pair is generic. +//! +//! A columnar (DuckDB) backend for querying trading history is planned but +//! unimplemented; the journal is the source of truth either way. + +use std::path::PathBuf; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::Result; +use crate::sink::JsonlSink; +use crate::source::JsonlSource; + +/// Names the journal's lock holder in the "in use" error a contender sees. +pub const JOURNAL_PURPOSE: &str = "paper account log"; + +/// Open (creating if absent) an account journal for appending, holding its +/// single-writer lock for the sink's lifetime — a concurrent open is +/// rejected, so one command's read→decide→append is one transaction. +pub fn journal(path: PathBuf) -> Result> { + JsonlSink::create(path, JOURNAL_PURPOSE) +} + +/// Open an account journal for replay, or `None` when none exists. Lock-free: +/// the caller guarantees writer quiescence, normally by holding the journal's +/// own [`journal`] sink (see [`JsonlSource`]). +pub fn replay(path: PathBuf) -> Option> { + JsonlSource::open(path) +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + use crate::error::Error; + use crate::sink::Sink; + use crate::source::Source; + + /// Stands in for the simulator's record envelope. + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Event { + balance: u32, + } + + #[test] + fn journal_round_trips_events_across_reopens() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + let mut sink = journal::(path.clone()).unwrap(); + sink.record(&[Event { balance: 100 }]).unwrap(); + drop(sink); // release the writer lock + + let mut sink = journal::(path.clone()).unwrap(); + sink.record(&[Event { balance: 90 }]).unwrap(); + drop(sink); + + let replayed: Vec = replay(path) + .expect("journal exists") + .read() + .unwrap() + .collect(); + assert_eq!(replayed, [Event { balance: 100 }, Event { balance: 90 }]); + } + + #[test] + fn concurrent_journal_open_is_rejected_naming_the_holder() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + let _held = journal::(path.clone()).unwrap(); + + let err = journal::(path).unwrap_err(); + assert!(matches!(err, Error::Rejected(_))); + assert!(err.to_string().contains(JOURNAL_PURPOSE), "got: {err}"); + } + + #[test] + fn replay_of_a_missing_journal_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert!(replay::(dir.path().join("events.jsonl")).is_none()); + } +} diff --git a/crates/kraken-recording/src/schema.rs b/crates/kraken-recording/src/schema.rs new file mode 100644 index 0000000..79c9416 --- /dev/null +++ b/crates/kraken-recording/src/schema.rs @@ -0,0 +1,88 @@ +//! The recording schema's cross-cutting pieces: the version stamp, its +//! compatibility rule, and the `_meta` table. The per-channel tables are +//! defined data-driven next to their codecs (`frames::duckdb::tables`). +//! +//! Storage conventions across the channel tables: numeric columns are exact +//! `DECIMAL(38,18)` (fed as decimal strings, never floats); unsigned wire +//! ids/counts use `UBIGINT`/`UINTEGER` (lossless); timestamps stay RFC3339 +//! `VARCHAR` — a microsecond `TIMESTAMP` would truncate the wire's +//! nanoseconds. `recv_ts` is the local write time; a monotonic per-frame +//! `seq` (high-water mark in `_meta.last_seq`) groups a frame's rows and +//! orders frames across reopen. + +/// Schema version stamp (SemVer; a differing MAJOR is incompatible, with no +/// migration path — old tapes are rejected and re-recorded). 1.0 is this +/// crate's first stamp: frames on kraken-core's typed envelope with exact +/// decimals, and a capture summary carrying `frames_unparsed`. +pub const SCHEMA_VERSION: &str = "1.0"; + +/// The capture source recorded in `_meta`. +pub const SOURCE: &str = "kraken-spot-ws-v2"; + +/// The key/value metadata table every tape carries: version/source/symbols/ +/// channels/window, the final lag summary, and the resumable `last_seq`. +#[cfg(feature = "duckdb")] +pub const META_DDL: &str = + "CREATE TABLE IF NOT EXISTS _meta (key VARCHAR PRIMARY KEY, value VARCHAR)"; + +/// Whether an on-disk schema version is compatible with what this build writes — +/// true iff they share a MAJOR component. Ungated: the jsonl sidecar carries +/// the same stamp, so version checks don't need the `duckdb` feature. +pub fn is_compatible(found: &str) -> bool { + major(found) == major(SCHEMA_VERSION) +} + +/// Whether a stamped version is a newer MINOR of this build's MAJOR — the one +/// case where undecodable content is a newer writer's vocabulary rather than +/// damage, so a reader may skip it instead of failing loud. +pub(crate) fn is_newer_minor(found: &str) -> bool { + is_compatible(found) && minor(found) > minor(SCHEMA_VERSION) +} + +/// The one incompatible-stamp rejection, shared by every backend's gate so +/// the rule and its wording cannot drift. `action` names the refused +/// operation ("append", "read"). +pub(crate) fn ensure_compatible(found: &str, action: &str) -> crate::error::Result<()> { + if is_compatible(found) { + return Ok(()); + } + Err(crate::error::Error::Rejected(format!( + "recording schema version {found} is incompatible with this build (v{SCHEMA_VERSION}); \ + refusing to {action}" + ))) +} + +fn major(version: &str) -> &str { + version.split('.').next().unwrap_or(version) +} + +/// An absent or unparseable minor reads as 0: never "newer", so damage +/// tolerance stays off for a malformed stamp. +fn minor(version: &str) -> u64 { + version + .split('.') + .nth(1) + .and_then(|m| m.parse().ok()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_compatibility_is_by_major() { + assert!(is_compatible("1.0")); + assert!(is_compatible("1.7")); // newer minor, same major + assert!(!is_compatible("0.9")); + assert!(!is_compatible("2.0")); + } + + #[test] + fn newer_minor_is_same_major_and_greater_minor_only() { + assert!(is_newer_minor("1.7")); + assert!(!is_newer_minor(SCHEMA_VERSION), "own stamp is not newer"); + assert!(!is_newer_minor("2.1"), "different major is incompatible"); + assert!(!is_newer_minor("1.oops"), "malformed minor is not newer"); + } +} diff --git a/crates/kraken-recording/src/sink.rs b/crates/kraken-recording/src/sink.rs new file mode 100644 index 0000000..b6f4042 --- /dev/null +++ b/crates/kraken-recording/src/sink.rs @@ -0,0 +1,154 @@ +//! The write side: the domain-free [`Sink`] contract and its generic JSONL +//! backend [`JsonlSink`]. Deliberately free of any event domain — any +//! `Serialize` entry type — so every recorded track shares one write +//! contract; domain extensions (e.g. the market capture's +//! `CaptureSink`) live with their domains under [`frames`](crate::frames). + +use std::marker::PhantomData; +use std::path::PathBuf; + +use serde::Serialize; + +use crate::error::Result; +use crate::jsonl::Log; + +/// The write side of a recorded track: a destination that persists (or +/// renders) batches of entries. Generic over the entry type so every log +/// shares one write contract instead of a bespoke implementation per file. +/// +/// Consumed **by value** — drivers are generic over the concrete sink, never +/// `Box`. Driven from a single dedicated blocking thread, so the +/// methods are synchronous: the async boundary is the bounded channel in +/// front of the thread, not the sink. +/// +/// The defaults encode the layer's baseline: every current backend is +/// durable-per-batch on a clean return (fsync'd log append, DB transaction, +/// flushed stdout write), so [`flush`](Self::flush) has nothing to do and +/// [`close`](Self::close) only flushes before resources release on drop. +/// A backend that buffers across `record` calls must override `flush`; +/// one with end-of-life work beyond flushing must override `close`. +pub trait Sink { + type Item; + + fn record(&mut self, batch: &[Self::Item]) -> Result<()>; + + /// Make everything recorded so far durable (or visible). + fn flush(&mut self) -> Result<()> { + Ok(()) + } + + /// Flush, then release resources by consuming the sink. Stamping a + /// stream's capture summary is the market layer's concern + /// ([`CaptureSink::finalize`](crate::frames::CaptureSink::finalize)). + fn close(mut self) -> Result<()> + where + Self: Sized, + { + self.flush() + } +} + +/// The generic JSONL sink — the [`Sink`] view of the crate's log engine. +/// +/// One fsync'd JSONL line per entry, for any `Serialize` entry type, so any +/// log reuses it instead of growing its own write path. Deliberately +/// domain-free; the market tape variant with frame filtering and the +/// metadata sidecar is [`TapeSink`](crate::frames::jsonl::TapeSink). +#[derive(Debug)] +pub struct JsonlSink { + log: Log, + _entry: PhantomData, +} + +impl JsonlSink { + /// Open the log at `path` (creating it if absent) and hold its single-writer + /// lock for the sink's lifetime. `purpose` names the holder in the busy-log + /// error a contender sees. + pub fn create(path: PathBuf, purpose: impl Into) -> Result { + Ok(Self { + log: Log::open(path, purpose)?, + _entry: PhantomData, + }) + } + + /// Delete the log under its lock, consuming the sink. Already absent + /// counts as success. + pub fn remove(self) -> Result<()> { + self.log.remove() + } +} + +impl Sink for JsonlSink { + type Item = T; + + /// One fsync'd batch per call; the log is durable after every append and + /// the writer lock releases on drop — there is nothing to finalize. + fn record(&mut self, batch: &[T]) -> Result<()> { + self.log.append(batch) + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use serde::Deserialize; + + use super::*; + + struct Probe { + flushes: Rc>, + } + + impl Sink for Probe { + type Item = (); + + fn record(&mut self, _batch: &[()]) -> Result<()> { + Ok(()) + } + + fn flush(&mut self) -> Result<()> { + self.flushes.set(self.flushes.get() + 1); + Ok(()) + } + } + + #[test] + fn default_close_flushes_before_release() { + // The trio's composition contract: a buffering sink that overrides + // only `flush` must still be flushed by the default `close`. + let flushes = Rc::new(Cell::new(0)); + let sink = Probe { + flushes: Rc::clone(&flushes), + }; + + sink.close().unwrap(); + assert_eq!(flushes.get(), 1); + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Entry { + n: u32, + } + + #[test] + fn appends_each_entry_as_one_line_across_reopens() { + // The sink is a thin typed view of `Log`: entries land one per line, a + // later create() appends rather than truncates, and close releases the + // lock so the next writer can open. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + + let mut sink: JsonlSink = JsonlSink::create(path.clone(), "test log").unwrap(); + sink.record(&[Entry { n: 1 }, Entry { n: 2 }]).unwrap(); + drop(sink); // the writer lock releases on drop + + let mut sink: JsonlSink = JsonlSink::create(path.clone(), "test log").unwrap(); + sink.record(&[Entry { n: 3 }]).unwrap(); + drop(sink); + + let entries: Vec = crate::jsonl::read_strict(&path).unwrap(); + assert_eq!(entries, [Entry { n: 1 }, Entry { n: 2 }, Entry { n: 3 }]); + } +} diff --git a/crates/kraken-recording/src/source.rs b/crates/kraken-recording/src/source.rs new file mode 100644 index 0000000..7f887d5 --- /dev/null +++ b/crates/kraken-recording/src/source.rs @@ -0,0 +1,112 @@ +//! The read side — pendant of [`sink`](crate::sink): the domain-free +//! [`Source`] contract and the generic JSONL backend [`JsonlSource`]. +//! Write and read share each track's serde structs, so an undecodable line +//! is damage and fails loud; only a crash's torn tail is tolerated. The +//! market read contracts live with their domain under +//! [`frames`](crate::frames). + +use std::marker::PhantomData; +use std::path::PathBuf; + +use serde::de::DeserializeOwned; + +use crate::error::Result; +use crate::jsonl; + +/// The read side of a recorded track, handed back as entries. The pendant +/// of [`Sink`](crate::sink::Sink), and generic over the entry type for the +/// same reason — one read contract for every track. +/// +/// Construction stays on the concrete types: what it takes to open a track +/// (a path, a table, a lock already held) is exactly what differs per +/// backend. +pub trait Source { + type Item; + + /// The whole track as an ordered stream. Damage fails here, before the + /// first item, so no consumer ever observes a partial stream — the + /// deliberate cost is that implementations materialize the entire track + /// in memory before yielding (validation and cross-table ordering need + /// it), so a GB-scale replay pays O(track) memory. + fn read(&self) -> Result>; +} + +/// The generic JSONL source — the read pendant of +/// [`JsonlSink`](crate::sink::JsonlSink). +/// +/// Lock-free by design, so it can read a log whose lock the caller already +/// holds (a journal owner reads its own log under its sink's lock). That +/// makes writer quiescence the CALLER's contract: read only while you hold +/// the log's sink or its [`FileLock`](crate::lock::FileLock), or after +/// probing [`is_lock_held`](crate::lock::is_lock_held). Racing a live writer +/// can observe a torn-tail repair mid-splice and read lines no process ever +/// wrote. +pub struct JsonlSource { + path: PathBuf, + _entry: PhantomData, +} + +impl JsonlSource { + /// Open an existing log for reading, or `None` when there is nothing on disk. + pub fn open(path: PathBuf) -> Option { + path.exists().then_some(Self { + path, + _entry: PhantomData, + }) + } + + /// [`Source::read`], each entry paired with its 1-based *physical* line + /// number (blank lines are skipped but counted) — for consumers whose + /// `seq` and diagnostics must agree with this module's own on-disk line + /// reporting. + pub fn read_numbered(&self) -> Result> { + Ok(jsonl::numbered_json_lines(&self.path)? + .collect::>>()? + .into_iter()) + } +} + +impl Source for JsonlSource { + type Item = T; + + /// Strict read: a torn tail is ignored (a crash artifact), a malformed + /// interior line fails loud. + fn read(&self) -> Result> { + Ok(jsonl::read_strict(&self.path)?.into_iter()) + } +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use super::*; + use crate::sink::{JsonlSink, Sink}; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Entry { + n: u32, + } + + #[test] + fn open_of_a_missing_log_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert!(JsonlSource::::open(dir.path().join("log.jsonl")).is_none()); + } + + #[test] + fn reads_back_what_the_sink_wrote() { + // The pair's round-trip contract: source(sink(entries)) == entries. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + let mut sink: JsonlSink = JsonlSink::create(path.clone(), "test log").unwrap(); + sink.record(&[Entry { n: 1 }, Entry { n: 2 }]).unwrap(); + drop(sink); // release the writer lock before reading + + let source = JsonlSource::::open(path).expect("log exists"); + assert_eq!( + source.read().unwrap().collect::>(), + [Entry { n: 1 }, Entry { n: 2 }] + ); + } +} diff --git a/crates/kraken-recording/src/tape.rs b/crates/kraken-recording/src/tape.rs new file mode 100644 index 0000000..f64ff00 --- /dev/null +++ b/crates/kraken-recording/src/tape.rs @@ -0,0 +1,400 @@ +//! Tape identity and the read-only catalog behind `kraken tape list`. +//! +//! A printed [`TapeRef`] is a public contract: the replay `--source` flag +//! and sealed lab session plans consume the exact `Display` form, so the grammar +//! is explicit-prefix only (`tape:` for the global library, +//! `run:` for a session's own tape) and parse/display round-trip by law. +//! Growth (content hashes, time slices) must be additive to the string form. + +use std::fmt; +use std::path::Path; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use serde::Serialize; + +use crate::error::{Error, Result}; +use crate::frames::capture::{RecordingIntegrity, load_sidecar, tapes_root}; +use crate::lock::is_lock_held; + +/// A namespaced pointer to recorded market data: a standalone `kraken record` +/// tape, or the tape a playground session captured for itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TapeRef { + Recording { name: String }, + Session { name: String }, +} + +impl TapeRef { + pub fn name(&self) -> &str { + match self { + Self::Recording { name } | Self::Session { name } => name, + } + } +} + +impl FromStr for TapeRef { + type Err = Error; + + /// Explicit prefixes only — no magic resolution order, so a ref means + /// the same thing in every future consumer. + fn from_str(s: &str) -> Result { + let (namespace, name) = s.split_once(':').ok_or_else(|| { + Error::Rejected(format!( + "invalid tape ref {s:?}: expected 'tape:' or 'session:'" + )) + })?; + if !kraken_core::name::is_safe_name_segment(name) { + return Err(Error::Rejected(format!( + "invalid tape name {name:?}: use only letters, digits, '.', '-', or '_' \ + (no '/', spaces, or '.'/'..')." + ))); + } + let name = name.to_owned(); + match namespace { + "tape" => Ok(Self::Recording { name }), + "session" => Ok(Self::Session { name }), + other => Err(Error::Rejected(format!( + "unknown tape namespace {other:?}: expected 'tape' or 'session'" + ))), + } + } +} + +impl fmt::Display for TapeRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Recording { name } => write!(f, "tape:{name}"), + Self::Session { name } => write!(f, "session:{name}"), + } + } +} + +/// Storage backend of one tape file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TapeBackend { + Jsonl, + Duckdb, +} + +impl TapeBackend { + /// The file extension this backend reads and writes. + pub fn extension(self) -> &'static str { + match self { + Self::Jsonl => "jsonl", + Self::Duckdb => "duckdb", + } + } + + /// Backends this build can read — the one place the `duckdb` feature + /// gate lives, so no caller duplicates cfg knowledge. + pub fn supported() -> &'static [Self] { + #[cfg(feature = "duckdb")] + { + &[Self::Jsonl, Self::Duckdb] + } + #[cfg(not(feature = "duckdb"))] + { + &[Self::Jsonl] + } + } +} + +impl fmt::Display for TapeBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.extension()) + } +} + +/// What a tape file can say about its completeness at list time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TapeState { + /// Finalized with integrity counters — replayable as recorded. + Finalized, + /// Being written now, or crashed mid-capture; completeness unknown. + Unfinalized, + /// Present but unreadable under this build's contract (see `detail`). + Damaged, +} + +/// One catalog row. Never an error by construction: a damaged tape is a +/// described row, so one bad file cannot kill the listing. +#[derive(Debug, Serialize)] +pub struct TapeEntry { + /// The [`TapeRef`] display form — the string every other command accepts. + pub tape: String, + pub backend: TapeBackend, + pub state: TapeState, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub symbols: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub channels: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_start: Option>, + /// The finalize stamp, verbatim — window end and gap counters. + #[serde(skip_serializing_if = "Option::is_none")] + pub integrity: Option, + /// Frames the tape holds, so an empty capture is distinguishable from a + /// dense one at a glance. `None` when the backend can't be read (damaged + /// or held by a live writer) — shown as unknown, never a misleading zero. + #[serde(skip_serializing_if = "Option::is_none")] + pub events: Option, + /// Whether a writer holds the tape right now. + pub in_use: bool, + /// Why the row is `Damaged` — and only then: a live capture is healthy, + /// its metadata is just not readable yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl TapeEntry { + /// The catalog's total order: tape ref, then backend. Defined once so + /// `list_recordings` and the `tape list` merge sort by the same key. + pub fn sort_key(&self) -> (&str, TapeBackend) { + (self.tape.as_str(), self.backend) + } +} + +/// Describe one tape file without ever failing: metadata when readable, +/// a `Damaged` row naming the reason when not. +pub fn describe_tape(tape: &TapeRef, path: &Path, backend: TapeBackend) -> TapeEntry { + let in_use = is_lock_held(path); + let manifest = match backend { + #[cfg(feature = "duckdb")] + TapeBackend::Duckdb => duckdb_manifest(path), + _ => load_sidecar(path, "list"), + }; + match manifest { + Ok(manifest) => { + let state = if manifest.summary.is_some() { + TapeState::Finalized + } else { + TapeState::Unfinalized + }; + TapeEntry { + tape: tape.to_string(), + backend, + state, + symbols: manifest.meta.symbols, + channels: manifest.meta.channels, + window_start: Some(manifest.meta.window_start), + integrity: manifest.summary, + events: frame_count(path), + in_use, + detail: None, + } + } + // A held writer lock is a live capture, not damage — the metadata is + // simply not readable yet (DuckDB keeps it inside the locked file). + Err(err) => { + let state = if in_use { + TapeState::Unfinalized + } else { + TapeState::Damaged + }; + TapeEntry { + tape: tape.to_string(), + backend, + state, + symbols: Vec::new(), + channels: Vec::new(), + window_start: None, + integrity: None, + events: None, + in_use, + detail: (state == TapeState::Damaged).then(|| err.to_string()), + } + } + } +} + +/// Frames a tape holds, read cheaply at list time and keyed off the file +/// extension: DuckDB's persisted `last_seq` high-water mark, or a JSONL line +/// count (one frame per line). `None` when the file can't be read (a +/// live-locked or damaged tape), so a caller shows "unknown" rather than a +/// misleading zero. +pub fn frame_count(path: &Path) -> Option { + match path.extension().and_then(|ext| ext.to_str()) { + #[cfg(feature = "duckdb")] + Some("duckdb") => crate::frames::DuckdbSource::open(path) + .ok()? + .frame_count() + .ok() + .flatten(), + Some("jsonl") => jsonl_line_count(path), + _ => None, + } +} + +/// One JSONL frame per line, so the committed line count is the frame count. +fn jsonl_line_count(path: &Path) -> Option { + use std::io::BufRead; + let file = std::fs::File::open(path).ok()?; + u64::try_from(std::io::BufReader::new(file).lines().count()).ok() +} + +/// Read a DuckDB tape's self-description through the read-only source +/// (shared lock; a live writer rejects and the caller degrades the row). +#[cfg(feature = "duckdb")] +fn duckdb_manifest(path: &Path) -> Result { + use crate::frames::CaptureSource; + crate::frames::DuckdbSource::open(path)?.capture() +} + +/// Every tape under `/tapes/`, sorted by ref then backend. +/// A missing tapes directory is an empty catalog, not an error. +/// On a build without the `duckdb` feature, `.duckdb` files are skipped +/// entirely — that build can neither describe nor replay them. +pub fn list_recordings(base: &Path) -> Result> { + let entries = match std::fs::read_dir(tapes_root(base)) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut rows = Vec::new(); + for entry in entries { + let path = entry?.path(); + let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else { + continue; + }; + // Sidecars, locks, and foreign files match no supported backend. + let Some(&backend) = TapeBackend::supported() + .iter() + .find(|backend| backend.extension() == extension) + else { + continue; + }; + // A stem outside the name grammar cannot be referenced by any ref + // string, so it cannot be listed under one either. + let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if !kraken_core::name::is_safe_name_segment(name) { + continue; + } + let tape = TapeRef::Recording { + name: name.to_owned(), + }; + rows.push(describe_tape(&tape, &path, backend)); + } + rows.sort_unstable_by(|a, b| a.sort_key().cmp(&b.sort_key())); + Ok(rows) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::frames::capture::resolve; + + #[test] + fn frame_count_reads_jsonl_lines_and_is_unknown_for_foreign_files() { + // One JSONL frame per line, so the count is the line count; a file that + // matches no backend reads as unknown (None), never a misleading 0. + let dir = tempfile::tempdir().unwrap(); + let jsonl = dir.path().join("t.jsonl"); + std::fs::write(&jsonl, "frame-a\nframe-b\nframe-c\n").unwrap(); + assert_eq!(frame_count(&jsonl), Some(3)); + + let empty = dir.path().join("empty.jsonl"); + std::fs::write(&empty, "").unwrap(); + assert_eq!( + frame_count(&empty), + Some(0), + "a quiet capture is 0, not unknown" + ); + + let foreign = dir.path().join("notes.txt"); + std::fs::write(&foreign, "x").unwrap(); + assert_eq!(frame_count(&foreign), None); + assert_eq!(frame_count(&dir.path().join("gone.jsonl")), None); + } + + #[test] + fn ref_parses_both_namespaces_and_rejects_the_rest() { + assert_eq!( + "tape:jun-crash".parse::().unwrap(), + TapeRef::Recording { + name: "jun-crash".into() + } + ); + assert_eq!( + "session:btc-dip".parse::().unwrap(), + TapeRef::Session { + name: "btc-dip".into() + } + ); + for bad in [ + "jun-crash", + "tapes:jun-crash", + "tape:", + "tape:a/b", + "tape:..", + "session:a b", + // The pre-release path-shaped grammar is gone, not aliased. + "recordings/jun-crash", + "sessions/btc-dip", + ] { + assert!(bad.parse::().is_err(), "{bad}"); + } + } + + proptest! { + #[test] + fn ref_display_round_trips(name in "[A-Za-z0-9._-]{1,32}", session in proptest::bool::ANY) { + prop_assume!(kraken_core::name::is_safe_name_segment(&name)); + let tape = if session { + TapeRef::Session { name } + } else { + TapeRef::Recording { name } + }; + prop_assert_eq!(tape.to_string().parse::().unwrap(), tape); + } + } + + #[test] + fn missing_recordings_dir_lists_empty_not_an_error() { + let base = tempfile::tempdir().unwrap(); + assert!(list_recordings(base.path()).unwrap().is_empty()); + } + + /// A held writer lock is a live capture: unfinalized, in use, and no + /// damage detail — never a false "something is wrong" signal. + #[test] + fn live_writer_reads_unfinalized_and_in_use_not_damaged() { + let base = tempfile::tempdir().unwrap(); + let path = resolve(base.path(), "live", "jsonl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "").unwrap(); + let _writer = crate::lock::FileLock::acquire(&path, "capture").unwrap(); + + let entry = describe_tape( + &TapeRef::Recording { + name: "live".into(), + }, + &path, + TapeBackend::Jsonl, + ); + assert_eq!(entry.state, TapeState::Unfinalized); + assert!(entry.in_use); + assert!(entry.detail.is_none()); + } + + #[test] + fn damaged_sidecar_is_a_described_row_not_a_listing_failure() { + let base = tempfile::tempdir().unwrap(); + let path = resolve(base.path(), "broken", "jsonl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "{}\n").unwrap(); + + let rows = list_recordings(base.path()).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].tape, "tape:broken"); + assert_eq!(rows[0].state, TapeState::Damaged); + assert!(rows[0].detail.is_some()); + assert!(!rows[0].in_use); + } +} diff --git a/crates/kraken-recording/src/time.rs b/crates/kraken-recording/src/time.rs new file mode 100644 index 0000000..33b7bae --- /dev/null +++ b/crates/kraken-recording/src/time.rs @@ -0,0 +1,20 @@ +//! Instant parsing for recorded timestamps, shared by every layer that +//! orders them. + +use chrono::{DateTime, SecondsFormat, Utc}; + +/// Recorded stamps mix offset styles (`+00:00` from older builds, `Z` from +/// the wire), so ordering must compare parsed instants — string comparison +/// sorts them wrong. +pub fn parse_instant(ts: &str) -> Option> { + DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +/// The one rendering for instants at non-serde boundaries (DuckDB `_meta` +/// TEXT values): `Z`-suffixed RFC3339, byte-identical to chrono's serde +/// output, so a stamp reads the same no matter which path wrote it. +pub fn format_instant(at: DateTime) -> String { + at.to_rfc3339_opts(SecondsFormat::AutoSi, true) +} diff --git a/crates/kraken-recording/tests/e2e.rs b/crates/kraken-recording/tests/e2e.rs new file mode 100644 index 0000000..cd583d8 --- /dev/null +++ b/crates/kraken-recording/tests/e2e.rs @@ -0,0 +1,448 @@ +//! End-to-end tests over the crate's public surface: the full capture +//! lifecycle a real recorder drives — validate channels, resolve the tape +//! path, record batches, finalize, replay — plus the cross-cutting behaviours +//! no single module owns: crash-and-resume, torn-tail repair, writer/reader +//! exclusion, and (behind `duckdb`) backend equivalence across sessions. + +// Integration tests compile with `--test` (so `cfg(test)` holds); same +// carve-out as lib.rs, extended to helpers outside `#[test]` functions. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + +use std::io::Write; +use std::path::Path; +use std::str::FromStr; + +use kraken_core::subscribe::message::{ + BookData, ExtraFields, MessageType, OhlcData, PriceLevel, StatusData, SystemStatus, TickerData, + TradeData, +}; +use kraken_core::{ChannelData, ChannelMessage, OrderSide, OrderType, SubscribableChannel}; +use kraken_recording::{ + CaptureSink, CaptureSource, Error, MarketEvent, RecordingDeclaration, RecordingIntegrity, Sink, + Source, TapeSink, TapeSource, ensure_recordable, is_lock_held, resolve, sanitize_id, schema, +}; + +/// Parse a numeric fixture literal into the field's own inferred type — +/// `Decimal` at every call site, without a direct `rust_decimal` dev-dependency. +fn dec(raw: &str) -> T +where + T::Err: std::fmt::Debug, +{ + raw.parse().expect("fixture literal parses") +} + +fn meta() -> RecordingDeclaration { + RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into()], + channels: vec![ + "ticker".into(), + "trade".into(), + "book".into(), + "ohlc".into(), + ], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "e2e".into(), + } +} + +/// The server pushes `status` unsolicited on every connection; a capture +/// must drop it without disturbing the frame numbering. +fn status_frame() -> ChannelMessage { + ChannelMessage { + body: ChannelData::Status(vec![StatusData { + api_version: "v2".into(), + connection_id: 1, + system: SystemStatus::Online, + version: "2.0".into(), + extra: ExtraFields::new(), + }]), + message_type: MessageType::Update, + sequence: None, + } +} + +/// One frame per recordable channel inside minute `minute` (single digit), +/// timestamps strictly increasing — including the shapes that stress the +/// read-back inverse: a two-entry trade frame and a two-sided book snapshot. +fn market_frames(minute: u8) -> Vec { + let ts = |sec: u8| format!("2026-01-01T00:0{minute}:0{sec}.000000Z"); + let id = u64::from(minute) * 10; + let frame = |message_type: MessageType, body: ChannelData| ChannelMessage { + body, + message_type, + sequence: None, + }; + vec![ + frame( + MessageType::Update, + ChannelData::Ticker(vec![TickerData { + symbol: "BTC/USD".into(), + bid: dec("68000.1"), + bid_qty: dec("0.5"), + ask: dec("68000.2"), + ask_qty: dec("0.25"), + last: dec("68000.15"), + volume: dec("1234.5"), + vwap: dec("67990.4"), + low: dec("67000.0"), + high: dec("69000.0"), + change: dec("-120.5"), + change_pct: dec("-0.18"), + timestamp: ts(1), + }]), + ), + frame( + MessageType::Update, + ChannelData::Trade(vec![ + TradeData { + symbol: "BTC/USD".into(), + side: OrderSide::Buy, + price: dec("68000.1"), + qty: dec("0.5"), + ord_type: OrderType::Market, + trade_id: id + 1, + timestamp: ts(2), + }, + TradeData { + symbol: "BTC/USD".into(), + side: OrderSide::Sell, + price: dec("68000.2"), + qty: dec("0.25"), + ord_type: OrderType::Limit, + trade_id: id + 2, + timestamp: ts(3), + }, + ]), + ), + frame( + MessageType::Snapshot, + ChannelData::Book(vec![BookData { + symbol: "BTC/USD".into(), + bids: vec![ + PriceLevel { + price: dec("68000.0"), + qty: dec("1.0"), + }, + PriceLevel { + price: dec("67999.5"), + qty: dec("2.0"), + }, + ], + asks: vec![PriceLevel { + price: dec("68000.5"), + qty: dec("3.0"), + }], + checksum: 4242, + timestamp: ts(4), + }]), + ), + frame( + MessageType::Update, + ChannelData::Ohlc(vec![OhlcData { + symbol: "BTC/USD".into(), + open: dec("68000.0"), + high: dec("68001.0"), + low: dec("67999.0"), + close: dec("68000.5"), + vwap: dec("68000.2"), + trades: 7, + volume: dec("123.0"), + interval_begin: format!("2026-01-01T00:0{minute}:00.000000000Z"), + interval: 1, + timestamp: Some(ts(5)), + }]), + ), + ] +} + +fn payloads(events: &[MarketEvent]) -> Vec { + events.iter().map(|e| e.frame.clone()).collect() +} + +/// Replay's ordering contract: the merge key `(at, seq)` is strictly +/// increasing, so a consumer's sort can never reorder replay against capture. +fn assert_replay_ordered(events: &[MarketEvent]) { + assert!( + events + .windows(2) + .all(|w| (w[0].at, w[0].seq) < (w[1].at, w[1].seq)), + "replay must be strictly (at, seq)-ordered, got: {:?}", + events.iter().map(|e| (e.at, e.seq)).collect::>() + ); +} + +/// `TapeSource` has no Debug impl (unwrap is unavailable): open or panic. +fn open_reader(path: &Path) -> TapeSource { + match TapeSource::open(path) { + Ok(source) => source, + Err(e) => panic!("reader must open: {e}"), + } +} + +/// The lifecycle contract every backend must honour, driven the way the +/// production recorder drives it — generic over the concrete sink and source, +/// exactly as the crate's drivers are: record batches (the wire interleaves +/// an unsolicited status frame), finalize with a lossy summary, then replay +/// the events and the capture's self-description. +fn assert_capture_lifecycle( + path: &Path, + open_sink: impl FnOnce(&Path, &RecordingDeclaration) -> kraken_recording::Result, + open_source: impl FnOnce(&Path) -> kraken_recording::Result, +) where + W: CaptureSink, + R: CaptureSource, +{ + let written = market_frames(0); + let mut sink = open_sink(path, &meta()).unwrap_or_else(|e| panic!("sink opens: {e}")); + sink.record(&[vec![status_frame()], written[..2].to_vec()].concat()) + .unwrap(); + sink.record(&written[2..]).unwrap(); + let summary = RecordingIntegrity { + window_end: "2026-01-01T00:01:00Z".parse().unwrap(), + events_dropped: 1, + frames_unparsed: 2, + reconnect_count: 3, + }; + sink.finalize(summary.clone()).unwrap(); + + let source = open_source(path).unwrap_or_else(|e| panic!("source opens: {e}")); + let report = source.capture().unwrap(); + assert_eq!(report.schema_version, schema::SCHEMA_VERSION); + assert_eq!(report.meta, meta()); + assert_eq!(report.summary, Some(summary)); + + let events: Vec = source.read().unwrap().collect(); + assert_eq!( + payloads(&events), + written, + "the status frame never becomes an event" + ); + assert_replay_ordered(&events); +} + +#[test] +fn capture_lifecycle_round_trips_frames_and_report() { + let dir = tempfile::tempdir().unwrap(); + // The production prelude: validate the requested channels, then derive + // the tape path from the symbol. + let channels: Vec = meta() + .channels + .iter() + .map(|name| name.parse().expect("fixture channel parses")) + .collect(); + ensure_recordable(&channels).unwrap(); + let path = resolve(dir.path(), &sanitize_id("BTC/USD"), "jsonl"); + + assert_capture_lifecycle(&path, TapeSink::open, TapeSource::open); +} + +#[test] +fn crash_reads_unfinalized_then_resume_completes_the_capture() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let first_run = market_frames(0); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&first_run).unwrap(); + drop(sink); // crash: no finalize + + { + let source = open_reader(&path); + assert_eq!( + source.capture().unwrap().summary, + None, + "completeness unknown after a crash" + ); + assert_eq!( + payloads(&source.read().unwrap().collect::>()), + first_run, + "every committed frame survives the crash" + ); + } // the reader's shared lock releases before the resume + + let second_run = market_frames(1); + let mut resumed = TapeSink::open(&path, &meta()).unwrap(); + resumed.record(&second_run).unwrap(); + let summary = RecordingIntegrity { + window_end: "2026-01-01T00:02:00Z".parse().unwrap(), + reconnect_count: 1, + ..RecordingIntegrity::now() + }; + resumed.finalize(summary.clone()).unwrap(); + + let source = open_reader(&path); + assert_eq!( + source.capture().unwrap().summary, + Some(summary), + "the stamp describes the resumed capture" + ); + let events: Vec = source.read().unwrap().collect(); + assert_eq!(payloads(&events), [first_run, second_run].concat()); + assert_replay_ordered(&events); +} + +#[test] +fn torn_tail_from_a_crash_never_becomes_a_frame_and_repair_resumes_cleanly() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let committed = market_frames(0); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&committed).unwrap(); + drop(sink); + // A crash mid-append: bytes after the last newline, no commit marker. + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + file.write_all(br#"{"channel":"trade","type":"upd"#) + .unwrap(); + drop(file); + + { + let source = open_reader(&path); + assert_eq!( + payloads(&source.read().unwrap().collect::>()), + committed, + "the torn tail is a crash artifact, not damage" + ); + } + + let tail = market_frames(1); + let mut resumed = TapeSink::open(&path, &meta()).unwrap(); + resumed.record(&tail).unwrap(); // the append repairs the torn tail first + resumed.finalize(RecordingIntegrity::now()).unwrap(); + + let events: Vec = open_reader(&path).read().unwrap().collect(); + assert_eq!(payloads(&events), [committed, tail].concat()); + assert_eq!( + events.iter().map(|e| e.seq).collect::>(), + (1i64..=8).collect::>(), + "the repaired tail leaves no phantom line behind" + ); +} + +#[test] +fn live_recorder_excludes_writers_and_readers_until_it_releases() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let mut sink = TapeSink::open(&path, &meta()).unwrap(); + sink.record(&market_frames(0)).unwrap(); + assert!( + is_lock_held(&path), + "a live recorder reads as held to the liveness probe" + ); + + let err = TapeSink::open(&path, &meta()).unwrap_err(); + assert!(matches!(err, Error::Rejected(_)), "got: {err}"); + let err = match TapeSource::open(&path) { + Ok(_) => panic!("a live recorder must reject readers"), + Err(e) => e, + }; + assert!(matches!(err, Error::Rejected(_)), "got: {err}"); + assert!(err.to_string().contains("in use"), "got: {err}"); + + sink.finalize(RecordingIntegrity::now()).unwrap(); + assert!(!is_lock_held(&path), "finalize releases the writer lock"); + + let first_reader = open_reader(&path); + let second_reader = open_reader(&path); + assert!( + !is_lock_held(&path), + "readers stay invisible to the recorder-liveness probe" + ); + let err = TapeSink::open(&path, &meta()).unwrap_err(); + assert!( + matches!(err, Error::Rejected(_)), + "a live reader rejects a starting recorder, got: {err}" + ); + drop(first_reader); + drop(second_reader); + drop(TapeSink::open(&path, &meta()).expect("the released tape records again")); +} + +#[test] +fn an_empty_capture_finalizes_and_replays_to_zero_events() { + // A session can end before its first market frame; the tape must + // still self-describe as complete and replay as an empty stream. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("BTC-USD.jsonl"); + let sink = TapeSink::open(&path, &meta()).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let source = open_reader(&path); + assert!(source.capture().unwrap().summary.is_some()); + assert_eq!(source.read().unwrap().count(), 0); +} + +#[cfg(feature = "duckdb")] +mod duckdb_backend { + use kraken_recording::{DuckdbSink, DuckdbSource}; + + use super::*; + + /// `DuckdbSource` has no Debug impl (unwrap is unavailable): open or panic. + fn open_duckdb_reader(path: &Path) -> DuckdbSource { + match DuckdbSource::open(path) { + Ok(source) => source, + Err(e) => panic!("reader must open: {e}"), + } + } + + #[test] + fn capture_lifecycle_round_trips_frames_and_report() { + // The jsonl lifecycle's columnar mirror, through the public surface + // only — no raw SQL. + let dir = tempfile::tempdir().unwrap(); + let path = resolve(dir.path(), &sanitize_id("BTC/USD"), "duckdb"); + + assert_capture_lifecycle(&path, DuckdbSink::open, DuckdbSource::open); + } + + #[test] + fn backends_agree_across_a_crash_and_resume() { + // Frame numbering continues differently per backend (physical line + // numbers vs `_meta.last_seq`), so their equivalence across a crashed + // and resumed capture is an invariant neither backend can pin alone. + let dir = tempfile::tempdir().unwrap(); + let duck_path = dir.path().join("market.duckdb"); + let jsonl_path = dir.path().join("market.jsonl"); + let first_run: Vec = [vec![status_frame()], market_frames(0)].concat(); + let second_run = market_frames(1); + let summary = RecordingIntegrity { + window_end: "2026-01-01T00:02:00Z".parse().unwrap(), + events_dropped: 2, + frames_unparsed: 1, + reconnect_count: 1, + }; + + let mut duck = DuckdbSink::open(&duck_path, &meta()).unwrap(); + duck.record(&first_run).unwrap(); + drop(duck); // crash: no finalize + let mut duck = DuckdbSink::open(&duck_path, &meta()).unwrap(); + duck.record(&second_run).unwrap(); + duck.finalize(summary.clone()).unwrap(); + + let mut jsonl = TapeSink::open(&jsonl_path, &meta()).unwrap(); + jsonl.record(&first_run).unwrap(); + drop(jsonl); // crash: no finalize + let mut jsonl = TapeSink::open(&jsonl_path, &meta()).unwrap(); + jsonl.record(&second_run).unwrap(); + jsonl.finalize(summary).unwrap(); + + let duck_source = open_duckdb_reader(&duck_path); + let jsonl_source = open_reader(&jsonl_path); + let from_duck: Vec = duck_source.read().unwrap().collect(); + let from_jsonl: Vec = jsonl_source.read().unwrap().collect(); + assert_eq!(payloads(&from_duck), payloads(&from_jsonl)); + assert_eq!( + from_duck.iter().map(|e| (e.at, e.seq)).collect::>(), + from_jsonl.iter().map(|e| (e.at, e.seq)).collect::>(), + "one merge-key sequence regardless of backend, across the crash" + ); + assert_eq!( + duck_source.capture().unwrap(), + jsonl_source.capture().unwrap(), + "one capture self-description regardless of backend" + ); + } +} diff --git a/crates/kraken-replay/Cargo.toml b/crates/kraken-replay/Cargo.toml new file mode 100644 index 0000000..7c03738 --- /dev/null +++ b/crates/kraken-replay/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "kraken-replay" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Explain-pnl decomposition and paced timeline playback for kraken-cli" +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +chrono = { workspace = true } +kraken-core = { workspace = true } +kraken-paper = { workspace = true } +kraken-recording = { workspace = true } +kraken-session = { workspace = true } +rust_decimal = { workspace = true } +rust_decimal_macros = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_with = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["time"] } +tracing = { workspace = true } + +[dev-dependencies] +kraken-session = { workspace = true, features = ["fixtures"] } +# Pins the P&L decomposition identity over random fill sequences. +proptest = { workspace = true } +tempfile = { workspace = true } +# test-util's paused clock keeps the pacing tests instant and deterministic. +tokio = { workspace = true, features = ["macros", "rt", "test-util"] } + +[lints] +workspace = true diff --git a/crates/kraken-replay/README.md b/crates/kraken-replay/README.md new file mode 100644 index 0000000..21d8b20 --- /dev/null +++ b/crates/kraken-replay/README.md @@ -0,0 +1,53 @@ +# kraken-replay + +Session projections: the explain-pnl decomposition and paced playback. + +## Purpose + +Two pure consumers of a recorded session timeline. `explain_pnl` decomposes a +stopped session's P&L into plain-terms components — anchored on the totals +`playground stop` wrote, with the identity `price_movement + fees + spread + +slippage + residual = total_pnl` holding exactly by construction. +`replay_events` plays a timeline back into a caller-owned sink on the +recorded gaps divided by a speed factor. + +## Architecture + +Projections over `kraken_session::SessionTimeline` — no I/O construction, no +network, no clap. Playback takes a caller-owned `&mut impl Write` and a +**pure** renderer `Fn(&TimelineEvent) -> Result`; +locking stdout and choosing the render format stay at the call site. The +`PlaybackClock` anchors absolute deadlines, so an emission that falls behind +flushes back-to-back instead of re-pacing. + +- `explain.rs` / `explain/report.rs` — the decomposition and its serialized + vocabulary +- `playback.rs` — `PlaybackClock`, `replay_events`, the speed bounds + +## Interface + +What crosses upward: `explain_pnl(&SessionTimeline) -> Result`, +the report vocabulary (`PnlReport`, `Anchor`, `ComponentLine`, +`ComponentKind`, `TradeLine`, `MissedFillSection`, `OriginSlice`, +`WindowSummary`, `SymbolWindow`, `EndMarkSource`, `Coverage`), +`PlaybackClock`, `replay_events`, `MIN_SPEED`/`MAX_SPEED`, and `ReplayError`. + +`PnlReport`'s serialized shape is a **public contract** (agents consume it +over MCP); fields are additive-only. + +## Errors + +`ReplayError` — `NotStopped` (no final summary to anchor on; → envelope +`validation`, hint byte-stable), `Emit` (sink failure; → `io`), `Render` +(→ `parse`). Bridged once in the binary's `src/errors.rs`. + +## Testing + +The full explain suite including proptests and the `PnlReport` serialization +pin; playback pacing under paused tokio (no real sleeps); the +BrokenPipe-is-a-clean-end pin; and `tests/replay_pipeline.rs` driving a +fixture session end-to-end through the public API only. + +```text +cargo test -p kraken-replay +``` diff --git a/crates/kraken-replay/src/explain.rs b/crates/kraken-replay/src/explain.rs new file mode 100644 index 0000000..b43dddf --- /dev/null +++ b/crates/kraken-replay/src/explain.rs @@ -0,0 +1,1931 @@ +//! Pure P&L decomposition from a session timeline and manifest. +//! +//! Stop totals remain authoritative. Quantized components plus a remainder +//! preserve the emitted total; the remainder also exposes valuation gaps and +//! disclosed data loss. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use chrono::{DateTime, Utc}; +use kraken_core::{ChannelData, OrderSide}; +use kraken_paper::account::Origin; +use kraken_paper::{AccountEvent, PaperOrder, PaperOrderType, PaperState, PaperTrade}; +use kraken_paper::{canonical_key, valid_quote}; +use kraken_recording::{CaptureState, RecordingManifest}; +use kraken_session::manifest::SessionManifest; +use kraken_session::timeline::SessionTimeline; +use kraken_session::timeline::event::{EventPayload, TimelineEvent, final_epoch_start}; +use rust_decimal::{Decimal, RoundingStrategy}; +use rust_decimal_macros::dec; + +use self::report::{ + Anchor, ComponentKind, ComponentLine, Coverage, EndMarkSource, MissedFill, MissedFillSection, + MissedOutcome, OriginSlice, PnlReport, SymbolWindow, TradeLine, WindowSummary, +}; +use crate::{ReplayError, Result}; + +pub(crate) mod report; + +/// The honesty preamble on every missed-fill section: how fills actually +/// happen, and why this scan is a proxy. +const MISSED_FILL_ASSUMPTIONS: &str = "the paper engine reconciles resting orders only when a command runs, against live REST \ + quotes; this scan replays the recorded stream instead — a proxy that can disagree with \ + what actually happened in either direction — and a cancelled order may have been \ + cancelled before any cross"; + +/// Keeps typical component values within lossless f64 JSON precision. +const COMPONENT_SCALE: u32 = 8; + +/// Decomposes a stopped session, failing closed on incomplete account data. +pub fn explain_pnl(session: &SessionTimeline) -> Result { + let manifest: &SessionManifest = &session.manifest; + let Some(outcome) = &manifest.summary else { + return Err(ReplayError::NotStopped { + session: manifest.id.clone(), + }); + }; + if session.newer_records_skipped > 0 { + return Err(ReplayError::NewerJournal { + skipped: session.newer_records_skipped, + }); + } + + let Some(epoch) = final_epoch(&session.events) else { + return Err(ReplayError::NoEpoch { + session: manifest.id.clone(), + }); + }; + let index = MarketIndex::build(&session.events); + let sc = epoch.state.starting_currency.clone(); + + let mut caveats = Vec::new(); + if epoch.epochs_skipped > 0 { + caveats.push(format!( + "{} earlier account epoch(s) on the journal were skipped; the anchor and every \ + component describe the final epoch only", + epoch.epochs_skipped + )); + } + + let decomposition = components(&epoch, &index, &sc, outcome.pnl, &mut caveats); + let missed = missed_fills(&epoch, &index, session.capture.as_ref(), &sc); + let attribution = attribution(&decomposition.trades); + let window = window_summary(&index, session.capture.as_ref()); + + Ok(PnlReport { + group: "session", + kind: "pnl_explanation", + session: manifest.id.clone(), + strategy: manifest.strategy.as_ref().map(|s| s.name.clone()), + anchor: Anchor { + starting_balance: epoch.state.starting_balance, + final_value: outcome.final_value, + total_pnl: outcome.pnl, + ended_at: outcome.ended_at, + currency: sc, + }, + components: decomposition.components, + trades: decomposition.trades, + missed_fills: MissedFillSection { + assumptions: MISSED_FILL_ASSUMPTIONS, + orders: missed, + }, + attribution, + window, + epochs_skipped: epoch.epochs_skipped, + caveats, + }) +} + +/// The journal's final epoch: everything at/after the last +/// `Initialized`/`Reset`, folded through [`PaperState::apply`] for exact +/// parity with what `run stop` anchored on, plus the side maps the +/// decomposition joins against. +struct FinalEpoch { + state: PaperState, + fills: Vec, + /// Submitted (resting) orders by id — the recorded `order_type` and the + /// submitting actor. Market fills never journal a submission. + submissions: HashMap, + /// Cancellation instants by order id (the record's `ts` is authoritative + /// for cancellations). + cancelled_at: HashMap>, + /// `--reason` rationale by order id, final-epoch decisions only (order + /// ids restart per epoch, so an older epoch's decision must not join). + reasons: HashMap, + epochs_skipped: usize, +} + +struct Fill { + trade: PaperTrade, + /// The *fill record's* origin: whoever ran the command that reconciled + /// it — for limit fills the submission's origin names the decider. + origin: Origin, +} + +struct Submission { + order_type: PaperOrderType, + origin: Origin, +} + +/// `None` when no `Initialized`/`Reset` is on the record: folding from +/// [`PaperState::default`] would conjure an account no writer created (the +/// journal's own projection in `kraken_paper::account` refuses the same). +fn final_epoch(events: &[TimelineEvent]) -> Option { + let epoch_start = final_epoch_start(events); + let start = epoch_start.start?; + let mut epoch = FinalEpoch { + state: PaperState::default(), + fills: Vec::new(), + submissions: HashMap::new(), + cancelled_at: HashMap::new(), + reasons: HashMap::new(), + epochs_skipped: epoch_start.skipped, + }; + for event in &events[start..] { + match &event.payload { + EventPayload::Account(record) => { + epoch.state.apply(record.ts, &record.event); + match &record.event { + AccountEvent::OrderSubmitted { order } => { + epoch.submissions.insert( + order.id.clone(), + Submission { + order_type: order.order_type, + origin: record.origin, + }, + ); + } + AccountEvent::OrderFilled { trade } => { + epoch.fills.push(Fill { + trade: trade.clone(), + origin: record.origin, + }); + } + AccountEvent::OrderCancelled { order } => { + epoch.cancelled_at.insert(order.id.clone(), record.ts); + } + AccountEvent::Initialized(_) + | AccountEvent::Reset(_) + | AccountEvent::Command(_) + | AccountEvent::OrderRejected(_) + | AccountEvent::Unknown => {} + // `AccountEvent` is `#[non_exhaustive]`: whatever a newer + // build adds is a no-op here, exactly like `Unknown`. + _ => {} + } + } + EventPayload::Decision(decision) => { + if let Some(order_id) = &decision.order_id { + epoch + .reasons + .insert(order_id.clone(), decision.reason.clone()); + } + } + EventPayload::Market(_) => {} + } + } + Some(epoch) +} + +/// A recorded ticker top-of-book at one timeline instant. +struct TickerPoint { + at: DateTime, + bid: Decimal, + ask: Decimal, +} + +impl TickerPoint { + fn mid(&self) -> Decimal { + (self.ask + self.bid) / dec!(2) + } +} + +/// One instrument's recorded market series. +#[derive(Default)] +struct SymbolSeries { + ticker: Vec, + last_trade_price: Option, + last_ohlc_close: Option, + frames: usize, +} + +/// The recorded market, indexed by canonical `(base, quote)` — never by the +/// pair string: `parse_pair` canonicalizes XBT→BTC only in its base/quote +/// outputs, so string keys would not collide `XBTUSD` with `BTC/USD`. +struct MarketIndex { + series: HashMap<(String, String), SymbolSeries>, +} + +impl MarketIndex { + fn build(events: &[TimelineEvent]) -> Self { + let mut series: HashMap<(String, String), SymbolSeries> = HashMap::new(); + let mut unparseable: HashSet = HashSet::new(); + for event in events { + let EventPayload::Market(frame) = &event.payload else { + continue; + }; + match &frame.body { + ChannelData::Ticker(entries) => { + for ticker in entries { + let Some(entry) = entry_for(&mut series, &mut unparseable, &ticker.symbol) + else { + continue; + }; + entry.frames += 1; + if valid_quote(ticker.ask, ticker.bid) { + entry.ticker.push(TickerPoint { + at: event.at, + bid: ticker.bid, + ask: ticker.ask, + }); + } + } + } + ChannelData::Trade(entries) => { + for trade in entries { + let Some(entry) = entry_for(&mut series, &mut unparseable, &trade.symbol) + else { + continue; + }; + entry.frames += 1; + if trade.price > Decimal::ZERO { + entry.last_trade_price = Some(trade.price); + } + } + } + ChannelData::Ohlc(entries) => { + for candle in entries { + let Some(entry) = entry_for(&mut series, &mut unparseable, &candle.symbol) + else { + continue; + }; + entry.frames += 1; + if candle.close > Decimal::ZERO { + entry.last_ohlc_close = Some(candle.close); + } + } + } + ChannelData::Book(entries) => { + for book in entries { + if let Some(entry) = entry_for(&mut series, &mut unparseable, &book.symbol) + { + entry.frames += 1; + } + } + } + // Non-market feeds (account, system) carry no per-symbol + // price series to index. + ChannelData::Instrument(_) + | ChannelData::Executions(_) + | ChannelData::Balances(_) + | ChannelData::Level3(_) + | ChannelData::Status(_) => {} + } + } + Self { series } + } + + fn series(&self, base: &str, quote: &str) -> Option<&SymbolSeries> { + self.series.get(&(base.to_string(), quote.to_string())) + } + + /// The recorded end mark for `base` priced in `quote`: last ticker bid + /// (mirroring `compute_portfolio_value`'s bid marking, so the residual + /// stays "live vs recorded"), falling back to last trade price, then + /// last OHLC close. + fn end_mark(&self, base: &str, quote: &str) -> Option<(Decimal, EndMarkSource)> { + let series = self.series(base, quote)?; + if let Some(point) = series.ticker.last() { + return Some((point.bid, EndMarkSource::TickerBid)); + } + if let Some(price) = series.last_trade_price { + return Some((price, EndMarkSource::TradePrice)); + } + series + .last_ohlc_close + .map(|close| (close, EndMarkSource::OhlcClose)) + } + + /// Value of one unit of `asset` in the starting currency at the recorded + /// end: `1` for the starting currency itself. + fn rate(&self, asset: &str, starting_currency: &str) -> Option { + if asset == starting_currency { + return Some(Decimal::ONE); + } + self.end_mark(asset, starting_currency) + .map(|(mark, _)| mark) + } + + fn is_empty(&self) -> bool { + self.series.is_empty() + } +} + +/// The market track tolerates unknown vocabulary like every other reader: +/// a symbol `parse_pair` cannot split is warned once and skipped, never a +/// silent hole in the index. +fn entry_for<'series>( + series: &'series mut HashMap<(String, String), SymbolSeries>, + unparseable: &mut HashSet, + symbol: &str, +) -> Option<&'series mut SymbolSeries> { + match canonical_key(symbol) { + Some(key) => Some(series.entry(key).or_default()), + None => { + if unparseable.insert(symbol.to_string()) { + tracing::warn!(symbol, "skipping market frames for unparseable symbol"); + } + None + } + } +} + +fn signed(side: OrderSide, volume: Decimal) -> Decimal { + match side { + OrderSide::Buy => volume, + OrderSide::Sell => -volume, + } +} + +struct Decomposition { + components: Vec, + /// One line per final-epoch fill, in journal order (attribution zips + /// these against the epoch's fills). + trades: Vec, +} + +/// The additive split. Per fill *i* (signed volume `s`, fill price `p`, mid +/// `m` of the recorded reference quote, `q` = end-mark rate of the quote +/// asset, `B` = end-mark rate of the base asset): +/// +/// - price movement: `s·(B − m·q)` — the frictionless twin, executed at mid +/// and marked at the recorded end +/// - fees: `−fee·q` +/// - spread: market fills pay the half-spread (`ask−m` buy / `m−bid` sell, +/// × volume); limit fills carry `−s·(p−m)` — with detection-time quotes +/// this is a *cost* (the market has already moved through the resting +/// price when the fill is detected), never "price improvement" +/// - slippage: market buy `−(p−ask)·v`, market sell `−(bid−p)·v`, limit `0` +/// +/// The split reconstructs `s·(p−m)` exactly in every case, so the recorded- +/// mark identity is exact; whatever cannot be attributed (no reference +/// quote → `m := p`; no end mark → contribution dropped) degrades into +/// price movement or the residual, each with a caveat. +fn components( + epoch: &FinalEpoch, + index: &MarketIndex, + sc: &str, + total_pnl: Decimal, + caveats: &mut Vec, +) -> Decomposition { + let mut totals = FillTotals::default(); + let trades = epoch + .fills + .iter() + .map(|fill| fill_line(fill, epoch, index, sc, &mut totals)) + .collect(); + caveats.extend(degradation_caveats(&totals)); + + // Unknown, never a fake zero: with fills on the record but not one + // derivable movement (unmarked base or unconverted quote on every fill), + // the component has no number and the residual carries it. + let underivable = !epoch.fills.is_empty() && totals.movement_derived == 0; + if underivable { + caveats.push( + "not one fill's price movement was derivable from the recorded market; \ + the residual carries it" + .to_string(), + ); + } + let price_movement = (!underivable).then_some(totals.movement); + + Decomposition { + components: component_lines(epoch, &totals, price_movement, total_pnl), + trades, + } +} + +/// Running totals over the fill loop — the aggregation half [`components`] +/// reports while [`fill_line`] does the per-fill math. +#[derive(Default)] +struct FillTotals { + movement: Decimal, + /// Fills whose movement was derivable (base and quote both marked). + movement_derived: usize, + fees: Decimal, + market_spread: Decimal, + limit_spread: Decimal, + slippage: Decimal, + market_fills: usize, + limit_fills: usize, + /// Fills whose quote asset converts to the anchor currency — the set the + /// fee/spread/slippage sums actually describe. + converted_fills: usize, + fills_without_quote: usize, + unconverted_quotes: BTreeSet, + unmarked_bases: BTreeSet, +} + +/// One fill's decomposition into its [`TradeLine`], folding the +/// contributions into `totals` (see [`components`] for the formulas). +fn fill_line( + fill: &Fill, + epoch: &FinalEpoch, + index: &MarketIndex, + sc: &str, + totals: &mut FillTotals, +) -> TradeLine { + let trade = &fill.trade; + let submission = epoch.submissions.get(&trade.order_id); + let order_type = submission.map_or(PaperOrderType::Market, |sub| sub.order_type); + let s = signed(trade.side, trade.volume); + let mid = trade.reference_quote.map(|quote| quote.mid()); + if mid.is_none() { + totals.fills_without_quote += 1; + } + + let mut line = TradeLine { + trade_id: trade.id.clone(), + order_id: trade.order_id.clone(), + pair: trade.pair.clone(), + side: trade.side, + order_type, + volume: trade.volume, + price: trade.price, + notional: None, + mid, + price_movement: None, + fees: Decimal::ZERO, + spread: Decimal::ZERO, + slippage: Decimal::ZERO, + origin: submission.map_or(fill.origin, |sub| sub.origin), + reason: epoch.reasons.get(&trade.order_id).cloned(), + }; + + let Some(q) = index.rate(&trade.quote, sc) else { + // Nothing about this fill converts to the anchor currency; the + // residual absorbs its whole effect. + totals.unconverted_quotes.insert(trade.quote.clone()); + return line; + }; + totals.converted_fills += 1; + line.notional = Some(trade.cost * q); + + // `m := p` when no reference quote survived: friction is forced to + // zero and rides inside price movement instead. + let m = mid.unwrap_or(trade.price); + let (spread_quote, slippage_quote) = match (order_type, trade.reference_quote) { + (_, None) => (Decimal::ZERO, Decimal::ZERO), + (PaperOrderType::Limit, Some(_)) => (-s * (trade.price - m), Decimal::ZERO), + (PaperOrderType::Market, Some(quote)) => match trade.side { + OrderSide::Buy => ( + -(quote.ask - m) * trade.volume, + -(trade.price - quote.ask) * trade.volume, + ), + OrderSide::Sell => ( + -(m - quote.bid) * trade.volume, + -(quote.bid - trade.price) * trade.volume, + ), + }, + }; + + line.fees = -trade.fee * q; + line.spread = spread_quote * q; + line.slippage = slippage_quote * q; + totals.fees += line.fees; + totals.slippage += line.slippage; + match order_type { + PaperOrderType::Market => { + totals.market_fills += 1; + totals.market_spread += line.spread; + } + PaperOrderType::Limit => { + totals.limit_fills += 1; + totals.limit_spread += line.spread; + } + } + + match index.rate(&trade.base, sc) { + Some(base_rate) => { + let movement = s * (base_rate - m * q); + line.price_movement = Some(movement); + totals.movement += movement; + totals.movement_derived += 1; + } + None => { + totals.unmarked_bases.insert(trade.base.clone()); + } + } + line +} + +fn degradation_caveats(totals: &FillTotals) -> Vec { + let mut caveats = Vec::new(); + if totals.fills_without_quote > 0 { + caveats.push(format!( + "{} fill(s) carried no valid recorded reference quote; their friction is folded \ + into price movement", + totals.fills_without_quote + )); + } + for quote in &totals.unconverted_quotes { + caveats.push(format!( + "trades quoted in {quote} have no recorded end mark against the account currency; \ + their components are folded into the residual" + )); + } + for base in &totals.unmarked_bases { + caveats.push(format!( + "{base} has no recorded end mark; its price movement is folded into the residual" + )); + } + caveats +} + +fn component_lines( + epoch: &FinalEpoch, + totals: &FillTotals, + price_movement: Option, + total_pnl: Decimal, +) -> Vec { + // Money rounding matches the binary's output convention (midpoint away + // from zero), applied here so the additive identity is defined over the + // very numbers the report emits. + let quantize = |amount: Decimal| { + amount.round_dp_with_strategy(COMPONENT_SCALE, RoundingStrategy::MidpointAwayFromZero) + }; + let price_movement = price_movement.map(quantize); + let fees = quantize(totals.fees); + let spread = quantize(totals.market_spread + totals.limit_spread); + let slippage = quantize(totals.slippage); + let residual = total_pnl - (price_movement.unwrap_or(Decimal::ZERO) + fees + spread + slippage); + let fee_rate_pct = epoch.state.fee_rate * dec!(100); + let slippage_rate_pct = epoch.state.slippage_rate * dec!(100); + vec![ + ComponentLine { + kind: ComponentKind::PriceMovement, + amount: price_movement, + explanation: if price_movement.is_none() { + "the recorded market cannot mark your position changes (see caveats), so their \ + frictionless value is unknown; the residual carries it" + .to_string() + } else { + "your position changes valued at mid-market when filled and marked at the \ + recorded end, before any costs" + .to_string() + }, + }, + ComponentLine { + kind: ComponentKind::Fees, + amount: Some(fees), + explanation: format!( + "paid across {} fill(s) at the account's {fee_rate_pct:.2}% fee rate", + totals.converted_fills + ), + }, + ComponentLine { + kind: ComponentKind::Spread, + amount: Some(spread), + explanation: format!( + "{} market fill(s) paid the half-spread ({:.2}); {} limit fill(s) executed at \ + their resting price vs mid ({:.2}) — a cost of late fill detection", + totals.market_fills, totals.market_spread, totals.limit_fills, totals.limit_spread + ), + }, + ComponentLine { + kind: ComponentKind::Slippage, + amount: Some(slippage), + explanation: format!( + "simulated slippage on market fills (rate {slippage_rate_pct:.2}%)" + ), + }, + ComponentLine { + kind: ComponentKind::Residual, + amount: Some(residual), + explanation: "the gap between the live prices 'session stop' marked at and this \ + session's recorded prices (plus anything the caveats folded in); \ + components + residual sum to the total, exactly in the engine and to \ + double precision on this JSON wire" + .to_string(), + }, + ] +} + +/// The counterfactual: every final-epoch resting limit order that ended +/// cancelled or still open, scanned against the *recorded* ticker stream +/// under the engine's own crossing rule ([`PaperOrder::crosses`]). Never +/// part of the component sum. +fn missed_fills( + epoch: &FinalEpoch, + index: &MarketIndex, + capture: Option<&RecordingManifest>, + sc: &str, +) -> Vec { + let candidates = epoch + .state + .cancelled_orders + .iter() + .map(|order| (order, MissedOutcome::Cancelled)) + .chain( + epoch + .state + .open_orders + .iter() + .map(|order| (order, MissedOutcome::StillOpen)), + ) + .filter(|(order, _)| order.order_type == PaperOrderType::Limit); + + candidates + .map(|(order, outcome)| { + let resting_until = match outcome { + MissedOutcome::Cancelled => epoch.cancelled_at.get(&order.id).copied(), + MissedOutcome::StillOpen => match CaptureState::of(capture) { + CaptureState::Finalized(_, summary) => Some(summary.window_end), + _ => None, + }, + }; + let mut missed = MissedFill { + order_id: order.id.clone(), + pair: order.pair.clone(), + side: order.side, + volume: order.volume, + limit_price: order.price, + resting_from: order.created_at, + resting_until, + outcome, + coverage: Coverage::Uncovered, + uncovered_reason: None, + crossed: None, + first_crossed_at: None, + hypothetical_pnl: None, + }; + match coverage_window(order, capture, resting_until) { + Err(reason) => missed.uncovered_reason = Some(reason), + Ok((from, until)) => { + missed.coverage = Coverage::Covered; + scan_crossing(&mut missed, epoch, index, order, sc, from, until); + } + } + missed + }) + .collect() +} + +/// All-or-nothing gate: the recording can answer "would this have filled?" +/// only when it is finalized, gap-free, covers the order's pair on the +/// ticker channel, and spans the whole resting window. +fn coverage_window( + order: &PaperOrder, + capture: Option<&RecordingManifest>, + resting_until: Option>, +) -> std::result::Result<(DateTime, DateTime), String> { + let (report, summary) = match CaptureState::of(capture) { + CaptureState::Absent => return Err("session has no market recording".to_string()), + CaptureState::Unfinalized(_) => { + return Err("the market capture never finalized; completeness is unknown".to_string()); + } + CaptureState::Finalized(report, summary) => (report, summary), + }; + if summary.events_dropped > 0 { + return Err(format!( + "{} recorded frame(s) were dropped mid-capture; gaps would be invisible", + summary.events_dropped + )); + } + // An unparsed frame never reached the tape: if it was a ticker for this + // pair, the crossing scan has a hole it cannot see. + if summary.frames_unparsed > 0 { + return Err(format!( + "{} frame(s) failed to parse mid-capture; ticker holes would be invisible", + summary.frames_unparsed + )); + } + // A reconnect is an invisible gap too: the socket sends a fresh snapshot + // on resume, never a replay of the outage, so a cross that happened (and + // reverted) during the disconnect is simply not on the record. + if summary.reconnect_count > 0 { + return Err(format!( + "{} mid-capture reconnect(s); frames during the outage are unrecorded", + summary.reconnect_count + )); + } + let Some(order_key) = canonical_key(&order.pair) else { + return Err(format!("the order's pair {} is unparseable", order.pair)); + }; + let recorded_pair = report + .meta + .symbols + .iter() + .any(|symbol| canonical_key(symbol).as_ref() == Some(&order_key)); + if !recorded_pair { + return Err(format!("pair {} was not recorded", order.pair)); + } + if !report + .meta + .channels + .iter() + .any(|channel| channel == "ticker") + { + return Err("the ticker channel was not recorded".to_string()); + } + let window_start = report.meta.window_start; + let window_end = summary.window_end; + let from = order.created_at; + let until = resting_until.unwrap_or(window_end); + // `from > until` is a still-open order created *after* the capture + // finalized (a recorder stopped early while the session kept trading): + // its resting window inverts, and an empty scan must read as "outside + // the window", never as "covered, no cross". + if from < window_start || until > window_end || from > until { + return Err("the order rested outside the recorded window".to_string()); + } + Ok((from, until)) +} + +fn scan_crossing( + missed: &mut MissedFill, + epoch: &FinalEpoch, + index: &MarketIndex, + order: &PaperOrder, + sc: &str, + from: DateTime, + until: DateTime, +) { + let first_cross = index + .series(&order.base, &order.quote) + .into_iter() + .flat_map(|series| &series.ticker) + .filter(|point| point.at >= from && point.at <= until) + .find(|point| order.crosses(point.ask, point.bid)); + missed.crossed = Some(first_cross.is_some()); + let Some(cross) = first_cross else { + return; + }; + missed.first_crossed_at = Some(cross.at); + // The hypothetical fill, at the limit price with the engine's fee rule, + // marked at the recorded end — only when both conversion marks exist. + if let (Some(base_rate), Some(q)) = (index.rate(&order.base, sc), index.rate(&order.quote, sc)) + { + let s = signed(order.side, order.volume); + let fee = order.price * order.volume * epoch.state.fee_rate; + missed.hypothetical_pnl = Some(s * (base_rate - order.price * q) - fee * q); + } +} + +/// Per-origin slices over the *actual* components: a limit fill belongs to +/// its submitter (the deciding actor), a market fill to its own record's +/// origin — `TradeLine.origin` already encodes that rule, and every summed +/// figure (including notional) is already in the anchor currency. +fn attribution(trades: &[TradeLine]) -> Vec { + let mut cli = empty_slice(Origin::Cli); + let mut mcp = empty_slice(Origin::Mcp); + for line in trades { + let slice = match line.origin { + Origin::Cli => &mut cli, + Origin::Mcp => &mut mcp, + }; + slice.trades += 1; + slice.gross_notional += line.notional.unwrap_or(Decimal::ZERO); + slice.fees += line.fees; + slice.spread += line.spread; + slice.slippage += line.slippage; + if line.reason.is_some() { + slice.decided += 1; + } + } + [cli, mcp] + .into_iter() + .filter(|slice| slice.trades > 0) + .collect() +} + +fn empty_slice(origin: Origin) -> OriginSlice { + OriginSlice { + origin, + trades: 0, + gross_notional: Decimal::ZERO, + fees: Decimal::ZERO, + spread: Decimal::ZERO, + slippage: Decimal::ZERO, + decided: 0, + } +} + +/// What the recorded market did — the whole story for a zero-trade session. +fn window_summary( + index: &MarketIndex, + capture: Option<&RecordingManifest>, +) -> Option { + if index.is_empty() && capture.is_none() { + return None; + } + let mut symbols: Vec = index + .series + .iter() + .map(|((base, quote), series)| { + let first_mid = series.ticker.first().map(TickerPoint::mid); + let last_mid = series.ticker.last().map(TickerPoint::mid); + let move_pct = match (first_mid, last_mid) { + (Some(first), Some(last)) if first > Decimal::ZERO => { + Some((last - first) / first * dec!(100)) + } + _ => None, + }; + let avg_spread = (!series.ticker.is_empty()).then(|| { + series.ticker.iter().map(|p| p.ask - p.bid).sum::() + / Decimal::from(series.ticker.len()) + }); + SymbolWindow { + symbol: format!("{base}{quote}"), + first_mid, + last_mid, + move_pct, + avg_spread, + frames: series.frames, + end_mark_source: index.end_mark(base, quote).map(|(_, source)| source), + } + }) + .collect(); + symbols.sort_by(|a, b| a.symbol.cmp(&b.symbol)); + let (start, end) = match CaptureState::of(capture) { + CaptureState::Absent => (None, None), + CaptureState::Unfinalized(report) => (Some(report.meta.window_start), None), + CaptureState::Finalized(report, summary) => { + (Some(report.meta.window_start), Some(summary.window_end)) + } + }; + Some(WindowSummary { + start, + end, + symbols, + }) +} + +#[cfg(test)] +mod tests { + use kraken_paper::PaperConfig; + use kraken_recording::RecordingIntegrity; + use kraken_session::manifest::SessionOutcome; + use kraken_session::timeline::fixtures::{self, SessionBuilder, T0}; + + use super::*; + + fn stopped_manifest(final_value: Decimal, starting_balance: Decimal) -> SessionManifest { + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T01:00:00Z".parse().unwrap(), + final_value, + pnl: final_value - starting_balance, + }); + manifest + } + + fn finalized_capture() -> RecordingManifest { + RecordingManifest { + schema_version: "3.0".to_string(), + meta: kraken_recording::RecordingDeclaration { + source: "test".to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["ticker".to_string()], + window_start: T0.parse().unwrap(), + cli_version: "test".to_string(), + }, + summary: Some(RecordingIntegrity { + window_end: "2026-01-01T01:00:00Z".parse().unwrap(), + ..RecordingIntegrity::now() + }), + } + } + + fn amount(report: &PnlReport, kind: ComponentKind) -> Option { + report + .components + .iter() + .find(|line| line.kind == kind) + .and_then(|line| line.amount) + } + + fn assert_close(actual: Decimal, expected: Decimal, what: &str) { + assert!( + (actual - expected).abs() < dec!(0.000000001), + "{what}: {actual} != {expected}" + ); + } + + /// A consumer's exact read: the digits the number wire emitted. + fn wire(value: &serde_json::Value) -> Decimal { + value.as_number().unwrap().to_string().parse().unwrap() + } + + fn wire_component_sum(json: &serde_json::Value) -> Decimal { + json["components"] + .as_array() + .unwrap() + .iter() + .map(|line| wire(&line["amount"])) + .sum() + } + + #[test] + fn components_and_residual_sum_to_the_anchor_total() { + // The worked example from the design review: buy 0.1 BTC at + // ask 50_000 / bid 49_900, slippage 0.001, fee 0.0026, last recorded + // bid 51_000. Recorded-mark P&L = +81.987, so a recorded-consistent + // anchor leaves residual == 0. + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.001)); + builder.market_fill( + "2026-01-01T00:10:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_081.987), dec!(10_000.0))); + + let report = explain_pnl(&session).unwrap(); + + assert_close( + amount(&report, ComponentKind::PriceMovement).unwrap(), + dec!(105.0), + "price movement", + ); + assert_close( + amount(&report, ComponentKind::Fees).unwrap(), + -dec!(13.013), + "fees", + ); + assert_close( + amount(&report, ComponentKind::Spread).unwrap(), + -dec!(5.0), + "spread", + ); + assert_close( + amount(&report, ComponentKind::Slippage).unwrap(), + -dec!(5.0), + "slippage", + ); + assert_close( + amount(&report, ComponentKind::Residual).unwrap(), + dec!(0.0), + "residual", + ); + let sum: Decimal = report + .components + .iter() + .filter_map(|line| line.amount) + .sum(); + assert_close(sum, report.anchor.total_pnl, "sum equals the anchor"); + } + + /// The identity as a consumer sees it: components whose exact Decimals + /// exceed what a JSON double carries must still re-sum to the anchor + /// total digit-for-digit on the emitted wire (regression for the + /// unquantized f64 wire, where each line rounded independently). This + /// pins the digit-exact regime — an anchor total within + /// [`COMPONENT_SCALE`] decimals; the sibling test pins the bound beyond. + #[test] + fn emitted_component_lines_re_sum_to_the_emitted_total_digit_for_digit() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.001)); + // 0.12345678 × 50_000.12 × 1.001 × 0.0026 fee rate → fees + // 16.065469338433875…: 17+ significant digits before quantization. + builder.market_fill( + "2026-01-01T00:10:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.12345678), + (dec!(50_000.12), dec!(49_900.99)), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.37), + dec!(51_100.11), + ); + let session = builder.session(None, stopped_manifest(dec!(10_123.45), dec!(10_000.0))); + + let report = explain_pnl(&session).unwrap(); + let json = serde_json::to_value(&report).unwrap(); + assert_eq!( + wire_component_sum(&json), + wire(&json["anchor"]["total_pnl"]) + ); + } + + /// The other regime: an anchor total shaped like the manifest's f64 + /// wire (scale beyond [`COMPONENT_SCALE`]) makes the residual inherit + /// that scale, so digit-exactness can fall to double precision. One f64 + /// ulp at this magnitude is ~1e-13; this pins the identity inside the + /// module's 1e-9 closeness envelope, a loose ceiling over that bound + /// that still fails any quantization regression (those surface at + /// ≥ 1e-8, the component scale). + #[test] + fn wire_scale_anchor_re_sums_within_double_precision() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.001)); + builder.market_fill( + "2026-01-01T00:10:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.12345678), + (dec!(50_000.12), dec!(49_900.99)), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.37), + dec!(51_100.11), + ); + // pnl = 1_716.9715859185314: 13 decimals, a shortest-f64 shape. + let session = builder.session( + None, + stopped_manifest(dec!(11_716.9715859185314), dec!(10_000.0)), + ); + + let report = explain_pnl(&session).unwrap(); + let json = serde_json::to_value(&report).unwrap(); + assert_close( + wire_component_sum(&json), + wire(&json["anchor"]["total_pnl"]), + "wire re-sum vs emitted total", + ); + } + + #[test] + fn sell_market_fill_splits_friction_symmetrically() { + let mut builder = SessionBuilder::with_rates(dec!(100_000.0), dec!(0.0026), dec!(0.001)); + builder.market_fill( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.2), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.market_fill( + "2026-01-01T00:10:00Z", + Origin::Cli, + OrderSide::Sell, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_000.0), dec!(100_000.0))); + let report = explain_pnl(&session).unwrap(); + + let sell = &report.trades[1]; + // half-spread (m − bid) = 50 → −5.0; slippage (bid − p) = 49.9 → −4.99 + assert_close(sell.spread, -dec!(5.0), "sell half-spread"); + assert_close(sell.slippage, -dec!(4.99), "sell slippage"); + } + + #[test] + fn limit_fill_execution_vs_mid_is_a_cost_not_price_improvement() { + // Engine semantics: the resting buy fills at its own price only once + // ask ≤ limit, with the reference quote taken at detection — so the + // fill is always at-or-above mid: a cost, never an improvement. + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.submit_limit( + "2026-01-01T00:05:00Z", + Origin::Mcp, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(45_000.0), + ); + builder.fill_pending( + "2026-01-01T00:20:00Z", + Origin::Cli, + "BTCUSD", + dec!(44_900.0), + dec!(44_800.0), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(45_500.0), + dec!(45_600.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_050.0), dec!(10_000.0))); + let report = explain_pnl(&session).unwrap(); + + let fill = &report.trades[0]; + assert_eq!(fill.order_type, PaperOrderType::Limit); + // mid at detection = 44_850; fill at 45_000 → −(45_000 − 44_850)·0.1 + assert_close(fill.spread, -dec!(15.0), "limit execution vs mid is a cost"); + assert_close(fill.slippage, dec!(0.0), "limit fills carry no slippage"); + assert_eq!( + fill.origin, + Origin::Mcp, + "the submitter decided, not the reconciler" + ); + } + + #[test] + fn fill_without_reference_quote_contributes_zero_friction_and_is_caveated() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.001)); + let mut trade = builder + .state + .decide_market_order( + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(50_000.0), + dec!(49_900.0), + ) + .unwrap(); + trade.filled_at = "2026-01-01T00:10:00Z".parse().unwrap(); + trade.reference_quote = None; // a legacy journal line + builder.account( + "2026-01-01T00:10:00Z", + Origin::Cli, + AccountEvent::OrderFilled { trade }, + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_081.987), dec!(10_000.0))); + let report = explain_pnl(&session).unwrap(); + + let fill = &report.trades[0]; + assert_close(fill.spread, dec!(0.0), "no quote, no spread attribution"); + assert_close( + fill.slippage, + dec!(0.0), + "no quote, no slippage attribution", + ); + // m := p folds the friction into price movement: 0.1·(51_000 − 50_050) + assert_close( + fill.price_movement.unwrap(), + dec!(95.0), + "friction rides in movement", + ); + assert!( + report.caveats.iter().any(|c| c.contains("reference quote")), + "caveats: {:?}", + report.caveats + ); + } + + #[test] + fn zero_trade_session_reports_window_summary_not_error() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:05:00Z", + "BTC/USD", + dec!(49_000.0), + dec!(49_100.0), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(50_000.0), + dec!(50_100.0), + ); + let session = builder.session( + Some(finalized_capture()), + stopped_manifest(dec!(10_000.0), dec!(10_000.0)), + ); + let report = explain_pnl(&session).unwrap(); + + assert!(report.trades.is_empty()); + for kind in [ + ComponentKind::PriceMovement, + ComponentKind::Fees, + ComponentKind::Spread, + ComponentKind::Slippage, + ComponentKind::Residual, + ] { + assert_close( + amount(&report, kind).unwrap(), + dec!(0.0), + "all-zero components", + ); + } + let window = report.window.expect("window story"); + assert_eq!(window.symbols.len(), 1); + let symbol = &window.symbols[0]; + assert_eq!(symbol.symbol, "BTCUSD"); + assert_close(symbol.avg_spread.unwrap(), dec!(100.0), "avg spread"); + assert!(symbol.move_pct.unwrap() > dec!(2.0), "mid moved ~+2%"); + assert_eq!(symbol.end_mark_source, Some(EndMarkSource::TickerBid)); + } + + #[test] + fn unstopped_session_is_a_validation_error() { + let builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let session = builder.session(None, fixtures::manifest(vec![])); // summary: None + + let err = explain_pnl(&session).unwrap_err(); + assert!(matches!(err, ReplayError::NotStopped { .. })); + assert!(err.to_string().contains("session stop"), "got: {err}"); + } + + #[test] + fn journal_records_from_a_newer_build_fail_the_explanation() { + // The timeline drops what it cannot read; a fold over the partial + // account track would report confident, wrong numbers. + let builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let mut session = builder.session(None, stopped_manifest(dec!(10_000.0), dec!(10_000.0))); + session.newer_records_skipped = 1; + + let err = explain_pnl(&session).unwrap_err(); + assert!(matches!(err, ReplayError::NewerJournal { skipped: 1 })); + assert!(err.to_string().contains("upgrade kraken"), "got: {err}"); + } + + #[test] + fn journal_without_an_initialization_cannot_anchor_an_epoch() { + let session = SessionTimeline { + events: Vec::new(), + capture: None, + manifest: stopped_manifest(dec!(10_000.0), dec!(10_000.0)), + newer_records_skipped: 0, + }; + + let err = explain_pnl(&session).unwrap_err(); + assert!(matches!(err, ReplayError::NoEpoch { .. })); + assert!(err.to_string().contains("initialization"), "got: {err}"); + } + + #[test] + fn missing_market_coverage_marks_missed_fills_uncovered_with_reason() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let order = builder.submit_limit( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(44_000.0), + ); + builder.cancel("2026-01-01T00:30:00Z", Origin::Cli, &order.id); + let session = builder.session(None, stopped_manifest(dec!(10_000.0), dec!(10_000.0))); + let report = explain_pnl(&session).unwrap(); + + let missed = &report.missed_fills.orders[0]; + assert_eq!(missed.coverage, Coverage::Uncovered); + assert_eq!(missed.outcome, MissedOutcome::Cancelled); + assert!( + missed + .uncovered_reason + .as_deref() + .unwrap() + .contains("no market recording") + ); + assert_eq!(missed.crossed, None); + } + + #[test] + fn missed_fill_uses_the_engine_crossing_predicate() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let order = builder.submit_limit( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(44_000.0), + ); + builder.ticker( + "2026-01-01T00:10:00Z", + "BTC/USD", + dec!(44_400.0), + dec!(44_500.0), + ); // no cross + builder.ticker( + "2026-01-01T00:20:00Z", + "BTC/USD", + dec!(43_800.0), + dec!(43_900.0), + ); // ask ≤ 44_000 + builder.cancel("2026-01-01T00:30:00Z", Origin::Cli, &order.id); + builder.ticker( + "2026-01-01T00:40:00Z", + "BTC/USD", + dec!(45_000.0), + dec!(45_100.0), + ); // end mark + let session = builder.session( + Some(finalized_capture()), + stopped_manifest(dec!(10_000.0), dec!(10_000.0)), + ); + let report = explain_pnl(&session).unwrap(); + + let missed = &report.missed_fills.orders[0]; + assert_eq!(missed.coverage, Coverage::Covered); + assert_eq!(missed.crossed, Some(true)); + assert_eq!( + missed.first_crossed_at, + Some("2026-01-01T00:20:00Z".parse().unwrap()) + ); + // 0.1·(45_000 − 44_000) − 44_000·0.1·0.0026 = 100 − 11.44 + assert_close( + missed.hypothetical_pnl.unwrap(), + dec!(88.56), + "hypothetical", + ); + } + + #[test] + fn cancelled_before_cross_reports_not_crossed() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let order = builder.submit_limit( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(44_000.0), + ); + builder.ticker( + "2026-01-01T00:10:00Z", + "BTC/USD", + dec!(44_400.0), + dec!(44_500.0), + ); // no cross + builder.cancel("2026-01-01T00:15:00Z", Origin::Cli, &order.id); + builder.ticker( + "2026-01-01T00:20:00Z", + "BTC/USD", + dec!(43_800.0), + dec!(43_900.0), + ); // after cancel + let session = builder.session( + Some(finalized_capture()), + stopped_manifest(dec!(10_000.0), dec!(10_000.0)), + ); + let report = explain_pnl(&session).unwrap(); + + let missed = &report.missed_fills.orders[0]; + assert_eq!(missed.coverage, Coverage::Covered); + assert_eq!( + missed.crossed, + Some(false), + "the cross came only after cancellation" + ); + assert!(missed.hypothetical_pnl.is_none()); + } + + #[test] + fn still_open_order_created_after_the_recorded_window_is_uncovered() { + // The recorder can stop (capture finalized, window_end stamped) + // while the session keeps trading: a limit order submitted after + // that rests entirely outside the recorded window, and its inverted + // scan range must refuse coverage — never report "covered, no + // cross" over a tape that ended before the order existed. + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.submit_limit( + "2026-01-01T01:30:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(44_000.0), + ); + let session = builder.session( + Some(finalized_capture()), + stopped_manifest(dec!(10_000.0), dec!(10_000.0)), + ); + let report = explain_pnl(&session).unwrap(); + + let missed = &report.missed_fills.orders[0]; + assert_eq!(missed.outcome, MissedOutcome::StillOpen); + assert_eq!(missed.coverage, Coverage::Uncovered); + assert!( + missed + .uncovered_reason + .as_deref() + .unwrap() + .contains("outside the recorded window") + ); + assert_eq!(missed.crossed, None, "an unseen window must not answer"); + } + + #[test] + fn reconnected_capture_marks_missed_fills_uncovered() { + // A reconnect is an invisible gap (fresh snapshot, no replay of the + // outage): the recording must refuse to answer, even though a + // recorded frame does cross the limit. + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let order = builder.submit_limit( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(44_000.0), + ); + builder.ticker( + "2026-01-01T00:20:00Z", + "BTC/USD", + dec!(43_800.0), + dec!(43_900.0), + ); + builder.cancel("2026-01-01T00:30:00Z", Origin::Cli, &order.id); + let mut capture = finalized_capture(); + if let Some(summary) = &mut capture.summary { + summary.reconnect_count = 1; + } + let session = builder.session( + Some(capture), + stopped_manifest(dec!(10_000.0), dec!(10_000.0)), + ); + let report = explain_pnl(&session).unwrap(); + + let missed = &report.missed_fills.orders[0]; + assert_eq!(missed.coverage, Coverage::Uncovered); + assert!( + missed + .uncovered_reason + .as_deref() + .unwrap() + .contains("reconnect") + ); + assert_eq!(missed.crossed, None, "a gapped recording must not answer"); + } + + #[test] + fn unparsed_frames_mark_missed_fills_uncovered() { + // A frame the recorder could not parse never reached the tape: the + // recording must refuse to answer, even though a recorded frame does + // cross the limit. + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let order = builder.submit_limit( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(44_000.0), + ); + builder.ticker( + "2026-01-01T00:20:00Z", + "BTC/USD", + dec!(43_800.0), + dec!(43_900.0), + ); + builder.cancel("2026-01-01T00:30:00Z", Origin::Cli, &order.id); + let mut capture = finalized_capture(); + if let Some(summary) = &mut capture.summary { + summary.frames_unparsed = 1; + } + let session = builder.session( + Some(capture), + stopped_manifest(dec!(10_000.0), dec!(10_000.0)), + ); + let report = explain_pnl(&session).unwrap(); + + let missed = &report.missed_fills.orders[0]; + assert_eq!(missed.coverage, Coverage::Uncovered); + assert!( + missed + .uncovered_reason + .as_deref() + .unwrap() + .contains("failed to parse") + ); + assert_eq!(missed.crossed, None, "a holey recording must not answer"); + } + + #[test] + fn multiple_epochs_decompose_only_the_last_and_disclose_skipped() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.account( + "2026-01-01T00:10:00Z", + Origin::Cli, + AccountEvent::Reset(PaperConfig { + balance: dec!(5_000.0), + currency: "USD".to_string(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.0), + }), + ); + builder.market_fill( + "2026-01-01T00:20:00Z", + Origin::Mcp, + OrderSide::Buy, + "BTCUSD", + dec!(0.05), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(5_040.0), dec!(5_000.0))); + let report = explain_pnl(&session).unwrap(); + + assert_eq!(report.epochs_skipped, 1); + assert_eq!(report.trades.len(), 1, "only the final epoch's fill"); + assert_close( + report.anchor.starting_balance, + dec!(5_000.0), + "anchor is the final epoch's balance, not the manifest's", + ); + assert!(report.caveats.iter().any(|c| c.contains("skipped"))); + } + + #[test] + fn attribution_splits_fees_by_submission_origin_for_limit_fills() { + let mut builder = SessionBuilder::with_rates(dec!(100_000.0), dec!(0.0026), dec!(0.0)); + builder.submit_limit( + "2026-01-01T00:05:00Z", + Origin::Mcp, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + dec!(45_000.0), + ); + // A CLI command reconciles the fill — the MCP submitter still owns it. + builder.fill_pending( + "2026-01-01T00:20:00Z", + Origin::Cli, + "BTCUSD", + dec!(44_900.0), + dec!(44_800.0), + ); + builder.market_fill( + "2026-01-01T00:25:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(45_000.0), dec!(44_900.0)), + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(45_500.0), + dec!(45_600.0), + ); + let session = builder.session(None, stopped_manifest(dec!(100_100.0), dec!(100_000.0))); + let report = explain_pnl(&session).unwrap(); + + assert_eq!(report.attribution.len(), 2); + let by_origin = |origin: Origin| { + report + .attribution + .iter() + .find(|slice| slice.origin == origin) + .unwrap() + }; + assert_eq!(by_origin(Origin::Mcp).trades, 1, "the limit fill is MCP's"); + assert_eq!(by_origin(Origin::Cli).trades, 1, "the market fill is CLI's"); + assert!(by_origin(Origin::Mcp).fees < dec!(0.0)); + } + + #[test] + fn decision_reasons_join_trades_by_order_id() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let trade = builder.market_fill( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000.0), dec!(49_900.0)), + ); + builder.decision("2026-01-01T00:05:00Z", &trade.order_id, "dip below 20d MA"); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_100.0), dec!(10_000.0))); + let report = explain_pnl(&session).unwrap(); + + assert_eq!(report.trades[0].reason.as_deref(), Some("dip below 20d MA")); + assert_eq!(report.attribution[0].decided, 1); + } + + #[test] + fn unmarked_base_asset_folds_into_residual_with_caveat() { + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "ETHUSD", + dec!(1.0), + (dec!(2_000.0), dec!(1_999.0)), + ); + // Only BTC was recorded: ETH has no end mark. + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_050.0), dec!(10_000.0))); + let report = explain_pnl(&session).unwrap(); + + assert!(report.trades[0].price_movement.is_none()); + assert!( + amount(&report, ComponentKind::PriceMovement).is_none(), + "not one fill's movement was derivable: unknown, never a fake zero" + ); + assert!( + report.caveats.iter().any(|c| c.contains("ETH")), + "caveats: {:?}", + report.caveats + ); + // The identity still holds: residual absorbs the unmarked movement. + let sum: Decimal = report + .components + .iter() + .filter_map(|line| line.amount) + .sum(); + assert_close(sum, report.anchor.total_pnl, "sum equals the anchor"); + } + + #[test] + fn fees_line_counts_only_the_converted_fills() { + // A fill whose quote asset has no end mark is excluded from the fee + // sum; the sentence must count the same set the number describes. + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + builder.market_fill( + "2026-01-01T00:05:00Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.01), + (dec!(50_000.0), dec!(49_900.0)), + ); + // Hand-built EUR-quoted fill: EUR has no recorded end mark below. + let state = PaperState::new(dec!(10_000.0), "EUR"); + let mut trade = state + .decide_market_order( + OrderSide::Buy, + "BTCEUR", + dec!(0.01), + dec!(46_000.0), + dec!(45_900.0), + ) + .unwrap(); + trade.filled_at = "2026-01-01T00:06:00Z".parse().unwrap(); + builder.account( + "2026-01-01T00:06:00Z", + Origin::Cli, + AccountEvent::OrderFilled { trade }, + ); + builder.ticker( + "2026-01-01T00:50:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_050.0), dec!(10_000.0))); + let report = explain_pnl(&session).unwrap(); + + let fees = report + .components + .iter() + .find(|line| line.kind == ComponentKind::Fees) + .unwrap(); + assert!( + fees.explanation.contains("across 1 fill"), + "2 fills on record, 1 converted: {}", + fees.explanation + ); + assert!(report.trades[1].notional.is_none(), "EUR fill unconverted"); + } + + #[test] + fn attribution_notional_is_converted_to_the_anchor_currency() { + // A BTC/EUR fill on a USD account: cost is EUR, and both the trade + // line and the origin slice must carry it multiplied by the EUR/USD + // end mark, never the raw quote-currency figure. + let mut builder = SessionBuilder::with_rates(dec!(10_000.0), dec!(0.0026), dec!(0.0)); + let state = PaperState::new(dec!(100_000.0), "EUR"); + let mut trade = state + .decide_market_order( + OrderSide::Buy, + "BTCEUR", + dec!(1.0), + dec!(45_000.0), + dec!(44_900.0), + ) + .unwrap(); + trade.filled_at = "2026-01-01T00:05:00Z".parse().unwrap(); + builder.account( + "2026-01-01T00:05:00Z", + Origin::Mcp, + AccountEvent::OrderFilled { trade }, + ); + builder.ticker("2026-01-01T00:50:00Z", "EUR/USD", dec!(1.10), dec!(1.11)); + builder.ticker( + "2026-01-01T00:51:00Z", + "BTC/USD", + dec!(51_000.0), + dec!(51_100.0), + ); + let session = builder.session(None, stopped_manifest(dec!(10_050.0), dec!(10_000.0))); + let report = explain_pnl(&session).unwrap(); + + assert_close( + report.trades[0].notional.unwrap(), + dec!(45_000.0) * dec!(1.10), + "trade notional in USD", + ); + assert_close( + report.attribution[0].gross_notional, + dec!(45_000.0) * dec!(1.10), + "slice notional in USD", + ); + } + + mod properties { + use proptest::prelude::*; + use rust_decimal::prelude::FromPrimitive; + + use super::*; + + #[derive(Debug, Clone)] + struct Op { + buy: bool, + limit: bool, + volume: Decimal, + price: Decimal, + spread_fraction: Decimal, + } + + /// Fuzzed f64 ranges, snapped to `Decimal` at the boundary — the ops + /// themselves are exact so the engine math stays float-free. + fn op() -> impl Strategy { + ( + any::(), + any::(), + 0.001..0.5_f64, + 1_000.0..100_000.0_f64, + 0.0001..0.01_f64, + ) + .prop_map(|(buy, limit, volume, price, spread_fraction)| Op { + buy, + limit, + volume: Decimal::from_f64(volume).unwrap(), + price: Decimal::from_f64(price).unwrap(), + spread_fraction: Decimal::from_f64(spread_fraction).unwrap(), + }) + } + + /// Drive the real engine through a random op sequence; infeasible + /// ops (insufficient balance) are skipped, exactly as `decide_*` + /// rejects them live. + fn random_session( + ops: &[Op], + slippage_rate: Decimal, + ) -> (SessionBuilder, Decimal, (Decimal, Decimal)) { + let mut builder = + SessionBuilder::with_rates(dec!(1_000_000.0), dec!(0.0026), slippage_rate); + let mut gross = dec!(0.0); + let mut last_quote = (dec!(50_000.0), dec!(49_900.0)); + for (index, op) in ops.iter().enumerate() { + let ts = format!("2026-01-01T00:{:02}:{:02}Z", 10 + index / 60, index % 60); + let side = if op.buy { + OrderSide::Buy + } else { + OrderSide::Sell + }; + let bid = op.price; + let ask = bid * (dec!(1.0) + op.spread_fraction); + last_quote = (ask, bid); + if op.limit { + // A marketable limit: rests, then fills at its own price + // on the next reconcile pass against the same quote. + let limit_price = if op.buy { ask } else { bid }; + if builder + .state + .decide_limit_order(side, "BTCUSD", op.volume, limit_price) + .is_ok() + { + builder.submit_limit( + &ts, + Origin::Cli, + side, + "BTCUSD", + op.volume, + limit_price, + ); + builder.fill_pending(&ts, Origin::Mcp, "BTCUSD", ask, bid); + gross += op.volume * limit_price; + } + } else if builder + .state + .decide_market_order(side, "BTCUSD", op.volume, ask, bid) + .is_ok() + { + let trade = builder.market_fill( + &ts, + Origin::Cli, + side, + "BTCUSD", + op.volume, + (ask, bid), + ); + gross += trade.cost; + } + } + builder.ticker( + "2026-01-01T00:59:59Z", + "BTC/USD", + last_quote.1, + last_quote.0, + ); + (builder, gross, last_quote) + } + + proptest! { + /// The additive identity holds for any anchor. The sum matching + /// is definitional (the residual is the remainder), so this pins + /// only that every component exists and sums cleanly over the + /// fuzzed op space; a *wrong* component is caught by the sibling + /// recorded-marks test, where the residual must collapse to dust. + #[test] + fn identity_holds_for_random_fill_sequences( + ops in prop::collection::vec(op(), 0..30), + slippage_rate in 0.0..0.005_f64, + final_value in 500_000.0..1_500_000.0_f64, + ) { + let slippage_rate = Decimal::from_f64(slippage_rate).unwrap(); + let final_value = Decimal::from_f64(final_value).unwrap(); + let (builder, gross, _) = random_session(&ops, slippage_rate); + let session = + builder.session(None, stopped_manifest(final_value, dec!(1_000_000.0))); + let report = explain_pnl(&session).unwrap(); + + let mut sum = dec!(0.0); + for line in &report.components { + let amount = line.amount.expect("fully marked session"); + sum += amount; + } + let tolerance = dec!(0.000001) * gross.max(dec!(1)); + prop_assert!( + (sum - report.anchor.total_pnl).abs() <= tolerance, + "sum {sum} vs total {}", + report.anchor.total_pnl + ); + } + + /// The ticket's bounded-residual acceptance: when the anchor's + /// final value is computed from the same recorded end marks the + /// decomposition uses, the residual collapses to at most + /// 28-digit rounding dust — any large real-world residual is the + /// live-vs-recorded gap, never the math. + #[test] + fn residual_is_bounded_when_final_value_comes_from_recorded_marks( + ops in prop::collection::vec(op(), 1..30), + slippage_rate in 0.0..0.005_f64, + ) { + let slippage_rate = Decimal::from_f64(slippage_rate).unwrap(); + let (builder, gross, last_quote) = random_session(&ops, slippage_rate); + let prices = HashMap::from([("BTCUSD".to_string(), last_quote)]); + let (final_value, complete) = builder.state.compute_portfolio_value(&prices); + prop_assume!(complete); + let session = + builder.session(None, stopped_manifest(final_value, dec!(1_000_000.0))); + let report = explain_pnl(&session).unwrap(); + + let residual = report + .components + .iter() + .find(|line| line.kind == ComponentKind::Residual) + .and_then(|line| line.amount) + .expect("residual present"); + let tolerance = dec!(0.000001) * gross.max(dec!(1)); + prop_assert!( + residual.abs() <= tolerance, + "residual {residual} exceeds {tolerance} (gross {gross})" + ); + } + } + } +} diff --git a/crates/kraken-replay/src/explain/report.rs b/crates/kraken-replay/src/explain/report.rs new file mode 100644 index 0000000..ea1a199 --- /dev/null +++ b/crates/kraken-replay/src/explain/report.rs @@ -0,0 +1,298 @@ +//! Public response model for `kraken explain pnl`. +//! +//! The additive JSON contract reuses paper-engine vocabulary; decomposition +//! logic remains in [`super`]. + +use chrono::{DateTime, Utc}; +use kraken_core::OrderSide; +use kraken_paper::PaperOrderType; +use kraken_paper::account::Origin; +use rust_decimal::Decimal; +use serde::Serialize; +use serde_with::skip_serializing_none; + +/// A reconciled P&L explanation with counterfactuals kept outside the sum. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct PnlReport { + /// Output-envelope discriminators: `group` names the command surface + /// (distinct from an account's execution `mode` of paper/live), `type` the + /// result kind. + pub group: &'static str, + #[serde(rename = "type")] + pub kind: &'static str, + pub session: String, + pub strategy: Option, + pub anchor: Anchor, + pub components: Vec, + pub trades: Vec, + pub missed_fills: MissedFillSection, + pub attribution: Vec, + pub window: Option, + /// Earlier `Initialized`/`Reset` epochs on the journal, not decomposed — + /// only the final epoch is (parity with what `session stop` folded). + pub epochs_skipped: usize, + pub caveats: Vec, +} + +/// Authoritative stop totals from the final account epoch. +#[derive(Debug, Serialize)] +pub struct Anchor { + pub starting_balance: Decimal, + pub final_value: Decimal, + pub total_pnl: Decimal, + pub ended_at: DateTime, + pub currency: String, +} + +/// One term in `price_movement + fees + spread + slippage + residual = total_pnl`. +#[derive(Debug, Serialize)] +pub struct ComponentLine { + pub kind: ComponentKind, + /// Missing when unavailable; the residual absorbs it and a caveat explains why. + pub amount: Option, + /// One sentence of what this number means for *this* session. + pub explanation: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentKind { + PriceMovement, + Fees, + Spread, + Slippage, + Residual, +} + +/// One fill's contributions to the components, in the anchor currency. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct TradeLine { + pub trade_id: String, + pub order_id: String, + pub pair: String, + pub side: OrderSide, + pub order_type: PaperOrderType, + pub volume: Decimal, + pub price: Decimal, + /// The fill's cost converted to the anchor currency; `None` when the + /// quote asset has no recorded end mark (the whole fill sits in the + /// residual). + pub notional: Option, + /// Mid of the recorded reference quote at fill time; `None` = no valid + /// recorded quote (that fill contributes zero spread/slippage). + pub mid: Option, + /// This fill's price-movement contribution; `None` = its base or quote + /// asset has no recorded end mark (folded into the residual). + pub price_movement: Option, + pub fees: Decimal, + pub spread: Decimal, + pub slippage: Decimal, + pub origin: Origin, + /// The decision log's rationale, joined by order id (`--reason`). + pub reason: Option, +} + +/// The counterfactual section: what resting limit orders *might* have done. +/// Never part of the component sum — a cancelled order contributed $0 to +/// actual P&L. +#[derive(Debug, Serialize)] +pub struct MissedFillSection { + /// The honesty preamble: how the engine actually fills, and why this + /// scan is a recording-based proxy that can disagree with it. + pub assumptions: &'static str, + pub orders: Vec, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct MissedFill { + pub order_id: String, + pub pair: String, + pub side: OrderSide, + pub volume: Decimal, + pub limit_price: Decimal, + pub resting_from: DateTime, + /// Cancellation instant, or the recording's window end while still + /// open; absent when neither is known (unfinalized capture). + pub resting_until: Option>, + pub outcome: MissedOutcome, + pub coverage: Coverage, + /// Why the recording cannot answer the question (uncovered only). + pub uncovered_reason: Option, + /// Whether a recorded ticker frame crossed the limit while resting. + pub crossed: Option, + pub first_crossed_at: Option>, + /// The hypothetical fill marked at the recorded end, net of fees. + pub hypothetical_pnl: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, strum::Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum MissedOutcome { + Cancelled, + StillOpen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Coverage { + Covered, + Uncovered, +} + +/// One origin's share of the activity: who (CLI vs agent) traded, paid, and +/// decided what. A limit fill belongs to whoever *submitted* the order — +/// the fill record's origin is whichever later command happened to +/// reconcile it. +#[derive(Debug, Serialize)] +pub struct OriginSlice { + pub origin: Origin, + pub trades: usize, + /// Σ fill cost in the anchor currency; fills whose quote asset has no + /// end mark are excluded (their whole effect sits in the residual). + pub gross_notional: Decimal, + pub fees: Decimal, + pub spread: Decimal, + pub slippage: Decimal, + /// Fills whose order id joins a logged decision (`--reason`). + pub decided: usize, +} + +/// What the recorded market did over the session — the whole story for a +/// zero-trade session. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct WindowSummary { + pub start: Option>, + pub end: Option>, + pub symbols: Vec, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct SymbolWindow { + /// Canonical `BASEQUOTE` built from the folded base/quote (XBT→BTC) — + /// alias pairs therefore differ from the decision log's alias-preserving + /// canon (`XBTUSD` stays `XBTUSD` there). + pub symbol: String, + pub first_mid: Option, + pub last_mid: Option, + pub move_pct: Option, + /// Mean recorded ticker spread; `None` when no ticker was recorded. + pub avg_spread: Option, + pub frames: usize, + pub end_mark_source: Option, +} + +/// Where a symbol's end-of-recording mark came from, best first: the mark +/// mirrors `compute_portfolio_value`'s bid marking, so the residual stays +/// interpretable as purely "live vs recorded". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EndMarkSource { + TickerBid, + TradePrice, + OhlcClose, +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn vocabulary_serializes_snake_case() { + for (value, expected) in [ + ( + serde_json::to_string(&ComponentKind::PriceMovement).unwrap(), + "\"price_movement\"", + ), + ( + serde_json::to_string(&MissedOutcome::StillOpen).unwrap(), + "\"still_open\"", + ), + ( + serde_json::to_string(&Coverage::Uncovered).unwrap(), + "\"uncovered\"", + ), + ( + serde_json::to_string(&EndMarkSource::TickerBid).unwrap(), + "\"ticker_bid\"", + ), + ] { + assert_eq!(value, expected); + } + } + + #[test] + fn engine_enums_normalize_to_snake_case_on_the_report_wire() { + // The reused engine enums spell snake_case at the source; this pins + // the report's public contract against a casing regression there. + let line = TradeLine { + trade_id: "PAPER-00002".into(), + order_id: "PAPER-00001".into(), + pair: "BTCUSD".into(), + side: OrderSide::Buy, + order_type: PaperOrderType::Limit, + volume: dec!(0.1), + price: dec!(45_000.0), + notional: Some(dec!(4_500.0)), + mid: None, + price_movement: None, + fees: -dec!(1.0), + spread: dec!(0.0), + slippage: dec!(0.0), + origin: Origin::Mcp, + reason: None, + }; + let json = serde_json::to_value(&line).unwrap(); + assert_eq!(json["side"], "buy"); + assert_eq!(json["order_type"], "limit"); + assert_eq!(json["origin"], "mcp"); + } + + #[test] + fn report_serializes_the_pinned_top_level_keys() { + // The JSON contract agents consume: pinned so a rename fails here, + // not in a consumer. + let report = PnlReport { + group: "session", + kind: "pnl_explanation", + session: "btc-dip".into(), + strategy: None, + anchor: Anchor { + starting_balance: dec!(10_000.0), + final_value: dec!(10_250.5), + total_pnl: dec!(250.5), + ended_at: "2026-07-08T10:30:00Z".parse().unwrap(), + currency: "USD".into(), + }, + components: vec![ComponentLine { + kind: ComponentKind::Residual, + amount: Some(dec!(250.5)), + explanation: "test".into(), + }], + trades: Vec::new(), + missed_fills: MissedFillSection { + assumptions: "test", + orders: Vec::new(), + }, + attribution: Vec::new(), + window: None, + epochs_skipped: 0, + caveats: Vec::new(), + }; + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["group"], "session"); + assert_eq!(json["type"], "pnl_explanation"); + assert_eq!(json["session"], "btc-dip"); + assert_eq!(json["anchor"]["total_pnl"], 250.5); + assert_eq!(json["components"][0]["kind"], "residual"); + assert!(json.get("strategy").is_none(), "absent optionals drop out"); + assert!(json.get("window").is_none()); + } +} diff --git a/crates/kraken-replay/src/lib.rs b/crates/kraken-replay/src/lib.rs new file mode 100644 index 0000000..9011859 --- /dev/null +++ b/crates/kraken-replay/src/lib.rs @@ -0,0 +1,53 @@ +#![doc = include_str!("../README.md")] + +mod explain; +mod playback; +mod source; + +pub use explain::explain_pnl; +pub use explain::report::{ + Anchor, ComponentKind, ComponentLine, Coverage, EndMarkSource, MissedFill, MissedFillSection, + MissedOutcome, OriginSlice, PnlReport, SymbolWindow, TradeLine, WindowSummary, +}; +pub use playback::{MAX_SPEED, MIN_SPEED, PlaybackClock, replay_events}; +pub use source::resolve_source_path; + +pub type Result = std::result::Result; + +/// The replay layer's failure modes. The binary maps each variant onto the +/// JSON envelope exactly once, in its `errors` module. +#[derive(Debug, thiserror::Error)] +pub enum ReplayError { + /// A run with no final summary to anchor a P&L explanation on. The + /// hint is byte-stable — it is the agent skill's retry cue. + /// → envelope `validation`. + #[error( + "session '{session}' has no final summary to anchor on; run 'kraken session stop' first" + )] + NotStopped { session: String }, + /// Journal records newer than this build can read: a P&L decomposed + /// from a partial account track would report confident, wrong numbers. + /// → envelope `config`. + #[error( + "{skipped} account journal record(s) are newer than this build supports and were not \ + read; a P&L decomposed from a partial journal would be wrong; upgrade kraken" + )] + NewerJournal { skipped: usize }, + /// A journal with no `Initialized`/`Reset` to anchor the final epoch on. + /// → envelope `validation`. + #[error( + "session '{session}' has no account initialization on its journal to anchor a final \ + epoch on" + )] + NoEpoch { session: String }, + /// A source ref that resolves to no JSONL file (missing, or DuckDB-only). + /// → envelope `validation`. + #[error("{0}")] + Source(String), + /// The playback sink failed. → envelope `io`. + #[error(transparent)] + Emit(#[from] std::io::Error), + /// Rendering an event for emission failed. → envelope `parse`. + #[error(transparent)] + Render(#[from] serde_json::Error), +} diff --git a/crates/kraken-replay/src/playback.rs b/crates/kraken-replay/src/playback.rs new file mode 100644 index 0000000..e882fc9 --- /dev/null +++ b/crates/kraken-replay/src/playback.rs @@ -0,0 +1,342 @@ +//! Paced emission of timeline events in recorded order. +//! +//! Playback reproduces recorded gaps; analytics remain outside this module. + +use std::io::Write; + +use chrono::{DateTime, Utc}; +use kraken_session::timeline::event::TimelineEvent; +use tokio::time::Instant; + +use crate::Result; + +/// Supported playback range: 100× slow motion through near-I/O-speed replay. +pub const MIN_SPEED: f64 = 0.01; +pub const MAX_SPEED: f64 = 1000.0; + +/// Prevents damaged timestamps from parking playback for more than a year. +const MAX_STALL: std::time::Duration = std::time::Duration::from_secs(366 * 24 * 60 * 60); + +/// An anchored virtual clock with absolute deadlines. +/// +/// Absolute timing lets delayed sinks catch up instead of compounding drift. +#[derive(Debug, Clone, Copy)] +pub struct PlaybackClock { + /// Wall-clock instant playback started (w₀). + start: Instant, + /// The first event's recorded instant (t₀). + anchor: DateTime, + speed: f64, +} + +impl PlaybackClock { + pub fn anchored(anchor: DateTime, speed: f64) -> Self { + Self { + start: Instant::now(), + anchor, + speed, + } + } + + /// Computes `(at − anchor)/speed`, saturating malformed timestamps. + fn offset(&self, at: DateTime) -> std::time::Duration { + let recorded_us = (at - self.anchor).num_microseconds().unwrap_or(i64::MAX); + if recorded_us <= 0 { + return std::time::Duration::ZERO; + } + let scaled = recorded_us as f64 / 1_000_000.0 / self.speed; + std::time::Duration::try_from_secs_f64(scaled).unwrap_or(std::time::Duration::MAX) + } + + /// Waits until due; unsafe deadlines preserve order but skip pacing. + /// + /// Public for the playground's replay pump, which paces recorded frames + /// through the live capture pipeline with this same clock. + pub async fn wait_until_due(&self, at: DateTime) { + let offset = self.offset(at); + if offset > MAX_STALL { + tracing::warn!(%at, "replay deadline is over a year of wall clock away; emitting immediately"); + return; + } + match self.start.checked_add(offset) { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => { + tracing::warn!(%at, "replay deadline overflows the clock; emitting immediately") + } + } + } +} + +/// The playback loop: wait for each event's deadline, write one rendered +/// line, flush, count. The only await is the sleep, so cancellation (a +/// caller's `select!` shutdown branch) can never drop a half-written line. +/// +/// `render` is a pure transform (event → line, no trailing newline); sink +/// construction and locking stay at the call site — stdout belongs to the +/// caller for the whole playback. `emitted` is an out-parameter, not a +/// return, so the count survives cancellation and errors. +pub async fn replay_events( + events: &[TimelineEvent], + clock: PlaybackClock, + out: &mut impl Write, + render: impl Fn(&TimelineEvent) -> std::result::Result, + emitted: &mut u64, +) -> Result<()> { + for event in events { + clock.wait_until_due(event.at).await; + let mut line = render(event)?; + line.push('\n'); + if let Err(err) = out.write_all(line.as_bytes()).and_then(|()| out.flush()) { + // A closed pipe (`kraken replay … | head`) ends a *finite* + // playback cleanly — deliberately unlike a live-stream sink, + // which must surface it. + if err.kind() == std::io::ErrorKind::BrokenPipe { + return Ok(()); + } + return Err(err.into()); + } + *emitted += 1; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use kraken_recording::parse_instant; + use kraken_session::decision::{Decision, DecisionKind}; + + use super::*; + use crate::ReplayError; + + fn json_render(event: &TimelineEvent) -> std::result::Result { + serde_json::to_string(event) + } + + fn decision_event(ts: &str, seq: i64) -> TimelineEvent { + TimelineEvent::decision( + parse_instant(ts).unwrap(), + seq, + Decision { + timestamp: ts.parse().unwrap(), + kind: DecisionKind::Skip, + symbol: None, + reason: "pin".to_string(), + order_id: None, + }, + ) + } + + fn three_events_with_2s_and_3s_gaps() -> Vec { + vec![ + decision_event("2026-01-01T00:00:00Z", 1), + decision_event("2026-01-01T00:00:02Z", 2), + decision_event("2026-01-01T00:00:05Z", 3), + ] + } + + /// Records the virtual instant of every line written, so pacing tests + /// assert on the paused clock instead of real time. + #[derive(Default)] + struct InstantWriter { + instants: Vec, + bytes: Vec, + } + + impl Write for InstantWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.instants.push(Instant::now()); + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// Fails every write past the first `ok_writes` with the given kind. + struct FailingWriter { + ok_writes: usize, + kind: std::io::ErrorKind, + } + + impl Write for FailingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.ok_writes == 0 { + return Err(std::io::Error::from(self.kind)); + } + self.ok_writes -= 1; + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn offset_scales_gaps_by_speed() { + let anchor = parse_instant("2026-01-01T00:00:00Z").unwrap(); + let at = parse_instant("2026-01-01T00:00:02Z").unwrap(); + for (speed, expected) in [ + (1.0, Duration::from_secs(2)), + (10.0, Duration::from_millis(200)), + (0.5, Duration::from_secs(4)), + ] { + let clock = PlaybackClock::anchored(anchor, speed); + assert_eq!(clock.offset(at), expected, "speed {speed}"); + } + } + + #[test] + fn offset_keeps_submillisecond_precision() { + // recv_ts and wire timestamps carry microseconds; pacing must not + // floor them to whole milliseconds. + let anchor = parse_instant("2026-01-01T00:00:00Z").unwrap(); + let at = anchor + chrono::Duration::microseconds(400); + for (speed, expected) in [ + (1.0, Duration::from_micros(400)), + (0.01, Duration::from_micros(40_000)), + ] { + let clock = PlaybackClock::anchored(anchor, speed); + assert_eq!(clock.offset(at), expected, "speed {speed}"); + } + } + + #[test] + fn pre_anchor_stamps_clamp_to_zero_offset() { + let anchor = parse_instant("2026-01-01T00:00:05Z").unwrap(); + let clock = PlaybackClock::anchored(anchor, 1.0); + let earlier = parse_instant("2026-01-01T00:00:00Z").unwrap(); + assert_eq!(clock.offset(earlier), Duration::ZERO); + } + + #[test] + fn extreme_gap_offset_scales_without_panicking() { + let anchor = parse_instant("2026-01-01T00:00:00Z").unwrap(); + let clock = PlaybackClock::anchored(anchor, MIN_SPEED); + // ~100k years of recorded gap (near chrono's date ceiling) at 100× + // slow motion: a finite, huge offset — the point is no panic. + let far = anchor + chrono::Duration::days(36_500_000); + assert!(clock.offset(far) > Duration::from_secs(100_000_000_000_000)); + } + + #[tokio::test(start_paused = true)] + async fn astronomic_deadline_emits_immediately_instead_of_parking() { + let anchor = parse_instant("2026-01-01T00:00:00Z").unwrap(); + let clock = PlaybackClock::anchored(anchor, MIN_SPEED); + let far = anchor + chrono::Duration::days(36_500_000); + let before = Instant::now(); + clock.wait_until_due(far).await; + // Past MAX_STALL the wait degrades to immediate emission: the paused + // clock must not have moved at all. + assert_eq!(Instant::now(), before, "no virtual time consumed"); + } + + #[tokio::test(start_paused = true)] + async fn replay_emits_on_the_recorded_gaps_at_speed_1() { + let events = three_events_with_2s_and_3s_gaps(); + let clock = PlaybackClock::anchored(events[0].at, 1.0); + let start = clock.start; + let mut out = InstantWriter::default(); + let mut emitted = 0; + + replay_events(&events, clock, &mut out, json_render, &mut emitted) + .await + .unwrap(); + + assert_eq!(emitted, 3); + let offsets: Vec = out.instants.iter().map(|i| *i - start).collect(); + assert_eq!( + offsets, + [ + Duration::ZERO, + Duration::from_secs(2), + Duration::from_secs(5) + ] + ); + } + + #[tokio::test(start_paused = true)] + async fn faster_speed_compresses_the_gaps() { + let events = three_events_with_2s_and_3s_gaps(); + let clock = PlaybackClock::anchored(events[0].at, 10.0); + let start = clock.start; + let mut out = InstantWriter::default(); + let mut emitted = 0; + + replay_events(&events, clock, &mut out, json_render, &mut emitted) + .await + .unwrap(); + + let offsets: Vec = out.instants.iter().map(|i| *i - start).collect(); + assert_eq!( + offsets, + [ + Duration::ZERO, + Duration::from_millis(200), + Duration::from_millis(500) + ] + ); + } + + #[tokio::test(start_paused = true)] + async fn overdue_events_flush_back_to_back_after_a_stall() { + // Deadlines are absolute (`start + offset`), so after a 10 s stall + // every already-due event flushes at one instant — no re-pacing. + let events = three_events_with_2s_and_3s_gaps(); + let clock = PlaybackClock::anchored(events[0].at, 1.0); + let start = clock.start; + tokio::time::advance(Duration::from_secs(10)).await; + let mut out = InstantWriter::default(); + let mut emitted = 0; + + replay_events(&events, clock, &mut out, json_render, &mut emitted) + .await + .unwrap(); + + let stalled_until = start + Duration::from_secs(10); + assert!( + out.instants.iter().all(|at| *at == stalled_until), + "expected one shared instant, got {:?}", + out.instants + ); + } + + #[tokio::test(start_paused = true)] + async fn write_error_stops_the_replay() { + let events = three_events_with_2s_and_3s_gaps(); + let clock = PlaybackClock::anchored(events[0].at, MAX_SPEED); + let mut out = FailingWriter { + ok_writes: 1, + kind: std::io::ErrorKind::StorageFull, + }; + let mut emitted = 0; + + let err = replay_events(&events, clock, &mut out, json_render, &mut emitted) + .await + .unwrap_err(); + + assert!(matches!(err, ReplayError::Emit(_)), "got: {err:?}"); + assert_eq!(emitted, 1, "no further emissions after the failure"); + } + + #[tokio::test(start_paused = true)] + async fn broken_pipe_is_a_clean_end_not_an_error() { + let events = three_events_with_2s_and_3s_gaps(); + let clock = PlaybackClock::anchored(events[0].at, MAX_SPEED); + let mut out = FailingWriter { + ok_writes: 1, + kind: std::io::ErrorKind::BrokenPipe, + }; + let mut emitted = 0; + + replay_events(&events, clock, &mut out, json_render, &mut emitted) + .await + .unwrap(); + + assert_eq!(emitted, 1, "the pipe closed after the first line"); + } +} diff --git a/crates/kraken-replay/src/source.rs b/crates/kraken-replay/src/source.rs new file mode 100644 index 0000000..ff80159 --- /dev/null +++ b/crates/kraken-replay/src/source.rs @@ -0,0 +1,121 @@ +//! Resolving a replay-source ref to the file playback reads. Shared by the +//! replay venue, the `run start --from` path, and the lab's seal +//! enforcement — the one place that knows a source is JSONL-on-disk. + +use std::path::{Path, PathBuf}; + +use kraken_recording::TapeRef; +use kraken_session::manifest::RecordingBackend; +use kraken_session::session::{parse_ordinal, session_dir, tape_file}; + +use crate::{ReplayError, Result}; + +/// The JSONL file backing a replay source, erroring if it is absent. Replay +/// reads JSONL only, so a tape recorded to DuckDB alone resolves to a +/// missing path and is refused with the re-record hint. +/// +/// Two bases, deliberately: `tape:` addresses the GLOBAL shared +/// library (`library`; market memory is never workspace-scoped), while +/// `session:s` addresses the caller's scope's sessions tree (`scope`). In an +/// unscoped context the two coincide. +/// +/// A `run:` addresses runs by ordinal (`session:s`); label resolution is +/// CLI-layer policy and sealed lab plans store ordinal refs, so anything +/// else is refused here. +pub fn resolve_source_path(library: &Path, scope: &Path, tape: &TapeRef) -> Result { + let path = match tape { + TapeRef::Recording { name } => kraken_recording::resolve(library, name, "jsonl"), + TapeRef::Session { name } => { + let Some(ordinal) = parse_ordinal(name) else { + return Err(ReplayError::Source(format!( + "session ref '{tape}' does not name a session ordinal; replay sources address \ + sessions as session:s — resolve a label to its ordinal first (see 'kraken session \ + list')" + ))); + }; + session_dir(scope, ordinal).join(tape_file(RecordingBackend::Jsonl)) + } + }; + if path.exists() { + return Ok(path); + } + // "recorded to DuckDB alone" is only true when the DuckDB sibling exists; + // an absent tape gets a plain not-found instead of that misleading hint. + let message = match tape { + TapeRef::Recording { name } + if kraken_recording::resolve(library, name, "duckdb").exists() => + { + format!( + "tape '{tape}' was recorded to DuckDB only; replay reads JSONL — re-record \ + it with --to jsonl (see 'kraken tape list')" + ) + } + TapeRef::Recording { .. } => { + format!("tape '{tape}' not found (see 'kraken tape list')") + } + TapeRef::Session { .. } => { + format!("session '{tape}' has no recorded tape (see 'kraken session list')") + } + }; + Err(ReplayError::Source(message)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_ordinal_ref_resolves_to_the_run_tape() { + let base = tempfile::tempdir().unwrap(); + let dir = session_dir(base.path(), 3); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("tape.jsonl"), "{}\n").unwrap(); + + let tape: TapeRef = "session:s3".parse().unwrap(); + let path = resolve_source_path(base.path(), base.path(), &tape).unwrap(); + assert_eq!(path, dir.join("tape.jsonl")); + } + + #[test] + fn run_label_ref_is_refused_with_the_ordinal_grammar() { + let base = tempfile::tempdir().unwrap(); + let tape: TapeRef = "session:m1-s3".parse().unwrap(); + let err = resolve_source_path(base.path(), base.path(), &tape).unwrap_err(); + assert!(matches!(err, ReplayError::Source(_)), "got: {err}"); + assert!(err.to_string().contains("session:s"), "got: {err}"); + } + + #[test] + fn missing_session_tape_points_at_session_list() { + let base = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(session_dir(base.path(), 3)).unwrap(); + + let tape: TapeRef = "session:s3".parse().unwrap(); + let err = resolve_source_path(base.path(), base.path(), &tape).unwrap_err(); + assert!( + err.to_string().contains("has no recorded tape"), + "got: {err}" + ); + } + + #[test] + fn absent_tape_reads_not_found_without_the_duckdb_hint() { + let base = tempfile::tempdir().unwrap(); + let tape: TapeRef = "tape:nope".parse().unwrap(); + let err = resolve_source_path(base.path(), base.path(), &tape).unwrap_err(); + assert!(err.to_string().contains("not found"), "got: {err}"); + assert!(!err.to_string().contains("DuckDB"), "got: {err}"); + } + + #[test] + fn duckdb_only_tape_keeps_the_re_record_hint() { + let base = tempfile::tempdir().unwrap(); + let duckdb = kraken_recording::resolve(base.path(), "qa", "duckdb"); + std::fs::create_dir_all(duckdb.parent().unwrap()).unwrap(); + std::fs::write(&duckdb, b"").unwrap(); + + let tape: TapeRef = "tape:qa".parse().unwrap(); + let err = resolve_source_path(base.path(), base.path(), &tape).unwrap_err(); + assert!(err.to_string().contains("--to jsonl"), "got: {err}"); + } +} diff --git a/crates/kraken-replay/tests/replay_pipeline.rs b/crates/kraken-replay/tests/replay_pipeline.rs new file mode 100644 index 0000000..1544b32 --- /dev/null +++ b/crates/kraken-replay/tests/replay_pipeline.rs @@ -0,0 +1,133 @@ +//! One realistic flow through the public API only — the boundary proof for +//! the lab crate above: a fixture session explains and replays without +//! reaching past `pub` items. + +use kraken_paper::OrderSide; +use kraken_paper::account::Origin; +use kraken_replay::{MAX_SPEED, PlaybackClock, explain_pnl, replay_events}; +use kraken_session::timeline::{self, fixtures}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +/// The decomposition identity, restated through the public surface: +/// components sum exactly to the anchored total. +#[test] +fn components_sum_to_total_pnl() { + let mut builder = fixtures::SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_100), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_100), dec!(50_000)), + ); + builder.ticker( + "2026-01-01T00:00:03Z", + "BTC/USD", + dec!(51_000), + dec!(51_100), + ); + + let mut manifest = fixtures::manifest(vec![]); + let final_value = dec!(10_087.6740); // 10_000 - cost - fee + 0.1 * end bid + manifest.summary = Some(kraken_session::manifest::SessionOutcome { + ended_at: "2026-01-01T00:00:04Z".parse().unwrap(), + final_value, + pnl: final_value - dec!(10_000), + }); + let session = builder.session(None, manifest); + + let report = explain_pnl(&session).unwrap(); + let component_sum: Decimal = report + .components + .iter() + .filter_map(|line| line.amount) + .sum(); + assert_eq!(component_sum, report.anchor.total_pnl); +} + +/// NDJSON out equals the timeline in: every emitted line parses back to the +/// event that produced it, in order. +#[tokio::test(start_paused = true)] +async fn replays_a_fixture_session_end_to_end_in_order() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![fixtures::jsonl_recording()]); + fixtures::write_market( + base.path(), + &name, + &["2026-01-01T00:00:01.000000Z", "2026-01-01T00:00:03.000000Z"], + ); + fixtures::write_account( + base.path(), + &name, + &[fixtures::account_record("2026-01-01T00:00:02+00:00")], + ); + fixtures::write_decision(base.path(), &name, "2026-01-01T00:00:03+00:00"); + + let session = timeline::read(base.path(), &name).unwrap(); + let clock = PlaybackClock::anchored(session.events[0].at, MAX_SPEED); + let mut out = Vec::new(); + let mut emitted = 0; + replay_events( + &session.events, + clock, + &mut out, + serde_json::to_string, + &mut emitted, + ) + .await + .unwrap(); + + let lines: Vec = std::str::from_utf8(&out) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(lines.len(), session.events.len()); + assert_eq!(emitted, session.events.len() as u64); + for (line, event) in lines.iter().zip(&session.events) { + assert_eq!(line, &serde_json::to_value(event).unwrap()); + } +} + +/// Recorded gaps are reproduced at speed 1 — proven under paused time from +/// outside the crate. +#[tokio::test(start_paused = true)] +async fn pacing_reproduces_recorded_gaps_at_speed_1() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![fixtures::jsonl_recording()]); + fixtures::write_market( + base.path(), + &name, + &["2026-01-01T00:00:00.000000Z", "2026-01-01T00:00:02.000000Z"], + ); + + let session = timeline::read(base.path(), &name).unwrap(); + let clock = PlaybackClock::anchored(session.events[0].at, 1.0); + let started = tokio::time::Instant::now(); + let mut out = Vec::new(); + let mut emitted = 0; + replay_events( + &session.events, + clock, + &mut out, + serde_json::to_string, + &mut emitted, + ) + .await + .unwrap(); + + assert_eq!(emitted, 2); + assert_eq!( + tokio::time::Instant::now() - started, + std::time::Duration::from_secs(2), + "the recorded 2 s gap is reproduced on the virtual clock" + ); +} diff --git a/crates/kraken-session/Cargo.toml b/crates/kraken-session/Cargo.toml new file mode 100644 index 0000000..37c475b --- /dev/null +++ b/crates/kraken-session/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "kraken-session" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Playground session contract and timeline read model for kraken-cli" +license.workspace = true +repository.workspace = true +publish = false + +[features] +clap = ["dep:clap"] +duckdb = ["kraken-recording/duckdb"] +fixtures = ["dep:rust_decimal_macros"] + +[dependencies] +chrono = { workspace = true } +clap = { workspace = true, optional = true } +kraken-core = { workspace = true } +kraken-paper = { workspace = true } +kraken-recording = { workspace = true } +rust_decimal = { workspace = true } +rust_decimal_macros = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_with = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +# Tests use dec! even with the fixtures feature off. +rust_decimal_macros = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true + +# Feature-dependent integration targets are declared, not cfg'd out: a missing +# feature skips the target loudly in `cargo test`, never silently. +# The windowed-fold pins: every score anchors on this arithmetic. Declared +# so it can never silently drop out of the target set again. +[[test]] +name = "session_window" + +[[test]] +name = "session_roundtrip" +required-features = ["fixtures"] + +[[test]] +name = "duckdb_roundtrip" +required-features = ["duckdb", "fixtures"] diff --git a/crates/kraken-session/README.md b/crates/kraken-session/README.md new file mode 100644 index 0000000..e069674 --- /dev/null +++ b/crates/kraken-session/README.md @@ -0,0 +1,67 @@ +# kraken-session + +The on-disk session contract and the timeline read model. + +## Purpose + +A session is a named directory `/sessions//` holding a market +recording, an isolated paper-account journal (`events.jsonl`), a decision log +(`decisions.jsonl`), and a versioned manifest (`manifest.json`). This crate +owns that contract — the validated `SessionName`, the path layout, the +manifest MAJOR gate, the decision writer — plus the one read model over it: +`timeline::read`, merging all three tracks into a single `(at, track, seq)` +ordered event stream. + +## Architecture + +Repository + read model (CQRS-lite). The write side (provisioning, stop +finalization) stays in the binary; this crate reads. Every CLI seam is a +feature or a parameter: `base: &Path` instead of config-dir resolution, +`cli_version` as an argument instead of a telemetry global, clap derives +behind the `clap` feature, the DuckDB read path behind `duckdb`. + +- `lib.rs` — `SessionName`, path layout, `MANIFEST_VERSION` gate +- `manifest.rs` — the durable session description, `load`/`save` +- `decision.rs` — the "why" track and its locked appender +- `timeline.rs` / `timeline/event.rs` — the three-track merge and its event + vocabulary; journal-accurate `fixtures` for downstream tests + +## Interface + +What crosses upward: `SessionName`, the path fns (`sessions_root`, `dir`, +`events_path`, `manifest_path`, `decisions_path`, `ensure_provisioned`), +`SessionManifest`/`SessionOutcome`/`StrategyRef`, `DecisionKind` and +`decision::append`, `timeline::read` → `SessionTimeline`/`TimelineEvent`/ +`EventPayload`/`Track`/`final_epoch_start`, and `SessionError`. + +What never enters: `config_dir()` resolution, clap (outside the feature), +telemetry. + +## Errors + +`SessionError` — `Rejected` (→ envelope `validation`), `IncompatibleManifest` +(→ `config`), `Damaged` (→ `parse`), `Recording` (transparent over +`kraken_recording::Error`), `Io` (→ `io`). Bridged once in the binary's +`src/errors.rs`. + +## Features + +| feature | enables | default | +|------------|----------------------------------------------------|---------| +| `clap` | `clap::ValueEnum` on `DecisionKind` | off | +| `duckdb` | the DuckDB timeline read path | off | +| `fixtures` | `timeline::fixtures` for downstream crates' tests | off | + +All strictly additive. + +## Testing + +Manifest round-trip and MAJOR-gate pins, merge/lock/damage suites, and the +`tests/session_roundtrip.rs` flow through the public API (plus a +`duckdb`-gated sibling). Feature-matrix proof: + +```text +cargo test -p kraken-session +cargo test -p kraken-session --all-features +cargo check -p kraken-session --no-default-features +``` diff --git a/crates/kraken-session/src/decision.rs b/crates/kraken-session/src/decision.rs new file mode 100644 index 0000000..e6aec3e --- /dev/null +++ b/crates/kraken-session/src/decision.rs @@ -0,0 +1,204 @@ +//! Append-only rationale records for session actions. +//! +//! A short writer lock preserves timestamp order for later P&L attribution. + +use std::path::Path; + +use chrono::{DateTime, Utc}; +use kraken_recording::JsonlSink; +use kraken_recording::Sink; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::Result; + +/// One entry in a session's decision log. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Decision { + /// Instant the decision was recorded. + pub timestamp: DateTime, + /// What kind of decision this is. + pub kind: DecisionKind, + /// The trading pair the decision concerns, if any (a `skip`/`alert` may + /// have none). Canonicalized on construction (uppercase, `/` stripped) so + /// one instrument keys identically no matter which writer logged it. + pub symbol: Option, + /// Free-text rationale ("the why"). + pub reason: String, + /// A paper order this decision produced or references, if any. + pub order_id: Option, +} + +/// The kind of a logged decision. +/// +/// Matches `playground note --kind`: `buy`, `sell`, `skip`, +/// `alert`. The `clap::ValueEnum` derive rides the optional `clap` feature — +/// front-ends turn it on; the crate itself never needs clap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum DecisionKind { + /// A decision to buy. + Buy, + /// A decision to sell. + Sell, + /// A decision to pull a resting order. + Cancel, + /// A deliberate no-op (considered and declined to act). + Skip, + /// A noteworthy observation with no trade. + Alert, + /// A newer build's kind. Never constructed by writers (nor offered as a + /// `--kind` value); the timeline reader skips it. + #[serde(other)] + #[cfg_attr(feature = "clap", value(skip))] + Unknown, +} + +/// Uses the REST form because slash removal is deterministic but insertion is not. +fn canonical_symbol(symbol: &str) -> String { + symbol.replace('/', "").to_uppercase() +} + +/// Appends under lock, stamping after acquisition to preserve timestamp order. +pub fn append( + path: &Path, + kind: DecisionKind, + symbol: Option, + reason: String, + order_id: Option, +) -> Result { + let mut sink = JsonlSink::create(path.to_path_buf(), "session decision log")?; + let decision = Decision { + timestamp: Utc::now(), + kind, + symbol: symbol.as_deref().map(canonical_symbol), + reason, + order_id, + }; + sink.record(std::slice::from_ref(&decision))?; + Ok(decision) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buy() -> Decision { + Decision { + timestamp: "2026-07-02T10:00:00Z".parse().unwrap(), + kind: DecisionKind::Buy, + symbol: Some("BTC/USD".to_string()), + reason: "dip below 20d MA".to_string(), + order_id: Some("O123".to_string()), + } + } + + #[test] + fn kinds_render_as_note_flag_values() { + for (kind, expected) in [ + (DecisionKind::Buy, "\"buy\""), + (DecisionKind::Sell, "\"sell\""), + (DecisionKind::Cancel, "\"cancel\""), + (DecisionKind::Skip, "\"skip\""), + (DecisionKind::Alert, "\"alert\""), + ] { + assert_eq!(serde_json::to_string(&kind).unwrap(), expected); + } + } + + #[test] + fn unknown_kind_is_the_deserialization_fallback() { + // The timeline reader's skip depends on newer kinds parsing as Unknown. + assert_eq!( + serde_json::from_str::("\"hold\"").unwrap(), + DecisionKind::Unknown + ); + } + + #[test] + fn serializes_to_a_single_line() { + let json = serde_json::to_string(&buy()).unwrap(); + assert!(!json.contains('\n'), "JSONL line must not embed a newline"); + } + + #[test] + fn busy_decision_log_rejects_the_append() { + // The conflict must surface as an Err — `paper --reason` warns and + // continues, `playground note` propagates; both need it reported. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("decisions.jsonl"); + let _held = kraken_recording::FileLock::acquire(&path, "test holder").unwrap(); + + let err = append(&path, DecisionKind::Buy, None, "dip".into(), None).unwrap_err(); + // The lock refusal is the store's, surfaced transparently — the + // binary's bridge lands `Recording(Rejected)` on `validation`. + assert!(matches!( + err, + crate::SessionError::Recording(kraken_recording::Error::Rejected(_)) + )); + assert!(err.to_string().contains("in use"), "got: {err}"); + } + + #[test] + fn append_is_append_only_across_calls() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("decisions.jsonl"); + let first = append( + &path, + DecisionKind::Buy, + Some("BTC/USD".into()), + "dip".into(), + None, + ) + .unwrap(); + let second = append( + &path, + DecisionKind::Skip, + None, + "spread too wide".into(), + None, + ) + .unwrap(); + + let lines: Vec = std::fs::read_to_string(&path) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!(lines, [first, second]); + } + + #[test] + fn symbol_forms_converge_to_one_canonical_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("decisions.jsonl"); + for input in ["ETH/USD", "ETHUSD", "ethusd", "eth/usd"] { + let d = append( + &path, + DecisionKind::Skip, + Some(input.into()), + "why".into(), + None, + ) + .unwrap(); + assert_eq!(d.symbol.as_deref(), Some("ETHUSD"), "input {input:?}"); + } + } + + #[test] + fn optional_refs_are_omitted_when_absent() { + let skip = Decision { + timestamp: "2026-07-02T10:00:00Z".parse().unwrap(), + kind: DecisionKind::Skip, + symbol: None, + reason: "spread too wide".to_string(), + order_id: None, + }; + let json = serde_json::to_string(&skip).unwrap(); + assert!(!json.contains("symbol")); + assert!(!json.contains("order_id")); + } +} diff --git a/crates/kraken-session/src/lib.rs b/crates/kraken-session/src/lib.rs new file mode 100644 index 0000000..1d5236b --- /dev/null +++ b/crates/kraken-session/src/lib.rs @@ -0,0 +1,199 @@ +#![doc = include_str!("../README.md")] + +use std::fmt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +pub mod decision; +pub mod manifest; +pub mod session; +pub mod timeline; + +pub type Result = std::result::Result; + +/// Session failures aligned with recording categories for stable CLI mapping. +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + /// A session that doesn't exist, is held by a writer, or needs a backend + /// this build lacks; also an invalid name. → envelope `validation`. + #[error("{0}")] + Rejected(String), + /// A manifest MAJOR this build can't read. → envelope `config`. + #[error( + "session manifest version {found} is incompatible with this build (expects {expected})" + )] + IncompatibleManifest { found: String, expected: String }, + /// A manifest the schema can't decode. → envelope `parse`. + #[error("{0}")] + Damaged(String), + /// Journal-store failure; the envelope mapping delegates to the recording + /// bridge (Rejected→validation, Damaged→parse, Io/Engine→io). + #[error(transparent)] + Recording(#[from] kraken_recording::Error), + /// Filesystem failure reading the manifest. → envelope `io`. + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// The manifest schema version (SemVer; a differing MAJOR is incompatible). +/// +/// Owned here, distinct from [`kraken_recording::schema::SCHEMA_VERSION`]: the +/// manifest and the recording format version independently. +pub const MANIFEST_VERSION: &str = "1.0"; + +/// Whether an on-disk manifest version is compatible with what this build +/// writes — true iff they share a MAJOR component. +/// +/// Deliberately mirrors [`kraken_recording::schema::is_compatible`] rather than +/// sharing code: the two version domains evolve independently. +pub fn is_compatible(found: &str) -> bool { + major(found) == major(MANIFEST_VERSION) +} + +/// The MAJOR component of a `MAJOR.MINOR` version string. +fn major(version: &str) -> &str { + version.split('.').next().unwrap_or(version) +} + +/// One validated path segment using the shared artifact-name grammar. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionName(String); + +impl FromStr for SessionName { + type Err = SessionError; + + fn from_str(s: &str) -> Result { + // Shared grammar prevents artifact namespaces from diverging. + if !kraken_core::name::is_safe_name_segment(s) { + return Err(SessionError::Rejected(format!( + "Invalid session name {s:?}: use only letters, digits, '.', '-', or '_' \ + (no '/', spaces, or '.'/'..')." + ))); + } + Ok(Self(s.to_owned())) + } +} + +impl TryFrom<&str> for SessionName { + type Error = SessionError; + + /// See [`SessionName::from_str`]. + fn try_from(s: &str) -> Result { + s.parse() + } +} + +impl fmt::Display for SessionName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for SessionName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// The root under which all named sessions live: `/sessions/`. `base` +/// is the config dir in production and a temp dir in tests. +pub fn sessions_root(base: &Path) -> PathBuf { + base.join("sessions") +} + +/// A session's directory: `/sessions//`. +pub fn dir(base: &Path, name: &SessionName) -> PathBuf { + sessions_root(base).join(name.as_ref()) +} + +/// The isolated paper account's event log: `/sessions//events.jsonl`. +pub fn events_path(base: &Path, name: &SessionName) -> PathBuf { + dir(base, name).join("events.jsonl") +} + +/// The session manifest: `/sessions//manifest.json`. +pub fn manifest_path(base: &Path, name: &SessionName) -> PathBuf { + dir(base, name).join("manifest.json") +} + +/// The decision log: `/sessions//decisions.jsonl`. +pub fn decisions_path(base: &Path, name: &SessionName) -> PathBuf { + dir(base, name).join("decisions.jsonl") +} + +/// Requires a manifest so decision writers cannot orphan rationale logs. +pub fn ensure_provisioned(base: &Path, name: &SessionName) -> Result<()> { + if manifest_path(base, name).exists() { + Ok(()) + } else { + Err(SessionError::Rejected(format!( + "session {name} not found; start one with 'kraken session start'" + ))) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn session_name_accepts_safe_segments() { + // Note `..btc` is a literal segment, not the `..` traversal token. + for ok in ["btc-dip", "s1", "ETH_USD.1", "a.b-c_d", "..btc"] { + assert!(ok.parse::().is_ok(), "{ok:?} should parse"); + } + } + + #[test] + fn session_name_rejects_traversal_and_empty() { + for bad in [ + "", + ".", + "..", + "...", + "/", + "./", + "../../etc/passwd", + "a/b", + "BTC/USD", + "BTC USD", + "a\0b", + ] { + assert!(bad.parse::().is_err(), "{bad:?} should reject"); + } + } + + #[test] + fn resolvers_stay_under_sessions_dir() { + let base = Path::new("/cfg"); + let name: SessionName = "btc-dip".parse().unwrap(); + assert_eq!(dir(base, &name), base.join("sessions").join("btc-dip")); + assert!(events_path(base, &name).ends_with("sessions/btc-dip/events.jsonl")); + assert!(manifest_path(base, &name).ends_with("sessions/btc-dip/manifest.json")); + assert!(decisions_path(base, &name).ends_with("sessions/btc-dip/decisions.jsonl")); + } + + #[test] + fn ensure_provisioned_requires_a_manifest() { + // A bare `paper --session` account creates the directory without a manifest; a decision + // must not be written there, so the guard keys off the manifest, not the directory. + let base = tempfile::tempdir().unwrap(); + let name: SessionName = "btc-dip".parse().unwrap(); + + fs::create_dir_all(dir(base.path(), &name)).unwrap(); + assert!(ensure_provisioned(base.path(), &name).is_err()); + + fs::write(manifest_path(base.path(), &name), "{}").unwrap(); + assert!(ensure_provisioned(base.path(), &name).is_ok()); + } + + #[test] + fn version_compatibility_is_by_major() { + assert!(is_compatible("1.0")); + assert!(is_compatible("1.7")); // newer minor, same major + assert!(!is_compatible("2.0")); + assert!(!is_compatible("0.9")); + } +} diff --git a/crates/kraken-session/src/manifest.rs b/crates/kraken-session/src/manifest.rs new file mode 100644 index 0000000..c23b8dd --- /dev/null +++ b/crates/kraken-session/src/manifest.rs @@ -0,0 +1,458 @@ +//! Durable session metadata and lifecycle state. +//! +//! Additive fields and serde defaults preserve minor-version compatibility. + +use std::path::Path; + +use chrono::{DateTime, Utc}; +use kraken_recording::write_json_atomic; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::{MANIFEST_VERSION, Result, SessionError, is_compatible}; + +/// The durable description of a recorded session. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionManifest { + /// Manifest schema version; a differing MAJOR is incompatible (see [`super::is_compatible`]). + #[serde(default = "default_manifest_version")] + pub manifest_version: String, + /// CLI version that wrote the manifest. + pub cli_version: String, + /// The session name (its directory under `sessions/`). + pub id: String, + /// PID of the background recorder, so `stop` can signal it. + pub pid: u32, + /// The market recordings captured into this session — one per durable backend. + pub recordings: Vec, + /// The isolated paper account seeded for this session. + pub paper: PaperRef, + /// The strategy driving the session, if one was declared. + pub strategy: Option, + /// Opaque experiment label; session provisioning does not interpret it. + pub experiment: Option, + /// Present for replay; absence is the authoritative live/replay discriminator. + pub source: Option, + /// Lifecycle status; persisted intent only — the effective status is + /// derived from the recording lock at read time. + #[serde(default)] + pub status: SessionState, + /// End-of-run summary, present once the session is finalized. + pub summary: Option, + /// Instant the session was created. + pub created_at: DateTime, + /// Optional human handle (`--label`), resolvable like an ordinal. + pub label: Option, + /// The window this manifest cut into its journal. + pub window: SessionWindow, +} + +/// The window cut into the journal. Written at start, closed at stop; the +/// opening anchor is frozen here and never recomputed, so a score today and +/// a score next month anchor on the same number. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionWindow { + /// The window's own id — also the value its journal markers carry. + pub session: String, + pub started_at: DateTime, + /// The account's equity when the window opened, in the account currency. + #[serde(with = "rust_decimal::serde::str")] + pub opening_equity: Decimal, + /// False when the opening valuation could not price every position — + /// disclosed, never guessed. + pub opening_complete: bool, + /// `None` while the window is open (or crashed without a stop). + pub ended_at: Option>, +} + +/// Pointer to the market recording a session captured. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RecordingRef { + /// Storage backend the tape was written with. + pub backend: RecordingBackend, + /// Tape file name relative to the session directory. + pub file: String, + /// `record` schema version at write time ([`kraken_recording::schema::SCHEMA_VERSION`]). + pub schema_version: String, + /// Trading pairs captured (e.g. `["BTC/USD","ETH/USD"]`). + pub symbols: Vec, + /// Channels captured (e.g. `["trade","book","ohlc"]`). + pub channels: Vec, +} + +/// The store a recording was written with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordingBackend { + /// The embedded DuckDB store (`record-duckdb` feature). + Duckdb, + /// The dependency-free JSONL sink. + Jsonl, +} + +/// The starting shape of a session's isolated paper account. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaperRef { + /// Opening balance in [`currency`](PaperRef::currency). + pub starting_balance: Decimal, + /// Quote currency of the opening balance (e.g. `"USD"`). + pub currency: String, +} + +/// The strategy driving a session's decisions. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StrategyRef { + /// Strategy identifier (e.g. a recipe skill name). + pub name: String, + /// Opaque strategy parameters, passed through verbatim. + pub params: Option, +} + +/// Persisted lifecycle state; OS locks determine whether a recorder is actually live. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, strum::Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum SessionState { + /// The recorder is running (the manifest's initial state). + #[default] + Running, + /// The recorder no longer holds its locks — stopped, crashed, or killed. + Stopped, +} + +/// Replay provenance and the affine mapping between tape and session time. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionSource { + /// The tape ref in display form (`tape:` / `run:`). + pub tape: String, + /// Playback speed: tape seconds per session wall second. + pub speed: f64, + /// The first tape frame's instant — the affine anchor t₀. + pub anchor: DateTime, + /// The session wall instant the pump started — the affine anchor w₀. + pub started_at: DateTime, + /// sha256 of the source tape this session replayed — the session's own record of + /// the bytes the lab FSM checks against a sealed replay leg. `None` on a + /// manifest written before hashes were recorded. + pub content_hash: Option, +} + +impl SessionSource { + /// Map a tape instant into session wall time: `w₀ + (t − t₀)/speed`. + pub fn session_time(&self, tape: DateTime) -> DateTime { + let elapsed = (tape - self.anchor).num_microseconds().unwrap_or(i64::MAX); + // Manifests are editable; invalid speed and anchors must not panic readers. + let scaled = if self.speed > 0.0 { + (elapsed as f64 / self.speed) as i64 + } else { + 0 + }; + self.started_at + .checked_add_signed(chrono::Duration::microseconds(scaled)) + .unwrap_or(DateTime::::MAX_UTC) + } + + /// Map a session wall instant onto the tape: `t₀ + (w − w₀)·speed`. + pub fn tape_time(&self, session: DateTime) -> DateTime { + let elapsed = (session - self.started_at) + .num_microseconds() + .unwrap_or(i64::MAX); + let scaled = (elapsed as f64 * self.speed) as i64; + self.anchor + .checked_add_signed(chrono::Duration::microseconds(scaled)) + .unwrap_or(DateTime::::MAX_UTC) + } +} + +/// The end-of-run summary written when a session is finalized. +/// +/// Named `SessionOutcome` (not `Summary`) to avoid confusion with the unrelated +/// `CaptureSummary` in the binary's sink driver. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionOutcome { + /// Instant the session ended. + pub ended_at: DateTime, + /// Portfolio value at the close, in the paper account's currency. + pub final_value: Decimal, + /// Profit/loss against the starting balance. + pub pnl: Decimal, +} + +fn default_manifest_version() -> String { + MANIFEST_VERSION.to_string() +} + +impl SessionManifest { + /// A fresh manifest for a starting session: version and creation time + /// stamped; `Running` with no summary yet. `cli_version` is the writing + /// binary's version — a parameter, so the crate never learns where the + /// caller keeps it. + pub fn new( + id: String, + cli_version: impl Into, + pid: u32, + recordings: Vec, + paper: PaperRef, + window: SessionWindow, + ) -> Self { + Self { + manifest_version: MANIFEST_VERSION.to_string(), + cli_version: cli_version.into(), + id, + pid, + recordings, + paper, + strategy: None, + experiment: None, + source: None, + status: SessionState::Running, + summary: None, + created_at: Utc::now(), + label: None, + window, + } + } + + /// Attach the human handle (`--label`), resolvable like an ordinal. + pub fn with_label(mut self, label: Option) -> Self { + self.label = label; + self + } + + /// Label the manifest with the template that drove it, so + /// replay/explain-pnl/lab can attribute the session to a hypothesis — the + /// playground itself runs no strategy. + pub fn with_strategy(mut self, strategy: Option) -> Self { + self.strategy = strategy; + self + } + + /// Link the session to a lab experiment. The label is the durable run + /// link `discover_sessions` trusts over the session-name convention. + pub fn with_experiment(mut self, experiment: Option) -> Self { + self.experiment = experiment; + self + } + + /// Stamp replay provenance — this is what flips every reader onto the + /// tape-marked, REST-free path. + pub fn with_source(mut self, source: Option) -> Self { + self.source = source; + self + } + + /// Read and validate a manifest from `path`. A read failure is `Io`, an + /// undecodable manifest `Damaged`, an incompatible MAJOR + /// `IncompatibleManifest`; the guard is feature-free so `status`/`list` + /// work without the `duckdb` backend. + pub fn load(path: &Path) -> Result { + let data = std::fs::read_to_string(path)?; + let manifest: Self = + serde_json::from_str(&data).map_err(|e| SessionError::Damaged(e.to_string()))?; + if !is_compatible(&manifest.manifest_version) { + return Err(SessionError::IncompatibleManifest { + found: manifest.manifest_version, + expected: MANIFEST_VERSION.to_string(), + }); + } + Ok(manifest) + } + + /// Atomically write this manifest to `path`. + pub fn save(&self, path: &Path) -> Result<()> { + Ok(write_json_atomic(path, self)?) + } +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + fn sample() -> SessionManifest { + SessionManifest { + manifest_version: MANIFEST_VERSION.to_string(), + cli_version: "0.3.2".to_string(), + id: "btc-dip".to_string(), + pid: 4242, + recordings: vec![RecordingRef { + backend: RecordingBackend::Jsonl, + file: "BTC-USD.jsonl".to_string(), + schema_version: "2.0".to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trade".to_string(), "book".to_string()], + }], + paper: PaperRef { + starting_balance: dec!(10_000.0), + currency: "USD".to_string(), + }, + strategy: Some(StrategyRef { + name: "recipe-playground-dca".to_string(), + params: Some(serde_json::json!({ "interval_s": 60 })), + }), + experiment: Some("dca-robust-1".to_string()), + source: Some(SessionSource { + tape: "tape:jun-crash".to_string(), + speed: 10.0, + anchor: "2026-06-01T00:00:00Z".parse().unwrap(), + started_at: "2026-07-02T10:00:00Z".parse().unwrap(), + content_hash: Some("sha256:jun-crash".to_string()), + }), + status: SessionState::Stopped, + summary: Some(SessionOutcome { + ended_at: "2026-07-02T10:30:00Z".parse().unwrap(), + final_value: dec!(10_250.5), + pnl: dec!(250.5), + }), + created_at: "2026-07-02T10:00:00Z".parse().unwrap(), + label: Some("dca-robust-1-s1".to_string()), + window: SessionWindow { + session: "s1".to_string(), + started_at: "2026-07-02T10:00:00Z".parse().unwrap(), + opening_equity: dec!(10_000.0), + opening_complete: true, + ended_at: Some("2026-07-02T10:30:00Z".parse().unwrap()), + }, + } + } + + #[test] + fn manifest_round_trips_through_serde() { + let manifest = sample(); + let json = serde_json::to_string(&manifest).unwrap(); + let back: SessionManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(manifest, back); + } + + #[test] + fn manifest_serializes_with_the_pinned_1_0_keys() { + // Encode-side pin: a serialize-side key rename would pass the round-trip + // and decode-fixture tests unnoticed. + let json = serde_json::to_value(sample()).unwrap(); + let pinned = serde_json::json!({ + "manifest_version": "1.0", + "cli_version": "0.3.2", + "id": "btc-dip", + "pid": 4242, + "recordings": [{ + "backend": "jsonl", + "file": "BTC-USD.jsonl", + "schema_version": "2.0", + "symbols": ["BTC/USD"], + "channels": ["trade", "book"] + }], + "paper": { "starting_balance": 10000.0, "currency": "USD" }, + "strategy": { + "name": "recipe-playground-dca", + "params": { "interval_s": 60 } + }, + "experiment": "dca-robust-1", + "source": { + "tape": "tape:jun-crash", + "speed": 10.0, + "anchor": "2026-06-01T00:00:00Z", + "started_at": "2026-07-02T10:00:00Z", + "content_hash": "sha256:jun-crash" + }, + "status": "stopped", + "summary": { + "ended_at": "2026-07-02T10:30:00Z", + "final_value": 10250.5, + "pnl": 250.5 + }, + "created_at": "2026-07-02T10:00:00Z", + "label": "dca-robust-1-s1", + "window": { + "session": "s1", + "started_at": "2026-07-02T10:00:00Z", + "opening_equity": "10000.0", + "opening_complete": true, + "ended_at": "2026-07-02T10:30:00Z" + } + }); + assert_eq!(json, pinned); + } + + #[test] + fn unknown_fields_and_missing_optionals_use_defaults() { + // A lean manifest from a future build: an unknown key, and every optional + // (strategy/summary) plus the defaulted version/status omitted. + let json = r#"{ + "cli_version": "0.9.9", + "id": "s1", + "pid": 7, + "recordings": [{ + "backend": "duckdb", + "file": "BTC-USD.duckdb", + "schema_version": "2.0", + "symbols": ["BTC/USD"], + "channels": ["trade"] + }], + "paper": { "starting_balance": 500.0, "currency": "EUR" }, + "window": { + "session": "s1", + "started_at": "2026-07-02T10:00:00Z", + "opening_equity": "500.0", + "opening_complete": true, + "ended_at": null + }, + "created_at": "2026-07-02T10:00:00Z", + "future_field": {"added": "later"} + }"#; + let manifest: SessionManifest = serde_json::from_str(json).unwrap(); + assert_eq!(manifest.manifest_version, MANIFEST_VERSION); + assert_eq!(manifest.status, SessionState::Running); + assert!(manifest.strategy.is_none()); + assert!(manifest.experiment.is_none()); + assert!(manifest.source.is_none()); + assert!(manifest.summary.is_none()); + } + + #[test] + fn load_rejects_incompatible_major_version() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("manifest.json"); + + let mut manifest = sample(); + manifest.manifest_version = "2.0".to_string(); // future MAJOR + manifest.save(&path).unwrap(); + assert!(SessionManifest::load(&path).is_err()); + + manifest.manifest_version = "1.7".to_string(); // newer MINOR, same MAJOR + manifest.save(&path).unwrap(); + assert!(SessionManifest::load(&path).is_ok()); + } + + #[test] + fn affine_mapping_round_trips_between_tape_and_session_time() { + let source = SessionSource { + tape: "tape:jun-crash".to_string(), + speed: 10.0, + anchor: "2026-06-01T00:00:00Z".parse().unwrap(), + started_at: "2026-07-02T10:00:00Z".parse().unwrap(), + content_hash: None, + }; + // One tape minute at 10× lands six session seconds after w₀. + let tape: DateTime = "2026-06-01T00:01:00Z".parse().unwrap(); + let session = source.session_time(tape); + assert_eq!( + session, + "2026-07-02T10:00:06Z".parse::>().unwrap() + ); + assert_eq!(source.tape_time(session), tape); + } + + #[test] + fn save_then_load_round_trips_through_a_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/manifest.json"); // parent created by save + let manifest = sample(); + manifest.save(&path).unwrap(); + assert_eq!(SessionManifest::load(&path).unwrap(), manifest); + } +} diff --git a/crates/kraken-session/src/session.rs b/crates/kraken-session/src/session.rs new file mode 100644 index 0000000..86eee99 --- /dev/null +++ b/crates/kraken-session/src/session.rs @@ -0,0 +1,411 @@ +//! Recorded windows over a scope's shared account journal. +//! +//! This module owns the artifact and reader; allocation and reference policy +//! remain above the storage layer. + +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use kraken_paper::account::{AccountRecord, RECORD_VERSION}; +use kraken_paper::{AccountEvent, PaperState, VenueSnapshot, WindowAnchor}; +use kraken_recording::{JsonlSource, line_seq}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::manifest::{RecordingBackend, SessionManifest}; +use crate::timeline::SessionTimeline; +use crate::timeline::event::TimelineEvent; +use crate::{Result, SessionError}; + +/// The session contract file inside a session directory. +pub const SESSION_FILE: &str = "session.json"; + +/// The only journal commands allowed to carry a session stamp. +pub const START_MARKER: &str = "session start"; +pub const STOP_MARKER: &str = "session stop"; + +/// `/sessions/` — every session of the scope's journal. +pub fn sessions_root(scope: &Path) -> PathBuf { + scope.join("sessions") +} + +/// `/sessions/s/`. +pub fn session_dir(scope: &Path, ordinal: u32) -> PathBuf { + sessions_root(scope).join(format!("s{ordinal}")) +} + +/// `/sessions/s/session.json`. +pub fn session_file_path(scope: &Path, ordinal: u32) -> PathBuf { + session_dir(scope, ordinal).join(SESSION_FILE) +} + +/// `/sessions/s/decisions.jsonl` — the session's own rationale log, the +/// one [`read_window`] joins to fills. +pub fn session_decisions_path(scope: &Path, ordinal: u32) -> PathBuf { + session_dir(scope, ordinal).join("decisions.jsonl") +} + +/// The agent state cell inside a session directory — the loop's durable +/// cursor, distinct from the evidential decision log. +pub const STATE_FILE: &str = "state.json"; + +/// Cap on the serialized state document: it is a cursor, not a database. +/// Sized generously above any sane round/leg/zone bookkeeping. +pub const MAX_STATE_BYTES: usize = 64 * 1024; + +/// `/sessions/s/state.json`. +pub fn session_state_path(scope: &Path, ordinal: u32) -> PathBuf { + session_dir(scope, ordinal).join(STATE_FILE) +} + +/// Agent-owned loop state, intentionally excluded from evidence and seals. +/// +/// The cursor nests under its own key (never flattened): serde's +/// `deny_unknown_fields` — the typo guard this artifact depends on — does +/// not compose with `flatten`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionState { + /// Stamped by [`write_state`] on every replace. + pub updated_at: DateTime, + pub cursor: SessionCursor, +} + +/// Typed cursor fields that reject configuration typos. +#[skip_serializing_none] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionCursor { + /// The last COMPLETED round — a firing acts as `round + 1` and writes it + /// back once done (DCA and rebalance gates). + pub round: Option, + /// Pairs already executed in the round in flight — rebalance crash + /// recovery resumes at the first missing leg. + pub legs_done: Option>, + /// Price zone for crossing-based strategies (alert on the change, not + /// the level). + pub zone: Option, + /// Instant of the loop's last order — spacing gates (`min_spacing_s`). + pub last_action_at: Option>, + /// Free-text context for a human reading the artifact; never parsed. + pub note: Option, +} + +/// Where price sits relative to a strategy's band. +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum Zone { + Above, + Within, + Below, +} + +/// Reads an optional state cell, refusing damage instead of assuming round zero. +/// +/// No read lock: the cell is replaced atomically as a whole file, so a +/// concurrent read sees the old or the new document, never a torn one. +pub fn read_state(scope: &Path, ordinal: u32) -> Result> { + let path = session_state_path(scope, ordinal); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + let state: SessionState = serde_json::from_str(&raw).map_err(|err| { + SessionError::Damaged(format!( + "session state at {} is unreadable ({err}); fix or delete the file", + path.display() + )) + })?; + Ok(Some(state)) +} + +/// Replaces the state atomically; loop construction guarantees one writer. +pub fn write_state(scope: &Path, ordinal: u32, cursor: SessionCursor) -> Result { + let state = SessionState { + updated_at: Utc::now(), + cursor, + }; + let size = serde_json::to_vec(&state) + .map_err(|err| SessionError::Rejected(format!("session state is unserializable: {err}")))? + .len(); + if size > MAX_STATE_BYTES { + return Err(SessionError::Rejected(format!( + "session state is {size} bytes; the cap is {MAX_STATE_BYTES} — keep it a cursor, \ + not a database" + ))); + } + kraken_recording::write_json_atomic(&session_state_path(scope, ordinal), &state)?; + Ok(state) +} + +/// Reads known decisions, skipping forward-compatible kinds with a warning. +pub fn read_decisions(scope: &Path, ordinal: u32) -> Result> { + let path = session_decisions_path(scope, ordinal); + let _guard = crate::timeline::read_guard(&path, "session decision log")?; + let Some(source) = JsonlSource::::open(path.clone()) else { + return Ok(Vec::new()); + }; + let mut decisions = Vec::new(); + for (line, decision) in source.read_numbered()? { + if matches!(decision.kind, crate::decision::DecisionKind::Unknown) { + tracing::warn!(path = %path.display(), line, "skipping unknown decision kind"); + continue; + } + decisions.push(decision); + } + Ok(decisions) +} + +/// The session's market recording file name for one backend: `tape.`. +pub fn tape_file(backend: RecordingBackend) -> String { + match backend { + RecordingBackend::Duckdb => "tape.duckdb".to_string(), + RecordingBackend::Jsonl => "tape.jsonl".to_string(), + } +} + +/// Parse a strict session ordinal: `s`, digits only, no leading zeros, n ≥ 1. +/// Anything else — including `r0`, `r01`, `R3` — is not a session directory. +pub fn parse_ordinal(name: &str) -> Option { + let digits = name.strip_prefix('s')?; + if digits.is_empty() || digits.starts_with('0') || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// The three files a windowed read spans, resolved by the caller — the +/// reader takes paths, not layout policy, so it never learns what a +/// workspace is. +pub struct SessionTracks { + /// The scope's shared account journal. + pub journal: PathBuf, + /// The session's directory: `decisions.jsonl` and `tape.*` live here. + pub session_dir: PathBuf, + pub manifest: SessionManifest, +} + +/// Read one session's window of the shared journal as a [`SessionTimeline`], so +/// every timeline consumer (explain, score, replay) works on sessions unchanged. +/// +/// The strict journal prefix becomes one synthetic `Attached` epoch at the +/// frozen opening anchor. The window, decisions, and tape then share the +/// timeline's `(at, track, seq)` order. +pub fn read_window(tracks: SessionTracks) -> Result { + let SessionTracks { + journal, + session_dir, + manifest: run, + } = tracks; + + let mut events = Vec::new(); + let capture = crate::timeline::read_market_track_in(&session_dir, &run, &mut events)?; + + // Writer-order locks keep account events and their rationale in one snapshot. + let decisions = session_dir.join("decisions.jsonl"); + let _journal_guard = crate::timeline::read_guard(&journal, "paper account journal")?; + let _decisions_guard = crate::timeline::read_guard(&decisions, "session decision log")?; + crate::timeline::read_decisions_track(&decisions, &mut events)?; + let newer_records_skipped = read_windowed_account_track(&journal, &run, &mut events)?; + + events.sort_by_key(TimelineEvent::key); + Ok(SessionTimeline { + events, + capture, + manifest: run, + newer_records_skipped, + }) +} + +/// This session's own account-journal window — the synthesized anchor epoch +/// plus the journal slice `(start..stop]` — WITHOUT reading the market tape. +/// +/// [`read_window`] reads the tape too, which a *recording* session holds +/// locked; but the counts a live `session show` reports (trades, open orders) +/// are account-journal events, never tape frames. This is the tape-free read +/// that is therefore safe on open and stopped sessions alike. +pub fn read_account_window( + journal: &Path, + manifest: &SessionManifest, +) -> Result> { + let mut events = Vec::new(); + let _guard = crate::timeline::read_guard(journal, "paper account journal")?; + read_windowed_account_track(journal, manifest, &mut events)?; + events.sort_by_key(TimelineEvent::key); + Ok(events) +} + +/// Fold the prefix, synthesize the anchor epoch, slice the window. +fn read_windowed_account_track( + journal: &Path, + run: &SessionManifest, + events: &mut Vec, +) -> Result { + let session_id = run.window.session.as_str(); + let Some(source) = JsonlSource::::open(journal.to_path_buf()) else { + // No journal at all: the synthesized epoch alone is the window — + // an account that never traded before its first session. + events.push(synthesized_epoch(run, PaperState::default(), 0)); + return Ok(0); + }; + + let records: Vec<(usize, AccountRecord)> = source.read_numbered()?.collect(); + let start = records + .iter() + .position(|(_, record)| is_marker(record, START_MARKER, session_id)) + .ok_or_else(|| { + SessionError::Rejected(format!( + "session '{session_id}' has no start marker in the journal — the journal may have been \ + replaced since the session was recorded" + )) + })?; + let stop = records[start + 1..] + .iter() + .position(|(_, record)| is_marker(record, STOP_MARKER, session_id)) + .map(|offset| start + 1 + offset); + + // The opening state: everything up to AND including the start marker, + // folded strictly. A record from a newer build here would silently skew + // the opening balances, so it refuses like the account's own projection. + let mut opening = PaperState::default(); + for (line, record) in &records[..=start] { + if record.v > RECORD_VERSION { + return Err(SessionError::Rejected(format!( + "journal line {line} was written by a newer kraken (v{}); upgrade to read this \ + session's opening state", + record.v + ))); + } + opening.apply(record.ts, &record.event); + } + let marker_seq = line_seq(records[start].0); + events.push(synthesized_epoch(run, opening, marker_seq)); + + // The window slice, `(start..stop]`: tolerant like the whole-session + // read — a newer record is dropped and disclosed, and money consumers + // refuse on the count. + let end = stop.map_or(records.len(), |s| s + 1); + let mut newer_skipped = 0usize; + for (line, record) in &records[start + 1..end] { + if record.v > RECORD_VERSION { + tracing::warn!(journal = %journal.display(), line, v = record.v, "skipping newer-version journal record"); + newer_skipped += 1; + continue; + } + if matches!(record.event, AccountEvent::Unknown) { + tracing::warn!(journal = %journal.display(), line, "skipping unknown account event kind"); + continue; + } + events.push(TimelineEvent::account( + record.ts, + line_seq(*line), + record.clone(), + )); + } + Ok(newer_skipped) +} + +fn is_marker(record: &AccountRecord, name: &str, session_id: &str) -> bool { + matches!( + &record.event, + AccountEvent::Command(entry) + if entry.name == name && entry.session.as_deref() == Some(session_id) + ) +} + +/// The one in-memory epoch a window opens with: the folded opening balances +/// as an `Attached` snapshot carrying the frozen `session.json` anchor. Stamped +/// at `started_at` with the start marker's own seq, so it sorts before every +/// sliced event (their journal lines are all later) and never reaches disk. +fn synthesized_epoch(run: &SessionManifest, opening: PaperState, seq: i64) -> TimelineEvent { + let snapshot = VenueSnapshot { + balances: opening.balances.into_iter().collect(), + complete: run.window.opening_complete, + anchor: Some(WindowAnchor { + equity: run.window.opening_equity, + currency: run.paper.currency.clone(), + fee_rate: opening.fee_rate, + slippage_rate: opening.slippage_rate, + }), + }; + TimelineEvent::account( + run.window.started_at, + seq, + AccountRecord { + v: RECORD_VERSION, + ts: run.window.started_at, + origin: kraken_paper::account::Origin::Cli, + event: AccountEvent::Attached(snapshot), + }, + ) +} + +#[cfg(test)] +mod state_tests { + use super::*; + + #[test] + fn state_round_trips_typed_and_omits_unset_fields_on_the_wire() { + let dir = tempfile::tempdir().expect("tempdir"); + let cursor = SessionCursor { + round: Some(3), + zone: Some(Zone::Above), + legs_done: Some(vec!["BTC/USD".to_string()]), + ..SessionCursor::default() + }; + let written = write_state(dir.path(), 1, cursor.clone()).expect("write"); + let read = read_state(dir.path(), 1).expect("read").expect("present"); + assert_eq!(read, written); + assert_eq!(read.cursor, cursor); + + // Unset cases stay off the wire, so the artifact reads as exactly + // what the loop tracks. + let raw = + std::fs::read_to_string(session_state_path(dir.path(), 1)).expect("state on disk"); + assert!(raw.contains("\"zone\": \"above\""), "{raw}"); + assert!(!raw.contains("last_action_at"), "{raw}"); + } + + #[test] + fn unset_state_reads_none_and_damaged_state_refuses_loud() { + let dir = tempfile::tempdir().expect("tempdir"); + assert!(read_state(dir.path(), 1).expect("read").is_none()); + + std::fs::create_dir_all(session_dir(dir.path(), 1)).expect("mkdir"); + std::fs::write(session_state_path(dir.path(), 1), "{ not json").expect("write"); + let err = read_state(dir.path(), 1).expect_err("must refuse"); + assert!( + matches!(err, SessionError::Damaged(_)), + "a corrupt cursor must never read as round zero: {err}" + ); + } + + #[test] + fn unknown_cursor_keys_refuse_instead_of_steering_silently() { + // A typo'd case ("rond") must fail the read, not vanish: this file is + // agent-authored configuration, not tolerated ingest. + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(session_dir(dir.path(), 1)).expect("mkdir"); + std::fs::write( + session_state_path(dir.path(), 1), + r#"{"updated_at":"2026-01-01T00:00:00Z","cursor":{"rond":3}}"#, + ) + .expect("write"); + let err = read_state(dir.path(), 1).expect_err("must refuse"); + assert!(err.to_string().contains("rond"), "{err}"); + } + + #[test] + fn oversized_state_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let cursor = SessionCursor { + note: Some("x".repeat(MAX_STATE_BYTES)), + ..SessionCursor::default() + }; + let err = write_state(dir.path(), 1, cursor).expect_err("oversized"); + assert!(err.to_string().contains("cursor, not a database"), "{err}"); + } +} diff --git a/crates/kraken-session/src/timeline.rs b/crates/kraken-session/src/timeline.rs new file mode 100644 index 0000000..6115881 --- /dev/null +++ b/crates/kraken-session/src/timeline.rs @@ -0,0 +1,988 @@ +//! Chronological composition of market, account, and decision tracks. +//! +//! Shared locks reject live writers, while paired account and decision reads +//! share one cut so a trade cannot appear without its rationale. + +use std::path::Path; + +use kraken_paper::AccountEvent; +use kraken_paper::account::{AccountRecord, RECORD_VERSION}; +use kraken_recording::FileLock; +use kraken_recording::JsonlSource; +use kraken_recording::RecordingManifest; +use kraken_recording::TapeSource; +use kraken_recording::{CaptureSource, line_seq}; + +use self::event::TimelineEvent; +use crate::decision::{Decision, DecisionKind}; +use crate::manifest::{RecordingBackend, RecordingRef, SessionManifest}; +use crate::{ + Result, SessionError, SessionName, decisions_path, dir, ensure_provisioned, events_path, +}; + +pub mod event; + +/// A session read whole: every recorded event in `(at, track, seq)` order, +/// the market capture's self-description, and the manifest that named the +/// tracks (read once here — consumers must not reload it). +#[derive(Debug)] +pub struct SessionTimeline { + pub events: Vec, + /// `None` when the session has no market recording. A report whose + /// `summary` is `None` is a capture that never finalized (a crashed + /// recorder) — completeness is then unknown, and consumers must say so + /// rather than replay holes silently. Interrogate via + /// [`CaptureState::of`](kraken_recording::CaptureState::of). + pub capture: Option, + pub manifest: SessionManifest, + /// Account journal records from a newer build, absent from `events`. + /// Unknown event kinds are dropped too but not counted — the engine's + /// strict fold ignores those, so their absence cannot skew a fold; a + /// whole version it cannot read can. Display consumers may tolerate a + /// nonzero count; consumers computing money from the account track + /// must refuse. + pub newer_records_skipped: usize, +} + +/// Read a provisioned session's full timeline. `validation` when the session +/// doesn't exist, a writer still holds one of its logs, or its market +/// recording needs a backend this build lacks. +pub fn read(base: &Path, name: &SessionName) -> Result { + ensure_provisioned(base, name)?; + let manifest = SessionManifest::load(&crate::manifest_path(base, name))?; + + let mut events = Vec::new(); + let capture = read_market_track_in(&dir(base, name), &manifest, &mut events)?; + // Writer-order locks keep an account event and its decision in one snapshot. + // Missing files are locked too, preventing first-write races. + let journal = events_path(base, name); + let decisions = decisions_path(base, name); + let _journal_guard = read_guard(&journal, "paper account journal")?; + let _decisions_guard = read_guard(&decisions, "session decision log")?; + read_decisions_track(&decisions, &mut events)?; + let newer_records_skipped = read_account_track(&journal, &mut events)?; + events.sort_by_key(TimelineEvent::key); + Ok(SessionTimeline { + events, + capture, + manifest, + newer_records_skipped, + }) +} + +/// Reads one durable backend, preferring DuckDB and refusing damaged primary data. +pub(crate) fn read_market_track_in( + recording_dir: &Path, + manifest: &SessionManifest, + events: &mut Vec, +) -> Result> { + let by_backend = |backend: RecordingBackend| -> Option<&RecordingRef> { + manifest.recordings.iter().find(|r| r.backend == backend) + }; + + #[cfg(feature = "duckdb")] + if let Some(recording) = by_backend(RecordingBackend::Duckdb) { + ensure_readable_schema(recording)?; + let source = kraken_recording::DuckdbSource::open(&recording_dir.join(&recording.file))?; + return collect_market(source, events); + } + + if let Some(recording) = by_backend(RecordingBackend::Jsonl) { + ensure_readable_schema(recording)?; + let source = TapeSource::open(&recording_dir.join(&recording.file))?; + return collect_market(source, events); + } + + #[cfg(not(feature = "duckdb"))] + if by_backend(RecordingBackend::Duckdb).is_some() { + return Err(SessionError::Rejected( + "this session's market recording is DuckDB-only and this build has no DuckDB \ + support; rebuild with `--features record-duckdb`" + .into(), + )); + } + + Ok(None) +} + +/// Gate on the manifest's version stamp — present even if the recording never +/// finalized — so an old-MAJOR session is rejected, not misread as damage. +fn ensure_readable_schema(recording: &RecordingRef) -> Result<()> { + if kraken_recording::schema::is_compatible(&recording.schema_version) { + return Ok(()); + } + Err(SessionError::Rejected(format!( + "session recording '{}' has schema version {}, which this build cannot read \ + (reads v{}); re-record the session", + recording.file, + recording.schema_version, + kraken_recording::schema::SCHEMA_VERSION + ))) +} + +/// Reads the version stamp before decoding frames under its contract. +fn collect_market( + source: impl CaptureSource, + events: &mut Vec, +) -> Result> { + let capture = source.capture()?; + events.extend(source.read()?.map(TimelineEvent::from)); + Ok(Some(capture)) +} + +/// Caller contract: `read` holds this track's [`read_guard`] — acquired +/// there, not here, so the paired account/decision tracks share one cut. +pub(crate) fn read_decisions_track(path: &Path, events: &mut Vec) -> Result<()> { + let Some(source) = JsonlSource::::open(path.to_path_buf()) else { + return Ok(()); + }; + for (line, decision) in source.read_numbered()? { + if matches!(decision.kind, DecisionKind::Unknown) { + tracing::warn!(path = %path.display(), line, "skipping unknown decision kind"); + continue; + } + events.push(TimelineEvent::decision( + decision.timestamp, + line_seq(line), + decision, + )); + } + Ok(()) +} + +/// Caller contract: `read` holds this track's [`read_guard`] — acquired +/// there, not here, so the paired account/decision tracks share one cut. +/// Returns how many newer-version records were dropped, for +/// [`SessionTimeline::newer_records_skipped`]. +fn read_account_track(path: &Path, events: &mut Vec) -> Result { + let Some(source) = JsonlSource::::open(path.to_path_buf()) else { + return Ok(0); + }; + let mut newer_skipped = 0usize; + for (line, record) in source.read_numbered()? { + // Display readers may skip future records; money readers inspect the count and refuse. + if record.v > RECORD_VERSION { + tracing::warn!(path = %path.display(), line, v = record.v, "skipping newer-version journal record"); + newer_skipped += 1; + continue; + } + if matches!(record.event, AccountEvent::Unknown) { + tracing::warn!(path = %path.display(), line, "skipping unknown account event kind"); + continue; + } + events.push(TimelineEvent::account(record.ts, line_seq(line), record)); + } + Ok(newer_skipped) +} + +/// Held shared for the whole read: a live writer's torn-tail repair must not +/// be observed mid-splice, and shared holders coexist without registering on +/// the exclusive-only recorder-liveness probe. Taken even when the tracked +/// file doesn't exist yet — a writer creating it mid-read would tear the +/// cross-track cut. +pub(crate) fn read_guard(path: &Path, purpose: &str) -> Result { + Ok(FileLock::acquire_shared(path, purpose)?) +} + +/// Shared fixture builders for timeline tests and downstream consumers' tests +/// (`kraken replay`, `kraken explain pnl`, the Lab): provision a session +/// under a temp base and write each track exactly as the real writers do. +/// Crossing crates only via the `fixtures` cargo feature, never bare +/// `cfg(test)`. +#[cfg(any(test, feature = "fixtures"))] +// Fixture misuse is a test bug, so unwrap/expect/panic are the right tool — +// but `--all-features` compiles this module outside cfg(test), where +// clippy.toml's allow-*-in-tests carve-outs do not apply. (`#[expect]` would +// be unfulfilled in cfg(test) compilations and trip `-D warnings`.) +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +pub mod fixtures { + use std::collections::HashMap; + use std::path::Path; + + use kraken_core::OrderSide; + use kraken_paper::account::{AccountRecord, Origin, RECORD_VERSION}; + use kraken_paper::{AccountEvent, PaperConfig, PaperOrder, PaperState, PaperTrade}; + use kraken_recording::JsonlSink; + use kraken_recording::RecordingDeclaration; + use kraken_recording::Sink; + use kraken_recording::TapeSink; + use kraken_recording::parse_instant; + use kraken_recording::schema; + use kraken_recording::{CaptureSink, MarketEvent, RecordingIntegrity, RecordingManifest}; + use rust_decimal::Decimal; + use rust_decimal_macros::dec; + + use super::SessionTimeline; + use super::event::TimelineEvent; + use crate::decision::{Decision, DecisionKind}; + use crate::manifest::{ + PaperRef, RecordingBackend, RecordingRef, SessionManifest, SessionState, + }; + use crate::{SessionName, decisions_path, dir, events_path}; + + pub const NAME: &str = "btc-dip"; + + pub const T0: &str = "2026-01-01T00:00:00Z"; + + /// A journal-accurate in-memory session builder: a scratch [`PaperState`] + /// mirrors every event so `decide_*` produces exactly what the engine + /// would, while the events vec is what a composer actually consumes. + pub struct SessionBuilder { + pub events: Vec, + pub state: PaperState, + seq: i64, + } + + impl SessionBuilder { + pub fn with_rates(balance: Decimal, fee_rate: Decimal, slippage_rate: Decimal) -> Self { + let mut builder = Self { + events: Vec::new(), + state: PaperState::default(), + seq: 0, + }; + builder.account( + T0, + Origin::Cli, + AccountEvent::Initialized(PaperConfig { + balance, + currency: "USD".to_string(), + fee_rate, + slippage_rate, + }), + ); + builder + } + + fn next_seq(&mut self) -> i64 { + self.seq += 1; + self.seq + } + + pub fn account(&mut self, ts: &str, origin: Origin, event: AccountEvent) { + let at = parse_instant(ts).unwrap(); + self.state.apply(at, &event); + let seq = self.next_seq(); + self.events.push(TimelineEvent::account( + at, + seq, + AccountRecord { + v: RECORD_VERSION, + ts: at, + origin, + event, + }, + )); + } + + pub fn market_fill( + &mut self, + ts: &str, + origin: Origin, + side: OrderSide, + pair: &str, + volume: Decimal, + (ask, bid): (Decimal, Decimal), + ) -> PaperTrade { + let mut trade = self + .state + .decide_market_order(side, pair, volume, ask, bid) + .unwrap(); + trade.filled_at = parse_instant(ts).unwrap(); + self.account( + ts, + origin, + AccountEvent::OrderFilled { + trade: trade.clone(), + }, + ); + trade + } + + pub fn submit_limit( + &mut self, + ts: &str, + origin: Origin, + side: OrderSide, + pair: &str, + volume: Decimal, + price: Decimal, + ) -> PaperOrder { + let mut order = self + .state + .decide_limit_order(side, pair, volume, price) + .unwrap(); + order.created_at = parse_instant(ts).unwrap(); + self.account( + ts, + origin, + AccountEvent::OrderSubmitted { + order: order.clone(), + }, + ); + order + } + + pub fn fill_pending( + &mut self, + ts: &str, + origin: Origin, + pair: &str, + ask: Decimal, + bid: Decimal, + ) { + let prices = HashMap::from([(pair.to_string(), (ask, bid))]); + let fills = self.state.decide_pending_fills(&prices); + for mut trade in fills { + trade.filled_at = parse_instant(ts).unwrap(); + self.account(ts, origin, AccountEvent::OrderFilled { trade }); + } + } + + pub fn cancel(&mut self, ts: &str, origin: Origin, order_id: &str) { + let order = self.state.decide_cancel(order_id).unwrap(); + self.account(ts, origin, AccountEvent::OrderCancelled { order }); + } + + pub fn ticker(&mut self, ts: &str, symbol: &str, bid: Decimal, ask: Decimal) { + let frame = kraken_core::ChannelMessage::parse(&format!( + r#"{{"channel":"ticker","type":"update","data":[{{"symbol":"{symbol}", + "bid":{bid},"bid_qty":1.0,"ask":{ask},"ask_qty":1.0,"last":{bid}, + "volume":10.0,"vwap":{bid},"low":{bid},"high":{ask},"change":0.0, + "change_pct":0.0,"timestamp":"{ts}"}}]}}"# + )) + .unwrap(); + let seq = self.next_seq(); + self.events.push(TimelineEvent::from(MarketEvent { + at: parse_instant(ts).unwrap(), + seq, + frame, + })); + } + + pub fn decision(&mut self, ts: &str, order_id: &str, reason: &str) { + let seq = self.next_seq(); + self.events.push(TimelineEvent::decision( + parse_instant(ts).unwrap(), + seq, + Decision { + timestamp: parse_instant(ts).unwrap(), + kind: DecisionKind::Buy, + symbol: Some("BTCUSD".to_string()), + reason: reason.to_string(), + order_id: Some(order_id.to_string()), + }, + )); + } + + pub fn session( + mut self, + capture: Option, + manifest: SessionManifest, + ) -> SessionTimeline { + // `timeline::read` delivers events sorted by the merge key; the + // builder honors the same contract so same-instant frame/account + // pairs order Market-first here too. + self.events.sort_by_key(TimelineEvent::key); + SessionTimeline { + events: self.events, + capture, + manifest, + newer_records_skipped: 0, + } + } + } + + pub fn session_name() -> SessionName { + NAME.parse().expect("valid test session name") + } + + pub fn manifest(recordings: Vec) -> SessionManifest { + SessionManifest { + manifest_version: crate::MANIFEST_VERSION.to_string(), + cli_version: "test".to_string(), + id: NAME.to_string(), + pid: 1, + recordings, + paper: PaperRef { + starting_balance: dec!(10_000.0), + currency: "USD".to_string(), + }, + strategy: None, + experiment: None, + source: None, + status: SessionState::Stopped, + summary: None, + created_at: "2026-01-01T00:00:00Z".parse().unwrap(), + label: None, + window: crate::manifest::SessionWindow { + session: "r1".to_string(), + started_at: "2026-01-01T00:00:00Z".parse().unwrap(), + opening_equity: dec!(10_000.0), + opening_complete: true, + ended_at: None, + }, + } + } + + pub fn jsonl_recording() -> RecordingRef { + RecordingRef { + backend: RecordingBackend::Jsonl, + file: "market.jsonl".to_string(), + schema_version: schema::SCHEMA_VERSION.to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trade".to_string()], + } + } + + pub fn provision(base: &Path, recordings: Vec) -> SessionName { + let name = session_name(); + manifest(recordings) + .save(&crate::manifest_path(base, &name)) + .unwrap(); + name + } + + pub fn write_market(base: &Path, name: &SessionName, timestamps: &[&str]) { + write_frames_at(&dir(base, name).join("market.jsonl"), timestamps); + } + + /// A session's tape: the same frames as [`write_market`], at + /// `/tape.jsonl`. + pub fn write_run_tape(session_dir: &Path, timestamps: &[&str]) { + write_frames_at(&session_dir.join("tape.jsonl"), timestamps); + } + + fn write_frames_at(path: &Path, timestamps: &[&str]) { + let mut sink = TapeSink::open( + path, + &RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into()], + channels: vec!["trade".into()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + }, + ) + .unwrap(); + let frames: Vec<_> = timestamps + .iter() + .enumerate() + .map(|(i, ts)| { + kraken_core::ChannelMessage::parse(&format!( + r#"{{"channel":"trade","type":"update","data":[{{"symbol":"BTC/USD", + "side":"buy","price":1.0,"qty":1.0,"ord_type":"market","trade_id":{i}, + "timestamp":"{ts}"}}]}}"# + )) + .unwrap() + }) + .collect(); + sink.record(&frames).unwrap(); + sink.finalize(RecordingIntegrity { + window_end: "2026-01-01T01:00:00Z".parse().unwrap(), + ..RecordingIntegrity::now() + }) + .unwrap(); + } + + pub fn write_decision(base: &Path, name: &SessionName, ts: &str) { + let mut sink: JsonlSink = + JsonlSink::create(decisions_path(base, name), "test decision log").unwrap(); + sink.record(&[Decision { + timestamp: ts.parse().unwrap(), + kind: DecisionKind::Buy, + symbol: Some("BTCUSD".to_string()), + reason: "dip".to_string(), + order_id: Some("O1".to_string()), + }]) + .unwrap(); + } + + pub fn account_record(ts: &str) -> AccountRecord { + AccountRecord { + v: RECORD_VERSION, + ts: ts.parse().unwrap(), + origin: Origin::Cli, + event: AccountEvent::Initialized(PaperConfig { + balance: dec!(10_000.0), + currency: "USD".to_string(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.0), + }), + } + } + + pub fn write_account(base: &Path, name: &SessionName, records: &[AccountRecord]) { + let mut sink: JsonlSink = + JsonlSink::create(events_path(base, name), "test journal").unwrap(); + sink.record(records).unwrap(); + } + + /// A journal-shaped legacy fill (`reference_quote: None`): buy 1 BTC at + /// 1.0 — the price [`write_market`]'s trade frames mark at. + pub fn fill_record(ts: &str) -> AccountRecord { + AccountRecord { + v: RECORD_VERSION, + ts: ts.parse().unwrap(), + origin: Origin::Cli, + event: AccountEvent::OrderFilled { + trade: PaperTrade { + id: "PAPER-00001".to_string(), + order_id: "PAPER-00001".to_string(), + pair: "BTCUSD".to_string(), + base: "BTC".to_string(), + quote: "USD".to_string(), + side: OrderSide::Buy, + volume: dec!(1.0), + price: dec!(1.0), + fee: dec!(0.0026), + cost: dec!(1.0), + filled_at: ts.parse().unwrap(), + reference_quote: None, + }, + }, + } + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "duckdb")] + use kraken_recording::{CaptureSink, RecordingDeclaration, RecordingIntegrity, Sink}; + use kraken_recording::{Source, schema}; + + #[cfg(feature = "duckdb")] + use super::event::EventPayload; + use super::event::Track; + use super::fixtures::*; + use super::*; + + #[test] + fn merges_three_tracks_chronologically_with_track_tiebreak() { + // The tied instant is written in two offset styles (`Z` vs `+00:00`), + // so the track tiebreak must hold across timestamp formats. + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![jsonl_recording()]); + write_market( + base.path(), + &name, + &["2026-01-01T00:00:01.000000Z", "2026-01-01T00:00:03.000000Z"], + ); + write_account( + base.path(), + &name, + &[account_record("2026-01-01T00:00:02+00:00")], + ); + write_decision(base.path(), &name, "2026-01-01T00:00:03+00:00"); + + let timeline = read(base.path(), &name).unwrap(); + let tracks: Vec = timeline.events.iter().map(TimelineEvent::track).collect(); + assert_eq!( + tracks, + [ + Track::Market, + Track::Account, + Track::Market, + Track::Decision + ] + ); + assert!( + timeline.events.windows(2).all(|w| w[0].key() <= w[1].key()), + "events are sorted by the merge key" + ); + let capture = timeline.capture.expect("session has a market recording"); + let summary = capture.summary.expect("finalized capture"); + assert_eq!( + summary.window_end, + "2026-01-01T01:00:00Z" + .parse::>() + .unwrap() + ); + } + + #[test] + fn market_only_session_reads_without_other_tracks() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![jsonl_recording()]); + write_market(base.path(), &name, &["2026-01-01T00:00:01.000000Z"]); + + let timeline = read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 1); + assert_eq!(timeline.events[0].track(), Track::Market); + } + + #[test] + fn session_without_recordings_still_merges_account_and_decisions() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + write_account( + base.path(), + &name, + &[account_record("2026-01-01T00:00:01+00:00")], + ); + write_decision(base.path(), &name, "2026-01-01T00:00:02+00:00"); + + let timeline = read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 2); + assert!(timeline.capture.is_none(), "no market capture to report"); + } + + #[test] + fn unprovisioned_session_is_a_validation_error() { + let base = tempfile::tempdir().unwrap(); + let err = read(base.path(), &session_name()).unwrap_err(); + assert!(matches!(err, SessionError::Rejected(_))); + } + + #[test] + fn live_journal_writer_is_rejected() { + // Like the real writer (`Log::create`), the holder touches the + // file eagerly. + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + let path = events_path(base.path(), &name); + std::fs::write(&path, "").unwrap(); + let _held = kraken_recording::FileLock::acquire(&path, "test holder").unwrap(); + + let err = read(base.path(), &name).unwrap_err(); + assert!(matches!( + err, + SessionError::Recording(kraken_recording::Error::Rejected(_)) + )); + assert!(err.to_string().contains("in use"), "got: {err}"); + } + + #[test] + fn journal_guard_is_taken_before_the_decision_log_is_read() { + // The paired tracks are read under one cut, journal-first (the + // writer's lock order). With a writer holding the journal, the read + // must be rejected before decoding decisions — the damaged decision + // log proves the ordering by *not* surfacing as a parse error. + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + std::fs::write( + decisions_path(base.path(), &name), + "{\"timestamp\":\"not-a-timestamp\",\"kind\":\"skip\",\"reason\":\"x\"}\n", + ) + .unwrap(); + let journal = events_path(base.path(), &name); + std::fs::write(&journal, "").unwrap(); + let _writer = kraken_recording::FileLock::acquire(&journal, "test writer").unwrap(); + + let err = read(base.path(), &name).unwrap_err(); + assert!( + matches!( + err, + SessionError::Recording(kraken_recording::Error::Rejected(_)) + ), + "got: {err}" + ); + assert!(err.to_string().contains("in use"), "got: {err}"); + } + + #[test] + fn unknown_account_event_is_skipped_not_fatal() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + write_account( + base.path(), + &name, + &[account_record("2026-01-01T00:00:01+00:00")], + ); + // The strict reader proves the line still parses (as Unknown): we are + // testing the timeline's *skip*, not a parse failure. + let path = events_path(base.path(), &name); + let raw = format!( + "{}\n", + r#"{"v":1,"ts":"2026-01-01T00:00:02+00:00","origin":"cli","event":"margin_called"}"# + ); + std::fs::write( + &path, + [std::fs::read_to_string(&path).unwrap(), raw].concat(), + ) + .unwrap(); + let parsed: Vec = JsonlSource::open(path).unwrap().read().unwrap().collect(); + assert!(matches!(parsed[1].event, AccountEvent::Unknown)); + + let timeline = read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 1, "unknown event skipped"); + assert_eq!( + timeline.newer_records_skipped, 0, + "an unknown kind is benign (the strict fold ignores it too), not a version drop" + ); + } + + #[test] + fn newer_version_journal_record_is_skipped_not_fatal() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + write_account( + base.path(), + &name, + &[account_record("2026-01-01T00:00:01+00:00")], + ); + let path = events_path(base.path(), &name); + let raw = format!( + "{}\n", + r#"{"v":99,"ts":"2026-01-01T00:00:02+00:00","origin":"cli","event":"initialized","balance":"1.0","currency":"USD","fee_rate":"0.0","slippage_rate":"0.0"}"# + ); + std::fs::write( + &path, + [std::fs::read_to_string(&path).unwrap(), raw].concat(), + ) + .unwrap(); + + let timeline = read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 1, "newer-version record skipped"); + assert_eq!( + timeline.newer_records_skipped, 1, + "the drop is disclosed so money consumers can refuse" + ); + } + + #[test] + fn manifest_referencing_a_missing_recording_is_a_validation_error() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![jsonl_recording()]); // no market.jsonl on disk + + let err = read(base.path(), &name).unwrap_err(); + assert!(matches!( + err, + SessionError::Recording(kraken_recording::Error::Rejected(_)) + )); + assert!(err.to_string().contains("not found"), "got: {err}"); + } + + #[test] + fn different_major_recording_is_rejected_via_the_manifest_stamp() { + let base = tempfile::tempdir().unwrap(); + let name = provision( + base.path(), + vec![RecordingRef { + schema_version: "2.0".to_string(), + ..jsonl_recording() + }], + ); + std::fs::write(dir(base.path(), &name).join("market.jsonl"), "").unwrap(); + + let err = read(base.path(), &name).unwrap_err(); + assert!(matches!(err, SessionError::Rejected(_))); + assert!(err.to_string().contains("re-record"), "got: {err}"); + } + + #[test] + fn unknown_decision_kind_is_skipped_not_fatal() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + write_decision(base.path(), &name, "2026-01-01T00:00:01+00:00"); + let path = decisions_path(base.path(), &name); + let raw = format!( + "{}\n", + r#"{"timestamp":"2026-01-01T00:00:02+00:00","kind":"hold","reason":"wait"}"# + ); + std::fs::write( + &path, + [std::fs::read_to_string(&path).unwrap(), raw].concat(), + ) + .unwrap(); + // The strict reader proves the line still parses (as Unknown): we are + // testing the timeline's *skip*, not a parse failure. + let parsed: Vec = JsonlSource::open(path).unwrap().read().unwrap().collect(); + assert_eq!(parsed[1].kind, DecisionKind::Unknown); + + let timeline = read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 1, "unknown kind skipped"); + } + + #[test] + fn decision_with_unparseable_timestamp_is_damage() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + std::fs::write( + decisions_path(base.path(), &name), + "{\"timestamp\":\"not-a-timestamp\",\"kind\":\"skip\",\"reason\":\"x\"}\n", + ) + .unwrap(); + + let err = read(base.path(), &name).unwrap_err(); + assert!(matches!( + err, + SessionError::Recording(kraken_recording::Error::Damaged(_)) + )); + // Typed `DateTime` fields reject a bad stamp at decode, so the line + // surfaces as malformed rather than reaching the reader. + assert!(err.to_string().contains("malformed log line"), "got: {err}"); + } + + #[test] + fn account_seq_counts_physical_lines_across_blanks() { + // A hand-edited journal with a blank line: `seq` must keep naming + // the on-disk line, in agreement with the market track's `line_seq` + // and the jsonl reader's damage diagnostics. + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + write_account( + base.path(), + &name, + &[account_record("2026-01-01T00:00:01+00:00")], + ); + let path = events_path(base.path(), &name); + let raw = format!( + "\n{}\n", + serde_json::to_string(&account_record("2026-01-01T00:00:02+00:00")).unwrap() + ); + std::fs::write( + &path, + [std::fs::read_to_string(&path).unwrap(), raw].concat(), + ) + .unwrap(); + + let timeline = read(base.path(), &name).unwrap(); + let seqs: Vec = timeline.events.iter().map(|event| event.seq).collect(); + assert_eq!(seqs, [1, 3], "the blank line is counted, not renumbered"); + } + + #[test] + fn decision_damage_names_the_physical_line_across_blanks() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + std::fs::write( + decisions_path(base.path(), &name), + "\n{\"timestamp\":\"not-a-timestamp\",\"kind\":\"skip\",\"reason\":\"x\"}\n", + ) + .unwrap(); + + let err = read(base.path(), &name).unwrap_err(); + assert!(matches!( + err, + SessionError::Recording(kraken_recording::Error::Damaged(_)) + )); + assert!(err.to_string().contains("line 2"), "got: {err}"); + } + + #[test] + fn account_record_with_unparseable_timestamp_is_damage() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + std::fs::write( + events_path(base.path(), &name), + r#"{"v":1,"ts":"not-a-timestamp","origin":"cli","event":"initialized","balance":"1.0","currency":"USD","fee_rate":"0.0","slippage_rate":"0.0"}"#.to_string() + "\n", + ) + .unwrap(); + + let err = read(base.path(), &name).unwrap_err(); + assert!(matches!( + err, + SessionError::Recording(kraken_recording::Error::Damaged(_)) + )); + // Typed `DateTime` fields reject a bad stamp at decode, so the line + // surfaces as malformed rather than reaching the reader. + assert!(err.to_string().contains("malformed log line"), "got: {err}"); + } + + #[test] + fn concurrent_reader_does_not_block_the_timeline_read() { + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![jsonl_recording()]); + write_market(base.path(), &name, &["2026-01-01T00:00:01.000000Z"]); + let market = dir(base.path(), &name).join("market.jsonl"); + let _other_reader = + kraken_recording::FileLock::acquire_shared(&market, "another reader").unwrap(); + + let timeline = read(base.path(), &name).expect("shared holders coexist"); + assert_eq!(timeline.events.len(), 1); + } + + #[test] + fn a_sessions_first_note_is_rejected_while_the_read_guard_is_held() { + // The decision log doesn't exist yet, but the guard must still cover + // its track: a first `playground note` landing between the decision + // and account reads would tear the cross-track cut. + let base = tempfile::tempdir().unwrap(); + let name = provision(base.path(), vec![]); + let decisions = decisions_path(base.path(), &name); + let _cut = read_guard(&decisions, "session decision log").unwrap(); + + let err = crate::decision::append( + &decisions, + DecisionKind::Buy, + None, + "mid-read note".to_string(), + None, + ) + .unwrap_err(); + assert!(matches!( + err, + SessionError::Recording(kraken_recording::Error::Rejected(_)) + )); + assert!(err.to_string().contains("in use"), "got: {err}"); + } + + #[cfg(not(feature = "duckdb"))] + #[test] + fn duckdb_only_session_without_the_feature_is_a_validation_error() { + let base = tempfile::tempdir().unwrap(); + let name = provision( + base.path(), + vec![RecordingRef { + backend: RecordingBackend::Duckdb, + file: "market.duckdb".to_string(), + schema_version: schema::SCHEMA_VERSION.to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trade".to_string()], + }], + ); + + let err = read(base.path(), &name).unwrap_err(); + assert!(matches!(err, SessionError::Rejected(_))); + assert!(err.to_string().contains("record-duckdb"), "got: {err}"); + } + + #[cfg(feature = "duckdb")] + #[test] + fn dual_backend_session_prefers_duckdb_and_never_duplicates() { + use kraken_recording::DuckdbSink; + + let base = tempfile::tempdir().unwrap(); + let name = provision( + base.path(), + vec![ + RecordingRef { + backend: RecordingBackend::Duckdb, + file: "market.duckdb".to_string(), + schema_version: schema::SCHEMA_VERSION.to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trade".to_string()], + }, + jsonl_recording(), + ], + ); + write_market(base.path(), &name, &["2026-01-01T00:00:01.000000Z"]); + let frame = kraken_core::ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":0, + "timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + ) + .unwrap(); + let duck_path = dir(base.path(), &name).join("market.duckdb"); + let mut duck = DuckdbSink::open( + &duck_path, + &RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into()], + channels: vec!["trade".into()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + }, + ) + .unwrap(); + duck.record(std::slice::from_ref(&frame)).unwrap(); + duck.finalize(RecordingIntegrity::now()).unwrap(); + + let timeline = read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 1, "one copy, not one per backend"); + match &timeline.events[0].payload { + EventPayload::Market(read_back) => assert_eq!(read_back, &frame), + other => panic!("expected a market payload, got {other:?}"), + } + } +} diff --git a/crates/kraken-session/src/timeline/event.rs b/crates/kraken-session/src/timeline/event.rs new file mode 100644 index 0000000..18e08d4 --- /dev/null +++ b/crates/kraken-session/src/timeline/event.rs @@ -0,0 +1,231 @@ +//! Event vocabulary for merged session timelines. +//! +//! Payloads reuse persisted models and serialize flat, keeping merged output +//! sink-compatible without a parallel read model. + +use chrono::{DateTime, Utc}; +use kraken_core::ChannelMessage; +use kraken_paper::AccountEvent; +use kraken_paper::account::AccountRecord; +use kraken_recording::MarketEvent; +use serde::Serialize; + +use crate::decision::Decision; + +#[derive(Debug, Clone, Serialize)] +pub struct TimelineEvent { + pub at: DateTime, + /// Within-track order: the DuckDB frame `seq`, or the track's line number. + pub seq: i64, + #[serde(flatten)] + pub payload: EventPayload, +} + +/// A persisted payload; boxing keeps the common market variant compact. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum EventPayload { + /// A market frame (`market.duckdb` / `market.jsonl`). + Market(ChannelMessage), + /// A paper-account journal record (`events.jsonl`). + Account(Box), + /// A strategy/operator decision (`decisions.jsonl`). + Decision(Decision), +} + +/// Load-bearing tie-break order: market context, account effect, then rationale. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Track { + Market, + Account, + Decision, +} + +impl TimelineEvent { + pub fn account(at: DateTime, seq: i64, record: AccountRecord) -> Self { + Self { + at, + seq, + payload: EventPayload::Account(Box::new(record)), + } + } + + pub fn decision(at: DateTime, seq: i64, decision: Decision) -> Self { + Self { + at, + seq, + payload: EventPayload::Decision(decision), + } + } + + pub fn track(&self) -> Track { + match self.payload { + EventPayload::Market(_) => Track::Market, + EventPayload::Account(_) => Track::Account, + EventPayload::Decision(_) => Track::Decision, + } + } + + /// Orders by instant and track before comparing unrelated per-track sequences. + pub fn key(&self) -> (DateTime, Track, i64) { + (self.at, self.track(), self.seq) + } + + /// The human render's line label: a market frame shows its channel + /// (`trade`/`ticker`/…), the other tracks their track name. + pub fn track_label(&self) -> String { + match &self.payload { + EventPayload::Market(frame) => frame.channel().to_string(), + EventPayload::Account(_) => "account".to_string(), + EventPayload::Decision(_) => "decision".to_string(), + } + } +} + +/// Where the journal's final account epoch begins — the last +/// `Initialized`/`Reset` on the account track — and how many earlier epoch +/// starts precede it. `start: None` when the journal has no epoch at all. +pub struct EpochStart { + pub start: Option, + pub skipped: usize, +} + +/// Finds the shared epoch boundary; order IDs restart at every boundary. +pub fn final_epoch_start(events: &[TimelineEvent]) -> EpochStart { + let starts: Vec = events + .iter() + .enumerate() + .filter(|(_, event)| { + matches!( + &event.payload, + EventPayload::Account(record) if matches!( + record.event, + AccountEvent::Initialized(_) + | AccountEvent::Reset(_) + | AccountEvent::Attached(_) + ) + ) + }) + .map(|(index, _)| index) + .collect(); + EpochStart { + start: starts.last().copied(), + skipped: starts.len().saturating_sub(1), + } +} + +/// A market source item becomes a timeline event verbatim: the source already +/// clamped the instant and owns the `seq`. +impl From for TimelineEvent { + fn from(event: MarketEvent) -> Self { + Self { + at: event.at, + seq: event.seq, + payload: EventPayload::Market(event.frame), + } + } +} + +#[cfg(test)] +mod tests { + use kraken_paper::PaperConfig; + use kraken_paper::account::{Origin, RECORD_VERSION}; + use kraken_recording::parse_instant; + use rust_decimal_macros::dec; + + use super::*; + use crate::decision::DecisionKind; + + fn decision_at(ts: &str) -> TimelineEvent { + TimelineEvent::decision( + parse_instant(ts).unwrap(), + 0, + Decision { + timestamp: ts.parse().unwrap(), + kind: DecisionKind::Skip, + symbol: None, + reason: "test".to_string(), + order_id: None, + }, + ) + } + + fn account_at(ts: &str) -> TimelineEvent { + TimelineEvent::account( + parse_instant(ts).unwrap(), + 0, + AccountRecord { + v: RECORD_VERSION, + ts: ts.parse().unwrap(), + origin: Origin::Cli, + event: AccountEvent::Initialized(PaperConfig { + balance: dec!(10_000.0), + currency: "USD".to_string(), + fee_rate: dec!(0.0026), + slippage_rate: dec!(0.0), + }), + }, + ) + } + + fn market_at(ts: &str, seq: i64) -> TimelineEvent { + let frame = ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":1,"timestamp":"TS"}]}"#, + ) + .unwrap(); + TimelineEvent::from(MarketEvent { + at: parse_instant(ts).unwrap(), + seq, + frame, + }) + } + + #[test] + fn equal_instant_ties_break_market_account_decision() { + // Market gets the highest seq: a pass proves track priority outranks seq. + let ts = "2026-01-01T00:00:00Z"; + let market = market_at(ts, 9); + let account = account_at(ts); + let decision = decision_at(ts); + assert!(market.key() < account.key(), "market precedes account"); + assert!(account.key() < decision.key(), "account precedes decision"); + } + + #[test] + fn serializes_with_the_payload_flattened_to_its_wire_shape() { + // Pins the NDJSON contract `kraken replay` emits verbatim. + let event = market_at("2026-01-01T00:00:00Z", 7); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["seq"], 7); + assert_eq!(json["channel"], "trade"); + assert_eq!(json["data"][0]["price"], 1.0); + assert!(json.get("payload").is_none(), "no discriminant wrapper"); + } + + #[test] + fn track_labels_show_the_channel_for_market_and_the_track_otherwise() { + let ts = "2026-01-01T00:00:00Z"; + assert_eq!(market_at(ts, 1).track_label(), "trade"); + assert_eq!(account_at(ts).track_label(), "account"); + assert_eq!(decision_at(ts).track_label(), "decision"); + } + + #[test] + fn account_and_decision_payloads_serialize_flat_too() { + // Pins the module's deepest serde stack: outer flatten → untagged enum + // → Box → AccountRecord's own inner flatten. + let account = serde_json::to_value(account_at("2026-01-01T00:00:00Z")).unwrap(); + assert_eq!(account["event"], "initialized"); + assert_eq!(account["origin"], "cli"); + // The journal's digit-exact string wire must survive the flatten + // stack's `Content` buffering, digits included. + assert_eq!(account["balance"], "10000.0"); + assert!(account.get("payload").is_none(), "no discriminant wrapper"); + + let decision = serde_json::to_value(decision_at("2026-01-01T00:00:00Z")).unwrap(); + assert_eq!(decision["kind"], "skip"); + assert_eq!(decision["reason"], "test"); + assert!(decision.get("payload").is_none(), "no discriminant wrapper"); + } +} diff --git a/crates/kraken-session/tests/duckdb_roundtrip.rs b/crates/kraken-session/tests/duckdb_roundtrip.rs new file mode 100644 index 0000000..0e2ea19 --- /dev/null +++ b/crates/kraken-session/tests/duckdb_roundtrip.rs @@ -0,0 +1,49 @@ +//! A DuckDB-recorded session read whole through the public API — the +//! feature-gated sibling of `session_roundtrip.rs`. + +use kraken_recording::{ + CaptureSink, DuckdbSink, RecordingDeclaration, RecordingIntegrity, Sink, schema, +}; +use kraken_session::manifest::{RecordingBackend, RecordingRef}; +use kraken_session::timeline::event::Track; +use kraken_session::timeline::{self, fixtures}; + +#[test] +fn duckdb_recording_reads_back_through_the_timeline() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision( + base.path(), + vec![RecordingRef { + backend: RecordingBackend::Duckdb, + file: "market.duckdb".to_string(), + schema_version: schema::SCHEMA_VERSION.to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trade".to_string()], + }], + ); + + let frame = kraken_core::ChannelMessage::parse( + r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"buy", + "price":1.0,"qty":1.0,"ord_type":"market","trade_id":0, + "timestamp":"2026-01-01T00:00:01.000000Z"}]}"#, + ) + .unwrap(); + let mut sink = DuckdbSink::open( + &kraken_session::dir(base.path(), &name).join("market.duckdb"), + &RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into()], + channels: vec!["trade".into()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + }, + ) + .unwrap(); + sink.record(std::slice::from_ref(&frame)).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let timeline = timeline::read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 1); + assert_eq!(timeline.events[0].track(), Track::Market); + assert!(timeline.capture.is_some(), "capture manifest read back"); +} diff --git a/crates/kraken-session/tests/session_roundtrip.rs b/crates/kraken-session/tests/session_roundtrip.rs new file mode 100644 index 0000000..41f3de1 --- /dev/null +++ b/crates/kraken-session/tests/session_roundtrip.rs @@ -0,0 +1,84 @@ +//! One realistic session flow through the public API only — the boundary +//! proof for every timeline consumer: provision, write all three tracks with +//! the public writers, read the merged timeline, and pin the merge order, +//! the writer-lock refusal, and the unknown-kind skip. + +use kraken_session::timeline::event::Track; +use kraken_session::timeline::{self, fixtures}; +use kraken_session::{SessionError, decisions_path, events_path, manifest_path}; + +#[test] +fn three_tracks_merge_in_at_track_seq_order() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![fixtures::jsonl_recording()]); + fixtures::write_market( + base.path(), + &name, + &["2026-01-01T00:00:01.000000Z", "2026-01-01T00:00:03.000000Z"], + ); + fixtures::write_account( + base.path(), + &name, + &[fixtures::account_record("2026-01-01T00:00:02+00:00")], + ); + fixtures::write_decision(base.path(), &name, "2026-01-01T00:00:03+00:00"); + + // The manifest round-trips through its own save/load on the way in + // (fixtures::provision saved it; read loads it). + let manifest = + kraken_session::manifest::SessionManifest::load(&manifest_path(base.path(), &name)) + .unwrap(); + assert_eq!(manifest.id, name.to_string()); + + let timeline = timeline::read(base.path(), &name).unwrap(); + let tracks: Vec = timeline.events.iter().map(|e| e.track()).collect(); + assert_eq!( + tracks, + [ + Track::Market, + Track::Account, + Track::Market, + Track::Decision + ] + ); + assert!( + timeline.events.windows(2).all(|w| w[0].key() <= w[1].key()), + "events are sorted by the (at, track, seq) merge key" + ); +} + +#[test] +fn live_writer_rejects_the_read_with_in_use() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![]); + let journal = events_path(base.path(), &name); + std::fs::write(&journal, "").unwrap(); + let _writer = kraken_recording::FileLock::acquire(&journal, "test writer").unwrap(); + + let err = timeline::read(base.path(), &name).unwrap_err(); + assert!(matches!( + err, + SessionError::Recording(kraken_recording::Error::Rejected(_)) + )); + assert!(err.to_string().contains("in use"), "got: {err}"); +} + +#[test] +fn unknown_decision_kind_is_skipped_not_fatal() { + let base = tempfile::tempdir().unwrap(); + let name = fixtures::provision(base.path(), vec![]); + fixtures::write_decision(base.path(), &name, "2026-01-01T00:00:01+00:00"); + let path = decisions_path(base.path(), &name); + let raw = format!( + "{}\n", + r#"{"timestamp":"2026-01-01T00:00:02+00:00","kind":"hold","reason":"wait"}"# + ); + std::fs::write( + &path, + [std::fs::read_to_string(&path).unwrap(), raw].concat(), + ) + .unwrap(); + + let timeline = timeline::read(base.path(), &name).unwrap(); + assert_eq!(timeline.events.len(), 1, "unknown kind skipped, not fatal"); +} diff --git a/crates/kraken-session/tests/session_window.rs b/crates/kraken-session/tests/session_window.rs new file mode 100644 index 0000000..de93b0e --- /dev/null +++ b/crates/kraken-session/tests/session_window.rs @@ -0,0 +1,374 @@ +//! The windowed reader's contract: the synthesized epoch equals the prefix +//! fold, the slice partitions the journal, and the anchor is frozen in +//! `session.json` — pinned here because every score and P&L report downstream +//! anchors on exactly this arithmetic. + +#![allow(clippy::unwrap_used)] + +use std::path::Path; + +use chrono::{DateTime, TimeZone, Utc}; +use kraken_paper::account::{Origin, PaperAccount}; +use kraken_paper::{AccountEvent, CommandEntry, OrderSide, PaperConfig, PaperState}; +use kraken_session::manifest::{PaperRef, SessionManifest, SessionWindow}; +use kraken_session::session::{ + START_MARKER, STOP_MARKER, SessionTracks, parse_ordinal, read_account_window, read_window, + session_dir, +}; +use kraken_session::timeline::event::{EventPayload, final_epoch_start}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +fn t(minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 1, 1, 0, minute, 0).unwrap() +} + +fn config(balance: Decimal) -> PaperConfig { + PaperConfig { + balance, + currency: "USD".to_string(), + fee_rate: dec!(0.0026), + slippage_rate: Decimal::ZERO, + } +} + +fn marker(name: &str, run: &str) -> CommandEntry { + CommandEntry { + name: name.to_string(), + session: Some(run.to_string()), + ..CommandEntry::default() + } +} + +/// A market fill decided against fixed quotes, committed like the real path. +fn fill(account: &mut PaperAccount, at: DateTime, volume: Decimal) { + let trade = account + .state() + .unwrap() + .clone() + .decide_market_order(OrderSide::Buy, "BTCUSD", volume, dec!(100.0), dec!(99.0)) + .unwrap(); + let records = account.stamp_at(vec![AccountEvent::OrderFilled { trade }], at); + account.append(&records).unwrap(); +} + +fn run_manifest(scope: &Path, run: &str, started_at: DateTime) -> SessionManifest { + let _ = scope; + SessionManifest::new( + run.parse().unwrap(), + "0.0.0-test".to_string(), + 4242, + Vec::new(), + PaperRef { + starting_balance: dec!(10_000), + currency: "USD".to_string(), + }, + SessionWindow { + session: run.to_string(), + started_at, + opening_equity: dec!(9_950), + opening_complete: true, + ended_at: None, + }, + ) + .with_label(Some("momentum-s1".to_string())) +} + +/// Journal: epoch → pre-run fill → start marker → 2 window fills → stop +/// marker → post-run fill. The window must see exactly the 2 fills plus the +/// stop marker, opening from the prefix-folded balances. +fn seeded_scope() -> (tempfile::TempDir, PaperState) { + let scope = tempfile::tempdir().unwrap(); + let journal = scope.path().join("journal.jsonl"); + let mut account = PaperAccount::open_at(journal, Origin::Cli).unwrap(); + + let records = account.stamp_at(vec![AccountEvent::Initialized(config(dec!(10_000)))], t(0)); + account.append(&records).unwrap(); + fill(&mut account, t(1), dec!(0.5)); + + // What the prefix fold must reproduce: the state at the start marker. + let records = account.stamp_at( + vec![AccountEvent::Command(marker(START_MARKER, "s1"))], + t(2), + ); + account.append(&records).unwrap(); + let opening = account.state().unwrap().clone(); + + fill(&mut account, t(3), dec!(0.1)); + fill(&mut account, t(4), dec!(0.2)); + let records = account.stamp_at(vec![AccountEvent::Command(marker(STOP_MARKER, "s1"))], t(5)); + account.append(&records).unwrap(); + fill(&mut account, t(6), dec!(1.0)); + + (scope, opening) +} + +fn tracks(scope: &Path, run: &str, started_at: DateTime) -> SessionTracks { + SessionTracks { + journal: scope.join("journal.jsonl"), + session_dir: session_dir(scope, parse_ordinal(run).unwrap()), + manifest: run_manifest(scope, run, started_at), + } +} + +fn account_events(timeline: &kraken_session::timeline::SessionTimeline) -> Vec<&AccountEvent> { + timeline + .events + .iter() + .filter_map(|e| match &e.payload { + EventPayload::Account(record) => Some(&record.event), + _ => None, + }) + .collect() +} + +#[test] +fn windowed_fold_equals_prefix_fold() { + let (scope, opening) = seeded_scope(); + let timeline = read_window(tracks(scope.path(), "s1", t(2))).unwrap(); + + let events = account_events(&timeline); + let AccountEvent::Attached(snapshot) = events[0] else { + panic!( + "window opens with the synthesized epoch, got {:?}", + events[0] + ); + }; + for (asset, amount) in &opening.balances { + assert_eq!( + snapshot.balances.get(asset), + Some(amount), + "synthesized {asset} equals the prefix fold" + ); + } + let anchor = snapshot + .anchor + .as_ref() + .expect("the window opener carries an anchor"); + assert_eq!(anchor.equity, dec!(9_950), "anchor from session.json"); + assert_eq!(anchor.currency, "USD"); +} + +#[test] +fn read_account_window_folds_the_window_without_a_tape() { + // A recording session holds its tape locked, so `session show` folds the + // account window tape-free. The seeded scope writes a journal but no tape; + // the fold must still yield exactly the window's two fills — the count + // `session show` reports for an open session, window-scoped like a stopped + // one, never the account lifetime. + let (scope, _) = seeded_scope(); + let journal = scope.path().join("journal.jsonl"); + let manifest = run_manifest(scope.path(), "s1", t(2)); + + let events = read_account_window(&journal, &manifest).unwrap(); + let mut state = PaperState::default(); + for event in &events { + if let EventPayload::Account(record) = &event.payload { + state.apply(record.ts, &record.event); + } + } + assert_eq!( + state.filled_trades.len(), + 2, + "the account window folds its own two fills, not the account's four" + ); +} + +#[test] +fn window_slice_partitions_the_journal() { + let (scope, _) = seeded_scope(); + let timeline = read_window(tracks(scope.path(), "s1", t(2))).unwrap(); + + let fills: Vec = timeline + .events + .iter() + .filter_map(|e| match &e.payload { + EventPayload::Account(record) => match &record.event { + AccountEvent::OrderFilled { trade } => Some(trade.volume), + _ => None, + }, + _ => None, + }) + .collect(); + assert_eq!( + fills, + vec![dec!(0.1), dec!(0.2)], + "pre-session and post-session fills are outside the window" + ); +} + +#[test] +fn synthesized_epoch_is_the_final_epoch_and_anchors_the_fold() { + let (scope, _) = seeded_scope(); + let timeline = read_window(tracks(scope.path(), "s1", t(2))).unwrap(); + + let epoch = final_epoch_start(&timeline.events); + let start = epoch.start.expect("the synthesized Attached is an epoch"); + + // Fold from the epoch exactly as explain-pnl does: default state forward. + let mut state = PaperState::default(); + for event in &timeline.events[start..] { + if let EventPayload::Account(record) = &event.payload { + state.apply(record.ts, &record.event); + } + } + assert_eq!( + state.starting_balance, + dec!(9_950), + "the windowed fold anchors on the frozen opening equity" + ); + assert_eq!(state.filled_trades.len(), 2); +} + +#[test] +fn open_run_reads_to_the_journal_end() { + let scope = tempfile::tempdir().unwrap(); + let journal = scope.path().join("journal.jsonl"); + let mut account = PaperAccount::open_at(journal, Origin::Cli).unwrap(); + let records = account.stamp_at(vec![AccountEvent::Initialized(config(dec!(10_000)))], t(0)); + account.append(&records).unwrap(); + let records = account.stamp_at( + vec![AccountEvent::Command(marker(START_MARKER, "s1"))], + t(1), + ); + account.append(&records).unwrap(); + fill(&mut account, t(2), dec!(0.3)); + drop(account); + + let timeline = read_window(tracks(scope.path(), "s1", t(1))).unwrap(); + let events = account_events(&timeline); + assert!( + matches!(events.last().unwrap(), AccountEvent::OrderFilled { trade } if trade.volume == dec!(0.3)), + "an open session's window extends to the journal end" + ); +} + +#[test] +fn missing_start_marker_is_rejected() { + let scope = tempfile::tempdir().unwrap(); + let journal = scope.path().join("journal.jsonl"); + let mut account = PaperAccount::open_at(journal, Origin::Cli).unwrap(); + let records = account.stamp_at(vec![AccountEvent::Initialized(config(dec!(10_000)))], t(0)); + account.append(&records).unwrap(); + drop(account); + + let err = read_window(tracks(scope.path(), "s9", t(1))).unwrap_err(); + assert!( + err.to_string().contains("no start marker"), + "unexpected: {err}" + ); +} + +#[test] +fn absent_journal_reads_as_the_epoch_alone() { + let scope = tempfile::tempdir().unwrap(); + let timeline = read_window(tracks(scope.path(), "s1", t(1))).unwrap(); + let events = account_events(&timeline); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], AccountEvent::Attached(_))); +} + +#[test] +fn run_manifest_round_trips_label_and_window() { + let scope = tempfile::tempdir().unwrap(); + let path = scope.path().join("session.json"); + let manifest = run_manifest(scope.path(), "s1", t(2)); + manifest.save(&path).unwrap(); + let back = SessionManifest::load(&path).unwrap(); + assert_eq!(back.label.as_deref(), Some("momentum-s1")); + assert_eq!(back.window.session, "s1"); + assert_eq!(back.window.opening_equity, dec!(9_950)); + assert_eq!(back.id.to_string(), "s1"); +} + +/// The run keys ride BESIDE the pinned session keys — an accidental move of +/// `label`/`window` into `SessionManifest` (whose key set is pinned) or a +/// rename here must fail loud. +#[test] +fn run_json_carries_the_session_keys_plus_label_and_window() { + let value = serde_json::to_value(run_manifest(Path::new("/tmp"), "s1", t(2))).unwrap(); + let object = value.as_object().unwrap(); + for key in [ + "manifest_version", + "cli_version", + "id", + "pid", + "paper", + "status", + "created_at", + ] { + assert!(object.contains_key(key), "session key {key} missing"); + } + assert!(object.contains_key("label")); + let window = object["window"].as_object().unwrap(); + let keys: Vec<&str> = window.keys().map(String::as_str).collect(); + assert_eq!( + keys, + [ + "ended_at", + "opening_complete", + "opening_equity", + "session", + "started_at" + ], + "the window's serialized key set is pinned" + ); + assert_eq!( + window["opening_equity"], "9950", + "money is a string on the wire" + ); +} + +#[test] +fn ordinals_parse_strictly() { + assert_eq!(parse_ordinal("s1"), Some(1)); + assert_eq!(parse_ordinal("s42"), Some(42)); + for bad in ["s0", "s01", "S3", "s", "x1", "s-1", "s1x", "r3", "1"] { + assert_eq!(parse_ordinal(bad), None, "{bad} must not parse"); + } +} + +mod proptests { + use proptest::prelude::*; + + use super::*; + + proptest! { + /// Any prefix of fills folds to the same balances the synthesized + /// epoch carries — the windowed read can never disagree with the + /// journal's own arithmetic. + #[test] + fn windowed_opening_always_equals_the_prefix_fold( + volumes in proptest::collection::vec(1u32..=50, 0..6), + ) { + let scope = tempfile::tempdir().unwrap(); + let journal = scope.path().join("journal.jsonl"); + let mut account = PaperAccount::open_at(journal, Origin::Cli).unwrap(); + let records = account.stamp_at( + vec![AccountEvent::Initialized(config(dec!(10_000)))], + t(0), + ); + account.append(&records).unwrap(); + for (i, centi) in volumes.iter().enumerate() { + let volume = Decimal::from(*centi) / Decimal::from(100); + fill(&mut account, t(1 + i as u32), volume); + } + let records = account.stamp_at( + vec![AccountEvent::Command(marker(START_MARKER, "s1"))], + t(20), + ); + account.append(&records).unwrap(); + let expected = account.state().unwrap().balances.clone(); + drop(account); + + let timeline = read_window(tracks(scope.path(), "s1", t(20))).unwrap(); + let events = account_events(&timeline); + let AccountEvent::Attached(snapshot) = events[0] else { + panic!("window must open with the synthesized epoch"); + }; + for (asset, amount) in &expected { + prop_assert_eq!(snapshot.balances.get(asset), Some(amount)); + } + } + } +} diff --git a/crates/kraken-workspace/Cargo.toml b/crates/kraken-workspace/Cargo.toml new file mode 100644 index 0000000..ff81f8c --- /dev/null +++ b/crates/kraken-workspace/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "kraken-workspace" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Strategy workspaces for kraken-cli: the account that unifies paper and live trading" +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +chrono = { workspace = true } +kraken-core = { workspace = true } +kraken-paper = { workspace = true } +kraken-recording = { workspace = true } +kraken-session = { workspace = true } +rust_decimal = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_with = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +insta = { version = "1.48.0", features = ["json"] } +proptest = { workspace = true } +rust_decimal_macros = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/kraken-workspace/src/app.rs b/crates/kraken-workspace/src/app.rs new file mode 100644 index 0000000..195d0a8 --- /dev/null +++ b/crates/kraken-workspace/src/app.rs @@ -0,0 +1,508 @@ +//! The `Workspaces` facade — the one seam the binary dispatches to. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use kraken_paper::account::{Origin, PaperAccount}; +use kraken_paper::{AccountEvent, CommandEntry, CommandOutcome, PaperConfig, PaperState}; +use rust_decimal::Decimal; +use serde::Serialize; +use serde_with::skip_serializing_none; + +use crate::manifest::{WorkspaceManifest, WorkspaceMode}; +use crate::naming::{DEFAULT_WORKSPACE, WorkspaceName}; +use crate::{Result, WorkspaceError, store}; + +/// Validated input for workspace creation; storage owns the timestamp. +#[derive(Debug, Clone)] +pub struct CreateSpec { + pub name: WorkspaceName, + pub capital: Decimal, + pub currency: String, + pub mode: WorkspaceMode, + pub fee_rate: Decimal, + pub slippage_rate: Decimal, + /// `None` = unrestricted; `Some([])` = deny-all. + pub allowed_pairs: Option>, +} + +/// Reset overrides written atomically with the new journal epoch. +#[derive(Debug, Clone, Default)] +pub struct ResetOverrides { + pub capital: Option, + pub currency: Option, + pub fee_rate: Option, + pub slippage_rate: Option, +} + +impl ResetOverrides { + fn is_empty(&self) -> bool { + self.capital.is_none() + && self.currency.is_none() + && self.fee_rate.is_none() + && self.slippage_rate.is_none() + } +} + +/// Which account a workspace command addresses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// The real Kraken account on the master credentials — never created, + /// never locally funded; its balance is the venue's. + Default, + Named(WorkspaceName), +} + +impl Target { + /// Resolve an optional user-supplied name: `default` (or nothing) is the + /// real account, anything else must be a valid workspace name. + pub fn resolve(name: Option<&str>) -> Result { + match name { + None => Ok(Self::Default), + Some(DEFAULT_WORKSPACE) => Ok(Self::Default), + Some(named) => Ok(Self::Named(named.parse()?)), + } + } +} + +/// One `workspace list` row. The synthesized `default` row carries no local +/// contract fields — its account lives at the venue. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct ListedWorkspace { + pub name: String, + pub mode: WorkspaceMode, + #[serde(with = "rust_decimal::serde::str_option")] + pub capital: Option, + pub currency: Option, + pub created_at: Option>, + /// Why the contract could not be read — and only then. + pub damaged: Option, +} + +/// The `workspace show` payload. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct WorkspaceOverview { + pub name: String, + pub mode: WorkspaceMode, + #[serde(with = "rust_decimal::serde::str_option")] + pub capital: Option, + pub currency: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub fee_rate: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub slippage_rate: Option, + pub allowed_pairs: Option>, + pub created_at: Option>, + pub workspace_version: Option, +} + +impl WorkspaceOverview { + fn synthesized_default() -> Self { + Self { + name: DEFAULT_WORKSPACE.to_string(), + mode: WorkspaceMode::Live, + capital: None, + currency: None, + fee_rate: None, + slippage_rate: None, + allowed_pairs: None, + created_at: None, + workspace_version: None, + } + } +} + +impl From for WorkspaceOverview { + fn from(manifest: WorkspaceManifest) -> Self { + Self { + name: manifest.name, + mode: manifest.mode, + capital: Some(manifest.capital), + currency: Some(manifest.currency), + fee_rate: Some(manifest.fee_rate), + slippage_rate: Some(manifest.slippage_rate), + allowed_pairs: manifest.allowed_pairs, + created_at: Some(manifest.created_at), + workspace_version: Some(manifest.workspace_version), + } + } +} + +/// One asset's balance, folded from the journal. +#[derive(Debug, Serialize)] +pub struct AssetBalance { + #[serde(with = "rust_decimal::serde::str")] + pub total: Decimal, + /// Held against resting limit orders. + #[serde(with = "rust_decimal::serde::str")] + pub reserved: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub available: Decimal, +} + +/// The `workspace balance` payload for a paper account. +#[derive(Debug, Serialize)] +pub struct WorkspaceBalances { + pub workspace: String, + pub mode: WorkspaceMode, + pub balances: BTreeMap, +} + +/// Application service over the workspace store. `base` is the outer config +/// dir, never a scoped root: the facade derives each workspace's tree itself. +#[derive(Debug)] +pub struct Workspaces { + base: PathBuf, +} + +impl Workspaces { + pub fn new(base: impl Into) -> Self { + Self { base: base.into() } + } + + /// The gate for scoping into a named workspace: a typo, a never-created + /// name, or a damaged contract must refuse rather than silently trade the + /// wrong account. + pub fn ensure_exists(&self, name: &WorkspaceName) -> Result<()> { + store::ensure_exists(&self.base, name) + } + + /// Creates and funds as one logical operation; failed funding removes the contract. + pub fn create(&self, spec: CreateSpec, origin: Origin) -> Result { + if spec.name.is_default() { + return Err(WorkspaceError::CapitalManaged(format!( + "'{DEFAULT_WORKSPACE}' is the real Kraken account; its capital is managed by \ + the venue. Create a named workspace instead." + ))); + } + crate::policy::ensure_mode_creatable(spec.mode, spec.name.as_ref())?; + kraken_paper::validate_money(spec.capital, "capital")?; + ensure_rate(spec.fee_rate, "fee rate")?; + ensure_rate(spec.slippage_rate, "slippage rate")?; + // Persist canonical pairs so order-time policy cannot diverge from engine parsing. + let spec = CreateSpec { + allowed_pairs: spec + .allowed_pairs + .as_deref() + .map(crate::policy::canonicalize_pairs) + .transpose()?, + ..spec + }; + + let manifest = store::create(&self.base, &spec)?; + let funded = self.fund(&spec, origin); + if let Err(err) = funded { + store::discard_created(&self.base, &spec.name); + return Err(err); + } + Ok(manifest) + } + + fn fund(&self, spec: &CreateSpec, origin: Origin) -> Result<()> { + let mut account = PaperAccount::open_at(self.journal_path(&spec.name), origin)?; + let config = PaperConfig { + balance: spec.capital, + currency: spec.currency.clone(), + fee_rate: spec.fee_rate, + slippage_rate: spec.slippage_rate, + }; + let records = account.stamp(vec![AccountEvent::Initialized(config)]); + account.append_if_uninitialized(&records)?; + Ok(()) + } + + /// Every account, the real one first: `default` is synthesized (mode + /// live, venue-held capital), then the named workspaces sorted by name. + pub fn list(&self) -> Result> { + let mut rows = vec![ListedWorkspace { + name: DEFAULT_WORKSPACE.to_string(), + mode: WorkspaceMode::Live, + capital: None, + currency: None, + created_at: None, + damaged: None, + }]; + for (name, manifest) in store::list(&self.base)? { + rows.push(match manifest { + Ok(manifest) => ListedWorkspace { + name: manifest.name, + mode: manifest.mode, + capital: Some(manifest.capital), + currency: Some(manifest.currency), + created_at: Some(manifest.created_at), + damaged: None, + }, + Err(err) => ListedWorkspace { + name: name.to_string(), + // The mode is unreadable; paper is the only creatable + // mode, so it is the honest placeholder. + mode: WorkspaceMode::Paper, + capital: None, + currency: None, + created_at: None, + damaged: Some(err.to_string()), + }, + }); + } + Ok(rows) + } + + pub fn show(&self, target: &Target) -> Result { + match target { + Target::Default => Ok(WorkspaceOverview::synthesized_default()), + Target::Named(name) => Ok(store::load(&self.base, name)?.into()), + } + } + + /// The named workspace's contract, verified. `default` has none. + pub fn manifest(&self, name: &WorkspaceName) -> Result { + store::load(&self.base, name) + } + + /// Fold the journal into per-asset balances. Holds the journal writer + /// lock for the read, like every account operation. + pub fn balances(&self, name: &WorkspaceName, origin: Origin) -> Result { + let manifest = store::load(&self.base, name)?; + Ok(WorkspaceBalances { + workspace: manifest.name, + mode: manifest.mode, + balances: journal_balances(self.journal_path(name), origin)?, + }) + } + + /// Return a paper account to its starting capital: a `Reset(capital)` + /// epoch on the journal. History survives — a reset is a new epoch, not + /// an erasure. + pub fn reset( + &self, + name: &WorkspaceName, + origin: Origin, + overrides: ResetOverrides, + ) -> Result { + let mut manifest = store::load(&self.base, name)?; + if manifest.mode != WorkspaceMode::Paper { + return Err(WorkspaceError::CapitalManaged(format!( + "workspace '{name}' is mode {}; only a paper account can be reset to its \ + capital", + manifest.mode + ))); + } + // A reset epoch inside an open session window would compete with the + // window's own anchor and skew every score of that session. + if let Some((run, _)) = crate::session::active(&crate::workspace_dir(&self.base, name))? { + return Err(WorkspaceError::SessionActive { + session: run.to_string(), + }); + } + // Re-parameterization updates the contract BEFORE the epoch is + // written: validated first, persisted after the epoch lands, so a + // failed journal append leaves both untouched. + let reparameterized = !overrides.is_empty(); + if let Some(capital) = overrides.capital { + kraken_paper::validate_money(capital, "capital")?; + manifest.capital = capital; + } + if let Some(currency) = overrides.currency { + manifest.currency = currency.to_uppercase(); + } + if let Some(fee_rate) = overrides.fee_rate { + ensure_rate(fee_rate, "fee rate")?; + manifest.fee_rate = fee_rate; + } + if let Some(slippage_rate) = overrides.slippage_rate { + ensure_rate(slippage_rate, "slippage rate")?; + manifest.slippage_rate = slippage_rate; + } + let mut account = PaperAccount::open_at(self.journal_path(name), origin)?; + let config = PaperConfig { + balance: manifest.capital, + currency: manifest.currency.clone(), + fee_rate: manifest.fee_rate, + slippage_rate: manifest.slippage_rate, + }; + let entry = CommandEntry { + name: "workspace reset".to_string(), + balance: Some(manifest.capital), + currency: Some(manifest.currency.clone()), + fee_rate: Some(manifest.fee_rate), + slippage_rate: Some(manifest.slippage_rate), + outcome: CommandOutcome::Ok { + order_ids: Vec::new(), + trade_ids: Vec::new(), + }, + ..CommandEntry::default() + }; + account.commit(vec![AccountEvent::Reset(config)], entry)?; + if reparameterized { + kraken_recording::write_json_atomic(&store::manifest_path(&self.base, name), &manifest) + .map_err(WorkspaceError::Recording)?; + } + Ok(manifest) + } + + /// Promotion stays fail-closed until workspace-scoped credentials can + /// prevent a named workspace from inheriting the master account. + pub fn promote( + &self, + name: &WorkspaceName, + evidence: crate::promote::PromotionInputs, + ) -> Result { + let manifest = store::load(&self.base, name)?; + let checklist = crate::promote::evaluate(&manifest, evidence); + Err(WorkspaceError::NotPromotable { + workspace: manifest.name, + checklist: Box::new(checklist), + }) + } + + /// The account journal: `/workspaces//journal.jsonl`. + pub fn journal_path(&self, name: &WorkspaceName) -> PathBuf { + store::journal_path(&self.base, name) + } +} + +/// Fold any account journal into per-asset balances — one shaping shared by +/// workspace reads and (until runs replace sessions) session reads. +pub fn journal_balances( + journal: PathBuf, + origin: Origin, +) -> Result> { + let account = PaperAccount::open_at(journal, origin)?; + Ok(asset_balances(account.state()?)) +} + +fn ensure_rate(rate: Decimal, what: &str) -> Result<()> { + if rate < Decimal::ZERO || rate > Decimal::ONE { + return Err(WorkspaceError::Spec(format!( + "{what} must be between 0 and 1, got {rate}" + ))); + } + Ok(()) +} + +fn asset_balances(state: &PaperState) -> BTreeMap { + let mut rows = BTreeMap::new(); + for (asset, &total) in &state.balances { + let reserved = state.reserved.get(asset).copied().unwrap_or(Decimal::ZERO); + rows.insert( + asset.clone(), + AssetBalance { + total, + reserved, + available: total - reserved, + }, + ); + } + rows +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + fn spec(name: &str) -> CreateSpec { + CreateSpec { + name: name.parse().expect("valid name"), + capital: dec!(10000), + currency: "USD".to_string(), + mode: WorkspaceMode::Paper, + fee_rate: dec!(0.0026), + slippage_rate: dec!(0), + allowed_pairs: None, + } + } + + #[test] + fn create_funds_the_journal_with_the_capital_epoch() { + let base = tempfile::tempdir().expect("tempdir"); + let workspaces = Workspaces::new(base.path()); + workspaces + .create(spec("btc-momentum"), Origin::Cli) + .expect("create"); + + let name: WorkspaceName = "btc-momentum".parse().expect("valid name"); + let balances = workspaces.balances(&name, Origin::Cli).expect("balances"); + assert_eq!(balances.balances["USD"].total, dec!(10000)); + assert_eq!(balances.balances["USD"].available, dec!(10000)); + } + + #[test] + fn create_refuses_the_default_name_as_capital_managed() { + let base = tempfile::tempdir().expect("tempdir"); + let err = Workspaces::new(base.path()) + .create(spec("default"), Origin::Cli) + .expect_err("must refuse"); + assert!(matches!(err, WorkspaceError::CapitalManaged(_)), "{err}"); + assert_eq!(err.category(), "validation"); + } + + #[test] + fn create_refuses_live_mode_until_scoped_credentials() { + let base = tempfile::tempdir().expect("tempdir"); + let mut live = spec("scalper"); + live.mode = WorkspaceMode::Live; + let err = Workspaces::new(base.path()) + .create(live, Origin::Cli) + .expect_err("must refuse"); + assert!( + matches!(err, WorkspaceError::LiveUnavailable { .. }), + "{err}" + ); + assert!(err.to_string().contains("scoped credentials"), "{err}"); + } + + #[test] + fn reset_returns_balances_to_capital_and_history_survives_in_the_journal() { + let base = tempfile::tempdir().expect("tempdir"); + let workspaces = Workspaces::new(base.path()); + workspaces.create(spec("w1"), Origin::Cli).expect("create"); + let name: WorkspaceName = "w1".parse().expect("valid name"); + + workspaces + .reset(&name, Origin::Cli, ResetOverrides::default()) + .expect("reset"); + let balances = workspaces.balances(&name, Origin::Cli).expect("balances"); + assert_eq!(balances.balances["USD"].total, dec!(10000)); + + let journal = + std::fs::read_to_string(workspaces.journal_path(&name)).expect("journal exists"); + let lines: Vec<&str> = journal.lines().collect(); + assert!(lines[0].contains("\"initialized\""), "epoch survives"); + assert!( + lines.iter().any(|line| line.contains("\"reset\"")), + "reset epoch appended" + ); + } + + #[test] + fn list_synthesizes_the_default_row_first() { + let base = tempfile::tempdir().expect("tempdir"); + let workspaces = Workspaces::new(base.path()); + workspaces + .create(spec("alpha"), Origin::Cli) + .expect("create"); + + let rows = workspaces.list().expect("list"); + assert_eq!(rows[0].name, "default"); + assert_eq!(rows[0].mode, WorkspaceMode::Live); + assert!(rows[0].capital.is_none(), "venue-held, never local"); + assert_eq!(rows[1].name, "alpha"); + assert_eq!(rows[1].capital, Some(dec!(10000))); + } + + #[test] + fn show_default_is_the_synthesized_live_account() { + let base = tempfile::tempdir().expect("tempdir"); + let overview = Workspaces::new(base.path()) + .show(&Target::Default) + .expect("show"); + assert_eq!(overview.name, "default"); + assert_eq!(overview.mode, WorkspaceMode::Live); + assert!(overview.capital.is_none()); + } +} diff --git a/crates/kraken-workspace/src/lib.rs b/crates/kraken-workspace/src/lib.rs new file mode 100644 index 0000000..a5ff6e9 --- /dev/null +++ b/crates/kraken-workspace/src/lib.rs @@ -0,0 +1,260 @@ +//! Isolated account scopes for paper and live trading. +//! +//! Journals, sessions, and experiments are workspace-local; recorded market +//! data remains global. + +mod app; +mod manifest; +mod naming; +pub mod policy; +pub mod promote; +pub mod receipt; +pub mod report; +pub mod session; +mod store; + +use std::path::{Path, PathBuf}; + +pub use app::{ + AssetBalance, CreateSpec, ListedWorkspace, ResetOverrides, Target, WorkspaceBalances, + WorkspaceOverview, Workspaces, journal_balances, +}; +pub use manifest::{ + DECISIONS_FILE, JOURNAL_FILE, WORKSPACE_FILE, WORKSPACE_VERSION, WorkspaceManifest, + WorkspaceMode, is_compatible, +}; +pub use naming::{DEFAULT_WORKSPACE, WorkspaceName}; +pub use store::{journal_path, manifest_path}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceError { + /// The name has no home under `workspaces/` — scoping into it would + /// silently create a parallel empty tree. → envelope `validation`. + #[error( + "Workspace '{0}' not found. Create it with 'kraken workspace create {0} --capital --mode paper'." + )] + NotFound(String), + /// A create under a name that already holds an account. → envelope `validation`. + #[error("Workspace '{0}' already exists.")] + Exists(String), + /// Bad name or spec input. → envelope `validation`. + #[error("{0}")] + Spec(String), + /// A balance-setting operation on an account whose capital is not locally + /// managed (the real account, or a non-paper mode). → envelope `validation`. + #[error("{0}")] + CapitalManaged(String), + /// Refuses named live workspaces until credentials can be scoped safely. + #[error( + "Workspace '{name}' cannot go live yet: live execution needs workspace-scoped \ + credentials. Create it with --mode paper and promote it later." + )] + LiveUnavailable { name: String }, + /// Refuses pairs outside the workspace allow-list. + #[error("pair {pair} is not permitted in workspace '{workspace}' (allowed: {allowed})")] + PairNotAllowed { + pair: String, + workspace: String, + allowed: String, + }, + /// A money surface with no paper equivalent, refused inside a paper + /// workspace rather than silently degraded. → envelope `validation`. + #[error( + "{what} has no paper equivalent; workspace '{workspace}' is mode {mode}. Promote the \ + workspace (kraken workspace promote) or unset KRAKEN_WORKSPACE to use the real account." + )] + Unsupported { + what: String, + workspace: String, + mode: WorkspaceMode, + }, + /// Futures paper state is still global (one snapshot for every scope) — + /// mutating it from inside a workspace would corrupt shared state. + /// → envelope `validation`. + #[error("futures paper is not workspace-scoped yet; run it without a workspace")] + FuturesUnscoped { workspace: String }, + /// A second `run start` (or a `workspace reset`) while a session is still + /// recording. → envelope `validation`. + #[error( + "session {session} is still recording in this scope; stop it first: kraken session stop" + )] + SessionActive { session: String }, + /// A run-addressed command with nothing to address. → envelope `validation`. + #[error("no active session in this scope; start one with 'kraken session start'")] + NoActiveSession, + /// A session ref that resolves to nothing; names what exists. → envelope + /// `validation`. + #[error("session '{reference}' not found (sessions: {known})")] + SessionNotFound { reference: String, known: String }, + /// A `--label` another run already carries — labels resolve like + /// ordinals, so they must be unique. → envelope `validation`. + #[error("session label '{label}' is already taken by {session}")] + LabelTaken { label: String, session: String }, + /// A promotion evaluated but refused — the checklist rides the error so + /// the caller (and the JSON envelope) can say exactly what is missing. + /// → envelope `validation`, with a `checklist` field. + #[error("workspace '{workspace}' is not promotable yet; the checklist names what is missing")] + NotPromotable { + workspace: String, + checklist: Box, + }, + /// Run artifact failure (session.json, windowed read); the envelope mapping + /// delegates to the session bridge. + #[error(transparent)] + Session(#[from] kraken_session::SessionError), + /// A contract written by a newer MAJOR layout. → envelope `config`. + #[error( + "workspace contract version {found} is incompatible with this build (expects {expected})" + )] + Incompatible { found: String, expected: String }, + /// A stored contract this build cannot trust: undecodable, or naming a + /// different workspace than its directory. → envelope `parse`. + #[error("damaged workspace '{name}': {what}")] + Damaged { name: String, what: String }, + /// The account journal refused an operation; the envelope mapping + /// delegates to the paper bridge. + #[error(transparent)] + Paper(#[from] kraken_paper::PaperError), + /// Durable storage under the workspace failed; delegates to the + /// recording bridge. + #[error(transparent)] + Recording(#[from] kraken_recording::Error), + #[error(transparent)] + Store(#[from] std::io::Error), +} + +impl WorkspaceError { + /// Stable category mirrored by the CLI bridge and pinned by cross-layer tests. + pub fn category(&self) -> &'static str { + match self { + Self::NotFound(_) + | Self::Exists(_) + | Self::Spec(_) + | Self::CapitalManaged(_) + | Self::LiveUnavailable { .. } + | Self::PairNotAllowed { .. } + | Self::Unsupported { .. } + | Self::FuturesUnscoped { .. } + | Self::SessionActive { .. } + | Self::NoActiveSession + | Self::SessionNotFound { .. } + | Self::LabelTaken { .. } + | Self::NotPromotable { .. } => "validation", + Self::Incompatible { .. } => "config", + Self::Damaged { .. } => "parse", + Self::Paper(err) => paper_category(err), + Self::Recording(err) => recording_category(err), + Self::Session(err) => session_category(err), + Self::Store(_) => "io", + } + } +} + +/// Mirrors the CLI bridge so crate-level errors retain stable categories. +fn session_category(err: &kraken_session::SessionError) -> &'static str { + match err { + kraken_session::SessionError::Rejected(_) => "validation", + kraken_session::SessionError::IncompatibleManifest { .. } => "config", + kraken_session::SessionError::Damaged(_) => "parse", + kraken_session::SessionError::Recording(inner) => recording_category(inner), + kraken_session::SessionError::Io(_) => "io", + } +} + +/// Mirror of the binary's `From` bridge, for self-labeling. +fn paper_category(err: &kraken_paper::PaperError) -> &'static str { + match err { + kraken_paper::PaperError::Rejected(_) => "validation", + kraken_paper::PaperError::Incompatible(_) => "config", + kraken_paper::PaperError::Journal(inner) => recording_category(inner), + } +} + +/// Mirror of the binary's `From` bridge. +fn recording_category(err: &kraken_recording::Error) -> &'static str { + match err { + kraken_recording::Error::Rejected(_) => "validation", + kraken_recording::Error::Damaged(_) => "parse", + // Io and (with the duckdb feature) Engine are both local-I/O failures. + _ => "io", + } +} + +/// The root under which all named workspaces live: `/workspaces/`. +/// `base` is the config dir in production and a temp dir in tests. +pub fn workspaces_root(base: &Path) -> PathBuf { + base.join("workspaces") +} + +/// A workspace's scope root: `/workspaces//`. +pub fn workspace_dir(base: &Path, name: &WorkspaceName) -> PathBuf { + workspaces_root(base).join(name.as_ref()) +} + +/// Resolves scope paths without I/O; absent and `default` preserve the flat layout. +pub fn scoped_base(base: &Path, workspace: Option<&WorkspaceName>) -> PathBuf { + match workspace { + None => base.to_path_buf(), + Some(name) if name.is_default() => base.to_path_buf(), + Some(name) => workspace_dir(base, name), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scoped_base_aliases_none_and_default_to_the_flat_layout() { + let base = Path::new("/cfg"); + let default: WorkspaceName = DEFAULT_WORKSPACE.parse().expect("reserved name is valid"); + assert_eq!(scoped_base(base, None), base); + assert_eq!(scoped_base(base, Some(&default)), base); + } + + #[test] + fn scoped_base_roots_a_named_workspace_one_level_under_workspaces() { + let base = Path::new("/cfg"); + let name: WorkspaceName = "btc-momentum".parse().expect("valid name"); + assert_eq!( + scoped_base(base, Some(&name)), + Path::new("/cfg/workspaces/btc-momentum") + ); + } + + #[test] + fn error_categories_self_label_for_every_variant() { + assert_eq!( + WorkspaceError::NotFound("x".into()).category(), + "validation" + ); + assert_eq!(WorkspaceError::Exists("x".into()).category(), "validation"); + assert_eq!(WorkspaceError::Spec("x".into()).category(), "validation"); + assert_eq!( + WorkspaceError::CapitalManaged("x".into()).category(), + "validation" + ); + assert_eq!( + WorkspaceError::LiveUnavailable { name: "x".into() }.category(), + "validation" + ); + assert_eq!( + WorkspaceError::Incompatible { + found: "2.0".into(), + expected: "1.0".into() + } + .category(), + "config" + ); + assert_eq!( + WorkspaceError::Damaged { + name: "x".into(), + what: "y".into() + } + .category(), + "parse" + ); + } +} diff --git a/crates/kraken-workspace/src/manifest.rs b/crates/kraken-workspace/src/manifest.rs new file mode 100644 index 0000000..9c4475a --- /dev/null +++ b/crates/kraken-workspace/src/manifest.rs @@ -0,0 +1,85 @@ +//! Durable workspace account contracts. +//! +//! Additive fields remain forward-compatible; major layout changes do not. + +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// Manifest schema version; compatibility is MAJOR-only (see [`is_compatible`]). +pub const WORKSPACE_VERSION: &str = "1.0"; +/// Named explicitly to distinguish the workspace contract from `session.json`. +pub const WORKSPACE_FILE: &str = "workspace.json"; +/// One journal spans both modes, so its name remains mode-neutral. +pub const JOURNAL_FILE: &str = "journal.jsonl"; + +/// The workspace decision log: `--reason` rationales keyed by order id. +pub const DECISIONS_FILE: &str = "decisions.jsonl"; + +/// Which backend the account's trading verbs execute against. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case", ascii_case_insensitive)] +pub enum WorkspaceMode { + /// Simulated fills from the event-sourced engine, live market data. + Paper, + /// The real venue; named workspaces remain blocked until credentials can be scoped. + Live, +} + +/// The durable description of a workspace account. +#[skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceManifest { + /// Contract schema version; a differing MAJOR is incompatible. + #[serde(default = "default_workspace_version")] + pub workspace_version: String, + /// CLI version that wrote the contract. + pub cli_version: String, + /// The workspace name (its directory under `workspaces/`). + pub name: String, + /// Starting capital — fixed at create; `workspace reset` returns to it. + /// Digit-exact on the wire, like every journaled amount. + #[serde(with = "rust_decimal::serde::str")] + pub capital: Decimal, + /// Quote currency the capital is denominated in. + pub currency: String, + pub mode: WorkspaceMode, + #[serde(with = "rust_decimal::serde::str")] + pub fee_rate: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub slippage_rate: Decimal, + /// Account permission policy: `None` = unrestricted, empty = deny-all + /// (the paper analogue of live API-key pair permissions). + pub allowed_pairs: Option>, + /// Instant the account was created and funded. + pub created_at: DateTime, +} + +fn default_workspace_version() -> String { + WORKSPACE_VERSION.to_string() +} + +/// MAJOR-only compatibility: additive fields never bump it; a layout change +/// this build cannot read does. +pub fn is_compatible(found: &str) -> bool { + fn major(version: &str) -> Option<&str> { + version.split('.').next() + } + major(found) == major(WORKSPACE_VERSION) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compatibility_is_major_only() { + assert!(is_compatible("1.0")); + assert!(is_compatible("1.7")); + assert!(!is_compatible("2.0")); + } +} diff --git a/crates/kraken-workspace/src/naming.rs b/crates/kraken-workspace/src/naming.rs new file mode 100644 index 0000000..e92e9d2 --- /dev/null +++ b/crates/kraken-workspace/src/naming.rs @@ -0,0 +1,80 @@ +//! The workspace name contract. + +use std::fmt; +use std::str::FromStr; + +use crate::{Result, WorkspaceError}; + +/// The reserved implicit workspace: the flat layout under the config dir. +/// It cannot be created, and scoping to it is byte-identical to no scoping +/// at all. +pub const DEFAULT_WORKSPACE: &str = "default"; + +/// One validated path segment using the shared artifact-name grammar. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceName(String); + +impl FromStr for WorkspaceName { + type Err = WorkspaceError; + + fn from_str(s: &str) -> Result { + if !kraken_core::name::is_safe_name_segment(s) { + return Err(WorkspaceError::Spec(format!( + "Invalid workspace name {s:?}: use only letters, digits, '.', '-', or '_' \ + (no '/', spaces, or '.'/'..')." + ))); + } + Ok(Self(s.to_owned())) + } +} + +impl TryFrom<&str> for WorkspaceName { + type Error = WorkspaceError; + + /// See [`WorkspaceName::from_str`]. + fn try_from(s: &str) -> Result { + s.parse() + } +} + +impl WorkspaceName { + /// True for the reserved implicit workspace (no scoping). + pub fn is_default(&self) -> bool { + self.0 == DEFAULT_WORKSPACE + } +} + +impl fmt::Display for WorkspaceName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for WorkspaceName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_safe_segments_and_rejects_traversal_shapes() { + for good in ["btc-momentum", "eth.dca_2", "default"] { + assert!(good.parse::().is_ok(), "{good}"); + } + for bad in ["", ".", "..", "a/b", "a b", "ünïcode"] { + assert!(bad.parse::().is_err(), "{bad:?}"); + } + } + + #[test] + fn only_the_reserved_name_is_default() { + let default: WorkspaceName = DEFAULT_WORKSPACE.parse().expect("reserved name is valid"); + assert!(default.is_default()); + let named: WorkspaceName = "btc-momentum".parse().expect("valid name"); + assert!(!named.is_default()); + } +} diff --git a/crates/kraken-workspace/src/policy.rs b/crates/kraken-workspace/src/policy.rs new file mode 100644 index 0000000..7564698 --- /dev/null +++ b/crates/kraken-workspace/src/policy.rs @@ -0,0 +1,118 @@ +//! Account permission policy — the paper analogue of live API-key pair +//! permissions and subaccount walls, enforced at order time. + +use crate::manifest::{WorkspaceManifest, WorkspaceMode}; +use crate::{Result, WorkspaceError}; + +/// Canonicalize an allow-list: every entry through the engine's pair grammar +/// (so `BTC/USD`, `btcusd`, and `XBTUSD` are one entry), deduped and sorted. +/// An explicit empty list stays empty — that is deny-all, not unrestricted. +pub fn canonicalize_pairs(pairs: &[String]) -> Result> { + let mut canonical = pairs + .iter() + .map(|pair| canonical_pair(pair)) + .collect::>>()?; + canonical.sort_unstable(); + canonical.dedup(); + Ok(canonical) +} + +/// One pair in policy form (`BASE/QUOTE`), via the engine's grammar so venue +/// aliases collapse (XBT → BTC) and the check can never disagree with fills. +pub fn canonical_pair(pair: &str) -> Result { + let (_, base, quote) = kraken_paper::parse_pair(pair)?; + Ok(format!("{base}/{quote}")) +} + +/// Pair permission over a policy-form pair. `None` = unrestricted; an +/// explicit empty list = deny-all. +pub fn ensure_pair_allowed(manifest: &WorkspaceManifest, canonical_pair: &str) -> Result<()> { + let Some(allowed) = &manifest.allowed_pairs else { + return Ok(()); + }; + if allowed.iter().any(|pair| pair == canonical_pair) { + return Ok(()); + } + Err(WorkspaceError::PairNotAllowed { + pair: canonical_pair.to_string(), + workspace: manifest.name.clone(), + allowed: if allowed.is_empty() { + "none (deny-all)".to_string() + } else { + allowed.join(", ") + }, + }) +} + +/// Mode admissibility at create: live needs scoped credentials first — +/// without them a live workspace would silently trade the master account. +pub fn ensure_mode_creatable(mode: WorkspaceMode, name: &str) -> Result<()> { + match mode { + WorkspaceMode::Paper => Ok(()), + WorkspaceMode::Live => Err(WorkspaceError::LiveUnavailable { + name: name.to_string(), + }), + } +} + +/// The honest refusal for a money surface with no paper equivalent. +pub fn unsupported(what: &str, workspace: &str, mode: WorkspaceMode) -> WorkspaceError { + WorkspaceError::Unsupported { + what: what.to_string(), + workspace: workspace.to_string(), + mode, + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use rust_decimal_macros::dec; + + use super::*; + + fn manifest(allowed_pairs: Option>) -> WorkspaceManifest { + WorkspaceManifest { + workspace_version: crate::WORKSPACE_VERSION.to_string(), + cli_version: "0.0.0-test".to_string(), + name: "scalper".to_string(), + capital: dec!(1000), + currency: "USD".to_string(), + mode: WorkspaceMode::Paper, + fee_rate: dec!(0.0026), + slippage_rate: dec!(0), + allowed_pairs, + created_at: Utc::now(), + } + } + + #[test] + fn pair_allowed_when_no_allowlist_is_set() { + assert!(ensure_pair_allowed(&manifest(None), "SOL/USD").is_ok()); + } + + #[test] + fn explicit_empty_allowlist_denies_all_pairs() { + let err = ensure_pair_allowed(&manifest(Some(Vec::new())), "BTC/USD") + .expect_err("deny-all must refuse"); + assert_eq!(err.category(), "validation"); + assert!(err.to_string().contains("deny-all"), "{err}"); + } + + #[test] + fn pair_check_canonicalizes_before_comparing() { + // XBTUSD, btcusd, and BTC/USD are one pair in policy form. + let allowed = canonicalize_pairs(&["xbtusd".to_string(), "BTC/USD".to_string()]) + .expect("canonicalize"); + assert_eq!(allowed, vec!["BTC/USD".to_string()], "aliases collapse"); + let subject = manifest(Some(allowed)); + assert!(ensure_pair_allowed(&subject, &canonical_pair("btcusd").expect("valid")).is_ok()); + assert!(ensure_pair_allowed(&subject, &canonical_pair("ETH/USD").expect("valid")).is_err()); + } + + #[test] + fn live_mode_is_not_creatable_yet() { + let err = ensure_mode_creatable(WorkspaceMode::Live, "scalper").expect_err("refuse"); + assert!(err.to_string().contains("scoped credentials"), "{err}"); + } +} diff --git a/crates/kraken-workspace/src/promote.rs b/crates/kraken-workspace/src/promote.rs new file mode 100644 index 0000000..296100a --- /dev/null +++ b/crates/kraken-workspace/src/promote.rs @@ -0,0 +1,221 @@ +//! Mechanical paper-to-live promotion checks. +//! +//! Scoped credentials remain a hard blocker; refusals return the full evidence +//! checklist. + +use serde::Serialize; + +use crate::{WorkspaceManifest, WorkspaceMode}; + +/// One experiment's evidence, as `lab compare` graded it. +#[derive(Debug, Clone, Serialize)] +pub struct ExperimentEvidence { + pub experiment: String, + pub total_sessions: usize, + /// Runs whose sealed criteria passed mechanically. + pub passing_sessions: usize, + /// Passing runs that traded the live market — replay passes count only + /// as screening, never as the promotion-carrying evidence. + pub passing_live_sessions: usize, +} + +impl ExperimentEvidence { + /// Per experiment: at least two passes with a live + /// pass among them. Two replay passes never qualify. + fn qualifies(&self) -> bool { + self.passing_sessions >= 2 && self.passing_live_sessions >= 1 + } +} + +/// One line of the checklist: a criterion, whether the evidence satisfies +/// it, and the observation behind the verdict. +#[derive(Debug, Clone, Serialize)] +pub struct ChecklistItem { + pub criterion: String, + pub satisfied: bool, + pub detail: String, +} + +/// What the binary observed on disk; [`evaluate`] never reads anything. +#[derive(Debug, Clone, Default)] +pub struct PromotionInputs { + pub experiments: Vec, + /// Fills inside stopped windows without a decision-log entry. + pub manual_trades_in_windows: usize, +} + +/// The full evidence picture a promotion decision reads. +#[derive(Debug, Clone, Serialize)] +pub struct PromotionChecklist { + pub workspace: String, + pub mode: WorkspaceMode, + /// False until every criterion holds and no hard blocker remains. + pub promotable: bool, + pub criteria: Vec, + pub experiments: Vec, + pub manual_trades_in_windows: usize, + /// What no amount of evidence can satisfy yet. + pub blockers: Vec, +} + +/// Grade the evidence against the frozen gate. Pure — the caller gathers. +pub fn evaluate(manifest: &WorkspaceManifest, inputs: PromotionInputs) -> PromotionChecklist { + let PromotionInputs { + experiments, + manual_trades_in_windows, + } = inputs; + + let sealed = !experiments.is_empty(); + let best_passes = experiments + .iter() + .map(|e| e.passing_sessions) + .max() + .unwrap_or(0); + let qualifying = experiments.iter().find(|e| e.qualifies()); + + let criteria = vec![ + ChecklistItem { + criterion: "a sealed experiment exists".to_string(), + satisfied: sealed, + detail: if sealed { + format!("{} sealed", experiments.len()) + } else { + "none — freeze one with 'kraken lab new'".to_string() + }, + }, + ChecklistItem { + criterion: "an experiment has at least 2 passing sessions".to_string(), + satisfied: best_passes >= 2, + detail: format!("best experiment has {best_passes} passing session(s)"), + }, + ChecklistItem { + criterion: "its passes include a live session".to_string(), + satisfied: qualifying.is_some(), + detail: match qualifying { + Some(evidence) => format!( + "'{}': {} passes, {} live", + evidence.experiment, evidence.passing_sessions, evidence.passing_live_sessions + ), + None => { + "no experiment pairs 2 passes with a live pass — replay alone never promotes" + .to_string() + } + }, + }, + ChecklistItem { + criterion: "manual trades inside session windows are disclosed".to_string(), + // The gate requires disclosure, not zero manual activity. + satisfied: true, + detail: format!("{manual_trades_in_windows} manual trade(s) in stopped windows"), + }, + ChecklistItem { + criterion: "live credentials scoped to this workspace".to_string(), + satisfied: false, + detail: "workspace-scoped credentials are not configured".to_string(), + }, + ]; + + let blockers = vec![ + "live execution needs scoped credentials; until then promotion is \ + evaluated but never performed" + .to_string(), + ]; + + PromotionChecklist { + workspace: manifest.name.clone(), + mode: manifest.mode, + promotable: criteria.iter().all(|c| c.satisfied) && blockers.is_empty(), + criteria, + experiments, + manual_trades_in_windows, + blockers, + } +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + fn manifest() -> WorkspaceManifest { + WorkspaceManifest { + workspace_version: crate::WORKSPACE_VERSION.to_string(), + cli_version: "0.0.0-test".to_string(), + name: "btc-momentum".to_string(), + capital: dec!(10_000), + currency: "USD".to_string(), + mode: WorkspaceMode::Paper, + fee_rate: dec!(0.0026), + slippage_rate: dec!(0), + allowed_pairs: None, + created_at: "2026-01-01T00:00:00Z".parse().unwrap(), + } + } + + fn evidence(passing: usize, live: usize) -> ExperimentEvidence { + ExperimentEvidence { + experiment: "m1".to_string(), + total_sessions: passing, + passing_sessions: passing, + passing_live_sessions: live, + } + } + + #[test] + fn promotion_checklist_requires_a_passing_live_session() { + let checklist = evaluate( + &manifest(), + PromotionInputs { + experiments: vec![evidence(3, 1)], + manual_trades_in_windows: 0, + }, + ); + let live_item = &checklist.criteria[2]; + assert!(live_item.satisfied, "{live_item:?}"); + assert!( + !checklist.promotable, + "the credentials blocker holds regardless of evidence" + ); + } + + #[test] + fn two_replay_passes_do_not_promote() { + let checklist = evaluate( + &manifest(), + PromotionInputs { + experiments: vec![evidence(2, 0)], + manual_trades_in_windows: 0, + }, + ); + assert!(checklist.criteria[1].satisfied, "two passes exist"); + assert!( + !checklist.criteria[2].satisfied, + "without a live pass the gate must hold: {:?}", + checklist.criteria[2] + ); + } + + #[test] + fn manual_trades_inside_windows_are_disclosed_not_hidden() { + let checklist = evaluate( + &manifest(), + PromotionInputs { + experiments: vec![evidence(2, 1)], + manual_trades_in_windows: 3, + }, + ); + assert_eq!(checklist.manual_trades_in_windows, 3); + let disclosure = &checklist.criteria[3]; + assert!(disclosure.satisfied, "disclosure is the requirement"); + assert!(disclosure.detail.contains('3'), "{disclosure:?}"); + } + + #[test] + fn no_experiments_fail_the_first_criterion_with_the_freeze_hint() { + let checklist = evaluate(&manifest(), PromotionInputs::default()); + assert!(!checklist.criteria[0].satisfied); + assert!(checklist.criteria[0].detail.contains("kraken lab new")); + assert!(!checklist.promotable); + } +} diff --git a/crates/kraken-workspace/src/receipt.rs b/crates/kraken-workspace/src/receipt.rs new file mode 100644 index 0000000..e3a91ed --- /dev/null +++ b/crates/kraken-workspace/src/receipt.rs @@ -0,0 +1,114 @@ +//! One trading-response schema across paper and live execution. +//! +//! Stable identity stays in the flattened core; backend-specific data remains +//! in the optional `venue` and `paper` blocks. + +use kraken_core::OrderSide; +use rust_decimal::Decimal; +use serde::Serialize; +use serde_with::skip_serializing_none; + +/// Which backend executed the command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeMode { + Paper, + Live, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptStatus { + /// Resting on the book (or accepted by the venue without a fill yet). + Submitted, + Filled, + Cancelled, + /// `--validate`: checked, never executed. + Validated, +} + +/// The substitutable identity every trading response carries, mode-agnostic. +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct ReceiptCore { + pub mode: TradeMode, + pub status: ReceiptStatus, + pub order_id: Option, + pub pair: Option, + pub side: Option, + #[serde(rename = "type")] + pub order_type: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub volume: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub price: Option, +} + +/// One order command's receipt. +#[skip_serializing_none] +#[derive(Debug, Serialize)] +pub struct OrderReceipt { + #[serde(flatten)] + pub core: ReceiptCore, + /// The raw venue response, verbatim — live mode only, never parsed into + /// shape (prose fields like `descr` stay the venue's own). + pub venue: Option, + /// The simulator's fill economics — paper mode only, honest paper shapes. + pub paper: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct PaperFill { + pub trade_id: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub fee: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub cost: Option, +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn live_and_paper_receipts_share_one_core_shape() { + let core = |mode| ReceiptCore { + mode, + status: ReceiptStatus::Filled, + order_id: Some("X".into()), + pair: Some("BTCUSD".into()), + side: Some(OrderSide::Buy), + order_type: Some("market".into()), + volume: Some(dec!(0.01)), + price: Some(dec!(50000)), + }; + let paper = serde_json::to_value(OrderReceipt { + core: core(TradeMode::Paper), + venue: None, + paper: Some(PaperFill { + trade_id: Some("PAPER-00002".into()), + fee: Some(dec!(1.3)), + cost: Some(dec!(500)), + }), + }) + .expect("serialize"); + let live = serde_json::to_value(OrderReceipt { + core: core(TradeMode::Live), + venue: Some(serde_json::json!({"txid": ["X"]})), + paper: None, + }) + .expect("serialize"); + + // Scripts depend on these identity keys remaining mode-independent. + for key in [ + "mode", "status", "order_id", "pair", "side", "type", "volume", + ] { + assert!(paper.get(key).is_some(), "paper receipt lacks {key}"); + assert!(live.get(key).is_some(), "live receipt lacks {key}"); + } + assert_eq!(paper["volume"], "0.01", "money is a string on the wire"); + } +} diff --git a/crates/kraken-workspace/src/report.rs b/crates/kraken-workspace/src/report.rs new file mode 100644 index 0000000..fc83c4c --- /dev/null +++ b/crates/kraken-workspace/src/report.rs @@ -0,0 +1,321 @@ +//! Workspace totals assembled from authoritative session outcomes. +//! +//! Unscored sessions remain error rows rather than contributing invented zeros. + +use rust_decimal::Decimal; +use serde::Serialize; +use serde_with::skip_serializing_none; + +use crate::session::{DriftLine, SessionStatus}; +use crate::{WorkspaceManifest, WorkspaceMode}; + +/// One session's line of the report. Money fields are `None` exactly when +/// `error` says why — a row never carries invented numbers. +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct SessionRow { + pub session: String, + pub label: Option, + pub experiment: Option, + pub status: SessionStatus, + /// The scorecard anchor, verbatim: `starting_balance` / `final_value`. + #[serde(with = "rust_decimal::serde::str_option")] + pub opening_equity: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub closing_equity: Option, + #[serde(with = "rust_decimal::serde::str_option")] + pub pnl: Option, + pub fills: Option, + /// Fills inside the window without a decision-log entry. + pub manual_trades: Option, + /// The mechanical verdict, when the session is stamped with an experiment. + pub verdict: Option, + /// Why this row carries no numbers. + pub error: Option, +} + +/// Trading outside any stopped session window — disclosed, never summed into a +/// run. +#[derive(Debug, Clone, Serialize)] +pub struct ManualSegment { + pub fills: usize, +} + +/// What the binary observed; [`assemble`] only sums and discloses. +#[derive(Debug, Clone, Default)] +pub struct ReportInputs { + pub sessions: Vec, + /// The run still recording, excluded from every sum. + pub running: Option, + pub manual_fills: usize, + pub reset_epochs: usize, + /// Current equity as the caller valued it (the crate stays + /// network-free): `(value, complete)`. + pub equity: Option<(Decimal, bool)>, + /// Folded balances vs the last `Reconciled` snapshot (live journals). + pub drift: Option>, +} + +/// The whole picture, JSON payload = this type verbatim. +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceReport { + pub workspace: String, + pub mode: WorkspaceMode, + pub currency: String, + #[serde(with = "rust_decimal::serde::str")] + pub capital: Decimal, + #[serde(with = "rust_decimal::serde::str_option")] + pub equity: Option, + pub valuation_complete: bool, + /// The Decimal sum of the emitted run rows' `pnl` — nothing else. + #[serde(with = "rust_decimal::serde::str")] + pub sessions_pnl: Decimal, + pub reset_epochs: usize, + pub sessions: Vec, + pub manual: ManualSegment, + /// Disclosed AND excluded from every sum. + pub running: Option, + pub drift: Option>, + pub caveats: Vec, +} + +/// The one place sums happen. Totals are Decimal sums of the rows this +/// report emits; error rows contribute a caveat instead of a number. +pub fn assemble(manifest: &WorkspaceManifest, inputs: ReportInputs) -> WorkspaceReport { + let ReportInputs { + sessions, + running, + manual_fills, + reset_epochs, + equity, + drift, + } = inputs; + + let sessions_pnl: Decimal = sessions.iter().filter_map(|row| row.pnl).sum(); + + let mut caveats = Vec::new(); + let unscored = sessions.iter().filter(|row| row.error.is_some()).count(); + if unscored > 0 { + caveats.push(format!( + "{unscored} session(s) carry no numbers (see their error field); sessions_pnl sums only \ + the scored rows" + )); + } + if let Some(run) = &running { + caveats.push(format!( + "session {run} is still recording; it is excluded from every sum" + )); + } + if manual_fills > 0 { + caveats.push(format!( + "{manual_fills} fill(s) happened outside any stopped session window" + )); + } + if reset_epochs > 0 { + caveats.push(format!( + "{reset_epochs} reset epoch(s): equity is measured from the newest epoch's capital" + )); + } + let (equity, valuation_complete) = match equity { + Some((value, complete)) => (Some(value), complete), + None => { + caveats.push("current equity unavailable (valuation failed)".to_string()); + (None, false) + } + }; + if let Some(lines) = &drift + && !lines.is_empty() + { + caveats.push(format!( + "{} asset(s) drift from the last venue reconciliation", + lines.len() + )); + } + + WorkspaceReport { + workspace: manifest.name.clone(), + mode: manifest.mode, + currency: manifest.currency.clone(), + capital: manifest.capital, + equity, + valuation_complete, + sessions_pnl, + reset_epochs, + sessions, + manual: ManualSegment { + fills: manual_fills, + }, + running, + drift, + caveats, + } +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + fn manifest() -> WorkspaceManifest { + WorkspaceManifest { + workspace_version: crate::WORKSPACE_VERSION.to_string(), + cli_version: "0.0.0-test".to_string(), + name: "btc-momentum".to_string(), + capital: dec!(10_000), + currency: "USD".to_string(), + mode: WorkspaceMode::Paper, + fee_rate: dec!(0.0026), + slippage_rate: dec!(0), + allowed_pairs: None, + created_at: "2026-01-01T00:00:00Z".parse().unwrap(), + } + } + + fn scored_row(run: &str, pnl: Decimal) -> SessionRow { + SessionRow { + session: run.to_string(), + label: None, + experiment: None, + status: SessionStatus::Stopped, + opening_equity: Some(dec!(10_000)), + closing_equity: Some(dec!(10_000) + pnl), + pnl: Some(pnl), + fills: Some(1), + manual_trades: Some(0), + verdict: None, + error: None, + } + } + + fn error_row(run: &str) -> SessionRow { + SessionRow { + session: run.to_string(), + label: None, + experiment: None, + status: SessionStatus::Aborted, + opening_equity: None, + closing_equity: None, + pnl: None, + fills: None, + manual_trades: None, + verdict: None, + error: Some("validation: session 's2' has no final summary to anchor on; run 'kraken session stop' first".to_string()), + } + } + + /// The report law: the total is the Decimal sum of the emitted rows + /// — digit-exact through a serde round trip. + #[test] + fn report_totals_are_the_decimal_sum_of_emitted_rows() { + let report = assemble( + &manifest(), + ReportInputs { + sessions: vec![ + scored_row("r1", dec!(-0.1675346)), + scored_row("r3", dec!(0.0852500)), + ], + equity: Some((dec!(9_999.9177154), true)), + ..ReportInputs::default() + }, + ); + assert_eq!(report.sessions_pnl, dec!(-0.0822846)); + + let json = serde_json::to_value(&report).unwrap(); + let summed: Decimal = json["sessions"] + .as_array() + .unwrap() + .iter() + .map(|row| row["pnl"].as_str().unwrap().parse::().unwrap()) + .sum(); + assert_eq!( + json["sessions_pnl"].as_str().unwrap(), + summed.to_string(), + "the wire total re-sums byte-equal from the wire rows" + ); + } + + #[test] + fn error_rows_never_fake_a_zero_and_are_caveated() { + let report = assemble( + &manifest(), + ReportInputs { + sessions: vec![scored_row("r1", dec!(5)), error_row("r2")], + equity: Some((dec!(10_005), true)), + ..ReportInputs::default() + }, + ); + assert_eq!( + report.sessions_pnl, + dec!(5), + "error rows contribute nothing" + ); + assert!(report.sessions[1].pnl.is_none()); + assert!( + report + .caveats + .iter() + .any(|c| c.contains("carry no numbers")), + "{:?}", + report.caveats + ); + } + + #[test] + fn running_session_is_disclosed_and_excluded_from_sums() { + let report = assemble( + &manifest(), + ReportInputs { + sessions: vec![scored_row("r1", dec!(1))], + running: Some("r2".to_string()), + equity: Some((dec!(10_001), true)), + ..ReportInputs::default() + }, + ); + assert_eq!(report.running.as_deref(), Some("r2")); + assert_eq!(report.sessions_pnl, dec!(1)); + assert!( + report.caveats.iter().any(|c| c.contains("still recording")), + "{:?}", + report.caveats + ); + } + + #[test] + fn drift_lines_surface_reconciled_deltas() { + let report = assemble( + &manifest(), + ReportInputs { + equity: Some((dec!(10_000), true)), + drift: Some(vec![DriftLine { + asset: "BTC".to_string(), + local: dec!(1), + venue: dec!(0.9), + }]), + ..ReportInputs::default() + }, + ); + assert!( + report.caveats.iter().any(|c| c.contains("drift")), + "{:?}", + report.caveats + ); + assert_eq!(report.drift.as_ref().unwrap()[0].asset, "BTC"); + } + + #[test] + fn failed_valuation_is_disclosed_never_zeroed() { + let report = assemble(&manifest(), ReportInputs::default()); + assert!(report.equity.is_none()); + assert!(!report.valuation_complete); + assert!( + report + .caveats + .iter() + .any(|c| c.contains("equity unavailable")), + "{:?}", + report.caveats + ); + } +} diff --git a/crates/kraken-workspace/src/session.rs b/crates/kraken-workspace/src/session.rs new file mode 100644 index 0000000..197ba3b --- /dev/null +++ b/crates/kraken-workspace/src/session.rs @@ -0,0 +1,495 @@ +//! Run policy over a scope — the directory holding a `journal.jsonl`, be it +//! a workspace or the config root for the real account. The artifact itself +//! (layout, `session.json`, the windowed reader) lives in `kraken_session::run`; +//! here is what the artifact cannot know: how ordinals allocate, what a session +//! ref resolves to, which run is active, and the pure reconciliation core +//! that heals a live journal from venue history. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt; +use std::path::Path; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use kraken_core::OrderSide; +use kraken_paper::{BALANCE_DUST, PaperTrade}; +use kraken_session::manifest::SessionManifest; +use kraken_session::session::{parse_ordinal, session_dir, session_file_path, sessions_root}; +use rust_decimal::Decimal; +use serde::Serialize; + +use crate::{Result, WorkspaceError}; + +/// A session's identity within its scope. Displays — and serializes — as the +/// directory name `s`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SessionId(u32); + +impl SessionId { + pub fn ordinal(self) -> u32 { + self.0 + } +} + +impl fmt::Display for SessionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "s{}", self.0) + } +} + +impl Serialize for SessionId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +/// How a `--session` argument addresses a session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionRefSpec { + /// The highest ordinal on disk — the default everywhere. + Latest, + Ordinal(u32), + Label(String), +} + +impl FromStr for SessionRefSpec { + type Err = std::convert::Infallible; + + /// Never fails: `latest` and `s` have fixed meanings, anything else + /// is a label lookup (which fails later, with the sessions listed). + fn from_str(s: &str) -> Result { + Ok(match s { + "latest" => Self::Latest, + other => match parse_ordinal(other) { + Some(n) => Self::Ordinal(n), + None => Self::Label(other.to_string()), + }, + }) + } +} + +impl fmt::Display for SessionRefSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Latest => write!(f, "latest"), + Self::Ordinal(n) => write!(f, "s{n}"), + Self::Label(label) => write!(f, "{label}"), + } + } +} + +/// Liveness derived at read time, never stored: `session.json` says whether the +/// window closed; the recording lock says whether a recorder still writes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, strum::Display)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum SessionStatus { + /// Window open, recorder lock held. + Recording, + /// Window closed by `session stop`. + Stopped, + /// Window open but no recorder holds the tape — a crash or kill. Never + /// blocks the next `run start`. + Aborted, + /// An unreadable `session.json`; described, never dropped from the listing. + Damaged, +} + +/// One `run list` row. +#[derive(Debug, Clone, Serialize)] +pub struct SessionRecord { + pub session: SessionId, + pub status: SessionStatus, + pub label: Option, + pub experiment: Option, + /// The replay source ref, when the session replayed a tape. + pub source: Option, + pub started_at: Option>, + pub ended_at: Option>, + /// What is wrong, for a damaged row. + pub note: Option, +} + +/// Allocate the next session: `max(s) + 1`, never reusing an ordinal (no +/// command deletes a session directory). Refuses while a session is still recording, +/// and refuses a label another run already carries. +/// +/// Caller contract: hold the scope journal's writer lock (the open +/// `PaperAccount`) across allocate → provision → start-marker append. That +/// lock is what serializes two concurrent `run start`s — this function only +/// scans. +pub fn allocate(scope: &Path, label: Option<&str>) -> Result { + if let Some((active, _)) = active(scope)? { + return Err(WorkspaceError::SessionActive { + session: active.to_string(), + }); + } + let runs = list(scope)?; + if let Some(label) = label + && let Some(taken) = runs.iter().find(|r| r.label.as_deref() == Some(label)) + { + return Err(WorkspaceError::LabelTaken { + label: label.to_string(), + session: taken.session.to_string(), + }); + } + let next = runs.iter().map(|r| r.session.0).max().unwrap_or(0) + 1; + Ok(SessionId(next)) +} + +/// Every run in the scope, ordinal-sorted (numerically — `r10` after `r2`). +/// A damaged `session.json` becomes a described row, never a failed listing. +pub fn list(scope: &Path) -> Result> { + let entries = match std::fs::read_dir(sessions_root(scope)) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut runs = Vec::new(); + for entry in entries { + let entry = entry?; + let Some(ordinal) = entry.file_name().to_str().and_then(parse_ordinal) else { + continue; + }; + runs.push(record(scope, SessionId(ordinal))); + } + runs.sort_by_key(|r| r.session.0); + Ok(runs) +} + +fn record(scope: &Path, run: SessionId) -> SessionRecord { + match SessionManifest::load(&session_file_path(scope, run.0)) { + Ok(manifest) => SessionRecord { + session: run, + status: status_of(scope, run, &manifest), + label: manifest.label.clone(), + experiment: manifest.experiment.clone(), + source: manifest.source.as_ref().map(|s| s.tape.clone()), + started_at: Some(manifest.window.started_at), + ended_at: manifest.window.ended_at, + note: None, + }, + Err(err) => SessionRecord { + session: run, + status: SessionStatus::Damaged, + label: None, + experiment: None, + source: None, + started_at: None, + ended_at: None, + note: Some(err.to_string()), + }, + } +} + +/// Resolve a session ref to its contract. `Latest` with no runs, an unknown +/// ordinal, or an unknown label all name what WAS addressable. +pub fn resolve(scope: &Path, spec: &SessionRefSpec) -> Result<(SessionId, SessionManifest)> { + let load = |run: SessionId| -> Result<(SessionId, SessionManifest)> { + Ok(( + run, + SessionManifest::load(&session_file_path(scope, run.0))?, + )) + }; + match spec { + SessionRefSpec::Ordinal(n) => { + if !session_file_path(scope, *n).exists() { + return Err(not_found(scope, spec)); + } + load(SessionId(*n)) + } + SessionRefSpec::Latest => { + let latest = list(scope)?.into_iter().map(|r| r.session).max(); + match latest { + Some(run) => load(run), + None => Err(not_found(scope, spec)), + } + } + SessionRefSpec::Label(label) => { + let runs = list(scope)?; + let hit = runs + .iter() + .find(|r| r.label.as_deref() == Some(label.as_str())); + match hit { + Some(row) => load(row.session), + None => Err(not_found(scope, spec)), + } + } + } +} + +fn not_found(scope: &Path, spec: &SessionRefSpec) -> WorkspaceError { + let known = list(scope) + .map(|runs| { + runs.iter() + .map(|r| match &r.label { + Some(label) => format!("{} ({label})", r.session), + None => r.session.to_string(), + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + WorkspaceError::SessionNotFound { + reference: spec.to_string(), + known: if known.is_empty() { + "none yet — start one with 'kraken session start'".to_string() + } else { + known + }, + } +} + +/// The run still recording in this scope, if any: window open AND a recorder +/// holding one of the session's tape locks. Aborted runs (open window, dead +/// recorder) never count — a crash must not block the next start. +pub fn active(scope: &Path) -> Result> { + for row in list(scope)?.into_iter().rev() { + if row.status != SessionStatus::Recording { + continue; + } + let manifest = SessionManifest::load(&session_file_path(scope, row.session.0))?; + return Ok(Some((row.session, manifest))); + } + Ok(None) +} + +fn status_of(scope: &Path, run: SessionId, manifest: &SessionManifest) -> SessionStatus { + if manifest.window.ended_at.is_some() { + return SessionStatus::Stopped; + } + let dir = session_dir(scope, run.0); + let alive = manifest + .recordings + .iter() + .any(|rec| kraken_recording::is_lock_held(&dir.join(&rec.file))); + if alive { + SessionStatus::Recording + } else { + SessionStatus::Aborted + } +} + +/// One venue trade as `session stop` reconciliation consumes it — parsed by the +/// binary from `TradesHistory`, joined here by txid. +#[derive(Debug, Clone)] +pub struct VenueFill { + /// The venue trade txid — becomes `PaperTrade.id`, the idempotency key. + pub txid: String, + /// The venue order txid — becomes `PaperTrade.order_id`, the same key + /// the live mirror stamps at submission. + pub order_txid: String, + pub pair: String, + pub side: OrderSide, + pub volume: Decimal, + pub price: Decimal, + pub fee: Decimal, + pub cost: Decimal, + pub time: DateTime, +} + +/// The venue fills the journal has not seen: keyed on venue txid == +/// `PaperTrade.id`, so appending the result and reconciling again yields +/// nothing — a second `session stop` appends nothing. +pub fn missing_fills<'v>(known: &[PaperTrade], venue: &'v [VenueFill]) -> Vec<&'v VenueFill> { + let seen: HashSet<&str> = known.iter().map(|t| t.id.as_str()).collect(); + venue + .iter() + .filter(|fill| !seen.contains(fill.txid.as_str())) + .collect() +} + +/// One asset whose folded balance disagrees with the venue beyond dust. +#[derive(Debug, Clone, Serialize)] +pub struct DriftLine { + pub asset: String, + #[serde(with = "rust_decimal::serde::str")] + pub local: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub venue: Decimal, +} + +/// Folded state vs a venue snapshot, both sides' assets, dust-tolerant so +/// float-era residue never reads as drift. +pub fn drift( + local: &HashMap, + venue: &BTreeMap, +) -> Vec { + let mut assets: Vec<&String> = local.keys().chain(venue.keys()).collect(); + assets.sort(); + assets.dedup(); + assets + .into_iter() + .filter_map(|asset| { + let ours = local.get(asset).copied().unwrap_or(Decimal::ZERO); + let theirs = venue.get(asset).copied().unwrap_or(Decimal::ZERO); + ((ours - theirs).abs() > BALANCE_DUST).then(|| DriftLine { + asset: asset.clone(), + local: ours, + venue: theirs, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use kraken_session::manifest::{PaperRef, SessionManifest, SessionWindow}; + use rust_decimal_macros::dec; + + use super::*; + + fn write_session(scope: &Path, ordinal: u32, label: Option<&str>, stopped: bool) { + let manifest = SessionManifest::new( + format!("s{ordinal}").parse().unwrap(), + "0.0.0-test".to_string(), + 4242, + Vec::new(), + PaperRef { + starting_balance: dec!(10_000), + currency: "USD".to_string(), + }, + SessionWindow { + session: format!("s{ordinal}"), + started_at: "2026-01-01T00:00:00Z".parse().unwrap(), + opening_equity: dec!(10_000), + opening_complete: true, + ended_at: stopped.then(|| "2026-01-01T01:00:00Z".parse().unwrap()), + }, + ) + .with_label(label.map(str::to_string)); + manifest + .save(&session_file_path(scope, ordinal)) + .expect("fixture session.json"); + } + + #[test] + fn ordinals_allocate_max_plus_one_and_never_reuse() { + let scope = tempfile::tempdir().unwrap(); + assert_eq!(allocate(scope.path(), None).unwrap().to_string(), "s1"); + write_session(scope.path(), 1, None, true); + write_session(scope.path(), 7, None, true); + assert_eq!( + allocate(scope.path(), None).unwrap().to_string(), + "s8", + "gaps stay gaps; the max drives the next ordinal" + ); + } + + #[test] + fn allocate_refuses_a_taken_label() { + let scope = tempfile::tempdir().unwrap(); + write_session(scope.path(), 1, Some("momentum-r1"), true); + let err = allocate(scope.path(), Some("momentum-r1")).unwrap_err(); + assert!(matches!(err, WorkspaceError::LabelTaken { .. }), "{err}"); + } + + #[test] + fn a_crashed_session_never_blocks_the_next_start() { + let scope = tempfile::tempdir().unwrap(); + // Open window, no recorder lock: aborted, not active. + write_session(scope.path(), 1, None, false); + assert!(active(scope.path()).unwrap().is_none()); + assert_eq!(allocate(scope.path(), None).unwrap().to_string(), "s2"); + } + + #[test] + fn sessions_sort_numerically_not_lexically() { + let scope = tempfile::tempdir().unwrap(); + for n in [1, 2, 10] { + write_session(scope.path(), n, None, true); + } + let order: Vec = list(scope.path()) + .unwrap() + .iter() + .map(|r| r.session.to_string()) + .collect(); + assert_eq!(order, ["s1", "s2", "s10"]); + } + + #[test] + fn resolve_latest_ordinal_and_label() { + let scope = tempfile::tempdir().unwrap(); + write_session(scope.path(), 1, Some("first"), true); + write_session(scope.path(), 2, None, true); + let (latest, _) = resolve(scope.path(), &SessionRefSpec::Latest).unwrap(); + assert_eq!(latest.to_string(), "s2"); + let (by_ordinal, _) = resolve(scope.path(), &SessionRefSpec::Ordinal(1)).unwrap(); + assert_eq!(by_ordinal.to_string(), "s1"); + let (by_label, _) = + resolve(scope.path(), &SessionRefSpec::Label("first".to_string())).unwrap(); + assert_eq!(by_label.to_string(), "s1"); + let err = resolve(scope.path(), &SessionRefSpec::Label("ghost".to_string())).unwrap_err(); + assert!( + err.to_string().contains("s1 (first)"), + "the refusal lists what exists: {err}" + ); + } + + #[test] + fn a_damaged_session_json_is_a_described_row_never_a_failed_listing() { + let scope = tempfile::tempdir().unwrap(); + write_session(scope.path(), 1, None, true); + std::fs::create_dir_all(session_dir(scope.path(), 2)).unwrap(); + std::fs::write(session_file_path(scope.path(), 2), "{ not json").unwrap(); + let runs = list(scope.path()).unwrap(); + assert_eq!(runs.len(), 2); + assert_eq!(runs[1].status, SessionStatus::Damaged); + assert!(runs[1].note.is_some()); + } + + #[test] + fn reconcile_twice_appends_nothing() { + let venue = vec![VenueFill { + txid: "TVENUE-1".to_string(), + order_txid: "OVENUE-1".to_string(), + pair: "BTCUSD".to_string(), + side: OrderSide::Buy, + volume: dec!(0.1), + price: dec!(50_000), + fee: dec!(13), + cost: dec!(5_000), + time: "2026-01-01T00:30:00Z".parse().unwrap(), + }]; + assert_eq!(missing_fills(&[], &venue).len(), 1); + + // After the first reconcile the fill is on the journal under the + // venue txid — the second pass must see nothing. + let folded = vec![PaperTrade { + id: "TVENUE-1".to_string(), + order_id: "OVENUE-1".to_string(), + pair: "BTCUSD".to_string(), + base: "BTC".to_string(), + quote: "USD".to_string(), + side: OrderSide::Buy, + volume: dec!(0.1), + price: dec!(50_000), + fee: dec!(13), + cost: dec!(5_000), + filled_at: "2026-01-01T00:30:00Z".parse().unwrap(), + reference_quote: None, + }]; + assert!(missing_fills(&folded, &venue).is_empty()); + } + + #[test] + fn drift_ignores_dust_and_reports_both_sides() { + let local: HashMap = + [("USD".to_string(), dec!(100)), ("BTC".to_string(), dec!(1))] + .into_iter() + .collect(); + let venue: BTreeMap = [ + ("USD".to_string(), dec!(100.0000000000001)), + ("ETH".to_string(), dec!(2)), + ] + .into_iter() + .collect(); + let lines = drift(&local, &venue); + let assets: Vec<&str> = lines.iter().map(|l| l.asset.as_str()).collect(); + assert_eq!( + assets, + ["BTC", "ETH"], + "USD residue is dust, both-side assets report" + ); + } +} diff --git a/crates/kraken-workspace/src/store.rs b/crates/kraken-workspace/src/store.rs new file mode 100644 index 0000000..bf05dfa --- /dev/null +++ b/crates/kraken-workspace/src/store.rs @@ -0,0 +1,225 @@ +//! Filesystem and clock boundary behind [`crate::Workspaces`]. + +use std::path::{Path, PathBuf}; + +use chrono::Utc; + +use crate::manifest::{WORKSPACE_FILE, WORKSPACE_VERSION, WorkspaceManifest, is_compatible}; +use crate::naming::WorkspaceName; +use crate::{CreateSpec, Result, WorkspaceError, workspace_dir, workspaces_root}; + +/// The contract file: `/workspaces//workspace.json`. +pub fn manifest_path(base: &Path, name: &WorkspaceName) -> PathBuf { + workspace_dir(base, name).join(WORKSPACE_FILE) +} + +/// The account journal: `/workspaces//journal.jsonl`. +pub fn journal_path(base: &Path, name: &WorkspaceName) -> PathBuf { + workspace_dir(base, name).join(crate::manifest::JOURNAL_FILE) +} + +/// Writes a contract once, stamping storage-owned time and version. +pub(crate) fn create(base: &Path, spec: &CreateSpec) -> Result { + let dir = workspace_dir(base, &spec.name); + if dir.exists() { + return Err(WorkspaceError::Exists(spec.name.to_string())); + } + let manifest = WorkspaceManifest { + workspace_version: WORKSPACE_VERSION.to_string(), + // Workspace-unified versions make this the CLI version. + cli_version: env!("CARGO_PKG_VERSION").to_string(), + name: spec.name.to_string(), + capital: spec.capital, + currency: spec.currency.clone(), + mode: spec.mode, + fee_rate: spec.fee_rate, + slippage_rate: spec.slippage_rate, + allowed_pairs: spec.allowed_pairs.clone(), + created_at: Utc::now(), + }; + kraken_recording::write_json_atomic(&manifest_path(base, &spec.name), &manifest)?; + Ok(manifest) +} + +/// Loads a contract, preserving distinct routing for absence, damage, and version mismatch. +pub(crate) fn load(base: &Path, name: &WorkspaceName) -> Result { + let path = manifest_path(base, name); + let data = match std::fs::read_to_string(&path) { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(WorkspaceError::NotFound(name.to_string())); + } + Err(err) => return Err(err.into()), + }; + let manifest: WorkspaceManifest = + serde_json::from_str(&data).map_err(|err| WorkspaceError::Damaged { + name: name.to_string(), + what: err.to_string(), + })?; + if !is_compatible(&manifest.workspace_version) { + return Err(WorkspaceError::Incompatible { + found: manifest.workspace_version, + expected: WORKSPACE_VERSION.to_string(), + }); + } + if manifest.name != name.as_ref() { + return Err(WorkspaceError::Damaged { + name: name.to_string(), + what: format!("contract names workspace '{}'", manifest.name), + }); + } + Ok(manifest) +} + +/// Remove a just-created contract after a failed funding step, so `create` +/// never leaves a workspace that exists but holds no account. Best-effort: +/// the funding error is the one the caller surfaces. +pub(crate) fn discard_created(base: &Path, name: &WorkspaceName) { + let dir = workspace_dir(base, name); + if let Err(err) = std::fs::remove_file(manifest_path(base, name)) { + tracing::warn!(workspace = %name, %err, "could not discard the contract of a failed create"); + } + if let Err(err) = std::fs::remove_dir(&dir) { + tracing::warn!(workspace = %name, %err, "could not remove the dir of a failed create"); + } +} + +/// The gate for scoping into a named workspace: a typo, a never-created name, +/// or a damaged contract must refuse rather than silently trade the wrong +/// account. +pub(crate) fn ensure_exists(base: &Path, name: &WorkspaceName) -> Result<()> { + load(base, name).map(drop) +} + +/// Every named workspace, sorted by name. A damaged contract is a described +/// row — one broken workspace cannot hide the others. +pub(crate) fn list(base: &Path) -> Result)>> { + let entries = match std::fs::read_dir(workspaces_root(base)) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err.into()), + }; + let mut rows = Vec::new(); + for entry in entries { + let entry = entry?; + if !entry.path().is_dir() { + continue; + } + // Only valid names can be addressed by --workspace; foreign dirs are + // not workspaces. + let raw = entry.file_name(); + let Ok(name) = raw.to_string_lossy().parse::() else { + continue; + }; + let manifest = load(base, &name); + rows.push((name, manifest)); + } + rows.sort_unstable_by(|a, b| a.0.as_ref().cmp(b.0.as_ref())); + Ok(rows) +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + use crate::manifest::WorkspaceMode; + + fn spec(name: &str) -> CreateSpec { + CreateSpec { + name: name.parse().expect("valid name"), + capital: dec!(10000), + currency: "USD".to_string(), + mode: WorkspaceMode::Paper, + fee_rate: dec!(0.0026), + slippage_rate: dec!(0), + allowed_pairs: None, + } + } + + #[test] + fn unknown_workspace_is_not_found_with_the_create_hint() { + let base = tempfile::tempdir().expect("tempdir"); + let name: WorkspaceName = "ghost".parse().expect("valid name"); + let err = ensure_exists(base.path(), &name).expect_err("must refuse"); + assert_eq!(err.category(), "validation"); + assert!( + err.to_string() + .contains("kraken workspace create ghost --capital"), + "{err}" + ); + } + + #[test] + fn create_refuses_a_second_workspace_of_the_same_name() { + let base = tempfile::tempdir().expect("tempdir"); + create(base.path(), &spec("btc")).expect("first create"); + let err = create(base.path(), &spec("btc")).expect_err("must refuse"); + assert!(matches!(err, WorkspaceError::Exists(_)), "{err}"); + } + + #[test] + fn damaged_manifest_fails_the_existence_gate_loud() { + let base = tempfile::tempdir().expect("tempdir"); + let name: WorkspaceName = "broken".parse().expect("valid name"); + std::fs::create_dir_all(workspace_dir(base.path(), &name)).expect("mkdir"); + std::fs::write(manifest_path(base.path(), &name), "not json").expect("write"); + let err = ensure_exists(base.path(), &name).expect_err("must refuse"); + assert_eq!(err.category(), "parse"); + } + + #[test] + fn newer_major_manifest_is_incompatible() { + let base = tempfile::tempdir().expect("tempdir"); + let name: WorkspaceName = "future".parse().expect("valid name"); + let manifest = create(base.path(), &spec("future")).expect("create"); + let mut raw = serde_json::to_value(&manifest).expect("to json"); + raw["workspace_version"] = "2.0".into(); + kraken_recording::write_json_atomic(&manifest_path(base.path(), &name), &raw) + .expect("rewrite"); + let err = load(base.path(), &name).expect_err("must refuse"); + assert_eq!(err.category(), "config"); + } + + #[test] + fn contract_naming_another_workspace_is_damaged() { + let base = tempfile::tempdir().expect("tempdir"); + create(base.path(), &spec("real")).expect("create"); + let real: WorkspaceName = "real".parse().expect("valid name"); + let copied: WorkspaceName = "copied".parse().expect("valid name"); + std::fs::create_dir_all(workspace_dir(base.path(), &copied)).expect("mkdir"); + std::fs::copy( + manifest_path(base.path(), &real), + manifest_path(base.path(), &copied), + ) + .expect("copy"); + let err = load(base.path(), &copied).expect_err("must refuse"); + assert_eq!(err.category(), "parse"); + } + + #[test] + fn unknown_manifest_keys_are_tolerated() { + let base = tempfile::tempdir().expect("tempdir"); + let name: WorkspaceName = "fwd".parse().expect("valid name"); + let manifest = create(base.path(), &spec("fwd")).expect("create"); + let mut raw = serde_json::to_value(&manifest).expect("to json"); + raw["credential_profile"] = "future-profile".into(); + kraken_recording::write_json_atomic(&manifest_path(base.path(), &name), &raw) + .expect("rewrite"); + assert!(load(base.path(), &name).is_ok()); + } + + #[test] + fn one_damaged_workspace_never_hides_the_others() { + let base = tempfile::tempdir().expect("tempdir"); + create(base.path(), &spec("healthy")).expect("create"); + let broken: WorkspaceName = "broken".parse().expect("valid name"); + std::fs::create_dir_all(workspace_dir(base.path(), &broken)).expect("mkdir"); + std::fs::write(manifest_path(base.path(), &broken), "not json").expect("write"); + + let rows = list(base.path()).expect("list"); + assert_eq!(rows.len(), 2); + assert!(rows[0].1.is_err(), "broken sorts first and is described"); + assert!(rows[1].1.is_ok()); + } +} diff --git a/crates/kraken-workspace/tests/snapshots.rs b/crates/kraken-workspace/tests/snapshots.rs new file mode 100644 index 0000000..b92828b --- /dev/null +++ b/crates/kraken-workspace/tests/snapshots.rs @@ -0,0 +1,178 @@ +//! Snapshot pins for every serialized workspace contract. These wire shapes +//! are what agents and scripts parse; a diff here is a deliberate contract +//! change, reviewed via `cargo insta review`, never an accident. + +#![allow(clippy::unwrap_used)] + +use chrono::{TimeZone, Utc}; +use insta::assert_json_snapshot; +use kraken_workspace::receipt::{OrderReceipt, PaperFill, ReceiptCore, ReceiptStatus, TradeMode}; +use kraken_workspace::{ + AssetBalance, ListedWorkspace, WORKSPACE_VERSION, WorkspaceBalances, WorkspaceManifest, + WorkspaceMode, WorkspaceOverview, +}; +use rust_decimal_macros::dec; + +fn manifest() -> WorkspaceManifest { + WorkspaceManifest { + workspace_version: WORKSPACE_VERSION.to_string(), + cli_version: "0.0.0-test".to_string(), + name: "btc-momentum".to_string(), + capital: dec!(10000), + currency: "USD".to_string(), + mode: WorkspaceMode::Paper, + fee_rate: dec!(0.0026), + slippage_rate: dec!(0), + allowed_pairs: Some(vec!["BTC/USD".to_string(), "ETH/USD".to_string()]), + created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + } +} + +#[test] +fn workspace_manifest_wire_shape_is_pinned() { + assert_json_snapshot!(manifest()); +} + +/// The 1.0 key set, as a literal: an accidental rename or reorder of the +/// serialized fields must fail loud, independent of the snapshot file. +#[test] +fn manifest_1_0_keys_are_pinned() { + let value = serde_json::to_value(manifest()).unwrap(); + let keys: Vec<&str> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + [ + "allowed_pairs", + "capital", + "cli_version", + "created_at", + "currency", + "fee_rate", + "mode", + "name", + "slippage_rate", + "workspace_version", + ] + ); +} + +#[test] +fn listed_workspaces_include_the_synthesized_default_row() { + let rows = vec![ + ListedWorkspace { + name: "default".to_string(), + mode: WorkspaceMode::Live, + capital: None, + currency: None, + created_at: None, + damaged: None, + }, + ListedWorkspace { + name: "btc-momentum".to_string(), + mode: WorkspaceMode::Paper, + capital: Some(dec!(10000)), + currency: Some("USD".to_string()), + created_at: Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()), + damaged: None, + }, + ]; + assert_json_snapshot!(rows); +} + +#[test] +fn workspace_overview_wire_shape_is_pinned() { + let overview: WorkspaceOverview = manifest().into(); + assert_json_snapshot!(overview); +} + +#[test] +fn balances_wire_shape_is_pinned() { + let balances = WorkspaceBalances { + workspace: "btc-momentum".to_string(), + mode: WorkspaceMode::Paper, + balances: [ + ( + "USD".to_string(), + AssetBalance { + total: dec!(9500.50), + reserved: dec!(120.25), + available: dec!(9380.25), + }, + ), + ( + "BTC".to_string(), + AssetBalance { + total: dec!(0.01), + reserved: dec!(0), + available: dec!(0.01), + }, + ), + ] + .into_iter() + .collect(), + }; + assert_json_snapshot!(balances); +} + +fn receipt_core(mode: TradeMode, status: ReceiptStatus) -> ReceiptCore { + ReceiptCore { + mode, + status, + order_id: Some("OTEST1-RCPT0-000001".to_string()), + pair: Some("BTC/USD".to_string()), + side: Some(kraken_core::OrderSide::Buy), + order_type: Some("limit".to_string()), + volume: Some(dec!(0.01)), + price: Some(dec!(50000)), + } +} + +#[test] +fn paper_order_receipt_wire_shape_is_pinned() { + assert_json_snapshot!(OrderReceipt { + core: receipt_core(TradeMode::Paper, ReceiptStatus::Filled), + venue: None, + paper: Some(PaperFill { + trade_id: Some("PAPER-00002".to_string()), + fee: Some(dec!(1.3)), + cost: Some(dec!(500)), + }), + }); +} + +#[test] +fn live_order_receipt_wire_shape_is_pinned() { + assert_json_snapshot!(OrderReceipt { + core: receipt_core(TradeMode::Live, ReceiptStatus::Submitted), + venue: Some(serde_json::json!({ + "descr": { "order": "buy 0.01000000 XBTUSD @ limit 50000.0" }, + "txid": ["OTEST1-RCPT0-000001"] + })), + paper: None, + }); +} + +mod proptests { + use proptest::prelude::*; + use rust_decimal::Decimal; + + use super::*; + + proptest! { + /// Digit-exactness is the law for money: any capital survives the + /// serde round trip byte-for-byte. + #[test] + fn manifest_round_trips_through_serde(mantissa in 1i64..=i64::MAX, scale in 0u32..=9) { + let mut subject = manifest(); + subject.capital = Decimal::new(mantissa, scale); + let json = serde_json::to_string(&subject).unwrap(); + let back: WorkspaceManifest = serde_json::from_str(&json).unwrap(); + prop_assert_eq!(back, subject); + } + } +} diff --git a/crates/kraken-workspace/tests/snapshots/snapshots__balances_wire_shape_is_pinned.snap b/crates/kraken-workspace/tests/snapshots/snapshots__balances_wire_shape_is_pinned.snap new file mode 100644 index 0000000..9038e2d --- /dev/null +++ b/crates/kraken-workspace/tests/snapshots/snapshots__balances_wire_shape_is_pinned.snap @@ -0,0 +1,20 @@ +--- +source: crates/kraken-workspace/tests/snapshots.rs +expression: balances +--- +{ + "workspace": "btc-momentum", + "mode": "paper", + "balances": { + "BTC": { + "total": "0.01", + "reserved": "0", + "available": "0.01" + }, + "USD": { + "total": "9500.50", + "reserved": "120.25", + "available": "9380.25" + } + } +} diff --git a/crates/kraken-workspace/tests/snapshots/snapshots__listed_workspaces_include_the_synthesized_default_row.snap b/crates/kraken-workspace/tests/snapshots/snapshots__listed_workspaces_include_the_synthesized_default_row.snap new file mode 100644 index 0000000..abe76d0 --- /dev/null +++ b/crates/kraken-workspace/tests/snapshots/snapshots__listed_workspaces_include_the_synthesized_default_row.snap @@ -0,0 +1,17 @@ +--- +source: crates/kraken-workspace/tests/snapshots.rs +expression: rows +--- +[ + { + "name": "default", + "mode": "live" + }, + { + "name": "btc-momentum", + "mode": "paper", + "capital": "10000", + "currency": "USD", + "created_at": "2026-01-01T00:00:00Z" + } +] diff --git a/crates/kraken-workspace/tests/snapshots/snapshots__live_order_receipt_wire_shape_is_pinned.snap b/crates/kraken-workspace/tests/snapshots/snapshots__live_order_receipt_wire_shape_is_pinned.snap new file mode 100644 index 0000000..3d16a2c --- /dev/null +++ b/crates/kraken-workspace/tests/snapshots/snapshots__live_order_receipt_wire_shape_is_pinned.snap @@ -0,0 +1,22 @@ +--- +source: crates/kraken-workspace/tests/snapshots.rs +expression: "OrderReceipt\n{\n core: receipt_core(TradeMode::Live, ReceiptStatus::Submitted), venue:\n Some(serde_json::json!({\n \"descr\": { \"order\": \"buy 0.01000000 XBTUSD @ limit 50000.0\" }, \"txid\":\n [\"OTEST1-RCPT0-000001\"]\n })), paper: None,\n}" +--- +{ + "mode": "live", + "status": "submitted", + "order_id": "OTEST1-RCPT0-000001", + "pair": "BTC/USD", + "side": "buy", + "type": "limit", + "volume": "0.01", + "price": "50000", + "venue": { + "descr": { + "order": "buy 0.01000000 XBTUSD @ limit 50000.0" + }, + "txid": [ + "OTEST1-RCPT0-000001" + ] + } +} diff --git a/crates/kraken-workspace/tests/snapshots/snapshots__paper_order_receipt_wire_shape_is_pinned.snap b/crates/kraken-workspace/tests/snapshots/snapshots__paper_order_receipt_wire_shape_is_pinned.snap new file mode 100644 index 0000000..fa4dab1 --- /dev/null +++ b/crates/kraken-workspace/tests/snapshots/snapshots__paper_order_receipt_wire_shape_is_pinned.snap @@ -0,0 +1,19 @@ +--- +source: crates/kraken-workspace/tests/snapshots.rs +expression: "OrderReceipt\n{\n core: receipt_core(TradeMode::Paper, ReceiptStatus::Filled), venue: None,\n paper:\n Some(PaperFill\n {\n trade_id: Some(\"PAPER-00002\".to_string()), fee: Some(dec!(1.3)), cost:\n Some(dec!(500)),\n }),\n}" +--- +{ + "mode": "paper", + "status": "filled", + "order_id": "OTEST1-RCPT0-000001", + "pair": "BTC/USD", + "side": "buy", + "type": "limit", + "volume": "0.01", + "price": "50000", + "paper": { + "trade_id": "PAPER-00002", + "fee": "1.3", + "cost": "500" + } +} diff --git a/crates/kraken-workspace/tests/snapshots/snapshots__workspace_manifest_wire_shape_is_pinned.snap b/crates/kraken-workspace/tests/snapshots/snapshots__workspace_manifest_wire_shape_is_pinned.snap new file mode 100644 index 0000000..b035746 --- /dev/null +++ b/crates/kraken-workspace/tests/snapshots/snapshots__workspace_manifest_wire_shape_is_pinned.snap @@ -0,0 +1,19 @@ +--- +source: crates/kraken-workspace/tests/snapshots.rs +expression: manifest() +--- +{ + "workspace_version": "1.0", + "cli_version": "0.0.0-test", + "name": "btc-momentum", + "capital": "10000", + "currency": "USD", + "mode": "paper", + "fee_rate": "0.0026", + "slippage_rate": "0", + "allowed_pairs": [ + "BTC/USD", + "ETH/USD" + ], + "created_at": "2026-01-01T00:00:00Z" +} diff --git a/crates/kraken-workspace/tests/snapshots/snapshots__workspace_overview_wire_shape_is_pinned.snap b/crates/kraken-workspace/tests/snapshots/snapshots__workspace_overview_wire_shape_is_pinned.snap new file mode 100644 index 0000000..5ce2861 --- /dev/null +++ b/crates/kraken-workspace/tests/snapshots/snapshots__workspace_overview_wire_shape_is_pinned.snap @@ -0,0 +1,18 @@ +--- +source: crates/kraken-workspace/tests/snapshots.rs +expression: overview +--- +{ + "name": "btc-momentum", + "mode": "paper", + "capital": "10000", + "currency": "USD", + "fee_rate": "0.0026", + "slippage_rate": "0", + "allowed_pairs": [ + "BTC/USD", + "ETH/USD" + ], + "created_at": "2026-01-01T00:00:00Z", + "workspace_version": "1.0" +} diff --git a/crates/kraken-ws/Cargo.toml b/crates/kraken-ws/Cargo.toml new file mode 100644 index 0000000..7d9be1e --- /dev/null +++ b/crates/kraken-ws/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "kraken-ws" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Kraken v2 WebSocket client: connection lifecycle, correlation, and transport for kraken-core's wire model" +license.workspace = true +repository.workspace = true +publish = false + +# Layer 1: moves kraken-core's types over a socket. Owns connection lifecycle — +# never payload meaning. +[dependencies] +futures-util = { workspace = true } +kraken-core = { workspace = true } +rustls = { version = "0.23.43", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros", "sync", "time", "net"] } +tokio-stream = { workspace = true } +tokio-tungstenite = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] } + +[lints] +workspace = true diff --git a/crates/kraken-ws/src/backoff.rs b/crates/kraken-ws/src/backoff.rs new file mode 100644 index 0000000..6bab2c7 --- /dev/null +++ b/crates/kraken-ws/src/backoff.rs @@ -0,0 +1,224 @@ +//! Reconnect pacing: exponential backoff with jitter (no lockstep thundering herd), +//! plus a stable-session reset so only a genuinely flapping socket escalates. +//! Deliberately no attempt cap — streaming retries until the operator interrupts. +//! Pure of I/O: it computes *how long*; the actor owns the interruptible sleep. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Multiplier applied to the delay after each consecutive failed attempt. +const BACKOFF_FACTOR: u32 = 2; + +/// Tunable reconnect schedule. `Copy` so the [`StreamClient`](crate::StreamClient) can stamp one into +/// every connection it spawns. +#[derive(Debug, Clone, Copy)] +pub struct ReconnectPolicy { + /// Delay before the first reconnect, and the base the schedule escalates from. + pub initial: Duration, + /// Upper bound on a single backoff delay (before jitter is added). + pub max: Duration, + /// Upper bound on the random jitter added to each delay. + pub jitter_max: Duration, + /// A session that stayed connected at least this long resets the schedule to `initial`. + pub stable_after: Duration, +} + +impl ReconnectPolicy { + /// Whether a session of this length counts as healthy — the one stability notion + /// shared by the backoff reset and the connection's rejection-incident tracking. + pub(crate) fn is_stable(&self, session: Duration) -> bool { + session >= self.stable_after + } +} + +impl Default for ReconnectPolicy { + fn default() -> Self { + Self { + initial: Duration::from_millis(250), + max: Duration::from_secs(30), + jitter_max: Duration::from_millis(750), + stable_after: Duration::from_secs(60), + } + } +} + +/// Mutable reconnect state for one connection. Not `Clone` — each actor owns exactly one. +pub(crate) struct Backoff { + policy: ReconnectPolicy, + /// The next delay's escalating base (before jitter): reset to `initial` after a stable + /// session, multiplied by [`BACKOFF_FACTOR`] after each attempt. + current: Duration, +} + +impl Backoff { + /// A fresh controller primed at `policy.initial`. + /// + /// A base above the cap means the caller wants that pacing: the effective `max` is + /// raised to `initial` here — `ReconnectPolicy`'s pub fields make a validating + /// constructor bypassable, so the invariant lives at the one funnel every policy + /// passes through — rather than `next_delay` silently clamping `initial` back down. + pub(crate) fn new(policy: ReconnectPolicy) -> Self { + let policy = ReconnectPolicy { + max: policy.max.max(policy.initial), + ..policy + }; + Self { + policy, + current: policy.initial, + } + } + + /// Record the just-ended session's duration. A session that lasted at least + /// `stable_after` is healthy, so the schedule restarts from `initial`; a short one + /// keeps the escalated delay, so a flapping socket backs off progressively. + pub(crate) fn note_session(&mut self, session: Duration) { + if self.policy.is_stable(session) { + self.current = self.policy.initial; + } + } + + /// The delay before the next reconnect attempt, advancing the controller: escalates the + /// base for the following attempt. `wait = min(base, max) + jitter`, computed with + /// saturating arithmetic throughout so it can never overflow. + pub(crate) fn next_delay(&mut self) -> Duration { + let backoff = self + .current + .min(self.policy.max) + .saturating_add(jitter(self.policy.jitter_max)); + + // Escalate the base for the next attempt, capped at `max`. + self.current = self + .current + .saturating_mul(BACKOFF_FACTOR) + .min(self.policy.max); + + backoff + } +} + +/// A pseudo-random jitter in `[0, max]`, derived dependency-free from the clock's +/// sub-second nanos (the same approach the futures WS engine uses). De-correlates +/// reconnects across many clients without pulling in an RNG crate. +fn jitter(max: Duration) -> Duration { + if max.is_zero() { + return Duration::ZERO; + } + let max_ms = u64::try_from(max.as_millis()).unwrap_or(u64::MAX).max(1); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| u64::from(d.subsec_nanos())) + .unwrap_or(0); + // Saturating: at a (nonsensical) u64::MAX-ms jitter cap, `+ 1` would overflow — + // a debug panic or a release remainder-by-zero. + Duration::from_millis(nanos % max_ms.saturating_add(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A policy with no jitter, so a test sees the bare escalation/reset behaviour + /// deterministically. + fn deterministic(initial: Duration, max: Duration) -> ReconnectPolicy { + ReconnectPolicy { + initial, + max, + jitter_max: Duration::ZERO, + stable_after: Duration::from_secs(60), + } + } + + #[test] + fn escalates_by_factor_and_caps_at_max() { + let mut b = Backoff::new(deterministic( + Duration::from_secs(1), + Duration::from_secs(8), + )); + let delays: Vec = (0..5).map(|_| b.next_delay()).collect(); + assert_eq!( + delays, + vec![ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), + Duration::from_secs(8), // capped + ] + ); + } + + #[test] + fn an_initial_above_the_cap_is_not_clamped_down() { + let mut b = Backoff::new(deterministic( + Duration::from_secs(60), + Duration::from_secs(30), + )); + assert_eq!(b.next_delay(), Duration::from_secs(60)); + assert_eq!(b.next_delay(), Duration::from_secs(60), "raised cap holds"); + } + + #[test] + fn a_stable_session_resets_the_schedule() { + let mut b = Backoff::new(deterministic( + Duration::from_secs(1), + Duration::from_secs(8), + )); + b.next_delay(); // 1s + b.next_delay(); // 2s — schedule now escalated + b.note_session(Duration::from_secs(60)); + assert_eq!(b.next_delay(), Duration::from_secs(1)); + } + + #[test] + fn a_short_session_keeps_escalating() { + let mut b = Backoff::new(deterministic( + Duration::from_secs(1), + Duration::from_secs(8), + )); + b.next_delay(); // 1s + b.note_session(Duration::from_secs(1)); + assert_eq!(b.next_delay(), Duration::from_secs(2)); + } + + #[test] + fn jitter_never_exceeds_its_bound() { + let max = Duration::from_millis(750); + for _ in 0..1000 { + assert!(jitter(max) <= max); + } + assert_eq!(jitter(Duration::ZERO), Duration::ZERO); + } +} + +#[cfg(test)] +mod delay_properties { + use super::*; + + proptest::proptest! { + /// Every delay the schedule ever yields stays inside the policy envelope: + /// `max(initial, max) + jitter_max` — under escalation, resets, and any + /// step count. + #[test] + fn every_delay_stays_within_the_policy_envelope( + initial_ms in 0u64..5_000, + max_ms in 0u64..60_000, + jitter_ms in 0u64..2_000, + steps in 1usize..40, + ) { + let policy = ReconnectPolicy { + initial: Duration::from_millis(initial_ms), + max: Duration::from_millis(max_ms), + jitter_max: Duration::from_millis(jitter_ms), + stable_after: Duration::from_secs(60), + }; + let cap = Duration::from_millis(initial_ms.max(max_ms) + jitter_ms); + let mut backoff = Backoff::new(policy); + for _ in 0..steps { + let delay = backoff.next_delay(); + proptest::prop_assert!( + delay <= cap, + "delay {delay:?} escaped the envelope {cap:?}" + ); + } + } + } +} diff --git a/crates/kraken-ws/src/config.rs b/crates/kraken-ws/src/config.rs new file mode 100644 index 0000000..b211e97 --- /dev/null +++ b/crates/kraken-ws/src/config.rs @@ -0,0 +1,115 @@ +//! [`WsConfig`]: exactly what both clients need to open a socket. + +use std::sync::Arc; + +use kraken_core::endpoint::Endpoint; + +use super::token::{TokenCache, TokenSource}; +use super::transport::TungsteniteTransport; + +/// Per-endpoint URL overrides, defaulting to the production hosts. +#[derive(Debug, Clone, Default)] +pub struct Urls { + pub public: Option, + pub auth: Option, + pub l3: Option, +} + +impl Urls { + /// The configured override for `endpoint`, or its production default host. + pub(crate) fn resolve(&self, endpoint: Endpoint) -> String { + let override_url = match endpoint { + Endpoint::Public => &self.public, + Endpoint::Auth => &self.auth, + Endpoint::L3 => &self.l3, + }; + override_url + .clone() + .unwrap_or_else(|| endpoint.default_url().to_string()) + } +} + +/// Everything both WS paths need to open a socket. Plain data with no invariant, so +/// it is built as a struct literal; `Default` is a public-only production client. +/// +/// The reconnect schedule ([`ReconnectPolicy`](super::backoff::ReconnectPolicy)) is +/// stream-only and passed to the streaming client directly, not held here — so the +/// request path is never handed a field it must ignore. +#[derive(Clone, Default)] +pub struct WsConfig { + /// Token fetcher, present when credentials are configured; required by the auth and + /// L3 endpoints. Whether absence is an error is each client's policy: the streaming + /// client skips private subscriptions (failing closed only when nothing requested + /// remains), the request path fails with + /// [`Error::NoTokenSource`](kraken_core::error::Error::NoTokenSource). Fetches are + /// cached and single-flighted inside each client; reconnects re-mint. + pub token_source: Option>, + pub urls: Urls, + /// Disables TLS verification (`--accept-invalid-certs`), for beta/UAT hosts only. + pub accept_invalid_certs: bool, + /// Handshake headers (client identity/telemetry), injected by the application — + /// this crate carries no telemetry knowledge of its own. + pub headers: Vec<(String, String)>, +} + +/// Hand-written: `token_source` is a credential fetcher (`dyn` and deliberately not +/// `Debug`), and header *values* carry instance identity — names alone suffice. +impl std::fmt::Debug for WsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WsConfig") + .field( + "token_source", + &self.token_source.as_ref().map(|_| ""), + ) + .field("urls", &self.urls) + .field("accept_invalid_certs", &self.accept_invalid_certs) + .field( + "headers", + &self + .headers + .iter() + .map(|(k, _)| k.as_str()) + .collect::>(), + ) + .finish() + } +} + +impl WsConfig { + /// A transport for `endpoint`, resolving its URL override and the TLS switch. + pub(crate) fn transport(&self, endpoint: Endpoint) -> TungsteniteTransport { + TungsteniteTransport::new( + self.urls.resolve(endpoint), + self.accept_invalid_certs, + self.headers.clone(), + ) + } + + /// A fresh cache over `token_source` — built once per client, so its single-flight + /// spans exactly that client's connections. + pub(crate) fn new_token_cache(&self) -> Option> { + self.token_source + .clone() + .map(|source| Arc::new(TokenCache::new(source))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_prefers_an_override_then_falls_back_to_the_production_host() { + let urls = Urls { + public: None, + auth: Some("wss://uat-auth.example/v2".to_string()), + l3: None, + }; + assert_eq!(urls.resolve(Endpoint::Auth), "wss://uat-auth.example/v2"); + assert_eq!( + urls.resolve(Endpoint::Public), + Endpoint::Public.default_url() + ); + assert_eq!(urls.resolve(Endpoint::L3), Endpoint::L3.default_url()); + } +} diff --git a/crates/kraken-ws/src/connection.rs b/crates/kraken-ws/src/connection.rs new file mode 100644 index 0000000..41b09ed --- /dev/null +++ b/crates/kraken-ws/src/connection.rs @@ -0,0 +1,1102 @@ +//! Both connection flavours over the one [`Transport`] seam, as one generic +//! [`Connection`] machine — the clients above never touch a socket directly. +//! +//! The shared mechanism is written once: dialing paced by [`Backoff`], token minting +//! through [`TokenCache`], data exchange over one events channel, drop-triggered +//! shutdown via [`CancellationToken`]. What differs is the session intent, carried by +//! the [`Mode`] value: [`Stream`] replays a fixed subscription set and reconnects +//! forever; [`OneShot`] sends one request and ends at the server's close — never +//! re-sending, since a redelivered order is a double-submit. A mode says what to put +//! on the wire at session start; what the replies *mean* stays with each client (the +//! stream types its frames only because rejection-driven routing is lifecycle policy). + +use std::sync::Arc; +use std::time::Duration; + +use kraken_core::endpoint::Endpoint; +use kraken_core::error::{Error, Result}; +use kraken_core::{ + ChannelMessage, Inbound, Method, MethodReply, MethodResponse, PingParams, Request, Wire, + WsSubscription, +}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::backoff::{Backoff, ReconnectPolicy}; +use super::token::TokenCache; +use super::transport::{ConnEvent, ConnReader, ConnWriter, Transport}; + +/// A connection flavour: the session intent the shared machinery drives, and the +/// vocabulary it publishes. A mode carries what to say once the socket is up — never +/// how to interpret what comes back. +pub(crate) trait Mode: Send + Sync + 'static { + /// What this flavour publishes on its events channel. + type Event: Send; +} + +/// The streaming intent: a fixed subscription set, replayed on every reconnect. +pub(crate) struct Stream { + /// Reconnect pacing — caller intent here, unlike the one-shot's fixed dial schedule. + pub(super) policy: ReconnectPolicy, + /// Fixed at construction; re-sent (with a forced snapshot) on every reconnect. + pub(super) subs: Vec, + /// The caller's `--req-id` echoed on the initial subscribe ack; multiplexed streams + /// pass `None`. + pub(super) req_id: Option, +} + +impl Mode for Stream { + type Event = Event; +} + +/// The request intent: one method call, answered by one correlated reply batch. +pub(crate) struct OneShot

{ + pub(super) params: P, + pub(super) req_id: u64, +} + +impl Mode for OneShot

{ + /// Raw frames, verbatim: parsing, correlation, and gathering are the request + /// client's job. + type Event = ConnEvent; +} + +pub(crate) type StreamConnection = Connection; +pub(crate) type OneShotConnection = Connection>; + +/// One session machine over a [`Transport`], generic in its [`Mode`]. The shared +/// fields are the shared mechanism; each mode's `run` drives them to completion. +/// +/// Built as a struct literal by its client (`StreamClient` / `RequestClient`), so the +/// fields are `pub(super)` rather than a six-argument constructor. +pub(crate) struct Connection { + pub(super) endpoint: Endpoint, + pub(super) transport: T, + /// `Some` exactly when this session authenticates; both flavours mint through it. + pub(super) token_cache: Option>, + /// Fan-in sender for this mode's events; the send fails only once the client's + /// receiving end is dropped. + pub(super) events_tx: mpsc::UnboundedSender, + /// Cancelled when the owning handle is dropped; every phase races it. + pub(super) shutdown: CancellationToken, + /// The session intent: what this connection says once the socket is up. + pub(super) mode: M, +} + +impl Connection { + /// Close the socket on teardown. A failed close is traced and the writer dropped — + /// an abrupt TCP close — never propagated: the session is ending either way. + async fn close_quietly(&self, writer: &mut T::Writer) { + if let Err(error) = writer.close().await { + tracing::debug!(endpoint = %self.endpoint, %error, "close failed during teardown"); + } + } + + /// Publish an event. A failed send after shutdown is the drained fan-out (traced); + /// mid-session it means every consumer vanished — a socket feeding nobody has no + /// reason to stay open, so it cancels this connection like a handle drop. + fn emit(&self, event: M::Event) { + if self.events_tx.send(event).is_ok() { + return; + } + if self.shutdown.is_cancelled() { + tracing::trace!(endpoint = %self.endpoint, "event dropped after shutdown; channel closed"); + } else { + tracing::warn!( + endpoint = %self.endpoint, + "event channel dropped mid-session; shutting the connection down" + ); + self.shutdown.cancel(); + } + } + + /// This session's token, or `None` for an unauthenticated session — the cache is + /// `Some` exactly when the session authenticates. + async fn session_token(&self) -> Result> { + match &self.token_cache { + Some(cache) => cache.get().await.map(Some), + None => Ok(None), + } + } + + /// Like [`Self::session_token`], but mints fresh instead of trusting the cache: + /// tokens are connection-scoped, and re-reading the shared cache could pick up a + /// sibling's concurrent mint. + async fn fresh_session_token(&self) -> Result> { + match &self.token_cache { + Some(cache) => cache.refresh().await.map(Some), + None => Ok(None), + } + } + + /// Wait out the next backoff delay; `true` if shutdown fired while waiting. + async fn backoff_or_stop(&self, backoff: &mut Backoff) -> bool { + self.shutdown + .run_until_cancelled(tokio::time::sleep(backoff.next_delay())) + .await + .is_none() + } +} + +// ── Streaming connections ─────────────────────────────────────────────────── + +const PING_INTERVAL: Duration = Duration::from_secs(30); + +/// Maximum read-half silence before the socket is presumed dead. A healthy socket sees +/// frames continuously (heartbeats, pongs, data), so silence spanning several ping +/// rounds means a wedged read a ping write hasn't yet failed on. +pub(super) const READ_IDLE_TIMEOUT: Duration = PING_INTERVAL.saturating_mul(3); + +/// `sub` minus every evicted symbol: `None` once nothing subscribable remains. +/// Eviction is symbol-scoped, not (channel, symbol)-scoped: the rejection ack names +/// only the offending pair, so an evicted symbol drops out of *every* channel on this +/// connection — a channel-specific rejection (say, an unsupported book depth) takes +/// the pair's healthy channels with it. Tightening this needs per-subscription +/// `req_id` correlation on the acks. +fn effective_subscription(sub: &WsSubscription, evicted: &[String]) -> Option { + let mut params = sub.clone(); + params + .retain_symbols(|symbol| !evicted.iter().any(|e| e == symbol)) + .then_some(params) +} + +/// Open rejection incidents, scoped to what the rejection ack named: per symbol for +/// named rejections, per endpoint for anonymous ones. Each incident earns exactly one +/// fresh-token reconnect (a stale token is the one cause a retry cures); a repeat is +/// the second strike. An incident closes when its symbol's re-subscribe is acked +/// ([`cure`](Self::cure)) or when a session streams past the stability threshold; +/// recording happens in `run()` *after* the stability clear, so the strike that ended +/// a session always survives into the retry it pays for. +#[derive(Default)] +struct RejectionIncidents { + /// Symbols whose rejection already spent its fresh-token retry. + symbols: Vec, + /// An anonymous (symbol-less) rejection already spent the endpoint's retry. + anonymous: bool, +} + +impl RejectionIncidents { + /// Whether this rejection repeats an incident that already spent its retry. + fn is_repeat(&self, symbol: Option<&str>) -> bool { + match symbol { + Some(symbol) => self.symbols.iter().any(|s| s == symbol), + None => self.anonymous, + } + } + + /// Open the incident: the fresh-token retry is now spent. + fn record(&mut self, symbol: Option<&str>) { + match symbol { + Some(symbol) if !self.is_repeat(Some(symbol)) => self.symbols.push(symbol.to_owned()), + Some(_) => {} + None => self.anonymous = true, + } + } + + /// A successful re-subscribe ack for `symbol`: the fresh token cured it, so a + /// later rejection is a new incident owed its own retry. Anonymous incidents have + /// no ack to key on and close only via the stability clear. + fn cure(&mut self, symbol: &str) { + self.symbols.retain(|s| s != symbol); + } + + fn clear(&mut self) { + self.symbols.clear(); + self.anonymous = false; + } +} + +/// An event published by a streaming connection onto the client's event channel. +#[derive(Debug, Clone)] +pub enum Event { + /// The socket connected (initial connect or a reconnect). + Connected(Endpoint), + /// The socket dropped; a reconnect attempt follows. + Disconnected(Endpoint), + /// The session never became live: the dial failed, or its setup (token mint, + /// subscribe write) failed before anything streamed. Distinct from + /// [`Disconnected`](Self::Disconnected) so the monitor can tell "never started" + /// from a healthy-but-quiet stream. + ConnectFailed(Endpoint), + /// A decoded channel-data frame. + Message(ChannelMessage), + /// A successful subscribe acknowledgement — the only method success a stream socket + /// surfaces (pong is dropped, anything else is drift), so readiness can key off it. + Ack(MethodResponse), + /// A rejected method acknowledgement with no awaiting caller. + ApiError(MethodResponse), + /// A server keepalive: the monitor's connection-level liveness signal, independent + /// of market activity. + Heartbeat, + /// An inbound frame failed the pinned schema and was dropped before reaching any + /// consumer. The capture-integrity signal: sinks and monitors count it, because + /// the loss is invisible to them otherwise — the frame never becomes a `Message`. + ParseFailure(Endpoint), + /// The connection gave up for good; `cause` carries why, so consumers render the + /// actor's decision instead of assuming one. The session must end as an error, + /// not a clean capture. + Aborted { + endpoint: Endpoint, + cause: AbortCause, + }, +} + +/// Why a connection gave up. A new terminal give-up must name itself here — the +/// cause travels on [`Event::Aborted`] rather than living in a consumer's string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AbortCause { + /// Every subscription was rejected on a fresh token. Carries the server's last + /// rejection string (when the ack had one) so the binary edge classifies the + /// abort — auth, rate limit — instead of flattening it to a generic category. + AllSubscriptionsRejected { reason: Option }, +} + +impl AbortCause { + /// The server's error string behind the give-up, when one was sent. + pub fn reason(&self) -> Option<&str> { + match self { + Self::AllSubscriptionsRejected { reason } => reason.as_deref(), + } + } +} + +impl std::fmt::Display for AbortCause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AllSubscriptionsRejected { reason } => { + f.write_str("every subscription was rejected on a fresh token")?; + if let Some(reason) = reason { + write!(f, ": {reason}")?; + } + Ok(()) + } + } + } +} + +/// Which attempt of the reconnect loop a session belongs to — decides the token (reuse +/// vs. re-mint) and the subscribe shape. An enum because only three of the four flag +/// combinations are legal: a replay always re-mints. +#[derive(Clone, Copy)] +enum Attempt { + /// The very first attempt: subscribe with the token minted up front. + First, + /// A prior attempt failed before a session established: re-mint (the up-front token + /// may have gone stale), but subscribe as a first send. + Redial, + /// A served session dropped: re-mint and replay with a forced snapshot, no `req_id`. + Replay, +} + +impl Attempt { + /// Whether the session must re-mint rather than trust the up-front token. + fn must_remint(self) -> bool { + !matches!(self, Attempt::First) + } + + /// Whether the subscribe replays with a forced snapshot and no echoed `req_id`. + fn is_replay(self) -> bool { + matches!(self, Attempt::Replay) + } +} + +/// What the event loop should do after one inbound frame. +enum FrameOutcome { + Continue, + /// A `subscribe` was rejected; carries the pair the ack named (for eviction) and + /// the server's error string (for the abort cause). + SubscribeRejected { + symbol: Option, + reason: Option, + }, +} + +/// Why a serve loop ended. +enum ServeOutcome { + /// Shutdown was signalled. Stop for good. + Stop, + /// An established session's socket dropped. Reconnect. + Disconnected, + /// A subscribe was rejected before the incident's fresh-token retry was spent: + /// reconnect so the replay re-mints. + SubscribeRejected { + /// The pair the rejection named, if any — recorded in `run()` post-clear. + symbol: Option, + }, + /// Every subscription rejected on a fresh token: stop instead of reconnect-flapping. + /// Carries the last rejection's server error string for the abort cause. + AllRejected { reason: Option }, + /// The dial, token mint, or subscribe write failed before the event loop started. + /// Retried on backoff, so the consumer never sees a `Connected` flap. + SetupFailed, +} + +impl Connection { + /// Drive the connection until shutdown is signalled. + pub(crate) async fn run(self) { + let mut backoff = Backoff::new(self.mode.policy); + let mut attempt = Attempt::First; + let mut incidents = RejectionIncidents::default(); + let mut evicted: Vec = Vec::new(); + loop { + let outcome = match self + .shutdown + .run_until_cancelled(self.transport.connect()) + .await + { + None => break, + // A failed dial is a failed setup: same recovery, one policy point. + Some(Err(error)) => { + tracing::warn!(endpoint = %self.endpoint, %error, "connect failed"); + ServeOutcome::SetupFailed + } + Some(Ok((writer, reader))) => { + // `tokio::time::Instant`, so the stability threshold is + // exercisable under paused virtual time (as token.rs does). + let session_start = tokio::time::Instant::now(); + let outcome = self + .serve(writer, reader, attempt, &mut incidents, &mut evicted) + .await; + // A session that *streamed* past the stability threshold closes + // every open incident (a slow SetupFailed proves nothing — so it + // earns neither the clear nor the backoff reset below). Cleared + // before the outcome records its own strike, so the rejection that + // ended this session always survives into the retry it pays for. + let session = session_start.elapsed(); + if !matches!(outcome, ServeOutcome::SetupFailed) { + if self.mode.policy.is_stable(session) { + incidents.clear(); + } + // Only a *stable* session restarts the schedule, so an + // instantly-dropping socket escalates instead of hot-looping. + backoff.note_session(session); + } + outcome + } + }; + match outcome { + ServeOutcome::Stop => break, + ServeOutcome::AllRejected { reason } => { + tracing::error!( + endpoint = %self.endpoint, + reason = reason.as_deref().unwrap_or("none given"), + "every subscription was rejected on a fresh token; stopping the connection" + ); + self.emit(Event::Aborted { + endpoint: self.endpoint, + cause: AbortCause::AllSubscriptionsRejected { reason }, + }); + break; + } + ServeOutcome::SubscribeRejected { ref symbol } => { + incidents.record(symbol.as_deref()); + attempt = Attempt::Replay; + self.emit(Event::Disconnected(self.endpoint)); + if self.backoff_or_stop(&mut backoff).await { + break; + } + } + ServeOutcome::Disconnected => { + // A session that reached the wire escalates to Replay. + attempt = Attempt::Replay; + self.emit(Event::Disconnected(self.endpoint)); + if self.backoff_or_stop(&mut backoff).await { + break; + } + } + ServeOutcome::SetupFailed => { + // Retries as a first send (Redial re-mints) without downgrading + // a Replay. + if matches!(attempt, Attempt::First) { + attempt = Attempt::Redial; + } + self.emit(Event::ConnectFailed(self.endpoint)); + if self.backoff_or_stop(&mut backoff).await { + break; + } + } + } + } + } + + async fn serve( + &self, + mut writer: T::Writer, + mut reader: T::Reader, + attempt: Attempt, + incidents: &mut RejectionIncidents, + evicted: &mut Vec, + ) -> ServeOutcome { + if let Some(outcome) = self.establish_session(&mut writer, attempt, evicted).await { + return outcome; + } + // Announced only once the subscriptions are on the wire, so a consumer never + // sees `Connected` for a session that failed its setup. + self.emit(Event::Connected(self.endpoint)); + self.run_event_loop(&mut writer, &mut reader, incidents, evicted) + .await + } + + /// Resolve the session's token and send the subscription set, raced against + /// shutdown. `Some` short-circuits `serve`; `None` means the session is live. + async fn establish_session( + &self, + writer: &mut T::Writer, + attempt: Attempt, + evicted: &[String], + ) -> Option { + let setup = async { + // A stale cached token can't serve this new socket, so a mint failure + // tears down and backs off; the next attempt re-mints. + let token = if attempt.must_remint() { + self.fresh_session_token().await? + } else { + self.session_token().await? + }; + self.send_subscriptions(&mut *writer, attempt, token.as_deref(), evicted) + .await + }; + // Bound before matching: the raced future borrows the writer, and a match + // scrutinee's temporary would hold that borrow across the close arm. + let outcome = self.shutdown.run_until_cancelled(setup).await; + match outcome { + None => { + self.close_quietly(writer).await; + Some(ServeOutcome::Stop) + } + Some(Ok(())) => None, + Some(Err(error)) => { + tracing::warn!( + endpoint = %self.endpoint, + %error, + "session setup failed; backing off to retry" + ); + Some(ServeOutcome::SetupFailed) + } + } + } + + /// Drive the live socket until it drops or shutdown fires. + async fn run_event_loop( + &self, + writer: &mut T::Writer, + reader: &mut T::Reader, + incidents: &mut RejectionIncidents, + evicted: &mut Vec, + ) -> ServeOutcome { + let mut ping = tokio::time::interval(PING_INTERVAL); + ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + ping.tick().await; // consume the immediate first tick + + // Built from the domain `ping` request so the keepalive shares every other + // method's wire framing: `{"method":"ping"}`, pinned by test. + let ping_params = PingParams {}; + let ping_wire = Wire::new(PingParams::METHOD, None, &ping_params, None); + + // A deadline only a *read* resets: catches a half-open socket whose buffered + // ping writes haven't failed yet. + let idle = tokio::time::sleep(READ_IDLE_TIMEOUT); + tokio::pin!(idle); + + // Fresh-token rejections whose ack names no pair, counted per session so a + // socket where *everything* was rejected aborts instead of idling forever. + let mut anonymous_rejections: usize = 0; + + loop { + tokio::select! { + // Shutdown first, so a stop is honoured even on a continuously-busy socket. + biased; + + () = self.shutdown.cancelled() => { + self.close_quietly(writer).await; + return ServeOutcome::Stop; + } + + event = reader.recv() => match event { + // The actor parses its own frames: routing (rejection-driven + // reconnect) is lifecycle policy, so the data must be typed here. + ConnEvent::Frame(text) => match text.parse::() { + Ok(frame) => { + // A parsed frame proves the read half is alive. A drifted one + // is never fatal to a stream, but it is not liveness either — + // no idle reset, so an all-junk feed still trips the reconnect. + idle.as_mut() + .reset(tokio::time::Instant::now() + READ_IDLE_TIMEOUT); + if let FrameOutcome::SubscribeRejected { symbol, reason } = + self.handle_frame(frame, &text, incidents) + && let Some(outcome) = self.on_subscribe_rejected( + symbol, + reason, + incidents, + evicted, + &mut anonymous_rejections, + ) + { + return outcome; + } + } + Err(error) => { + tracing::warn!(endpoint = %self.endpoint, %error, "ignoring unparseable inbound frame"); + self.emit(Event::ParseFailure(self.endpoint)); + } + }, + ConnEvent::Closed => return ServeOutcome::Disconnected, + ConnEvent::Error(reason) => { + tracing::warn!(endpoint = %self.endpoint, %reason, "stream error"); + return ServeOutcome::Disconnected; + } + }, + + _ = ping.tick() => { + if let Err(error) = writer.send_frame(&ping_wire).await { + tracing::warn!(endpoint = %self.endpoint, %error, "keepalive send failed; reconnecting"); + return ServeOutcome::Disconnected; + } + } + + () = &mut idle => { + tracing::warn!( + endpoint = %self.endpoint, + idle_ms = READ_IDLE_TIMEOUT.as_millis(), + "no inbound frame; presuming the socket dead and reconnecting" + ); + return ServeOutcome::Disconnected; + } + } + } + } + + /// Send the subscription set, skipping anything `evicted` names. A first send keeps + /// each subscription's own snapshot preference and echoes the mode's `req_id`; a + /// replay forces a snapshot (local state rebuilds cleanly) and echoes no id. + async fn send_subscriptions( + &self, + writer: &mut T::Writer, + attempt: Attempt, + token: Option<&str>, + evicted: &[String], + ) -> Result<()> { + for sub in &self.mode.subs { + let Some(mut params) = effective_subscription(sub, evicted) else { + continue; + }; + let req_id = if attempt.is_replay() { + params.force_snapshot(); + None + } else { + self.mode.req_id.map(std::num::NonZeroU64::get) + }; + writer + .send_frame(&Wire::new(WsSubscription::METHOD, token, ¶ms, req_id)) + .await?; + } + Ok(()) + } + + fn handle_frame( + &self, + frame: Inbound, + raw: &str, + incidents: &mut RejectionIncidents, + ) -> FrameOutcome { + match frame { + Inbound::Channel(message) => { + // Tolerated vocabulary growth is still logged, never silent: the + // fields decoded as `Unknown` and the frame flows on. The warn + // carries no payload; the original token survives nowhere else + // (re-serialization emits "unknown"), so the raw frame is the one + // diagnostic — debug-gated per the raw-payload logging rule, and + // never for an endpoint whose frames carry the account's balances + // and order flow. + if message.body.has_unknown_vocabulary() { + tracing::warn!( + endpoint = %self.endpoint, + channel = %message.channel(), + "frame carries vocabulary outside the pinned catalogue; decoded as \"unknown\"" + ); + if !self.endpoint.carries_account_data() { + tracing::debug!(endpoint = %self.endpoint, frame = %raw, "unknown-vocabulary frame"); + } + } + self.emit(Event::Message(message)); + } + Inbound::Method(resp) => return self.route_method(resp, incidents), + Inbound::Heartbeat => { + self.emit(Event::Heartbeat); + } + } + FrameOutcome::Continue + } + + /// Route a method reply by (method, success) — the streaming twin of the one-shot + /// client's `correlated`. Every [`Method`] is enumerated — no catch-all — so a new + /// variant fails to compile until it picks a row here. + fn route_method( + &self, + resp: MethodResponse, + incidents: &mut RejectionIncidents, + ) -> FrameOutcome { + match (resp.method(), resp.is_success()) { + // The reply to our own keepalive: no awaiting caller, not consumer data. + (Method::Pong, _) => FrameOutcome::Continue, + (Method::Subscribe, true) => { + // The fresh token demonstrably works for this pair: its incident is + // cured, so a later rejection is a new problem owed its own retry. + if let MethodReply::Subscribe(Some(ack)) = &resp.reply + && let Some(symbol) = ack.symbol.as_deref() + { + incidents.cure(symbol); + } + self.emit(Event::Ack(resp)); + FrameOutcome::Continue + } + // A rejected subscribe drives the reconnect/evict policy. + (Method::Subscribe, false) => { + let symbol = resp.symbol.clone(); + let reason = resp.error.clone(); + self.emit(Event::ApiError(resp)); + FrameOutcome::SubscribeRejected { symbol, reason } + } + // Not a catch-all: every remaining method is consciously routed here. + ( + Method::AddOrder + | Method::AmendOrder + | Method::BatchAdd + | Method::BatchCancel + | Method::CancelAll + | Method::CancelAllOrdersAfter + | Method::CancelOrder + | Method::Ping + | Method::Unsubscribe, + success, + ) => { + if success { + // The actor sends only subscribe + ping, so no other success can + // answer on this socket: protocol drift, surfaced — never ack'd + // (see [`Event::Ack`]). + tracing::warn!( + endpoint = %self.endpoint, + method = %resp.method(), + "unexpected method success on a stream socket" + ); + } else { + // A rejection is consumer-visible error data (e.g. a refused + // keepalive). + self.emit(Event::ApiError(resp)); + } + FrameOutcome::Continue + } + } + } + + /// Decide what a rejected subscribe means. First rejection of an incident: + /// reconnect, since a stale token is the one cause a retry cures — one retry per + /// incident, scoped to the pair the ack named (an unrelated later rejection is its + /// own incident with its own retry). The same pair rejected again on its fresh + /// token: evict it (the remainder keeps streaming), stop once nothing is left. No + /// pair named: count it — one dead channel among live ones is left dead rather + /// than flapping the endpoint, but once every live subscription has been rejected + /// the socket streams nothing and must abort (its own keepalive pongs would + /// otherwise defeat the idle watchdog forever). + fn on_subscribe_rejected( + &self, + symbol: Option, + reason: Option, + incidents: &mut RejectionIncidents, + evicted: &mut Vec, + anonymous_rejections: &mut usize, + ) -> Option { + if !incidents.is_repeat(symbol.as_deref()) { + tracing::warn!( + endpoint = %self.endpoint, + symbol = symbol.as_deref().unwrap_or(""), + "a subscribe was rejected; reconnecting once to re-subscribe with a fresh token" + ); + return Some(ServeOutcome::SubscribeRejected { symbol }); + } + let Some(symbol) = symbol else { + *anonymous_rejections += 1; + if *anonymous_rejections >= self.live_subscriptions(evicted) { + return Some(ServeOutcome::AllRejected { reason }); + } + tracing::warn!( + endpoint = %self.endpoint, + "subscribe rejected again on a fresh token and the ack named no pair; \ + leaving that channel dead on this socket" + ); + return None; + }; + tracing::warn!( + endpoint = %self.endpoint, + %symbol, + "subscribe rejected again on a fresh token; dropping the pair from the replay set" + ); + evicted.push(symbol); + (self.live_subscriptions(evicted) == 0).then_some(ServeOutcome::AllRejected { reason }) + } + + /// How many subscriptions still have anything to subscribe once `evicted` is + /// applied. + fn live_subscriptions(&self, evicted: &[String]) -> usize { + self.mode + .subs + .iter() + .filter(|sub| effective_subscription(sub, evicted).is_some()) + .count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ping_frame_matches_the_v2_keepalive_bytes() { + let params = PingParams {}; + let wire = Wire::new(PingParams::METHOD, None, ¶ms, None); + assert_eq!( + serde_json::to_string(&wire).unwrap(), + r#"{"method":"ping"}"# + ); + } +} + +// ── One-shot connections ──────────────────────────────────────────────────── + +/// The one-shot dial's pacing: the same [`Backoff`] the streaming actor uses, with +/// jitter disabled — a single process probing one host has no herd to de-correlate. +/// The schedule is 250ms, 500ms, 1s, 2s (~3.75s of sleeps inside [`DIAL_TIMEOUT`]). +/// Machinery, not caller intent, so it lives here rather than in the [`OneShot`] mode. +const DIAL_RETRY: ReconnectPolicy = ReconnectPolicy { + initial: Duration::from_millis(250), + max: Duration::from_secs(2), + jitter_max: Duration::ZERO, + // Never consulted: the dial loop notes no sessions. + stable_after: Duration::ZERO, +}; + +/// Dial attempts before giving up. Bounds the retry *count* independently of the +/// dial budget — a fast-failing host would otherwise burst through the window — and +/// is what makes the dial surface the concrete last error instead of retrying +/// forever like the streaming flavour deliberately does. +const DIAL_MAX_ATTEMPTS: u32 = 5; + +/// Overall dial budget across every retry. A miss is the retry-safe +/// [`Error::DialTimeout`]: nothing has been sent yet. +const DIAL_TIMEOUT: Duration = Duration::from_secs(10); + +/// Reply budget once the request is on the wire, for a server that never closes the +/// socket. An elapse surfaces as [`Error::Unacknowledged`] — never a retry-safe +/// timeout, since the frame may have reached the exchange. +const REPLY_TIMEOUT: Duration = Duration::from_secs(10); + +/// Brand a post-send stop reason. Only the connection knows which side of the send a +/// failure happened on: dial and mint failures keep their own class (retry-safe, nothing +/// reached the wire), while a failure at or after the send must not read as blindly +/// retryable — the request may have reached the exchange. +fn verify_first(stop: Error) -> Error { + if stop.is_verify_first() { + stop + } else { + Error::ReplyLost(Box::new(stop)) + } +} + +impl Connection> { + /// Drive the session to completion: dial, send the one request, pump raw replies + /// onto the events channel. Every terminal path emits `Closed` or `Error` last, so + /// the gathering client always learns how the exchange ended. + pub(crate) async fn run(self) { + let Some(dialed) = self.dial().await else { + return; // shutdown while dialing: the caller is gone + }; + let (mut writer, mut reader) = match dialed { + Ok(socket) => socket, + Err(error) => { + self.emit(ConnEvent::Error(error)); + return; + } + }; + // Bound before matching: the raced future borrows the writer, and a match + // scrutinee's temporary would hold that borrow across the close arm. + let established = self + .shutdown + .run_until_cancelled(self.establish(&mut writer)) + .await; + let Some(established) = established else { + self.close_quietly(&mut writer).await; + return; + }; + match established { + Ok(()) => { + self.pump(&mut reader).await; + // Close only now, after the batch ended (client done, server close, + // or the reply budget): a Close sent before the reply licenses the + // server to drop it — RFC 6455 forbids data frames after its echo. + self.close_quietly(&mut writer).await; + } + Err(error) => self.emit(ConnEvent::Error(error)), + } + } + + /// Dial with the capped retry, the whole schedule inside one [`DIAL_TIMEOUT`] + /// budget; `None` on shutdown. + async fn dial(&self) -> Option> { + let attempts = async { + let mut backoff = Backoff::new(DIAL_RETRY); + let mut attempt = 1; + loop { + match self + .shutdown + .run_until_cancelled(self.transport.connect()) + .await? + { + Ok(socket) => return Some(Ok(socket)), + Err(error) => { + if attempt >= DIAL_MAX_ATTEMPTS { + return Some(Err(error)); + } + tracing::warn!(%error, attempt, max = DIAL_MAX_ATTEMPTS, "dial failed; retrying"); + if self.backoff_or_stop(&mut backoff).await { + return None; + } + attempt += 1; + } + } + } + }; + match tokio::time::timeout(DIAL_TIMEOUT, attempts).await { + Ok(outcome) => outcome, + Err(_elapsed) => Some(Err(Error::DialTimeout)), + } + } + + /// Mint (when the session authenticates), frame, send. The socket stays open — + /// the client's gather ends the batch at its expected reply count, and the close + /// handshake runs afterwards. A mint failure propagates with its own class — it + /// is pre-send, so it must never read as verify-first; the send is where the + /// verify-first branding starts. + async fn establish(&self, writer: &mut T::Writer) -> Result<()> { + // Never a re-mint: a one-shot has no retry incident a fresh token could cure. + let token = self.session_token().await?; + let wire = Wire::new( + P::METHOD, + token.as_deref(), + &self.mode.params, + Some(self.mode.req_id), + ); + writer.send_frame(&wire).await.map_err(verify_first) + } + + /// Forward raw frames until the server closes, the reply budget elapses, or + /// shutdown fires. The budget is total, not per-frame: one exchange has no + /// business outliving it, however slowly the server drips. + async fn pump(&self, reader: &mut T::Reader) { + let deadline = tokio::time::sleep(REPLY_TIMEOUT); + tokio::pin!(deadline); + loop { + tokio::select! { + biased; + () = self.shutdown.cancelled() => return, + () = &mut deadline => { + // The frame is on the wire, so silence must not read as retry-safe. + self.emit(ConnEvent::Error(Error::Unacknowledged)); + return; + } + event = reader.recv() => { + // Everything the pump sees happens after the send: a socket loss + // here loses the reply, not the request. + let event = match event { + ConnEvent::Error(cause) => ConnEvent::Error(verify_first(cause)), + other => other, + }; + let terminal = matches!(event, ConnEvent::Closed | ConnEvent::Error(_)); + self.emit(event); + if terminal { + return; + } + } + } + } + } +} + +#[cfg(test)] +mod one_shot_tests { + use std::sync::atomic::Ordering; + + use super::*; + use crate::mock_transport::{MockTransport, wait_until}; + use crate::token::{BoxFuture, Minted, TokenSource}; + + /// A one-shot `ping` session over `transport`, plus its gather-side receiver. + fn one_shot( + transport: MockTransport, + ) -> ( + OneShotConnection, + mpsc::UnboundedReceiver, + ) { + one_shot_with_cache(transport, None) + } + + fn one_shot_with_cache( + transport: MockTransport, + token_cache: Option>, + ) -> ( + OneShotConnection, + mpsc::UnboundedReceiver, + ) { + let (events_tx, events_rx) = mpsc::unbounded_channel(); + let connection = OneShotConnection { + endpoint: Endpoint::Public, + transport, + token_cache, + events_tx, + shutdown: CancellationToken::new(), + mode: OneShot { + params: PingParams {}, + req_id: 1, + }, + }; + (connection, events_rx) + } + + /// A token source whose mint always fails, for the pre-send phase test. + struct RejectingSource; + + impl TokenSource for RejectingSource { + fn fetch(&self) -> BoxFuture> { + Box::pin(async { Err(Error::TokenRejected("EAPI:Invalid key".into())) }) + } + } + + #[tokio::test(start_paused = true)] + async fn a_transient_dial_failure_is_retried_within_the_budget() { + let transport = MockTransport::new(); + transport.fail_first.store(2, Ordering::SeqCst); + let (connection, _events) = one_shot(transport.clone()); + let dialed = connection.dial().await.expect("no shutdown was signalled"); + let _socket = dialed.expect("connects after the transient failures"); + assert_eq!(transport.connect_count(), 3); + } + + #[tokio::test(start_paused = true)] + async fn a_dead_endpoint_gives_up_after_the_attempt_limit() { + let mut transport = MockTransport::new(); + transport.fail = true; + let (connection, _events) = one_shot(transport.clone()); + let Err(err) = connection.dial().await.expect("no shutdown was signalled") else { + panic!("a permanently dead endpoint must not connect"); + }; + assert!(matches!(err, Error::Transport(reason) if reason == "mock connect failure")); + assert_eq!(transport.connect_count(), DIAL_MAX_ATTEMPTS as usize); + } + + #[tokio::test(start_paused = true)] + async fn a_dial_slower_than_its_budget_is_the_retry_safe_timeout() { + let mut transport = MockTransport::new(); + transport.hang = true; + let (connection, _events) = one_shot(transport); + let Err(err) = connection.dial().await.expect("no shutdown was signalled") else { + panic!("a hung handshake must not connect"); + }; + assert!(matches!(err, Error::DialTimeout)); + } + + #[tokio::test(start_paused = true)] + async fn a_silent_server_after_the_send_is_unacknowledged() { + // The mock's reader is fed by a channel nothing writes to, so only the reply + // deadline can end the session. + let (connection, mut events) = one_shot(MockTransport::new()); + connection.run().await; + let event = events.recv().await.expect("a terminal event"); + assert!(matches!(event, ConnEvent::Error(Error::Unacknowledged))); + } + + #[tokio::test(start_paused = true)] + async fn a_one_shot_sends_exactly_once_and_never_redials_after_the_send() { + // The double-submit guard: whatever happens once the frame is on the wire — + // here a reply-less server that trips the deadline — the machine must not + // reconnect and must not re-send. + let transport = MockTransport::new(); + let (connection, _events) = one_shot(transport.clone()); + connection.run().await; + assert_eq!(transport.connect_count(), 1, "one dial, never a re-dial"); + assert_eq!(transport.sent().len(), 1, "exactly one frame sent"); + } + + #[tokio::test] + async fn the_socket_closes_only_after_the_batch_ends() { + // The double-submit-safe ordering: no Close frame may precede the reply. + let transport = MockTransport::new(); + let (connection, mut events) = one_shot(transport.clone()); + let session = tokio::spawn(connection.run()); + wait_until(|| !transport.sent().is_empty()).await; + assert_eq!( + transport.close_count(), + 0, + "the request must not race a close" + ); + transport + .reader(0) + .send(ConnEvent::Frame(r#"{"method":"pong","req_id":1}"#.into())) + .expect("the session is reading"); + let reply = events.recv().await.expect("the reply is forwarded"); + assert!(matches!(reply, ConnEvent::Frame(_))); + assert_eq!( + transport.close_count(), + 0, + "still open while the batch may grow" + ); + transport + .reader(0) + .send(ConnEvent::Closed) + .expect("the session is reading"); + session.await.expect("the session task must not panic"); + assert_eq!(transport.close_count(), 1, "teardown closes the socket"); + } + + #[tokio::test] + async fn a_post_send_socket_error_ends_the_session_without_a_retry() { + let transport = MockTransport::new(); + let (connection, mut events) = one_shot(transport.clone()); + let session = tokio::spawn(connection.run()); + wait_until(|| !transport.sent().is_empty()).await; + transport + .reader(0) + .send(ConnEvent::Error(Error::Transport("reset".into()))) + .expect("the session is reading"); + session.await.expect("the session task must not panic"); + assert_eq!(transport.connect_count(), 1, "one dial, never a re-dial"); + assert_eq!(transport.sent().len(), 1, "exactly one frame sent"); + let mut saw_branded_error = false; + while let Ok(event) = events.try_recv() { + // Post-send, so the stop reason must carry the verify-first brand. + saw_branded_error |= matches!(event, ConnEvent::Error(Error::ReplyLost(_))); + } + assert!( + saw_branded_error, + "the branded stop reason must reach the gather" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_post_dial_mint_failure_keeps_its_class_and_sends_nothing() { + // Pre-send: a rejected mint must surface as the credentials problem it is, + // never as "the request may have reached the exchange". + let transport = MockTransport::new(); + let cache = Arc::new(TokenCache::new(Arc::new(RejectingSource))); + let (connection, mut events) = one_shot_with_cache(transport.clone(), Some(cache)); + connection.run().await; + assert!(transport.sent().is_empty(), "nothing reached the wire"); + let event = events.recv().await.expect("a terminal event"); + let ConnEvent::Error(err) = event else { + panic!("expected the mint failure, got {event:?}"); + }; + assert!(matches!(err, Error::TokenRejected(_)), "got: {err:?}"); + assert!( + !err.to_string().contains("verify its state"), + "a pre-send failure must not carry the verify-first marker: {err}" + ); + } +} diff --git a/crates/kraken-ws/src/connection_tests.rs b/crates/kraken-ws/src/connection_tests.rs new file mode 100644 index 0000000..7f50991 --- /dev/null +++ b/crates/kraken-ws/src/connection_tests.rs @@ -0,0 +1,971 @@ +//! In-process tests for the [`Connection`] actor, driven over the shared +//! [`MockTransport`], so each test drives connect, the fixed-subscription send, +//! replay-on-reconnect, frame decoding, reconnect backoff, the read-idle timeout, +//! and cancellation shutdown without a real socket. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use kraken_core::endpoint::Endpoint; +use kraken_core::error::{Error, Result}; +use kraken_core::{Method, WsSubscription}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use super::backoff::ReconnectPolicy; +use super::connection::{Event, Stream, StreamConnection}; +use super::mock_transport::{MockTransport, wait_until}; +use super::token::{BoxFuture, Minted, TokenCache, TokenSource}; +use super::transport::ConnEvent; + +// --- mock token source ----------------------------------------------------- + +struct MockToken(&'static str); + +impl TokenSource for MockToken { + fn fetch(&self) -> BoxFuture> { + let token = self.0.to_string(); + Box::pin(async move { Ok(token.into()) }) + } +} + +/// Mints a distinct token per fetch (`tok-0`, `tok-1`, …), so a test can tell which mint a +/// subscribe frame carried. +struct CountingToken { + calls: Arc, +} + +impl TokenSource for CountingToken { + fn fetch(&self) -> BoxFuture> { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { Ok(format!("tok-{n}").into()) }) + } +} + +/// Mints a token on the first fetch, then fails every one after — so the initial connect +/// subscribes but the reconnect's refresh fails. +struct TokenThenFail { + calls: Arc, +} + +impl TokenSource for TokenThenFail { + fn fetch(&self) -> BoxFuture> { + let first = self.calls.fetch_add(1, Ordering::SeqCst) == 0; + Box::pin(async move { + if first { + Ok("secret".into()) + } else { + Err(Error::TokenRejected("refresh failed".into())) + } + }) + } +} + +/// Fails the first fetch, then mints — so the initial session's setup fails and a test can +/// prove the retry kept first-send semantics. +struct FailThenToken { + calls: Arc, +} + +impl TokenSource for FailThenToken { + fn fetch(&self) -> BoxFuture> { + let first = self.calls.fetch_add(1, Ordering::SeqCst) == 0; + Box::pin(async move { + if first { + Err(Error::TokenRejected("first mint failed".into())) + } else { + Ok("fresh".into()) + } + }) + } +} + +// --- harness --------------------------------------------------------------- + +/// Spawn an actor over `transport` streaming `subs`, returning the shutdown token +/// (cancel it to stop the actor), the event receiver, and the actor task's handle. +fn spawn( + endpoint: Endpoint, + transport: MockTransport, + token_cache: Option>, + subs: Vec, + req_id: Option, +) -> ( + CancellationToken, + mpsc::UnboundedReceiver, + JoinHandle<()>, +) { + let (events_tx, events_rx) = mpsc::unbounded_channel(); + let shutdown = CancellationToken::new(); + let conn = StreamConnection { + endpoint, + transport, + token_cache, + events_tx, + shutdown: shutdown.child_token(), + mode: Stream { + policy: fast_policy(), + subs, + req_id, + }, + }; + let handle = tokio::spawn(conn.run()); + (shutdown, events_rx, handle) +} + +/// A ready [`TokenCache`] over `source`, hiding the double-`Arc` ceremony. +fn mint_cache(source: impl TokenSource + 'static) -> Arc { + Arc::new(TokenCache::new(Arc::new(source))) +} + +/// Zero delays so reconnect-driven tests run at full speed. +fn fast_policy() -> ReconnectPolicy { + ReconnectPolicy { + initial: Duration::ZERO, + max: Duration::ZERO, + jitter_max: Duration::ZERO, + stable_after: Duration::from_secs(60), + } +} + +/// A raw wire frame as the transport would deliver it: verbatim text. +fn frame(raw: &str) -> ConnEvent { + ConnEvent::Frame(raw.into()) +} + +/// Poll `cond` under paused virtual time, alternating yields (letting the actor run) +/// with small clock steps (firing whatever timer it parks on); panics if `cond` never +/// holds within the advanced window. +async fn advance_until(mut cond: impl FnMut() -> bool) { + for _ in 0..200 { + for _ in 0..20 { + if cond() { + return; + } + tokio::task::yield_now().await; + } + tokio::time::advance(Duration::from_millis(100)).await; + } + panic!("condition not met within the advanced window"); +} + +/// Read events until the first that matches `pick`, returning it. +async fn next_matching( + rx: &mut mpsc::UnboundedReceiver, + mut pick: impl FnMut(&Event) -> Option, +) -> T { + for _ in 0..500 { + if let Ok(event) = rx.try_recv() { + if let Some(found) = pick(&event) { + return found; + } + } else { + tokio::time::sleep(Duration::from_millis(2)).await; + } + } + panic!("expected event never arrived"); +} + +fn ticker(symbol: &str) -> WsSubscription { + WsSubscription::Ticker { + symbol: vec![symbol.into()], + event_trigger: None, + snapshot: None, + } +} + +fn balances() -> WsSubscription { + WsSubscription::Balances { + snapshot: None, + rebased: None, + users: None, + } +} + +// --- tests ----------------------------------------------------------------- + +#[tokio::test] +async fn the_subscription_set_is_sent_on_connect() { + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Public, + transport.clone(), + None, + vec![ticker("BTC/USD")], + None, + ); + wait_until(|| transport.sent().iter().any(|f| f.contains("\"ticker\""))).await; +} + +#[tokio::test] +async fn data_frame_reaches_the_consumer_as_a_typed_message() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + let raw = r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD", + "side":"buy","price":1.0,"qty":2.0,"ord_type":"market","trade_id":7,"timestamp":"TS"}]}"#; + transport.reader(0).send(frame(raw)).unwrap(); + let message = next_matching(&mut events, |e| match e { + Event::Message(m) => Some(m.clone()), + _ => None, + }) + .await; + assert!(serde_json::to_string(&message).unwrap().contains("BTC/USD")); +} + +#[tokio::test] +async fn an_unparseable_frame_is_counted_not_fatal() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + // Schema drift: the frame is dropped, but the loss must surface as an event so + // sinks and monitors can count the hole — and the session must keep streaming. + transport + .reader(0) + .send(frame( + r#"{"channel":"ticker","type":"update","data":[{"last":"drift"}]}"#, + )) + .unwrap(); + let dropped_on = next_matching(&mut events, |e| match e { + Event::ParseFailure(endpoint) => Some(*endpoint), + _ => None, + }) + .await; + assert_eq!(dropped_on, Endpoint::Public); + let raw = r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD", + "side":"buy","price":1.0,"qty":2.0,"ord_type":"market","trade_id":7,"timestamp":"TS"}]}"#; + transport.reader(0).send(frame(raw)).unwrap(); + next_matching(&mut events, |e| match e { + Event::Message(m) => Some(m.clone()), + _ => None, + }) + .await; +} + +#[tokio::test] +async fn an_unexpected_method_success_is_not_an_ack() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + // A success this actor never requested (it sends only subscribe + ping): drift, + // not readiness — surfacing it as an ack would fire session-ready hooks. + transport + .reader(0) + .send(frame( + r#"{"method":"cancel_all","success":true,"result":{"count":1},"req_id":9}"#, + )) + .unwrap(); + transport + .reader(0) + .send(frame(r#"{"method":"subscribe","success":true,"req_id":1}"#)) + .unwrap(); + let resp = next_matching(&mut events, |e| match e { + Event::Ack(r) => Some(r.clone()), + _ => None, + }) + .await; + assert_eq!(resp.method(), Method::Subscribe); +} + +#[tokio::test] +async fn a_malformed_frame_does_not_kill_the_stream() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + let drifted = r#"{"method":"add_order","success":true,"result":{"order_id":42}}"#; + transport.reader(0).send(frame(drifted)).unwrap(); + transport + .reader(0) + .send(frame(r#"{"channel":"heartbeat"}"#)) + .unwrap(); + next_matching(&mut events, |e| matches!(e, Event::Heartbeat).then_some(())).await; + assert_eq!(transport.connect_count(), 1, "must not reconnect"); +} + +#[tokio::test] +async fn failed_subscribe_ack_surfaces_as_api_error() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + let ack = r#"{"method":"subscribe","success":false,"error":"Currency pair not supported","req_id":1}"#; + transport.reader(0).send(frame(ack)).unwrap(); + let resp = next_matching(&mut events, |e| match e { + Event::ApiError(r) => Some(r.clone()), + _ => None, + }) + .await; + assert_eq!(resp.error.as_deref(), Some("Currency pair not supported")); +} + +#[tokio::test] +async fn a_pong_reply_is_not_surfaced_as_an_ack() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + transport + .reader(0) + .send(frame(r#"{"method":"pong"}"#)) + .unwrap(); + transport + .reader(0) + .send(frame(r#"{"method":"subscribe","success":true,"req_id":1}"#)) + .unwrap(); + let resp = next_matching(&mut events, |e| match e { + Event::Ack(r) => Some(r.clone()), + _ => None, + }) + .await; + assert_eq!( + resp.method(), + Method::Subscribe, + "the pong must not surface as an ack" + ); +} + +#[tokio::test] +async fn a_server_heartbeat_reaches_the_consumer_as_a_liveness_event() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + transport + .reader(0) + .send(frame(r#"{"channel":"heartbeat"}"#)) + .unwrap(); + next_matching(&mut events, |e| matches!(e, Event::Heartbeat).then_some(())).await; +} + +#[tokio::test] +async fn reconnect_replays_the_subscription_with_a_fresh_token_and_forced_snapshot() { + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(mint_cache(MockToken("secret"))), + vec![balances()], + None, + ); + wait_until(|| transport.sent().iter().any(|f| f.contains("balances"))).await; + + transport.reader(0).send(ConnEvent::Closed).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + + wait_until(|| { + transport + .sent() + .iter() + .any(|f| f.contains("\"token\":\"secret\"") && f.contains("\"snapshot\":true")) + }) + .await; +} + +#[tokio::test] +async fn a_failed_token_refresh_on_reconnect_backs_off_instead_of_replaying_a_stale_token() { + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(mint_cache(TokenThenFail { + calls: Arc::new(AtomicUsize::new(0)), + })), + vec![balances()], + None, + ); + + wait_until(|| transport.sent().iter().any(|f| f.contains("balances"))).await; + transport.reader(0).send(ConnEvent::Closed).unwrap(); + wait_until(|| transport.connect_count() >= 3).await; + assert_eq!( + transport + .sent() + .iter() + .filter(|f| f.contains("balances")) + .count(), + 1, + "a stale token must not be replayed after a failed refresh" + ); +} + +#[tokio::test] +async fn a_reconnect_subscribes_with_its_own_fresh_mint() { + let transport = MockTransport::new(); + let cache = mint_cache(CountingToken { + calls: Arc::new(AtomicUsize::new(0)), + }); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(cache), + vec![balances()], + None, + ); + wait_until(|| transport.sent().iter().any(|f| f.contains("tok-0"))).await; + + transport.reader(0).send(ConnEvent::Closed).unwrap(); + wait_until(|| transport.sent().iter().any(|f| f.contains("tok-1"))).await; +} + +#[tokio::test] +async fn a_failed_dial_re_mints_the_token_for_the_next_attempt() { + let transport = MockTransport::new(); + transport.fail_first.store(1, Ordering::SeqCst); + let cache = mint_cache(CountingToken { + calls: Arc::new(AtomicUsize::new(0)), + }); + cache.get().await.unwrap(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(cache), + vec![balances()], + None, + ); + + wait_until(|| transport.sent().iter().any(|f| f.contains("balances"))).await; + let sent = transport.sent(); + let frame = sent.iter().find(|f| f.contains("balances")).unwrap(); + assert!( + frame.contains("tok-1"), + "the session after a failed dial must carry a fresh mint, got: {frame}" + ); +} + +#[tokio::test] +async fn a_rejected_subscribe_reconnects_to_resubscribe() { + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Public, + transport.clone(), + None, + vec![ticker("BTC/USD")], + None, + ); + wait_until(|| transport.connect_count() >= 1).await; + let ack = r#"{"method":"subscribe","success":false,"error":"stale token","req_id":1}"#; + transport.reader(0).send(frame(ack)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + wait_until(|| { + transport + .sent() + .iter() + .filter(|f| f.contains("\"ticker\"")) + .count() + >= 2 + }) + .await; +} + +#[tokio::test] +async fn a_new_rejection_after_a_cured_incident_gets_its_own_fresh_token_retry() { + // The incident is per symbol, not per endpoint: after BOGUS/USD's rejection spent + // its fresh-token retry and the replay was ACKED (the incident is cured), a later + // rejection of a DIFFERENT pair inside the stability window must earn its own + // fresh-token reconnect — never count as the old incident's second strike and be + // evicted unheard. + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Public, + transport.clone(), + None, + vec![WsSubscription::Ticker { + symbol: vec!["BTC/USD".into(), "ETH/USD".into()], + event_trigger: None, + snapshot: None, + }], + None, + ); + wait_until(|| transport.connect_count() >= 1).await; + transport + .reader(0) + .send(frame( + r#"{"method":"subscribe","success":false,"error":"stale","symbol":"BTC/USD"}"#, + )) + .unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + // The replay is acked: BTC/USD's incident is cured. + transport + .reader(1) + .send(frame( + r#"{"method":"subscribe","success":true,"result":{"channel":"ticker","symbol":"BTC/USD"}}"#, + )) + .unwrap(); + // A different pair rejects within the stability window. + transport + .reader(1) + .send(frame( + r#"{"method":"subscribe","success":false,"error":"stale","symbol":"ETH/USD"}"#, + )) + .unwrap(); + // Its own fresh-token reconnect — not an eviction. + wait_until(|| transport.connect_count() >= 3).await; + wait_until(|| { + transport + .sent() + .iter() + .filter(|f| f.contains("\"ticker\"")) + .count() + >= 3 + }) + .await; + let replay = transport + .sent() + .into_iter() + .rev() + .find(|f| f.contains("\"ticker\"")) + .expect("a replayed subscribe"); + assert!( + replay.contains("ETH/USD"), + "ETH/USD must still be in the replay set, not evicted: {replay}" + ); +} + +#[tokio::test] +async fn an_anonymous_rejection_after_a_named_incident_gets_its_own_retry() { + // Named and anonymous incidents are separate axes: a named pair's spent retry + // must not consume the endpoint's anonymous retry. + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Public, + transport.clone(), + None, + vec![ticker("BTC/USD")], + None, + ); + wait_until(|| transport.connect_count() >= 1).await; + transport + .reader(0) + .send(frame( + r#"{"method":"subscribe","success":false,"error":"stale","symbol":"BTC/USD"}"#, + )) + .unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + transport + .reader(1) + .send(frame( + r#"{"method":"subscribe","success":false,"error":"stale"}"#, + )) + .unwrap(); + // The anonymous incident earns a reconnect instead of aborting the endpoint. + wait_until(|| transport.connect_count() >= 3).await; +} + +#[tokio::test] +async fn a_repeat_rejection_evicts_even_when_every_session_is_stable() { + // The strike must survive its own session's stability clear (record runs after + // the clear): with stable_after = ZERO every session is "stable", and a repeated + // rejection must still evict deterministically instead of earning endless retries. + let transport = MockTransport::new(); + let (events_tx, _events_rx) = mpsc::unbounded_channel(); + let shutdown = CancellationToken::new(); + let conn = StreamConnection { + endpoint: Endpoint::Public, + transport: transport.clone(), + token_cache: None, + events_tx, + shutdown: shutdown.child_token(), + mode: Stream { + policy: ReconnectPolicy { + initial: Duration::ZERO, + max: Duration::ZERO, + jitter_max: Duration::ZERO, + stable_after: Duration::ZERO, + }, + subs: vec![WsSubscription::Ticker { + symbol: vec!["BTC/USD".into(), "BOGUS/USD".into()], + event_trigger: None, + snapshot: None, + }], + req_id: None, + }, + }; + let _handle = tokio::spawn(conn.run()); + let reject = r#"{"method":"subscribe","success":false,"error":"nope","symbol":"BOGUS/USD"}"#; + wait_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(frame(reject)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + transport.reader(1).send(frame(reject)).unwrap(); + // Eviction, not a third fresh-token reconnect: the replay after a close excludes + // the pair. + transport.reader(1).send(ConnEvent::Closed).unwrap(); + wait_until(|| { + transport + .sent() + .iter() + .filter(|f| f.contains("\"ticker\"")) + .count() + >= 3 + }) + .await; + let replay = transport + .sent() + .into_iter() + .rev() + .find(|f| f.contains("\"ticker\"")) + .expect("a replayed subscribe"); + assert!( + !replay.contains("BOGUS/USD"), + "the twice-rejected pair must be evicted: {replay}" + ); + let _ = shutdown; +} + +#[tokio::test] +async fn a_cured_pair_rejected_again_gets_a_fresh_retry_not_eviction() { + // The successful re-subscribe ack closes the incident: a later rejection of the + // SAME pair is a new problem owed its own fresh-token retry — not the old + // incident's second strike. + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Public, + transport.clone(), + None, + vec![ticker("BTC/USD")], + None, + ); + let reject = r#"{"method":"subscribe","success":false,"error":"stale","symbol":"BTC/USD"}"#; + wait_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(frame(reject)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + // The replay is acked (result carries the pair): the incident is cured. + transport + .reader(1) + .send(frame( + r#"{"method":"subscribe","success":true,"result":{"channel":"ticker","symbol":"BTC/USD"}}"#, + )) + .unwrap(); + // The socket drops (well inside the stability window), then the pair rejects again. + transport.reader(1).send(ConnEvent::Closed).unwrap(); + wait_until(|| transport.connect_count() >= 3).await; + transport.reader(2).send(frame(reject)).unwrap(); + // A fresh-token reconnect with the pair still in the replay — not an eviction. + wait_until(|| transport.connect_count() >= 4).await; + wait_until(|| { + transport + .sent() + .iter() + .filter(|f| f.contains("\"ticker\"")) + .count() + >= 4 + }) + .await; + let replay = transport + .sent() + .into_iter() + .rev() + .find(|f| f.contains("\"ticker\"")) + .expect("a replayed subscribe"); + assert!( + replay.contains("BTC/USD"), + "the cured pair must stay in the replay: {replay}" + ); +} + +#[tokio::test] +async fn a_repeat_rejection_evicts_the_subscription_from_the_replay() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = spawn( + Endpoint::Public, + transport.clone(), + None, + vec![WsSubscription::Ticker { + symbol: vec!["BTC/USD".into(), "BOGUS/USD".into()], + event_trigger: None, + snapshot: None, + }], + None, + ); + let reject = r#"{"method":"subscribe","success":false,"error":"Currency pair not supported BOGUS/USD","symbol":"BOGUS/USD"}"#; + wait_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(frame(reject)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + transport.reader(1).send(frame(reject)).unwrap(); + transport + .reader(1) + .send(frame(r#"{"channel":"heartbeat"}"#)) + .unwrap(); + next_matching(&mut events, |e| matches!(e, Event::Heartbeat).then_some(())).await; + assert_eq!(transport.connect_count(), 2); + transport.reader(1).send(ConnEvent::Closed).unwrap(); + wait_until(|| { + transport + .sent() + .iter() + .filter(|f| f.contains("\"ticker\"")) + .count() + >= 3 + }) + .await; + let replay = transport + .sent() + .into_iter() + .rev() + .find(|f| f.contains("\"ticker\"")) + .expect("a replayed ticker subscribe"); + assert!(replay.contains("BTC/USD")); + assert!(!replay.contains("BOGUS/USD")); +} + +#[tokio::test] +async fn all_subscriptions_rejected_twice_stop_the_actor() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = spawn( + Endpoint::Public, + transport.clone(), + None, + vec![ticker("BOGUS/USD")], + None, + ); + let reject = r#"{"method":"subscribe","success":false,"error":"Currency pair not supported BOGUS/USD","symbol":"BOGUS/USD"}"#; + wait_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(frame(reject)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + transport.reader(1).send(frame(reject)).unwrap(); + let mut aborted = false; + wait_until(|| { + while let Ok(event) = events.try_recv() { + aborted |= matches!(event, Event::Aborted { .. }); + } + events.is_closed() + }) + .await; + assert!( + aborted, + "the terminal give-up must surface as Event::Aborted" + ); + assert_eq!(transport.connect_count(), 2); +} + +#[tokio::test] +async fn a_sole_symbol_less_subscription_rejected_twice_stops_the_actor() { + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(mint_cache(MockToken("secret"))), + vec![balances()], + None, + ); + let reject = r#"{"method":"subscribe","success":false,"error":"EGeneral:Permission denied"}"#; + wait_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(frame(reject)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + transport.reader(1).send(frame(reject)).unwrap(); + wait_until(|| { + while events.try_recv().is_ok() {} + events.is_closed() + }) + .await; + assert_eq!(transport.connect_count(), 2); +} + +#[tokio::test] +async fn two_symbol_less_subscriptions_both_rejected_twice_stop_the_actor() { + // Two account channels ride one Auth socket. When the fresh-token retry rejects + // both without naming a pair, nothing is streaming — the actor must abort, not + // idle forever on its own keepalive pongs. + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(mint_cache(MockToken("secret"))), + vec![executions(), balances()], + None, + ); + let reject = r#"{"method":"subscribe","success":false,"error":"EGeneral:Permission denied"}"#; + wait_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(frame(reject)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + transport.reader(1).send(frame(reject)).unwrap(); + transport.reader(1).send(frame(reject)).unwrap(); + let mut aborted = false; + wait_until(|| { + while let Ok(event) = events.try_recv() { + aborted |= matches!(event, Event::Aborted { .. }); + } + events.is_closed() + }) + .await; + assert!( + aborted, + "an all-channels-rejected socket must abort, not hang" + ); + assert_eq!(transport.connect_count(), 2); +} + +#[tokio::test] +async fn one_dead_channel_among_live_ones_does_not_stop_the_actor() { + // Only one of the two account channels is rejected on the fresh token: the + // healthy one keeps streaming and the socket stays up. + let transport = MockTransport::new(); + let (_shutdown, mut events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(mint_cache(MockToken("secret"))), + vec![executions(), balances()], + None, + ); + let reject = r#"{"method":"subscribe","success":false,"error":"EGeneral:Permission denied"}"#; + wait_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(frame(reject)).unwrap(); + wait_until(|| transport.connect_count() >= 2).await; + transport.reader(1).send(frame(reject)).unwrap(); + transport + .reader(1) + .send(frame(r#"{"channel":"heartbeat"}"#)) + .unwrap(); + next_matching(&mut events, |e| matches!(e, Event::Heartbeat).then_some(())).await; + assert_eq!(transport.connect_count(), 2, "must not reconnect or abort"); +} + +fn executions() -> WsSubscription { + WsSubscription::Executions { + snap_trades: None, + snap_orders: None, + order_status: None, + rebased: None, + ratecounter: None, + users: None, + } +} + +#[tokio::test] +async fn a_setup_failure_retries_as_a_first_send_not_a_replay() { + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = spawn( + Endpoint::Auth, + transport.clone(), + Some(mint_cache(FailThenToken { + calls: Arc::new(AtomicUsize::new(0)), + })), + vec![balances()], + std::num::NonZeroU64::new(7), + ); + wait_until(|| transport.sent().iter().any(|f| f.contains("\"balances\""))).await; + let subscribe = transport + .sent() + .into_iter() + .find(|f| f.contains("\"balances\"")) + .expect("a subscribe frame"); + assert!( + subscribe.contains("\"req_id\":7"), + "a first send echoes the caller's req_id: {subscribe}" + ); + assert!( + !subscribe.contains("\"snapshot\":true"), + "a first send keeps its own snapshot preference: {subscribe}" + ); +} + +#[tokio::test] +async fn connect_failures_do_not_stop_the_actor() { + let mut transport = MockTransport::new(); + transport.fail = true; + let (_shutdown, _events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 3).await; +} + +#[tokio::test] +async fn a_cancel_during_a_hung_connect_shuts_the_actor_down() { + let mut transport = MockTransport::new(); + transport.hang = true; + let (shutdown, _events, handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + + wait_until(|| transport.connect_count() >= 1).await; + shutdown.cancel(); // handle gone while the handshake is hung + + tokio::time::timeout(Duration::from_secs(1), handle) + .await + .expect("actor must shut down promptly during a hung connect") + .expect("actor task panicked"); +} + +#[tokio::test] +async fn the_actor_stops_when_every_consumer_drops() { + let transport = MockTransport::new(); + let (_shutdown, events, handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + wait_until(|| transport.connect_count() >= 1).await; + + drop(events); // the event stream dropped while the socket is live + let _ = transport + .reader(0) + .send(frame(r#"{"channel":"heartbeat"}"#)); + + tokio::time::timeout(Duration::from_secs(1), handle) + .await + .expect("actor must stop once every consumer is gone") + .expect("actor task panicked"); +} + +#[tokio::test(start_paused = true)] +async fn a_session_past_stable_after_resets_the_backoff_schedule() { + // Two instant drops escalate the schedule (1s, then 2s, base now 4s); a session + // that then streams past `stable_after` must reset it, so the next reconnect + // waits `initial` again instead of the escalated 4s. + let transport = MockTransport::new(); + let (events_tx, _events_rx) = mpsc::unbounded_channel(); + let shutdown = CancellationToken::new(); + let conn = StreamConnection { + endpoint: Endpoint::Public, + transport: transport.clone(), + token_cache: None, + events_tx, + shutdown: shutdown.child_token(), + mode: Stream { + policy: ReconnectPolicy { + initial: Duration::from_secs(1), + max: Duration::from_secs(8), + jitter_max: Duration::ZERO, + stable_after: Duration::from_secs(5), + }, + subs: vec![], + req_id: None, + }, + }; + let _handle = tokio::spawn(conn.run()); + + advance_until(|| transport.connect_count() >= 1).await; + transport.reader(0).send(ConnEvent::Closed).unwrap(); + advance_until(|| transport.connect_count() >= 2).await; + let second_drop = tokio::time::Instant::now(); + transport.reader(1).send(ConnEvent::Closed).unwrap(); + advance_until(|| transport.connect_count() >= 3).await; + assert!( + second_drop.elapsed() >= Duration::from_secs(2), + "the instant drops must have escalated the schedule, or the reset below pins nothing" + ); + + tokio::time::advance(Duration::from_secs(6)).await; // stream past stable_after + let stable_drop = tokio::time::Instant::now(); + transport.reader(2).send(ConnEvent::Closed).unwrap(); + advance_until(|| transport.connect_count() >= 4).await; + assert!( + stable_drop.elapsed() < Duration::from_secs(2), + "a stable session must reset the reconnect delay to initial, waited {:?}", + stable_drop.elapsed() + ); +} + +#[tokio::test(start_paused = true)] +async fn a_silent_socket_reconnects_after_the_read_idle_timeout() { + let transport = MockTransport::new(); + let (_shutdown, _events, _handle) = + spawn(Endpoint::Public, transport.clone(), None, vec![], None); + while transport.connect_count() < 1 { + tokio::task::yield_now().await; + } + tokio::time::advance(super::connection::READ_IDLE_TIMEOUT + Duration::from_secs(1)).await; + while transport.connect_count() < 2 { + tokio::task::yield_now().await; + } +} diff --git a/crates/kraken-ws/src/lib.rs b/crates/kraken-ws/src/lib.rs new file mode 100644 index 0000000..448fe74 --- /dev/null +++ b/crates/kraken-ws/src/lib.rs @@ -0,0 +1,32 @@ +//! Kraken v2 WebSocket transport over [`kraken_core`]'s wire model. +//! +//! [`StreamClient`] reconnects per endpoint; [`RequestClient`] uses single-use +//! sockets. Both share one connection layer and an abstract [`TokenSource`]. + +#![warn(missing_debug_implementations)] + +//! [`Event`] is exhaustive and configuration fields are public while this crate +//! remains workspace-private. + +// Tests assert with unwrap/panic by design; the workspace lints deny them elsewhere. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::panic))] + +mod backoff; +mod config; +mod connection; +#[cfg(test)] +mod connection_tests; +#[cfg(test)] +mod mock_transport; +mod request_client; +mod stream_client; +mod token; +mod transport; + +pub use backoff::ReconnectPolicy; +pub use config::{Urls, WsConfig}; +pub use connection::{AbortCause, Event}; +pub use request_client::{ReplyBatch, RequestClient}; +pub use stream_client::{EventStream, StreamClient}; +pub use token::{BoxFuture, Minted, TokenSource}; +pub use transport::dangerous_tls_connector; diff --git a/crates/kraken-ws/src/mock_transport.rs b/crates/kraken-ws/src/mock_transport.rs new file mode 100644 index 0000000..5c28442 --- /dev/null +++ b/crates/kraken-ws/src/mock_transport.rs @@ -0,0 +1,133 @@ +//! The scripted fake [`Transport`] shared by the connection test suites: records +//! every frame written, feeds each connection's reader from a test-controlled +//! channel, and fails or hangs dials on demand. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use kraken_core::error::{Error, Result}; +use tokio::sync::mpsc; + +use super::transport::{ConnEvent, ConnReader, ConnWriter, Transport}; + +/// Poll `cond` until true, panicking if it never holds — so an exhausted wait fails +/// loud here instead of falling through to a confusing downstream assert. +pub(crate) async fn wait_until(mut cond: impl FnMut() -> bool) { + for _ in 0..500 { + if cond() { + return; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + panic!("condition not met in time"); +} + +/// Records frames written across every connection, and exposes a per-connection +/// reader-feed sender so a test can push inbound frames (or close the socket). +#[derive(Clone)] +pub(crate) struct MockTransport { + sent: Arc>>, + readers: Arc>>>, + connects: Arc, + closes: Arc, + /// When set, every `connect()` fails — a permanently dead endpoint. + pub(crate) fail: bool, + /// Fail this many connect attempts before succeeding — a transient dial failure. + pub(crate) fail_first: Arc, + /// When set, `connect()` registers the attempt then never resolves — a hung + /// handshake, so a test can prove shutdown (or a dial budget) still ends it. + pub(crate) hang: bool, +} + +impl MockTransport { + pub(crate) fn new() -> Self { + Self { + sent: Arc::new(Mutex::new(Vec::new())), + readers: Arc::new(Mutex::new(Vec::new())), + connects: Arc::new(AtomicUsize::new(0)), + closes: Arc::new(AtomicUsize::new(0)), + fail: false, + fail_first: Arc::new(AtomicUsize::new(0)), + hang: false, + } + } + + pub(crate) fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } + + pub(crate) fn connect_count(&self) -> usize { + self.connects.load(Ordering::SeqCst) + } + + pub(crate) fn close_count(&self) -> usize { + self.closes.load(Ordering::SeqCst) + } + + /// The feed sender for the `nth` connection (0-based), once it has connected. + pub(crate) fn reader(&self, nth: usize) -> mpsc::UnboundedSender { + self.readers.lock().unwrap()[nth].clone() + } +} + +impl Transport for MockTransport { + type Writer = MockWriter; + type Reader = MockReader; + + async fn connect(&self) -> Result<(MockWriter, MockReader)> { + self.connects.fetch_add(1, Ordering::SeqCst); + if self.fail { + return Err(Error::Transport("mock connect failure".into())); + } + if self + .fail_first + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .is_ok() + { + return Err(Error::Transport("mock transient connect failure".into())); + } + if self.hang { + std::future::pending::<()>().await; + } + let (tx, rx) = mpsc::unbounded_channel(); + self.readers.lock().unwrap().push(tx); + Ok(( + MockWriter { + sent: self.sent.clone(), + closes: self.closes.clone(), + }, + MockReader { rx }, + )) + } +} + +pub(crate) struct MockWriter { + sent: Arc>>, + closes: Arc, +} + +impl ConnWriter for MockWriter { + async fn send_frame(&mut self, frame: &impl serde::Serialize) -> Result<()> { + self.sent + .lock() + .unwrap() + .push(serde_json::to_string(frame)?); + Ok(()) + } + + async fn close(&mut self) -> Result<()> { + self.closes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +pub(crate) struct MockReader { + rx: mpsc::UnboundedReceiver, +} + +impl ConnReader for MockReader { + async fn recv(&mut self) -> ConnEvent { + self.rx.recv().await.unwrap_or(ConnEvent::Closed) + } +} diff --git a/crates/kraken-ws/src/request_client.rs b/crates/kraken-ws/src/request_client.rs new file mode 100644 index 0000000..1b40763 --- /dev/null +++ b/crates/kraken-ws/src/request_client.rs @@ -0,0 +1,551 @@ +//! The one-shot request/response client — the request-side sibling of +//! [`StreamClient`](crate::StreamClient). +//! +//! The client owns the data: parsing each raw frame, correlating replies, gathering +//! the batch — complete at the request's expected reply count — and typing the +//! outcomes. Everything session-shaped (dial, token, deadlines, the teardown close +//! once the batch is done) belongs to the [`OneShotConnection`] it spawns per send, +//! answered over the events channel exactly as the stream actors answer their client. + +use std::sync::Arc; + +use kraken_core::endpoint::Endpoint; +use kraken_core::error::{Error, Result}; +use kraken_core::{Inbound, MethodResponse, Request}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::config::WsConfig; +use super::connection::{OneShot, OneShotConnection}; +use super::token::TokenCache; +use super::transport::ConnEvent; + +/// One-shot request/response client. Holds the connection config and its one token +/// cache (shared across sends); every send spawns its own single-use connection. +pub struct RequestClient { + cfg: WsConfig, + token: Option>, +} + +/// Hand-written: the token cache holds a live credential, so only its presence is +/// shown. +impl std::fmt::Debug for RequestClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RequestClient") + .field("cfg", &self.cfg) + .field("token", &self.token.as_ref().map(|_| "")) + .finish() + } +} + +impl RequestClient { + pub fn new(cfg: WsConfig) -> Self { + Self { + token: cfg.new_token_cache(), + cfg, + } + } + + /// Submit one request and collect every correlated reply, typed, in arrival order. + /// Most methods answer once; a multi-id `cancel_order` answers once per id, and each + /// reply projects into [`Request::Response`] individually, so a half-completed batch + /// still reports per-id outcomes — with [`ReplyBatch::stopped_short`] carrying the + /// typed reason the batch is incomplete. + /// + /// # Errors + /// An `Err` means no reply arrived at all. The modes a caller must tell apart + /// before retrying: + /// - [`Error::DialTimeout`]: the dial missed its budget — nothing was sent, safe + /// to retry. + /// - [`Error::SendTimeout`] / [`Error::Unacknowledged`] / [`Error::ReplyLost`]: the + /// frame may have reached the exchange — verify before retrying. + /// - [`Error::NoTokenSource`] / [`Error::MintFailed`]: no token source, or the mint + /// failed — pre-send, nothing reached the exchange. + /// - [`Error::Transport`]: the dial failed — nothing was sent. + pub async fn send( + &self, + params: P, + req_id: std::num::NonZeroU64, + ) -> Result> { + let req_id = req_id.get(); + let endpoint = params.endpoint(); + let expected = params.expected_replies().get(); + + // Fail closed before dialing: a token-requiring method with no source is a + // configuration error, not a session outcome. The connection then holds a + // cache exactly when its session authenticates — the same invariant the + // stream client establishes when it plans its endpoints. + let token_cache = if params.requires_token() { + Some(self.token.clone().ok_or(Error::NoTokenSource(endpoint))?) + } else { + None + }; + + let (events_tx, mut events) = mpsc::unbounded_channel(); + let shutdown = CancellationToken::new(); + let connection = OneShotConnection { + endpoint, + transport: self.cfg.transport(endpoint), + token_cache, + events_tx, + shutdown: shutdown.child_token(), + mode: OneShot { params, req_id }, + }; + // Dropped when this future ends — completed, failed, or cancelled — so the + // session task never outlives its one caller. + let _guard = shutdown.drop_guard(); + tokio::spawn(connection.run()); + + let (replies, stopped_short) = + collect_replies(&mut events, endpoint, req_id, expected).await?; + let outcomes: Vec> = replies + .into_iter() + .map(MethodResponse::into_result::

) + .collect(); + // A rejected session token is dead whatever its cache age; drop it so the + // next send re-mints instead of failing identically until the TTL lapses. + if let Some(cache) = &self.token + && saw_token_rejection(&outcomes) + { + cache.invalidate().await; + } + Ok(ReplyBatch { + outcomes, + stopped_short, + }) + } +} + +/// What one send produced: each correlated reply, typed, in arrival order. +#[derive(Debug)] +pub struct ReplyBatch { + pub outcomes: Vec>, + /// Why the session ended before every expected reply arrived, when it did — typed, + /// so a caller classifies the shortfall (verify-first, category) instead of + /// guessing from counts. `None` when the batch completed. + pub stopped_short: Option, +} + +/// Whether the venue rejected the session token itself — the cache-invalidation cue. +fn saw_token_rejection(outcomes: &[Result]) -> bool { + outcomes + .iter() + .any(|outcome| matches!(outcome, Err(error) if error.is_token_rejection())) +} + +/// A reply correlated to `req_id`, or `None` for anything else on the wire. An id-less +/// *error* reply is kept too: the server rejected before echoing the id, and only our +/// request is in flight on this single-use socket, so it is ours. +fn correlated(frame: Inbound, req_id: u64) -> Option { + match frame { + Inbound::Method(resp) if resp.req_id == Some(req_id) => Some(resp), + Inbound::Method(resp) if resp.req_id.is_none() && !resp.is_success() => Some(resp), + // Listed out (not `_`) so a new `Inbound` variant forces a decision here. + Inbound::Method(_) | Inbound::Channel(_) | Inbound::Heartbeat => None, + } +} + +/// How a gather run ended. +enum GatherEnd { + /// The batch completed: `expected` replies, or an id-less whole-frame rejection + /// (the per-id replies it leaves unanswered were never owed). + Complete, + /// The server closed the socket before the batch completed. + ServerClosed, + /// The connection reported a terminal stop reason. + Stopped(Error), +} + +/// What one gather run produced. +struct Gathered { + batch: Vec, + /// The first frame that failed the pinned schema, when one was seen. + malformed: Option, + end: GatherEnd, +} + +/// Gather every correlated reply until the batch completes — `expected` replies, or an +/// id-less whole-frame rejection — or the session ends. +/// +/// Each raw frame is parsed here — this client's strictness, not the connection's. A +/// frame that fails the pinned schema is recorded (first kept), never fatal: later +/// replies of a batch may still parse. +async fn gather( + events: &mut mpsc::UnboundedReceiver, + endpoint: Endpoint, + req_id: u64, + expected: usize, +) -> Gathered { + let mut batch = Vec::new(); + let mut malformed = None; + let end = loop { + match events.recv().await { + Some(ConnEvent::Frame(text)) => match text.parse::() { + Ok(frame) => { + if let Some(resp) = correlated(frame, req_id) { + // An id-less failure answers the whole frame: no further + // replies are owed, however many ids the request carried. + let whole_frame = resp.req_id.is_none() && !resp.is_success(); + batch.push(resp); + if batch.len() >= expected || whole_frame { + break GatherEnd::Complete; + } + } + } + Err(error) => { + tracing::warn!(%error, "inbound frame failed the pinned schema"); + malformed.get_or_insert(error); + } + }, + Some(ConnEvent::Closed) => break GatherEnd::ServerClosed, + // The connection brands its stop reasons by phase: pre-send failures + // (dial, mint) keep their own retry-safe class, post-send ones carry + // the verify-first marker. + Some(ConnEvent::Error(reason)) => break GatherEnd::Stopped(reason), + // The connection emits a terminal event before ending, so a bare channel + // close means the session task died. The phase is unknowable, so the safe + // reading is verify-first. + None => { + break GatherEnd::Stopped(Error::ReplyLost(Box::new(Error::ConnectionClosed( + endpoint, + )))); + } + } + }; + Gathered { + batch, + malformed, + end, + } +} + +/// Gather replies until the batch completes or the session ends. An empty batch is +/// the shortfall verdict as `Err`; a partial one survives as `Ok` with that same +/// verdict riding along, so the reason it stopped short never vanishes at this seam. +/// +/// The verdict for an early end: missing replies are verify-first territory whatever +/// else was seen — a frame arrived only if the request was sent. A recorded malformed +/// frame rides along as the [`Error::ReplyLost`] cause (it may well have been the +/// drifted reply) rather than replacing the verdict: a bare parse error would classify +/// as non-retryable with no instruction to check order state. With nothing recorded, +/// the connection's stop reason, then bare [`Error::Unacknowledged`] for a server that +/// closed without answering. A completed batch takes the early return — no verdict +/// path exists for it to take. +async fn collect_replies( + events: &mut mpsc::UnboundedReceiver, + endpoint: Endpoint, + req_id: u64, + expected: usize, +) -> Result<(Vec, Option)> { + let Gathered { + batch, + malformed, + end, + } = gather(events, endpoint, req_id, expected).await; + + let verdict = match (malformed, end) { + (_, GatherEnd::Complete) => return Ok((batch, None)), + (Some(drift), _) => Error::ReplyLost(Box::new(drift)), + (None, GatherEnd::Stopped(stop)) => stop, + (None, GatherEnd::ServerClosed) => Error::Unacknowledged, + }; + if batch.is_empty() { + return Err(verdict); + } + Ok((batch, Some(verdict))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A gather feed as the connection would deliver it: the scripted events, then the + /// channel closes (the sender drops here). + fn feed(events: Vec) -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel(); + for event in events { + tx.send(event).expect("the receiver is held"); + } + rx + } + + fn frame(s: &str) -> ConnEvent { + ConnEvent::Frame(s.into()) + } + + fn reply(req_id: u64, order_id: &str) -> ConnEvent { + frame(&format!( + r#"{{"method":"cancel_order","success":true,"result":{{"order_id":"{order_id}"}},"req_id":{req_id}}}"# + )) + } + + fn error(msg: &str) -> ConnEvent { + ConnEvent::Error(Error::Transport(msg.into())) + } + + /// A reply whose result body drifted from the pinned schema (`order_id` as a number). + const DRIFTED_REPLY: &str = + r#"{"method":"add_order","success":true,"result":{"order_id":42},"req_id":5}"#; + + #[test] + fn only_a_token_rejection_triggers_invalidation() { + let dead: Vec> = vec![Err(Error::FrameRejected("EAPI:Invalid token".into()))]; + assert!(saw_token_rejection(&dead)); + let ordinary: Vec> = + vec![Err(Error::ServerRejected("EOrder:Unknown order".into()))]; + assert!( + !saw_token_rejection(&ordinary), + "an order rejection keeps the token" + ); + } + + #[tokio::test] + async fn collects_the_single_reply_ended_by_the_server_close() { + let mut events = feed(vec![reply(5, "O1"), ConnEvent::Closed]); + let (batch, stopped_short) = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap(); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].req_id, Some(5)); + assert!(stopped_short.is_none(), "a complete batch stopped nothing"); + } + + #[tokio::test] + async fn a_full_batch_ends_the_gather_without_a_server_close() { + // The close-order inversion: the batch is complete at the expected count, no + // server close required — the old design needed the server's close as the + // end-of-batch marker, which raced the reply itself (RFC 6455 lets a server + // drop pending data once it echoes a Close). + let mut events = feed(vec![reply(7, "O1"), reply(7, "O2"), reply(7, "O3")]); + let (batch, _) = collect_replies(&mut events, Endpoint::Auth, 7, 3) + .await + .unwrap(); + assert_eq!(batch.len(), 3); + } + + #[tokio::test] + async fn an_id_less_rejection_ends_a_multi_id_gather_early() { + // A whole-frame rejection is the only reply there will ever be: waiting for + // the other per-id replies would stall until the reply deadline. + let mut events = feed(vec![frame( + r#"{"method":"cancel_order","success":false,"error":"EGeneral:Invalid arguments"}"#, + )]); + let (batch, stopped_short) = collect_replies(&mut events, Endpoint::Auth, 7, 3) + .await + .unwrap(); + assert_eq!(batch.len(), 1); + assert!( + stopped_short.is_none(), + "the per-id replies a whole-frame rejection leaves unanswered were never owed" + ); + } + + #[tokio::test] + async fn gathers_every_reply_of_a_multi_id_batch() { + let mut events = feed(vec![ + reply(7, "O1"), + reply(7, "O2"), + reply(7, "O3"), + ConnEvent::Closed, + ]); + let (batch, _) = collect_replies(&mut events, Endpoint::Auth, 7, 3) + .await + .unwrap(); + assert_eq!(batch.len(), 3); + assert!(batch.iter().all(|resp| resp.req_id == Some(7))); + } + + #[tokio::test] + async fn a_pong_or_mismatched_id_is_not_counted() { + let mut events = feed(vec![ + frame(r#"{"method":"pong"}"#), + reply(99, "OTHER"), + reply(5, "O1"), + ConnEvent::Closed, + ]); + let (batch, _) = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap(); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].req_id, Some(5)); + } + + #[tokio::test] + async fn an_id_less_error_reply_is_ours_on_a_single_request_socket() { + let mut events = feed(vec![ + frame(r#"{"method":"add_order","success":false,"error":"EGeneral:Invalid arguments"}"#), + ConnEvent::Closed, + ]); + let (batch, _) = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap(); + assert_eq!(batch.len(), 1); + assert_eq!( + batch[0].error.as_deref(), + Some("EGeneral:Invalid arguments") + ); + } + + #[tokio::test] + async fn an_empty_close_is_unacknowledged_never_blindly_retryable() { + // The server received the request (its close is the end-of-batch marker) and + // answered nothing: the order may have executed, so no retry-safe error here. + let mut events = feed(vec![ConnEvent::Closed]); + let err = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap_err(); + assert!(matches!(err, Error::Unacknowledged)); + } + + #[tokio::test] + async fn a_dead_session_task_is_branded_verify_first() { + // No terminal event at all: the defensive arm for a task that died mid-session. + // The phase is unknowable, so the safe reading is "may have been sent". + let mut events = feed(Vec::new()); + let err = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap_err(); + assert!(matches!( + err, + Error::ReplyLost(cause) if matches!(*cause, Error::ConnectionClosed(Endpoint::Auth)) + )); + } + + #[tokio::test] + async fn a_pre_send_stop_reason_passes_through_with_its_own_class() { + // The connection brands by phase; the gather must neither add nor strip a + // brand. A dial failure arrives bare and must stay blindly retryable. + let mut events = feed(vec![error("connection refused")]); + let err = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap_err(); + assert!(matches!(&err, Error::Transport(reason) if reason == "connection refused")); + assert!( + !err.to_string().contains("verify its state"), + "a pre-send failure must not carry the verify-first marker: {err}" + ); + } + + #[tokio::test] + async fn a_drifted_reply_is_verify_first_carrying_the_parse_detail() { + // A frame arrived, so the request reached the exchange: the verdict must be + // verify-first, with the parse failure as its cause — a bare Json error would + // classify as `parse` ("do not retry") and suppress the check-your-orders + // instruction. + let mut events = feed(vec![frame(DRIFTED_REPLY), ConnEvent::Closed]); + let err = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap_err(); + assert!(err.is_verify_first(), "got: {err:?}"); + assert!( + matches!(&err, Error::ReplyLost(cause) if matches!(&**cause, Error::Json(_))), + "the parse detail must survive as the cause: {err:?}" + ); + } + + #[tokio::test] + async fn a_drifted_reply_then_the_reply_deadline_still_reports_the_parse_detail() { + // Silence arrives as the connection's Unacknowledged; the recorded parse + // failure is the more diagnostic cause and must not vanish behind it. + let mut events = feed(vec![ + frame(DRIFTED_REPLY), + ConnEvent::Error(Error::Unacknowledged), + ]); + let err = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap_err(); + assert!(err.is_verify_first(), "got: {err:?}"); + assert!( + matches!(&err, Error::ReplyLost(cause) if matches!(&**cause, Error::Json(_))), + "got: {err:?}" + ); + } + + #[tokio::test] + async fn a_drifted_side_frame_does_not_mask_a_parseable_reply() { + let mut events = feed(vec![ + frame(DRIFTED_REPLY), + reply(5, "O1"), + ConnEvent::Closed, + ]); + let (batch, _) = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap(); + assert_eq!(batch.len(), 1); + } + + #[tokio::test] + async fn a_branded_stop_reason_keeps_its_marker_and_cause() { + // What the connection emits for a post-send socket loss: the verify-first + // brand and the underlying cause must both survive the gather. + let mut events = feed(vec![ConnEvent::Error(Error::ReplyLost(Box::new( + Error::Transport("boom".into()), + )))]); + let err = collect_replies(&mut events, Endpoint::Auth, 5, 1) + .await + .unwrap_err(); + assert!(matches!( + &err, + Error::ReplyLost(cause) if matches!(&**cause, Error::Transport(reason) if reason == "boom") + )); + assert!( + err.to_string().contains("verify its state before retrying"), + "the envelope message must carry the verify-first marker: {err}" + ); + } + + #[tokio::test] + async fn a_partial_batch_survives_a_mid_batch_stream_error_with_its_stop_reason() { + let mut events = feed(vec![reply(7, "O1"), reply(7, "O2"), error("reset")]); + let (batch, stopped_short) = collect_replies(&mut events, Endpoint::Auth, 7, 3) + .await + .unwrap(); + assert_eq!(batch.len(), 2); + assert!( + matches!(stopped_short, Some(Error::Transport(reason)) if reason == "reset"), + "the typed stop reason must ride along with the partial batch" + ); + } + + #[tokio::test] + async fn a_token_requiring_method_with_no_source_fails_before_dialing() { + // No credentials configured: the send must fail closed without a socket. + let client = RequestClient::new(WsConfig::default()); + let err = client + .send(kraken_core::CancelAllParams {}, std::num::NonZeroU64::MIN) + .await + .unwrap_err(); + assert!(matches!(err, Error::NoTokenSource(Endpoint::Auth))); + } + + #[tokio::test] + async fn a_partial_batch_survives_the_reply_deadline_keeping_the_verify_first_verdict() { + let mut events = feed(vec![ + reply(7, "O1"), + reply(7, "O2"), + ConnEvent::Error(Error::Unacknowledged), + ]); + let (batch, stopped_short) = collect_replies(&mut events, Endpoint::Auth, 7, 3) + .await + .unwrap(); + assert_eq!(batch.len(), 2); + let stop = stopped_short.expect("the deadline is the reason the batch is short"); + assert!( + stop.is_verify_first(), + "the missing replies' verdict must not lose its marker: {stop:?}" + ); + } + + #[tokio::test] + async fn a_partial_batch_truncated_by_the_close_brands_the_missing_unacknowledged() { + // The server closed after answering one of three ids: the other two may have + // executed, so the shortfall must carry the verify-first verdict — the same + // one an entirely unanswered request gets — not read as a mere count gap. + let mut events = feed(vec![reply(7, "O1"), ConnEvent::Closed]); + let (batch, stopped_short) = collect_replies(&mut events, Endpoint::Auth, 7, 3) + .await + .unwrap(); + assert_eq!(batch.len(), 1); + assert!(matches!(stopped_short, Some(Error::Unacknowledged))); + } +} diff --git a/crates/kraken-ws/src/stream_client.rs b/crates/kraken-ws/src/stream_client.rs new file mode 100644 index 0000000..0c393e8 --- /dev/null +++ b/crates/kraken-ws/src/stream_client.rs @@ -0,0 +1,230 @@ +//! The streaming client — the stream-side sibling of [`RequestClient`](crate::RequestClient). +//! +//! [`StreamClient::connect`] plans one connection per [`Endpoint`], mints each +//! private endpoint's token once (failing fast on a bad key), spawns one +//! self-reconnecting actor per endpoint, and returns a control handle plus the +//! [`EventStream`] the actors fan into. Shutdown is drop-triggered from either end: +//! dropping the handle cancels every actor; dropping the stream fails their next send +//! and they wind down. + +use std::num::NonZeroU64; + +use kraken_core::WsSubscription; +use kraken_core::endpoint::Endpoint; +use kraken_core::error::{Error, Result}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tokio_util::sync::{CancellationToken, DropGuard}; + +use super::backoff::ReconnectPolicy; +use super::config::WsConfig; +use super::connection::{Event, Stream, StreamConnection}; + +/// Which endpoints to open for a set of subscriptions, given the credential state. +struct EndpointPlan { + /// `(endpoint, its subscriptions)` per endpoint to open, in first-seen order. + open: Vec<(Endpoint, Vec)>, + /// Subscriptions dropped because no credentials are configured. + skipped: Vec, +} + +/// Group `subs` by endpoint, applying the no-credentials policy. Pure, so the +/// partition/skip policy is testable without a socket or token. +fn plan_endpoints(subs: Vec, have_token: bool) -> EndpointPlan { + let mut open: Vec<(Endpoint, Vec)> = Vec::new(); + let mut skipped: Vec = Vec::new(); + for sub in subs { + if sub.requires_token() && !have_token { + skipped.push(sub); + continue; + } + let endpoint = sub.endpoint(); + match open.iter_mut().find(|(e, _)| *e == endpoint) { + Some((_, group)) => group.push(sub), + None => open.push((endpoint, vec![sub])), + } + } + EndpointPlan { open, skipped } +} + +/// A handle to the streaming connections for one session. Dropping it shuts every +/// connection down; the event data flows through the separate [`EventStream`]. +#[must_use = "dropping the StreamClient handle shuts every connection down"] +#[derive(Debug)] +pub struct StreamClient { + /// Cancels every spawned actor when the handle is dropped. + _guard: DropGuard, + skipped_for_credentials: usize, +} + +/// The decoded [`Event`]s from every connection actor, fanned into one sequence. It +/// buffers from the moment the actors spawn, so no early event is lost before the +/// caller starts draining; dropping it signals every actor to wind down. +pub type EventStream = UnboundedReceiverStream; + +impl StreamClient { + /// Open the streaming connections for `subs`, returning the control handle and the + /// [`EventStream`]. Private endpoints are skipped (with a warning) when no + /// credentials are configured; each surviving private endpoint's token is minted up + /// front so a bad key fails now rather than in a forever-reconnecting actor. + /// `req_id` is the caller's `--req-id`, echoed on the subscribe ack. + /// + /// # Errors + /// - [`Error::NoTokenSource`]: every requested channel is private and no credentials + /// are configured (fail closed). + /// - [`Error::TokenRejected`] / [`Error::MintFailed`]: the up-front token mint + /// failed. + pub async fn connect( + subs: Vec, + req_id: Option, + cfg: &WsConfig, + policy: ReconnectPolicy, + ) -> Result<(Self, EventStream)> { + let token_cache = cfg.new_token_cache(); + let plan = plan_endpoints(subs, token_cache.is_some()); + for sub in &plan.skipped { + tracing::warn!( + channel = %sub.channel(), + endpoint = %sub.endpoint(), + "skipping authenticated subscription: credentials required but none configured" + ); + } + if plan.open.is_empty() { + // Fail closed: an auth error, not a silent empty capture. Empty `subs` is + // guarded upstream, so the `None` arm is defensive. + return match plan.skipped.first() { + Some(sub) => Err(Error::NoTokenSource(sub.endpoint())), + None => Err(Error::Validation("no subscriptions to stream".into())), + }; + } + let skipped_for_credentials = plan.skipped.len(); + + // Up-front mint (the cache bounds it): a bad key or wedged REST endpoint fails + // the command now rather than in a forever-reconnecting actor. + if let Some(cache) = token_cache + .as_ref() + .filter(|_| plan.open.iter().any(|(e, _)| e.requires_token())) + { + cache.get().await?; + } + + // Unbounded fan-in so a burst never back-pressures an actor's read loop; the + // receiver buffers events emitted before the caller drains. + let (events_tx, events_rx) = mpsc::unbounded_channel(); + let shutdown = CancellationToken::new(); + for (endpoint, endpoint_subs) in plan.open { + let connection = StreamConnection { + endpoint, + transport: cfg.transport(endpoint), + token_cache: token_cache.clone().filter(|_| endpoint.requires_token()), + events_tx: events_tx.clone(), + shutdown: shutdown.child_token(), + mode: Stream { + policy, + subs: endpoint_subs, + req_id, + }, + }; + tokio::spawn(connection.run()); + } + // Only the actors' clones remain, so the stream ends when the last actor stops. + drop(events_tx); + Ok(( + Self { + _guard: shutdown.drop_guard(), + skipped_for_credentials, + }, + EventStream::new(events_rx), + )) + } + + /// How many requested subscriptions the planner dropped for missing credentials — + /// reported so the caller never re-derives the skip policy. + pub fn skipped_for_credentials(&self) -> usize { + self.skipped_for_credentials + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::token::{BoxFuture, Minted, TokenSource}; + + fn ticker() -> WsSubscription { + WsSubscription::Ticker { + symbol: vec!["BTC/USD".into()], + event_trigger: None, + snapshot: None, + } + } + + fn balances() -> WsSubscription { + WsSubscription::Balances { + snapshot: None, + rebased: None, + users: None, + } + } + + #[test] + fn plan_keeps_public_and_skips_private_without_credentials() { + let plan = plan_endpoints(vec![ticker(), balances()], false); + assert_eq!(plan.open.len(), 1); + assert_eq!(plan.open[0].0, Endpoint::Public); + assert_eq!(plan.skipped.len(), 1); + assert_eq!(plan.skipped[0].endpoint(), Endpoint::Auth); + } + + #[test] + fn plan_opens_private_endpoints_when_credentials_are_present() { + let plan = plan_endpoints(vec![ticker(), balances()], true); + let opened: Vec = plan.open.iter().map(|(e, _)| *e).collect(); + assert_eq!(opened, vec![Endpoint::Public, Endpoint::Auth]); + assert!(plan.skipped.is_empty()); + } + + #[test] + fn plan_groups_subscriptions_that_share_an_endpoint() { + let plan = plan_endpoints(vec![ticker(), ticker()], false); + assert_eq!(plan.open.len(), 1); + assert_eq!(plan.open[0].1.len(), 2); + } + + #[test] + fn plan_leaves_nothing_open_when_all_private_and_unauthenticated() { + let plan = plan_endpoints(vec![balances()], false); + assert!(plan.open.is_empty()); + assert_eq!(plan.skipped.len(), 1); + assert_eq!(plan.skipped[0].endpoint(), Endpoint::Auth); + } + + /// A token source whose mint always fails, for the bad-key path. + struct FailingToken; + + impl TokenSource for FailingToken { + fn fetch(&self) -> BoxFuture> { + Box::pin(async { Err(Error::TokenRejected("EAPI:Invalid key".into())) }) + } + } + + #[tokio::test] + async fn connect_fails_fast_when_a_private_token_mint_fails() { + let cfg = WsConfig { + token_source: Some(Arc::new(FailingToken)), + ..WsConfig::default() + }; + let result = + StreamClient::connect(vec![balances()], None, &cfg, ReconnectPolicy::default()).await; + assert!(matches!(result, Err(Error::TokenRejected(_)))); + } + + #[tokio::test] + async fn connect_fails_closed_for_an_all_private_unauthenticated_stream() { + let cfg = WsConfig::default(); + let result = + StreamClient::connect(vec![balances()], None, &cfg, ReconnectPolicy::default()).await; + assert!(matches!(result, Err(Error::NoTokenSource(Endpoint::Auth)))); + } +} diff --git a/crates/kraken-ws/src/token.rs b/crates/kraken-ws/src/token.rs new file mode 100644 index 0000000..c590daa --- /dev/null +++ b/crates/kraken-ws/src/token.rs @@ -0,0 +1,365 @@ +//! WebSocket authentication tokens: private frames carry a short-lived, +//! connection-scoped `token` inside `params`. Retrieval sits behind [`TokenSource`] +//! (implemented by the application, keeping this crate REST-free); [`TokenCache`] +//! caches the current value and single-flights concurrent fetches. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use kraken_core::error::{Error, Result}; +use tokio::sync::Mutex; + +/// Budget for one token mint, so a wedged REST endpoint fails the command instead of +/// stalling it. +const TOKEN_TIMEOUT: Duration = Duration::from_secs(10); + +/// Cache freshness when the source states no lifetime (Kraken invalidates an unused +/// token ~15 minutes after minting). +const FALLBACK_TTL: Duration = Duration::from_secs(10 * 60); + +/// Shaved off a server-stated lifetime: slack between serving a token and the dial +/// that uses it. +const EXPIRY_MARGIN: Duration = Duration::from_secs(60); + +/// Cap on any cached lifetime: `fresh_until` is `now + ttl`, and a nonsensical +/// venue-stated `expires_in` must clamp here instead of overflowing that Add. +const MAX_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// The object-safe return type for [`TokenSource::fetch`], so the client can hold an +/// `Arc` without being generic over it. +pub type BoxFuture = Pin + Send>>; + +/// A freshly minted token, with the server-stated lifetime when the source knows it +/// (Kraken reports `expires` alongside the token). +pub struct Minted { + pub token: String, + /// `None`: the cache falls back to a conservative client-side lifetime. + pub expires_in: Option, +} + +/// Hand-written: the token is a live credential and must never reach a log via +/// `{:?}`. +impl std::fmt::Debug for Minted { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Minted") + .field("token", &"[redacted]") + .field("expires_in", &self.expires_in) + .finish() + } +} + +impl From for Minted { + fn from(token: String) -> Self { + Self { + token, + expires_in: None, + } + } +} + +impl From<&str> for Minted { + fn from(token: &str) -> Self { + Self::from(token.to_string()) + } +} + +/// Supplies authentication tokens (Kraken: the REST `GetWebSocketsToken` call). +/// Object-safe so the client stays non-generic; the live impl is the application wiring's. +pub trait TokenSource: Send + Sync { + /// Fetch a fresh token: on first private use and on every reconnect. A rejected + /// mint (a credentials problem) must map to `Error::TokenRejected`; any other + /// failure to `Error::MintFailed`, carrying the original error whole. + fn fetch(&self) -> BoxFuture>; +} + +/// A cached token, servable until `fresh_until` — an absolute `tokio::time::Instant`, +/// so freshness is testable under paused virtual time. +struct CachedToken { + token: String, + fresh_until: tokio::time::Instant, +} + +impl CachedToken { + fn is_fresh(&self) -> bool { + tokio::time::Instant::now() < self.fresh_until + } +} + +/// Caches the current token — served only while fresh — and single-flights concurrent +/// fetches: the fetch runs while the async lock is held, so a cold-cache burst makes +/// one upstream request. +pub(crate) struct TokenCache { + source: Arc, + cached: Mutex>, +} + +impl TokenCache { + /// Wrap a [`TokenSource`], starting with an empty cache. + pub(crate) fn new(source: Arc) -> Self { + Self { + source, + cached: Mutex::new(None), + } + } + + /// The cached token, fetching one if the cache is empty or stale. The mint is + /// bounded by [`TOKEN_TIMEOUT`]. + pub(crate) async fn get(&self) -> Result { + let mut slot = self.cached.lock().await; + if let Some(cached) = slot.as_ref() + && cached.is_fresh() + { + return Ok(cached.token.clone()); + } + self.fetch_bounded(&mut slot).await + } + + /// Force a new fetch and return *this* mint (a later `get()` could observe a + /// sibling's concurrent refresh). A failed fetch leaves the cache unchanged. + pub(crate) async fn refresh(&self) -> Result { + let mut slot = self.cached.lock().await; + self.fetch_bounded(&mut slot).await + } + + /// Drop the cached token: the venue rejected it, so it is dead whatever its age. + /// May discard a sibling's concurrent fresh mint — one extra mint on the next + /// `get()`, never a wrong token. + pub(crate) async fn invalidate(&self) { + *self.cached.lock().await = None; + } + + /// Mint within the [`TOKEN_TIMEOUT`] budget and cache the result. Runs under the + /// single-flight lock its caller holds, so a wedged fetch releases the lock at the + /// bound instead of never. + async fn fetch_bounded(&self, slot: &mut Option) -> Result { + let minted = tokio::time::timeout(TOKEN_TIMEOUT, self.source.fetch()) + .await + .map_err(|_| Error::MintTimeout(TOKEN_TIMEOUT))??; + let ttl = minted + .expires_in + .map_or(FALLBACK_TTL, |lifetime| { + lifetime.saturating_sub(EXPIRY_MARGIN) + }) + .min(MAX_TTL); + *slot = Some(CachedToken { + token: minted.token.clone(), + fresh_until: tokio::time::Instant::now() + ttl, + }); + Ok(minted.token) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use kraken_core::error::Error; + + use super::*; + + /// A [`TokenSource`] whose fetch never resolves, for the mint-timeout path. + struct StalledSource; + + impl TokenSource for StalledSource { + fn fetch(&self) -> BoxFuture> { + Box::pin(std::future::pending()) + } + } + + /// A [`TokenSource`] that counts fetches and yields a distinct token each call + /// (`tok-{n}`). The internal yield gives a concurrent caller a chance to observe a cold + /// cache if the lock were ever released across the fetch, so the single-flight test can + /// catch that regression; `fail` drives the refresh error path. + #[derive(Default)] + struct CountingSource { + calls: AtomicUsize, + fail: AtomicBool, + } + + impl TokenSource for CountingSource { + fn fetch(&self) -> BoxFuture> { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + let fail = self.fail.load(Ordering::SeqCst); + Box::pin(async move { + tokio::task::yield_now().await; + if fail { + Err(Error::TokenRejected("mint failed".into())) + } else { + Ok(format!("tok-{n}").into()) + } + }) + } + } + + /// Yields `tok-{n}` with a server-stated lifetime. + struct ExpiringSource { + calls: AtomicUsize, + expires_in: Duration, + } + + impl TokenSource for ExpiringSource { + fn fetch(&self) -> BoxFuture> { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + let expires_in = self.expires_in; + Box::pin(async move { + Ok(Minted { + token: format!("tok-{n}"), + expires_in: Some(expires_in), + }) + }) + } + } + + #[tokio::test] + async fn get_caches_after_the_first_fetch() { + let src = Arc::new(CountingSource::default()); + let cache = TokenCache::new(src.clone()); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + assert_eq!( + src.calls.load(Ordering::SeqCst), + 1, + "second get is served from cache" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_cold_get_makes_a_single_fetch() { + let src = Arc::new(CountingSource::default()); + let cache = Arc::new(TokenCache::new(src.clone())); + let handles: Vec<_> = (0..8) + .map(|_| { + let cache = cache.clone(); + tokio::spawn(async move { cache.get().await }) + }) + .collect(); + for handle in handles { + assert_eq!(handle.await.unwrap().unwrap(), "tok-0"); + } + assert_eq!(src.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn refresh_replaces_the_cached_token() { + let src = Arc::new(CountingSource::default()); + let cache = TokenCache::new(src.clone()); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + assert_eq!( + cache.refresh().await.unwrap(), + "tok-1", + "refresh returns the token it just minted" + ); + assert_eq!( + cache.get().await.unwrap(), + "tok-1", + "get serves the refreshed value" + ); + assert_eq!( + src.calls.load(Ordering::SeqCst), + 2, + "get after refresh does not refetch" + ); + } + + #[tokio::test] + async fn a_failed_refresh_leaves_the_cached_token_intact() { + let src = Arc::new(CountingSource::default()); + let cache = TokenCache::new(src.clone()); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + src.fail.store(true, Ordering::SeqCst); + assert!(cache.refresh().await.is_err()); + src.fail.store(false, Ordering::SeqCst); + assert_eq!( + cache.get().await.unwrap(), + "tok-0", + "the cache survived the failed refresh" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_stale_cached_token_is_re_minted_not_served() { + // A token unused past its venue lifetime is dead on arrival at a fresh + // socket, and the one-shot path never re-mints — so the cache must never + // serve it in the first place. + let src = Arc::new(CountingSource::default()); + let cache = TokenCache::new(src.clone()); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + tokio::time::advance(FALLBACK_TTL + Duration::from_secs(1)).await; + assert_eq!(cache.get().await.unwrap(), "tok-1"); + assert_eq!(src.calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn invalidate_forces_the_next_get_to_re_mint() { + let src = Arc::new(CountingSource::default()); + let cache = TokenCache::new(src.clone()); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + cache.invalidate().await; + assert_eq!( + cache.get().await.unwrap(), + "tok-1", + "a rejected token is never re-served" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_server_stated_lifetime_overrides_the_fallback() { + let src = Arc::new(ExpiringSource { + calls: AtomicUsize::new(0), + expires_in: Duration::from_secs(120), + }); + let cache = TokenCache::new(src.clone()); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + tokio::time::advance(Duration::from_secs(59)).await; + assert_eq!( + cache.get().await.unwrap(), + "tok-0", + "fresh inside expires - margin" + ); + tokio::time::advance(Duration::from_secs(2)).await; + assert_eq!( + cache.get().await.unwrap(), + "tok-1", + "stale past expires - margin" + ); + } + + #[tokio::test] + async fn an_absurd_server_lifetime_is_clamped_not_a_panic() { + // `fresh_until` is `now + ttl`: unclamped, a nonsensical venue `expires_in` + // overflows the Instant's range and panics. + let src = Arc::new(ExpiringSource { + calls: AtomicUsize::new(0), + expires_in: Duration::MAX, + }); + let cache = TokenCache::new(src); + assert_eq!(cache.get().await.unwrap(), "tok-0"); + } + + #[tokio::test(start_paused = true)] + async fn a_wedged_mint_fails_at_the_budget_instead_of_stalling() { + let cache = TokenCache::new(Arc::new(StalledSource)); + let err = cache.get().await.unwrap_err(); + assert!(matches!(err, Error::MintTimeout(_))); + } + + /// The bound covers only the fetch, not the lock wait: a waiter queued behind a + /// wedged holder still errors once its own fetch hits the budget, rather than + /// blocking on the shared cache forever. + #[tokio::test(start_paused = true)] + async fn a_waiter_behind_a_wedged_mint_is_bounded_too() { + let cache = Arc::new(TokenCache::new(Arc::new(StalledSource))); + let holder = tokio::spawn({ + let cache = cache.clone(); + async move { cache.get().await } + }); + let waiter = tokio::spawn({ + let cache = cache.clone(); + async move { cache.get().await } + }); + assert!(holder.await.unwrap().is_err()); + assert!(waiter.await.unwrap().is_err()); + } +} diff --git a/crates/kraken-ws/src/transport.rs b/crates/kraken-ws/src/transport.rs new file mode 100644 index 0000000..f540e5a --- /dev/null +++ b/crates/kraken-ws/src/transport.rs @@ -0,0 +1,261 @@ +//! The transport seam: a split read/write socket plus the live `tokio-tungstenite` +//! implementation. Split so the actor can hold the [`ConnWriter`] while `select!`-ing +//! on the [`ConnReader`] (one combined `&mut` can't), and so a mock can script a feed. +//! Every write is time-bounded here; the read's liveness bound stays with the callers — +//! an inner timer would reset on every `select!` re-poll and never trip. + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::stream::{SplitSink, SplitStream}; +use futures_util::{SinkExt, StreamExt}; +use kraken_core::error::{Error, Result}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, Error as RustlsError, SignatureScheme}; +use serde::Serialize; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::{HeaderValue, Request}; +use tokio_tungstenite::tungstenite::{Message, Utf8Bytes}; +use tokio_tungstenite::{ + Connector as TlsConnector, MaybeTlsStream, WebSocketStream, connect_async, + connect_async_tls_with_config, +}; + +const CLOSE_TIMEOUT: Duration = Duration::from_secs(2); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const SEND_TIMEOUT: Duration = Duration::from_secs(10); + +/// A connected `tokio-tungstenite` stream. +type WsStream = WebSocketStream>; + +/// An event read from a connection. This seam ships raw frames — parsing them, and +/// tolerating ones that fail the pinned schema, is each consumer's concern. +#[derive(Debug)] +pub(crate) enum ConnEvent { + /// One raw inbound text frame, verbatim — a cheaply-cloneable view of the wire + /// bytes, not a copy. + Frame(Utf8Bytes), + /// The peer closed, or the stream ended. + Closed, + Error(Error), +} + +/// The write half of a live connection. +pub(crate) trait ConnWriter: Send { + /// Serialize `frame` and send it as one text frame. + async fn send_frame(&mut self, frame: &impl Serialize) -> Result<()>; + + /// Close handshake. The outcome is the caller's to interpret — teardown paths trace a + /// failure and drop the writer (an abrupt TCP close) rather than propagate it. + async fn close(&mut self) -> Result<()>; +} + +/// The read half of a live connection. +pub(crate) trait ConnReader: Send { + /// Await the next meaningful event, skipping ping/pong/binary frames. + async fn recv(&mut self) -> ConnEvent; +} + +/// Opens split [`ConnWriter`]/[`ConnReader`] pairs on demand, once per (re)connect. +pub(crate) trait Transport: Send + Sync + 'static { + type Writer: ConnWriter; + type Reader: ConnReader; + /// Dial and return a fresh split writer/reader pair. + async fn connect(&self) -> Result<(Self::Writer, Self::Reader)>; +} + +/// Production transport backed by `tokio-tungstenite`. Owns its URL and handshake +/// headers so it stays `'static` for the spawned connection task. +pub(crate) struct TungsteniteTransport { + url: String, + skip_verify: bool, + /// Extra handshake headers (client identity/telemetry), injected by the application — + /// this crate carries no telemetry knowledge of its own. + headers: Vec<(String, String)>, +} + +impl TungsteniteTransport { + /// `skip_verify` disables TLS verification (`--accept-invalid-certs`), for beta/UAT hosts only. + pub(crate) fn new( + url: impl Into, + skip_verify: bool, + headers: Vec<(String, String)>, + ) -> Self { + Self { + url: url.into(), + skip_verify, + headers, + } + } +} + +impl Transport for TungsteniteTransport { + type Writer = TungWriter; + type Reader = TungReader; + + async fn connect(&self) -> Result<(TungWriter, TungReader)> { + let request = handshake_request(&self.url, &self.headers)?; + let handshake = async { + if self.skip_verify { + let tls = dangerous_tls_connector(); + connect_async_tls_with_config(request, None, false, Some(tls)).await + } else { + connect_async(request).await + } + }; + let (ws, _) = match tokio::time::timeout(CONNECT_TIMEOUT, handshake).await { + Ok(handshook) => handshook.map_err(Error::transport)?, + Err(_elapsed) => return Err(Error::ConnectTimeout(CONNECT_TIMEOUT)), + }; + let (write, read) = ws.split(); + Ok((TungWriter(write), TungReader(read))) + } +} + +/// The write half of a connected `tokio-tungstenite` stream. +pub(crate) struct TungWriter(SplitSink); + +impl ConnWriter for TungWriter { + async fn send_frame(&mut self, frame: &impl Serialize) -> Result<()> { + let text = serde_json::to_string(frame)?; + + match tokio::time::timeout(SEND_TIMEOUT, self.0.send(Message::Text(text.into()))).await { + Ok(sent) => sent.map_err(Error::transport), + Err(_elapsed) => Err(Error::SendTimeout(SEND_TIMEOUT)), + } + } + + async fn close(&mut self) -> Result<()> { + match tokio::time::timeout(CLOSE_TIMEOUT, self.0.close()).await { + Ok(closed) => closed.map_err(Error::transport), + Err(_elapsed) => Err(Error::CloseTimeout(CLOSE_TIMEOUT)), + } + } +} + +/// The read half of a connected `tokio-tungstenite` stream. +pub(crate) struct TungReader(SplitStream); + +impl ConnReader for TungReader { + async fn recv(&mut self) -> ConnEvent { + loop { + match self.0.next().await { + Some(Ok(Message::Text(text))) => return ConnEvent::Frame(text), + Some(Ok(Message::Close(_))) | None => return ConnEvent::Closed, + Some(Err(e)) => return ConnEvent::Error(Error::transport(e)), + // Ping/Pong/Binary: tungstenite queues an auto-Pong on Ping and flushes it + // on its next read — which this loop performs immediately — so the read half + // answers Pings itself; Pong/Binary carry nothing in the v2 protocol. + Some(Ok(_)) => continue, + } + } + } +} + +/// Build the WebSocket handshake request for `url`, carrying the caller-injected +/// client/telemetry headers. +fn handshake_request(url: &str, extra: &[(String, String)]) -> Result> { + let mut request = url.into_client_request().map_err(Error::transport)?; + let headers = request.headers_mut(); + for (name, value) in extra { + match ( + name.parse::(), + HeaderValue::from_str(value), + ) { + (Ok(name), Ok(value)) => { + headers.insert(name, value); + } + // Skip a telemetry header with an unencodable name/value rather than fail the + // handshake, but surface it so it can be fixed. + _ => tracing::warn!(header = %name, "skipping telemetry header with invalid value"), + } + } + Ok(request) +} + +/// A TLS connector that accepts any server certificate — the one implementation of the +/// `--accept-invalid-certs` escape hatch, shared by the spot and futures WS stacks so +/// the dangerous block exists exactly once. Warns once per process: a reconnect loop +/// would otherwise flood stderr. +pub fn dangerous_tls_connector() -> TlsConnector { + static TLS_WARN: std::sync::Once = std::sync::Once::new(); + TLS_WARN.call_once(|| { + tracing::warn!("TLS certificate verification is disabled for WebSocket connections"); + }); + + let config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + TlsConnector::Rustls(Arc::new(config)) +} + +/// A `rustls` verifier that accepts any certificate. Never constructed directly: +/// [`dangerous_tls_connector`] is the sole gateway. +#[derive(Debug)] +struct NoVerify; + +impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + SignatureScheme::ED448, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handshake_carries_injected_headers_and_skips_invalid_ones() { + let headers = vec![ + ("x-korigin".to_string(), "u004".to_string()), + ("X-Bad".to_string(), "line\nbreak".to_string()), + ]; + let req = handshake_request("wss://ws.kraken.com/v2", &headers).unwrap(); + assert_eq!(req.headers().get("x-korigin").unwrap(), "u004"); + assert!(req.headers().get("X-Bad").is_none()); + } +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..7f0fc7e --- /dev/null +++ b/deny.toml @@ -0,0 +1,33 @@ +[graph] +all-features = true + +[advisories] +yanked = "deny" + +[licenses] +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Zlib", +] + +[bans] +multiple-versions = "warn" +deny = [ + # reqwest and tokio-tungstenite are already on their rustls backends; keep + # a future dep from dragging native TLS back into the graph. + { crate = "openssl-sys", reason = "rustls-only TLS stack" }, + { crate = "native-tls", reason = "rustls-only TLS stack" }, +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/dist-workspace.toml b/dist-workspace.toml index 6e79f08..18c614d 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -5,7 +5,12 @@ members = ["cargo:."] cargo-dist-version = "0.31.0" ci = "github" installers = ["shell"] -targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", +] pr-run-mode = "plan" unix-archive = ".tar.gz" github-attestations = true diff --git a/examples/01-playground.md b/examples/01-playground.md new file mode 100644 index 0000000..488a954 --- /dev/null +++ b/examples/01-playground.md @@ -0,0 +1,52 @@ +# The playground + +One command gives you a live market feed, a paper account with $10,000, and a recorded session you can inspect afterwards. No account, no API keys. + +```bash +kraken playground +``` + +What happens: + +- A paper workspace named `playground` is created (or reused) with 10,000 USD of simulated capital. +- A recorded session starts inside it, streaming live BTC/USD ticker and trade data to a local DuckDB file. +- The stream prints to stdout as JSONL; guidance for what to do next prints to stderr. + +The session stops itself after an hour. Pass `--for 10m` for a shorter window, or `--symbols BTC/USD,ETH/USD` to watch more pairs. + +## Trade against it + +In a second terminal, point your session at the playground workspace and trade: + +```bash +export KRAKEN_WORKSPACE=playground + +kraken order buy BTCUSD 0.01 # simulated fill at the live price +kraken workspace status # capital, P&L, open orders +kraken session show # the recording window and its valuation +``` + +Inside a paper workspace, `kraken order buy/sell` executes simulated fills against real market prices, with fees applied. Nothing touches the live exchange. + +## Stop and inspect + +```bash +kraken session stop # close the window (or just wait for --for to elapse) +kraken session list # every session this workspace has recorded +``` + +Each run leaves a complete record behind: the market tape, your fills, and the account journal. That record is what [replay and P&L explain](04-sessions-replay-explain.md) work on. + +## Clean up + +```bash +kraken workspace reset --yes # back to the starting capital +``` + +Or leave it. The playground is an ordinary workspace, and the [next walkthrough](02-paper-workspaces.md) shows what workspaces can do on their own. + +## Hand it to an agent + +Everything above is packaged as the [`kraken-playground`](../skills/kraken-playground/SKILL.md) skill, with ready-made strategy drivers like [`recipe-playground-dca`](../skills/recipe-playground-dca/SKILL.md) and [`recipe-playground-price-alert`](../skills/recipe-playground-price-alert/SKILL.md). With the skills installed ([how](06-agent-skills.md)), this is one prompt: + +> *"Start a playground run for 15 minutes and DCA into BTC while it records. Show me the P&L when the window closes."* diff --git a/examples/02-paper-workspaces.md b/examples/02-paper-workspaces.md new file mode 100644 index 0000000..274a6d3 --- /dev/null +++ b/examples/02-paper-workspaces.md @@ -0,0 +1,71 @@ +# Paper workspaces + +A workspace is an isolated trading account: its own capital, fee rate, slippage model, trade history, and recorded sessions. Create as many as you like and test different ideas side by side without them contaminating each other. + +## Create one + +```bash +kraken workspace create btc-momentum --capital 10000 --mode paper +``` + +Capital and currency are fixed at creation, as are the economics: + +```bash +kraken workspace create realistic \ + --capital 25000 \ + --mode paper \ + --fee-rate 0.0026 \ + --slippage-rate 0.001 +``` + +The fee rate defaults to 0.26% (Kraken's standard taker fee) and slippage to zero. `--allow-pairs BTCUSD,ETHUSD` restricts what the workspace may trade. + +## Activate and trade + +Activate a workspace with the `--workspace` flag or the environment variable: + +```bash +export KRAKEN_WORKSPACE=btc-momentum + +kraken paper buy BTCUSD 0.05 +kraken paper sell BTCUSD 0.02 --type limit --price 70000 +kraken paper orders +kraken paper history +``` + +Fills execute at live market prices with your workspace's fee and slippage applied. The `--reason "breakout above resistance"` flag on buy/sell logs your rationale alongside the trade, which pays off later when you ask the CLI to explain a session's P&L. + +## Check where you stand + +```bash +kraken workspace status # capital, current value, unrealized P&L +kraken workspace balance # per-asset balances +kraken workspace list # every workspace, plus 'default' (the real account) +kraken workspace report # one row per session: P&L, fills, verdict +``` + +The name `default` is reserved for your real Kraken account. Every other workspace is paper until it earns promotion. + +## The promotion gate + +```bash +kraken workspace promote btc-momentum +``` + +Promotion toward live trading is not a flag flip. The command walks the workspace's recorded evidence and prints a checklist: experiments scored by the [autoresearch lab](05-autoresearch-lab.md), how many sessions passed, whether a live-market session passed, and whether trades happened outside recorded windows. A workspace with no evidence gets a clear list of what's missing rather than a promotion. + +## Reset or start over + +```bash +kraken workspace reset --yes # wipe history, return to the created capital +``` + +Everything a workspace owns lives in one directory under your config dir (`workspaces//`): the account journal, the workspace manifest, and a `sessions/` folder. Delete the directory and the workspace is gone. + +## Hand it to an agent + +The [`kraken-paper-strategy`](../skills/kraken-paper-strategy/SKILL.md) skill teaches an agent to test strategy logic inside a workspace, and [`kraken-paper-to-live`](../skills/kraken-paper-to-live/SKILL.md) walks the promotion gate, evidence checklist and all. With the skills installed ([how](06-agent-skills.md)): + +> *"Create a paper workspace with $10k and realistic fees, trade a simple mean-reversion idea on BTC for 20 minutes, and report the workspace status."* + +Next: give your workspaces something to work with — [a local library of recorded market data](03-market-memory.md). diff --git a/examples/03-market-memory.md b/examples/03-market-memory.md new file mode 100644 index 0000000..e5b6c71 --- /dev/null +++ b/examples/03-market-memory.md @@ -0,0 +1,58 @@ +# Market memory + +Every stream the CLI touches can be captured to clean local storage. Over time you build a private dataset of real market conditions: raw material for backtests, dashboards, and the replayable experiments in later walkthroughs. + +## Record a window + +```bash +kraken record --symbols BTC/USD,ETH/USD --channels ticker,trade --to duckdb --tape morning-session +``` + +Let it run for as long as you want the window to be, then Ctrl-C. The recorder finalizes the tape and prints a summary to stderr naming the files it wrote. + +- `--to` takes `duckdb`, `jsonl`, or both (`duckdb,jsonl`). DuckDB gives you SQL over the tape; JSONL gives you a plain-text file you can inspect with anything. +- `--channels` covers the public feed: `ticker`, `trade`, `book`, `ohlc`, `instrument`. Book depth and OHLC interval are tunable (`--book-depth 25`, `--ohlc-interval 5`). +- Tapes land in the global tape library under your config dir, shared across all workspaces. + +## See what you have + +```bash +kraken tape list +``` + +Lists every tape in the library and every session-scoped recording, with its backend, state, symbols, and whether something is using it right now. A finalized tape is addressable as `tape:` anywhere the CLI accepts a replay source. + +## Query it + +A DuckDB tape is just a database file in the tape library — `~/Library/Application Support/kraken/tapes/` on macOS, `~/.config/kraken/tapes/` on Linux: + +```bash +duckdb "$HOME/Library/Application Support/kraken/tapes/morning-session.duckdb" \ + "SELECT symbol, count(*) AS executions FROM trades GROUP BY symbol" +``` + +## Stream without recording + +When you want the live feed itself, `streamd` multiplexes any mix of symbols and channels to stdout as JSONL: + +```bash +kraken streamd start --symbols BTC/USD,ETH/USD,SOL/USD --channels ticker,trade +``` + +## Trust the tape + +A gap you can't see is a backtest you can't trust. Add `--monitor` to any streaming command (`ws`, `streamd`, `record`) and the CLI emits health advisories to stderr while the data flows on stdout: + +```bash +kraken record --symbols BTC/USD --channels ticker,trade --to duckdb --tape audited --monitor +``` + +You get periodic health summaries, latency and gap detection, stale-stream alerts, and heartbeat timeouts, without any of it polluting the recorded data. Finalized recordings carry their own integrity counters, and the [autoresearch lab](05-autoresearch-lab.md) seals a content hash of every tape an experiment depends on. + +## Hand it to an agent + +Streams and recordings are plain JSONL and flags, so agents drive them directly. The [`kraken-ws-streaming`](../skills/kraken-ws-streaming/SKILL.md) skill covers the streaming surface, and watcher recipes like [`recipe-multi-pair-breakout-watch`](../skills/recipe-multi-pair-breakout-watch/SKILL.md) and [`recipe-track-orderbook-depth`](../skills/recipe-track-orderbook-depth/SKILL.md) build on it ([install](06-agent-skills.md)): + +> *"Record BTC and ETH ticker and trades for ten minutes as a tape called `tuesday-open`, keep an eye on stream health, and tell me if anything drops."* + +Next: recording your own trading, not just the market — [sessions, replay, and P&L explain](04-sessions-replay-explain.md). diff --git a/examples/04-sessions-replay-explain.md b/examples/04-sessions-replay-explain.md new file mode 100644 index 0000000..7d4514f --- /dev/null +++ b/examples/04-sessions-replay-explain.md @@ -0,0 +1,75 @@ +# Sessions, replay, and P&L explain + +A session is a recorded window over a workspace: the market tape, every fill, every balance change, and every decision you logged, all on one timeline. Once a session exists you can rewind it at any speed and ask for a plain-terms breakdown of where the money went. + +You need a paper workspace ([walkthrough 2](02-paper-workspaces.md)). Activate it first: + +```bash +export KRAKEN_WORKSPACE=btc-momentum +``` + +## Record a trading window + +```bash +kraken session start --symbols BTC/USD --channels ticker,trade --for 5m --label first-window +``` + +The session streams live market data into the workspace and recording begins. While it runs, trade in a second terminal: + +```bash +kraken paper buy BTCUSD 0.05 --reason "testing the session recorder" +kraken session note --kind skip --symbol ETHUSD --reason "spread too wide, sitting out" +kraken paper sell BTCUSD 0.05 +``` + +`--reason` and `session note` write to the session's decision log (`--kind` takes `buy`, `sell`, `skip`, or `alert`; deliberate no-ops are worth recording too). They cost nothing now and turn a P&L number into a story later. + +The window closes itself after `--for` elapses, or close it early: + +```bash +kraken session stop # stops the latest session +kraken session show # window, status, valuation +kraken session list # every session in this workspace +``` + +## Replay it + +```bash +kraken replay --session latest --speed 10 +``` + +The recorded timeline plays back to stdout at 10x: market events, fills, and decisions interleaved in original order. `--session` also accepts an ordinal (`s3`) or a label (`first-window`). Speed ranges from 0.01 (slow motion) to 1000. + +Replay reads only from disk. No connection, no API calls, and the tape cannot differ from what was recorded. + +## Explain the P&L + +```bash +kraken explain pnl --session latest +``` + +Instead of one opaque number, the session's P&L is decomposed line by line: + +- **Price movement** — what the market did to your positions +- **Fees** — what execution cost you +- **Spread and slippage** — what crossing the book cost you +- **Missed fills** — limit orders that never executed +- **Residual** — anything the model cannot attribute, stated rather than hidden + +Each trade appears with its context, including the reasons you logged. With `-o json` the full report is machine-readable, so an agent can consume it too. + +## Rerun a window against old data + +`run start --from` replays a recorded tape into a fresh run instead of subscribing live: + +```bash +kraken session start --from tape:morning-session --speed 20 --label rerun-on-history +``` + +Same recorder, same journal, same scoring surface, but the market is yesterday's. This is the primitive the [autoresearch lab](05-autoresearch-lab.md) builds on: test an idea on captured windows before you let it near a live feed. + +## Hand it to an agent + +The [`recipe-session-post-mortem`](../skills/recipe-session-post-mortem/SKILL.md) skill turns a stopped session into a story a non-trader can follow: it replays the window, walks the P&L decomposition, and writes a summary you could paste into a deck. The CLI supplies the deterministic facts; the agent supplies the narration. With the skills installed ([how](06-agent-skills.md)): + +> *"Do a post-mortem on my latest session. What did the market do, what did I do, and where did every dollar of P&L go?"* diff --git a/examples/05-autoresearch-lab.md b/examples/05-autoresearch-lab.md new file mode 100644 index 0000000..e0f5e52 --- /dev/null +++ b/examples/05-autoresearch-lab.md @@ -0,0 +1,72 @@ +# The autoresearch lab + +The lab turns "I have a hunch" into a pre-registered experiment: hypothesis, pass criteria, and session plan are frozen and hash-sealed before the first session, and the verdict is mechanical. You cannot move the goalposts, swap the data, or rubber-stamp a strategy that didn't earn it. + +This walkthrough assumes a paper workspace ([walkthrough 2](02-paper-workspaces.md)) and at least two recorded tapes ([walkthrough 3](03-market-memory.md)), say `window-am` and `window-pm`. + +```bash +export KRAKEN_WORKSPACE=btc-momentum +``` + +## Pre-register the experiment + +```bash +kraken lab new dip-buy \ + --hypothesis "buying dips and taking profit on BTC beats sitting in cash" \ + --min-return-pct 0.5 \ + --max-drawdown-pct 2 \ + --min-fills 4 \ + --session replay:tape:window-am \ + --session replay:tape:window-pm \ + --session live:20m +``` + +This freezes three things at once: + +- **The criteria** — a real return floor, a drawdown ceiling, a minimum trade count. +- **The session plan** — screen on two recorded windows first, then confirm on a live one. Order matters and is sealed. +- **The data** — each replay entry carries a content hash of its tape. If a single byte of a recorded window changes later, the lab refuses to run against it and names both hashes. + +`kraken lab show dip-buy` displays the frozen experiment and verifies the seal. + +## Work the plan + +```bash +kraken lab next dip-buy +``` + +`lab next` tells you exactly which run comes next and prints the `kraken session start` command to execute, with `--experiment dip-buy` stamped in so results attach to the experiment. Run it, trade the idea (yourself, from a script, or with an agent driving the CLI), stop the window, and ask for the score: + +```bash +kraken lab score --session latest --experiment dip-buy +``` + +The scorecard combines the P&L decomposition with mechanical metrics — return, max drawdown, fill count — and delivers a pass/fail verdict against the sealed criteria. Repeat `lab next` until the plan is complete. + +## Compare and decide + +```bash +kraken lab compare dip-buy +``` + +Every session of the experiment is re-scored against the sealed criteria and laid out side by side: recorded windows next to the live confirmation, each with its own verdict. The hypothesis holds or it doesn't, and the output shows why. + +## From verdict to promotion + +The lab's verdicts are the evidence [`kraken workspace promote`](02-paper-workspaces.md#the-promotion-gate) reads. Promotion requires experiments with multiple passing sessions including a live pass, so the path from idea to real capital runs through this loop by construction: + +```text +hypothesis → sealed plan → replay screens → live confirmation → compare → promote +``` + +If the numbers don't hold, the gate says no, mechanically, with nothing to argue about. That refusal is the product. + +## Hand it to an agent + +This loop was built to be driven by an agent. The [`kraken-lab-experiment`](../skills/kraken-lab-experiment/SKILL.md) skill runs the whole lifecycle — freeze, run, score, verdict — and every step reads and writes plain files on disk, so the agent can be killed and restarted at any point and resume from whatever it finds half-done. With the skills installed ([how](06-agent-skills.md)), your trading hypothesis is one prompt: + +> *"I think buying BTC dips and taking profit beats holding cash. Register that as a lab experiment with a 0.5% return floor, screen it on my two recorded windows, confirm on a 20-minute live session, and give me the verdict."* + +The agent trades the idea inside each window; the sealed criteria judge it. The agent cannot talk its way past the gate any more than you can. + +That is the whole journey. [Walkthrough 6](06-agent-skills.md) covers installing the skills and what else ships in the catalog. diff --git a/examples/06-agent-skills.md b/examples/06-agent-skills.md new file mode 100644 index 0000000..f3f4d8d --- /dev/null +++ b/examples/06-agent-skills.md @@ -0,0 +1,112 @@ +# Agent skills + +Everything in walkthroughs 1 through 5 can be driven by an AI agent instead of typed by hand. The repo ships 58 skill packages under [skills/](../skills/) — structured instructions that teach an agent the CLI's contracts, safety rules, and workflows, so a plain-language prompt becomes a correct sequence of commands. + +Each skill is a standard `SKILL.md` package and works with any agent runtime that reads them: Claude Code, OpenClaw, and others. The full catalog is in the [Skills Index](../skills/INDEX.md). + +## Install + +Symlink or copy the skills into your agent's skills directory. For OpenClaw: + +```bash +cd /path/to/kraken-cli +for skill in skills/kraken-* skills/recipe-*; do + ln -s "$(pwd)/$skill" ~/.openclaw/skills/ +done +``` + +For Claude Code, copy the ones you want into `~/.claude/skills/` (personal) or your project's `.claude/skills/`: + +```bash +cp -r skills/kraken-playground skills/kraken-lab-experiment skills/recipe-session-post-mortem ~/.claude/skills/ +``` + +Agents can also reach the same features over MCP (`kraken mcp`) instead of the shell; the [`kraken-mcp-integration`](../skills/kraken-mcp-integration/SKILL.md) skill covers that path. + +## The journey, as prompts + +With the skills installed, the manual walkthroughs collapse into conversation. All of it stays on paper accounts: + +> *"Start a playground session and paper trade BTC for 15 minutes. Show me your P&L."* +> — [`kraken-playground`](../skills/kraken-playground/SKILL.md) creates the sandbox and records the window ([walkthrough 1](01-playground.md)) + +> *"Record two ten-minute windows of BTC and ETH market data, morning and afternoon."* +> — the tapes land in your local library ([walkthrough 3](03-market-memory.md)) + +> *"I think buying dips and taking profit on BTC beats sitting in cash. Register that as a lab experiment, screen it on both recorded windows, confirm live for 20 minutes, and give me the verdict."* +> — [`kraken-lab-experiment`](../skills/kraken-lab-experiment/SKILL.md) freezes the hypothesis and criteria, works the sealed session plan, and reports the mechanical pass/fail ([walkthrough 5](05-autoresearch-lab.md)) + +> *"Do a post-mortem on the live session. Where did every dollar go?"* +> — [`recipe-session-post-mortem`](../skills/recipe-session-post-mortem/SKILL.md) replays the window and narrates the P&L decomposition ([walkthrough 4](04-sessions-replay-explain.md)) + +> *"The experiment passed. What would it take to go live?"* +> — [`kraken-paper-to-live`](../skills/kraken-paper-to-live/SKILL.md) walks the promotion checklist against the recorded evidence ([walkthrough 2](02-paper-workspaces.md#the-promotion-gate)) + +The agent has no shortcut around the gates. Lab criteria are hash-sealed before the first session, tapes are content-hashed, and promotion reads recorded evidence from disk. An agent that trades badly gets the same mechanical refusal you would. + +## Running a hypothesis as a ReAct loop + +The lab is deliberately shaped for an agent that works in a **ReAct loop** — reason, act, observe, repeat. The lab experiment prompt above isn't a single command the agent memorizes; it's a state machine the agent *queries the world for* on every turn. One command is the whole loop: + +```bash +kraken lab next -o json +``` + +`lab next` re-derives progress from the sessions directory on disk each time it's called. It never trusts what the agent thinks happened — it reads what actually happened and returns the next state: `start`, `running`, or `plan_complete`. That single fact is what makes the loop robust: **the observation is the filesystem, not the agent's memory.** + +Here is one experiment playing out, turn by turn. The agent's private reasoning is *Thought*, the command it runs is *Action*, and what the CLI hands back — never what the agent assumes — is *Observation*. + +**Setup (once).** The hypothesis is frozen with its pass criteria and session plan before anything runs, so the goalposts are nailed down: + +```bash +kraken lab new dip-buy \ + --hypothesis "buying BTC dips and taking profit beats holding cash" \ + --min-return-pct 0.5 --max-drawdown-pct 2 --min-fills 4 \ + --session replay:tape:window-am \ + --session replay:tape:window-pm \ + --session live:20m +``` + +### Iteration 1 — screen on the first recorded window + +- **Thought:** I don't know where this experiment stands. Ask the world, don't guess. +- **Action:** `kraken lab next dip-buy -o json` +- **Observation:** `{"state":"start","command":"kraken session start --label dip-buy-r1 --experiment dip-buy --from tape:window-am --speed 10 ..."}` — the plan's first entry is a replay screen. +- **Thought:** State is `start`. I run the exact command it gave me, drive my dip-buy logic while the tape replays, and log a reason on every fill so the scorecard can attribute it. +- **Action:** run that command; `kraken paper buy BTCUSD 0.05 --reason "dip below VWAP"`; `kraken session stop`; then `kraken lab score --session latest --experiment dip-buy -o json` +- **Observation:** `{"verdict":{"pass":false,"checks":[{"criterion":"min_return_pct","threshold":0.5,"observed":-0.31,"pass":false}]}}` — the waterfall shows the return went to fees. A round-trip dip-buy booked only the cost of trading. + +### Iteration 2 — the loop asks again + +- **Thought:** One window is done. I do not assume the next step — I ask. +- **Action:** `kraken lab next dip-buy -o json` +- **Observation:** `{"state":"start","command":"... --from tape:window-pm ..."}` — the second replay entry. The completed run consumed the first plan slot; a failed verdict does not stop the plan. +- The agent screens `window-pm` the same way, stops, and scores. + +### Iteration 3 — live confirmation, then the wall + +- **Action:** `kraken lab next dip-buy` returns `{"state":"start","command":"... --symbols BTC/USD --channels ticker,trade ..."}` — the `live:20m` entry. The agent trades the live window and scores it. +- **Action:** `kraken lab next dip-buy` now returns `{"state":"plan_complete"}`. +- **Thought:** Plan's done. The verdict is not mine to declare — I read it off `compare`. +- **Action:** `kraken lab compare dip-buy -o json` +- **Observation:** `{"pass_count":0,"total":3,"runs":[...]}` — zero of three cleared the sealed bar. + +**Conclusion.** The agent reports what the CLI produced: the hypothesis failed, the return floor was the binding check, and the money went to fees on every window. It cannot round that up. The skill's own rule is blunt about it — *never report a verdict you did not obtain from `lab score` or `lab compare` in this session; no estimating, no "it would probably pass."* The mechanical gate is what keeps the agent honest. + +### Why this shape matters + +- **The loop is grounded, not remembered.** Because `lab next` reads state from disk, the agent can lose its context, crash, or be handed off to a fresh session between any two turns and still resume exactly where the files say it left off. Whatever step is half-done on disk *is* the next step. +- **It runs unattended.** Drive the same skill under [`/loop`](../skills/kraken-lab-experiment/SKILL.md) and each firing advances the machine by one step — `start` a session, or stop-and-score a finished one, or conclude on `plan_complete` — then exits. A firing that finds nothing to advance does nothing. No babysitting a long live window. +- **The agent can't move the goalposts.** Criteria and the session plan are hash-sealed at `lab new`; each replay tape is content-hashed, so a re-recorded window is refused outright. The agent's judgement decides how to *trade*; the frozen spec decides whether it *passed*. + +If the numbers hold — at least two passing sessions including a live one — the same loop hands off to [`kraken-paper-to-live`](../skills/kraken-paper-to-live/SKILL.md), which walks the promotion checklist against exactly this recorded evidence. The path from a sentence-long hunch to real capital runs entirely through observe-think-act, and every gate on it is mechanical. + +## Guardrails worth knowing + +- [`kraken-autonomy-levels`](../skills/kraken-autonomy-levels/SKILL.md) defines graduated permission tiers, from read-only market data up to live execution, so you decide how much rope the agent gets. +- [`kraken-shared`](../skills/kraken-shared/SKILL.md) is the base contract every skill builds on: JSON output, error categories, and the rule that dangerous commands need explicit confirmation. +- The lab lifecycle is resumable from disk at every step, so long-running experiments survive agent restarts and context loss. + +## Beyond the walkthroughs + +The catalog goes well past what these examples cover: futures trading and liquidation guards, DCA and grid strategies, fee optimization, portfolio intel, tax exports, and 25 ready-made recipes from morning market briefs to drawdown circuit breakers. Browse the [Skills Index](../skills/INDEX.md). diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..0b86fda --- /dev/null +++ b/examples/README.md @@ -0,0 +1,22 @@ +# Examples + +Guided walkthroughs for everything the CLI can do beyond single API calls. Each one is a short, runnable session: the commands, what to expect, and why the step matters. + +None of them require a Kraken account. Market data comes from the public WebSocket feed, and all trading happens in paper workspaces with simulated fills. + +Work through them in order and you go from "never traded" to a pre-registered strategy experiment with a mechanical pass/fail verdict: + +| # | Walkthrough | What you build | Time | +| --- | --- | --- | --- | +| 1 | [The playground](01-playground.md) | A live paper-trading sandbox from one command | 2 min | +| 2 | [Paper workspaces](02-paper-workspaces.md) | Isolated accounts with their own capital, fees, and history | 5 min | +| 3 | [Market memory](03-market-memory.md) | A local library of recorded market data you can query and replay | 5 min | +| 4 | [Sessions, replay, and P&L explain](04-sessions-replay-explain.md) | A recorded trading window you can rewind and decompose | 10 min | +| 5 | [The autoresearch lab](05-autoresearch-lab.md) | A sealed experiment that promotes a strategy only when the numbers hold | 10 min | +| 6 | [Agent skills](06-agent-skills.md) | The same journey driven by an AI agent, from one prompt | 5 min | + +Each walkthrough ends with a "Hand it to an agent" section: the CLI ships 58 [agent skills](../skills/INDEX.md) that let an AI agent run the same workflow from a plain-language prompt — including registering a trading hypothesis in the lab and reporting the verdict. Walkthrough 6 shows how to set that up. + +Every command supports `-o json` for machine-readable output, so anything you see here also works from a script. Ready-made agent scripts live in [agents/examples/](../agents/examples/), and the same features are exposed to MCP clients via `kraken mcp`. + +If you are new to the CLI itself (installation, market data, live trading), start with the [README](../README.md). diff --git a/gemini-extension.json b/gemini-extension.json index 2545b42..ccbeb6f 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,12 +1,12 @@ { "name": "kraken-cli", - "version": "0.3.2", + "version": "0.4.0", "description": "Agent-first CLI for trading crypto, stocks, forex, and derivatives on Kraken.", "contextFileName": "CONTEXT.md", "mcpServers": { "kraken": { "command": "kraken", - "args": ["mcp", "-s", "market,paper"] + "args": ["mcp", "-s", "market,paper,feedback"] } } } diff --git a/justfile b/justfile new file mode 100644 index 0000000..338f811 --- /dev/null +++ b/justfile @@ -0,0 +1,75 @@ +# kraken-cli task runner — the single source of truth for every quality gate. +# +# CI runs these same recipes, so a green `just ready` means a green pipeline: +# each gate is defined exactly once, here. + +# Tool pins. CI caches the installed binaries; a bump here rebuilds them once. +CARGO_SORT_VERSION := "2.1.4" +CARGO_DENY_VERSION := "0.20.2" +CARGO_NEXTEST_VERSION := "0.9.140" + +# Derived from Cargo.toml's rust-version so the gate can't drift from it. +MSRV := `sed -nE 's/^rust-version *= *"([0-9.]+)"$/\1/p' Cargo.toml` + +# List available recipes. +default: + @just --list + +# Full local gate — run before pushing. Mirrors the CI `test` stage 1:1. +ready: fmt-check sort-check lint test docs deny msrv + @echo "✓ ready — matches the CI test stage" + +# Format all code (nightly: group_imports in rustfmt.toml is nightly-only). +fmt: install-nightly-rustfmt + cargo +nightly fmt + +# Check formatting without writing (CI: test-fmt). +fmt-check: install-nightly-rustfmt + cargo +nightly fmt --check + +# Best-effort: offline runs still format fine when nightly is already present. +[private] +install-nightly-rustfmt: + rustup toolchain install nightly --profile minimal --component rustfmt 2>/dev/null || true + +# Check Cargo.toml key ordering in every workspace crate (CI: test-sort). +sort-check: + cargo install --locked cargo-sort@{{CARGO_SORT_VERSION}} + cargo sort --check --workspace + +# `-D warnings` stays an argument, not RUSTFLAGS: RUSTFLAGS enters crate +# fingerprints and would defeat CI's shared target/ cache. +# Lint with clippy, warnings-as-errors (CI: test-clippy, passes `json`). +lint format="human": + cargo clippy --locked --workspace --all-targets --all-features --message-format={{format}} -- -D warnings + +# Fix clippy findings that have machine-applicable suggestions. +fix: + cargo clippy --fix --workspace --all-targets --all-features --allow-dirty --allow-staged + +# No fail-fast anywhere: one run reports every failure yet still fails the +# gate. Doctests are a second step because nextest doesn't execute them. +# Run the full test suite (CI: test-unit, passes `ci` for the JUnit report). +test profile="default": + #!/usr/bin/env bash + set -euo pipefail + cargo install --locked cargo-nextest@{{CARGO_NEXTEST_VERSION}} + rc=0 + cargo nextest run --locked --workspace --no-fail-fast --profile {{profile}} || rc=$? + cargo test --doc --locked --workspace --no-fail-fast || rc=$? + exit "$rc" + +# Private items included: internal docs are the audience of a bin-first crate. +# Build docs with warnings-as-errors (CI: test-docs). +docs: + RUSTDOCFLAGS="-D warnings" cargo doc --locked --workspace --no-deps --document-private-items + +# Check licenses and advisories (CI: test-deny). +deny: + cargo install --locked cargo-deny@{{CARGO_DENY_VERSION}} + cargo deny check + +# Verify the workspace builds on the MSRV from Cargo.toml (CI: test-msrv). +msrv: + rustup toolchain install {{MSRV}} --profile minimal + RUSTUP_TOOLCHAIN={{MSRV}} cargo check --locked --workspace --all-targets diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..59277fe --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f802cad --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,7 @@ +# group_imports is nightly-only: CI formats with `cargo +nightly fmt --check`, +# and stable rustfmt silently skips the option (warning only). +style_edition = "2024" +unstable_features = true +group_imports = "StdExternalCrate" +newline_style = "Unix" +use_field_init_shorthand = true diff --git a/skills/INDEX.md b/skills/INDEX.md index fac5b37..585a10c 100644 --- a/skills/INDEX.md +++ b/skills/INDEX.md @@ -1,6 +1,6 @@ # Skills Index -50 agent skills for `kraken-cli`, organized by category. Covers spot and futures paper trading, live trading, and portfolio management. +59 agent skills for spot, futures, paper trading, live trading, and portfolio management. ## Core @@ -38,6 +38,7 @@ Order execution, position management, and spot paper testing. | [kraken-fee-optimization](./kraken-fee-optimization/SKILL.md) | Minimize fees through maker orders, volume tiers, and fee currency selection. | | [kraken-paper-strategy](./kraken-paper-strategy/SKILL.md) | Test spot strategies on paper trading before live deployment. | | [kraken-paper-to-live](./kraken-paper-to-live/SKILL.md) | Promote validated paper strategies (spot and futures) to live trading. | +| [kraken-playground](./kraken-playground/SKILL.md) | Drive recorded paper-trading sessions (and the one-command playground demo) to test a hypothesis on live prices. | | [kraken-risk-operations](./kraken-risk-operations/SKILL.md) | Operational risk controls for live agent trading sessions. | ## Futures @@ -86,6 +87,18 @@ Balance analysis, P&L tracking, subaccounts, and exports. Multi-step workflows combining multiple skills. +### Playground Recipes + +| Skill | Description | +|-------|-------------| +| [recipe-playground-dca](./recipe-playground-dca/SKILL.md) | Example driver: drive a time-based dollar-cost-averaging hypothesis into a recorded session. | +| [recipe-playground-dca-triggered](./recipe-playground-dca-triggered/SKILL.md) | Example driver: dip-triggered dollar-cost-averaging on a live WebSocket stream, recorded to a session. | +| [recipe-playground-rebalance](./recipe-playground-rebalance/SKILL.md) | Example driver: drive a portfolio-rebalance hypothesis into a recorded session. | +| [recipe-playground-price-alert](./recipe-playground-price-alert/SKILL.md) | Example driver: drive a price-level-alert hypothesis into a recorded session. | +| [recipe-playground-webhook](./recipe-playground-webhook/SKILL.md) | Example driver: turn incoming webhook signals into validated paper trades inside a recorded session. | +| [recipe-session-post-mortem](./recipe-session-post-mortem/SKILL.md) | Narrate a stopped session's post-mortem: replay the window and explain the P&L in plain, presentation-ready terms. | +| [kraken-lab-experiment](./kraken-lab-experiment/SKILL.md) | Run the Autoresearch Lab lifecycle under /loop: freeze a hash-sealed hypothesis, drive recorded sessions, score them, read the mechanical verdict. | + ### Strategy Recipes | Skill | Description | diff --git a/skills/README.md b/skills/README.md index bd677ac..9c4c87a 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,19 +1,19 @@ # kraken-cli Skills -49 goal-oriented skill packages for AI agents that operate `kraken-cli`. +55 goal-oriented skill packages for AI agents that operate `kraken-cli`. See the full [Skills Index](INDEX.md) for the complete categorized list. ## Skill Types -- **Core** (5): Shared contract, autonomy levels, rate limits, order types, error recovery +- **Core** (7): Setup, shared contract, autonomy levels, MCP integration, rate limits, order types, error recovery - **Market Data** (4): Price reads, multi-pair screening, alerts, WebSocket streaming -- **Spot Trading** (6): Order execution, stops, fees, paper testing, paper-to-live promotion, risk controls +- **Spot Trading** (7): Order execution, stops, fees, paper testing, paper-to-live promotion, playground sessions, risk controls - **Futures** (5): Trading lifecycle, risk management, liquidation guard, basis trades, funding carry - **Strategies** (4): DCA, grid trading, rebalancing, TWAP execution - **Funding & Earn** (3): Deposits/withdrawals, staking, tax exports - **Portfolio & Account** (2): Balance analysis, subaccount management -- **Recipes** (20): Multi-step workflows combining multiple skills +- **Recipes** (23): Multi-step workflows combining multiple skills, including playground drivers ## OpenClaw Setup diff --git a/skills/kraken-alert-patterns/SKILL.md b/skills/kraken-alert-patterns/SKILL.md index a1e0687..f5dc491 100644 --- a/skills/kraken-alert-patterns/SKILL.md +++ b/skills/kraken-alert-patterns/SKILL.md @@ -22,7 +22,7 @@ Use this skill for: Check price at intervals and compare against thresholds: ```bash -PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0]') +PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price') # Agent compares $PRICE to upper/lower thresholds # If breached, notify the user ``` @@ -58,7 +58,7 @@ done Compare current 24h volume against a baseline: ```bash -kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].v[1]' +kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].volume_24h' # Agent compares to historical average # Alert if volume > 2x baseline ``` @@ -157,3 +157,4 @@ The CLI outputs alerts to stdout. The agent is responsible for delivering notifi - Alerts are informational; they do not execute trades automatically. - Prefer streaming over high-frequency polling to reduce API load. - Close streams when monitoring is no longer needed. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-autonomy-levels/SKILL.md b/skills/kraken-autonomy-levels/SKILL.md index 11456f9..ad814ee 100644 --- a/skills/kraken-autonomy-levels/SKILL.md +++ b/skills/kraken-autonomy-levels/SKILL.md @@ -35,10 +35,11 @@ The agent can inform, alert, and recommend. It cannot place orders, cancel order The agent tests strategies against live prices with zero risk. No API key needed. ```bash -kraken paper init --balance 10000 -o json 2>/dev/null +kraken workspace create sandbox --capital 10000 --mode paper -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 -o json 2>/dev/null -kraken paper status -o json 2>/dev/null -kraken paper reset -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null +kraken workspace reset -o json --yes 2>/dev/null ``` Run paper strategies repeatedly. Compare results across parameter changes. Only move to Level 3 after consistent paper performance. @@ -129,3 +130,5 @@ This level is rare and high-risk. Only appropriate when: ## Progression Rule Move to the next level only after the agent demonstrates consistent behavior at the current level. Paper trading (Level 2) for at least a week before supervised trading (Level 3). Supervised trading for at least a week before autonomous (Level 4). + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-basis-trading/SKILL.md b/skills/kraken-basis-trading/SKILL.md index 5e8247c..dbd12d0 100644 --- a/skills/kraken-basis-trading/SKILL.md +++ b/skills/kraken-basis-trading/SKILL.md @@ -25,7 +25,7 @@ When futures trade at a premium to spot (positive basis), you can buy spot and s ## Basis Calculation ```bash -SPOT=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0]') +SPOT=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price') FUTURES=$(kraken futures ticker PF_XBTUSD -o json 2>/dev/null | jq -r '.ticker.last') BASIS=$(echo "scale=4; ($FUTURES - $SPOT) / $SPOT * 100" | bc) echo "Basis: ${BASIS}%" @@ -90,3 +90,4 @@ kraken futures order buy PF_XBTUSD 1 --reduce-only -o json 2>/dev/null - Monitor futures margin continuously; a large spot rally can trigger liquidation on the short. - Close both legs together; leaving one open converts to a directional bet. - Requires human approval for all live entries and exits. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-dca-strategy/SKILL.md b/skills/kraken-dca-strategy/SKILL.md index bdebc30..8b5a042 100644 --- a/skills/kraken-dca-strategy/SKILL.md +++ b/skills/kraken-dca-strategy/SKILL.md @@ -28,19 +28,20 @@ Always validate the strategy in paper mode before going live. The `paper buy` command takes base-asset volume, not dollar amount. Calculate volume first: ```bash -kraken paper init --balance 10000 --currency USD -o json 2>/dev/null +kraken workspace create sandbox --capital 10000 --mode paper --currency USD -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox # Calculate BTC volume for a $100 buy at current price -PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0]') +PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price') VOLUME=$(echo "scale=8; 100 / $PRICE" | bc) # Simulate weekly buys kraken paper buy BTCUSD $VOLUME -o json 2>/dev/null -kraken paper status -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null # Repeat buy, check status each iteration kraken paper buy BTCUSD $VOLUME -o json 2>/dev/null -kraken paper status -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null kraken paper history -o json 2>/dev/null ``` @@ -55,7 +56,7 @@ Each interval, the agent executes one market buy for the fixed amount: ``` 2. Calculate volume from dollar amount (e.g., $100 at current price): ```bash - PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0]') + PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price') VOLUME=$(echo "scale=8; 100 / $PRICE" | bc) ``` 3. Validate the order: @@ -88,7 +89,7 @@ The agent should maintain a running total: Instead of market buys, place limit orders slightly below the current price for better fills: ```bash -PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].b[0]') +PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].bid_price') LIMIT=$(echo "scale=2; $PRICE * 0.998" | bc) VOLUME=$(echo "scale=8; 100 / $LIMIT" | bc) kraken order buy BTCUSD $VOLUME --type limit --price $LIMIT -o json 2>/dev/null @@ -120,3 +121,4 @@ The CLI does not include a built-in scheduler. Agents should use external schedu - Always paper-test the DCA loop first. - Track cost basis after every buy; do not lose history. - Cancel stale limit orders before placing new ones to avoid duplicate exposure. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-earn-staking/SKILL.md b/skills/kraken-earn-staking/SKILL.md index 5f87075..b27cc2a 100644 --- a/skills/kraken-earn-staking/SKILL.md +++ b/skills/kraken-earn-staking/SKILL.md @@ -118,3 +118,4 @@ When helping a user choose a strategy: - Always show the lock type and any unbonding period before allocation. - For bonded strategies, warn the user that funds will be locked for the stated duration. - Check `can_allocate` and `can_deallocate` fields before attempting operations. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-error-recovery/SKILL.md b/skills/kraken-error-recovery/SKILL.md index 5bf14e6..dfbbffd 100644 --- a/skills/kraken-error-recovery/SKILL.md +++ b/skills/kraken-error-recovery/SKILL.md @@ -106,7 +106,7 @@ Limit orders may partially fill. Check order status: kraken query-orders -o json 2>/dev/null ``` -Fields: `vol` (requested volume), `vol_exec` (filled volume), `status` (open, closed, canceled). +Fields: `vol` (requested volume), `vol_exec` (filled volume), `status` (open, closed, canceled) — `query-orders` returns Kraken's raw wire fields, unlike `open-orders`/`closed-orders` which use the CLI schema (`volume`, `volume_executed`). If partially filled and the remaining volume is needed, amend or place a new order for the remaining amount. @@ -133,3 +133,4 @@ An order stuck in `open` state that should have filled or been canceled: - Use `--cl-ord-id` for all orders in automated loops. - On auth errors, stop all activity and re-authenticate. - Log every error and recovery action for post-session audit. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-fee-optimization/SKILL.md b/skills/kraken-fee-optimization/SKILL.md index f00e8f8..74ee753 100644 --- a/skills/kraken-fee-optimization/SKILL.md +++ b/skills/kraken-fee-optimization/SKILL.md @@ -106,3 +106,4 @@ kraken order batch orders.json --pair BTCUSD -o json 2>/dev/null - Order placement commands are dangerous. Never execute without explicit human approval. - Always `--validate` before submitting live orders. - Post-only orders can be rejected; handle rejection gracefully and retry. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-funding-carry/SKILL.md b/skills/kraken-funding-carry/SKILL.md index 116f458..ef7bbbd 100644 --- a/skills/kraken-funding-carry/SKILL.md +++ b/skills/kraken-funding-carry/SKILL.md @@ -102,3 +102,4 @@ kraken order sell BTCUSD 0.01 --type market -o json 2>/dev/null - Monitor funding rates continuously; a flip turns profit into loss. - Monitor margin on the futures leg; price moves require margin even when hedged. - Requires human approval for all entries and exits. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-funding-ops/SKILL.md b/skills/kraken-funding-ops/SKILL.md index fde7756..09dfc6d 100644 --- a/skills/kraken-funding-ops/SKILL.md +++ b/skills/kraken-funding-ops/SKILL.md @@ -114,3 +114,4 @@ INFO=$(kraken withdrawal info BTC "my-btc-address" 0.5 -o json 2>/dev/null) - Use `--verified true` when listing addresses to confirm approval status. - Check fees before every withdrawal; network fees fluctuate. - Cancel pending withdrawals promptly if the user changes their mind: the window is short. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-futures-risk/SKILL.md b/skills/kraken-futures-risk/SKILL.md index 0841a8f..e2f02f9 100644 --- a/skills/kraken-futures-risk/SKILL.md +++ b/skills/kraken-futures-risk/SKILL.md @@ -147,9 +147,10 @@ kraken futures cancel-after 300 -o json 2>/dev/null ## Hard Rules -- Never increase leverage without explicit human approval. +- `futures set-leverage` and `futures set-pnl-preference` are flagged dangerous. Never change either without explicit human approval — a leverage change also alters the margin requirement on open positions, in both directions. - Monitor margin ratio continuously during live sessions. - Alert when margin ratio exceeds 0.7 (conservative threshold). - Alert when unrealized loss exceeds a pre-defined stop-loss level. - Check funding rates before opening new positions to understand carry cost. - Use `--reduce-only` when closing positions to prevent accidental reversals. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-futures-trading/SKILL.md b/skills/kraken-futures-trading/SKILL.md index e213e78..1d07d1e 100644 --- a/skills/kraken-futures-trading/SKILL.md +++ b/skills/kraken-futures-trading/SKILL.md @@ -175,3 +175,4 @@ Switch between paper and live by replacing `futures paper` with `futures order`. - Use `--reduce-only` when closing positions to prevent accidental flips. - Enable `cancel-after` for any automated session. - Test strategies with `kraken futures paper` before going live. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-grid-trading/SKILL.md b/skills/kraken-grid-trading/SKILL.md index 854019b..76b8615 100644 --- a/skills/kraken-grid-trading/SKILL.md +++ b/skills/kraken-grid-trading/SKILL.md @@ -36,7 +36,8 @@ Grid spacing = (upper - lower) / grid levels. Always test in paper mode first: ```bash -kraken paper init --balance 10000 -o json 2>/dev/null +kraken workspace create sandbox --capital 10000 --mode paper -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox # Place buy grid below current price kraken paper buy BTCUSD 0.001 --type limit --price 58000 -o json 2>/dev/null @@ -49,7 +50,7 @@ kraken paper sell BTCUSD 0.001 --type limit --price 63000 -o json 2>/dev/null kraken paper sell BTCUSD 0.001 --type limit --price 64000 -o json 2>/dev/null kraken paper orders -o json 2>/dev/null -kraken paper status -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null ``` ## Live Grid Placement @@ -124,3 +125,4 @@ kraken order cancel-batch -o json 2>/dev/null - Enable `cancel-after` for unattended grid sessions. - Track total grid P&L, not just individual level fills. - Cancel the entire grid before adjusting parameters; do not leave orphaned orders. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-lab-experiment/SKILL.md b/skills/kraken-lab-experiment/SKILL.md new file mode 100644 index 0000000..dcc4097 --- /dev/null +++ b/skills/kraken-lab-experiment/SKILL.md @@ -0,0 +1,251 @@ +--- +name: kraken-lab-experiment +version: 2.0.0 +description: "Run the Autoresearch Lab lifecycle end-to-end: freeze a hypothesis with mechanical success criteria and a sealed session plan, drive recorded and replayed paper runs, score them honestly, and read the verdict — resumable from disk at every step, designed for /loop." +metadata: + openclaw: + category: "lab" + domain: "sessions" + requires: + bins: ["kraken", "jq"] + skills: ["kraken-playground"] +--- + +# Lab Experiment Lifecycle + +> **PREREQUISITE:** Load `kraken-playground` to understand what a session is +> and where it lives. + +Turn a trading hunch into a mechanical verdict: pre-register the hypothesis +and its success criteria (hash-sealed, so the goalposts cannot move), run it +as recorded paper runs, score each session against its own tape, and let the +frozen criteria — not vibes — say pass or fail. Every step reads and writes +plain files under the active scope, so the lifecycle survives kills, +restarts, and context loss: **whatever step you find half-done on disk is +the step you resume**. + +Use this skill for: + +- driving one experiment end-to-end under `/loop` (freeze → run → score → + verdict) +- resuming an experiment a previous context started +- reproducing a verdict cold, from files alone + +## Safety rules + +- Paper only: run experiments inside a paper workspace + (`export KRAKEN_WORKSPACE=`), where the order verbs fill on the + paper account and no real funds are reachable. +- Never edit a frozen experiment file. The seal exists to catch exactly + that; a tampered spec fails every later step with a `parse` envelope. +- Never report a verdict you did not obtain from `lab score --experiment` + or `lab compare` output in this session. No estimating, no "it would + probably pass". + +## The state machine + +With a sealed session plan the whole table collapses to one command — **run +`kraken lab next ` and do what it says**: + +```bash +kraken lab next momentum-1 -o json 2>/dev/null +# state: "start" → run the exact `command` it prints, then stop+score +# state: "running" → drive or wait, then `kraken session stop` + `lab score` +# state: "plan_complete" → `kraken lab compare `, conclude (Step 5) +``` + +`lab next` re-derives progress from the sessions tree on every call: completed +runs consume plan entries in order, aborted sessions keep their ordinals and +count for nothing, and a replay entry whose tape no longer matches its +sealed hash is refused outright. Stop and score a finished run *before* +asking again — an unstopped run has no summary and reads as aborted. + +For a plan-less experiment (registered before run plans existed), fall back +to the manual table: + +| State | Evidence | Next action | +|-------|----------|-------------| +| unregistered | `kraken lab show ` → `validation` error | Step 1: freeze | +| no run yet | `kraken session list` → no run stamped with the experiment | Step 2: start run | +| run live | `kraken session show` → status `recording` | Step 3: drive or wait, then stop | +| run stopped, unjudged | status `stopped`, no verdict recorded in your notes | Step 4: score + verdict | +| judged | verdict obtained | Step 5: conclude or start run n+1 | + +## Step 1: Freeze the hypothesis (once) + +Pick a name (path-safe segment: letters, digits, `.`, `-`, `_`) and state +the falsifiable claim plus at least one mechanical criterion: + +```bash +kraken lab new momentum-1 \ + --hypothesis "buying 15m strength beats sitting in cash" \ + --strategy recipe-playground-dca \ + --min-return-pct 1 --max-drawdown-pct 5 --min-fills 2 \ + --session replay:tape:jun-crash --session replay:tape:jun-chop \ + --session live:24h \ + -o json 2>/dev/null +``` + +Seal the **session plan** with the spec: repeatable `--session` entries, in +execution order — `replay:` (a finalized tape from +`kraken tape list`; its content hash is sealed, so a re-recorded tape +can never satisfy the plan) or `live:`. Replay first to screen +cheaply, live last: the promotion gate requires a passing live session because +replay cannot enforce lookahead. The session count is sealed too — that is the +point: no running until a pass appears. + +The output echoes the sealed spec with its `frozen` hash (`sha256:…`). +Freezing is once-only: a second `lab new` under the same name is a +`validation` error saying the spec is immutable. Verify any time with: + +```bash +kraken lab show momentum-1 -o json 2>/dev/null +``` + +If `show` returns a `parse` error mentioning "modified after freezing", the +file was tampered with. Stop the experiment and report it — do not re-freeze +over it. + +## Step 2: Start a session + +With a session plan, `kraken lab next ` prints the exact start command — +use it verbatim; adjusting `--speed` per the reaction-time note below is +the one permitted edit. Runs are ordinary recorded sessions stamped with +`--experiment` — the stamp is what discovery keys on; the `--label +-s` is the human handle: + +```bash +# live entry (fill in the market to record): +kraken session start --label momentum-1-s1 --experiment momentum-1 \ + --symbols BTC/USD --channels ticker,trade \ + -o json 2>/dev/null & + +# replay entry (the tape defines the market; 10x compresses the wait): +kraken session start --label momentum-1-s2 --experiment momentum-1 \ + --from tape:jun-crash --speed 10 -o json 2>/dev/null & +``` + +Replay reaction-time note: at speed N you have 1/N of live reaction time — +suits slow-cadence strategies (DCA); drop to `--speed 1` for reactive ones. + +(At least one `--channels` entry is required on live entries, and the +recorder runs in the foreground, so background it with `&` as the +playground skill does. The `session_started` stdout line carries the allocated +session id.) + +Drive the strategy the hypothesis names (e.g. the recipe skill from +`--strategy`), passing `--reason` on every trade so the scorecard's +decided-fill join has data — the rationale lands in the active session's +decision log automatically. + +## Step 3: Stop the session + +A session must stop before it can be scored — the stop summary is the anchor +every later number decomposes: + +```bash +kraken session stop +``` + +## Step 4: Score and judge + +One command produces the honest scorecard *and* the mechanical verdict: + +```bash +kraken lab score --session latest --experiment momentum-1 -o json 2>/dev/null +``` + +- `source`: what the session traded against — `{kind:"replay", dataset, speed}` + or `{kind:"live", symbols}`, derived from the session's contract, not + asserted. +- `anchor` + `components[]`: the stop totals and the explain-pnl waterfall, + carried verbatim — this output can never disagree with + `kraken explain pnl`. +- `metrics`: `return_pct`, `max_drawdown`, `max_drawdown_pct`, `fees`, + `friction`, `turnover_pct`, `hit_rate`, `profit_factor`, + `slippage_sensitivity` — every `null` is an honest "not derivable", never + zero (a buys-only DCA run has no closed round trips, so `hit_rate` and + `profit_factor` read `null` by design, not by breakage). +- `verdict.pass` and `verdict.checks[]`: one check per frozen criterion with + `threshold`, `observed`, `pass`. An unknown observation fails its check. +- `caveats[]`: read them; a verdict with caveats is reported *with* them. + +Digest for the loop log: + +```bash +kraken lab score --session latest --experiment momentum-1 -o json 2>/dev/null \ + | jq '{session, experiment, pass: .verdict.pass, + checks: [.verdict.checks[] | {criterion, threshold, observed, pass}], + caveats}' +``` + +The retry cues are in the error envelopes themselves: "still recording — stop +it with 'kraken session stop' first" means go back to Step 3; "was aborted +before it stopped … only a stopped session can be scored" means that session +is dead evidence — start a fresh one; "experiment '…' not found; run 'kraken +lab new …' first" means Step 1 never happened in this scope. + +## Step 5: Conclude — or run again + +The verdict is mechanical; the conclusion is yours to *report, not adjust*: + +- **pass**: state the hypothesis, the checks that carried it, and every + caveat. One passing run is evidence, not proof — follow the plan to its + end before promoting anything. +- **fail**: name the failing checks (`threshold` vs `observed`) and what the + waterfall says the money actually went to (fees? drawdown? never traded?). + A failed experiment is a completed experiment. + +**Promotion:** when `lab next` says `plan_complete`, assemble the promotion +evidence block from `kraken lab compare` and hand off to the +`kraken-paper-to-live` skill. Its gate is mechanical: at least 2 passing +runs with a passing **live** run among them — replay verdicts count only +with their lookahead caveat attached. Two replay passes never promote. + +More runs: repeat Steps 2–4 (each start allocates the next ordinal), then +read every session side by side: + +```bash +kraken lab compare momentum-1 -o json 2>/dev/null +``` + +One verdict column per session (`sessions[].verdict`), plus `pass_count`/`total` +and a named error column for any run that cannot be scored (a still-running +run says so — stop it first). Only the verdicts compare across runs: they +cover different market windows, so the metric rows are per-session context. +Never average scorecards across runs into a blended number the CLI did not +produce — `compare` deliberately never aggregates. + +## Cold-context verdict reproduction + +Any context — including one that never saw the sessions — reproduces a verdict +from disk alone: + +```bash +kraken lab show momentum-1 -o json 2>/dev/null # the sealed criteria +kraken lab score --session s1 --experiment momentum-1 -o json 2>/dev/null +``` + +Same files, same exact-Decimal math, same verdict, byte for byte. If the two +disagree with a previously reported verdict, something on disk changed — +`lab show` tells you whether it was the spec. + +## Running under /loop + +Invoke as `/loop` with this skill and the experiment name. Each firing: + +1. `kraken lab show ` — missing → freeze (Step 1, with `--session` + entries) and return. +2. `kraken lab next -o json` and act on its `state`. If it refuses + with a no-run-plan `validation` error (an experiment frozen before run + plans existed), drive the manual state table above instead. + `start` → run its `command`; `running` → drive/wait, then stop + score + (Steps 3–4) and log the digest; `plan_complete` → `kraken lab compare`, + conclude (Step 5), and stop the loop with the per-session verdicts (and the + promotion evidence block, if concluding toward promotion) as the final + report. + +Idempotent by construction: a firing that finds nothing to advance does +nothing. A killed loop resumes at the same step. + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-liquidation-guard/SKILL.md b/skills/kraken-liquidation-guard/SKILL.md index cbfc274..af34a26 100644 --- a/skills/kraken-liquidation-guard/SKILL.md +++ b/skills/kraken-liquidation-guard/SKILL.md @@ -119,3 +119,5 @@ If the agent crashes, orders auto-cancel, preventing further margin consumption. - Alert at 0.5 ratio; act at 0.7. Do not wait for the exchange to liquidate. - Emergency flatten requires human approval unless the agent operates at autonomy level 4+. - Never increase position size when margin ratio is above 0.3. +- `futures set-leverage` is flagged dangerous: it changes the margin requirement on open positions, so de-risking with it needs the same human approval as flattening. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-market-intel/SKILL.md b/skills/kraken-market-intel/SKILL.md index d62fb6e..4b5f9b8 100644 --- a/skills/kraken-market-intel/SKILL.md +++ b/skills/kraken-market-intel/SKILL.md @@ -43,3 +43,5 @@ Parse NDJSON line by line. Do not treat stream output as one JSON object. - Ticker: last price, bid, ask - Orderbook: near-book depth and imbalance - OHLC: trend and volatility windows + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-mcp-integration/SKILL.md b/skills/kraken-mcp-integration/SKILL.md index 983fb70..a380118 100644 --- a/skills/kraken-mcp-integration/SKILL.md +++ b/skills/kraken-mcp-integration/SKILL.md @@ -30,14 +30,14 @@ Add this to your MCP client configuration (Claude Desktop: `claude_desktop_confi "mcpServers": { "kraken": { "command": "kraken", - "args": ["mcp", "-s", "market,paper"] + "args": ["mcp", "-s", "market,paper,feedback"] } } } ``` -`-s market,paper` is the default service list: public market data plus paper -trading. No real funds can be touched until you opt in explicitly by +`-s market,paper,feedback` is the default service list: public market data, paper +trading, and the local feedback tool. No real funds can be touched until you opt in explicitly by expanding the service list. See [Enabling live trading](#enabling-live-trading) below. @@ -50,7 +50,7 @@ share the same config file, so a single `kraken auth set` serves both. ### 2. Set credentials (only needed for live trading) -If you stay on `market,paper`, skip this step — paper trading needs no keys. +If you stay on `market,paper,feedback`, skip this step — paper trading needs no keys. For live trading, the MCP server reads credentials from environment variables: @@ -69,7 +69,7 @@ Control which command groups the MCP server exposes: ```bash kraken mcp -s market # public data only (safe, no auth) -kraken mcp -s market,paper # market data + paper trading (marketplace default) +kraken mcp -s market,paper,feedback # market data + paper trading (marketplace default) kraken mcp -s market,trade,paper # add live trading kraken mcp -s market,account,trade # add account queries and live trading kraken mcp -s all # everything (many tools, includes funding/earn/subaccount) @@ -91,7 +91,7 @@ Keep the service list to what you need. MCP clients typically handle 50-100 tool ## Enabling live trading -Every `kraken-cli` marketplace listing ships with `-s market,paper` by default, +Every `kraken-cli` marketplace listing ships with `-s market,paper,feedback` by default, so a fresh install can never place a real order. To opt in to live trading, edit the MCP server args in your client's config and widen the service list: @@ -112,7 +112,7 @@ edit the MCP server args in your client's config and widen the service list: Recommended upgrade path: -1. Start with `market,paper` (the default). +1. Start with `market,paper,feedback` (the default). 2. Add `trade` once you are comfortable and have an API key scoped to orders only. See the "Recommended key scope" section of `skills/kraken-setup/SKILL.md` before generating a key. @@ -149,3 +149,5 @@ The `gemini-extension.json` includes `mcpServers` config, so the MCP server star **Too many tools**: Reduce the service list. `kraken mcp -s market,trade` is a good starting point. **Streaming commands not available**: WebSocket streaming commands are excluded from MCP v1. Use `kraken ws ...` directly from the terminal for streaming. + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-multi-pair/SKILL.md b/skills/kraken-multi-pair/SKILL.md index 95c6bde..e725c51 100644 --- a/skills/kraken-multi-pair/SKILL.md +++ b/skills/kraken-multi-pair/SKILL.md @@ -28,7 +28,7 @@ kraken ticker BTCUSD ETHUSD SOLUSD DOTUSD -o json 2>/dev/null The response contains one object per pair. Extract and compare: ```bash -kraken ticker BTCUSD ETHUSD SOLUSD -o json 2>/dev/null | jq 'to_entries[] | {pair: .key, last: .value.c[0], volume: .value.v[1]}' +kraken ticker BTCUSD ETHUSD SOLUSD -o json 2>/dev/null | jq 'to_entries[] | {pair: .key, last: .value.last_price, volume: .value.volume_24h}' ``` ## Multi-Pair Streaming @@ -68,7 +68,7 @@ kraken pairs -o json 2>/dev/null | jq '[to_entries[] | select(.key | endswith("U Compare bid-ask spreads across pairs to gauge liquidity: ```bash -kraken ticker BTCUSD ETHUSD SOLUSD -o json 2>/dev/null | jq 'to_entries[] | {pair: .key, spread: ((.value.a[0] | tonumber) - (.value.b[0] | tonumber))}' +kraken ticker BTCUSD ETHUSD SOLUSD -o json 2>/dev/null | jq 'to_entries[] | {pair: .key, spread: (.value.ask_price - .value.bid_price)}' ``` ## Volume Screening @@ -76,7 +76,7 @@ kraken ticker BTCUSD ETHUSD SOLUSD -o json 2>/dev/null | jq 'to_entries[] | {pai Identify high-volume pairs from a watchlist: ```bash -kraken ticker BTCUSD ETHUSD SOLUSD ADAUSD DOTUSD -o json 2>/dev/null | jq 'to_entries | sort_by(-(.value.v[1] | tonumber)) | .[] | {pair: .key, vol_24h: .value.v[1]}' +kraken ticker BTCUSD ETHUSD SOLUSD ADAUSD DOTUSD -o json 2>/dev/null | jq 'to_entries | sort_by(-.value.volume_24h) | .[] | {pair: .key, vol_24h: .value.volume_24h}' ``` ## Futures Multi-Symbol @@ -108,3 +108,5 @@ kraken ws ticker $WATCHLIST -o json 2>/dev/null - Batch pairs into a single command instead of one call per pair. - Use streaming for continuous monitoring; use REST snapshots for periodic checks. - Limit watchlist size to reduce context window consumption when passing results to an agent. + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-order-types/SKILL.md b/skills/kraken-order-types/SKILL.md index 825cac6..bbe6516 100644 --- a/skills/kraken-order-types/SKILL.md +++ b/skills/kraken-order-types/SKILL.md @@ -158,3 +158,4 @@ kraken order buy BTCUSD 0.001 --type limit --price 50000 --validate -o json 2>/d - All order placement commands are dangerous. Never execute without explicit human approval. - Always validate with `--validate` before live submission. - Use `--reduce-only` on futures exits to prevent accidental position flips. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-paper-strategy/SKILL.md b/skills/kraken-paper-strategy/SKILL.md index 60edcec..4e42aad 100644 --- a/skills/kraken-paper-strategy/SKILL.md +++ b/skills/kraken-paper-strategy/SKILL.md @@ -20,20 +20,21 @@ For futures paper trading (leverage, margin, shorts, liquidation), see `kraken f ## Limitations -Paper trading runs locally against live market prices. A 0.26% taker fee (Kraken Starter tier default) is applied to every fill, but slippage and partial fills are not modeled. +Paper trading runs locally against live market prices. It simulates fees and slippage but has other limitations. -- **Fees included.** A 0.26% taker fee is deducted from collateral on each fill. Live maker fees are lower (0.16%), so limit orders that provide liquidity on a live exchange cost less than paper predicts. -- **No slippage.** Paper orders fill at the exact quoted price. Live market orders can fill at worse prices, especially for larger sizes or thin order books. +- **Fees are configurable.** A 0.26% taker fee (Kraken Starter tier) is applied by default. Override with `--fee-rate` at `workspace create` (fixed for the account's life). Real fees vary by volume tier and maker/taker status. Live maker fees are lower (0.16%). +- **Slippage is configurable.** A flat slippage rate can be applied to market orders with `--slippage-rate` (default: 0.0). Market buys fill at `ask * (1 + slippage_rate)`, sells at `bid * (1 - slippage_rate)`. This is a conservative approximation; real slippage depends on order book depth, size, and volatility. - **No partial fills or rejection.** Paper orders always fill in full immediately. Live orders may partially fill, queue, or be rejected. -When presenting paper results to the user, note that slippage is not modeled and live performance will differ. The fee deduction is already reflected in paper P&L. +When presenting paper results to the user, note that partial fills and order book dynamics are not simulated. If slippage is set to 0.0 (default), remind the user that live market orders will fill at worse prices. ## Baseline Workflow ```bash -kraken paper init --balance 10000 --currency USD -o json 2>/dev/null +kraken workspace create sandbox --capital 10000 --mode paper --currency USD --slippage-rate 0.001 -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 -o json 2>/dev/null -kraken paper status -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null kraken paper sell BTCUSD 0.005 --type limit --price 70000 -o json 2>/dev/null kraken paper orders -o json 2>/dev/null kraken paper history -o json 2>/dev/null @@ -42,7 +43,7 @@ kraken paper history -o json 2>/dev/null ## Reset Between Runs ```bash -kraken paper reset -o json 2>/dev/null +kraken workspace reset -o json --yes 2>/dev/null ``` ## Migration Rule @@ -51,3 +52,5 @@ Only move a strategy to live trading after: 1. repeated paper runs with stable behavior 2. explicit user sign-off 3. `--validate` checks pass for live order payloads + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-paper-to-live/SKILL.md b/skills/kraken-paper-to-live/SKILL.md index d192df5..226519d 100644 --- a/skills/kraken-paper-to-live/SKILL.md +++ b/skills/kraken-paper-to-live/SKILL.md @@ -1,13 +1,13 @@ --- name: kraken-paper-to-live -version: 1.0.0 -description: "Promote a validated paper strategy to live trading with safety checks." +version: 1.1.0 +description: "Promote a validated paper strategy to live trading, gated on lab experiment evidence plus safety checks." metadata: openclaw: category: "finance" requires: - bins: ["kraken"] - skills: ["kraken-paper-strategy", "kraken-spot-execution", "kraken-risk-operations"] + bins: ["kraken", "jq"] + skills: ["kraken-paper-strategy", "kraken-spot-execution", "kraken-risk-operations", "kraken-lab-experiment"] --- # kraken-paper-to-live @@ -20,21 +20,77 @@ Use this skill for: ## Paper-to-Live Performance Gap -Both spot and futures paper trading simulate taker fees (0.26% spot, configurable for futures). Slippage and partial fills are not fully modeled. Live results may differ. Before promoting, factor in: +Both spot and futures paper trading simulate taker fees (0.26% spot, configurable for futures). Spot paper supports optional flat-rate slippage simulation via `--slippage-rate`. Gaps remain between paper and live. Before promoting, factor in: -- **Slippage:** Market orders fill at available liquidity, not the mid-price. Thin books or larger sizes widen the gap. -- **Partial fills and latency:** Limit orders may fill partially or not at all. Network and matching-engine latency can cause missed entries or exits. +- **Fees are approximate.** Paper uses flat fee rates. Real fees depend on your volume tier and maker/taker status. Each round-trip trade costs 0.32-0.52% at base tier. +- **Slippage is a flat estimate.** If `--slippage-rate` was set (spot), market orders include a fixed slippage factor. Real slippage depends on order book depth, order size, and volatility. Futures paper does not model depth-based slippage. +- **No partial fills or latency.** Limit orders fill fully or not at all. Live orders may partially fill, queue, or be rejected. Network and matching-engine latency can cause missed entries or exits. - **Maker fees:** Paper uses taker fee rates for all fills. Live limit orders that provide liquidity pay lower maker fees (0.16% spot). -When presenting promotion analysis to the user, explicitly state the expected performance reduction from these factors. - -## Promotion Criteria +When presenting promotion analysis to the user, note the remaining gaps (partial fills, depth-dependent slippage, tier-based fees). If slippage was not configured in paper, explicitly state that live market orders will fill at worse prices. + +## Promotion Gate — evidence, not narrative + +**Never propose a live order without the evidence block below.** The gate +rule: **at least 2 passing sessions of one frozen experiment, of which a live +paper session is mandatory.** Replay sessions enter the evidence only with their +lookahead caveat attached — replay cannot enforce lookahead (the agent can +read the source dataset), so replay screens and live confirms; two replay +passes must never promote on their own. + +**Start with the CLI's own checklist** — `kraken workspace promote` grades +the whole gate mechanically and returns it in the refusal envelope (exit 1 +until scoped credentials land; the evaluation is the point): + +```bash +kraken workspace promote --yes -o json 2>/dev/null | jq '.checklist' +# .criteria[]: one row per gate criterion, satisfied true/false + detail +# .experiments[]: per-experiment passing_sessions / passing_live_sessions +# .manual_trades_in_windows: disclosed manual interference +# .blockers[]: what no evidence can satisfy yet (scoped credentials) +``` + +Present that checklist verbatim. Then, for the per-session detail behind it, +build the evidence block from `kraken lab compare` — reproduced cold from +disk, seals verified via `kraken lab show`, no numbers of your own: + +```bash +kraken lab show -o json 2>/dev/null # seal must verify +kraken lab compare -o json 2>/dev/null | jq '{ + experiment, frozen, pass_count, total, + sessions: [.sessions[] | {session, + verdict: (if .error != null then "error" else .verdict.pass end), + source: (.source.kind // "unknown"), + caveats}], + live_run_passed: ([.sessions[] | select((.verdict.pass // false) and .source.kind == "live")] | length > 0) +}' +``` + +The evidence block you present for sign-off: + +- `experiment` + `frozen` hash (the sealed goalposts), +- every session: session id, `source.kind` (`live`, `replay`, or `unknown` for an + errored session — errored sessions carry no scorecard), verdict (`true`, `false`, or + `"error"` — a mechanical FAIL is not an error), caveats verbatim, +- `pass_count` of `total`, and `live_run_passed` — **a passing run whose + `source.kind` is `live`** (first-class, no caveat-string parsing). + +**Refusals must name what is missing.** If `pass_count < 2`: say which sessions +failed or errored. If no live session passed: say "replay evidence only — a +passing live session is mandatory; run the plan's live entry" even when +`pass_count` ≥ 2. A tampered or missing experiment file blocks promotion +with its own `parse`/`validation` envelope — report it, never reconstruct. + +**Reset rule:** a sealed spec cannot change. A new hypothesis, new criteria, +or a re-recorded dataset is a **new experiment**, and its evidence count +starts at zero. A strategy is ready for live promotion when: -1. Paper runs produce consistent results over multiple sessions. +1. The evidence block above passes the gate rule (≥2 passing sessions, live session + mandatory, caveats attached). 2. Error handling works correctly (rate limits, network failures). 3. The strategy stays within defined risk parameters. -4. Paper returns remain positive after subtracting estimated fees (0.26% per fill for spot, 0.05% for futures). +4. Paper returns remain positive with fees (0.26% spot, 0.05% futures) and slippage enabled (`--fee-rate`, `--slippage-rate`). 5. The user explicitly approves the transition. ## Pre-Flight Checklist @@ -167,7 +223,11 @@ If live behavior diverges from paper: ## Hard Rules - Never promote without explicit user sign-off. +- Never promote without the promotion evidence block: ≥2 passing sessions of a + seal-verified experiment, a passing **live** run among them, caveats + attached. Refusals name exactly which sessions or verdicts are missing. - Start at reduced size. - Always validate live orders before executing. - Maintain dead man's switch throughout the live session. - First live session should run at autonomy level 3 (supervised) regardless of prior paper autonomy. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-playground/SKILL.md b/skills/kraken-playground/SKILL.md new file mode 100644 index 0000000..df72fcc --- /dev/null +++ b/skills/kraken-playground/SKILL.md @@ -0,0 +1,142 @@ +--- +name: kraken-playground +version: 2.0.0 +description: "Paper-trading sandbox over workspace sessions: record live market data and agent decisions inside a session window, replay and score it later." +metadata: + openclaw: + category: "finance" + requires: + bins: ["kraken"] +--- + +# kraken-playground + +A session ties a market recorder and the workspace's paper account to one window. Live prices stream in and get recorded as the session's tape, paper orders fill against those prices on the shared account journal, and every decision is logged, so the window can be replayed and analyzed later. No real order is ever placed inside a paper workspace. + +Use this skill for: +- running a paper-trading hypothesis on live prices with no real money +- recording ticker, trade, and book data alongside paper fills and decision reasons +- driving an agent hypothesis over a window at a fixed cadence +- replaying a session and computing P&L after the fact + +## The One-Command Demo + +`kraken playground` is the fastest way in: it creates (or reuses, never re-funding) a paper workspace named `playground` with 10,000 USD, prints how to watch it, and starts a recorded, self-stopping demo session: + +```bash +kraken playground --symbols BTC/USD --for 1h 2>/dev/null & +export KRAKEN_WORKSPACE=playground +``` + +Everything it makes is a plain workspace plus a session — the commands below drive it like any other scope. + +## What a Session Is + +A session is a recorded window over the active scope's account journal. One session id (`s1`, `s2`, …) binds three things: the recorder (live market data to `sessions/s/tape.*`), the window markers on the account journal (which trades belong to the session), and the session's decision log (the "why" behind each action). There is no daemon and no shared memory between rounds. State lives on disk, and each round reads what it needs. + +The CLI has no scheduler and runs no strategy. It records, fills paper orders, and logs. The agent decides, and something external holds the pace (see Pacing). + +## Start a Session + +Work inside a paper workspace (create one once with `kraken workspace create --capital 10000 --mode paper`), then: + +```bash +export KRAKEN_WORKSPACE= + +kraken session start \ + --symbols BTC/USD --channels ticker,trade --to duckdb,jsonl \ + --label my-hypothesis-r1 \ + -o json 2>/dev/null & +``` + +The `session_started` JSON line on stdout carries the session id (`"session":"s"`). **Print it to the user as soon as the session starts, and again in the final report.** It is the handle for everything after: `kraken session show --session s`, resuming a `/loop`, and locating the artifacts under `sessions/s/`. + +- `--symbols`: instruments to record and trade against +- `--channels`: which feeds to record (e.g., `ticker,trade`, add `book` for depth) +- `--to`: recording sinks (`duckdb` for query, `jsonl` for raw replay) +- `--label`: a human handle, usable anywhere a `--session` ref is +- `--strategy` / `--strategy-params`: name and JSON knobs of the driver, recorded in `session.json` so two sessions compare knob-for-knob +- `--for`: auto-stop after a window (e.g. `1h`) — the session closes itself cleanly +- `--from tape: --speed 10`: replay a recorded tape instead of the live market + +## Paper Trades + +Buy or sell against recorded prices — the order verbs are mode-routed, so inside a paper workspace they fill on the paper account. Always pass a reason; it lands in the active session's decision log: + +```bash +kraken order buy BTC/USD --type market --reason "" -o json 2>/dev/null +kraken order sell BTC/USD --type market --reason "" -o json 2>/dev/null +``` + +Log a non-trade decision (a skip, a gate, an alert) so the log records rounds where nothing traded: + +```bash +kraken session note --kind skip --symbol BTC/USD --reason "" -o json 2>/dev/null +``` + +## Recording + +Data lands under the session directory of the active scope: + +```bash +# Read the decision log (evidence) and the typed state cell (the loop's +# cursor: round, legs_done, zone, last_action_at) through the CLI — +# no layout knowledge needed: +kraken session decisions --session s -o json +kraken session state get --session s -o json +``` + +- `session.json`: the session's contract — window, opening equity anchor, strategy, experiment +- `decisions.jsonl`: one line per decision (kind, symbol, reason, timestamp, order id) +- `tape.duckdb` / `tape.jsonl`: the raw tape for replay and P&L + +## Pacing + +Pacing is external to the CLI. Two mechanisms, one per hypothesis shape: + +- Time-gated: `/loop` owns the schedule. Set its interval to the decision spacing and it fires one round per interval. +- Event-gated: a WebSocket stream's event rate owns the schedule. The loop acts on qualifying events (see `kraken-ws-streaming`). + +For time-gated runs, the interval is a floor. A round may start late (the scheduler adds jitter), never early. Do not add a second pacing gate inside the round; `/loop` already enforces spacing. + +**The scheduler floor is 60 seconds.** `/loop`, cron, and `ScheduleWakeup` all clamp to a one-minute minimum, so `interval_s` must be `>= 60` and a sub-minute cadence is impossible. If the user asks for "every 30 seconds", clamp to 60s and say so — never set a sub-minute `interval_s`, and never let the report claim a cadence the harness cannot run. + +## Running over a Window + +A session over many rounds is interventional: one visit per interval, paced from outside. Pick the mechanism by what triggers a decision. + +- "Act every N minutes on the current price": time-gated. `/loop N` fires the round, each round takes one REST snapshot (`kraken ticker`, `kraken ohlc`), decides, acts. See `recipe-playground-dca`. +- "Act the instant a condition trips": event-gated. Subscribe to a stream and act on each qualifying tick, rate-limited so you do not act faster than intended. See `recipe-playground-dca-triggered`. + +Time-gated misses conditions that appear and resolve between visits. Event-gated catches them but runs as a long-lived process and is blind during reconnect gaps. Choose per hypothesis. + +## Driving Your Own Hypothesis + +A driver is a recipe that turns a hypothesis into recorded rounds. The shape of every round: + +1. READ: pull the market state the decision needs (price, an average, spread). +2. THINK: apply the numeric gates from `--strategy-params`. +3. ACT: `order buy` / `order sell` on a trigger, `session note` on a skip, always with a numeric reason. + +Keep every number that gates a buy or skip in `--strategy-params`, not only in prose reasons. That is what makes two sessions comparable. Keep the arithmetic deterministic (compute in `jq`/`bc`, do not eyeball it) so a rerun on the same tape reaches the same decisions. + +## Show, Stop, and Score + +```bash +kraken session show -o json 2>/dev/null | jq '.window, .valuation' +kraken session stop -o json 2>/dev/null +kraken explain pnl --session latest -o json 2>/dev/null +kraken lab score --session latest -o json 2>/dev/null +``` + +`--session` accepts `latest` (the default everywhere), an ordinal (`s3`), or a label. + +## Hard Rules + +- A session inside a paper workspace never places a live order. It records and fills paper only. +- Paper results may overstate live performance: fees and slippage are simulated and there are no partial fills (see `kraken-paper-strategy`). +- No daemon, no shared memory between rounds. State lives on disk under the workspace and its sessions. +- Pace from outside the CLI: `/loop` for time-gated, a stream's event rate for event-gated. Never add a redundant pacing gate inside a round. The scheduler floor is 60s — no sub-minute cadence. +- Always pass a reason on every trade and skip, so the decision log carries the full "why". +- The session directory (`session.json`, `decisions.jsonl`, and the tape sinks) is owned by the CLI recorder. The agent only **reads** it, and only for status and the stop-time summary. Never write, edit, `mkdir`, or append inside it — every decision goes through `kraken order buy` / `kraken session note`, so the recorder stays the single writer. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-portfolio-intel/SKILL.md b/skills/kraken-portfolio-intel/SKILL.md index c475536..3563f06 100644 --- a/skills/kraken-portfolio-intel/SKILL.md +++ b/skills/kraken-portfolio-intel/SKILL.md @@ -145,7 +145,7 @@ Download when ready: kraken export-retrieve -o json 2>/dev/null ``` -Clean up old reports: +Clean up old reports (`export-delete` is flagged dangerous — the report is gone and must be regenerated, so confirm with the user first): ```bash kraken export-delete -o json 2>/dev/null @@ -197,3 +197,5 @@ kraken futures positions -o json 2>/dev/null - Use `--without-count` on large history queries to skip count computation. - Filter by asset and time range to reduce response size. - Use exports for bulk data instead of paginating through REST. + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-rate-limits/SKILL.md b/skills/kraken-rate-limits/SKILL.md index 6eaa642..a311618 100644 --- a/skills/kraken-rate-limits/SKILL.md +++ b/skills/kraken-rate-limits/SKILL.md @@ -115,3 +115,5 @@ kraken trades-history -o json 2>/dev/null # 2 points (heavy call) ``` Leave headroom for retries and unexpected calls. + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-rebalancing/SKILL.md b/skills/kraken-rebalancing/SKILL.md index eb9fc2f..314efec 100644 --- a/skills/kraken-rebalancing/SKILL.md +++ b/skills/kraken-rebalancing/SKILL.md @@ -50,21 +50,22 @@ Set a minimum trade threshold (e.g., $50) to avoid placing tiny orders that wast ## Paper Rebalance Test ```bash -kraken paper init --balance 10000 -o json 2>/dev/null +kraken workspace create sandbox --capital 10000 --mode paper -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox # Initial buys to establish positions kraken paper buy BTCUSD 0.05 -o json 2>/dev/null kraken paper buy ETHUSD 1.0 -o json 2>/dev/null kraken paper buy SOLUSD 10 -o json 2>/dev/null -kraken paper status -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null # After price drift, rebalance # Sell overweight asset, buy underweight asset kraken paper sell BTCUSD 0.005 -o json 2>/dev/null kraken paper buy SOLUSD 2 -o json 2>/dev/null -kraken paper status -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null ``` ## Live Rebalance Execution @@ -102,7 +103,7 @@ Periodically compare current weights to targets. Trigger rebalance when any asse For lower fees, use limit orders instead of market: ```bash -PRICE=$(kraken ticker SOLUSD -o json 2>/dev/null | jq -r '.[].a[0]') +PRICE=$(kraken ticker SOLUSD -o json 2>/dev/null | jq -r '.[].ask_price') kraken order buy SOLUSD 2 --type limit --price $PRICE -o json 2>/dev/null ``` @@ -119,3 +120,4 @@ kraken open-orders -o json 2>/dev/null - Execute sells before buys to ensure sufficient quote currency. - Respect minimum order sizes for each pair (check pair info with `kraken pairs`). - Apply a minimum trade threshold to skip negligible adjustments. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-risk-operations/SKILL.md b/skills/kraken-risk-operations/SKILL.md index 94cc9b2..ed8519e 100644 --- a/skills/kraken-risk-operations/SKILL.md +++ b/skills/kraken-risk-operations/SKILL.md @@ -55,3 +55,4 @@ kraken futures positions -o json 2>/dev/null - All cancel and cancel-all operations are dangerous. Require explicit human approval before executing. - Dead-man switch (`cancel-after`) is also dangerous; confirm the timeout value with the user. - Never execute mass-cancel without verifying which orders and positions are open first. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-setup/SKILL.md b/skills/kraken-setup/SKILL.md index 6ddb570..2ceab98 100644 --- a/skills/kraken-setup/SKILL.md +++ b/skills/kraken-setup/SKILL.md @@ -41,9 +41,10 @@ kraken --version Paper trading uses live prices with a simulated balance. No API keys required. Start here. ```bash -kraken paper init --balance 10000 -o json +kraken workspace create sandbox --capital 10000 --mode paper -o json +export KRAKEN_WORKSPACE=sandbox kraken paper buy BTCUSD 0.01 -o json -kraken paper status -o json +kraken workspace status -o json ``` Futures paper trading works the same way: @@ -120,7 +121,7 @@ The CLI ships with a built-in MCP server. Point any MCP-compatible client (Claud See `skills/kraken-mcp-integration/SKILL.md` for the per-client config blocks and the full service reference. -The default MCP config in every `kraken-cli` marketplace listing (Claude, Cursor, Codex, Gemini) is `-s market,paper`: +The default MCP config in every `kraken-cli` marketplace listing (Claude, Cursor, Codex, Gemini) is `-s market,paper,feedback`: | Service | Auth | Why it's in the default | |---------|------|-------------------------| @@ -149,3 +150,5 @@ progression. **Futures commands fail with `auth` but spot works**: futures needs its own key. Set `KRAKEN_FUTURES_API_KEY` and `KRAKEN_FUTURES_API_SECRET`. **MCP client shows no Kraken tools**: confirm `kraken` is on the PATH the client sees (MCP clients often launch from a minimal shell). Then see `kraken-mcp-integration` troubleshooting. + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-shared/SKILL.md b/skills/kraken-shared/SKILL.md index 41962bc..d773e72 100644 --- a/skills/kraken-shared/SKILL.md +++ b/skills/kraken-shared/SKILL.md @@ -26,6 +26,10 @@ Rules: - Treat `stderr` as diagnostics. - Exit code `0` means success. - Non-zero exit means failure with JSON envelope in `stdout`. +- Streaming commands (`session start`, `record`, `ws`, `streamd`) emit JSONL: match lines by their `"type"` field (e.g. scan for `"type":"session_started"` to get the session handle) — never assume a line position, because live market frames interleave on `stdout`. +- Probe capability, not version, at the start of a loop: `kraken session --help >/dev/null 2>&1` exits `0` on a build that speaks this contract and `2` on an older one — `kraken --version` cannot tell them apart across a vocabulary change. +- Market-data field names are readable (`last_price`, not `c[0]`). Only `ticker` still keys on Kraken's INTERNAL pair name (`XXBTZUSD`, not `BTCUSD`) — reach its fields with `.[]` or `to_entries`, never a hardcoded key. `ohlc`/`trades`/`spreads` reshape to `{pair, …}`: fold the named row array (`.candles`/`.trades`/`.spreads`) directly, never the whole object — `ohlc` keeps a sibling `last` cursor that poisons naive iteration. `orderbook` drops the wrapper to top-level `.asks`/`.bids`. +- Validate every number a READ produces before it gates an order or lands in a reason. On an empty or failed read, record `kraken session note --kind alert --reason "READ failed: ..."` and end the step — never interpolate a failed read into a reason, or the decision log fills with plausible-looking false evidence. ## Authentication @@ -54,7 +58,7 @@ Route on `.error`: ## Safety -The catalog marks 32 commands as `dangerous: true`. Always check the `dangerous` field in `agents/tool-catalog.json` before executing a command. +The catalog marks 41 commands as `dangerous: true`. Always check the `dangerous` field in `agents/tool-catalog.json` before executing a command. Require explicit human approval before: - live buy or sell orders (spot and futures) @@ -68,5 +72,8 @@ Require explicit human approval before: Use paper trading for dry runs: ```bash -kraken paper init --balance 10000 -o json +kraken workspace create sandbox --capital 10000 --mode paper -o json +export KRAKEN_WORKSPACE=sandbox ``` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-spot-execution/SKILL.md b/skills/kraken-spot-execution/SKILL.md index 4258b1d..c53c397 100644 --- a/skills/kraken-spot-execution/SKILL.md +++ b/skills/kraken-spot-execution/SKILL.md @@ -52,3 +52,4 @@ Use this skill for: - Never execute live order commands without explicit user approval. - Route failures by `.error` category. - On `rate_limit`, read `suggestion` and `docs_url` fields, then adapt strategy. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-stop-take-profit/SKILL.md b/skills/kraken-stop-take-profit/SKILL.md index 3f85f2c..0395f31 100644 --- a/skills/kraken-stop-take-profit/SKILL.md +++ b/skills/kraken-stop-take-profit/SKILL.md @@ -111,3 +111,4 @@ When one side of a bracket fills, immediately cancel the other. - Always cancel the opposite leg when one side of a bracket fills. - Use `--reduce-only` on futures exits to prevent accidental position flips. - Validate all orders before submission with `--validate`. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-subaccount-ops/SKILL.md b/skills/kraken-subaccount-ops/SKILL.md index 2739c46..67ff756 100644 --- a/skills/kraken-subaccount-ops/SKILL.md +++ b/skills/kraken-subaccount-ops/SKILL.md @@ -88,7 +88,8 @@ For centralized monitoring, use the main account's credentials with query permis ## Hard Rules -- Subaccount transfers are flagged as dangerous. Never execute without explicit human approval. +- Subaccount transfers and `subaccount create` are flagged as dangerous. Never execute without explicit human approval — a created subaccount cannot be removed through the API. - Never share API keys across subaccounts; create separate keys per subaccount. - Verify IIBAN values before initiating transfers; incorrect IIBANs will fail. - Disable futures trading on subaccounts that should not trade futures. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-tax-export/SKILL.md b/skills/kraken-tax-export/SKILL.md index b2f52ca..9ac4f78 100644 --- a/skills/kraken-tax-export/SKILL.md +++ b/skills/kraken-tax-export/SKILL.md @@ -107,7 +107,8 @@ kraken ledgers --type staking --start 1704067200 --end 1735689600 -o json 2>/dev ## Hard Rules -- Export data is read-only; no dangerous operations involved. +- Requesting and reading exports is read-only, but `export-retrieve` (writes to disk) and `export-delete` (irreversible) are flagged dangerous. Never delete a report without explicit human approval. - Verify export completeness by checking trade counts against `trades-history` totals. - Keep exports secure; they contain full trading history. - Futures and spot exports are separate; ensure both are included. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-twap-execution/SKILL.md b/skills/kraken-twap-execution/SKILL.md index 2eab2a6..0272a99 100644 --- a/skills/kraken-twap-execution/SKILL.md +++ b/skills/kraken-twap-execution/SKILL.md @@ -32,7 +32,8 @@ Time-Weighted Average Price (TWAP) splits a large order into N equal slices exec ## Paper TWAP Test ```bash -kraken paper init --balance 50000 -o json 2>/dev/null +kraken workspace create sandbox --capital 50000 --mode paper -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox # Simulate 5 slices of 0.01 BTC each, 60s apart kraken paper buy BTCUSD 0.01 -o json 2>/dev/null @@ -43,7 +44,7 @@ kraken paper buy BTCUSD 0.01 -o json 2>/dev/null # repeat... kraken paper history -o json 2>/dev/null -kraken paper status -o json 2>/dev/null +kraken workspace status -o json 2>/dev/null ``` ## Live TWAP Loop @@ -67,7 +68,7 @@ done Use limit orders at the current best bid/ask for potentially better fills: ```bash -PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].a[0]') +PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].ask_price') kraken order buy BTCUSD $SLICE_VOL --type limit --price $PRICE -o json 2>/dev/null ``` @@ -97,3 +98,4 @@ The CLI does not pre-throttle requests. If a slice submission hits a rate limit, - Track cumulative fill volume and stop if total exceeds target (handle partial fills). - On error, pause the loop rather than skipping the slice; resume after recovery. - Log every slice for post-execution analysis. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/kraken-ws-streaming/SKILL.md b/skills/kraken-ws-streaming/SKILL.md index 9c21ef8..0df967a 100644 --- a/skills/kraken-ws-streaming/SKILL.md +++ b/skills/kraken-ws-streaming/SKILL.md @@ -190,3 +190,4 @@ For multi-feed agents, run each stream in a background process and merge events. - WebSocket order mutations are flagged as dangerous. Require human approval. - Never treat NDJSON stream output as a single JSON document. - Handle stream disconnects gracefully; the CLI reconnects automatically with paced exponential backoff and reconnect safety budgeting (up to 12 attempts per stream lifecycle). +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-basis-trade-entry/SKILL.md b/skills/recipe-basis-trade-entry/SKILL.md index 8d88963..2852ed1 100644 --- a/skills/recipe-basis-trade-entry/SKILL.md +++ b/skills/recipe-basis-trade-entry/SKILL.md @@ -21,7 +21,7 @@ Enter a delta-neutral basis trade (long spot, short futures) when the basis prem ## Steps -1. Get spot price: `SPOT=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0]')` +1. Get spot price: `SPOT=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price')` 2. Get futures price: `FUTURES=$(kraken futures ticker PF_XBTUSD -o json 2>/dev/null | jq -r '.ticker.last')` 3. Calculate basis: `BASIS=$(echo "scale=4; ($FUTURES - $SPOT) / $SPOT * 100" | bc)` 4. If basis < target threshold (e.g., 0.5% annualized), skip @@ -32,3 +32,5 @@ Enter a delta-neutral basis trade (long spot, short futures) when the basis prem 9. Execute futures short: `kraken futures order sell PF_XBTUSD 1 --type limit --price $FUTURES -o json 2>/dev/null` 10. Verify both legs filled: `kraken open-orders -o json 2>/dev/null` + `kraken futures open-orders -o json 2>/dev/null` 11. If one leg unfilled, cancel it to avoid directional exposure + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-daily-pnl-report/SKILL.md b/skills/recipe-daily-pnl-report/SKILL.md index a8ae2bd..748f63d 100644 --- a/skills/recipe-daily-pnl-report/SKILL.md +++ b/skills/recipe-daily-pnl-report/SKILL.md @@ -30,3 +30,5 @@ Generate a daily summary of trading activity, fees, and portfolio change. 9. Check futures positions if applicable: `kraken futures positions -o json 2>/dev/null` 10. Check futures funding accrued: `kraken futures accounts -o json 2>/dev/null` 11. Present summary: total value, daily change, realized P&L, fees, open positions + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-drawdown-circuit-breaker/SKILL.md b/skills/recipe-drawdown-circuit-breaker/SKILL.md index e384400..bae9c58 100644 --- a/skills/recipe-drawdown-circuit-breaker/SKILL.md +++ b/skills/recipe-drawdown-circuit-breaker/SKILL.md @@ -32,3 +32,5 @@ Monitor portfolio value and halt trading if cumulative drawdown from peak exceed 5. Present drawdown report: peak value, current value, drawdown percentage, orders cancelled 6. Cancel operations require explicit human approval unless operating at autonomy level 4+ 7. Wait for explicit user instruction before resuming any trading activity + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-earn-yield-compare/SKILL.md b/skills/recipe-earn-yield-compare/SKILL.md index 26deda7..739f5f4 100644 --- a/skills/recipe-earn-yield-compare/SKILL.md +++ b/skills/recipe-earn-yield-compare/SKILL.md @@ -31,3 +31,5 @@ Compare available staking/earn strategies across assets to find the best risk-ad 6. Check current allocations: `kraken earn allocations --hide-zero-allocations --converted-asset USD -o json 2>/dev/null` 7. Identify reallocation opportunities (move from lower to higher yield) 8. If user approves, deallocate from lower yield and allocate to higher yield + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-emergency-flatten/SKILL.md b/skills/recipe-emergency-flatten/SKILL.md index df34e37..a87c067 100644 --- a/skills/recipe-emergency-flatten/SKILL.md +++ b/skills/recipe-emergency-flatten/SKILL.md @@ -31,3 +31,5 @@ Immediately cancel all orders and close all positions across spot and futures. 8. Verify no futures orders remain: `kraken futures open-orders -o json 2>/dev/null` 9. Verify all positions closed: `kraken futures positions -o json 2>/dev/null` 10. Check final balances: `kraken balance -o json 2>/dev/null` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-fee-tier-progress/SKILL.md b/skills/recipe-fee-tier-progress/SKILL.md index 8beace8..314bac8 100644 --- a/skills/recipe-fee-tier-progress/SKILL.md +++ b/skills/recipe-fee-tier-progress/SKILL.md @@ -26,3 +26,5 @@ Check current 30-day volume and calculate how far you are from the next fee tier 5. Estimate days to reach at current daily average 6. Check futures volume if applicable: `kraken futures fee-schedule-volumes -o json 2>/dev/null` 7. Present summary: current tier, current volume, next tier threshold, volume remaining, estimated days + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-funding-rate-scan/SKILL.md b/skills/recipe-funding-rate-scan/SKILL.md index 8d1c284..eb15ae2 100644 --- a/skills/recipe-funding-rate-scan/SKILL.md +++ b/skills/recipe-funding-rate-scan/SKILL.md @@ -30,3 +30,5 @@ Scan available perpetual contracts, identify those with high funding rates, and 6. Check spot price for hedge calculation: `kraken ticker BTCUSD ETHUSD SOLUSD -o json 2>/dev/null` 7. Check futures account margin: `kraken futures accounts -o json 2>/dev/null` 8. Present ranked opportunities with annualized yield, direction, and required margin + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-futures-hedge-spot/SKILL.md b/skills/recipe-futures-hedge-spot/SKILL.md index 5edc53d..dee1719 100644 --- a/skills/recipe-futures-hedge-spot/SKILL.md +++ b/skills/recipe-futures-hedge-spot/SKILL.md @@ -25,9 +25,11 @@ Protect a spot BTC holding from downside by shorting an equivalent futures posit 2. Get spot price: `kraken ticker BTCUSD -o json 2>/dev/null` 3. Get futures price: `kraken futures ticker PF_XBTUSD -o json 2>/dev/null` 4. Check futures margin availability: `kraken futures accounts -o json 2>/dev/null` -5. Calculate matching futures size: `BTC_BAL=$(kraken balance -o json 2>/dev/null | jq -r '.XXBT // .XBT // "0"')` and `FUT_PRICE=$(kraken futures ticker PF_XBTUSD -o json 2>/dev/null | jq -r '.ticker.last')` +5. Calculate matching futures size: `BTC_BAL=$(kraken balance -o json 2>/dev/null | jq -r '.BTC // "0"')` and `FUT_PRICE=$(kraken futures ticker PF_XBTUSD -o json 2>/dev/null | jq -r '.ticker.last')` 6. Open short futures position (requires human approval): `kraken futures order sell PF_XBTUSD $BTC_BAL --type limit --price $FUT_PRICE -o json 2>/dev/null` 7. Verify the position: `kraken futures positions -o json 2>/dev/null` 8. Enable dead man's switch: `kraken futures cancel-after 3600 -o json 2>/dev/null` 9. Monitor margin: `kraken futures accounts -o json 2>/dev/null` 10. To remove hedge, close futures: `kraken futures order buy PF_XBTUSD $BTC_BAL --reduce-only -o json 2>/dev/null` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-launch-grid-bot/SKILL.md b/skills/recipe-launch-grid-bot/SKILL.md index 27229e5..6355dbe 100644 --- a/skills/recipe-launch-grid-bot/SKILL.md +++ b/skills/recipe-launch-grid-bot/SKILL.md @@ -21,16 +21,19 @@ Deploy a grid of buy and sell orders across a price range. ## Steps -1. Get current price: `PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0]')` +1. Get current price: `PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price')` 2. Compute grid levels (e.g., 3 levels at $1000 spacing): `BUY1=$(echo "$PRICE - 1000" | bc)`, `BUY2=$(echo "$PRICE - 2000" | bc)`, `BUY3=$(echo "$PRICE - 3000" | bc)`, `SELL1=$(echo "$PRICE + 1000" | bc)`, `SELL2=$(echo "$PRICE + 2000" | bc)`, `SELL3=$(echo "$PRICE + 3000" | bc)` -3. Paper-test the grid: `kraken paper init --balance 10000 -o json 2>/dev/null` +3. Paper-test the grid: `kraken workspace create sandbox --capital 10000 --mode paper -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox` 4. Place paper buy levels: `kraken paper buy BTCUSD 0.001 --type limit --price $BUY1 -o json 2>/dev/null` (repeat for BUY2, BUY3) 5. Place paper sell levels: `kraken paper sell BTCUSD 0.001 --type limit --price $SELL1 -o json 2>/dev/null` (repeat for SELL2, SELL3) 5. Monitor paper fills: `kraken paper orders -o json 2>/dev/null` -6. Review paper results: `kraken paper status -o json 2>/dev/null` +6. Review paper results: `kraken workspace status -o json 2>/dev/null` 7. Pre-flight live: `kraken auth test -o json 2>/dev/null && kraken balance -o json 2>/dev/null` 8. Enable dead man's switch: `kraken order cancel-after 3600 -o json 2>/dev/null` 10. Validate live grid orders: `kraken order buy BTCUSD 0.001 --type limit --price $BUY1 --validate -o json 2>/dev/null` 11. Place live grid (requires human approval): place buy and sell levels sequentially using $BUY1-3 and $SELL1-3 11. Monitor fills: `kraken ws executions -o json 2>/dev/null` 12. Replace filled levels: when a buy fills, place a sell one level up, and vice versa + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-morning-market-brief/SKILL.md b/skills/recipe-morning-market-brief/SKILL.md index 7d8fb1b..c89d9b5 100644 --- a/skills/recipe-morning-market-brief/SKILL.md +++ b/skills/recipe-morning-market-brief/SKILL.md @@ -27,3 +27,5 @@ Generate a concise morning summary covering market conditions and portfolio stat 6. Check futures positions if applicable: `kraken futures positions -o json 2>/dev/null` 7. Check earn allocations: `kraken earn allocations --hide-zero-allocations -o json 2>/dev/null` 8. Present summary: price table, 24h change, portfolio value, open orders count, position P&L + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-multi-pair-breakout-watch/SKILL.md b/skills/recipe-multi-pair-breakout-watch/SKILL.md index 4074595..938f05b 100644 --- a/skills/recipe-multi-pair-breakout-watch/SKILL.md +++ b/skills/recipe-multi-pair-breakout-watch/SKILL.md @@ -30,3 +30,5 @@ Watch a set of pairs for breakouts above resistance or below support levels. 6. On breakout detection: alert the user with pair, direction, price, and level crossed 7. Optionally check volume to confirm breakout strength: `kraken ticker BTCUSD ETHUSD SOLUSD -o json 2>/dev/null` 8. Present breakout candidates ranked by volume confirmation + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-paper-strategy-backtest/SKILL.md b/skills/recipe-paper-strategy-backtest/SKILL.md index 5c5cfab..c5a5c3e 100644 --- a/skills/recipe-paper-strategy-backtest/SKILL.md +++ b/skills/recipe-paper-strategy-backtest/SKILL.md @@ -15,22 +15,25 @@ metadata: > **PREREQUISITE:** Load the following skill to execute this recipe: `kraken-paper-strategy` -Run a strategy through multiple paper sessions to validate consistency before live deployment. +Run a strategy through multiple paper runs to validate consistency before live deployment. -## Important: Paper Results Overstate Live Performance +## Important: Paper Results May Overstate Live Performance -Paper trading fills at the exact quoted price with no fees, no slippage, and no partial fills. Live trading on Kraken incurs maker/taker fees (0.16%/0.26% at base tier), slippage on market orders, and possible partial fills on limit orders. When comparing session results in step 9 below, subtract at least 0.26% per fill from paper returns to get a more realistic estimate. A strategy that is only marginally profitable on paper is likely unprofitable live. +Paper trading applies configurable fees (default 0.26% taker) and optional flat-rate slippage to market orders. However, it does not simulate partial fills, order book depth, or latency. Live trading on Kraken has variable maker/taker fees by volume tier, depth-dependent slippage, and possible partial fills on limit orders. Enable `--slippage-rate` (e.g., `0.001` for 0.1%) at `workspace create` for more conservative estimates. A strategy that is only marginally profitable on paper with fees and slippage enabled is likely unprofitable live. ## Steps 1. Define strategy parameters (pair, entry/exit rules, position size) -2. Initialize paper account: `kraken paper init --balance 10000 -o json 2>/dev/null` -3. Run session 1: execute strategy logic using paper buy/sell commands -4. Record session 1 results: `kraken paper status -o json 2>/dev/null` + `kraken paper history -o json 2>/dev/null` -5. Reset: `kraken paper reset -o json 2>/dev/null` -6. Run session 2 with same parameters at a different time (different market conditions) -7. Record session 2 results -8. Repeat for 3-5 sessions minimum +2. Initialize paper account: `kraken workspace create sandbox --capital 10000 --mode paper --slippage-rate 0.001 -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox` +3. Round 1: execute strategy logic using paper buy/sell commands +4. Record round 1 results: `kraken workspace status -o json 2>/dev/null` + `kraken paper history -o json 2>/dev/null` +5. Reset: `kraken workspace reset -o json 2>/dev/null` +6. Round 2 with same parameters at a different time (different market conditions) +7. Record round 2 results +8. Repeat for 3-5 rounds minimum 9. Compare results: win rate, average P&L per trade, max drawdown, Sharpe-like ratio 10. If results are consistent and positive, the strategy is a candidate for live promotion 11. If results vary wildly, adjust parameters and re-test + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-playground-dca-triggered/SKILL.md b/skills/recipe-playground-dca-triggered/SKILL.md new file mode 100644 index 0000000..b0b20ec --- /dev/null +++ b/skills/recipe-playground-dca-triggered/SKILL.md @@ -0,0 +1,155 @@ +--- +name: recipe-playground-dca-triggered +version: 1.0.0 +description: "Example driver: dip-triggered dollar-cost-averaging on a live WebSocket stream, recorded to a session." +metadata: + openclaw: + category: "recipe" + domain: "sessions" + requires: + bins: ["kraken"] + skills: ["kraken-playground", "kraken-dca-strategy", "kraken-ws-streaming"] +--- + +# Playground: DCA (Dip-Triggered) + +> **PREREQUISITE:** Load `kraken-playground`, `kraken-dca-strategy`, and `kraken-ws-streaming` to run this recipe. +> **This recipe is an example, not a boundary.** Adapt the steps to your hypothesis, or write your own driver (see `kraken-playground` → Driving Your Own Hypothesis). + +Buy the instant price dips below a short-term average, not on a fixed clock. A WebSocket ticker stream drives the loop and every qualifying tick is a candidate buy, rate-limited so buys stay spaced. Record every buy and skip into one session for later replay and P&L. + +Use this skill for: +- catching an intra-interval dip that a time-based sampler would miss +- event-driven DCA where the trigger is a price condition, not a clock +- recording a streamed decision loop for replay + +## Important + +This recipe records a session. It never places a live order. Paper results may overstate live performance: fees and slippage are simulated and there are no partial fills (see `kraken-paper-strategy`). + +The stream is the loop, not `/loop`. A `while read` over a stream is a long-lived process that holds state (the reference average, the last-buy time) in memory across events. This is a deliberate exception to the stateless-round model (see `kraken-playground` → Running over a Window); keep the session short and the state minimal. + +The stream is blind during reconnect gaps. The CLI reconnects with paced exponential backoff (up to 12 attempts per stream lifecycle, see `kraken-ws-streaming`), and any dip inside that window is missed. The recorder tolerates gaps; a decision loop does not. + +## Params + +Every number that gates a buy goes in `--strategy-params`: + +- `dollars_per_buy`: quote currency deployed per triggered buy +- `dip_threshold_pct`: buy when price is at least this far below the short-term average +- `min_spacing_s`: minimum seconds between buys; the rate limit on the trigger. Enforce it from the typed cursor: `kraken session state set --last-action-at ` after each buy, and skip the trigger while `now - .cursor.last_action_at < min_spacing_s` (`kraken session state get`) +- `max_buys`: session length in buys; stop after this many +- `sma_refresh_s`: how often to refresh the reference average from REST + +## Quick Start + +Natural language: + +``` +Dollar-cost-average into Bitcoin. Watch the live price and buy $100 whenever +it dips more than 0.10% below the 1-hour SMA, but no more than once every +20 minutes, up to 10 buys. Record all buys and skips. At the end, show P&L. +``` + +## Start the Session + +Work inside a paper workspace (create one once: `kraken workspace create dcatrig --capital 10000 --mode paper`), then: + +```bash +export KRAKEN_WORKSPACE=dcatrig + +kraken session start \ + --symbols BTC/USD --channels ticker,trade --to duckdb,jsonl \ + --label dcatrig-btc-$(date +%Y%m%d-%H%M%S) \ + --strategy recipe-playground-dca-triggered \ + --strategy-params '{"dollars_per_buy":100,"dip_threshold_pct":-0.10,"min_spacing_s":1200,"max_buys":10,"sma_refresh_s":300}' \ + -o json 2>/dev/null & + +# The session_started stdout line carries the id: {"type":"session_started","session":"s",...} +``` + +Print the session id to the user right after starting, and again in the final report — it is the handle for checking `kraken session show` and locating the artifacts. + +## Stream and Decide + +Subscribe to the ticker with the BBO trigger to cut noise, and act on each tick. Hold the reference average and the last-buy time in the loop; refresh the average from REST every `sma_refresh_s`, not on the tick rate. + +Bind the gating numbers to the same `--strategy-params` you recorded, so the loop runs the hypothesis you started — never hardcode them into the arithmetic: + +```bash +DOLLARS_PER_BUY=100 +DIP_THRESHOLD_PCT=-0.10 +MIN_SPACING_S=1200 +MAX_BUYS=10 +SMA_REFRESH_S=300 + +# True 1h SMA: last twelve 5-minute closes. (--interval 60 would average the +# whole returned window — a multi-day mean, not 1h.) +SMA=$(kraken ohlc BTCUSD --interval 5 -o json 2>/dev/null | jq '[.candles[-12:][].close] | add/length') +SMA_TS=$(date +%s) +LAST_BUY=0 +BUYS=0 + +kraken ws ticker BTC/USD --event-trigger bbo -o json 2>/dev/null | while read -r line; do + PRICE=$(echo "$line" | jq -r '.data[0].last // empty'); [ -z "$PRICE" ] && continue + NOW=$(date +%s) + + # Refresh SMA on its own cadence, not per tick + if [ $((NOW - SMA_TS)) -ge $SMA_REFRESH_S ]; then + SMA=$(kraken ohlc BTCUSD --interval 5 -o json 2>/dev/null | jq '[.candles[-12:][].close] | add/length') + SMA_TS=$NOW + fi + + # Fail loud, never fabricate: a broken READ must not gate a buy or write a reason. + if [ -z "$PRICE" ] || [ -z "$SMA" ]; then + kraken session note --kind alert --symbol BTC/USD \ + --reason "tick skipped: READ failed (price='$PRICE' sma='$SMA')" -o json 2>/dev/null + continue + fi + VS_SMA=$(echo "scale=6; (($PRICE - $SMA) / $SMA) * 100" | bc -l) + + # THINK: dip tripped AND rate limit satisfied? + if (( $(echo "$VS_SMA <= $DIP_THRESHOLD_PCT" | bc -l) )) && [ $((NOW - LAST_BUY)) -ge $MIN_SPACING_S ]; then + VOL=$(echo "scale=8; $DOLLARS_PER_BUY / $PRICE" | bc -l) + kraken order buy BTC/USD "$VOL" --type market \ + --reason "dip-triggered buy: BTC/USD at $PRICE is ${VS_SMA}% below 1h SMA $SMA (threshold ${DIP_THRESHOLD_PCT}%); deploying \$$DOLLARS_PER_BUY" \ + -o json 2>/dev/null + LAST_BUY=$NOW + BUYS=$((BUYS + 1)) + [ $BUYS -ge $MAX_BUYS ] && break + fi +done + +kraken session stop -o json 2>/dev/null +``` + +Notes on the decision: +- `min_spacing_s` is a real rate limit on the trigger, not the sampling bookkeeping the time-gated recipe removed. It stops a sustained dip from firing on every tick. +- Refresh the average on `sma_refresh_s`. Recomputing it per tick burns REST calls for a number that barely moves. +- Log skips sparingly. A stream produces many non-qualifying ticks; noting each one floods the decision log. Note only meaningful events (a dip that was rate-limited, a wide spread), never every tick. + +## Stop and Review + +```bash +kraken session show -o json 2>/dev/null | jq '.summary' + +kraken session decisions --session s -o json 2>/dev/null \ + | jq -c '.decisions[] | {kind, symbol, reason}' +``` + +Report: +- buys placed vs `max_buys`, and time between them +- average fill cost vs window mean +- how many dips were caught vs rate-limited +- P&L, and whether the dip threshold and spacing fit the tape + +## Hard Rules + +- This recipe records a session. It never places a live order. +- The stream is the loop. Do not also wrap it in `/loop`; that is two clocks on one decision. +- Treat stream output as NDJSON, one object per line. Never parse it as a single document (see `kraken-ws-streaming`). +- The loop is blind during reconnect gaps. Keep the session short and accept that dips inside a backoff window are missed. +- Rate-limit buys with `min_spacing_s` so a sustained dip does not fire on every tick. +- Keep every gating number in `--strategy-params` and the arithmetic in `jq`/`bc`. +- The session directory (`decisions.jsonl` and the DuckDB/JSONL sinks) is owned by the CLI recorder. The agent only **reads** it, and only for the stop-time summary. Never write, edit, `mkdir`, or append inside it — every buy goes through `kraken paper buy`, so the recorder stays the single writer. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-playground-dca/SKILL.md b/skills/recipe-playground-dca/SKILL.md new file mode 100644 index 0000000..574b5f2 --- /dev/null +++ b/skills/recipe-playground-dca/SKILL.md @@ -0,0 +1,186 @@ +--- +name: recipe-playground-dca +version: 1.0.0 +description: "Example driver: drive a time-based dollar-cost-averaging hypothesis into a recorded session." +metadata: + openclaw: + category: "recipe" + domain: "sessions" + requires: + bins: ["kraken"] + skills: ["kraken-playground", "kraken-dca-strategy"] +--- + +# Playground: DCA (Time-Gated) + +> **PREREQUISITE:** Load `kraken-playground` and `kraken-dca-strategy` to run this recipe. +> **This recipe is an example, not a boundary.** Adapt the steps to your hypothesis, or write your own driver (see `kraken-playground` → Driving Your Own Hypothesis). + +Test a dollar-cost-averaging hypothesis on live prices with no real money. Buy on a fixed cadence, optionally only when price dips below a short-term average, and record every buy, skip, and average cost into one session for later replay and P&L. + +Use this skill for: +- running a time-based DCA hypothesis over a window +- gating each buy on a dip below a short-term average +- recording every decision with the numbers behind it + +## Important + +This recipe records a session inside a paper workspace. It never places a live order. Paper results may overstate live performance: fees and slippage are simulated and there are no partial fills (see `kraken-paper-strategy`). + +Pacing is external. `/loop` owns the schedule and fires one round per interval (see `kraken-playground` → Pacing). One decision per interval, paced from outside, so the session is interventional over the window (see `kraken-playground` → Running over a Window). + +## Params + +Every number that gates a buy or skip goes in `--strategy-params`, not only in prose reasons. That is what makes two sessions comparable knob-for-knob: + +- `dollars_per_buy`: quote currency deployed per qualifying round +- `rounds`: session length in decision rounds +- `interval_s`: spacing between rounds; set the `/loop` interval to this value +- `dip_threshold_pct` (dip-gated variant): only buy when price is at least this far below the short-term average; omit for unconditional time-based DCA + +## Quick Start + +Natural language: + +``` +Dollar-cost-average into Bitcoin over 10 hours. Buy $100 every hour if the +price dips more than 0.10% below the 1-hour SMA. Record all buys, skips, and +average cost. At the end, show P&L. +``` + +## Start the Session + +Work inside a paper workspace (create one once: `kraken workspace create dca --capital 10000 --mode paper`), then: + +```bash +export KRAKEN_WORKSPACE=dca + +kraken session start \ + --symbols BTC/USD --channels ticker,trade --to duckdb,jsonl \ + --label dca-btc-$(date +%Y%m%d-%H%M%S) \ + --strategy recipe-playground-dca \ + --strategy-params '{"dollars_per_buy":100,"rounds":10,"interval_s":3600,"dip_threshold_pct":-0.10}' \ + -o json 2>/dev/null & + +# The session_started stdout line carries the id: {"type":"session_started","session":"s",...} +``` + +Print the session id to the user right after starting, and again in the final report — it is the handle for resuming the `/loop`, checking `kraken session show`, and locating the artifacts. + +## Schedule the Rounds + +`/loop` owns the pacing. Set its interval to `interval_s` and it fires each round on cadence (late under jitter, never early). Parse the interval from the user's request: "every hour" to `1h`, "every 30 minutes" to `30m`, "twice a day" to `12h`. + +**The scheduler floor is 60 seconds** (`/loop`, cron, and `ScheduleWakeup` all clamp to a one-minute minimum). `interval_s` must be `>= 60`; a sub-minute cadence is impossible — clamp to `60` and tell the user if they ask for less. + +``` +/loop 1h "Run one DCA round for session s in workspace dca per recipe-playground-dca" +``` + +Do not re-add an elapsed-time gate inside the round. `/loop` already enforces the spacing. + +## Each Round + +On each firing: stop if the session is complete, otherwise READ, THINK, ACT. + +Check the last completed round against `rounds`. The round cursor lives in the session's typed state cell (`kraken session state get/set`) — not in the decision log, whose reasons stay free narrative (keep writing "round N" in them for the post-mortem story, but nothing parses it). Reading the cursor is O(1) however long the session runs, and a typo'd field refuses at `set` instead of silently steering the loop: + +```bash +ROUNDS=10 # the `rounds` you set in --strategy-params +SESSION_ID=s1 # from the session_started line + +# The typed cursor is the loop's memory: unset reads as round 0. +LAST_ROUND=$(kraken session state get --session "$SESSION_ID" -o json 2>/dev/null \ + | jq '.cursor.round // 0') +if [ "${LAST_ROUND:-0}" -ge "$ROUNDS" ]; then + kraken session stop -o json 2>/dev/null + exit 0 +fi +ROUND=$((LAST_ROUND + 1)) +# CLAIM the round BEFORE acting: a crash after the claim skips one buy +# (harmless); the reverse order would redo the round and buy twice. `set` +# replaces the whole cursor — carry every field your strategy tracks. +kraken session state set --round "$ROUND" -o json 2>/dev/null +``` + +READ: price and the short-term average. Mind the response shapes: the ticker +is keyed by Kraken's INTERNAL pair name (`XXBTZUSD`, not `BTC/USD`), so read +the price through the value (`.[].last_price`). The ohlc rows live under +`.candles`; read that array directly — the sibling `last` cursor would poison +naive iteration of the object. + +```bash +PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price') +# True 1h SMA: the last twelve 5-minute closes (NOT --interval 60, whose +# full-window mean is a multi-day average). +SMA=$(kraken ohlc BTCUSD --interval 5 -o json 2>/dev/null | jq '[.candles[-12:][].close] | add / length') +``` + +**Fail loud, never fabricate:** if either value comes back empty, the round +must not guess — note an alert and end the firing, or the decision log fills +with plausible-looking false reasons: + +```bash +if [ -z "$PRICE" ] || [ -z "$SMA" ]; then + kraken session note --kind alert --symbol BTC/USD \ + --reason "round $ROUND aborted: READ failed (price='$PRICE' sma='$SMA')" -o json 2>/dev/null + exit 0 +fi +``` + +Compute in `jq`/`bc`, do not eyeball it. + +THINK: +- `vs_sma = (price - sma) / sma * 100` +- If the ticker spread is wider than 1%, skip and note it. A wide spread distorts the paper fill. +- If `vs_sma <= dip_threshold_pct`, buy `dollars_per_buy / price` units. Otherwise skip. + +ACT (buy): + +```bash +kraken order buy BTC/USD --type market \ + --reason "DCA round N: BTC/USD at is % below 1h SMA (threshold %); deploying \$" \ + -o json 2>/dev/null +``` + +Inside the paper workspace the order fills on the paper account, and the reason lands in the active session's decision log. + +ACT (skip): + +```bash +kraken session note --kind skip --symbol BTC/USD \ + --reason "skip round N: BTC/USD at is % from 1h SMA (threshold %)" \ + -o json 2>/dev/null +``` + +The round is already claimed, so nothing to write after acting: buy or skip, +log the reason, and exit the firing. + +Always pass the numeric reason so the decision log carries the "why" behind every round. + +## Stop and Review + +```bash +kraken session stop -o json 2>/dev/null +kraken session show -o json 2>/dev/null | jq '.summary' +kraken explain pnl --session latest -o json 2>/dev/null | jq '{anchor, waterfall: [.components[] | {kind, amount}]}' + +kraken session decisions --session "$SESSION_ID" -o json 2>/dev/null \ + | jq -c '.decisions[] | {kind, symbol, reason, timestamp}' +``` + +Report: +- total deployed vs expected (`dollars_per_buy × rounds`) +- buys vs skips, with dip measurements +- average fill cost vs window mean +- P&L, and whether the dip threshold fit the tape + +## Hard Rules + +- This recipe records a session inside a paper workspace. It never places a live order. +- `/loop` drives the pace and the agent runs each round. The CLI has no scheduler and runs no strategy. +- Do not add a pacing gate inside the round; `/loop` enforces spacing. +- A scheduled `/loop` fire only runs what the agent may invoke unprompted. Keep `kraken order buy`, `kraken session note`, and `kraken session show` permitted non-interactively, or a fire stalls waiting on an approval you never see. +- Keep every gating number in `--strategy-params` and the arithmetic in `jq`/`bc`, so a rerun on the same tape reproduces the decisions. +- The session directory (`session.json`, `decisions.jsonl`, and the tape sinks) is owned by the CLI recorder. The agent only **reads** it, and only for the stop-time summary. Never write, edit, `mkdir`, or append inside it — every buy and skip goes through `kraken order buy` / `kraken session note`, so the recorder stays the single writer. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-playground-price-alert/SKILL.md b/skills/recipe-playground-price-alert/SKILL.md new file mode 100644 index 0000000..c58ea31 --- /dev/null +++ b/skills/recipe-playground-price-alert/SKILL.md @@ -0,0 +1,179 @@ +--- +name: recipe-playground-price-alert +version: 1.0.0 +description: "Example driver: drive a price-level-alert hypothesis into a recorded session." +metadata: + openclaw: + category: "recipe" + domain: "playground" + requires: + bins: ["kraken"] + skills: ["kraken-playground", "kraken-alert-patterns"] +--- + +# Playground: Price Alert + +> **PREREQUISITE:** Load `kraken-playground` and `kraken-alert-patterns` to run this recipe. +> **This recipe is an example, not a boundary.** Adapt the steps to your hypothesis, or write your own driver (see `kraken-playground` → Driving Your Own Hypothesis). + +Watch key price levels on live prices and record each crossing as an alert decision, capturing the market data and the "why" into one session for later replay and analysis. Alert-only: it records crossings, it never places a trade. + +Use this skill for: +- watching one or more price levels over a window +- recording each level break as an alert with the numbers behind it +- grading afterward which levels were hit, when, and which were never touched + +## Important + +Alerts are informational and never execute a trade (see `kraken-alert-patterns`). This recipe records a session (market data + alert decisions); it never places a live order. + +Pacing is external. `/loop` owns the schedule and fires one round per interval (see `kraken-playground` → Pacing). A cadence the operator names is a commitment: record it as `interval_s` and honor it — polls may run late, never early. A session whose params omit `interval_s` may not claim or imply any cadence in its report. + +Over a long window this recipe is **observational** — a missed poll is not a missed crossing. The recorder holds every level break, so late visits (or none) still grade the hypothesis at stop time (see `kraken-playground` → Running over a Window). + +## Params + +Every level that gates an alert goes in `--strategy-params`, not only in prose reasons. That is what makes two sessions comparable knob-for-knob: + +- `upper`: alert when price breaks above this level +- `lower`: alert when price breaks below this level +- `interval_s`: spacing between rounds; set the `/loop` interval to this value + +The values in the example are placeholders. A level the operator names replaces the example verbatim — params that don't match the request record a hypothesis nobody asked to test. + +## Quick Start + +Natural language: + +``` +Watch BTC for 30 minutes. Alert me if price breaks above $70,000 or below +$60,000. Check every 5 minutes. At the end, show which levels were hit and when. +``` + +## Start the Session + +Work inside a paper workspace (create one once: `kraken workspace create alerts --capital 10000 --mode paper`), then: + +```bash +export KRAKEN_WORKSPACE=alerts + +kraken session start \ + --symbols BTC/USD --channels ticker,trade --to duckdb,jsonl \ + --label alert-btc-$(date +%Y%m%d-%H%M%S) \ + --strategy recipe-playground-price-alert \ + --strategy-params '{"upper":70000,"lower":60000,"interval_s":300}' \ + -o json 2>/dev/null & + +# The session_started stdout line carries the id: {"type":"session_started","session":"s",...} +``` + +Print the session id to the user right after starting, and again in the final report — it is the handle for resuming the `/loop`, checking `kraken session show`, and locating the artifacts. + +## Schedule the Rounds + +`/loop` owns the pacing. Set its interval to `interval_s` and it fires each round on cadence (late under jitter, never early). Parse the interval from the user's request: "every 5 minutes" to `5m`, "every 2 minutes" to `2m`, "every hour" to `1h`. + +**The scheduler floor is 60 seconds** (`/loop`, cron, and `ScheduleWakeup` all clamp to a one-minute minimum). `interval_s` must be `>= 60`. If the user asks for a sub-minute cadence ("every 30 seconds"), clamp to `60` and tell them — never set a sub-minute `interval_s` and never let the report claim a cadence the harness cannot run. + +``` +/loop 5m "Run one price-alert round for session s in workspace alerts per recipe-playground-price-alert" +``` + +Do not re-add an elapsed-time gate inside the round. `/loop` already enforces the spacing. + +## Each Round + +On each firing: READ, THINK, ACT. + +READ: current price. + +```bash +kraken ticker BTCUSD -o json 2>/dev/null +``` + +Price is the ticker's last-trade close. Extract it with `jq`; do not eyeball it. Bind the band from the same `--strategy-params` you recorded — never hardcode the levels into the arithmetic: + +```bash +UPPER=70000 +LOWER=60000 +PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[] | .last_price') +``` + +THINK — alert on the *crossing*, not on the *level*. A sustained excursion is one event, not one per round. Classify the price into a **zone** — `above` / `within` / `below` — and alert only when the zone *changes* into a breakout. `kind` alone can't carry this (an `alert` doesn't say which side, and the per-round skip note would erase the last alert), so the zone lives in the session's typed state cell; reasons stay narrative: + +```bash +PREV_ZONE=$(kraken session state get --session s -o json 2>/dev/null \ + | jq -r '.cursor.zone // "within"') + +# Fail loud: an empty price must not classify a zone (a fabricated "within" +# would silently swallow a real breakout). +if [ -z "$PRICE" ]; then + kraken session note --kind alert --symbol BTC/USD \ + --reason "round skipped: READ failed (price empty)" -o json 2>/dev/null + exit 0 +fi +if (( $(echo "$PRICE > $UPPER" | bc -l) )); then ZONE=above +elif (( $(echo "$PRICE < $LOWER" | bc -l) )); then ZONE=below +else ZONE=within; fi + +# Persist the zone for the next firing (typed: a typo refuses instead of +# silently resetting the crossing detector). +kraken session state set --zone "$ZONE" -o json 2>/dev/null +``` + +- Zone entered `above` from a different zone → the upper level was just crossed — **alert**. +- Zone entered `below` from a different zone → the lower level was just crossed — **alert**. +- Zone unchanged (still `above`, still `below`, or still `within`) → same state, no new crossing — **skip**. +- `within` re-arms both boundaries: the next move to `above` or `below` alerts again. + +Because the marker is on every line, `above → below` reads as a genuine change and alerts, and a sustained `above → above` reads as unchanged and does not. + +ACT — alert on a fresh breakout, always ending the reason with the new `zone=` marker: + +```bash +# upper break (ZONE=above, PREV_ZONE != above) +kraken session note --kind alert --symbol BTC/USD \ + --reason "alert round N: BTC/USD broke above at ; zone=above" \ + -o json 2>/dev/null + +# lower break (ZONE=below, PREV_ZONE != below) +kraken session note --kind alert --symbol BTC/USD \ + --reason "alert round N: BTC/USD broke below at ; zone=below" \ + -o json 2>/dev/null +``` + +ACT (skip) — no zone change; record the round and carry the current zone forward so the next round sees it: + +```bash +kraken session note --kind skip --symbol BTC/USD \ + --reason "skip round N: BTC/USD at , no new crossing (/); zone=$ZONE" \ + -o json 2>/dev/null +``` + +Always pass the numeric reason, and always end it with the `zone=` marker — it is the state the next round reads to tell a fresh crossing from a sustained move. + +## Stop and Review + +```bash +kraken session stop -o json 2>/dev/null +kraken session show -o json 2>/dev/null | jq '.summary' + +kraken session decisions --session s -o json 2>/dev/null \ + | jq -c '.decisions[] | select(.kind=="alert") | {timestamp, reason}' +``` + +Report: +- which levels were crossed (upper and/or lower) +- when each crossing occurred +- any level never touched (INCONCLUSIVE for that level) + +## Hard Rules + +- This recipe records a session. It never places a live order — alerts are informational only. +- `/loop` drives the pace and the agent runs each round. The CLI has no scheduler and runs no strategy. +- Do not add a pacing gate inside the round; `/loop` enforces spacing. +- A scheduled `/loop` fire only runs what the agent may invoke unprompted. Keep `kraken ticker`, `kraken session note`, and `kraken session show` permitted non-interactively, or a fire stalls waiting on an approval you never see. +- Keep every level in `--strategy-params` and the comparison in `jq`/`bc`, so a rerun on the same tape reproduces the decisions. +- A session whose params omit `interval_s` may not claim any cadence in its report. +- The session directory (`session.json`, `decisions.jsonl`, and the tape sinks) is owned by the CLI recorder. The agent only **reads** it, and only for the stop-time summary. Never write, edit, `mkdir`, or append inside it — every decision goes through `kraken session note`, so the recorder stays the single writer. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-playground-rebalance/SKILL.md b/skills/recipe-playground-rebalance/SKILL.md new file mode 100644 index 0000000..1003d79 --- /dev/null +++ b/skills/recipe-playground-rebalance/SKILL.md @@ -0,0 +1,177 @@ +--- +name: recipe-playground-rebalance +version: 1.1.0 +description: "Example driver: drive a portfolio-rebalance hypothesis into a recorded session." +metadata: + openclaw: + category: "recipe" + domain: "sessions" + requires: + bins: ["kraken"] + skills: ["kraken-playground", "kraken-rebalancing"] +--- + +# Playground: Rebalance + +> **PREREQUISITE:** Load the following skills to execute this recipe: `kraken-playground`, `kraken-rebalancing` + +> **This recipe is an example, not a boundary.** Adapt its steps to your hypothesis, or write your own driver — see `kraken-playground` → Driving Your Own Hypothesis. + +Test a target-allocation rebalancing hypothesis on live prices with no real money, recording the market data, paper trades, and the "why" behind each adjustment into one session for later replay and P&L analysis. + +## Important + +Paper results may overstate live performance: fees and slippage are simulated and there are no partial fills (see `kraken-paper-strategy`). This recipe records a session inside a paper workspace; it never places a live order. + +Every number that gates a trade or a skip (`targets`, `threshold_usd`, `drift_pct`) belongs in `--strategy-params` — it's what makes two sessions comparable knob-for-knob. A recurring run adds `rounds` and `interval_s` (see step 8). The values in the step-2 example are placeholders: a number the operator names (band, threshold, cadence) replaces the example verbatim — params that don't match the mandate record a hypothesis nobody asked to test. Over a long window this recipe is **interventional** — one visit per `interval_s`, paced by the harness (see `kraken-playground` → Running over a Window). + +## Quick Start + +Rebalance a portfolio toward target allocations: + +``` +Hold a portfolio 50% BTC, 35% ETH, 15% SOL over 24 hours. +Rebalance when any asset drifts more than 5% from target AND the delta exceeds $50. +Check every 2 hours. Report final weights and P&L. +``` + +## Start the Session + +Work inside a paper workspace (create one once: `kraken workspace create rebalance --capital 10000 --mode paper`), then: + +```bash +export KRAKEN_WORKSPACE=rebalance + +kraken session start \ + --symbols BTC/USD,ETH/USD,SOL/USD --channels ticker --to duckdb,jsonl \ + --label rebalance-$(date +%Y%m%d-%H%M%S) \ + --strategy recipe-playground-rebalance \ + --strategy-params '{"targets":{"BTCUSD":0.50,"ETHUSD":0.35,"SOLUSD":0.15},"interval_s":7200,"drift_pct":5,"threshold_usd":50}' \ + -o json 2>/dev/null & + +# The session_started stdout line carries the id: {"type":"session_started","session":"s",...} +``` + +Print the session id to the user right after starting, and again in the final report — it is the handle for resuming the `/loop`, checking `kraken session show`, and locating the artifacts. + +## Schedule the Rounds + +`/loop` owns the pacing. Set its interval to `interval_s` and it fires each round on cadence (late under jitter, never early). Parse the interval from the user's request: "every 2 hours" to `2h`, "every 4 hours" to `4h`, "every day" to `24h`. + +**The scheduler floor is 60 seconds** (`/loop`, cron, and `ScheduleWakeup` all clamp to a one-minute minimum). `interval_s` must be `>= 60`. If the user asks for a sub-minute cadence, clamp to `60` and tell them — never set a sub-minute `interval_s` and never let the report claim a cadence the harness cannot run. + +``` +/loop 2h "Run one rebalance round for session s in workspace rebalance per recipe-playground-rebalance" +``` + +Do not re-add an elapsed-time gate inside the round. `/loop` already enforces the spacing. The round cursor lives in the session's typed state cell — a rebalance round writes one decision **per leg**, so the decision log cannot count rounds; the cursor can. Reasons stay narrative (keep "round N" in them for the story; nothing parses it). Stop once the cursor reaches `rounds`: + +```bash +LAST_ROUND=$(kraken session state get --session s -o json 2>/dev/null \ + | jq '.cursor.round // 0') +if [ "${LAST_ROUND:-0}" -ge "$ROUNDS" ]; then + kraken session stop -o json 2>/dev/null + exit 0 +fi +ROUND=$((LAST_ROUND + 1)) +``` + +Between legs, record progress so a crashed firing resumes at the first +missing leg instead of re-trading executed ones; completing the round writes +only the round (the replace drops `legs_done` automatically): + +```bash +kraken session state set --round "$LAST_ROUND" --legs-done "BTC/USD" \ + --note "round $ROUND: leg 1 of 3 done" -o json 2>/dev/null # after each leg +kraken session state set --round "$ROUND" -o json 2>/dev/null # round complete +``` + +On a fresh firing, `state get`'s `legs_done` names any half-done round — but +a crash can land between a leg executing and its `legs_done` write, so never +trust the cursor alone before re-trading: cross-check the evidence +(`kraken session decisions`, reasons mentioning the in-flight round) and skip +any leg that already filled. The cursor is the fast path; the decision log is +the truth. + The first firing is the seed round: every asset is underweight, so it allocates the whole portfolio toward the targets. + +## Each Round + +On each firing: READ, THINK, ACT. + +READ: a fresh balance and each symbol's price. Read the balance **every round** — never carry a snapshot across rounds or across trades within a round; each fill mutates cash and holdings. + +```bash +kraken paper balance -o json 2>/dev/null +kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[] | .last_price' # repeat per symbol +``` + +`kraken paper balance` returns `{"balances":{"USD":{"total","reserved","available"},...}}`. Cash is `.balances.USD.available`; a holding is `.balances..total` (0 if the key is absent). Price is the ticker's last-trade close (`.[] | .last_price`). + +**Fail loud before any leg trades:** validate the cash figure and EVERY price +before computing deltas — a rebalance acts on several legs, so one silently +failed read sizes every order from garbage. On an empty read, note an alert +and end the firing (the claimed round stays claimed; the next firing retries): + +```bash +if [ -z "$CASH" ] || [ -z "$PRICE_BTC" ]; then + kraken session note --kind alert \ + --reason "round $ROUND aborted: READ failed (cash='$CASH' btc='$PRICE_BTC')" -o json 2>/dev/null + exit 0 +fi +``` + +THINK: value the portfolio, then decide each leg. +- `total_value = cash + Σ(holdings × price)` +- Per symbol: `value = holdings × price`, `current_pct = value / total_value × 100`, `target_pct = target × 100`, `drift = current_pct − target_pct`, `delta_usd = target_value − value` where `target_value = total_value × target`. +- A leg needs action only when `|drift| > drift_pct` **AND** `|delta_usd| > threshold_usd`. Otherwise leave it. + +ACT: **sells before buys.** Execute every overweight leg (`delta_usd < 0`) first so its proceeds land back in cash, then execute the underweight legs. Size each buy against a fee/slippage reserve so the last leg never rejects: keep the buy cost at or below `available_cash / (1 + fee_rate + slippage_rate)` (defaults 0.0026 fee, 0.0 slippage — read them from `paper status` if unsure). Between legs, re-read the balance rather than assuming the pre-trade cash, and skip or shrink a buy the remaining cash can no longer fund. + +```bash +# Overweight — sell the excess first +kraken order sell BTC/USD --type market \ + --reason "rebalance round N: BTC/USD weight % above target %; selling " \ + -o json 2>/dev/null + +# Underweight — buy toward target, capped by fee-reserved cash +kraken order buy SOL/USD --type market \ + --reason "rebalance round N: SOL/USD weight % below target %; buying " \ + -o json 2>/dev/null +``` + +If no leg breaches both thresholds, note a single skip: + +```bash +kraken session note --kind skip --symbol BTC/USD \ + --reason "skip round N: all weights within %/$ band" \ + -o json 2>/dev/null +``` + +Always pass the numeric reason so the decision log carries the "why" behind every round. + +## Stop and Review + +```bash +kraken session stop -o json 2>/dev/null +kraken session show -o json 2>/dev/null | jq '.summary' + +kraken session decisions --session s -o json 2>/dev/null \ + | jq -c '.decisions[] | {kind, symbol, reason, timestamp}' +``` + +Report: +- Final weights vs targets (50/35/15) +- Number of rebalance trades vs skips +- Cumulative turnover (fees paid) +- P&L vs a buy-and-hold baseline +- Did the `drift_pct`/`threshold_usd` band hold the portfolio? + +## Hard Rules + +- This recipe records a session inside a paper workspace. It never places a live order. +- `/loop` drives the pace and the agent runs each round; the CLI has no scheduler and runs no strategy. The scheduler floor is 60s — no sub-minute cadence. +- Read a fresh balance every round and after each fill. Never size a trade off a stale snapshot — the seed round alone issues three buys, and a fixed pre-trade cash figure overspends once fees are charged. +- Sells before buys, and reserve fees on every buy, so a multi-leg round cannot reject its last order after earlier fills were already recorded. +- Keep every gating number (`targets`, `drift_pct`, `threshold_usd`, `interval_s`) in `--strategy-params` and the arithmetic in `jq`/`bc`, so a rerun on the same tape reproduces the decisions. +- The session directory (`session.json`, `decisions.jsonl`, and the tape sinks) is owned by the CLI recorder. The agent only **reads** it, and only for the stop-time summary. Never write, edit, or append inside it — every trade and skip goes through `kraken order buy` / `kraken session note`, so the recorder stays the single writer. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-playground-webhook/SKILL.md b/skills/recipe-playground-webhook/SKILL.md new file mode 100644 index 0000000..ea252cf --- /dev/null +++ b/skills/recipe-playground-webhook/SKILL.md @@ -0,0 +1,174 @@ +--- +name: recipe-playground-webhook +version: 1.0.0 +description: "Example driver: turn incoming webhook signals into validated paper trades inside a recorded session." +metadata: + openclaw: + category: "recipe" + domain: "sessions" + requires: + bins: ["kraken", "python3"] + skills: ["kraken-playground"] +--- + +# Playground: Webhook → Paper Trade + +> **PREREQUISITE:** Load `kraken-playground` to run this recipe. +> **This recipe is an example, not a boundary.** Adapt the steps to your signal source, or write your own driver (see `kraken-playground` → Driving Your Own Hypothesis). + +Bridge an external system into the sandbox: a TradingView alert, a CI job, or a plain `curl` POSTs a signal to a local listener, and each valid signal becomes a paper order inside a recorded session. The market tape, the fills, and the signal-to-order decisions all land in one window for replay and P&L. + +Use this skill for: +- wiring an existing alerting system to paper execution without touching its config +- testing a signal feed's quality (how its entries score) before any real money +- recording an event-driven loop whose events come from outside the market + +## Important + +This recipe records a session. It never places a live order. + +The webhook payload is **untrusted input**. Every field is validated against an allowlist or a numeric bound before it reaches an order — a signal that fails any check is logged as a skip, never executed, and never interpolated into a command. + +The listener binds `127.0.0.1` only. Do not bind other interfaces and do not port-forward it; anyone who can reach the port can trade the paper account. If the signal source is remote, tunnel (e.g. `ssh -R`) rather than expose. + +The signal stream is the loop, not `/loop` — the same long-lived-process exception as the dip-triggered recipe (see `kraken-playground` → Pacing). + +## Signal Contract + +One JSON object per POST body: + +```json +{"symbol": "BTC/USD", "side": "buy", "qty": "0.001", "token": "", "note": "free text"} +``` + +`symbol`, `side`, `qty` are required; `token` is required when the `token` param is set; `note` is echoed into the decision reason. + +## Params + +Every bound that gates execution goes in `--strategy-params`: + +- `port`: listener port on 127.0.0.1 +- `allowed_symbols`: the only pairs a signal may trade — must be a subset of the session's recorded `--symbols`, or fills have no marks to score by +- `max_notional_per_signal`: quote-currency cap per order; a signal above it is skipped, not clamped +- `max_signals`: stop after this many executed signals +- `token`: optional shared secret; when set, signals without a matching `token` field are dropped + +## Quick Start + +Natural language: + +``` +Listen on localhost:8787 for webhook signals. Trade only BTC/USD, at most +$500 per signal, at most 10 signals, then stop. Record every accepted and +rejected signal with its reason. At the end, show P&L. +``` + +## Start the Session + +Work inside a paper workspace (create one once: `kraken workspace create hook --capital 10000 --mode paper`), then: + +```bash +export KRAKEN_WORKSPACE=hook + +kraken session start \ + --symbols BTC/USD --channels ticker,trade --to duckdb,jsonl \ + --label hook-btc-$(date +%Y%m%d-%H%M%S) \ + --strategy recipe-playground-webhook \ + --strategy-params '{"port":8787,"allowed_symbols":["BTC/USD"],"max_notional_per_signal":500,"max_signals":10}' \ + -o json 2>/dev/null & + +# The session_started stdout line carries the id: {"type":"session_started","session":"s",...} +``` + +Print the session id to the user right after starting, and again in the final report. + +## Listen and Decide + +The listener appends each POST body to a signals file **outside the session directory** (the recorder owns that tree); the decision loop tails it. Both die with the session. + +```bash +PORT=8787 +ALLOWED_SYMBOLS="BTC/USD" # space-separated allowlist +MAX_NOTIONAL=500 +MAX_SIGNALS=10 +SIGNALS=$(mktemp -t hook-signals) + +python3 - "$PORT" "$SIGNALS" <<'PY' & +import http.server, sys +port, path = int(sys.argv[1]), sys.argv[2] +class H(http.server.BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + with open(path, "ab") as f: + f.write(body.strip() + b"\n") + self.send_response(204); self.end_headers() + def log_message(self, *args): pass +http.server.HTTPServer(("127.0.0.1", port), H).serve_forever() +PY +LISTENER=$! + +DONE=0 +tail -n +1 -f "$SIGNALS" | while read -r sig; do + SYMBOL=$(echo "$sig" | jq -r '.symbol // empty') + SIDE=$(echo "$sig" | jq -r '.side // empty') + QTY=$(echo "$sig" | jq -r '.qty // empty') + NOTE=$(echo "$sig" | jq -r '.note // ""') + + # Untrusted input: allowlist the enum and the pair, bound the number. + # A failed check is a recorded skip — never a clamped or guessed order. + case "$SIDE" in buy|sell) ;; *) + kraken session note --kind skip --reason "webhook rejected: bad side '$SIDE'" -o json 2>/dev/null; continue;; + esac + case " $ALLOWED_SYMBOLS " in *" $SYMBOL "*) ;; *) + kraken session note --kind skip --reason "webhook rejected: symbol '$SYMBOL' not in allowlist" -o json 2>/dev/null; continue;; + esac + if ! echo "$QTY" | grep -qE '^[0-9]*\.?[0-9]+$'; then + kraken session note --kind skip --reason "webhook rejected: non-numeric qty '$QTY'" -o json 2>/dev/null; continue + fi + PRICE=$(kraken ticker "$SYMBOL" -o json 2>/dev/null | jq -r '.. | .last? // empty' | head -1) + NOTIONAL=$(echo "scale=2; $QTY * $PRICE" | bc -l) + if (( $(echo "$NOTIONAL > $MAX_NOTIONAL" | bc -l) )); then + kraken session note --kind skip --symbol "$SYMBOL" \ + --reason "webhook rejected: notional \$$NOTIONAL exceeds cap \$$MAX_NOTIONAL" -o json 2>/dev/null; continue + fi + + kraken order "$SIDE" "$SYMBOL" "$QTY" --type market \ + --reason "webhook signal: $SIDE $QTY $SYMBOL (~\$$NOTIONAL) — $NOTE" -o json 2>/dev/null + DONE=$((DONE + 1)) + [ "$DONE" -ge "$MAX_SIGNALS" ] && break +done + +kill "$LISTENER" 2>/dev/null +kraken session stop -o json 2>/dev/null +``` + +Send a test signal: + +```bash +curl -s -X POST "127.0.0.1:8787" -d '{"symbol":"BTC/USD","side":"buy","qty":"0.001","note":"smoke test"}' +``` + +## Stop and Review + +```bash +kraken session show -o json 2>/dev/null | jq '.summary' + +kraken session decisions --session s -o json 2>/dev/null \ + | jq -c '.decisions[] | {kind, symbol, reason}' +``` + +Report: +- signals received vs executed vs rejected, with the rejection reasons +- fill quality: reason-joined entries from `kraken explain pnl` +- P&L, and whether the signal source earns a bigger cap + +## Hard Rules + +- This recipe records a session. It never places a live order. +- The listener binds `127.0.0.1` only — never another interface, never port-forwarded. +- Webhook payloads are untrusted: enum fields pass an allowlist, numbers pass a bound, and nothing from the payload is ever executed or eval'd — rejected signals become `skip` notes, not orders. +- `allowed_symbols` stays a subset of the session's recorded `--symbols`. +- The signals file lives outside the session directory; the CLI recorder stays the single writer of the session's artifacts. +- Cap executed signals with `max_signals`; a runaway signal source must exhaust the cap, not the account. +- Keep every gating number in `--strategy-params` and the arithmetic in `jq`/`bc`. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-portfolio-snapshot-csv/SKILL.md b/skills/recipe-portfolio-snapshot-csv/SKILL.md index 1cd0790..b60f201 100644 --- a/skills/recipe-portfolio-snapshot-csv/SKILL.md +++ b/skills/recipe-portfolio-snapshot-csv/SKILL.md @@ -28,3 +28,5 @@ Capture a point-in-time portfolio snapshot combining balances, prices, and earn 7. Format as CSV: `asset, balance, price_usd, value_usd, earn_allocated, total_exposure` 8. Output to stdout or write to file 9. Include timestamp and total portfolio value as header row + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-price-level-alerts/SKILL.md b/skills/recipe-price-level-alerts/SKILL.md index 462a0e5..d3b41d9 100644 --- a/skills/recipe-price-level-alerts/SKILL.md +++ b/skills/recipe-price-level-alerts/SKILL.md @@ -29,3 +29,5 @@ Define upper and lower price levels for a pair and get notified when they are cr 6. Compare against lower level: if price <= lower, alert "BTCUSD broke below 55000" 7. After alert fires, optionally stop monitoring or set new levels 8. Agent delivers alert through its notification channel (chat, Slack, etc.) + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-session-post-mortem/SKILL.md b/skills/recipe-session-post-mortem/SKILL.md new file mode 100644 index 0000000..8bd66e9 --- /dev/null +++ b/skills/recipe-session-post-mortem/SKILL.md @@ -0,0 +1,125 @@ +--- +name: recipe-session-post-mortem +version: 1.0.0 +description: "Narrate a recorded session's post-mortem: replay the window, explain the P&L decomposition in plain terms, and deliver a presentation-ready summary." +metadata: + openclaw: + category: "recipe" + domain: "sessions" + requires: + bins: ["kraken"] + skills: ["kraken-playground"] +--- + +# Session Post-Mortem + +> **PREREQUISITE:** Load `kraken-playground` to understand what a session is and where it lives. + +Turn a stopped, recorded session into a story a non-trader can follow: what the market did, what the agent did, where every dollar of P&L went, and what to change next session. The CLI supplies the deterministic facts (`kraken explain pnl`, `kraken replay`); this recipe is the interpretive layer on top. The output is good enough to paste into a board deck. + +Use this skill for: +- reviewing a session after it stops +- explaining a P&L number component-by-component in plain language +- producing an executive-ready one-pager from a recorded session + +## Important + +The session must be **stopped** (`kraken session stop`). `explain pnl` decomposes a stopped session's window against its recorded tape; on a recording session, stop it first. + +The CLI stays deterministic; the narrative is yours. Every dollar figure, percentage, and count in the narrative must come from the command output — never estimate, extrapolate, or invent a number the JSON does not contain. + +## Step 1: Pull the P&L Decomposition + +```bash +kraken explain pnl --session "$SESSION_ID" -o json 2>/dev/null # --session defaults to latest +``` + +One JSON object. The parts that drive the narrative: + +- `anchor`: `starting_balance`, `final_value`, `total_pnl`, `currency` — the headline numbers. +- `components[]`: the waterfall. Each entry has `kind`, `amount`, and a ready-made plain-terms `explanation`: + - `price_movement` — position changes valued at mid-market, before any costs + - `fees` — what the fee rate took across all fills + - `spread` — the half-spread paid by market fills (and limit-fill timing) + - `slippage` — simulated slippage on market fills + - `residual` — the reconciliation gap; **components + residual always sum to `total_pnl`** +- `trades[]`: per-fill attribution (`side`, `volume`, `price`, `notional`, `fees`, `spread`, `price_movement`) **plus the recorded `reason`** — the agent's own words for why it traded. +- `window.symbols[]`: what the market did (`first_mid`, `last_mid`, `move_pct`, `avg_spread`, `frames`). +- `caveats[]` and `missed_fills`: honest unknowns. If either is non-empty, the narrative must mention them. + +Useful digests: + +```bash +# Headline + waterfall +kraken explain pnl --session "$SESSION_ID" -o json 2>/dev/null \ + | jq '{anchor, waterfall: [.components[] | {kind, amount}]}' + +# Cost per trade, with the agent's stated reason +kraken explain pnl --session "$SESSION_ID" -o json 2>/dev/null \ + | jq -r '.trades[] | [.side, .volume, .price, .fees, .reason] | @tsv' +``` + +## Step 2: Replay the Timeline + +```bash +kraken replay --session "$SESSION_ID" --speed 1000 -o json 2>/dev/null +``` + +NDJSON, one event per line, in recorded order. `--speed` is a multiplier (0.01–1000, default 1 = real time with recorded gaps reproduced); for analysis always use `--speed 1000` so the tape flushes as fast as pacing allows. Three record families interleave on the same clock: + +- **Market frames** — `{channel, type: "snapshot"|"update", data: [...]}`: the price tape. +- **Account events** — `{event: "initialized"|"order_filled"|"command", ...}`: balances, fills with `reference_quote`, and command outcomes. +- **Decisions** — `{kind, symbol, reason, order_id}`: the why behind each action, including skips. + +Digest it rather than reading every tick: + +```bash +REPLAY=$(kraken replay --session "$SESSION_ID" --speed 1000 -o json 2>/dev/null) + +# The action beats: every fill and every decision, in order +echo "$REPLAY" | jq -c 'select(.event == "order_filled" or .kind != null)' + +# The price path: first, last, low, high of the recorded mids +echo "$REPLAY" | jq -s '[.[] | select(.channel == "ticker") | .data[0].last] | + {first: .[0], last: .[-1], low: min, high: max}' +``` + +## Step 3: Narrate + +Weave both outputs into one story, in this order: + +1. **Headline** — one sentence: outcome and dominant cause, citing `total_pnl` and the largest component. *"The session lost $20.70 on a flat market: $20.18 of it was fees from churning three fills through an 0.02% move."* +2. **What the market did** — from `window.symbols` and the replay price path: direction, size of the move, spread conditions. +3. **What the agent did** — from the replay beats: each decision with its recorded `reason`, and whether the fill helped or hurt (per-trade `price_movement` vs `fees` + `spread`). +4. **Where the money went** — the waterfall, every component, summing to the total. Name the dominant cost in plain terms. +5. **Patterns** — call out what the numbers show: churning a flat market and paying the spread N times, buying strength that faded, skips that saved money, fees exceeding gross edge. +6. **Next steps** — concrete, tied to the evidence: fewer/larger fills to cut the fee bill, limit orders to earn the spread instead of paying it, a wider trigger threshold, a different window. + +Honesty rules: +- Quote figures exactly (round for prose: dollars to cents, percentages to two decimals). +- If `caveats` or `missed_fills.orders` are non-empty, state them plainly — they bound what the decomposition can claim. +- Never attribute intent the decision log doesn't record. The `reason` fields are the only source for "why". + +## Zero-Trade Sessions + +A session with no fills is a valid post-mortem, not an error: `trades` is `[]` and every component is zero. Say so directly — "no trades were placed, so the balance is unchanged" — then narrate what the market did over the window and, if decisions were logged (skips with reasons), whether staying out was the right call given the tape. + +## Presenting to an Executive Audience + +When the post-mortem is for a demo or leadership review, format the same content as a one-pager: + +- Lead with the headline sentence, then the waterfall as a small table: component, signed dollar amount, one plain-terms phrase each (crib from the `explanation` fields). +- Follow with 3–6 timeline beats (time, action, reason, effect) — not the full tape. +- Close with the next-step list. +- No jargon in the top half: "cost of crossing the bid-ask gap" beats "half-spread on taker fills". +- If the environment can render documents or artifacts, a single page with the waterfall as the centerpiece chart lands best; the narrative text stands alone if not. + +The pitch this demonstrates: every automated trading session is fully auditable after the fact — the tape, the decisions, and the P&L reconcile to the cent, and an agent can explain it in plain language on demand. + +## Hard Rules + +- Read-only. This recipe never places orders, never starts or stops sessions (except telling the user to stop a recording one), and never writes into the session directory. +- Every number in the narrative traces to a field in `explain pnl` or `replay` output. +- The waterfall must be presented complete — components plus residual sum to the total; do not drop a component because it is small or unflattering. +- Surface `caveats` and `missed_fills` whenever they are non-empty. A polished story that hides a caveat is wrong, not polished. +- If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-start-dca-bot/SKILL.md b/skills/recipe-start-dca-bot/SKILL.md index c4d88e5..d63cba8 100644 --- a/skills/recipe-start-dca-bot/SKILL.md +++ b/skills/recipe-start-dca-bot/SKILL.md @@ -21,13 +21,16 @@ Set up a dollar cost averaging bot that buys a fixed dollar amount of BTC at reg ## Steps -1. Initialize paper account: `kraken paper init --balance 10000 -o json 2>/dev/null` -2. Calculate volume from dollar amount: `PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].c[0]') && VOLUME=$(echo "scale=8; 100 / $PRICE" | bc)` +1. Initialize paper account: `kraken workspace create sandbox --capital 10000 --mode paper -o json 2>/dev/null +export KRAKEN_WORKSPACE=sandbox` +2. Calculate volume from dollar amount: `PRICE=$(kraken ticker BTCUSD -o json 2>/dev/null | jq -r '.[].last_price') && VOLUME=$(echo "scale=8; 100 / $PRICE" | bc)` 3. Run 5 paper DCA buys to validate the loop: `kraken paper buy BTCUSD $VOLUME -o json 2>/dev/null` -4. Check paper results: `kraken paper status -o json 2>/dev/null` +4. Check paper results: `kraken workspace status -o json 2>/dev/null` 5. Review paper trade history: `kraken paper history -o json 2>/dev/null` 6. Verify live credentials: `kraken auth test -o json 2>/dev/null` 7. Check live balance: `kraken balance -o json 2>/dev/null` 8. Validate the live order: `kraken order buy BTCUSD $VOLUME --type market --validate -o json 2>/dev/null` 9. Execute first live buy (requires human approval): `kraken order buy BTCUSD $VOLUME --type market -o json 2>/dev/null` 10. Verify fill: `kraken trades-history -o json 2>/dev/null` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-subaccount-capital-rotation/SKILL.md b/skills/recipe-subaccount-capital-rotation/SKILL.md index c9580a1..e6403fc 100644 --- a/skills/recipe-subaccount-capital-rotation/SKILL.md +++ b/skills/recipe-subaccount-capital-rotation/SKILL.md @@ -30,3 +30,5 @@ Evaluate strategy performance per subaccount and reallocate capital to winning s 7. Transfer from underperforming subaccount to main (requires human approval): `kraken subaccount transfer USD 2000 --from $SUB_IIBAN --to $MAIN_IIBAN -o json 2>/dev/null` 8. Transfer from main to outperforming subaccount: `kraken subaccount transfer USD 2000 --from $MAIN_IIBAN --to $SUB_IIBAN -o json 2>/dev/null` 9. Verify balances after transfers: `kraken balance -o json 2>/dev/null` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-track-orderbook-depth/SKILL.md b/skills/recipe-track-orderbook-depth/SKILL.md index 0e370c0..27cc4dc 100644 --- a/skills/recipe-track-orderbook-depth/SKILL.md +++ b/skills/recipe-track-orderbook-depth/SKILL.md @@ -28,3 +28,5 @@ Monitor order book depth for a pair and detect bid-ask imbalance as a liquidity 5. Parse each update and recalculate imbalance 6. Alert when imbalance crosses threshold 7. Optionally compare across pairs (one call per pair): `kraken orderbook BTCUSD --count 10 -o json 2>/dev/null` then `kraken orderbook ETHUSD --count 10 -o json 2>/dev/null` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-trailing-stop-runner/SKILL.md b/skills/recipe-trailing-stop-runner/SKILL.md index 84e14eb..cac4ff5 100644 --- a/skills/recipe-trailing-stop-runner/SKILL.md +++ b/skills/recipe-trailing-stop-runner/SKILL.md @@ -27,3 +27,5 @@ Enter a position and attach a trailing stop to capture trend profits while limit 6. Monitor via stream: `kraken ws ticker BTC/USD -o json 2>/dev/null` 7. When the trailing stop triggers, verify the exit fill: `kraken trades-history -o json 2>/dev/null` 8. Report entry price, exit price, and net P&L + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-weekly-rebalance/SKILL.md b/skills/recipe-weekly-rebalance/SKILL.md index 8f4a45c..cf87a73 100644 --- a/skills/recipe-weekly-rebalance/SKILL.md +++ b/skills/recipe-weekly-rebalance/SKILL.md @@ -29,3 +29,5 @@ Check portfolio drift and rebalance to target weights once per week. 8. Execute sells first (requires human approval): `kraken order sell BTCUSD $SELL_VOL --type market -o json 2>/dev/null` 9. Execute buys with freed capital: `kraken order buy SOLUSD $BUY_VOL --type market -o json 2>/dev/null` 10. Verify final allocations: `kraken balance -o json 2>/dev/null` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/skills/recipe-withdrawal-to-cold-storage/SKILL.md b/skills/recipe-withdrawal-to-cold-storage/SKILL.md index e8e9511..a813824 100644 --- a/skills/recipe-withdrawal-to-cold-storage/SKILL.md +++ b/skills/recipe-withdrawal-to-cold-storage/SKILL.md @@ -29,3 +29,5 @@ Withdraw crypto to a pre-approved hardware wallet address with fee checks and st 6. Execute withdrawal (requires explicit human approval): `kraken withdraw BTC "cold-storage-btc" 0.5 -o json 2>/dev/null` 7. Track withdrawal status: `kraken withdrawal status --asset BTC -o json 2>/dev/null` 8. Verify balance reduction: `kraken balance -o json 2>/dev/null` + +If you hit a mismatch between what you are trying to do and the CLI's interface or responses — including a mismatch between this skill and the installed CLI version's contract — feel free to submit feedback with `kraken feedback`. diff --git a/src/auth.rs b/src/auth.rs index 27159ab..24cbd6a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; -use hmac::{Hmac, Mac}; +use base64::engine::general_purpose::STANDARD as BASE64; +use hmac::{Hmac, KeyInit, Mac}; +use secrecy::{ExposeSecret, SecretString}; use sha2::{Digest, Sha256, Sha512}; -use crate::config::SecretValue; use crate::errors::{KrakenError, Result}; type HmacSha512 = Hmac; @@ -49,10 +49,10 @@ pub(crate) fn spot_sign( uri_path: &str, nonce: u64, post_data: &str, - api_secret: &SecretValue, + api_secret: &SecretString, ) -> Result { let secret_bytes = BASE64 - .decode(api_secret.expose()) + .decode(api_secret.expose_secret()) .map_err(|e| KrakenError::Auth(format!("Failed to decode API secret: {e}")))?; // SHA256(nonce + post_data) @@ -81,10 +81,10 @@ pub(crate) fn futures_sign( post_data: &str, nonce: &str, endpoint_path: &str, - api_secret: &SecretValue, + api_secret: &SecretString, ) -> Result { let secret_bytes = BASE64 - .decode(api_secret.expose()) + .decode(api_secret.expose_secret()) .map_err(|e| KrakenError::Auth(format!("Failed to decode Futures API secret: {e}")))?; // SHA256(postData + Nonce + endpointPath) @@ -119,7 +119,7 @@ mod tests { #[test] fn spot_sign_deterministic() { // Known test vector: inputs chosen to produce a deterministic output. - let secret = SecretValue::new(BASE64.encode(b"test_secret_key_bytes_32_chars!!")); + let secret = SecretString::from(BASE64.encode(b"test_secret_key_bytes_32_chars!!")); let sig = spot_sign( "/0/private/Balance", 1616492376594, @@ -128,6 +128,12 @@ mod tests { ) .expect("signing should succeed"); assert!(!sig.is_empty()); + // Known-answer test: pins the exact HMAC-SHA512(SHA256(..)) output so a + // dependency bump (sha2/hmac/digest) can never silently change signatures. + assert_eq!( + sig, + "4X7nFc5U3rLzrjRGXsNIB/JXCS90F60ILkC/Xg9iGTvULGGPsq9GZd9UbcOK+FFNznOehsW5l/LIv4Rm5+caIg==" + ); // Verify determinism by signing twice let sig2 = spot_sign( "/0/private/Balance", @@ -141,7 +147,7 @@ mod tests { #[test] fn futures_sign_deterministic() { - let secret = SecretValue::new(BASE64.encode(b"futures_secret_key_bytes_32ch!!")); + let secret = SecretString::from(BASE64.encode(b"futures_secret_key_bytes_32ch!!")); let sig = futures_sign("", "1234567890", "/api/v3/accounts", &secret) .expect("signing should succeed"); assert!(!sig.is_empty()); @@ -152,7 +158,7 @@ mod tests { #[test] fn spot_sign_rejects_invalid_base64_secret() { - let secret = SecretValue::new("not-valid-base64!!!".to_string()); + let secret = SecretString::from("not-valid-base64!!!".to_string()); let result = spot_sign("/0/private/Balance", 123, "nonce=123", &secret); assert!(result.is_err()); } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..55782f7 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,797 @@ +//! CLI parsing and runtime-context resolution. +//! +//! [`AppContext::from_cli`] centralizes precedence across flags, environment, +//! and config so every command sees the same resolved state. + +use clap::Parser; +use secrecy::{ExposeSecret, SecretString}; + +use crate::commands::Command; +use crate::errors::{self, Result}; +use crate::output::OutputFormat; +use crate::{client, config}; + +/// Parse a boolean "danger" toggle leniently: `1`, `true`, or `yes` +/// (case-insensitive, surrounding whitespace ignored) enable it; anything else — +/// including an empty value or an unrecognized string — leaves it disabled. +/// +/// clap's built-in `bool` + `env` parser is strict (`true`/`false` only) and +/// errors at parse time on `1`/`yes`/empty, which would break the documented +/// `KRAKEN_DANGER_*=1` convention. This parser never errors, matching the +/// previous accessor semantics while letting clap own the env read. +fn parse_danger_toggle(value: &str) -> std::result::Result { + Ok(matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + )) +} + +/// Treats blank and `default` scopes as the flat account. +/// +/// Other values remain verbatim so whitespace and spelling errors fail at the +/// validation boundary instead of selecting a different account. +fn normalize_workspace(workspace: Option) -> Option { + workspace + .filter(|w| !w.trim().is_empty()) + .filter(|w| w != kraken_workspace::DEFAULT_WORKSPACE) +} + +/// Runtime context assembled from global CLI flags and config. +pub struct AppContext { + pub format: OutputFormat, + pub api_url: Option, + pub futures_url: Option, + pub ws_public_url: Option, + pub ws_auth_url: Option, + pub ws_l3_url: Option, + pub ws_futures_url: Option, + /// Base reconnect backoff (ms) for spot WebSocket streams; compresses the + /// backoff schedule when set. Resolved once here so the WS layer never reads + /// the environment. + pub ws_reconnect_base_ms: Option, + /// Resolved Spot credentials (flag > env > config), or `None` if unset. + pub spot_credentials: Option, + /// Resolved Futures credentials (flag > env > config), or `None` if unset. + pub futures_credentials: Option, + /// API secret source (stdin/file/flag), retained as a secret so `auth set` + /// can persist it. Authentication uses `spot_credentials` / + /// `futures_credentials`; this is the flag-tier secret that feeds them. + pub api_secret: Option, + /// OTP second factor, as a secret so the derived `Debug` redacts it — in + /// static-password 2FA mode it is as durable as the API secret. + pub otp: Option, + /// Workspace name (resolved `--workspace` > `$KRAKEN_WORKSPACE`), or + /// `None` for the flat layout. The reserved `default` collapses to `None` + /// at resolution. Carries the raw name only; validation and the existence + /// gate run in [`scoped_base`](Self::scoped_base), the single point where + /// scoped commands resolve their state root. + pub workspace: Option, + pub force: bool, + /// Disable TLS certificate verification for HTTP and WebSocket transport. + /// Resolved once here from `KRAKEN_DANGER_ACCEPT_INVALID_CERTS` and injected + /// into the clients/WS layer so no transport code reads the environment. + pub accept_invalid_certs: bool, + /// Whether non-Kraken hosts are permitted in URL overrides. Retained so the + /// REPL can re-validate per-line URL overrides under the same policy the + /// session was started with (see `AppContext::from_cli_with_fallback`). + pub allow_any_url_host: bool, + /// True when running in MCP server mode. Disables all interactive prompts. + pub mcp_mode: bool, + /// Live stream-reliability monitor configuration, or `None` when `--monitor` is + /// unset. Consumed by [`stream::run`](crate::stream::run) for `ws`/`streamd`/ + /// `record`; ignored by everything else. + pub(crate) monitor: Option, + /// Memoized HTTP clients, built once on first use. A `reqwest::Client` owns a + /// connection pool; rebuilding one per command discards keep-alive reuse. This + /// matters for the long-lived MCP server, which shares one `AppContext` across + /// every tool call (`run_server`). Accessed via [`spot`](Self::spot) / + /// [`futures`](Self::futures). The spot client is held behind an `Arc` so the + /// WS token source shares the same pool instead of building its own. + pub(crate) spot_client: std::sync::OnceLock>, + pub(crate) futures_client: std::sync::OnceLock, +} + +/// Memoize a fallibly-built value: return the cached one, or build and cache. +/// On a benign race two values may be built; `get_or_init` drops the loser. +fn memoized(slot: &std::sync::OnceLock, build: impl FnOnce() -> Result) -> Result<&T> { + if let Some(v) = slot.get() { + return Ok(v); + } + let built = build()?; + Ok(slot.get_or_init(|| built)) +} + +impl AppContext { + /// This context, re-scoped to another workspace — exactly what a + /// `--workspace ` line would have resolved, sharing the memoized + /// clients. For commands that thread a scope explicitly (the playground + /// demo) instead of asking the user to export it first. + pub(crate) fn rescope(&self, workspace: Option) -> Self { + Self { + format: self.format, + api_url: self.api_url.clone(), + futures_url: self.futures_url.clone(), + ws_public_url: self.ws_public_url.clone(), + ws_auth_url: self.ws_auth_url.clone(), + ws_l3_url: self.ws_l3_url.clone(), + ws_futures_url: self.ws_futures_url.clone(), + ws_reconnect_base_ms: self.ws_reconnect_base_ms, + spot_credentials: self.spot_credentials.clone(), + futures_credentials: self.futures_credentials.clone(), + api_secret: self.api_secret.clone(), + otp: self.otp.clone(), + workspace, + force: self.force, + accept_invalid_certs: self.accept_invalid_certs, + allow_any_url_host: self.allow_any_url_host, + mcp_mode: self.mcp_mode, + monitor: None, + spot_client: self.spot_client.clone(), + futures_client: self.futures_client.clone(), + } + } + + /// Resolves existing scope state without caching cross-request changes. + pub(crate) fn scoped_base(&self) -> Result { + let base = config::config_dir()?; + let Some(raw) = self.workspace.as_deref() else { + return Ok(base); + }; + // `default` was collapsed to `None` at resolution, so every name + // reaching this point scopes a real subtree. + let name: kraken_workspace::WorkspaceName = raw.parse()?; + kraken_workspace::Workspaces::new(&base).ensure_exists(&name)?; + Ok(kraken_workspace::scoped_base(&base, Some(&name))) + } + + /// Gate a destructive operation behind an interactive yes/no prompt, + /// aborting on decline. `--yes` skips it; the MCP server pins `force: true` + /// because acknowledgment is enforced at its tool layer instead. + pub(crate) fn confirm_destructive(&self, prompt: &str) -> Result<()> { + if self.force { + return Ok(()); + } + // A pipeline can never answer the prompt; refuse with the fix instead + // of surfacing dialoguer's raw "IO error: not a terminal". + if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { + return Err(errors::KrakenError::Validation(format!( + "confirmation needed ({prompt}), but stdin is not a terminal — re-run with --yes" + ))); + } + let confirmed = dialoguer::Confirm::new() + .with_prompt(prompt) + .default(false) + .interact() + .map_err(|e| errors::KrakenError::Config(format!("Prompt error: {e}")))?; + if confirmed { + Ok(()) + } else { + Err(errors::KrakenError::Validation( + "Operation cancelled by user".into(), + )) + } + } + + /// Borrow the memoized Spot client, building it once on first use. + /// + /// The client depends only on `api_url` and `accept_invalid_certs`, both + /// immutable for the lifetime of the context, so a single build is always + /// correct. + pub(crate) fn spot(&self) -> Result<&client::SpotClient> { + Ok(self.spot_shared()?.as_ref()) + } + + /// The memoized Spot client as an owned handle, for the WS token source — + /// the same instance [`spot`](Self::spot) borrows, never a second pool. + fn spot_shared(&self) -> Result<&std::sync::Arc> { + memoized(&self.spot_client, || { + client::SpotClient::new(self.api_url.as_deref(), self.accept_invalid_certs) + .map(std::sync::Arc::new) + }) + } + + /// Borrow the memoized Futures client, building it once on first use. + pub(crate) fn futures(&self) -> Result<&client::FuturesClient> { + memoized(&self.futures_client, || { + client::FuturesClient::new(self.futures_url.as_deref(), self.accept_invalid_certs) + }) + } + + /// Client, credentials, and optional OTP for an authenticated Spot command. + /// The OTP stays wrapped; it is exposed only inside `private_post*` request + /// construction. + pub(crate) fn spot_authed( + &self, + ) -> Result<( + &client::SpotClient, + &config::Credentials, + Option<&SecretString>, + )> { + let creds = self.require_credentials(config::Engine::Spot)?; + Ok((self.spot()?, creds, self.otp.as_ref())) + } + + /// Client + credentials for an authenticated Futures command. + /// + /// Credentials resolve before the client so a missing-key setup always + /// surfaces the actionable auth error, never a client-construction failure. + pub(crate) fn futures_authed(&self) -> Result<(&client::FuturesClient, &config::Credentials)> { + let creds = self.require_credentials(config::Engine::Futures)?; + Ok((self.futures()?, creds)) + } + + /// The WS client config — URL overrides, TLS switch, token source, handshake + /// headers — projected from the resolved context, beside the REST factories. + /// `token_source` is `None` without spot credentials (public-only use; private + /// endpoints then fail closed inside the client). + pub(crate) fn ws_config(&self) -> Result { + Ok(kraken_ws::WsConfig { + urls: kraken_ws::Urls { + public: self.ws_public_url.clone(), + auth: self.ws_auth_url.clone(), + l3: self.ws_l3_url.clone(), + }, + accept_invalid_certs: self.accept_invalid_certs, + token_source: self.ws_token_source()?, + // The transport crate takes the identity headers as data; it knows + // nothing about this binary's identity. + headers: crate::telemetry::ws_handshake_headers() + .into_iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(), + }) + } + + /// The WS reconnect schedule, with the `--ws-reconnect-base-ms` / + /// `KRAKEN_WS_RECONNECT_BASE_MS` knob folded into `initial` (the rest keeps its + /// defaults). A base above the default cap is honoured: the backoff raises its + /// effective `max` to `initial`. + pub(crate) fn ws_reconnect_policy(&self) -> kraken_ws::ReconnectPolicy { + use kraken_ws::ReconnectPolicy; + match self.ws_reconnect_base_ms { + Some(ms) => ReconnectPolicy { + initial: std::time::Duration::from_millis(ms), + ..ReconnectPolicy::default() + }, + None => ReconnectPolicy::default(), + } + } + + /// The live token source, or `None` when no spot credentials are configured. + fn ws_token_source(&self) -> Result>> { + use std::sync::Arc; + let Ok(credentials) = self.require_credentials(config::Engine::Spot) else { + return Ok(None); + }; + let credentials = credentials.clone(); + let spot = Arc::clone(self.spot_shared()?); + let source = client::SpotTokenSource::new(spot, Arc::new(credentials), self.otp.clone()); + Ok(Some(Arc::new(source))) + } + + /// Borrow resolved credentials for `engine`, or the standard "not + /// configured" error naming that engine's env vars. + pub(crate) fn require_credentials( + &self, + engine: config::Engine, + ) -> Result<&config::Credentials> { + let resolved = match engine { + config::Engine::Spot => &self.spot_credentials, + config::Engine::Futures => &self.futures_credentials, + }; + resolved.as_ref().ok_or_else(|| { + let (key_env, secret_env) = engine.env_names(); + errors::KrakenError::Auth(format!( + "No {} API credentials found. Use `kraken auth set` or set {key_env} / {secret_env} env vars.", + engine.label() + )) + }) + } + + /// Assemble the runtime context for a one-shot CLI invocation. + /// + /// This is the single place where global flags, environment variables, and + /// the config file are resolved into runtime state. clap already applies + /// flag > env precedence for every value carrying an `env = ...` attribute + /// (URLs, OTP); this builder only validates those values and layers in the + /// pieces clap cannot express: the stdin/file secret sources and the + /// three-tier (flag > env > config) credential resolver in [`config`]. + pub fn from_cli(cli: &Cli) -> Result { + // One-shot: clap has already applied flag > env for the scope names, so + // the flag-vs-env distinction is irrelevant here. + Self::build(cli, None, false) + } + + /// Assemble the runtime context for a single REPL line, falling back to + /// `base` (the session context) for anything the line did not set. + /// + /// Routes through the exact same resolution as [`from_cli`], so per-line + /// behaviour — including URL scheme **and host-allowlist** enforcement — + /// matches one-shot invocations rather than diverging from them. + /// + /// `workspace_from_flag` must be `true` only when `--workspace` was typed + /// on this line (clap's `ValueSource::CommandLine`), not merely inherited + /// from `$KRAKEN_WORKSPACE`. The shell's environment is constant, so an + /// env-sourced workspace is already baked into `base`; letting it + /// re-resolve per line would override an explicit `kraken shell + /// --workspace`. See [`build`]. + /// + /// [`from_cli`]: AppContext::from_cli + /// [`build`]: AppContext::build + pub(crate) fn from_cli_with_fallback( + cli: &Cli, + base: &AppContext, + workspace_from_flag: bool, + ) -> Result { + Self::build(cli, Some(base), workspace_from_flag) + } + + /// Shared resolution core. With `base = None` this is a one-shot invocation; + /// with `base = Some(shell)` it is a REPL line layered over the shell's + /// startup context. + /// + /// `workspace_from_flag` is consulted only in the REPL case; see + /// [`from_cli_with_fallback`](AppContext::from_cli_with_fallback). + fn build(cli: &Cli, base: Option<&AppContext>, workspace_from_flag: bool) -> Result { + let format = cli + .output + .unwrap_or_else(|| base.map_or(OutputFormat::Table, |b| b.format)); + + // The API secret source. A one-shot invocation may read it from stdin or + // a file; in the REPL only the inline flag is honoured (stdin is the line + // reader) and resolution falls back to the session secret. + let api_secret: Option = match base { + None => resolve_secret_source(cli)?, + Some(b) => cli + .api_secret + .clone() + .map(SecretString::from) + .or_else(|| b.api_secret.clone()), + }; + + // Transport "danger" toggles come from clap (flag or env, leniently + // parsed). In the REPL they are sticky: once a session enables a toggle + // it stays enabled, and per-line flags can only add to it. + let allow_any_url_host = + cli.danger_allow_any_url_host || base.is_some_and(|b| b.allow_any_url_host); + let accept_invalid_certs = + cli.danger_accept_invalid_certs || base.is_some_and(|b| b.accept_invalid_certs); + // Warned here, where the flag is resolved: this target passes the default + // stderr filter (`kraken_cli=warn`), so the safety notice survives log + // configurations that would filter the transport crates' own warnings out. + if accept_invalid_certs { + tracing::warn!( + "TLS certificate verification is DISABLED. Connections are vulnerable to \ + man-in-the-middle attacks. Do NOT use this in production." + ); + } + + // URL overrides: clap has already applied flag > env precedence, so here + // we only validate scheme/host, then fall back to the session's resolved + // value when the line set nothing. Errors are tagged with the originating + // flag/env names so the user knows which value to fix. + let resolve_url = |value: Option<&str>, + label: &str, + fallback: Option<&String>| + -> Result> { + let resolved = + client::resolve_url_override(value, allow_any_url_host).map_err(|e| { + let inner = match &e { + errors::KrakenError::Validation(msg) => msg.clone(), + other => other.to_string(), + }; + errors::KrakenError::Validation(format!("{label}: {inner}")) + })?; + Ok(resolved.or_else(|| fallback.cloned())) + }; + + let api_url = resolve_url( + cli.api_url.as_deref(), + "--api-url / KRAKEN_SPOT_URL", + base.and_then(|b| b.api_url.as_ref()), + )?; + let futures_url = resolve_url( + cli.futures_url.as_deref(), + "--futures-url / KRAKEN_FUTURES_URL", + base.and_then(|b| b.futures_url.as_ref()), + )?; + let ws_public_url = resolve_url( + cli.ws_public_url.as_deref(), + "--ws-public-url / KRAKEN_WS_PUBLIC_URL", + base.and_then(|b| b.ws_public_url.as_ref()), + )?; + let ws_auth_url = resolve_url( + cli.ws_auth_url.as_deref(), + "--ws-auth-url / KRAKEN_WS_AUTH_URL", + base.and_then(|b| b.ws_auth_url.as_ref()), + )?; + let ws_l3_url = resolve_url( + cli.ws_l3_url.as_deref(), + "--ws-l3-url / KRAKEN_WS_L3_URL", + base.and_then(|b| b.ws_l3_url.as_ref()), + )?; + // The private futures feed authenticates over this socket, so its host + // override takes the same trusted-host gate as every other URL. + let ws_futures_url = resolve_url( + cli.ws_futures_url.as_deref(), + "--ws-futures-url / KRAKEN_FUTURES_WS_URL", + base.and_then(|b| b.ws_futures_url.as_ref()), + )?; + + // Credentials keep their dedicated three-tier (flag > env > config) + // resolver, then fall back to the session's already-resolved credentials. + // clap reads every env var (shared --api-key/--api-secret flags plus the + // per-engine env vars); the resolver only applies precedence and the + // config-file tier, so it no longer touches the environment itself. + let flag_secret = api_secret.as_ref().map(|s| s.expose_secret()); + let spot_credentials = config::resolve_credentials( + config::Engine::Spot, + cli.api_key.as_deref(), + flag_secret, + cli.spot_api_key.as_deref(), + cli.spot_api_secret.as_deref(), + ) + .or_else(|| base.and_then(|b| b.spot_credentials.clone())); + let futures_credentials = config::resolve_credentials( + config::Engine::Futures, + cli.api_key.as_deref(), + flag_secret, + cli.futures_api_key.as_deref(), + cli.futures_api_secret.as_deref(), + ) + .or_else(|| base.and_then(|b| b.futures_credentials.clone())); + + // A REPL line that overrides the URL or the cert toggle must build its + // own client; otherwise the base's already-built client is carried over, + // since rebuilding per line would discard its connection pool. + let spot_client = base + .filter(|b| b.api_url == api_url && b.accept_invalid_certs == accept_invalid_certs) + .and_then(|b| b.spot_client.get().cloned()) + .map_or_else(std::sync::OnceLock::new, std::sync::OnceLock::from); + let futures_client = base + .filter(|b| { + b.futures_url == futures_url && b.accept_invalid_certs == accept_invalid_certs + }) + .and_then(|b| b.futures_client.get().cloned()) + .map_or_else(std::sync::OnceLock::new, std::sync::OnceLock::from); + + Ok(Self { + format, + api_url, + futures_url, + ws_public_url, + ws_auth_url, + ws_l3_url, + ws_futures_url, + ws_reconnect_base_ms: cli + .ws_reconnect_base_ms + .or_else(|| base.and_then(|b| b.ws_reconnect_base_ms)), + spot_credentials, + futures_credentials, + api_secret, + otp: cli + .otp + .clone() + .map(SecretString::from) + .or_else(|| base.and_then(|b| b.otp.clone())), + // Blank normalizes to "no workspace" (the real account); the + // reserved `default` collapses the same way. REPL lines are + // sticky: an explicit per-line `--workspace` is authoritative + // for that line; a line without the flag inherits the startup + // scope ($KRAKEN_WORKSPACE is constant for the shell and already + // reflected in `base`). + workspace: match base { + None => normalize_workspace(cli.workspace.clone()), + Some(_) if workspace_from_flag => normalize_workspace(cli.workspace.clone()), + Some(b) => b.workspace.clone(), + }, + force: cli.yes || base.is_some_and(|b| b.force), + accept_invalid_certs, + allow_any_url_host, + mcp_mode: false, + // Enabled by `--monitor`; in the REPL it is sticky, falling back to the + // session's setting when a line doesn't pass the flag (like `verbose`). + monitor: if cli.monitor { + Some(crate::monitor::MonitorConfig::new( + cli.monitor_health_interval, + cli.monitor_stale_after, + cli.monitor_heartbeat_timeout, + )) + } else { + base.and_then(|b| b.monitor.clone()) + }, + spot_client, + futures_client, + }) + } +} + +/// Resolve the API secret from its one-shot sources, in precedence order: +/// stdin > file > inline flag. Warns when the inline flag is used, since it is +/// visible in process listings. +fn resolve_secret_source(cli: &Cli) -> Result> { + if cli.api_secret_stdin { + Ok(Some(config::read_secret_from_stdin()?)) + } else if let Some(path) = &cli.api_secret_file { + Ok(Some(config::read_secret_from_file(path)?)) + } else if let Some(secret) = &cli.api_secret { + tracing::warn!( + "passing --api-secret on the command line exposes it in process listings. \ + Prefer --api-secret-stdin, --api-secret-file, or environment variables." + ); + Ok(Some(SecretString::from(secret.clone()))) + } else { + Ok(None) + } +} + +/// Kraken CLI — trade, query, and manage your Kraken account from the terminal. +#[derive(Parser)] +#[command(name = "kraken", version, about, long_about = None)] +pub struct Cli { + /// Output format: table (default) or json. + #[arg(short, long, value_enum, global = true)] + pub output: Option, + + /// Show request/response details on stderr. + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Diagnostic log format on stderr [default: json]. JSON (JSONL) is the + /// machine-parseable default everywhere; use `pretty` (or `compact`) for + /// interactive work. Independent of `-o`/`--output`, which formats the + /// command result on stdout. Use `RUST_LOG` (or `-v`) to control the level. + #[arg(long, value_enum, global = true, env = "KRAKEN_LOG_FORMAT")] + pub log_format: Option, + + /// Override Spot API base URL. + #[arg(long, global = true, env = "KRAKEN_SPOT_URL")] + pub api_url: Option, + + /// Override Futures API base URL. + #[arg(long, global = true, env = "KRAKEN_FUTURES_URL")] + pub futures_url: Option, + + /// Override public WebSocket URL. + #[arg(long, global = true, env = "KRAKEN_WS_PUBLIC_URL")] + pub ws_public_url: Option, + + /// Override authenticated WebSocket URL. + #[arg(long, global = true, env = "KRAKEN_WS_AUTH_URL")] + pub ws_auth_url: Option, + + /// Override L3 (authenticated) WebSocket URL. + #[arg(long, global = true, env = "KRAKEN_WS_L3_URL")] + pub ws_l3_url: Option, + + /// Override Futures WebSocket URL. + #[arg(long, global = true, env = "KRAKEN_FUTURES_WS_URL")] + pub ws_futures_url: Option, + + /// Base WebSocket reconnect backoff in milliseconds (testing/tuning knob; + /// compresses the backoff schedule). Defaults to the production schedule. + /// Zero is rejected: multiplicative backoff has a fixed point at zero, so a + /// 0 base never escalates and hammers the venue with sub-second dials forever. + #[arg(long, global = true, env = "KRAKEN_WS_RECONNECT_BASE_MS", hide = true, + value_parser = clap::value_parser!(u64).range(1..))] + pub ws_reconnect_base_ms: Option, + + /// API key override (takes precedence over env/config). + #[arg(long, global = true)] + pub api_key: Option, + + /// API secret override (prefer --api-secret-stdin for security). + #[arg(long, global = true)] + pub api_secret: Option, + + /// Read API secret from stdin (mutually exclusive with --api-secret and --api-secret-file). + #[arg(long, global = true, conflicts_with_all = ["api_secret", "api_secret_file"])] + pub api_secret_stdin: bool, + + /// Path to file containing API secret (mutually exclusive with --api-secret and --api-secret-stdin). + #[arg(long, global = true, conflicts_with_all = ["api_secret", "api_secret_stdin"])] + pub api_secret_file: Option, + + // Per-engine credential environment variables. clap owns the env read; the + // values feed the env tier of the credential resolver in `config.rs`. These + // are hidden because the supported entry points are the env vars themselves + // and the shared `--api-key`/`--api-secret` flags above — not per-engine + // flags. Spot and Futures use distinct env vars, which the single shared + // flag pair cannot express, so they are surfaced as separate fields here. + /// Spot API key (env: KRAKEN_API_KEY). + #[arg( + long = "spot-api-key", + env = "KRAKEN_API_KEY", + hide = true, + global = true + )] + pub spot_api_key: Option, + /// Spot API secret (env: KRAKEN_API_SECRET). + #[arg( + long = "spot-api-secret", + env = "KRAKEN_API_SECRET", + hide = true, + global = true + )] + pub spot_api_secret: Option, + /// Futures API key (env: KRAKEN_FUTURES_API_KEY). + #[arg( + long = "futures-api-key", + env = "KRAKEN_FUTURES_API_KEY", + hide = true, + global = true + )] + pub futures_api_key: Option, + /// Futures API secret (env: KRAKEN_FUTURES_API_SECRET). + #[arg( + long = "futures-api-secret", + env = "KRAKEN_FUTURES_API_SECRET", + hide = true, + global = true + )] + pub futures_api_secret: Option, + + /// OTP (two-factor authentication code). + #[arg(long, global = true, env = "KRAKEN_OTP")] + pub otp: Option, + + /// Workspace name. Scopes the paper account, its sessions, and lab + /// experiments to an isolated `workspaces//` tree; recorded market + /// data stays global. Unset (or `default`) uses the flat layout. Must be a single + /// path-safe segment: letters, digits, '.', '-', or '_' (no '/', spaces, + /// or '.'/'..'). + #[arg(long, global = true, env = "KRAKEN_WORKSPACE")] + pub workspace: Option, + + /// Skip confirmation prompts for destructive operations. + #[arg(long, alias = "force", global = true)] + pub yes: bool, + + /// DANGEROUS: disable TLS certificate verification for HTTP and WebSocket + /// connections. Exposes traffic to MITM; for local testing only. + #[arg( + long, + global = true, + env = "KRAKEN_DANGER_ACCEPT_INVALID_CERTS", + num_args = 0..=1, + default_value_t = false, + default_missing_value = "true", + value_parser = parse_danger_toggle, + )] + pub danger_accept_invalid_certs: bool, + + /// DANGEROUS: allow non-Kraken hosts in URL overrides (e.g. for UAT). + #[arg( + long, + global = true, + env = "KRAKEN_DANGER_ALLOW_ANY_URL_HOST", + num_args = 0..=1, + default_value_t = false, + default_missing_value = "true", + value_parser = parse_danger_toggle, + )] + pub danger_allow_any_url_host: bool, + + /// Emit live stream-reliability diagnostics (latency, trade-id gaps, staleness, + /// throughput, connection health) to stderr while streaming. Applies to `ws`, + /// `streamd`, and `record`; ignored by other commands. + #[arg(long, global = true)] + pub monitor: bool, + + /// Seconds between periodic monitor `health` snapshots (requires --monitor). + #[arg(long, global = true, default_value_t = 10, requires = "monitor")] + pub monitor_health_interval: u64, + + /// Soft alert after this many seconds with no market data (requires --monitor). + #[arg(long, global = true, default_value_t = 30, requires = "monitor")] + pub monitor_stale_after: u64, + + /// Hard alert after this many seconds with no heartbeat — connection-level + /// liveness (requires --monitor). + #[arg(long, global = true, default_value_t = 5, requires = "monitor")] + pub monitor_heartbeat_timeout: u64, + + #[command(subcommand)] + pub(crate) command: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn confirm_destructive_without_tty_names_the_yes_flag() { + if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + // Interactive `cargo test` would hit the real prompt and hang; + // the branch under test only exists without a TTY. + return; + } + let cli = Cli::try_parse_from(["kraken"]).expect("bare invocation parses"); + let ctx = AppContext::from_cli(&cli).expect("default context resolves"); + let err = ctx.confirm_destructive("drop it?").unwrap_err(); + assert!( + matches!(err, errors::KrakenError::Validation(_)), + "got: {err}" + ); + assert!(err.to_string().contains("--yes"), "got: {err}"); + } + + #[test] + fn normalize_workspace_keeps_real_names() { + assert_eq!( + normalize_workspace(Some("btc-dip".to_string())), + Some("btc-dip".to_string()) + ); + } + + #[test] + fn repl_line_reuses_base_client_when_transport_inputs_unchanged() { + let cli = Cli::try_parse_from(["kraken"]).expect("bare invocation parses"); + let base = AppContext::from_cli(&cli).expect("default context resolves"); + base.spot().expect("client builds offline"); + + let line = Cli::try_parse_from(["kraken"]).expect("bare line parses"); + let derived = AppContext::from_cli_with_fallback(&line, &base, false) + .expect("fallback context resolves"); + + // The base's memoized client is carried over, not rebuilt lazily. + assert!(derived.spot_client.get().is_some()); + } + + #[test] + fn repl_line_with_api_url_override_builds_its_own_client() { + let cli = Cli::try_parse_from(["kraken"]).expect("bare invocation parses"); + let base = AppContext::from_cli(&cli).expect("default context resolves"); + base.spot().expect("client builds offline"); + + let line = Cli::try_parse_from(["kraken", "--api-url", "https://api.kraken.com"]) + .expect("line with URL override parses"); + let derived = AppContext::from_cli_with_fallback(&line, &base, false) + .expect("fallback context resolves"); + + // An overridden URL must not inherit a client built for the old one. + assert!(derived.spot_client.get().is_none()); + } + + #[test] + fn futures_ws_url_override_rejects_untrusted_host() { + // The explicit `false` pins the toggle regardless of the ambient + // environment, which may export it. + let cli = Cli::try_parse_from([ + "kraken", + "--danger-allow-any-url-host", + "false", + "--ws-futures-url", + "wss://evil.example.com/ws/v1", + ]) + .expect("line with futures WS override parses"); + let Err(err) = AppContext::from_cli(&cli) else { + panic!("untrusted futures WS host must not resolve"); + }; + assert!(err.to_string().contains("Untrusted URL host"), "got: {err}"); + assert!(err.to_string().contains("--ws-futures-url"), "got: {err}"); + } + + #[test] + fn normalize_workspace_treats_blank_as_unset() { + // An exported-but-empty `KRAKEN_WORKSPACE` (clap yields `Some("")`) + // and a whitespace-only value must both degrade to the real account, + // not an invalid-name error. + assert_eq!(normalize_workspace(Some(String::new())), None); + assert_eq!(normalize_workspace(Some(" ".to_string())), None); + assert_eq!(normalize_workspace(Some("\t\n".to_string())), None); + assert_eq!(normalize_workspace(None), None); + } + + #[test] + fn normalize_workspace_collapses_blank_and_the_reserved_default() { + // Blank degrades like a session; `default` is the alias-in-place + // workspace, so it must resolve exactly like "no workspace". + assert_eq!(normalize_workspace(Some(String::new())), None); + assert_eq!(normalize_workspace(Some("default".to_string())), None); + assert_eq!( + normalize_workspace(Some("btc-momentum".to_string())), + Some("btc-momentum".to_string()) + ); + } +} diff --git a/src/client.rs b/src/client.rs index db232d9..f5b5cfb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,23 +1,31 @@ -/// HTTP clients for Kraken Spot and Futures APIs. -/// -/// Each client enforces TLS-only transport (via rustls), handles request -/// signing, and transient-error retry with exponential backoff for network -/// and 5xx errors. -/// -/// Rate limiting is server-authoritative: requests are sent immediately with -/// no client-side pre-throttling. When the Kraken API returns a rate limit -/// error, it is surfaced immediately as an enriched `KrakenError::RateLimit` -/// with `suggestion`, `docs_url`, and `retryable` fields so the caller -/// (agent or human) can decide how to proceed. +//! HTTP clients for Kraken Spot and Futures APIs. +//! +//! Each client enforces TLS-only transport (via rustls), handles request +//! signing, and transient-error retry with exponential backoff for network +//! and 5xx errors. +//! +//! Rate limiting is server-authoritative: requests are sent immediately with +//! no client-side pre-throttling. When the Kraken API returns a rate limit +//! error, it is surfaced immediately as an enriched `KrakenError::RateLimit` +//! with `suggestion`, `docs_url`, and `retryable` fields so the caller +//! (agent or human) can decide how to proceed. +//! +//! Diagnostics are emitted via `tracing`: request lines at `debug!`, retries at +//! `warn!`, and response bodies at `trace!` only. Bodies carry account/financial +//! data, so they are never logged above `trace!`, are truncated, and are passed +//! through [`redact_log_body`] to mask live WebSocket tokens. + use std::collections::HashMap; -use std::env; use std::time::Duration; +use kraken_core::error::Error as WsError; +use kraken_ws::{BoxFuture, Minted, TokenSource}; use reqwest::header::{HeaderMap, HeaderValue}; +use secrecy::{ExposeSecret, SecretString}; use serde_json::Value; use crate::auth; -use crate::config::{FuturesCredentials, SpotCredentials}; +use crate::config::{Credentials, DANGER_ALLOW_ANY_URL_HOST_ENV}; use crate::errors::{KrakenError, Result}; use crate::telemetry; @@ -25,15 +33,24 @@ pub(crate) const DEFAULT_SPOT_URL: &str = "https://api.kraken.com"; pub(crate) const DEFAULT_FUTURES_URL: &str = "https://futures.kraken.com/derivatives/api/v3"; const MAX_RETRIES: u32 = 3; const INITIAL_BACKOFF_MS: u64 = 500; -const DANGER_ALLOW_ANY_URL_HOST_ENV: &str = "KRAKEN_DANGER_ALLOW_ANY_URL_HOST"; /// Spot API client with transient-error retry and server-authoritative rate limiting. +/// +/// `Clone` is cheap: `reqwest::Client` is a handle over a shared connection pool, +/// so cloning reuses the pool rather than opening a new one. +/// [`AppContext::build`](crate::cli::AppContext) relies on this to clone the +/// memoized client into a REPL line's context when the line inherits unchanged +/// transport inputs, keeping the pool alive across lines. +#[derive(Clone)] pub struct SpotClient { http: reqwest::Client, base_url: String, } /// Futures API client with transient-error retry and server-authoritative rate limiting. +/// +/// See [`SpotClient`] for why `Clone` is cheap. +#[derive(Clone)] pub struct FuturesClient { http: reqwest::Client, base_url: String, @@ -41,47 +58,30 @@ pub struct FuturesClient { fn build_default_headers() -> Result { let mut headers = HeaderMap::new(); - headers.insert( - "X-Kraken-Client", - HeaderValue::from_static(telemetry::CLIENT_NAME), - ); - headers.insert( - "X-Kraken-Client-Version", - HeaderValue::from_static(telemetry::CLIENT_VERSION), - ); - headers.insert( - "X-Kraken-Agent-Client", - HeaderValue::from_str(telemetry::agent_client()) - .map_err(|e| KrakenError::Validation(format!("Invalid agent client header: {e}")))?, - ); - headers.insert( - "X-Kraken-Instance-Id", - HeaderValue::from_str(telemetry::instance_id()) - .map_err(|e| KrakenError::Validation(format!("Invalid instance ID header: {e}")))?, - ); - headers.insert( - "x-korigin", - HeaderValue::from_static(telemetry::KORIGIN_REST), - ); + for (name, value) in telemetry::rest_identity_headers() { + headers.insert( + name, + HeaderValue::from_str(value) + .map_err(|e| KrakenError::Validation(format!("Invalid {name} header: {e}")))?, + ); + } Ok(headers) } -fn build_http_client() -> Result { +fn build_http_client(accept_invalid_certs: bool) -> Result { let mut builder = reqwest::Client::builder() .use_rustls_tls() .timeout(Duration::from_secs(30)) .user_agent(telemetry::user_agent()) .default_headers(build_default_headers()?); - if let Ok(val) = env::var("KRAKEN_DANGER_ACCEPT_INVALID_CERTS") { - if matches!(val.as_str(), "1" | "true" | "yes") { - eprintln!( - "WARNING: TLS certificate verification is DISABLED via \ - KRAKEN_DANGER_ACCEPT_INVALID_CERTS. Connections are vulnerable to \ - man-in-the-middle attacks. Do NOT use this in production." - ); - builder = builder.danger_accept_invalid_certs(true); - } + if accept_invalid_certs { + tracing::warn!( + "TLS certificate verification is DISABLED via \ + KRAKEN_DANGER_ACCEPT_INVALID_CERTS. Connections are vulnerable to \ + man-in-the-middle attacks. Do NOT use this in production." + ); + builder = builder.danger_accept_invalid_certs(true); } builder @@ -90,26 +90,20 @@ fn build_http_client() -> Result { } impl SpotClient { - /// Create a new Spot client. - pub fn new(base_url: Option<&str>) -> Result { + /// `accept_invalid_certs` (resolved once in `AppContext::from_cli`) + /// controls whether TLS verification is disabled. + pub fn new(base_url: Option<&str>, accept_invalid_certs: bool) -> Result { Ok(Self { - http: build_http_client()?, + http: build_http_client(accept_invalid_certs)?, base_url: base_url.unwrap_or(DEFAULT_SPOT_URL).to_string(), }) } /// Execute a public GET request (no auth needed), with retry on transient /// errors and 5xx server errors (GET is inherently idempotent). - pub async fn public_get( - &self, - endpoint: &str, - params: &[(&str, &str)], - verbose: bool, - ) -> Result { + pub async fn public_get(&self, endpoint: &str, params: &[(&str, &str)]) -> Result { let url = format!("{}/0/public/{}", self.base_url, endpoint); - if verbose { - crate::output::verbose(&format!("GET {url}")); - } + tracing::debug!(method = "GET", %url, "request"); let mut attempt = 0u32; loop { @@ -119,22 +113,25 @@ impl SpotClient { let status = r.status(); attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Server error {status}, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + %status, + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "server error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } - Ok(r) => return self.parse_spot_response(r, verbose).await, + Ok(r) => return self.parse_spot_response(r, BodyLog::Full).await, Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -151,10 +148,9 @@ impl SpotClient { &self, endpoint: &str, params: HashMap, - creds: &SpotCredentials, - otp: Option<&str>, + creds: &Credentials, + otp: Option<&SecretString>, idempotent: bool, - verbose: bool, ) -> Result { let uri_path = format!("/0/private/{}", endpoint); let url = format!("{}{}", self.base_url, uri_path); @@ -165,7 +161,7 @@ impl SpotClient { let mut attempt_params = params.clone(); attempt_params.insert("nonce".to_string(), nonce.to_string()); if let Some(otp_val) = otp { - attempt_params.insert("otp".to_string(), otp_val.to_string()); + attempt_params.insert("otp".to_string(), otp_val.expose_secret().to_string()); } let post_data = url::form_urlencoded::Serializer::new(String::new()) @@ -174,14 +170,12 @@ impl SpotClient { let signature = auth::spot_sign(&uri_path, nonce, &post_data, &creds.api_secret)?; - if verbose { - crate::output::verbose(&format!("POST {url} (nonce={nonce})")); - } + tracing::debug!(method = "POST", %url, nonce, "request"); let mut headers = HeaderMap::new(); headers.insert( "API-Key", - HeaderValue::from_str(&creds.api_key) + HeaderValue::from_str(creds.api_key.expose_secret()) .map_err(|e| KrakenError::Auth(format!("Invalid API key header: {e}")))?, ); headers.insert( @@ -205,24 +199,27 @@ impl SpotClient { if idempotent && status.is_server_error() && attempt < MAX_RETRIES { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Server error {status}, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + %status, + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "server error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; continue; } - return self.parse_spot_response(r, verbose).await; + return self.parse_spot_response(r, BodyLog::SizeOnly).await; } Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -239,10 +236,9 @@ impl SpotClient { &self, endpoint: &str, body: serde_json::Map, - creds: &SpotCredentials, - otp: Option<&str>, + creds: &Credentials, + otp: Option<&SecretString>, idempotent: bool, - verbose: bool, ) -> Result { let uri_path = format!("/0/private/{}", endpoint); let url = format!("{}{}", self.base_url, uri_path); @@ -253,7 +249,10 @@ impl SpotClient { let mut attempt_body = body.clone(); attempt_body.insert("nonce".to_string(), Value::Number(nonce.into())); if let Some(otp_val) = otp { - attempt_body.insert("otp".to_string(), Value::String(otp_val.to_string())); + attempt_body.insert( + "otp".to_string(), + Value::String(otp_val.expose_secret().to_string()), + ); } let json_str = serde_json::to_string(&attempt_body) @@ -261,14 +260,12 @@ impl SpotClient { let signature = auth::spot_sign(&uri_path, nonce, &json_str, &creds.api_secret)?; - if verbose { - crate::output::verbose(&format!("POST {url} [json] (nonce={nonce})")); - } + tracing::debug!(method = "POST", %url, nonce, content_type = "json", "request"); let mut headers = HeaderMap::new(); headers.insert( "API-Key", - HeaderValue::from_str(&creds.api_key) + HeaderValue::from_str(creds.api_key.expose_secret()) .map_err(|e| KrakenError::Auth(format!("Invalid API key header: {e}")))?, ); headers.insert( @@ -293,24 +290,27 @@ impl SpotClient { if idempotent && status.is_server_error() && attempt < MAX_RETRIES { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Server error {status}, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + %status, + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "server error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; continue; } - return self.parse_spot_response(r, verbose).await; + return self.parse_spot_response(r, BodyLog::SizeOnly).await; } Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -318,18 +318,16 @@ impl SpotClient { } } - /// Execute a private POST that returns raw bytes (for export retrieval). - /// Supports OTP, retry on transient errors, and error-envelope detection. + /// Private POST returning raw bytes, for export retrieval. /// /// See `private_post` for the `idempotent` parameter semantics. pub async fn private_post_raw( &self, endpoint: &str, params: HashMap, - creds: &SpotCredentials, - otp: Option<&str>, + creds: &Credentials, + otp: Option<&SecretString>, idempotent: bool, - verbose: bool, ) -> Result> { let uri_path = format!("/0/private/{}", endpoint); let url = format!("{}{}", self.base_url, uri_path); @@ -340,7 +338,7 @@ impl SpotClient { let mut attempt_params = params.clone(); attempt_params.insert("nonce".to_string(), nonce.to_string()); if let Some(otp_val) = otp { - attempt_params.insert("otp".to_string(), otp_val.to_string()); + attempt_params.insert("otp".to_string(), otp_val.expose_secret().to_string()); } let post_data = url::form_urlencoded::Serializer::new(String::new()) @@ -348,14 +346,12 @@ impl SpotClient { .finish(); let signature = auth::spot_sign(&uri_path, nonce, &post_data, &creds.api_secret)?; - if verbose { - crate::output::verbose(&format!("POST {url} (raw download, nonce={nonce})")); - } + tracing::debug!(method = "POST", %url, nonce, raw = true, "request"); let mut headers = HeaderMap::new(); headers.insert( "API-Key", - HeaderValue::from_str(&creds.api_key) + HeaderValue::from_str(creds.api_key.expose_secret()) .map_err(|e| KrakenError::Auth(format!("Invalid API key header: {e}")))?, ); headers.insert( @@ -379,15 +375,20 @@ impl SpotClient { if idempotent && status.is_server_error() && attempt < MAX_RETRIES { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Server error {status}, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + %status, + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "server error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; continue; } let bytes = r.bytes().await?; + // Raw export payloads are account/financial data — never log the + // body, only its size. + tracing::trace!(%status, body_bytes = bytes.len(), "response (raw, body omitted)"); if let Some(err) = parse_spot_error_from_body_bytes(&bytes) { return Err(err); @@ -405,11 +406,12 @@ impl SpotClient { Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -417,19 +419,22 @@ impl SpotClient { } } - async fn parse_spot_response(&self, resp: reqwest::Response, verbose: bool) -> Result { + async fn parse_spot_response( + &self, + resp: reqwest::Response, + body_log: BodyLog, + ) -> Result { let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - if verbose { - crate::output::verbose(&format!("Response {status}: {}", truncate(&body, 500))); - } - if let Ok(parsed) = serde_json::from_str::(&body) { - if let Some(errors) = parsed.get("error").and_then(|e| e.as_array()) { - let errs: Vec<&str> = errors.iter().filter_map(|e| e.as_str()).collect(); - if !errs.is_empty() { - return Err(KrakenError::from_kraken_error(errs[0])); - } + tracing::debug!(%status, "error response"); + trace_response_body(status, &body, body_log); + if let Ok(parsed) = serde_json::from_str::(&body) + && let Some(errors) = parsed.get("error").and_then(|e| e.as_array()) + { + let errs: Vec<&str> = errors.iter().filter_map(|e| e.as_str()).collect(); + if !errs.is_empty() { + return Err(KrakenError::from_kraken_error(errs[0])); } } return Err(KrakenError::Network(format!( @@ -440,9 +445,8 @@ impl SpotClient { let body = resp.text().await?; - if verbose { - crate::output::verbose(&format!("Response {status}: {}", truncate(&body, 500))); - } + tracing::debug!(%status, "response"); + trace_response_body(status, &body, body_log); let parsed: Value = serde_json::from_str(&body)?; @@ -461,27 +465,99 @@ impl SpotClient { } } +/// Whether a response body may be written to a trace log. +/// +/// Authenticated/private endpoints return account and financial data (balances, +/// orders, trade history) that must never be logged, so those responses log only +/// their status and byte length — the same treatment the raw export/CSV paths +/// already apply. Public market-data endpoints carry no user data, so their +/// (token-redacted, truncated) body may be logged for diagnostics. +#[derive(Clone, Copy, PartialEq, Eq)] +enum BodyLog { + /// Public endpoint: log the redacted, truncated body at `trace`. + Full, + /// Private/authenticated endpoint: log only status and byte length. + SizeOnly, +} + +/// Emit a response body to a `trace` log according to `body_log`. Never logs the +/// body of a private/authenticated response, so no account or financial data can +/// reach the logs even when `kraken_cli::client=trace` is enabled. +fn trace_response_body(status: reqwest::StatusCode, body: &str, body_log: BodyLog) { + match body_log { + BodyLog::Full => tracing::trace!( + %status, + body = %truncate(redact_log_body(body).as_ref(), 500), + "response body" + ), + BodyLog::SizeOnly => tracing::trace!( + %status, + body_bytes = body.len(), + "response body (private, body omitted)" + ), + } +} + +/// Mask the value of every `"token"` field in a JSON response body before it is +/// written to a trace log. WebSocket tokens (from `GetWebSocketsToken`) are live +/// credentials and must never be logged, even partially. Returns the body unchanged +/// when it is not JSON (e.g. an HTML/error page, which carries no token) or holds no +/// token, so token-free responses log byte-for-byte as before. +/// +/// Note: this masks only `"token"` fields, not arbitrary account/financial data. +/// That is why response bodies are logged at `trace!` only and never at `debug!`. +fn redact_log_body(body: &str) -> std::borrow::Cow<'_, str> { + if let Ok(mut value) = serde_json::from_str::(body) + && redact_token_fields(&mut value) + { + return std::borrow::Cow::Owned(value.to_string()); + } + std::borrow::Cow::Borrowed(body) +} + +/// Recursively replace every `"token"` field value with ``. Returns +/// whether anything was redacted so [`redact_log_body`] can leave token-free bodies +/// untouched (and unallocated). +fn redact_token_fields(value: &mut Value) -> bool { + let mut changed = false; + match value { + Value::Object(map) => { + for (key, val) in map.iter_mut() { + if key == "token" { + *val = Value::String("".to_string()); + changed = true; + } else if redact_token_fields(val) { + changed = true; + } + } + } + Value::Array(items) => { + for val in items.iter_mut() { + if redact_token_fields(val) { + changed = true; + } + } + } + _ => {} + } + changed +} + impl FuturesClient { - /// Create a new Futures client. - pub fn new(base_url: Option<&str>) -> Result { + /// `accept_invalid_certs` (resolved once in `AppContext::from_cli`) + /// controls whether TLS verification is disabled. + pub fn new(base_url: Option<&str>, accept_invalid_certs: bool) -> Result { Ok(Self { - http: build_http_client()?, + http: build_http_client(accept_invalid_certs)?, base_url: base_url.unwrap_or(DEFAULT_FUTURES_URL).to_string(), }) } /// Execute a public GET request on the Futures API, with retry on /// transient errors and 5xx server errors (GET is inherently idempotent). - pub async fn public_get( - &self, - endpoint: &str, - params: &[(&str, &str)], - verbose: bool, - ) -> Result { + pub async fn public_get(&self, endpoint: &str, params: &[(&str, &str)]) -> Result { let url = format!("{}/{}", self.base_url, endpoint); - if verbose { - crate::output::verbose(&format!("GET {url}")); - } + tracing::debug!(method = "GET", %url, "request"); let mut attempt = 0u32; loop { @@ -491,22 +567,25 @@ impl FuturesClient { let status = r.status(); attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Server error {status}, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + %status, + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "server error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } - Ok(r) => return self.parse_futures_response(r, verbose).await, + Ok(r) => return self.parse_futures_response(r, BodyLog::Full).await, Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -520,8 +599,7 @@ impl FuturesClient { &self, endpoint: &str, params: &[(&str, &str)], - creds: &FuturesCredentials, - verbose: bool, + creds: &Credentials, ) -> Result { let url = format!("{}/{}", self.base_url, endpoint); let endpoint_path = format!("/api/v3/{}", endpoint); @@ -531,15 +609,13 @@ impl FuturesClient { let nonce = auth::generate_nonce()?.to_string(); let authent = auth::futures_sign("", &nonce, &endpoint_path, &creds.api_secret)?; - if verbose { - crate::output::verbose(&format!("GET {url} (authenticated)")); - } + tracing::debug!(method = "GET", %url, authenticated = true, "request"); let resp = self .http .get(&url) .query(params) - .header("APIKey", &creds.api_key) + .header("APIKey", creds.api_key.expose_secret()) .header("Nonce", &nonce) .header("Authent", &authent) .send() @@ -550,22 +626,25 @@ impl FuturesClient { let status = r.status(); attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Server error {status}, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + %status, + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "server error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } - Ok(r) => return self.parse_futures_response(r, verbose).await, + Ok(r) => return self.parse_futures_response(r, BodyLog::SizeOnly).await, Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -573,13 +652,13 @@ impl FuturesClient { } } - /// Execute a private POST on the Futures API with auth headers. + /// Unlike the GETs, 5xx responses are never retried here: a mutating + /// request may already have been processed server-side (e.g. order placed). pub(crate) async fn private_post( &self, endpoint: &str, params: HashMap, - creds: &FuturesCredentials, - verbose: bool, + creds: &Credentials, ) -> Result { let url = format!("{}/{}", self.base_url, endpoint); let endpoint_path = format!("/api/v3/{}", endpoint); @@ -594,18 +673,19 @@ impl FuturesClient { let authent = auth::futures_sign(&post_data, &nonce, &endpoint_path, &creds.api_secret)?; - if verbose { - crate::output::verbose(&format!("POST {url} (authenticated)")); - crate::output::verbose(&format!( - "Request body: ({} bytes)", - post_data.len() - )); - } + // Body may contain order parameters (financial data) — log only its size. + tracing::debug!( + method = "POST", + %url, + authenticated = true, + body_bytes = post_data.len(), + "request" + ); let resp = self .http .post(&url) - .header("APIKey", &creds.api_key) + .header("APIKey", creds.api_key.expose_secret()) .header("Nonce", &nonce) .header("Authent", &authent) .header("Content-Type", "application/x-www-form-urlencoded") @@ -614,15 +694,16 @@ impl FuturesClient { .await; match resp { - Ok(r) => return self.parse_futures_response(r, verbose).await, + Ok(r) => return self.parse_futures_response(r, BodyLog::SizeOnly).await, Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -639,23 +720,21 @@ impl FuturesClient { &self, endpoint: &str, body: serde_json::Value, - creds: &FuturesCredentials, - verbose: bool, + creds: &Credentials, ) -> Result { let json_str = serde_json::to_string(&body) .map_err(|e| KrakenError::Parse(format!("Failed to serialize JSON body: {e}")))?; let mut params = HashMap::new(); params.insert("json".to_string(), json_str); - self.private_post(endpoint, params, creds, verbose).await + self.private_post(endpoint, params, creds).await } - /// Execute a private PUT on the Futures API with form-urlencoded body. + /// 5xx is never retried, for the same reason as `private_post`. pub(crate) async fn private_put( &self, endpoint: &str, params: HashMap, - creds: &FuturesCredentials, - verbose: bool, + creds: &Credentials, ) -> Result { let url = format!("{}/{}", self.base_url, endpoint); let endpoint_path = format!("/api/v3/{}", endpoint); @@ -670,18 +749,18 @@ impl FuturesClient { let authent = auth::futures_sign(&post_data, &nonce, &endpoint_path, &creds.api_secret)?; - if verbose { - crate::output::verbose(&format!("PUT {url} (authenticated)")); - crate::output::verbose(&format!( - "Request body: ({} bytes)", - post_data.len() - )); - } + tracing::debug!( + method = "PUT", + %url, + authenticated = true, + body_bytes = post_data.len(), + "request" + ); let resp = self .http .put(&url) - .header("APIKey", &creds.api_key) + .header("APIKey", creds.api_key.expose_secret()) .header("Nonce", &nonce) .header("Authent", &authent) .header("Content-Type", "application/x-www-form-urlencoded") @@ -690,15 +769,16 @@ impl FuturesClient { .await; match resp { - Ok(r) => return self.parse_futures_response(r, verbose).await, + Ok(r) => return self.parse_futures_response(r, BodyLog::SizeOnly).await, Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -711,8 +791,7 @@ impl FuturesClient { &self, endpoint: &str, params: &[(&str, &str)], - creds: &FuturesCredentials, - verbose: bool, + creds: &Credentials, ) -> Result { let url = format!("{}/{}", self.base_url, endpoint); let endpoint_path = format!("/api/v3/{}", endpoint); @@ -722,15 +801,13 @@ impl FuturesClient { let nonce = auth::generate_nonce()?.to_string(); let authent = auth::futures_sign("", &nonce, &endpoint_path, &creds.api_secret)?; - if verbose { - crate::output::verbose(&format!("GET {url} (raw, authenticated)")); - } + tracing::debug!(method = "GET", %url, raw = true, authenticated = true, "request"); let resp = self .http .get(&url) .query(params) - .header("APIKey", &creds.api_key) + .header("APIKey", creds.api_key.expose_secret()) .header("Nonce", &nonce) .header("Authent", &authent) .send() @@ -741,22 +818,21 @@ impl FuturesClient { let status = r.status(); attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Server error {status}, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + %status, + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "server error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Ok(r) => { let status = r.status(); let body = r.text().await?; - if verbose { - crate::output::verbose(&format!( - "Response {status}: {}", - truncate(&body, 500) - )); - } + // Raw CSV (e.g. account log) is unredacted financial data — + // never log the body, only its size. + tracing::trace!(%status, body_bytes = body.len(), "response (raw csv, body omitted)"); if let Some(err) = parse_futures_error_from_body(&body) { return Err(err); } @@ -771,11 +847,12 @@ impl FuturesClient { Err(e) if is_transient(&e) && attempt < MAX_RETRIES => { attempt += 1; let backoff = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1); - if verbose { - crate::output::verbose(&format!( - "Transient error, retry {attempt}/{MAX_RETRIES} after {backoff}ms" - )); - } + tracing::warn!( + attempt, + max = MAX_RETRIES, + backoff_ms = backoff, + "transient error; retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } Err(e) => return Err(e.into()), @@ -786,14 +863,13 @@ impl FuturesClient { async fn parse_futures_response( &self, resp: reqwest::Response, - verbose: bool, + body_log: BodyLog, ) -> Result { let status = resp.status(); let body = resp.text().await?; - if verbose { - crate::output::verbose(&format!("Response {status}: {}", truncate(&body, 500))); - } + tracing::debug!(%status, "response"); + trace_response_body(status, &body, body_log); if !status.is_success() { if let Some(err) = parse_futures_error_from_body(&body) { @@ -807,10 +883,11 @@ impl FuturesClient { let parsed: Value = serde_json::from_str(&body)?; - if let Some(err) = parsed.get("error").and_then(|e| e.as_str()) { - if !err.is_empty() && err != "success" { - return Err(KrakenError::from_kraken_error(err)); - } + if let Some(err) = parsed.get("error").and_then(|e| e.as_str()) + && !err.is_empty() + && err != "success" + { + return Err(KrakenError::from_kraken_error(err)); } Ok(parsed) @@ -866,15 +943,8 @@ fn is_trusted_override_host(host: &str) -> bool { || host_lc == "ws.kraken.com" || host_lc == "ws-auth.kraken.com" || host_lc == "ws-l3.kraken.com" -} - -fn danger_allow_any_url_host() -> bool { - env::var(DANGER_ALLOW_ANY_URL_HOST_ENV) - .map(|v| { - let v_lc = v.to_ascii_lowercase(); - matches!(v_lc.as_str(), "1" | "true" | "yes") - }) - .unwrap_or(false) + || host_lc == "cli-feedback.kraken.com" + || host_lc == "cli-feedback.uat.kraken.com" } /// Validate that a URL uses a secure scheme (`https` or `wss`). @@ -908,16 +978,16 @@ pub(crate) fn validate_url_scheme(url: &str) -> Result<()> { } } -/// Resolve a URL override with CLI flag > env var > default precedence. +/// Validate a resolved URL override (secure scheme + trusted host). /// -/// When a user-provided value is present (CLI flag or env var), it is -/// validated for secure scheme before returning. When both are absent, -/// `None` is returned so the downstream constructor uses its built-in default. -pub fn resolve_url_override( - cli_flag: Option<&str>, - env_value: Option<&str>, -) -> Result> { - match cli_flag.or(env_value) { +/// clap applies the flag > env precedence upstream (via `#[arg(env = ...)]`), +/// so this receives the already-merged value. `allow_any_host` (resolved once +/// in `AppContext::from_cli`) relaxes the trusted-host check. When a value is +/// present it is validated for secure scheme and trusted host before returning; +/// when absent, `None` is returned so the downstream constructor uses its +/// built-in default. +pub fn resolve_url_override(value: Option<&str>, allow_any_host: bool) -> Result> { + match value { Some(url) => { validate_url_scheme(url)?; let parsed = url::Url::parse(url)?; @@ -925,11 +995,11 @@ pub fn resolve_url_override( KrakenError::Validation("URL override must include a host".to_string()) })?; if !is_trusted_override_host(host) { - if danger_allow_any_url_host() { - crate::output::warn(&format!( - "Using non-Kraken URL host '{host}' because \ - {DANGER_ALLOW_ANY_URL_HOST_ENV} is enabled." - )); + if allow_any_host { + tracing::warn!( + %host, + "using non-Kraken URL host because {DANGER_ALLOW_ANY_URL_HOST_ENV} is enabled" + ); } else { return Err(KrakenError::Validation(format!( "Untrusted URL host '{host}'. Allowed hosts are production Kraken endpoints. \ @@ -943,10 +1013,130 @@ pub fn resolve_url_override( } } +/// Live token source for the WS clients: signs `GetWebSocketsToken` with the spot +/// REST client. Constructed by [`AppContext::ws_config`](crate::cli::AppContext). +pub(crate) struct SpotTokenSource { + client: std::sync::Arc, + credentials: std::sync::Arc, + /// The one-time password from `--otp`, consumed by the first mint. A TOTP is valid for + /// seconds, so replaying it on a reconnect re-mint hours later can only fail — and the + /// server's "otp required" on the retry names the real problem, where a replayed stale + /// code reads as a bad key. `std::sync::Mutex`: taken and released before any await. + otp: std::sync::Mutex>, +} + +impl SpotTokenSource { + pub(crate) fn new( + client: std::sync::Arc, + credentials: std::sync::Arc, + otp: Option, + ) -> Self { + Self { + client, + credentials, + otp: std::sync::Mutex::new(otp), + } + } +} + +impl TokenSource for SpotTokenSource { + fn fetch(&self) -> BoxFuture> { + // Clone the cheap handles into the future so it owns its captures and stays + // `'static`, borrowing none of `self`. + let client = self.client.clone(); + let credentials = self.credentials.clone(); + let otp = self + .otp + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + Box::pin(async move { + tracing::debug!(endpoint = "GetWebSocketsToken", "minting ws token"); + let resp = client + .private_post( + "GetWebSocketsToken", + HashMap::new(), + &credentials, + otp.as_ref(), + true, + ) + .await + .map_err(map_token_error)?; + let token = resp + .get("token") + .and_then(|t| t.as_str()) + .map(str::to_string) + .ok_or_else(|| WsError::TokenRejected("response had no token field".to_string()))?; + // Server-stated lifetime; the cache prefers it over its fallback. + let expires_in = resp + .get("expires") + .and_then(serde_json::Value::as_u64) + .map(std::time::Duration::from_secs); + Ok(Minted { token, expires_in }) + }) + } +} + +/// Map a REST error from the token mint into the WS error type: an auth rejection becomes +/// [`WsError::TokenRejected`], and anything else (rate limit, network, IO, parse) is carried +/// whole so its category survives to the envelope instead of collapsing into `auth`. +fn map_token_error(err: KrakenError) -> WsError { + match err { + // Carry the *raw* reason, not the `Display` form. `WsError::TokenRejected` is re-wrapped + // into `KrakenError::Auth` at the WS boundary, which re-adds the "Authentication failed: " + // prefix — so stringifying an already-`Auth` error here would double it + // ("Authentication failed: Authentication failed: EAPI:Invalid key"). + KrakenError::Auth(reason) => WsError::TokenRejected(reason), + other => WsError::MintFailed(Box::new(other)), + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn an_auth_failure_is_not_double_prefixed() { + // Regression guard: an Auth failure is carried by its raw reason, not its Display + // form. WsError::TokenRejected re-wraps to KrakenError::Auth, whose Display adds + // "Authentication failed: " — stringifying here first would render it twice. + let mapped = map_token_error(KrakenError::Auth("EAPI:Invalid key".into())); + assert!(matches!(&mapped, WsError::TokenRejected(reason) if reason == "EAPI:Invalid key")); + let rendered = KrakenError::from(mapped).to_string(); + assert_eq!( + rendered.matches("Authentication failed:").count(), + 1, + "got: {rendered}" + ); + } + + #[test] + fn a_throttled_mint_keeps_its_rate_limit_category() { + // A throttled mint rides MintFailed with its category intact, so the envelope + // says `rate_limit` (retryable) rather than degrading to `api` or `auth`. + let mapped = map_token_error(KrakenError::from_kraken_error("EOrder:Rate limit exceeded")); + let rendered = KrakenError::from(mapped); + assert_eq!( + rendered.category(), + crate::errors::ErrorCategory::RateLimit, + "{rendered}" + ); + } + + #[test] + fn a_network_mint_failure_keeps_its_category() { + // A transient network error during the mint must round-trip with its own category — + // automation treats `auth` as non-retryable (re-key) but `network` as retryable, so + // collapsing it would send callers down the wrong repair path. + let mapped = map_token_error(KrakenError::Network("connection reset".into())); + let rendered = KrakenError::from(mapped); + assert_eq!( + rendered.category(), + crate::errors::ErrorCategory::Network, + "{rendered}" + ); + } + #[test] fn validate_url_scheme_accepts_https() { assert!(validate_url_scheme("https://api.kraken.com").is_ok()); @@ -1007,42 +1197,147 @@ mod tests { } #[test] - fn resolve_url_override_cli_over_env() { - let result = resolve_url_override( - Some("https://api.kraken.com"), - Some("https://futures.kraken.com"), - ) - .unwrap(); + fn resolve_url_override_accepts_trusted_spot_host() { + let result = resolve_url_override(Some("https://api.kraken.com"), false).unwrap(); assert_eq!(result, Some("https://api.kraken.com".to_string())); } #[test] - fn resolve_url_override_env_over_default() { - let result = resolve_url_override(None, Some("https://futures.kraken.com")).unwrap(); + fn resolve_url_override_accepts_trusted_futures_host() { + let result = resolve_url_override(Some("https://futures.kraken.com"), false).unwrap(); assert_eq!(result, Some("https://futures.kraken.com".to_string())); } #[test] - fn resolve_url_override_default_when_both_absent() { - let result = resolve_url_override(None, None).unwrap(); + fn resolve_url_override_none_when_absent() { + let result = resolve_url_override(None, false).unwrap(); assert_eq!(result, None); } #[test] - fn resolve_url_override_rejects_insecure_env() { - let err = resolve_url_override(None, Some("http://api.kraken.com")).unwrap_err(); + fn resolve_url_override_rejects_insecure_scheme() { + let err = resolve_url_override(Some("http://api.kraken.com"), false).unwrap_err(); assert!(err.to_string().contains("Insecure")); } #[test] - fn resolve_url_override_rejects_insecure_cli() { - let err = resolve_url_override(Some("http://futures.kraken.com"), None).unwrap_err(); - assert!(err.to_string().contains("Insecure")); + fn resolve_url_override_rejects_untrusted_host_by_default() { + let err = resolve_url_override(Some("https://evil.example.com"), false).unwrap_err(); + assert!(err.to_string().contains("Untrusted URL host")); + } + + #[test] + fn resolve_url_override_allows_untrusted_host_when_opted_in() { + let result = resolve_url_override(Some("https://evil.example.com"), true).unwrap(); + assert_eq!(result, Some("https://evil.example.com".to_string())); + } + + #[test] + fn resolve_url_override_accepts_feedback_host() { + let result = + resolve_url_override(Some("https://cli-feedback.kraken.com/v1/feedback"), false) + .unwrap(); + assert_eq!( + result.as_deref(), + Some("https://cli-feedback.kraken.com/v1/feedback") + ); } #[test] fn resolve_url_override_accepts_prod_ws_auth_host() { - let result = resolve_url_override(Some("wss://ws-auth.kraken.com/v2"), None).unwrap(); + let result = resolve_url_override(Some("wss://ws-auth.kraken.com/v2"), false).unwrap(); assert_eq!(result, Some("wss://ws-auth.kraken.com/v2".to_string())); } + + #[test] + fn redact_log_body_masks_token_field() { + // The GetWebSocketsToken response shape — the token must never reach a log. + let body = r#"{"error":[],"result":{"expires":900,"token":"LIVE-WS-TOKEN-VALUE"}}"#; + let out = redact_log_body(body); + assert!(!out.contains("LIVE-WS-TOKEN-VALUE")); + assert!(out.contains(r#""token":"""#)); + } + + #[test] + fn redact_log_body_leaves_token_free_response_unchanged() { + // No token field -> borrowed (no allocation), byte-for-byte identical. + let body = r#"{"error":[],"result":{"open":{}}}"#; + let out = redact_log_body(body); + assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + assert_eq!(out.as_ref(), body); + } + + #[test] + fn redact_log_body_passes_through_non_json() { + let body = "503 Service Unavailable"; + assert_eq!(redact_log_body(body).as_ref(), body); + } + + /// Capture everything emitted at `TRACE` while `f` runs. Uses a thread-local + /// subscriber (`with_default`) so tests running in parallel don't interfere. + fn capture_trace(f: impl FnOnce()) -> String { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + use tracing_subscriber::fmt::MakeWriter; + + #[derive(Clone)] + struct Buf(Arc>>); + impl Write for Buf { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl<'a> MakeWriter<'a> for Buf { + type Writer = Buf; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + let sink = Buf(Arc::new(Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .with_writer(sink.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, f); + let bytes = sink.0.lock().unwrap().clone(); + String::from_utf8(bytes).expect("log output is valid UTF-8") + } + + #[test] + fn private_response_body_is_never_logged() { + // A Balance response carries account/financial data — the SizeOnly policy + // must log only the byte length, never the body itself. + let body = r#"{"error":[],"result":{"ZUSD":"123456.7890"}}"#; + let logged = capture_trace(|| { + trace_response_body(reqwest::StatusCode::OK, body, BodyLog::SizeOnly); + }); + assert!( + !logged.contains("123456.7890"), + "private body leaked into trace logs: {logged}" + ); + assert!( + logged.contains("body_bytes"), + "expected byte-length field, got: {logged}" + ); + } + + #[test] + fn public_response_body_is_logged_for_diagnostics() { + // Public market data carries no user data, so its body is logged at trace. + let body = r#"{"error":[],"result":{"XXBTZUSD":{"c":["50000.0","1.0"]}}}"#; + let logged = capture_trace(|| { + trace_response_body(reqwest::StatusCode::OK, body, BodyLog::Full); + }); + assert!( + logged.contains("50000.0"), + "public body should be logged for diagnostics, got: {logged}" + ); + } } diff --git a/src/commands/account.rs b/src/commands/account.rs index 3385699..6856c47 100644 --- a/src/commands/account.rs +++ b/src/commands/account.rs @@ -1,648 +1,847 @@ -/// Private account data commands (balance, orders, history, exports). +//! Private account data commands. + use std::collections::HashMap; use std::path::PathBuf; use clap::Subcommand; +use enum_dispatch::enum_dispatch; use serde_json::Value; -use super::helpers::jstr; -use crate::client::SpotClient; -use crate::config::SpotCredentials; +use super::Execute; +use super::schema; +use super::value_types::{ + AssetClass, CliWire, Closetime, L3Depth, Pagination, RebaseOpts, ReportFormat, ReportType, + TradeType, +}; +use crate::cli::AppContext; use crate::errors::Result; use crate::output::CommandOutput; +use crate::output::json_field; +#[enum_dispatch(Execute)] #[derive(Debug, Subcommand)] pub(crate) enum AccountCommand { /// Get all cash balances. - Balance { - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + Balance(Balance), /// Get extended balances including credits and held amounts. - ExtendedBalance, + ExtendedBalance(ExtendedBalance), + /// Get L3 (per-order) order book via the authenticated Level3 endpoint. + /// + /// Authenticated market data: it needs credentials, so it lives with the + /// account handlers rather than the unauthenticated `market` path. + OrderbookL3(OrderbookL3), /// Get credit line details (VIP only). - CreditLines { - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + CreditLines(CreditLines), /// Get margin/equity trade balance summary. - TradeBalance { - /// Base asset for balance calculation (default: ZUSD). - #[arg(long)] - asset: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + TradeBalance(TradeBalance), /// Get currently open orders. - OpenOrders { - /// Include trade details. - #[arg(long)] - trades: bool, - /// Filter by user reference ID. - #[arg(long)] - userref: Option, - /// Filter by client order ID. - #[arg(long)] - cl_ord_id: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + OpenOrders(OpenOrders), /// Get closed orders. - ClosedOrders { - /// Include trade details. - #[arg(long)] - trades: bool, - /// Start unix timestamp or order tx ID (exclusive). - #[arg(long)] - start: Option, - /// End unix timestamp or order tx ID (inclusive). - #[arg(long)] - end: Option, - /// Result offset for pagination. - #[arg(long)] - offset: Option, - /// User reference filter. - #[arg(long)] - userref: Option, - /// Filter by client order ID. - #[arg(long)] - cl_ord_id: Option, - /// Which time to use (open, close, both). - #[arg(long)] - closetime: Option, - /// Consolidate trades by individual taker trades. - #[arg(long)] - consolidate_taker: bool, - /// Omit page count from result (faster for large histories). - #[arg(long)] - without_count: bool, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + ClosedOrders(ClosedOrders), /// Query specific orders by TXID. - QueryOrders { - /// Transaction IDs (comma-delimited, up to 50). - txids: Vec, - /// Include trade details. - #[arg(long)] - trades: bool, - /// User reference filter. - #[arg(long)] - userref: Option, - /// Consolidate taker trades. - #[arg(long)] - consolidate_taker: bool, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + QueryOrders(QueryOrders), /// Get trade history. - TradesHistory { - /// Trade type filter (all, any position, closed position, closing position, no position). - #[arg(long = "type")] - trade_type: Option, - /// Starting unix timestamp or trade tx ID (exclusive). - #[arg(long)] - start: Option, - /// Ending unix timestamp or trade tx ID (inclusive). - #[arg(long)] - end: Option, - /// Include trades related to position in output. - #[arg(long)] - trades: bool, - /// Result offset for pagination. - #[arg(long)] - offset: Option, - /// Consolidate trades by individual taker trades. - #[arg(long)] - consolidate_taker: bool, - /// Omit count from result (faster for large histories). - #[arg(long)] - without_count: bool, - /// Include related ledger IDs for each trade (slower). - #[arg(long)] - ledgers: bool, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + TradesHistory(TradesHistory), /// Query specific trades by TXID. - QueryTrades { - /// Transaction IDs (comma-delimited, up to 20). - txids: Vec, - /// Include trades related to position in output. - #[arg(long)] - trades: bool, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + QueryTrades(QueryTrades), /// Get open margin positions. - Positions { - /// Filter by TXIDs. - #[arg(long)] - txid: Vec, - /// Include P&L calculations. - #[arg(long)] - show_pnl: bool, - /// Consolidate positions by market/pair. - #[arg(long)] - consolidation: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + Positions(Positions), /// Get ledger entries. - Ledgers { - /// Filter by asset or comma-delimited list of assets. - #[arg(long)] - asset: Option, - /// Type of ledger to retrieve. - #[arg(long = "type")] - ledger_type: Option, - /// Starting unix timestamp or ledger ID (exclusive). - #[arg(long)] - start: Option, - /// Ending unix timestamp or ledger ID (inclusive). - #[arg(long)] - end: Option, - /// Filter by asset class. - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Result offset for pagination. - #[arg(long)] - offset: Option, - /// Omit count from result (faster for large ledgers). - #[arg(long)] - without_count: bool, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + Ledgers(Ledgers), /// Query specific ledger entries. - QueryLedgers { - /// Ledger IDs (comma-delimited, up to 20). - ids: Vec, - /// Include trades related to position in output. - #[arg(long)] - trades: bool, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + QueryLedgers(QueryLedgers), /// Get trade volume and fee info. - Volume { - /// Comma-delimited list of asset pairs for fee info. - #[arg(long)] - pair: Vec, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + Volume(Volume), /// Request an export report. - ExportReport { - /// Type of data to export (trades or ledgers). - #[arg(long)] - report: String, - /// Description for the export. - #[arg(long)] - description: String, - /// File format (CSV or TSV). - #[arg(long, default_value = "CSV")] - format: String, - /// Comma-delimited list of fields to include (default: all). - #[arg(long)] - fields: Option, - /// UNIX timestamp for report start time (default: 1st of current month). - #[arg(long)] - starttm: Option, - /// UNIX timestamp for report end time (default: now). - #[arg(long)] - endtm: Option, - }, + ExportReport(ExportReport), /// Check export report status. - ExportStatus { - /// Report type (trades or ledgers). - #[arg(long)] - report: String, - }, + ExportStatus(ExportStatus), /// Download an export report. - ExportRetrieve { - /// Report ID to retrieve. - report_id: String, - /// Output file path for the downloaded report. - #[arg(long)] - output_file: Option, - }, + ExportRetrieve(ExportRetrieve), /// Delete an export report. - ExportDelete { - /// Report ID to delete. - report_id: String, - }, -} - -pub(crate) async fn execute( - cmd: &AccountCommand, - client: &SpotClient, - creds: &SpotCredentials, - otp: Option<&str>, - verbose: bool, -) -> Result { - match cmd { - AccountCommand::Balance { rebase_multiplier } => { - let mut params = HashMap::new(); - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("Balance", params, creds, otp, true, verbose) - .await?; - let (headers, rows) = parse_balance_table(&data); - Ok(CommandOutput::new(data, headers, rows)) - } - AccountCommand::ExtendedBalance => { - let data = client - .private_post("BalanceEx", HashMap::new(), creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) - } - AccountCommand::CreditLines { rebase_multiplier } => { - let mut params = HashMap::new(); - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("CreditLines", params, creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) + ExportDelete(ExportDelete), +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Balance { + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Balance { + /// The plain venue view, for `workspace balance` addressing the real + /// account: no rebase, exactly the bare `kraken balance` request. + pub(crate) fn plain() -> Self { + Self { + rebase: RebaseOpts { + rebase_multiplier: None, + }, } - AccountCommand::TradeBalance { - asset, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if let Some(a) = asset { - params.insert("asset".into(), a.clone()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("TradeBalance", params, creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) + } +} + +impl OpenOrders { + /// Venue-only filters have no paper meaning; refuse rather than ignore. + fn ensure_paper_plain(&self, manifest: &kraken_workspace::WorkspaceManifest) -> Result<()> { + let set: &[(&str, bool)] = &[ + ("open-orders --trades", self.trades), + ("open-orders --userref", self.userref.is_some()), + ("open-orders --cl-ord-id", self.cl_ord_id.is_some()), + ( + "open-orders --rebase-multiplier", + self.rebase.rebase_multiplier.is_some(), + ), + ]; + ensure_no_venue_only_flags(set, manifest) + } +} + +impl TradesHistory { + /// Venue-only filters have no paper meaning; refuse rather than ignore. + fn ensure_paper_plain(&self, manifest: &kraken_workspace::WorkspaceManifest) -> Result<()> { + let set: &[(&str, bool)] = &[ + ("trades-history --type", self.trade_type.is_some()), + ("trades-history --trades", self.trades), + ("trades-history --consolidate-taker", self.consolidate_taker), + ("trades-history --ledgers", self.ledgers), + ("trades-history --start", self.page.is_bounded()), + ( + "trades-history --rebase-multiplier", + self.rebase.rebase_multiplier.is_some(), + ), + ]; + ensure_no_venue_only_flags(set, manifest) + } +} + +fn ensure_no_venue_only_flags( + set: &[(&str, bool)], + manifest: &kraken_workspace::WorkspaceManifest, +) -> Result<()> { + if let Some((flag, _)) = set.iter().find(|(_, on)| *on) { + return Err( + kraken_workspace::policy::unsupported(flag, &manifest.name, manifest.mode).into(), + ); + } + Ok(()) +} + +impl Execute for Balance { + async fn execute(self, ctx: &AppContext) -> Result { + match super::workspace_guard::active_mode(ctx)? { + super::workspace_guard::ActiveMode::Paper(manifest) => { + super::workspace::Balance { + name: Some(manifest.name), + } + .execute(ctx) + .await + } + super::workspace_guard::ActiveMode::Live(manifest) => { + Err(kraken_workspace::WorkspaceError::LiveUnavailable { + name: manifest.name, + } + .into()) + } + super::workspace_guard::ActiveMode::Master => self.venue(ctx).await, + } + } +} + +impl Balance { + /// The unrouted venue request — also the `workspace balance` default + /// target, which is the master account by definition. + pub(crate) async fn venue(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { rebase } = self; + let mut params = HashMap::new(); + rebase.apply(&mut params); + let mut data = client + .private_post("Balance", params, creds, otp, true) + .await?; + schema::balance(&mut data); + let (headers, rows) = parse_balance_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct ExtendedBalance {} + +impl Execute for ExtendedBalance { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let mut data = client + .private_post("BalanceEx", HashMap::new(), creds, otp, true) + .await?; + schema::extended_balance(&mut data); + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct OrderbookL3 { + /// Trading pair. + pair: String, + /// Number of price levels per side (0 for full book). + #[arg(long, default_value = "100")] + depth: L3Depth, +} + +impl Execute for OrderbookL3 { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { pair, depth } = self; + let mut body = serde_json::Map::new(); + body.insert("pair".into(), Value::String(pair)); + body.insert("depth".into(), Value::Number(depth.as_u64().into())); + let data = client + .private_post_json("Level3", body, creds, otp, true) + .await?; + Ok(CommandOutput::new( + data, + vec!["Data".into()], + vec![vec!["L3 orderbook data returned (see JSON output)".into()]], + )) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct CreditLines { + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for CreditLines { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { rebase } = self; + let mut params = HashMap::new(); + rebase.apply(&mut params); + let data = client + .private_post("CreditLines", params, creds, otp, true) + .await?; + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct TradeBalance { + /// Base asset for balance calculation (default: ZUSD). + #[arg(long)] + asset: Option, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl TradeBalance { + /// The plain venue view, for `workspace status` addressing the real + /// account: default asset, no rebase. + pub(crate) fn plain() -> Self { + Self { + asset: None, + rebase: RebaseOpts { + rebase_multiplier: None, + }, + } + } +} + +impl Execute for TradeBalance { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, rebase } = self; + let mut params = HashMap::new(); + if let Some(a) = asset { + params.insert("asset".into(), a); } - AccountCommand::OpenOrders { + rebase.apply(&mut params); + let mut data = client + .private_post("TradeBalance", params, creds, otp, true) + .await?; + schema::trade_balance(&mut data); + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct OpenOrders { + /// Include trade details. + #[arg(long)] + trades: bool, + /// Filter by user reference ID. + #[arg(long)] + userref: Option, + /// Filter by client order ID. + #[arg(long)] + cl_ord_id: Option, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for OpenOrders { + async fn execute(self, ctx: &AppContext) -> Result { + match super::workspace_guard::active_mode(ctx)? { + super::workspace_guard::ActiveMode::Paper(manifest) => { + self.ensure_paper_plain(&manifest)?; + let mut out = super::paper::orders_on_workspace(ctx, &manifest).await?; + out.stamp_workspace(&manifest.name); + Ok(out) + } + super::workspace_guard::ActiveMode::Live(manifest) => { + Err(kraken_workspace::WorkspaceError::LiveUnavailable { + name: manifest.name, + } + .into()) + } + super::workspace_guard::ActiveMode::Master => self.venue(ctx).await, + } + } +} + +impl OpenOrders { + async fn venue(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { trades, userref, cl_ord_id, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if *trades { - params.insert("trades".into(), "true".into()); - } - if let Some(r) = userref { - params.insert("userref".into(), r.clone()); - } - if let Some(id) = cl_ord_id { - params.insert("cl_ord_id".into(), id.clone()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("OpenOrders", params, creds, otp, true, verbose) - .await?; - let (headers, rows) = parse_orders_table(&data); - Ok(CommandOutput::new(data, headers, rows)) + rebase, + } = self; + let mut params = HashMap::new(); + if trades { + params.insert("trades".into(), "true".into()); + } + if let Some(r) = userref { + params.insert("userref".into(), r.to_string()); } - AccountCommand::ClosedOrders { + if let Some(id) = cl_ord_id { + params.insert("cl_ord_id".into(), id); + } + rebase.apply(&mut params); + let mut data = client + .private_post("OpenOrders", params, creds, otp, true) + .await?; + schema::order_rows(&mut data); + let (headers, rows) = parse_orders_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct ClosedOrders { + /// Include trade details. + #[arg(long)] + trades: bool, + #[command(flatten)] + page: Pagination, + /// User reference filter. + #[arg(long)] + userref: Option, + /// Filter by client order ID. + #[arg(long)] + cl_ord_id: Option, + /// Which time to use (open, close, both). + #[arg(long)] + closetime: Option, + /// Consolidate trades by individual taker trades. + #[arg(long)] + consolidate_taker: bool, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for ClosedOrders { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { trades, - start, - end, - offset, + page, userref, cl_ord_id, closetime, consolidate_taker, - without_count, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if *trades { - params.insert("trades".into(), "true".into()); - } - if let Some(s) = start { - params.insert("start".into(), s.clone()); - } - if let Some(e) = end { - params.insert("end".into(), e.clone()); - } - if let Some(o) = offset { - params.insert("ofs".into(), o.to_string()); - } - if let Some(r) = userref { - params.insert("userref".into(), r.clone()); - } - if let Some(id) = cl_ord_id { - params.insert("cl_ord_id".into(), id.clone()); - } - if let Some(ct) = closetime { - params.insert("closetime".into(), ct.clone()); - } - if *consolidate_taker { - params.insert("consolidate_taker".into(), "true".into()); - } - if *without_count { - params.insert("without_count".into(), "true".into()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("ClosedOrders", params, creds, otp, true, verbose) - .await?; - let (headers, rows) = parse_orders_table(&data); - Ok(CommandOutput::new(data, headers, rows)) + rebase, + } = self; + let mut params = HashMap::new(); + if trades { + params.insert("trades".into(), "true".into()); + } + page.apply(&mut params); + if let Some(r) = userref { + params.insert("userref".into(), r.to_string()); + } + if let Some(id) = cl_ord_id { + params.insert("cl_ord_id".into(), id); } - AccountCommand::QueryOrders { + if let Some(ct) = closetime { + params.insert("closetime".into(), ct.as_wire()); + } + if consolidate_taker { + params.insert("consolidate_taker".into(), "true".into()); + } + rebase.apply(&mut params); + let mut data = client + .private_post("ClosedOrders", params, creds, otp, true) + .await?; + schema::order_rows(&mut data); + let (headers, rows) = parse_orders_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct QueryOrders { + /// Transaction IDs (comma-delimited, up to 50). + #[arg(num_args = 1..)] + txids: Vec, + /// Include trade details. + #[arg(long)] + trades: bool, + /// User reference filter. + #[arg(long)] + userref: Option, + /// Consolidate taker trades. + #[arg(long)] + consolidate_taker: bool, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for QueryOrders { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { txids, trades, userref, consolidate_taker, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - params.insert("txid".into(), txids.join(",")); - if *trades { - params.insert("trades".into(), "true".into()); - } - if let Some(r) = userref { - params.insert("userref".into(), r.clone()); - } - if *consolidate_taker { - params.insert("consolidate_taker".into(), "true".into()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("QueryOrders", params, creds, otp, true, verbose) - .await?; - let (headers, rows) = parse_orders_table(&data); - Ok(CommandOutput::new(data, headers, rows)) + rebase, + } = self; + let mut params = HashMap::new(); + params.insert("txid".into(), txids.join(",")); + if trades { + params.insert("trades".into(), "true".into()); + } + if let Some(r) = userref { + params.insert("userref".into(), r.to_string()); } - AccountCommand::TradesHistory { + if consolidate_taker { + params.insert("consolidate_taker".into(), "true".into()); + } + rebase.apply(&mut params); + let data = client + .private_post("QueryOrders", params, creds, otp, true) + .await?; + let (headers, rows) = parse_orders_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct TradesHistory { + /// Trade type filter. + #[arg(long = "type")] + trade_type: Option, + /// Include trades related to position in output. + #[arg(long)] + trades: bool, + /// Consolidate trades by individual taker trades. + #[arg(long)] + consolidate_taker: bool, + /// Include related ledger IDs for each trade (slower). + #[arg(long)] + ledgers: bool, + #[command(flatten)] + page: Pagination, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for TradesHistory { + async fn execute(self, ctx: &AppContext) -> Result { + match super::workspace_guard::active_mode(ctx)? { + super::workspace_guard::ActiveMode::Paper(manifest) => { + self.ensure_paper_plain(&manifest)?; + let mut out = super::paper::history_on_workspace(ctx, &manifest).await?; + out.stamp_workspace(&manifest.name); + Ok(out) + } + super::workspace_guard::ActiveMode::Live(manifest) => { + Err(kraken_workspace::WorkspaceError::LiveUnavailable { + name: manifest.name, + } + .into()) + } + super::workspace_guard::ActiveMode::Master => self.venue(ctx).await, + } + } +} + +impl TradesHistory { + async fn venue(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { trade_type, - start, - end, trades, - offset, consolidate_taker, - without_count, ledgers, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if let Some(t) = trade_type { - params.insert("type".into(), t.clone()); - } - if let Some(s) = start { - params.insert("start".into(), s.clone()); - } - if let Some(e) = end { - params.insert("end".into(), e.clone()); - } - if *trades { - params.insert("trades".into(), "true".into()); - } - if let Some(o) = offset { - params.insert("ofs".into(), o.to_string()); - } - if *consolidate_taker { - params.insert("consolidate_taker".into(), "true".into()); - } - if *without_count { - params.insert("without_count".into(), "true".into()); - } - if *ledgers { - params.insert("ledgers".into(), "true".into()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("TradesHistory", params, creds, otp, true, verbose) - .await?; - let (headers, rows) = parse_trades_history(&data); - Ok(CommandOutput::new(data, headers, rows)) + page, + rebase, + } = self; + let mut params = HashMap::new(); + if let Some(t) = trade_type { + params.insert("type".into(), t.as_wire()); } - AccountCommand::QueryTrades { + if trades { + params.insert("trades".into(), "true".into()); + } + if consolidate_taker { + params.insert("consolidate_taker".into(), "true".into()); + } + if ledgers { + params.insert("ledgers".into(), "true".into()); + } + page.apply(&mut params); + rebase.apply(&mut params); + let mut data = client + .private_post("TradesHistory", params, creds, otp, true) + .await?; + schema::trade_rows(&mut data); + let (headers, rows) = parse_trades_history(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct QueryTrades { + /// Transaction IDs (comma-delimited, up to 20). + #[arg(num_args = 1..)] + txids: Vec, + /// Include trades related to position in output. + #[arg(long)] + trades: bool, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for QueryTrades { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { txids, trades, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - params.insert("txid".into(), txids.join(",")); - if *trades { - params.insert("trades".into(), "true".into()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("QueryTrades", params, creds, otp, true, verbose) - .await?; - let (headers, rows) = parse_trades_history(&data); - Ok(CommandOutput::new(data, headers, rows)) + rebase, + } = self; + let mut params = HashMap::new(); + params.insert("txid".into(), txids.join(",")); + if trades { + params.insert("trades".into(), "true".into()); } - AccountCommand::Positions { + rebase.apply(&mut params); + let data = client + .private_post("QueryTrades", params, creds, otp, true) + .await?; + let (headers, rows) = parse_trades_history(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Positions { + /// Filter by TXIDs. + #[arg(long)] + txid: Vec, + /// Include P&L calculations. + #[arg(long)] + show_pnl: bool, + /// Consolidate positions by market/pair. + #[arg(long)] + consolidation: Option, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for Positions { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { txid, show_pnl, consolidation, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if !txid.is_empty() { - params.insert("txid".into(), txid.join(",")); - } - if *show_pnl { - params.insert("docalcs".into(), "true".into()); - } - if let Some(c) = consolidation { - params.insert("consolidation".into(), c.clone()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("OpenPositions", params, creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) + rebase, + } = self; + let mut params = HashMap::new(); + if !txid.is_empty() { + params.insert("txid".into(), txid.join(",")); + } + if show_pnl { + params.insert("docalcs".into(), "true".into()); + } + if let Some(c) = consolidation { + params.insert("consolidation".into(), c); } - AccountCommand::Ledgers { + rebase.apply(&mut params); + let mut data = client + .private_post("OpenPositions", params, creds, otp, true) + .await?; + schema::position_rows(&mut data); + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Ledgers { + /// Filter by asset or comma-delimited list of assets. + #[arg(long)] + asset: Option, + /// Type of ledger to retrieve. + // Kraken's ledger-type set grows server-side (staking, dividends, NFT + // rebates arrived after launch); constraining it here would break valid + // filters, so the server validates. + #[arg(long = "type")] + ledger_type: Option, + /// Filter by asset class. + #[arg(long, alias = "aclass")] + asset_class: Option, + #[command(flatten)] + page: Pagination, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for Ledgers { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, ledger_type, - start, - end, asset_class, - offset, - without_count, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if let Some(a) = asset { - params.insert("asset".into(), a.clone()); - } - if let Some(t) = ledger_type { - params.insert("type".into(), t.clone()); - } - if let Some(s) = start { - params.insert("start".into(), s.clone()); - } - if let Some(e) = end { - params.insert("end".into(), e.clone()); - } - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(o) = offset { - params.insert("ofs".into(), o.to_string()); - } - if *without_count { - params.insert("without_count".into(), "true".into()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("Ledgers", params, creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) + page, + rebase, + } = self; + let mut params = HashMap::new(); + if let Some(a) = asset { + params.insert("asset".into(), a); + } + if let Some(t) = ledger_type { + params.insert("type".into(), t); + } + if let Some(ac) = asset_class { + params.insert("aclass".into(), ac.as_wire()); } - AccountCommand::QueryLedgers { + page.apply(&mut params); + rebase.apply(&mut params); + let mut data = client + .private_post("Ledgers", params, creds, otp, true) + .await?; + schema::ledger_rows(&mut data); + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct QueryLedgers { + /// Ledger IDs (comma-delimited, up to 20). + #[arg(num_args = 1..)] + ids: Vec, + /// Include trades related to position in output. + #[arg(long)] + trades: bool, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for QueryLedgers { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { ids, trades, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - params.insert("id".into(), ids.join(",")); - if *trades { - params.insert("trades".into(), "true".into()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("QueryLedgers", params, creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) - } - AccountCommand::Volume { - pair, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if !pair.is_empty() { - params.insert("pair".into(), pair.join(",")); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("TradeVolume", params, creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) + rebase, + } = self; + let mut params = HashMap::new(); + params.insert("id".into(), ids.join(",")); + if trades { + params.insert("trades".into(), "true".into()); } - AccountCommand::ExportReport { + rebase.apply(&mut params); + let mut data = client + .private_post("QueryLedgers", params, creds, otp, true) + .await?; + schema::ledger_rows(&mut data); + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Volume { + /// Comma-delimited list of asset pairs for fee info. + #[arg(long)] + pair: Vec, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for Volume { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { pair, rebase } = self; + let mut params = HashMap::new(); + if !pair.is_empty() { + params.insert("pair".into(), pair.join(",")); + } + rebase.apply(&mut params); + let mut data = client + .private_post("TradeVolume", params, creds, otp, true) + .await?; + schema::trade_volume(&mut data); + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct ExportReport { + /// Type of data to export (trades or ledgers). + #[arg(long)] + report: ReportType, + /// Description for the export. + #[arg(long)] + description: String, + /// File format. + #[arg(long, default_value = "CSV")] + format: ReportFormat, + /// Comma-delimited list of fields to include (default: all). + #[arg(long)] + fields: Option, + /// UNIX timestamp for report start time (default: 1st of current month). + #[arg(long)] + starttm: Option, + /// UNIX timestamp for report end time (default: now). + #[arg(long)] + endtm: Option, +} + +impl Execute for ExportReport { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { report, description, format, fields, starttm, endtm, - } => { - let mut params = HashMap::new(); - params.insert("report".into(), report.clone()); - params.insert("description".into(), description.clone()); - params.insert("format".into(), format.clone()); - if let Some(f) = fields { - params.insert("fields".into(), f.clone()); - } - if let Some(s) = starttm { - params.insert("starttm".into(), s.clone()); - } - if let Some(e) = endtm { - params.insert("endtm".into(), e.clone()); - } - let data = client - .private_post("AddExport", params, creds, otp, false, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) - } - AccountCommand::ExportStatus { report } => { - let mut params = HashMap::new(); - params.insert("report".into(), report.clone()); - let data = client - .private_post("ExportStatus", params, creds, otp, true, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) - } - AccountCommand::ExportRetrieve { + } = self; + let mut params = HashMap::new(); + params.insert("report".into(), report.as_wire()); + params.insert("description".into(), description); + params.insert("format".into(), format.as_wire()); + if let Some(f) = fields { + params.insert("fields".into(), f); + } + if let Some(s) = starttm { + params.insert("starttm".into(), s); + } + if let Some(e) = endtm { + params.insert("endtm".into(), e); + } + let data = client + .private_post("AddExport", params, creds, otp, false) + .await?; + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct ExportStatus { + /// Report type (trades or ledgers). + #[arg(long)] + report: ReportType, +} + +impl Execute for ExportStatus { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { report } = self; + let mut params = HashMap::new(); + params.insert("report".into(), report.as_wire()); + let data = client + .private_post("ExportStatus", params, creds, otp, true) + .await?; + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct ExportRetrieve { + /// Report ID to retrieve. + report_id: String, + /// Output file path for the downloaded report. + #[arg(long)] + output_file: Option, +} + +impl Execute for ExportRetrieve { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { report_id, output_file, - } => { - let mut params = HashMap::new(); - params.insert("id".into(), report_id.clone()); - - let bytes = client - .private_post_raw("RetrieveExport", params, creds, otp, true, verbose) - .await?; - - let dest = output_file - .clone() - .unwrap_or_else(|| PathBuf::from(format!("kraken_export_{report_id}.zip"))); - - std::fs::write(&dest, &bytes)?; - - let meta = serde_json::json!({ - "report_id": report_id, - "file": dest.display().to_string(), - "size_bytes": bytes.len(), - }); - Ok(CommandOutput::key_value( - vec![ - ("Report ID".into(), report_id.clone()), - ("File".into(), dest.display().to_string()), - ("Size".into(), format!("{} bytes", bytes.len())), - ], - meta, - )) - } - AccountCommand::ExportDelete { report_id } => { - let mut params = HashMap::new(); - params.insert("id".into(), report_id.clone()); - let data = client - .private_post("RemoveExport", params, creds, otp, false, verbose) - .await?; - let pairs = parse_kv_pairs(&data); - Ok(CommandOutput::key_value(pairs, data)) - } + } = self; + let mut params = HashMap::new(); + params.insert("id".into(), report_id.clone()); + + let bytes = client + .private_post_raw("RetrieveExport", params, creds, otp, true) + .await?; + + let dest = + output_file.unwrap_or_else(|| PathBuf::from(format!("kraken_export_{report_id}.zip"))); + + std::fs::write(&dest, &bytes)?; + + let meta = serde_json::json!({ + "report_id": report_id, + "file": dest.display().to_string(), + "size_bytes": bytes.len(), + }); + Ok(CommandOutput::key_value( + vec![ + ("Report ID".into(), report_id), + ("File".into(), dest.display().to_string()), + ("Size".into(), format!("{} bytes", bytes.len())), + ], + meta, + )) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct ExportDelete { + /// Report ID to delete. + report_id: String, +} + +impl Execute for ExportDelete { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { report_id } = self; + let mut params = HashMap::new(); + params.insert("id".into(), report_id); + let data = client + .private_post("RemoveExport", params, creds, otp, false) + .await?; + let pairs = parse_kv_pairs(&data); + Ok(CommandOutput::key_value(pairs, data)) } } @@ -666,7 +865,7 @@ fn parse_orders_table(data: &Value) -> (Vec, Vec>) { let headers = vec![ "TXID".into(), "Status".into(), - "Type".into(), + "Side".into(), "Pair".into(), "Price".into(), "Volume".into(), @@ -684,22 +883,33 @@ fn parse_orders_table(data: &Value) -> (Vec, Vec>) { let descr = order.get("descr").unwrap_or(&Value::Null); rows.push(vec![ txid.clone(), - jstr(order, "status"), - jstr(descr, "type"), - jstr(descr, "pair"), - jstr(descr, "price"), - jstr(order, "vol"), + json_field(order, "status"), + json_field_or(descr, "side", "type"), + json_field(descr, "pair"), + json_field(descr, "price"), + json_field_or(order, "volume", "vol"), ]); } } (headers, rows) } +/// A field under its published schema name, falling back to its raw wire +/// name — one renderer serves both the schema-shaped rows and the raw rows +/// of the query-* commands, which still emit the wire form. +fn json_field_or(val: &Value, schema_key: &str, wire: &str) -> String { + if val.get(schema_key).is_some() { + json_field(val, schema_key) + } else { + json_field(val, wire) + } +} + fn parse_trades_history(data: &Value) -> (Vec, Vec>) { let headers = vec![ "TXID".into(), "Pair".into(), - "Type".into(), + "Side".into(), "Price".into(), "Volume".into(), "Cost".into(), @@ -715,11 +925,11 @@ fn parse_trades_history(data: &Value) -> (Vec, Vec>) { for (txid, trade) in obj { rows.push(vec![ txid.clone(), - jstr(trade, "pair"), - jstr(trade, "type"), - jstr(trade, "price"), - jstr(trade, "vol"), - jstr(trade, "cost"), + json_field(trade, "pair"), + json_field_or(trade, "side", "type"), + json_field(trade, "price"), + json_field(trade, "vol"), + json_field(trade, "cost"), ]); } } diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 030167c..0b94309 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -2,12 +2,16 @@ use std::collections::HashMap; use clap::Subcommand; +use enum_dispatch::enum_dispatch; +use secrecy::{ExposeSecret, SecretString}; -use crate::config::{self, mask_string}; +use super::Execute; +use crate::AppContext; +use crate::config; use crate::errors::Result; use crate::output::CommandOutput; -use crate::AppContext; +#[enum_dispatch(Execute)] #[derive(Debug, Subcommand)] pub(crate) enum AuthCommand { /// Save API credentials to the config file. @@ -16,218 +20,220 @@ pub(crate) enum AuthCommand { /// `--api-secret-file`, or `--api-secret` flags. If none are provided, /// an interactive prompt is shown. Pass `--api-secret` directly here /// as a last resort (it exposes the secret in process listings). - Set { - /// Kraken API key. - #[arg(long)] - api_key: String, - /// Kraken API secret (prefer global --api-secret-stdin for security). - #[arg(long)] - api_secret: Option, - /// Kraken Futures API key (optional). - #[arg(long)] - futures_api_key: Option, - /// Kraken Futures API secret (prefer --futures-api-secret-stdin for security). - #[arg(long, requires = "futures_api_key", conflicts_with_all = ["futures_api_secret_stdin", "futures_api_secret_file"])] - futures_api_secret: Option, - /// Read Futures API secret from stdin (mutually exclusive with --futures-api-secret and --futures-api-secret-file). - #[arg(long, requires = "futures_api_key", conflicts_with_all = ["futures_api_secret", "futures_api_secret_file"])] - futures_api_secret_stdin: bool, - /// Path to file containing Futures API secret (mutually exclusive with --futures-api-secret and --futures-api-secret-stdin). - #[arg(long, requires = "futures_api_key", conflicts_with_all = ["futures_api_secret", "futures_api_secret_stdin"])] - futures_api_secret_file: Option, - }, + Set(Set), /// Show configured API key (secret is masked). - Show, + Show(Show), /// Test authentication by calling the Balance endpoint. - Test, + Test(Test), /// Delete stored credentials. - Reset, + Reset(Reset), +} + +// The futures-secret sources cross-reference each other and the futures key +// via `requires` / `conflicts_with_all`, so all six fields must stay in this +// one struct for clap to resolve the identifiers. +#[derive(Debug, clap::Args)] +pub(crate) struct Set { + /// Kraken API key. + #[arg(long)] + api_key: String, + /// Kraken API secret (prefer global --api-secret-stdin for security). + #[arg(long)] + api_secret: Option, + /// Kraken Futures API key (optional). + #[arg(long)] + futures_api_key: Option, + /// Kraken Futures API secret (prefer --futures-api-secret-stdin for security). + #[arg(long, requires = "futures_api_key", conflicts_with_all = ["futures_api_secret_stdin", "futures_api_secret_file"])] + futures_api_secret: Option, + /// Read Futures API secret from stdin (mutually exclusive with --futures-api-secret and --futures-api-secret-file). + #[arg(long, requires = "futures_api_key", conflicts_with_all = ["futures_api_secret", "futures_api_secret_file"])] + futures_api_secret_stdin: bool, + /// Path to file containing Futures API secret (mutually exclusive with --futures-api-secret and --futures-api-secret-stdin). + #[arg(long, requires = "futures_api_key", conflicts_with_all = ["futures_api_secret", "futures_api_secret_stdin"])] + futures_api_secret_file: Option, } -pub(crate) async fn execute(cmd: &AuthCommand, ctx: &AppContext) -> Result { - match cmd { - AuthCommand::Set { +#[derive(Debug, clap::Args)] +pub(crate) struct Show {} + +#[derive(Debug, clap::Args)] +pub(crate) struct Test {} + +#[derive(Debug, clap::Args)] +pub(crate) struct Reset {} + +/// Mask an API key to its last-4 tail for identification: `****jedl`. +/// +/// Like the last four digits of an account number, the tail tells the operator +/// which key is configured without exposing usable material. Keys of four or +/// fewer characters are fully masked so a short key is never shown whole. +fn mask_tail(key: &SecretString) -> String { + let count = key.expose_secret().chars().count(); + if count <= 4 { + return "****".to_string(); + } + let tail: String = key.expose_secret().chars().skip(count - 4).collect(); + format!("****{tail}") +} + +impl Execute for Set { + async fn execute(self, ctx: &AppContext) -> Result { + let Self { api_key, api_secret, futures_api_key, futures_api_secret, futures_api_secret_stdin, futures_api_secret_file, - } => { - let secret = if let Some(s) = &ctx.api_secret { - s.clone() - } else if let Some(s) = api_secret { - crate::output::warn( - "Passing --api-secret on the command line exposes it in process listings. \ - Prefer --api-secret-stdin, --api-secret-file, or `kraken setup` for interactive entry.", + } = self; + + let secret: SecretString = if let Some(s) = &ctx.api_secret { + s.clone() + } else if let Some(s) = api_secret { + tracing::warn!( + "Passing --api-secret on the command line exposes it in process listings. \ + Prefer --api-secret-stdin, --api-secret-file, or `kraken setup` for interactive entry." + ); + SecretString::from(s) + } else if ctx.mcp_mode { + return Err(crate::errors::KrakenError::Validation( + "API secret is required for auth set in non-interactive mode. \ + Provide --api-secret, --api-secret-stdin, or --api-secret-file." + .into(), + )); + } else { + let input = dialoguer::Password::new() + .with_prompt("API Secret") + .interact() + .map_err(|e| crate::errors::KrakenError::Config(format!("Input error: {e}")))?; + SecretString::from(input) + }; + + if secret.expose_secret().is_empty() { + return Err(crate::errors::KrakenError::Auth( + "Cannot save an empty API secret.".into(), + )); + } + + let mut cfg = config::load()?; + cfg.auth.api_key = Some(SecretString::from(api_key)); + cfg.auth.api_secret = Some(secret); + + if let Some(fk) = futures_api_key { + cfg.auth.futures_api_key = Some(SecretString::from(fk)); + + let futures_secret = if futures_api_secret_stdin { + if ctx.mcp_mode { + return Err(crate::errors::KrakenError::Validation( + "Cannot read Futures API secret from stdin in MCP mode \ + (stdin is the JSON-RPC transport). Provide \ + --futures-api-secret or --futures-api-secret-file instead." + .into(), + )); + } + config::read_secret_from_stdin()? + .expose_secret() + .to_string() + } else if let Some(path) = futures_api_secret_file { + config::read_secret_from_file(&path)? + .expose_secret() + .to_string() + } else if let Some(fs) = futures_api_secret { + tracing::warn!( + "Passing --futures-api-secret on the command line exposes it in process listings. \ + Prefer --futures-api-secret-stdin, --futures-api-secret-file, or `kraken setup` for interactive entry." ); - s.clone() + fs } else if ctx.mcp_mode { return Err(crate::errors::KrakenError::Validation( - "API secret is required for auth set in non-interactive mode. \ - Provide --api-secret, --api-secret-stdin, or --api-secret-file." + "Futures API secret is required for auth set in non-interactive mode. \ + Provide --futures-api-secret, --futures-api-secret-stdin, or --futures-api-secret-file." .into(), )); } else { - let input = dialoguer::Password::new() - .with_prompt("API Secret") + dialoguer::Password::new() + .with_prompt("Futures API Secret") .interact() - .map_err(|e| crate::errors::KrakenError::Config(format!("Input error: {e}")))?; - input + .map_err(|e| crate::errors::KrakenError::Config(format!("Input error: {e}")))? }; - if secret.is_empty() { + if futures_secret.is_empty() { return Err(crate::errors::KrakenError::Auth( - "Cannot save an empty API secret.".into(), + "Cannot save an empty Futures API secret.".into(), )); } + cfg.auth.futures_api_secret = Some(SecretString::from(futures_secret)); + } - let mut cfg = config::load()?; - cfg.auth.api_key = Some(api_key.clone()); - cfg.auth.api_secret = Some(secret); - - if let Some(fk) = futures_api_key { - cfg.auth.futures_api_key = Some(fk.clone()); - - let futures_secret = if *futures_api_secret_stdin { - if ctx.mcp_mode { - return Err(crate::errors::KrakenError::Validation( - "Cannot read Futures API secret from stdin in MCP mode \ - (stdin is the JSON-RPC transport). Provide \ - --futures-api-secret or --futures-api-secret-file instead." - .into(), - )); - } - config::read_secret_from_stdin()?.expose().to_string() - } else if let Some(ref path) = futures_api_secret_file { - config::read_secret_from_file(path)?.expose().to_string() - } else if let Some(fs) = futures_api_secret { - crate::output::warn( - "Passing --futures-api-secret on the command line exposes it in process listings. \ - Prefer --futures-api-secret-stdin, --futures-api-secret-file, or `kraken setup` for interactive entry.", - ); - fs.clone() - } else if ctx.mcp_mode { - return Err(crate::errors::KrakenError::Validation( - "Futures API secret is required for auth set in non-interactive mode. \ - Provide --futures-api-secret, --futures-api-secret-stdin, or --futures-api-secret-file." - .into(), - )); - } else { - let input = dialoguer::Password::new() - .with_prompt("Futures API Secret") - .interact() - .map_err(|e| { - crate::errors::KrakenError::Config(format!("Input error: {e}")) - })?; - input - }; - - if futures_secret.is_empty() { - return Err(crate::errors::KrakenError::Auth( - "Cannot save an empty Futures API secret.".into(), - )); - } - cfg.auth.futures_api_secret = Some(futures_secret); - } + config::save(&cfg)?; - config::save(&cfg)?; + Ok(CommandOutput::message("Credentials saved successfully.")) + } +} - Ok(CommandOutput::message("Credentials saved successfully.")) - } - AuthCommand::Show => { - let cfg = config::load()?; - - // Table output shows a last-4 mask of the api key so a user with - // multiple keys on the same machine can tell them apart at a glance, - // but never exposes secret material. Secrets render as a fixed - // "[REDACTED]" token in both modes: there is no legitimate reason to - // eyeball secret prefixes, and aligning both renderers reduces the - // screen-share / paste-to-Slack leak surface that motivates this - // command's output shape. - let key_display = cfg - .auth - .api_key - .as_deref() - .map(mask_string) - .unwrap_or_else(|| "(not set)".to_string()); - let futures_key_display = cfg - .auth - .futures_api_key - .as_deref() - .map(mask_string) - .unwrap_or_else(|| "(not set)".to_string()); - - let secret_display = if cfg.auth.api_secret.is_some() { - "[REDACTED]".to_string() - } else { - "(not set)".to_string() - }; +impl Execute for Show { + async fn execute(self, _ctx: &AppContext) -> Result { + let cfg = config::load()?; - let pairs = vec![ - ("API Key".into(), key_display.clone()), - ("API Secret".into(), secret_display), - ("Futures API Key".into(), futures_key_display.clone()), - ]; - // JSON output emits structured `{present, masked}` objects so - // downstream scripts can branch on presence without parsing a - // masked string, and so log-scrubbers keyed on the literal - // "[REDACTED]" token still match. The `masked` field is present - // only when the key exists and is safe to display; full key - // material never appears in JSON output. - let json = serde_json::json!({ - "api_key": match cfg.auth.api_key.as_deref() { - Some(k) => serde_json::json!({ - "present": true, - "masked": mask_string(k), - }), - None => serde_json::json!({ "present": false }), - }, - "api_secret": match cfg.auth.api_secret.as_deref() { - Some(_) => serde_json::json!({ "present": true, "masked": "[REDACTED]" }), - None => serde_json::json!({ "present": false }), - }, - "futures_api_key": match cfg.auth.futures_api_key.as_deref() { - Some(k) => serde_json::json!({ - "present": true, - "masked": mask_string(k), - }), - None => serde_json::json!({ "present": false }), - }, - }); - Ok(CommandOutput::key_value(pairs, json)) - } - AuthCommand::Test => { - let creds = config::resolve_spot_credentials( - ctx.api_key.as_deref(), - ctx.api_secret.as_deref(), - )?; - let client = crate::client::SpotClient::new(ctx.api_url.as_deref())?; - let result = client - .private_post( - "Balance", - HashMap::new(), - &creds, - ctx.otp.as_deref(), - true, - ctx.verbose, - ) - .await?; - - let pairs = vec![ - ("Status".into(), "Authentication successful".to_string()), - ("Source".into(), creds.source.to_string()), - ]; - let json = serde_json::json!({ - "status": "success", - "source": creds.source.to_string(), - "balances": result, - }); - Ok(CommandOutput::key_value(pairs, json)) - } - AuthCommand::Reset => { - config::reset_auth()?; - Ok(CommandOutput::message("Credentials deleted.")) - } + // Render each field's masked string once, then drive both outputs + // from it. Keys are identifiers and show a last-4 tail (`****jedl`) + // so the operator can tell which key is configured; the secret is + // never shown even partially and collapses to "[REDACTED]". `None` + // means the field is not configured. + let api_key = cfg.auth.api_key.as_ref().map(mask_tail); + let api_secret = cfg + .auth + .api_secret + .as_ref() + .map(|_| "[REDACTED]".to_string()); + let futures_api_key = cfg.auth.futures_api_key.as_ref().map(mask_tail); + + let cell = |m: &Option| m.as_deref().unwrap_or("(not set)").to_string(); + let pairs = vec![ + ("API Key".into(), cell(&api_key)), + ("API Secret".into(), cell(&api_secret)), + ("Futures API Key".into(), cell(&futures_api_key)), + ]; + + // JSON mirrors the table as `{present, masked}` so scripts can branch + // on presence without parsing a string. + let field = |m: &Option| match m { + Some(m) => serde_json::json!({ "present": true, "masked": m }), + None => serde_json::json!({ "present": false }), + }; + let json = serde_json::json!({ + "api_key": field(&api_key), + "api_secret": field(&api_secret), + "futures_api_key": field(&futures_api_key), + }); + Ok(CommandOutput::key_value(pairs, json)) + } +} + +impl Execute for Test { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let result = client + .private_post("Balance", HashMap::new(), creds, otp, true) + .await?; + + let pairs = vec![ + ("Status".into(), "Authentication successful".to_string()), + ("Source".into(), creds.source.to_string()), + ]; + let json = serde_json::json!({ + "status": "success", + "source": creds.source.to_string(), + "balances": result, + }); + Ok(CommandOutput::key_value(pairs, json)) + } +} + +impl Execute for Reset { + async fn execute(self, _ctx: &AppContext) -> Result { + config::reset_auth()?; + Ok(CommandOutput::message("Credentials deleted.")) } } diff --git a/src/commands/earn.rs b/src/commands/earn.rs index 5999df1..7dca35e 100644 --- a/src/commands/earn.rs +++ b/src/commands/earn.rs @@ -2,180 +2,215 @@ use std::collections::HashMap; use clap::Subcommand; +use enum_dispatch::enum_dispatch; -use super::helpers::{confirm_destructive, parse_generic}; -use crate::client::SpotClient; -use crate::config::SpotCredentials; +use super::Execute; +use crate::cli::AppContext; use crate::errors::Result; use crate::output::CommandOutput; +#[enum_dispatch(Execute)] #[derive(Debug, Subcommand)] pub(crate) enum EarnCommand { /// Allocate funds to an earn strategy. - Allocate { - /// Strategy ID. - strategy_id: String, - /// Amount to allocate. - amount: String, - }, + Allocate(Allocate), /// Deallocate funds from an earn strategy. - Deallocate { - /// Strategy ID. - strategy_id: String, - /// Amount to deallocate. - amount: String, - }, + Deallocate(Deallocate), /// Check allocation status. - AllocateStatus { - /// Strategy ID. - strategy_id: String, - }, + AllocateStatus(AllocateStatus), /// Check deallocation status. - DeallocateStatus { - /// Strategy ID. - strategy_id: String, - }, + DeallocateStatus(DeallocateStatus), /// List available earn strategies. - Strategies { - /// Filter by asset. - #[arg(long)] - asset: Option, - /// Filter by lock type (flex, bonded, timed, instant). Can specify multiple. - #[arg(long)] - lock_type: Vec, - /// Sort ascending (default: descending). - #[arg(long)] - ascending: bool, - /// Cursor for next page of results. - #[arg(long)] - cursor: Option, - /// Number of items per page. - #[arg(long)] - limit: Option, - }, + Strategies(Strategies), /// Get current allocations. - Allocations { - /// Sort ascending (default: descending). - #[arg(long)] - ascending: bool, - /// Secondary currency to express allocation value (default: USD). - #[arg(long)] - converted_asset: Option, - /// Omit strategies with zero allocations. - #[arg(long)] - hide_zero_allocations: bool, - }, + Allocations(Allocations), } -pub(crate) async fn execute( - cmd: &EarnCommand, - client: &SpotClient, - creds: &SpotCredentials, - otp: Option<&str>, - force: bool, - verbose: bool, +#[derive(Debug, clap::Args)] +pub(crate) struct Allocate { + /// Strategy ID. + strategy_id: String, + /// Amount to allocate. + amount: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Deallocate { + /// Strategy ID. + strategy_id: String, + /// Amount to deallocate. + amount: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct AllocateStatus { + /// Strategy ID. + strategy_id: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct DeallocateStatus { + /// Strategy ID. + strategy_id: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Strategies { + /// Filter by asset. + #[arg(long)] + asset: Option, + /// Filter by lock type (flex, bonded, timed, instant). Can specify multiple. + #[arg(long)] + lock_type: Vec, + /// Sort ascending (default: descending). + #[arg(long)] + ascending: bool, + /// Cursor for next page of results. + #[arg(long)] + cursor: Option, + /// Number of items per page. + #[arg(long)] + limit: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Allocations { + /// Sort ascending (default: descending). + #[arg(long)] + ascending: bool, + /// Secondary currency to express allocation value (default: USD). + #[arg(long)] + converted_asset: Option, + /// Omit strategies with zero allocations. + #[arg(long)] + hide_zero_allocations: bool, +} + +/// Allocate and Deallocate differ only by endpoint and prompt wording; both +/// are destructive and gate on confirmation unless `--force`. +async fn change_allocation( + ctx: &AppContext, + endpoint: &str, + prompt: &str, + strategy_id: String, + amount: String, ) -> Result { - match cmd { - EarnCommand::Allocate { + let (client, creds, otp) = ctx.spot_authed()?; + ctx.confirm_destructive(prompt)?; + let mut params = HashMap::new(); + params.insert("strategy_id".into(), strategy_id); + params.insert("amount".into(), amount); + let data = client + .private_post(endpoint, params, creds, otp, false) + .await?; + Ok(CommandOutput::from_untyped(&data)) +} + +async fn fetch_allocation_status( + ctx: &AppContext, + endpoint: &str, + strategy_id: String, +) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let mut params = HashMap::new(); + params.insert("strategy_id".into(), strategy_id); + let data = client + .private_post(endpoint, params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) +} + +impl Execute for Allocate { + async fn execute(self, ctx: &AppContext) -> Result { + let Self { strategy_id, amount, - } => { - if !force { - confirm_destructive(&format!( - "Allocate {amount} to earn strategy {strategy_id}?" - ))?; - } - let mut params = HashMap::new(); - params.insert("strategy_id".into(), strategy_id.clone()); - params.insert("amount".into(), amount.clone()); - let data = client - .private_post("Earn/Allocate", params, creds, otp, false, verbose) - .await?; - Ok(parse_generic(&data)) - } - EarnCommand::Deallocate { + } = self; + let prompt = format!("Allocate {amount} to earn strategy {strategy_id}?"); + change_allocation(ctx, "Earn/Allocate", &prompt, strategy_id, amount).await + } +} + +impl Execute for Deallocate { + async fn execute(self, ctx: &AppContext) -> Result { + let Self { strategy_id, amount, - } => { - if !force { - confirm_destructive(&format!( - "Deallocate {amount} from earn strategy {strategy_id}?" - ))?; - } - let mut params = HashMap::new(); - params.insert("strategy_id".into(), strategy_id.clone()); - params.insert("amount".into(), amount.clone()); - let data = client - .private_post("Earn/Deallocate", params, creds, otp, false, verbose) - .await?; - Ok(parse_generic(&data)) - } - EarnCommand::AllocateStatus { strategy_id } => { - let mut params = HashMap::new(); - params.insert("strategy_id".into(), strategy_id.clone()); - let data = client - .private_post("Earn/AllocateStatus", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) - } - EarnCommand::DeallocateStatus { strategy_id } => { - let mut params = HashMap::new(); - params.insert("strategy_id".into(), strategy_id.clone()); - let data = client - .private_post("Earn/DeallocateStatus", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) - } - EarnCommand::Strategies { + } = self; + let prompt = format!("Deallocate {amount} from earn strategy {strategy_id}?"); + change_allocation(ctx, "Earn/Deallocate", &prompt, strategy_id, amount).await + } +} + +impl Execute for AllocateStatus { + async fn execute(self, ctx: &AppContext) -> Result { + fetch_allocation_status(ctx, "Earn/AllocateStatus", self.strategy_id).await + } +} + +impl Execute for DeallocateStatus { + async fn execute(self, ctx: &AppContext) -> Result { + fetch_allocation_status(ctx, "Earn/DeallocateStatus", self.strategy_id).await + } +} + +impl Execute for Strategies { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, lock_type, ascending, cursor, limit, - } => { - let mut params = HashMap::new(); - if let Some(a) = asset { - params.insert("asset".into(), a.clone()); - } - if !lock_type.is_empty() { - params.insert( - "lock_type".into(), - serde_json::to_string(lock_type).unwrap_or_default(), - ); - } - if *ascending { - params.insert("ascending".into(), "true".into()); - } - if let Some(c) = cursor { - params.insert("cursor".into(), c.clone()); - } - if let Some(l) = limit { - params.insert("limit".into(), l.to_string()); - } - let data = client - .private_post("Earn/Strategies", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + } = self; + let mut params = HashMap::new(); + if let Some(a) = asset { + params.insert("asset".into(), a); + } + if !lock_type.is_empty() { + params.insert( + "lock_type".into(), + serde_json::to_string(&lock_type).unwrap_or_default(), + ); + } + if ascending { + params.insert("ascending".into(), "true".into()); + } + if let Some(c) = cursor { + params.insert("cursor".into(), c); + } + if let Some(l) = limit { + params.insert("limit".into(), l.to_string()); } - EarnCommand::Allocations { + let data = client + .private_post("Earn/Strategies", params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +impl Execute for Allocations { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { ascending, converted_asset, hide_zero_allocations, - } => { - let mut params = HashMap::new(); - if *ascending { - params.insert("ascending".into(), "true".into()); - } - if let Some(ca) = converted_asset { - params.insert("converted_asset".into(), ca.clone()); - } - if *hide_zero_allocations { - params.insert("hide_zero_allocations".into(), "true".into()); - } - let data = client - .private_post("Earn/Allocations", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + } = self; + let mut params = HashMap::new(); + if ascending { + params.insert("ascending".into(), "true".into()); + } + if let Some(ca) = converted_asset { + params.insert("converted_asset".into(), ca); + } + if hide_zero_allocations { + params.insert("hide_zero_allocations".into(), "true".into()); } + let data = client + .private_post("Earn/Allocations", params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) } } diff --git a/src/commands/executor.rs b/src/commands/executor.rs new file mode 100644 index 0000000..7b6d54f --- /dev/null +++ b/src/commands/executor.rs @@ -0,0 +1,75 @@ +//! Entry points: [`run`] for the binary, [`dispatch`] for parsed commands, and +//! the render-free [`execute_command`] for the MCP server. + +use super::futures::FuturesCommand; +use super::session::{self, SessionCommand}; +use super::streaming::Run; +use super::{Command, Execute, futures_ws}; +use crate::cli::AppContext; +use crate::errors::Result; +use crate::output::{CommandOutput, render}; + +/// Run the parsed CLI: dispatch the subcommand, or print help when none was +/// given. The binary's single entry point into the library; keeps [`Command`] +/// and [`dispatch`] crate-internal. +pub async fn run(ctx: &AppContext, cli: crate::Cli) -> Result<()> { + match cli.command { + Some(command) => dispatch(ctx, command).await, + None => { + use clap::CommandFactory; + crate::Cli::command().print_help()?; + println!(); + Ok(()) + } + } +} + +/// Central dispatch: streaming commands run inline through [`Run`]; everything +/// else executes and renders its structured output. +pub(crate) async fn dispatch(ctx: &AppContext, command: Command) -> Result<()> { + // Before the match: the streaming arms below bypass `Execute`, and the + // guard must cover them too (a ws order method is still a money command). + super::workspace_guard::enforce(ctx, &command)?; + match command { + Command::Streaming(cmd) => cmd.run(ctx).await?, + // `futures ws` is a streaming command, handled here alongside top-level + // `ws` rather than through the request/response executor. + Command::Futures(FuturesCommand::Ws(cmd)) => { + let creds = if futures_ws::requires_auth(&cmd) { + Some(ctx.require_credentials(crate::config::Engine::Futures)?) + } else { + ctx.futures_credentials.as_ref() + }; + futures_ws::execute( + &cmd, + creds, + ctx.ws_futures_url.as_deref(), + ctx.accept_invalid_certs, + ) + .await?; + } + // Only `session start` (and bare `playground`, its demo composition) + // streams; the request/response session verbs fall through to the + // shared executor like any other command. + Command::Session(SessionCommand::Start(args)) => { + session::start(&args, ctx).await?; + } + Command::Playground(args) => { + super::playground::demo(&args, ctx).await?; + } + other => { + let out = other.execute(ctx).await?; + render(ctx.format, &out); + } + } + + Ok(()) +} + +/// The MCP server's render-free entry: executes a command and returns its +/// structured output. Routing lives in the `enum_dispatch`-derived [`Execute`] +/// impls; this facade exists so the trait stays sealed inside `commands`. +pub(crate) async fn execute_command(ctx: &AppContext, command: Command) -> Result { + super::workspace_guard::enforce(ctx, &command)?; + command.execute(ctx).await +} diff --git a/src/commands/explain.rs b/src/commands/explain.rs new file mode 100644 index 0000000..9150c1f --- /dev/null +++ b/src/commands/explain.rs @@ -0,0 +1,270 @@ +//! CLI and MCP adapter for [`kraken_replay::explain_pnl`]. + +use clap::Subcommand; +use enum_dispatch::enum_dispatch; +use kraken_paper::account::Origin; +use kraken_replay::{ + ComponentKind, ComponentLine, MissedFill, OriginSlice, PnlReport, TradeLine, WindowSummary, +}; +use rust_decimal::Decimal; + +use super::Execute; +use crate::cli::AppContext; +use crate::errors::Result; +use crate::output::{CommandOutput, rounded}; + +#[enum_dispatch(Execute)] +#[derive(Debug, Subcommand)] +pub(crate) enum ExplainCommand { + /// Decompose a stopped session's P&L into plain-terms components. + Pnl(Pnl), +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Pnl { + /// Which session: `latest`, an ordinal (`s3`), or a label. + #[arg(long, default_value = "latest")] + session: String, +} + +impl Execute for Pnl { + async fn execute(self, ctx: &AppContext) -> Result { + super::session::ensure_session_stopped(ctx, &self.session)?; + let (session_id, window) = super::session::read_session_window(ctx, &self.session)?; + let report = kraken_replay::explain_pnl(&window)?; + let mut out = output(&report)?; + out.stamp_session(&session_id.to_string()); + if let Some(workspace) = ctx.workspace.as_deref() { + out.stamp_workspace(workspace); + } + Ok(out) + } +} + +fn output(report: &PnlReport) -> Result { + let headers = ["Line", "Amount", "Explanation"].map(String::from).to_vec(); + let mut rows = vec![total_row(report)]; + rows.extend(report.components.iter().map(component_row)); + rows.extend(trade_rows(report)); + rows.extend(report.window.iter().flat_map(window_rows)); + rows.extend(report.missed_fills.orders.iter().map(missed_row)); + rows.extend(report.attribution.iter().map(attribution_row)); + rows.extend(report.caveats.iter().map(|caveat| caveat_row(caveat))); + Ok(CommandOutput::new( + serde_json::to_value(report)?, + headers, + rows, + )) +} + +fn total_row(report: &PnlReport) -> Vec { + vec![ + "Total".to_string(), + signed(report.anchor.total_pnl), + format!( + "ended at {:.2} {} from a {:.2} start (as reported at session stop)", + rounded(report.anchor.final_value, 2), + report.anchor.currency, + rounded(report.anchor.starting_balance, 2) + ), + ] +} + +fn component_row(component: &ComponentLine) -> Vec { + vec![ + component_label(component.kind).to_string(), + component.amount.map_or_else(|| "?".to_string(), signed), + component.explanation.clone(), + ] +} + +/// One row per fill; a session without fills gets the cash story instead +/// (the market's side of it rides in the window rows). +fn trade_rows(report: &PnlReport) -> Vec> { + if report.trades.is_empty() { + let where_it_sat = if report.window.is_some() { + "over the recorded window" + } else { + "and no market was recorded" + }; + return vec![vec![ + "No trades".to_string(), + "—".to_string(), + format!( + "your {:.2} {} sat in cash {where_it_sat}", + rounded(report.anchor.starting_balance, 2), + report.anchor.currency + ), + ]]; + } + report.trades.iter().map(trade_row).collect() +} + +fn trade_row(line: &TradeLine) -> Vec { + // The row's amount is the fill's whole recorded-mark contribution; + // unknown movement (no end mark) makes the total unknowable too. + let contribution = line + .price_movement + .map(|movement| movement + line.fees + line.spread + line.slippage); + let reason = line + .reason + .as_deref() + .map(|reason| format!(" — {reason}")) + .unwrap_or_default(); + vec![ + "Trade".to_string(), + contribution.map_or_else(|| "?".to_string(), signed), + format!( + "{}: {} {} {} @ {:.2} ({}, {}){reason}", + line.trade_id, + line.side, + qty(line.volume), + line.pair, + rounded(line.price, 2), + line.order_type, + origin_label(line.origin), + ), + ] +} + +fn window_rows(window: &WindowSummary) -> Vec> { + window + .symbols + .iter() + .map(|symbol| { + let movement = symbol.move_pct.map_or_else( + || "had no mid data".to_string(), + |pct| format!("moved {:+.1}%", rounded(pct, 1)), + ); + let spread = symbol + .avg_spread + .map(|avg| format!(", avg spread {:.2}", rounded(avg, 2))) + .unwrap_or_default(); + vec![ + "Window".to_string(), + "—".to_string(), + format!( + "{} {movement}{spread} over the recorded window ({} frames)", + symbol.symbol, symbol.frames + ), + ] + }) + .collect() +} + +fn missed_row(missed: &MissedFill) -> Vec { + vec![ + "Missed fill".to_string(), + missed + .hypothetical_pnl + .map_or_else(|| "—".to_string(), |pnl| format!("~{}", signed(pnl))), + missed_story(missed), + ] +} + +fn attribution_row(slice: &OriginSlice) -> Vec { + vec![ + "Attribution".to_string(), + "—".to_string(), + format!( + "{}: {} fill(s), {:.2} notional, fees {}, {} with a logged reason", + origin_label(slice.origin), + slice.trades, + rounded(slice.gross_notional, 2), + signed(slice.fees), + slice.decided + ), + ] +} + +fn caveat_row(caveat: &str) -> Vec { + vec!["Caveat".to_string(), "—".to_string(), caveat.to_string()] +} + +/// Signed money for the Amount column. A rounded zero clamps to +0.00 so a +/// break-even line never reads as a loss. +fn signed(amount: Decimal) -> String { + let cents = rounded(amount, 2); + let amount = if cents.is_zero() { + Decimal::ZERO + } else { + cents + }; + format!("{amount:+.2}") +} + +/// A quantity without decimal-tail noise: at most 8 decimals, trimmed. +fn qty(volume: Decimal) -> String { + format!("{:.8}", rounded(volume, 8)) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() +} + +fn component_label(kind: ComponentKind) -> &'static str { + match kind { + ComponentKind::PriceMovement => "Price movement", + ComponentKind::Fees => "Fees", + ComponentKind::Spread => "Spread", + ComponentKind::Slippage => "Slippage", + ComponentKind::Residual => "Residual", + } +} + +fn missed_story(missed: &MissedFill) -> String { + let order = format!( + "{} ({} {} {} @ {:.2}, {})", + missed.order_id, + missed.side, + qty(missed.volume), + missed.pair, + rounded(missed.limit_price, 2), + missed.outcome + ); + match missed.crossed { + None => { + let reason = missed + .uncovered_reason + .as_deref() + .unwrap_or("coverage unknown"); + format!("{order}: the recording cannot answer whether it would have filled — {reason}") + } + Some(false) => format!("{order}: the recorded stream never touched the limit price"), + Some(true) => format!( + "{order}: the recorded stream first crossed the limit at {}; not part of the sum above", + missed + .first_crossed_at + .map(kraken_recording::format_instant) + .unwrap_or_else(|| "?".to_string()) + ), + } +} + +fn origin_label(origin: Origin) -> &'static str { + match origin { + Origin::Cli => "you (CLI)", + Origin::Mcp => "an agent (MCP)", + } +} + +#[cfg(test)] +mod tests { + use rust_decimal_macros::dec; + + use super::*; + + #[test] + fn signed_clamps_subcent_noise_to_break_even() { + assert_eq!(signed(-dec!(0.0049)), "+0.00"); + assert_eq!(signed(-dec!(0.0)), "+0.00"); + assert_eq!(signed(-dec!(0.005)), "-0.01"); + assert_eq!(signed(dec!(81.987)), "+81.99"); + } + + #[test] + fn qty_trims_trailing_zeros_and_point() { + assert_eq!(qty(dec!(0.01)), "0.01"); + assert_eq!(qty(dec!(0.1) + dec!(0.2)), "0.3"); + assert_eq!(qty(dec!(1.0)), "1"); + } +} diff --git a/src/commands/feedback.rs b/src/commands/feedback.rs new file mode 100644 index 0000000..6f6ea91 --- /dev/null +++ b/src/commands/feedback.rs @@ -0,0 +1,430 @@ +//! `kraken feedback`: capture product feedback from agents and users, upload +//! it to Kraken's ingest endpoint, and mirror a successful submission in a +//! local DuckDB under the Kraken config directory. +//! +//! Bodies stay free-form (`--input-string` / `--input-file`). Soft structure +//! recommendations live in help text only — the submit path never enforces +//! them. Upload is fail-closed: a non-2xx or network error fails the command +//! and nothing is written locally. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use clap::ValueEnum; +use serde_json::json; + +use super::Execute; +use crate::cli::AppContext; +use crate::client; +use crate::config; +use crate::errors::{KrakenError, Result}; +use crate::output::CommandOutput; +use crate::telemetry; + +/// Hard cap on the feedback body (UTF-8 byte length after trim). +pub(crate) const MAX_BODY_BYTES: usize = 8192; + +/// Production ingest endpoint (Cloudflare Worker on the kraken-prod account). +pub(crate) const DEFAULT_FEEDBACK_ENDPOINT: &str = "https://cli-feedback.kraken.com/v1/feedback"; + +const UPLOAD_TIMEOUT: Duration = Duration::from_secs(10); + +pub(crate) const LONG_ABOUT: &str = "\ +Submit product feedback so we can continuously improve kraken-cli and adapt it \ +to what users and agents need. + +Submitting uploads the feedback text (and metadata such as CLI version, \ +instance id, and optional --llm-model) to Kraken over HTTPS. A copy is also \ +saved locally under the Kraken config directory as a DuckDB file after a \ +successful upload. Do not include API keys, secrets, passwords, or personal \ +data in the body. + +Types (soft recommendations for the body; only a string is required): + feature-request Desired outcome, why it matters, and any current workaround. + friction What you tried, what felt wrong, and expected vs actual. + bug Description, steps to reproduce, and versions (CLI via \ +`kraken --version`, OS, skill name/version if relevant). Prefer --llm-model \ +for the model identity instead of burying it only in the body. + +Examples: + kraken feedback bug --input-string \"order buy rejected with unclear error\" -o json + kraken feedback friction --input-file ./notes.txt --llm-model claude-opus-4 -o json + +The body must be non-empty after trim and at most 8192 UTF-8 bytes. A failed \ +upload fails the command (nothing is stored locally). +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub(crate) enum FeedbackType { + /// Request a new capability or improvement. + #[value(name = "feature-request")] + FeatureRequest, + /// Report awkward or confusing CLI/skill behaviour. + Friction, + /// Report incorrect or broken behaviour. + Bug, +} + +impl FeedbackType { + fn as_str(self) -> &'static str { + match self { + Self::FeatureRequest => "feature-request", + Self::Friction => "friction", + Self::Bug => "bug", + } + } +} + +/// Submit product feedback (feature request, friction, or bug). +#[derive(Debug, clap::Args)] +pub(crate) struct Feedback { + /// Feedback category: feature-request, friction, or bug. + #[arg(value_enum)] + feedback_type: FeedbackType, + + /// Inline feedback body (mutually exclusive with --input-file). + #[arg( + long, + required_unless_present = "input_file", + conflicts_with = "input_file" + )] + input_string: Option, + + /// Read the feedback body from a UTF-8 file (mutually exclusive with --input-string). + #[arg( + long, + required_unless_present = "input_string", + conflicts_with = "input_string" + )] + input_file: Option, + + /// Optional LLM / agent model identity (e.g. claude-opus-4, gpt-5). + #[arg(long)] + llm_model: Option, +} + +impl Execute for Feedback { + async fn execute(self, ctx: &AppContext) -> Result { + let body = resolve_body(self.input_string.as_deref(), self.input_file.as_deref())?; + let llm_model = self + .llm_model + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + let event_id = telemetry::new_uuid_v4(); + let created_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let cli_version = env!("CARGO_PKG_VERSION").to_string(); + let payload = json!({ + "event_id": event_id, + "type": self.feedback_type.as_str(), + "body": body, + "cli_version": cli_version, + "instance_id": telemetry::instance_id(), + "agent_client": telemetry::agent_client(), + "llm_model": llm_model, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "created_at": created_at, + }); + + upload_feedback(ctx, &payload).await?; + + let stored = persist( + self.feedback_type, + &body, + llm_model.as_deref(), + &event_id, + &created_at, + )?; + + let mut pairs = vec![ + ("Status".into(), "submitted".into()), + ("Id".into(), stored.id.to_string()), + ("EventId".into(), event_id.clone()), + ("Type".into(), self.feedback_type.as_str().into()), + ("Path".into(), stored.path.display().to_string()), + ("CliVersion".into(), stored.cli_version.clone()), + ]; + if let Some(model) = &stored.llm_model { + pairs.push(("LlmModel".into(), model.clone())); + } + + let json = json!({ + "status": "submitted", + "id": stored.id, + "event_id": event_id, + "type": self.feedback_type.as_str(), + "path": stored.path, + "cli_version": stored.cli_version, + "llm_model": stored.llm_model, + "message": "Feedback submitted to Kraken. Thank you — we use this to improve kraken-cli.", + }); + Ok(CommandOutput::key_value(pairs, json)) + } +} + +fn resolve_body(input_string: Option<&str>, input_file: Option<&Path>) -> Result { + let raw = match (input_string, input_file) { + (Some(s), None) => s.to_string(), + (None, Some(path)) => std::fs::read_to_string(path).map_err(|e| { + KrakenError::Validation(format!( + "failed to read --input-file '{}': {e}", + path.display() + )) + })?, + _ => { + return Err(KrakenError::Validation( + "provide exactly one of --input-string or --input-file".into(), + )); + } + }; + let body = raw.trim().to_string(); + if body.is_empty() { + return Err(KrakenError::Validation( + "feedback body must not be empty".into(), + )); + } + if body.len() > MAX_BODY_BYTES { + return Err(KrakenError::Validation(format!( + "feedback body exceeds the maximum length of {MAX_BODY_BYTES} bytes \ + (got {} bytes after trim)", + body.len() + ))); + } + Ok(body) +} + +fn resolve_feedback_endpoint(allow_any_host: bool) -> Result { + match config::feedback_endpoint_override() { + None => Ok(DEFAULT_FEEDBACK_ENDPOINT.to_string()), + Some(url) if allow_any_host => { + // Test / staging hook: with KRAKEN_DANGER_ALLOW_ANY_URL_HOST, accept + // http(s) to any host so local wiremock can stand in for the Worker. + let parsed = url::Url::parse(&url) + .map_err(|_| KrakenError::Validation(format!("Invalid URL: {url}")))?; + let scheme = parsed.scheme(); + if scheme != "https" && scheme != "http" { + return Err(KrakenError::Validation(format!( + "Unsupported URL scheme '{scheme}' for {}", + config::FEEDBACK_ENDPOINT_ENV + ))); + } + if parsed.host().is_none() { + return Err(KrakenError::Validation( + "URL override must include a host".into(), + )); + } + if scheme == "http" { + tracing::warn!( + %url, + "using insecure http feedback endpoint because \ + KRAKEN_DANGER_ALLOW_ANY_URL_HOST is enabled" + ); + } + Ok(url) + } + Some(url) => match client::resolve_url_override(Some(&url), false)? { + Some(u) => Ok(u), + None => Ok(DEFAULT_FEEDBACK_ENDPOINT.to_string()), + }, + } +} + +async fn upload_feedback(ctx: &AppContext, payload: &serde_json::Value) -> Result<()> { + let endpoint = resolve_feedback_endpoint(ctx.allow_any_url_host)?; + let http = reqwest::Client::builder() + .use_rustls_tls() + .timeout(UPLOAD_TIMEOUT) + .user_agent(telemetry::user_agent()) + .build() + .map_err(|e| KrakenError::Network(format!("Failed to build HTTP client: {e}")))?; + + let response = http + .post(&endpoint) + .header("content-type", "application/json") + .header("X-Kraken-Cli-Version", env!("CARGO_PKG_VERSION")) + .header("X-Kraken-Instance-Id", telemetry::instance_id()) + .json(payload) + .send() + .await + .map_err(|e| { + KrakenError::Network(format!( + "failed to upload feedback to {endpoint}: {e}. \ + Check network connectivity and try again." + )) + })?; + + let status = response.status(); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let body_text = response.text().await.unwrap_or_default(); + + if status.as_u16() == 202 || status.is_success() { + return Ok(()); + } + + let detail = extract_error_detail(&body_text); + let msg = match status.as_u16() { + 413 => format!( + "feedback upload rejected: body too large (413){}", + detail_suffix(&detail) + ), + 429 => { + let retry = retry_after + .map(|s| format!("; retry after {s}s")) + .unwrap_or_default(); + format!( + "feedback upload rate limited (429){retry}{}", + detail_suffix(&detail) + ) + } + 400..=499 => format!( + "feedback upload rejected by server ({status}){}", + detail_suffix(&detail) + ), + _ => format!( + "feedback upload failed with server error ({status}){}", + detail_suffix(&detail) + ), + }; + Err(KrakenError::Network(msg)) +} + +fn extract_error_detail(body: &str) -> Option { + let trimmed = body.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(v) = serde_json::from_str::(trimmed) + && let Some(err) = v.get("error").and_then(|e| e.as_str()) + { + return Some(err.to_string()); + } + Some(trimmed.chars().take(200).collect()) +} + +fn detail_suffix(detail: &Option) -> String { + match detail { + Some(d) => format!(": {d}"), + None => String::new(), + } +} + +struct StoredFeedback { + id: i64, + path: PathBuf, + cli_version: String, + llm_model: Option, +} + +#[cfg(feature = "record-duckdb")] +fn persist( + feedback_type: FeedbackType, + body: &str, + llm_model: Option<&str>, + event_id: &str, + submitted_at: &str, +) -> Result { + use duckdb::params; + use kraken_recording::frames::duckdb::Db; + + let path = config::config_dir()?.join("feedback.duckdb"); + let db = Db::open(&path)?; + db.ensure_schema(&[ + "CREATE SEQUENCE IF NOT EXISTS feedback_id_seq START 1", + "CREATE TABLE IF NOT EXISTS feedback ( + id UBIGINT PRIMARY KEY DEFAULT nextval('feedback_id_seq'), + created_at TIMESTAMP NOT NULL, + type VARCHAR NOT NULL, + body VARCHAR NOT NULL, + cli_version VARCHAR NOT NULL, + llm_model VARCHAR, + event_id VARCHAR, + submitted_at VARCHAR + )", + "ALTER TABLE feedback ADD COLUMN IF NOT EXISTS event_id VARCHAR", + "ALTER TABLE feedback ADD COLUMN IF NOT EXISTS submitted_at VARCHAR", + ])?; + + let cli_version = env!("CARGO_PKG_VERSION").to_string(); + let created_at = chrono::Utc::now() + .format("%Y-%m-%d %H:%M:%S%.3f") + .to_string(); + + let mut stmt = db + .conn() + .prepare( + "INSERT INTO feedback (created_at, type, body, cli_version, llm_model, event_id, submitted_at) + VALUES (CAST(? AS TIMESTAMP), ?, ?, ?, ?, ?, ?) + RETURNING id", + ) + .map_err(|e| KrakenError::Io(std::io::Error::other(e)))?; + let id: i64 = stmt + .query_row( + params![ + created_at, + feedback_type.as_str(), + body, + cli_version, + llm_model, + event_id, + submitted_at, + ], + |row| row.get(0), + ) + .map_err(|e| KrakenError::Io(std::io::Error::other(e)))?; + + Ok(StoredFeedback { + id, + path, + cli_version, + llm_model: llm_model.map(str::to_string), + }) +} + +#[cfg(not(feature = "record-duckdb"))] +fn persist( + _feedback_type: FeedbackType, + _body: &str, + _llm_model: Option<&str>, + _event_id: &str, + _submitted_at: &str, +) -> Result { + Err(KrakenError::Validation( + "this build has no DuckDB support; rebuild with `--features record-duckdb` \ + to store feedback locally" + .into(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty_body() { + let err = resolve_body(Some(" \n"), None).unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + } + + #[test] + fn rejects_oversized_body() { + let big = "x".repeat(MAX_BODY_BYTES + 1); + let err = resolve_body(Some(&big), None).unwrap_err(); + assert!(err.to_string().contains("maximum length")); + } + + #[test] + fn accepts_max_sized_body() { + let body = "x".repeat(MAX_BODY_BYTES); + assert_eq!( + resolve_body(Some(&body), None).unwrap().len(), + MAX_BODY_BYTES + ); + } +} diff --git a/src/commands/funding.rs b/src/commands/funding.rs index 692de23..631dc62 100644 --- a/src/commands/funding.rs +++ b/src/commands/funding.rs @@ -2,435 +2,435 @@ use std::collections::HashMap; use clap::Subcommand; +use enum_dispatch::enum_dispatch; -use super::helpers::{confirm_destructive, parse_generic}; -use crate::client::SpotClient; -use crate::config::SpotCredentials; +use super::Execute; +use super::value_types::{AssetClass, CliWire, RebaseOpts}; +use crate::cli::AppContext; use crate::errors::Result; use crate::output::CommandOutput; +#[enum_dispatch(Execute)] #[derive(Debug, Subcommand)] -pub(crate) enum FundingCommand { - /// Get available deposit methods for an asset. - DepositMethods { - /// Asset being deposited (e.g. BTC, ETH). - asset: String, - /// Asset class (currency or tokenized_asset for xstocks). - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, +pub(crate) enum DepositCommand { + /// Get deposit methods for an asset. + Methods(DepositMethods), /// Get deposit addresses. - DepositAddresses { - /// Asset being deposited. - asset: String, - /// Name of the deposit method. - method: String, - /// Generate a new address. - #[arg(long)] - new: bool, - /// Asset class (currency or tokenized_asset for xstocks). - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Amount to deposit (required for Bitcoin Lightning). - #[arg(long)] - amount: Option, - }, - /// Get recent deposit status. - DepositStatus { - /// Filter by asset. - #[arg(long)] - asset: Option, - /// Asset class (currency or tokenized_asset for xstocks). - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Filter by deposit method name. - #[arg(long)] - method: Option, - /// Start timestamp (deposits before this are excluded). - #[arg(long)] - start: Option, - /// End timestamp (deposits after this are excluded). - #[arg(long)] - end: Option, - /// Enable pagination (true/false) or cursor string for next page. - #[arg(long)] - cursor: Option, - /// Number of results per page. - #[arg(long)] - limit: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, - /// Get available withdrawal methods. - WithdrawalMethods { - /// Filter by asset. - #[arg(long)] - asset: Option, - /// Asset class (currency or tokenized_asset for xstocks). - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Filter by network. - #[arg(long)] - network: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + Addresses(DepositAddresses), + /// Get deposit status. + Status(DepositStatus), +} + +#[enum_dispatch(Execute)] +#[derive(Debug, Subcommand)] +pub(crate) enum WithdrawalCommand { + /// Get withdrawal methods. + Methods(WithdrawalMethods), /// Get withdrawal addresses. - WithdrawalAddresses { - /// Filter by asset. - #[arg(long)] - asset: Option, - /// Asset class (currency or tokenized_asset for xstocks). - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Filter by withdrawal method. - #[arg(long)] - method: Option, - /// Find address by withdrawal key name. - #[arg(long)] - key: Option, - /// Filter by verification status. - #[arg(long)] - verified: Option, - }, + Addresses(WithdrawalAddresses), /// Get withdrawal fee info. - WithdrawalInfo { - /// Asset. - asset: String, - /// Withdrawal key name. - key: String, - /// Amount. - amount: String, - }, - /// Make a withdrawal. - Withdraw { - /// Asset being withdrawn. - asset: String, - /// Withdrawal key name, as set up on your account. - key: String, - /// Amount to withdraw. - amount: String, - /// Asset class (currency or tokenized_asset for xstocks). - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Crypto address to confirm it matches the key (returns error if different). - #[arg(long)] - address: Option, - /// Max fee — withdrawal fails with EFunding:Max fee exceeded if fee is higher. - #[arg(long)] - max_fee: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, - /// Get recent withdrawal status. - WithdrawalStatus { - /// Filter by asset. - #[arg(long)] - asset: Option, - /// Asset class (currency or tokenized_asset for xstocks). - #[arg(long, alias = "aclass")] - asset_class: Option, - /// Filter by withdrawal method name. - #[arg(long)] - method: Option, - /// Start timestamp (withdrawals before this are excluded). - #[arg(long)] - start: Option, - /// End timestamp (withdrawals after this are excluded). - #[arg(long)] - end: Option, - /// Enable pagination (true/false) or cursor string for next page. - #[arg(long)] - cursor: Option, - /// Number of results per page. - #[arg(long)] - limit: Option, - /// Rebase multiplier for xstocks data (rebased or base). - #[arg(long)] - rebase_multiplier: Option, - }, + Info(WithdrawalInfo), + /// Get withdrawal status. + Status(WithdrawalStatus), /// Cancel a pending withdrawal. - WithdrawalCancel { - /// Asset being withdrawn. - asset: String, - /// Withdrawal reference ID. - refid: String, - }, - /// Transfer between wallets (spot <-> futures). - WalletTransfer { - /// Asset to transfer. - asset: String, - /// Amount to transfer. - amount: String, - /// Source wallet. - #[arg(long)] - from: String, - /// Destination wallet. - #[arg(long)] - to: String, - }, + Cancel(WithdrawalCancel), } -pub(crate) async fn execute( - cmd: &FundingCommand, - client: &SpotClient, - creds: &SpotCredentials, - otp: Option<&str>, - force: bool, - verbose: bool, -) -> Result { - match cmd { - FundingCommand::DepositMethods { +#[derive(Debug, clap::Args)] +pub(crate) struct DepositMethods { + asset: String, + /// Asset class (currency or tokenized_asset for xstocks). + #[arg(long, alias = "aclass")] + asset_class: Option, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for DepositMethods { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, asset_class, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - params.insert("asset".into(), asset.clone()); - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("DepositMethods", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + rebase, + } = self; + let mut params = HashMap::new(); + params.insert("asset".into(), asset); + if let Some(ac) = asset_class { + params.insert("aclass".into(), ac.as_wire()); } - FundingCommand::DepositAddresses { + rebase.apply(&mut params); + let data = client + .private_post("DepositMethods", params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct DepositAddresses { + asset: String, + method: String, + #[arg(long)] + new: bool, + /// Asset class (currency or tokenized_asset for xstocks). + #[arg(long, alias = "aclass")] + asset_class: Option, + /// Amount to deposit (required for Bitcoin Lightning). + #[arg(long)] + amount: Option, +} + +impl Execute for DepositAddresses { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, method, new, asset_class, amount, - } => { - let mut params = HashMap::new(); - params.insert("asset".into(), asset.clone()); - params.insert("method".into(), method.clone()); - if *new { - params.insert("new".into(), "true".into()); - } - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(amt) = amount { - params.insert("amount".into(), amt.clone()); - } - let idempotent = !*new; - let data = client - .private_post("DepositAddresses", params, creds, otp, idempotent, verbose) - .await?; - Ok(parse_generic(&data)) + } = self; + let mut params = HashMap::new(); + params.insert("asset".into(), asset); + params.insert("method".into(), method); + if new { + params.insert("new".into(), "true".into()); } - FundingCommand::DepositStatus { - asset, - asset_class, - method, - start, - end, - cursor, - limit, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if let Some(a) = asset { - params.insert("asset".into(), a.clone()); - } - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(m) = method { - params.insert("method".into(), m.clone()); - } - if let Some(s) = start { - params.insert("start".into(), s.clone()); - } - if let Some(e) = end { - params.insert("end".into(), e.clone()); - } - if let Some(c) = cursor { - params.insert("cursor".into(), c.clone()); - } - if let Some(l) = limit { - params.insert("limit".into(), l.to_string()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("DepositStatus", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + if let Some(ac) = asset_class { + params.insert("aclass".into(), ac.as_wire()); + } + if let Some(amt) = amount { + params.insert("amount".into(), amt); } - FundingCommand::WithdrawalMethods { + let idempotent = !new; + let data = client + .private_post("DepositAddresses", params, creds, otp, idempotent) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +/// Filter surface shared verbatim by `deposit status` and `withdrawal status`; +/// the wire endpoint is the only divergence between the two commands. +#[derive(Debug, clap::Args)] +pub(crate) struct FundingStatusFilter { + #[arg(long)] + asset: Option, + /// Asset class (currency or tokenized_asset for xstocks). + #[arg(long, alias = "aclass")] + asset_class: Option, + /// Filter by transfer method name. + #[arg(long)] + method: Option, + /// Start timestamp (transfers before this are excluded). + #[arg(long)] + start: Option, + /// End timestamp (transfers after this are excluded). + #[arg(long)] + end: Option, + /// Enable pagination (true/false) or cursor for next page. + #[arg(long)] + cursor: Option, + /// Number of results per page. + #[arg(long)] + limit: Option, + #[command(flatten)] + rebase: RebaseOpts, +} + +async fn fetch_funding_status( + ctx: &AppContext, + endpoint: &str, + filter: FundingStatusFilter, +) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let FundingStatusFilter { + asset, + asset_class, + method, + start, + end, + cursor, + limit, + rebase, + } = filter; + let mut params = HashMap::new(); + if let Some(a) = asset { + params.insert("asset".into(), a); + } + if let Some(ac) = asset_class { + params.insert("aclass".into(), ac.as_wire()); + } + if let Some(m) = method { + params.insert("method".into(), m); + } + if let Some(s) = start { + params.insert("start".into(), s); + } + if let Some(e) = end { + params.insert("end".into(), e); + } + if let Some(c) = cursor { + params.insert("cursor".into(), c); + } + if let Some(l) = limit { + params.insert("limit".into(), l.to_string()); + } + rebase.apply(&mut params); + let data = client + .private_post(endpoint, params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) +} + +#[derive(Debug, clap::Args)] +pub(crate) struct DepositStatus { + #[command(flatten)] + filter: FundingStatusFilter, +} + +impl Execute for DepositStatus { + async fn execute(self, ctx: &AppContext) -> Result { + fetch_funding_status(ctx, "DepositStatus", self.filter).await + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct WithdrawalMethods { + #[arg(long)] + asset: Option, + /// Asset class (currency or tokenized_asset for xstocks). + #[arg(long, alias = "aclass")] + asset_class: Option, + /// Filter by network. + #[arg(long)] + network: Option, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for WithdrawalMethods { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, asset_class, network, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if let Some(a) = asset { - params.insert("asset".into(), a.clone()); - } - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(n) = network { - params.insert("network".into(), n.clone()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("WithdrawMethods", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + rebase, + } = self; + let mut params = HashMap::new(); + if let Some(a) = asset { + params.insert("asset".into(), a); + } + if let Some(ac) = asset_class { + params.insert("aclass".into(), ac.as_wire()); } - FundingCommand::WithdrawalAddresses { + if let Some(n) = network { + params.insert("network".into(), n); + } + rebase.apply(&mut params); + let data = client + .private_post("WithdrawMethods", params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct WithdrawalAddresses { + #[arg(long)] + asset: Option, + /// Asset class (currency or tokenized_asset for xstocks). + #[arg(long, alias = "aclass")] + asset_class: Option, + /// Filter by withdrawal method. + #[arg(long)] + method: Option, + /// Find address by withdrawal key name. + #[arg(long)] + key: Option, + /// Filter by verification status. + #[arg(long)] + verified: Option, +} + +impl Execute for WithdrawalAddresses { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, asset_class, method, key, verified, - } => { - let mut params = HashMap::new(); - if let Some(a) = asset { - params.insert("asset".into(), a.clone()); - } - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(m) = method { - params.insert("method".into(), m.clone()); - } - if let Some(k) = key { - params.insert("key".into(), k.clone()); - } - if let Some(v) = verified { - params.insert("verified".into(), v.to_string()); - } - let data = client - .private_post("WithdrawAddresses", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + } = self; + let mut params = HashMap::new(); + if let Some(a) = asset { + params.insert("asset".into(), a); + } + if let Some(ac) = asset_class { + params.insert("aclass".into(), ac.as_wire()); + } + if let Some(m) = method { + params.insert("method".into(), m); } - FundingCommand::WithdrawalInfo { asset, key, amount } => { - let mut params = HashMap::new(); - params.insert("asset".into(), asset.clone()); - params.insert("key".into(), key.clone()); - params.insert("amount".into(), amount.clone()); - let data = client - .private_post("WithdrawInfo", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + if let Some(k) = key { + params.insert("key".into(), k); } - FundingCommand::Withdraw { + if let Some(v) = verified { + params.insert("verified".into(), v.to_string()); + } + let data = client + .private_post("WithdrawAddresses", params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct WithdrawalInfo { + asset: String, + key: String, + amount: String, +} + +impl Execute for WithdrawalInfo { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, key, amount } = self; + let mut params = HashMap::new(); + params.insert("asset".into(), asset); + params.insert("key".into(), key); + params.insert("amount".into(), amount); + let data = client + .private_post("WithdrawInfo", params, creds, otp, true) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Withdraw { + /// Asset being withdrawn. + asset: String, + /// Withdrawal key name, as set up on your account. + key: String, + /// Amount to withdraw. + amount: String, + /// Asset class (currency or tokenized_asset for xstocks). + #[arg(long, alias = "aclass")] + asset_class: Option, + /// Crypto address to confirm it matches the key. + #[arg(long)] + address: Option, + /// Max fee — fails if processed fee exceeds this. + #[arg(long)] + max_fee: Option, + #[command(flatten)] + rebase: RebaseOpts, +} + +impl Execute for Withdraw { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, key, amount, asset_class, address, max_fee, - rebase_multiplier, - } => { - if !force { - confirm_destructive(&format!("Withdraw {amount} {asset} to key '{key}'?"))?; - } - let mut params = HashMap::new(); - params.insert("asset".into(), asset.clone()); - params.insert("key".into(), key.clone()); - params.insert("amount".into(), amount.clone()); - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(addr) = address { - params.insert("address".into(), addr.clone()); - } - if let Some(mf) = max_fee { - params.insert("max_fee".into(), mf.clone()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("Withdraw", params, creds, otp, false, verbose) - .await?; - Ok(parse_generic(&data)) + rebase, + } = self; + ctx.confirm_destructive(&format!("Withdraw {amount} {asset} to key '{key}'?"))?; + let mut params = HashMap::new(); + params.insert("asset".into(), asset); + params.insert("key".into(), key); + params.insert("amount".into(), amount); + if let Some(ac) = asset_class { + params.insert("aclass".into(), ac.as_wire()); } - FundingCommand::WithdrawalStatus { - asset, - asset_class, - method, - start, - end, - cursor, - limit, - rebase_multiplier, - } => { - let mut params = HashMap::new(); - if let Some(a) = asset { - params.insert("asset".into(), a.clone()); - } - if let Some(ac) = asset_class { - params.insert("aclass".into(), ac.clone()); - } - if let Some(m) = method { - params.insert("method".into(), m.clone()); - } - if let Some(s) = start { - params.insert("start".into(), s.clone()); - } - if let Some(e) = end { - params.insert("end".into(), e.clone()); - } - if let Some(c) = cursor { - params.insert("cursor".into(), c.clone()); - } - if let Some(l) = limit { - params.insert("limit".into(), l.to_string()); - } - if let Some(rm) = rebase_multiplier { - params.insert("rebase_multiplier".into(), rm.clone()); - } - let data = client - .private_post("WithdrawStatus", params, creds, otp, true, verbose) - .await?; - Ok(parse_generic(&data)) + if let Some(addr) = address { + params.insert("address".into(), addr); } - FundingCommand::WithdrawalCancel { asset, refid } => { - let mut params = HashMap::new(); - params.insert("asset".into(), asset.clone()); - params.insert("refid".into(), refid.clone()); - let data = client - .private_post("WithdrawCancel", params, creds, otp, false, verbose) - .await?; - Ok(parse_generic(&data)) + if let Some(mf) = max_fee { + params.insert("max_fee".into(), mf); } - FundingCommand::WalletTransfer { + rebase.apply(&mut params); + let data = client + .private_post("Withdraw", params, creds, otp, false) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct WithdrawalStatus { + #[command(flatten)] + filter: FundingStatusFilter, +} + +impl Execute for WithdrawalStatus { + async fn execute(self, ctx: &AppContext) -> Result { + fetch_funding_status(ctx, "WithdrawStatus", self.filter).await + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct WithdrawalCancel { + /// Asset being withdrawn. + asset: String, + /// Withdrawal reference ID. + refid: String, +} + +impl Execute for WithdrawalCancel { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, refid } = self; + let mut params = HashMap::new(); + params.insert("asset".into(), asset); + params.insert("refid".into(), refid); + let data = client + .private_post("WithdrawCancel", params, creds, otp, false) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct WalletTransfer { + /// Asset. + asset: String, + /// Amount. + amount: String, + /// Source wallet. + #[arg(long)] + from: String, + /// Destination wallet. + #[arg(long)] + to: String, +} + +impl Execute for WalletTransfer { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds, otp) = ctx.spot_authed()?; + let Self { asset, amount, from, to, - } => { - if !force { - confirm_destructive(&format!("Transfer {amount} {asset} from {from} to {to}?"))?; - } - let mut params = HashMap::new(); - params.insert("asset".into(), asset.clone()); - params.insert("amount".into(), amount.clone()); - params.insert("from".into(), from.clone()); - params.insert("to".into(), to.clone()); - let data = client - .private_post("WalletTransfer", params, creds, otp, false, verbose) - .await?; - Ok(parse_generic(&data)) - } + } = self; + ctx.confirm_destructive(&format!("Transfer {amount} {asset} from {from} to {to}?"))?; + let mut params = HashMap::new(); + params.insert("asset".into(), asset); + params.insert("amount".into(), amount); + params.insert("from".into(), from); + params.insert("to".into(), to); + let data = client + .private_post("WalletTransfer", params, creds, otp, false) + .await?; + Ok(CommandOutput::from_untyped(&data)) } } diff --git a/src/commands/futures.rs b/src/commands/futures.rs index 04eb509..544c9c3 100644 --- a/src/commands/futures.rs +++ b/src/commands/futures.rs @@ -2,13 +2,17 @@ use std::collections::HashMap; use clap::Subcommand; +use enum_dispatch::enum_dispatch; +use kraken_core::OrderSide; use serde_json::Value; -use super::helpers::{confirm_destructive, jstr, parse_generic}; -use crate::client::FuturesClient; -use crate::config::FuturesCredentials; +use super::Execute; +use super::value_types::{CliWire, ContractType, TrailingUnit}; +use crate::cli::AppContext; use crate::errors::{KrakenError, Result}; +use crate::futures_paper::{FuturesOrderType, TriggerSignal}; use crate::output::CommandOutput; +use crate::output::json_field; /// Boolean-like value for CLI args (accepts true/false, yes/no, 1/0). #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] @@ -29,821 +33,927 @@ impl BoolValue { } } +/// The `kraken futures` surface: request/response commands (flattened), plus +/// the streaming WS subtree and the paper engine, which route outside the +/// shared executor — mirroring how top-level `ws` and `paper` are siblings of +/// the request/response tree. +#[enum_dispatch(Execute)] #[derive(Debug, Subcommand)] pub(crate) enum FuturesCommand { + #[command(flatten)] + Market(FuturesMarketCommand), + + #[command(flatten)] + Trading(FuturesTradingCommand), + + /// Futures WebSocket streaming commands. + #[command(subcommand)] + Ws(super::futures_ws::FuturesWsCommand), + + /// Futures paper trading (simulated, no real money). + #[command(subcommand)] + Paper(super::futures_paper::FuturesPaperCommand), +} + +/// Public futures market data (no credentials required). +#[enum_dispatch(Execute)] +#[derive(Debug, Subcommand)] +pub(crate) enum FuturesMarketCommand { /// List all futures instruments/contracts. - Instruments, + Instruments(Instruments), /// Get all futures tickers. - Tickers, + Tickers(Tickers), /// Get ticker for a single contract. - Ticker { - /// Futures symbol (e.g. PI_XBTUSD). - symbol: String, - }, + Ticker(Ticker), /// Get futures order book. - Orderbook { - /// Futures symbol. - symbol: String, - }, + Orderbook(Orderbook), /// Get recent futures trade history. - History { - /// Futures symbol. - symbol: String, - /// Fetch history since this timestamp. - #[arg(long)] - since: Option, - /// Fetch history before this timestamp. - #[arg(long)] - before: Option, - }, + History(History), /// Get fee schedules (volumes and fees per contract). - Feeschedules, + Feeschedules(Feeschedules), /// Get instrument status (all or per symbol). - InstrumentStatus { - /// Specific symbol to query (omit for all). - #[arg(long)] - symbol: Option, - }, - /// Get trading instruments (optional filter by contract type) (auth required). - TradingInstruments { - /// Filter by contract type: futures_inverse, futures_vanilla, flexible_futures. - #[arg(long)] - contract_type: Option, - }, + InstrumentStatus(InstrumentStatus), /// Get historical funding rates. - HistoricalFundingRates { - /// Futures symbol (e.g. PI_XBTUSD). - symbol: String, - }, + HistoricalFundingRates(HistoricalFundingRates), +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Instruments {} + +impl Execute for Instruments { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let data = client.public_get("instruments", &[]).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Tickers {} + +impl Execute for Tickers { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let data = client.public_get("tickers", &[]).await?; + Ok(parse_tickers(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Ticker { + /// Futures symbol (e.g. PI_XBTUSD). + symbol: String, +} + +impl Execute for Ticker { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let Self { symbol } = self; + validate_path_segment(&symbol, "symbol")?; + let endpoint = format!("tickers/{symbol}"); + let data = client.public_get(&endpoint, &[]).await?; + Ok(parse_ticker(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Orderbook { + /// Futures symbol. + symbol: String, +} + +impl Execute for Orderbook { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let Self { symbol } = self; + validate_path_segment(&symbol, "symbol")?; + let data = client + .public_get("orderbook", &[("symbol", symbol.as_str())]) + .await?; + Ok(parse_orderbook(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct History { + /// Futures symbol. + symbol: String, + /// Fetch history since this timestamp. + #[arg(long)] + since: Option, + /// Fetch history before this timestamp. + #[arg(long)] + before: Option, +} + +impl Execute for History { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let Self { + symbol, + since, + before, + } = self; + validate_path_segment(&symbol, "symbol")?; + let mut params: Vec<(&str, &str)> = vec![("symbol", symbol.as_str())]; + if let Some(s) = since.as_deref() { + params.push(("since", s)); + } + if let Some(b) = before.as_deref() { + params.push(("before", b)); + } + let data = client.public_get("history", ¶ms).await?; + Ok(parse_trade_history(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Feeschedules {} + +impl Execute for Feeschedules { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let data = client.public_get("feeschedules", &[]).await?; + Ok(parse_feeschedules(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct InstrumentStatus { + /// Specific symbol to query (omit for all). + #[arg(long)] + symbol: Option, +} + +impl Execute for InstrumentStatus { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let Self { symbol } = self; + let endpoint = match symbol { + Some(s) => { + validate_path_segment(&s, "symbol")?; + format!("instruments/{s}/status") + } + None => "instruments/status".to_string(), + }; + let data = client.public_get(&endpoint, &[]).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct HistoricalFundingRates { + /// Futures symbol (e.g. PI_XBTUSD). + symbol: String, +} - // === Private commands === +impl Execute for HistoricalFundingRates { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.futures()?; + let Self { symbol } = self; + validate_path_segment(&symbol, "symbol")?; + let params = [("symbol", symbol.as_str())]; + let data = client + .public_get("historical-funding-rates", ¶ms) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +/// Authenticated futures REST: account data, orders, transfers, subaccounts. +#[enum_dispatch(Execute)] +#[derive(Debug, Subcommand)] +pub(crate) enum FuturesTradingCommand { + /// Get trading instruments (optional filter by contract type) (auth required). + TradingInstruments(TradingInstruments), /// Get futures account/wallet info (auth required). - Accounts, + Accounts(Accounts), /// Get open futures orders (auth required). - OpenOrders, + OpenOrders(OpenOrders), /// Get status of orders by ID (auth required). - OrderStatus { - /// Order ID to query. - #[arg(required = true, num_args = 1.., value_name = "ORDER_ID")] - order_ids: Vec, - }, + OrderStatus(OrderStatus), /// Place a futures order (auth required). - Order { - /// Direction: buy or sell. - #[command(subcommand)] - direction: FuturesOrderDirection, - }, + #[command(subcommand)] + Order(FuturesOrderDirection), /// Edit an existing futures order (auth required). - EditOrder { - /// Order ID to edit. - #[arg(long)] - order_id: String, - /// New order size. - #[arg(long)] - size: Option, - /// New limit price. - #[arg(long)] - price: Option, - /// New stop price. - #[arg(long)] - stop_price: Option, - }, + EditOrder(EditOrder), /// Cancel a futures order by order_id or client order id (auth required). - Cancel { - /// Exchange order ID. - #[arg(long, required_unless_present = "cli_ord_id")] - order_id: Option, - /// Client order ID. - #[arg(long, required_unless_present = "order_id")] - cli_ord_id: Option, - }, + Cancel(Cancel), /// Cancel all futures orders (auth required). - CancelAll { - /// Filter by symbol. - #[arg(long)] - symbol: Option, - }, + CancelAll(CancelAll), /// Dead man's switch: cancel all orders after timeout (auth required). - CancelAfter { - /// Timeout in seconds (0 to disable). - timeout: u64, - }, + CancelAfter(CancelAfter), /// Place a batch of futures orders (auth required). - BatchOrder { - /// Orders as JSON array or path to JSON file (prefix with @). - orders_json: String, - }, + BatchOrder(BatchOrder), /// Get open futures positions (auth required). - Positions, + Positions(Positions), /// Get recent fills (auth required). - Fills { - /// Filter since this timestamp. - #[arg(long)] - since: Option, - }, + Fills(Fills), /// Get current leverage preference (auth required). - Leverage { - /// Filter by symbol (omit for all). - #[arg(long)] - symbol: Option, - }, + Leverage(Leverage), /// Set leverage preference for a symbol (auth required). - SetLeverage { - /// Futures symbol. - symbol: String, - /// Max leverage value (omit to use cross margin). - #[arg(required = false)] - leverage: Option, - }, + SetLeverage(SetLeverage), /// Get PnL preferences (auth required). - PnlPreferences, + PnlPreferences(PnlPreferences), /// Set PnL preference for a symbol (auth required). - SetPnlPreference { - /// Futures symbol (e.g. PF_XBTUSD). - symbol: String, - /// PnL preference asset (e.g. USD). - preference: String, - }, + SetPnlPreference(SetPnlPreference), /// Get notifications (auth required). - Notifications, + Notifications(Notifications), /// Get execution history (auth required). - HistoryExecutions { - /// Filter since this timestamp. - #[arg(long)] - since: Option, - /// Filter before this timestamp. - #[arg(long)] - before: Option, - /// Sort order (asc or desc). - #[arg(long)] - sort: Option, - }, + HistoryExecutions(HistoryExecutions), /// Get order history (auth required). - HistoryOrders { - /// Filter since this timestamp. - #[arg(long)] - since: Option, - /// Filter before this timestamp. - #[arg(long)] - before: Option, - /// Sort order (asc or desc). - #[arg(long)] - sort: Option, - }, + HistoryOrders(HistoryOrders), /// Get trigger history (auth required). - HistoryTriggers { - /// Filter since this timestamp. - #[arg(long)] - since: Option, - /// Filter before this timestamp. - #[arg(long)] - before: Option, - /// Sort order (asc or desc). - #[arg(long)] - sort: Option, - }, + HistoryTriggers(HistoryTriggers), /// Download account log as CSV (auth required). - HistoryAccountLogCsv { - /// Filter since this timestamp. - #[arg(long)] - since: Option, - /// Filter before this timestamp. - #[arg(long)] - before: Option, - }, + HistoryAccountLogCsv(HistoryAccountLogCsv), /// Get futures transfer history (auth required). - Transfers, + Transfers(Transfers), /// Transfer funds between spot and futures wallets (auth required). - Transfer { - /// Amount. - amount: String, - /// Currency (e.g. USD, XBT). - currency: String, - }, + Transfer(Transfer), /// Get unwind queue (auth required). - UnwindQueue, + UnwindQueue(UnwindQueue), /// Get current assignment programs (auth required). - AssignmentPrograms, + AssignmentPrograms(AssignmentPrograms), /// Get fee schedule volumes (auth required). - FeeScheduleVolumes, + FeeScheduleVolumes(FeeScheduleVolumes), /// Get subaccounts (auth required). - Subaccounts, + Subaccounts(Subaccounts), /// Get trading-enabled status for a subaccount (auth required). - SubaccountStatus { - /// Subaccount UID. - subaccount_uid: String, - }, + SubaccountStatus(SubaccountStatus), /// Set trading-enabled for a subaccount (auth required). - SetSubaccountStatus { - /// Subaccount UID. - subaccount_uid: String, - /// Enable or disable trading (true/false, yes/no, 1/0). - trading_enabled: BoolValue, - }, + SetSubaccountStatus(SetSubaccountStatus), /// Transfer between wallets (auth required). - WalletTransfer { - /// Source account. - from_account: String, - /// Destination account. - to_account: String, - /// Currency unit (e.g. USD, XBT). - unit: String, - /// Amount to transfer. - amount: String, - }, - - /// Futures WebSocket streaming commands. - Ws { - #[command(subcommand)] - cmd: super::futures_ws::FuturesWsCommand, - }, + WalletTransfer(WalletTransfer), +} - /// Futures paper trading (simulated, no real money). - Paper { - #[command(subcommand)] - cmd: super::futures_paper::FuturesPaperCommand, - }, +#[derive(Debug, clap::Args)] +pub(crate) struct TradingInstruments { + /// Filter by contract type. + #[arg(long)] + contract_type: Option, } -#[derive(Debug, Subcommand)] -pub(crate) enum FuturesOrderDirection { - /// Place a buy order. - Buy { - /// Futures symbol. - symbol: String, - /// Order size. - size: String, - /// Order type. - #[arg(long, default_value = "limit", value_parser = ["limit", "post", "market", "stop", "take-profit", "ioc", "trailing-stop", "fok"])] - r#type: String, - /// Limit price. - #[arg(long)] - price: Option, - /// Stop price (for stop and take-profit orders). - #[arg(long)] - stop_price: Option, - /// Trigger signal: mark, index, or last. - #[arg(long, value_parser = ["mark", "index", "last"])] - trigger_signal: Option, - /// Client order ID for correlation. - #[arg(long)] - client_order_id: Option, - /// Reduce-only flag. - #[arg(long)] - reduce_only: bool, - /// Trailing stop max deviation. - #[arg(long)] - trailing_stop_max_deviation: Option, - /// Trailing stop deviation unit (percent or quote_currency). - #[arg(long)] - trailing_stop_deviation_unit: Option, - }, - /// Place a sell order. - Sell { - /// Futures symbol. - symbol: String, - /// Order size. - size: String, - /// Order type. - #[arg(long, default_value = "limit", value_parser = ["limit", "post", "market", "stop", "take-profit", "ioc", "trailing-stop", "fok"])] - r#type: String, - /// Limit price. - #[arg(long)] - price: Option, - /// Stop price (for stop and take-profit orders). - #[arg(long)] - stop_price: Option, - /// Trigger signal: mark, index, or last. - #[arg(long, value_parser = ["mark", "index", "last"])] - trigger_signal: Option, - /// Client order ID for correlation. - #[arg(long)] - client_order_id: Option, - /// Reduce-only flag. - #[arg(long)] - reduce_only: bool, - /// Trailing stop max deviation. - #[arg(long)] - trailing_stop_max_deviation: Option, - /// Trailing stop deviation unit (percent or quote_currency). - #[arg(long)] - trailing_stop_deviation_unit: Option, - }, -} - -pub(crate) async fn execute( - cmd: &FuturesCommand, - client: &FuturesClient, - creds: Option<&FuturesCredentials>, - _otp: Option<&str>, - force: bool, - verbose: bool, -) -> Result { - match cmd { - FuturesCommand::Instruments => { - let data = client.public_get("instruments", &[], verbose).await?; - Ok(parse_generic(&data)) - } - FuturesCommand::Tickers => { - let data = client.public_get("tickers", &[], verbose).await?; - Ok(parse_tickers(&data)) - } - FuturesCommand::Ticker { symbol } => { - validate_path_segment(symbol, "symbol")?; - let endpoint = format!("tickers/{symbol}"); - let data = client.public_get(&endpoint, &[], verbose).await?; - Ok(parse_ticker(&data)) - } - FuturesCommand::Orderbook { symbol } => { - validate_path_segment(symbol, "symbol")?; - let data = client - .public_get("orderbook", &[("symbol", symbol.as_str())], verbose) - .await?; - Ok(parse_orderbook(&data)) - } - FuturesCommand::History { - symbol, - since, - before, - } => { - validate_path_segment(symbol, "symbol")?; - let mut params: Vec<(&str, &str)> = vec![("symbol", symbol.as_str())]; - let since_owned; - if let Some(s) = since { - since_owned = s.clone(); - params.push(("since", &since_owned)); - } - let before_owned; - if let Some(b) = before { - before_owned = b.clone(); - params.push(("before", &before_owned)); - } - let data = client.public_get("history", ¶ms, verbose).await?; - Ok(parse_trade_history(&data)) - } - FuturesCommand::Feeschedules => { - let data = client.public_get("feeschedules", &[], verbose).await?; - Ok(parse_feeschedules(&data)) - } - FuturesCommand::InstrumentStatus { symbol } => { - let endpoint = match symbol { - Some(s) => { - validate_path_segment(s, "symbol")?; - format!("instruments/{s}/status") - } - None => "instruments/status".to_string(), - }; - let data = client.public_get(&endpoint, &[], verbose).await?; - Ok(parse_generic(&data)) - } - FuturesCommand::TradingInstruments { contract_type } => { - let creds = require_creds(creds)?; - if let Some(ref ct) = contract_type { - validate_contract_type(ct)?; - } - let mut params: Vec<(&str, &str)> = Vec::new(); - let ct_owned; - if let Some(ref ct) = contract_type { - ct_owned = ct.clone(); - params.push(("contractType", &ct_owned)); - } - let data = client - .private_get("trading/instruments", ¶ms, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::HistoricalFundingRates { symbol } => { - validate_path_segment(symbol, "symbol")?; - let params = [("symbol", symbol.as_str())]; - let data = client - .public_get("historical-funding-rates", ¶ms, verbose) - .await?; - Ok(parse_generic(&data)) +impl Execute for TradingInstruments { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { contract_type } = self; + let mut params: Vec<(&str, &str)> = Vec::new(); + let ct_wire; + if let Some(ct) = contract_type { + ct_wire = ct.as_wire(); + params.push(("contractType", &ct_wire)); } + let data = client + .private_get("trading/instruments", ¶ms, creds) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} - // === Private commands === - FuturesCommand::Accounts => { - let creds = require_creds(creds)?; - let data = client.private_get("accounts", &[], creds, verbose).await?; - Ok(parse_accounts(&data)) - } - FuturesCommand::OpenOrders => { - let creds = require_creds(creds)?; - let data = client - .private_get("openorders", &[], creds, verbose) - .await?; - Ok(parse_open_orders(&data)) - } - FuturesCommand::OrderStatus { order_ids } => { - let creds = require_creds(creds)?; - let mut params = HashMap::new(); - params.insert("orderIds".into(), order_ids.join(",")); - let data = client - .private_post("orders/status", params, creds, verbose) - .await?; - Ok(parse_order_status(&data)) - } - FuturesCommand::Order { direction } => { - let creds = require_creds(creds)?; - let params = build_futures_order(direction)?; - let data = client - .private_post("sendorder", params, creds, verbose) - .await?; - Ok(parse_order_response(&data)) - } - FuturesCommand::EditOrder { +#[derive(Debug, clap::Args)] +pub(crate) struct Accounts {} + +impl Execute for Accounts { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("accounts", &[], creds).await?; + Ok(parse_accounts(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct OpenOrders {} + +impl Execute for OpenOrders { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("openorders", &[], creds).await?; + Ok(parse_open_orders(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct OrderStatus { + /// Order ID to query. + #[arg(required = true, num_args = 1.., value_name = "ORDER_ID")] + order_ids: Vec, +} + +impl Execute for OrderStatus { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { order_ids } = self; + let mut params = HashMap::new(); + params.insert("orderIds".into(), order_ids.join(",")); + let data = client.private_post("orders/status", params, creds).await?; + Ok(parse_order_status(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct EditOrder { + /// Order ID to edit. + #[arg(long)] + order_id: String, + /// New order size. + #[arg(long)] + size: Option, + /// New limit price. + #[arg(long)] + price: Option, + /// New stop price. + #[arg(long)] + stop_price: Option, +} + +impl Execute for EditOrder { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { order_id, size, price, stop_price, - } => { - let creds = require_creds(creds)?; - let mut params = HashMap::new(); - params.insert("orderId".into(), order_id.clone()); - if let Some(s) = size { - params.insert("size".into(), s.clone()); - } - if let Some(p) = price { - params.insert("limitPrice".into(), p.clone()); - } - if let Some(sp) = stop_price { - params.insert("stopPrice".into(), sp.clone()); - } - let data = client - .private_post("editorder", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::Cancel { - order_id, - cli_ord_id, - } => { - let creds = require_creds(creds)?; - let mut params = HashMap::new(); - match (order_id, cli_ord_id) { - (Some(oid), None) => { - params.insert("order_id".into(), oid.clone()); - } - (None, Some(cid)) => { - params.insert("cliOrdId".into(), cid.clone()); - } - (Some(_), Some(_)) => { - return Err(KrakenError::Validation( - "Provide exactly one of --order-id or --cli-ord-id".into(), - )); - } - (None, None) => { - return Err(KrakenError::Validation( - "One of --order-id or --cli-ord-id is required".into(), - )); - } - } - let data = client - .private_post("cancelorder", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::CancelAll { symbol } => { - let creds = require_creds(creds)?; - if !force { - confirm_destructive("Cancel ALL open futures orders?")?; - } - let mut params = HashMap::new(); - if let Some(s) = symbol { - params.insert("symbol".into(), s.clone()); - } - let data = client - .private_post("cancelallorders", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) + } = self; + let mut params = HashMap::new(); + params.insert("orderId".into(), order_id); + if let Some(s) = size { + params.insert("size".into(), s); } - FuturesCommand::CancelAfter { timeout } => { - let creds = require_creds(creds)?; - let mut params = HashMap::new(); - params.insert("timeout".into(), timeout.to_string()); - let data = client - .private_post("cancelallordersafter", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) + if let Some(p) = price { + params.insert("limitPrice".into(), p); } - FuturesCommand::BatchOrder { orders_json } => { - let creds = require_creds(creds)?; - let json_str = if let Some(path) = orders_json.strip_prefix('@') { - std::fs::read_to_string(path).map_err(|e| { - KrakenError::Io(std::io::Error::new( - e.kind(), - format!("Failed to read batch order file '{path}': {e}"), - )) - })? - } else { - orders_json.clone() - }; - let parsed: Value = serde_json::from_str(&json_str) - .map_err(|e| KrakenError::Validation(format!("Invalid batch order JSON: {e}")))?; - let data = client - .private_post_json("batchorder", parsed, creds, verbose) - .await?; - Ok(parse_generic(&data)) + if let Some(sp) = stop_price { + params.insert("stopPrice".into(), sp); } - FuturesCommand::Positions => { - let creds = require_creds(creds)?; - let data = client - .private_get("openpositions", &[], creds, verbose) - .await?; - Ok(parse_positions(&data)) - } - FuturesCommand::Fills { since } => { - let creds = require_creds(creds)?; - let mut params: Vec<(&str, &str)> = Vec::new(); - let since_owned; - if let Some(s) = since { - since_owned = s.clone(); - params.push(("lastFillTime", &since_owned)); - } - let data = client.private_get("fills", ¶ms, creds, verbose).await?; - Ok(parse_fills(&data)) - } - FuturesCommand::Leverage { symbol } => { - let creds = require_creds(creds)?; - let mut params: Vec<(&str, &str)> = Vec::new(); - let sym_owned; - if let Some(s) = symbol { - sym_owned = s.clone(); - params.push(("symbol", &sym_owned)); + let data = client.private_post("editorder", params, creds).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +// `required_unless_present` on each side references the other, so both fields +// must stay in this one struct with these identifiers. +#[derive(Debug, clap::Args)] +pub(crate) struct Cancel { + /// Exchange order ID. + #[arg(long, required_unless_present = "cli_ord_id")] + order_id: Option, + /// Client order ID. + #[arg(long, required_unless_present = "order_id")] + cli_ord_id: Option, +} + +impl Execute for Cancel { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { + order_id, + cli_ord_id, + } = self; + let mut params = HashMap::new(); + match (order_id, cli_ord_id) { + (Some(oid), None) => { + params.insert("order_id".into(), oid); } - let data = client - .private_get("leveragepreferences", ¶ms, creds, verbose) - .await?; - Ok(parse_leverage_preferences(&data)) - } - FuturesCommand::SetLeverage { symbol, leverage } => { - let creds = require_creds(creds)?; - let mut params = HashMap::new(); - params.insert("symbol".into(), symbol.clone()); - if let Some(lev) = &leverage { - params.insert("maxLeverage".into(), lev.clone()); + (None, Some(cid)) => { + params.insert("cliOrdId".into(), cid); } - let data = client - .private_put("leveragepreferences", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::PnlPreferences => { - let creds = require_creds(creds)?; - let data = client - .private_get("pnlpreferences", &[], creds, verbose) - .await?; - Ok(parse_pnl_preferences(&data)) - } - FuturesCommand::SetPnlPreference { symbol, preference } => { - let creds = require_creds(creds)?; - let mut params = HashMap::new(); - params.insert("symbol".into(), symbol.clone()); - params.insert("pnlPreference".into(), preference.clone()); - let data = client - .private_put("pnlpreferences", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::Notifications => { - let creds = require_creds(creds)?; - let data = client - .private_get("notifications", &[], creds, verbose) - .await?; - Ok(parse_notifications(&data)) - } - FuturesCommand::HistoryExecutions { - since, - before, - sort, - } => { - let creds = require_creds(creds)?; - let params = build_history_params(since.as_deref(), before.as_deref(), sort.as_deref()); - let data = client - .private_get("executions", ¶ms, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::HistoryOrders { - since, - before, - sort, - } => { - let creds = require_creds(creds)?; - let params = build_history_params(since.as_deref(), before.as_deref(), sort.as_deref()); - let data = client - .private_get("orders", ¶ms, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::HistoryTriggers { - since, - before, - sort, - } => { - let creds = require_creds(creds)?; - let params = build_history_params(since.as_deref(), before.as_deref(), sort.as_deref()); - let data = client - .private_get("triggers", ¶ms, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::HistoryAccountLogCsv { since, before } => { - let creds = require_creds(creds)?; - let mut params: Vec<(&str, &str)> = Vec::new(); - let s_owned; - if let Some(s) = since { - s_owned = s.clone(); - params.push(("since", &s_owned)); + (Some(_), Some(_)) => { + return Err(KrakenError::Validation( + "Provide exactly one of --order-id or --cli-ord-id".into(), + )); } - let b_owned; - if let Some(b) = before { - b_owned = b.clone(); - params.push(("before", &b_owned)); + (None, None) => { + return Err(KrakenError::Validation( + "One of --order-id or --cli-ord-id is required".into(), + )); } - let csv_text = client - .private_get_raw("accountlogcsv", ¶ms, creds, verbose) - .await?; - let json_data = serde_json::json!({ "csv": csv_text }); - let total_lines = csv_text.lines().count(); - let summary = if total_lines > 1 { - format!("{} data rows", total_lines - 1) - } else { - "empty".to_string() - }; - let first_line = csv_text.lines().next().unwrap_or("").to_string(); - Ok(CommandOutput::new( - json_data, - vec!["Headers".into(), "Rows".into()], - vec![vec![first_line, summary]], - )) } - FuturesCommand::Transfers => { - let creds = require_creds(creds)?; - let data = client.private_get("transfers", &[], creds, verbose).await?; - Ok(parse_transfers(&data)) - } - FuturesCommand::Transfer { amount, currency } => { - let creds = require_creds(creds)?; - if !force { - confirm_destructive(&format!( - "Transfer {amount} {currency} between spot and futures wallets?" - ))?; - } - let mut params = HashMap::new(); - params.insert("amount".into(), amount.clone()); - params.insert("currency".into(), currency.clone()); - let data = client - .private_post("withdrawal", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) + let data = client.private_post("cancelorder", params, creds).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct CancelAll { + /// Filter by symbol. + #[arg(long)] + symbol: Option, +} + +impl Execute for CancelAll { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { symbol } = self; + ctx.confirm_destructive("Cancel ALL open futures orders?")?; + let mut params = HashMap::new(); + if let Some(s) = symbol { + params.insert("symbol".into(), s); } - FuturesCommand::UnwindQueue => { - let creds = require_creds(creds)?; - let data = client - .private_get("unwindqueue", &[], creds, verbose) - .await?; - Ok(parse_unwind_queue(&data)) + let data = client + .private_post("cancelallorders", params, creds) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct CancelAfter { + /// Timeout in seconds (0 to disable). + timeout: u64, +} + +impl Execute for CancelAfter { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { timeout } = self; + let mut params = HashMap::new(); + params.insert("timeout".into(), timeout.to_string()); + let data = client + .private_post("cancelallordersafter", params, creds) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct BatchOrder { + /// Orders as JSON array or path to JSON file (prefix with @). + orders_json: String, +} + +impl Execute for BatchOrder { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { orders_json } = self; + let json_str = if let Some(path) = orders_json.strip_prefix('@') { + std::fs::read_to_string(path).map_err(|e| { + KrakenError::Io(std::io::Error::new( + e.kind(), + format!("Failed to read batch order file '{path}': {e}"), + )) + })? + } else { + orders_json + }; + let parsed: Value = serde_json::from_str(&json_str) + .map_err(|e| KrakenError::Validation(format!("Invalid batch order JSON: {e}")))?; + let data = client + .private_post_json("batchorder", parsed, creds) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Positions {} + +impl Execute for Positions { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("openpositions", &[], creds).await?; + Ok(parse_positions(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Fills { + /// Filter since this timestamp. + #[arg(long)] + since: Option, +} + +impl Execute for Fills { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { since } = self; + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(s) = since.as_deref() { + params.push(("lastFillTime", s)); } - FuturesCommand::AssignmentPrograms => { - let creds = require_creds(creds)?; - let data = client - .private_get("assignmentprogram/current", &[], creds, verbose) - .await?; - Ok(parse_generic(&data)) + let data = client.private_get("fills", ¶ms, creds).await?; + Ok(parse_fills(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Leverage { + /// Filter by symbol (omit for all). + #[arg(long)] + symbol: Option, +} + +impl Execute for Leverage { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { symbol } = self; + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(s) = symbol.as_deref() { + params.push(("symbol", s)); } - FuturesCommand::FeeScheduleVolumes => { - let creds = require_creds(creds)?; - let data = client - .private_get("feeschedules/volumes", &[], creds, verbose) - .await?; - Ok(parse_fee_schedule_volumes(&data)) + let data = client + .private_get("leveragepreferences", ¶ms, creds) + .await?; + Ok(parse_leverage_preferences(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct SetLeverage { + /// Futures symbol. + symbol: String, + /// Max leverage value (omit to use cross margin). + #[arg(required = false)] + leverage: Option, +} + +impl Execute for SetLeverage { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { symbol, leverage } = self; + let mut params = HashMap::new(); + params.insert("symbol".into(), symbol); + if let Some(lev) = leverage { + params.insert("maxLeverage".into(), lev); } - FuturesCommand::Subaccounts => { - let creds = require_creds(creds)?; - let data = client - .private_get("subaccounts", &[], creds, verbose) - .await?; - Ok(parse_subaccounts(&data)) + let data = client + .private_put("leveragepreferences", params, creds) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct PnlPreferences {} + +impl Execute for PnlPreferences { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("pnlpreferences", &[], creds).await?; + Ok(parse_pnl_preferences(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct SetPnlPreference { + /// Futures symbol (e.g. PF_XBTUSD). + symbol: String, + /// PnL preference asset (e.g. USD). + preference: String, +} + +impl Execute for SetPnlPreference { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { symbol, preference } = self; + let mut params = HashMap::new(); + params.insert("symbol".into(), symbol); + params.insert("pnlPreference".into(), preference); + let data = client.private_put("pnlpreferences", params, creds).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Notifications {} + +impl Execute for Notifications { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("notifications", &[], creds).await?; + Ok(parse_notifications(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct HistoryWindow { + /// Filter since this timestamp. + #[arg(long)] + since: Option, + /// Filter before this timestamp. + #[arg(long)] + before: Option, + /// Sort order (asc or desc). + #[arg(long)] + sort: Option, +} + +// enum_dispatch needs a distinct type per variant, so each history command +// wraps the shared filter instead of reusing one Args struct three times. +#[derive(Debug, clap::Args)] +pub(crate) struct HistoryExecutions { + #[command(flatten)] + filter: HistoryWindow, +} + +impl Execute for HistoryExecutions { + async fn execute(self, ctx: &AppContext) -> Result { + fetch_history(self.filter, "executions", ctx).await + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct HistoryOrders { + #[command(flatten)] + filter: HistoryWindow, +} + +impl Execute for HistoryOrders { + async fn execute(self, ctx: &AppContext) -> Result { + fetch_history(self.filter, "orders", ctx).await + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct HistoryTriggers { + #[command(flatten)] + filter: HistoryWindow, +} + +impl Execute for HistoryTriggers { + async fn execute(self, ctx: &AppContext) -> Result { + fetch_history(self.filter, "triggers", ctx).await + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct HistoryAccountLogCsv { + /// Filter since this timestamp. + #[arg(long)] + since: Option, + /// Filter before this timestamp. + #[arg(long)] + before: Option, +} + +impl Execute for HistoryAccountLogCsv { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { since, before } = self; + let mut params: Vec<(&str, &str)> = Vec::new(); + if let Some(s) = since.as_deref() { + params.push(("since", s)); } - FuturesCommand::SubaccountStatus { subaccount_uid } => { - let creds = require_creds(creds)?; - validate_path_segment(subaccount_uid, "subaccount_uid")?; - let endpoint = format!("subaccount/{subaccount_uid}/trading-enabled"); - let data = client.private_get(&endpoint, &[], creds, verbose).await?; - Ok(parse_generic(&data)) + if let Some(b) = before.as_deref() { + params.push(("before", b)); } - FuturesCommand::SetSubaccountStatus { + let csv_text = client + .private_get_raw("accountlogcsv", ¶ms, creds) + .await?; + let json_data = serde_json::json!({ "csv": csv_text }); + let total_lines = csv_text.lines().count(); + let summary = if total_lines > 1 { + format!("{} data rows", total_lines - 1) + } else { + "empty".to_string() + }; + let first_line = csv_text.lines().next().unwrap_or("").to_string(); + Ok(CommandOutput::new( + json_data, + vec!["Headers".into(), "Rows".into()], + vec![vec![first_line, summary]], + )) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Transfers {} + +impl Execute for Transfers { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("transfers", &[], creds).await?; + Ok(parse_transfers(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Transfer { + /// Amount. + amount: String, + /// Currency (e.g. USD, XBT). + currency: String, +} + +impl Execute for Transfer { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { amount, currency } = self; + ctx.confirm_destructive(&format!( + "Transfer {amount} {currency} between spot and futures wallets?" + ))?; + let mut params = HashMap::new(); + params.insert("amount".into(), amount); + params.insert("currency".into(), currency); + let data = client.private_post("withdrawal", params, creds).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct UnwindQueue {} + +impl Execute for UnwindQueue { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("unwindqueue", &[], creds).await?; + Ok(parse_unwind_queue(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct AssignmentPrograms {} + +impl Execute for AssignmentPrograms { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client + .private_get("assignmentprogram/current", &[], creds) + .await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct FeeScheduleVolumes {} + +impl Execute for FeeScheduleVolumes { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client + .private_get("feeschedules/volumes", &[], creds) + .await?; + Ok(parse_fee_schedule_volumes(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Subaccounts {} + +impl Execute for Subaccounts { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let data = client.private_get("subaccounts", &[], creds).await?; + Ok(parse_subaccounts(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct SubaccountStatus { + /// Subaccount UID. + subaccount_uid: String, +} + +impl Execute for SubaccountStatus { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { subaccount_uid } = self; + validate_path_segment(&subaccount_uid, "subaccount_uid")?; + let endpoint = format!("subaccount/{subaccount_uid}/trading-enabled"); + let data = client.private_get(&endpoint, &[], creds).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct SetSubaccountStatus { + /// Subaccount UID. + subaccount_uid: String, + /// Enable or disable trading (true/false, yes/no, 1/0). + trading_enabled: BoolValue, +} + +impl Execute for SetSubaccountStatus { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { subaccount_uid, trading_enabled, - } => { - let creds = require_creds(creds)?; - validate_path_segment(subaccount_uid, "subaccount_uid")?; - let endpoint = format!("subaccount/{subaccount_uid}/trading-enabled"); - let mut params = HashMap::new(); - params.insert( - "tradingEnabled".into(), - trading_enabled.as_api_value().to_string(), - ); - let data = client - .private_put(&endpoint, params, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } - FuturesCommand::WalletTransfer { + } = self; + validate_path_segment(&subaccount_uid, "subaccount_uid")?; + let endpoint = format!("subaccount/{subaccount_uid}/trading-enabled"); + let mut params = HashMap::new(); + params.insert( + "tradingEnabled".into(), + trading_enabled.as_api_value().to_string(), + ); + let data = client.private_put(&endpoint, params, creds).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} + +#[derive(Debug, clap::Args)] +pub(crate) struct WalletTransfer { + /// Source account. + from_account: String, + /// Destination account. + to_account: String, + /// Currency unit (e.g. USD, XBT). + unit: String, + /// Amount to transfer. + amount: String, +} + +impl Execute for WalletTransfer { + async fn execute(self, ctx: &AppContext) -> Result { + let (client, creds) = ctx.futures_authed()?; + let Self { from_account, to_account, unit, amount, - } => { - let creds = require_creds(creds)?; - if !force { - confirm_destructive(&format!( - "Transfer {amount} {unit} from {from_account} to {to_account}?" - ))?; - } - let mut params = HashMap::new(); - params.insert("fromAccount".into(), from_account.clone()); - params.insert("toAccount".into(), to_account.clone()); - params.insert("unit".into(), unit.clone()); - params.insert("amount".into(), amount.clone()); - let data = client - .private_post("transfer", params, creds, verbose) - .await?; - Ok(parse_generic(&data)) - } + } = self; + ctx.confirm_destructive(&format!( + "Transfer {amount} {unit} from {from_account} to {to_account}?" + ))?; + let mut params = HashMap::new(); + params.insert("fromAccount".into(), from_account); + params.insert("toAccount".into(), to_account); + params.insert("unit".into(), unit); + params.insert("amount".into(), amount); + let data = client.private_post("transfer", params, creds).await?; + Ok(CommandOutput::from_untyped(&data)) + } +} - FuturesCommand::Ws { cmd } => super::futures_ws::execute(cmd, client, creds, verbose).await, - - FuturesCommand::Paper { cmd } => super::futures_paper::execute(cmd, client, verbose).await, - } -} - -/// Returns true for commands that require authentication. -pub(crate) fn requires_auth(cmd: &FuturesCommand) -> bool { - matches!( - cmd, - FuturesCommand::Accounts - | FuturesCommand::OpenOrders - | FuturesCommand::OrderStatus { .. } - | FuturesCommand::Order { .. } - | FuturesCommand::EditOrder { .. } - | FuturesCommand::Cancel { .. } - | FuturesCommand::CancelAll { .. } - | FuturesCommand::CancelAfter { .. } - | FuturesCommand::BatchOrder { .. } - | FuturesCommand::Positions - | FuturesCommand::Fills { .. } - | FuturesCommand::Leverage { .. } - | FuturesCommand::SetLeverage { .. } - | FuturesCommand::PnlPreferences - | FuturesCommand::SetPnlPreference { .. } - | FuturesCommand::Notifications - | FuturesCommand::HistoryExecutions { .. } - | FuturesCommand::HistoryOrders { .. } - | FuturesCommand::HistoryTriggers { .. } - | FuturesCommand::HistoryAccountLogCsv { .. } - | FuturesCommand::Transfers - | FuturesCommand::Transfer { .. } - | FuturesCommand::UnwindQueue - | FuturesCommand::AssignmentPrograms - | FuturesCommand::FeeScheduleVolumes - | FuturesCommand::Subaccounts - | FuturesCommand::SubaccountStatus { .. } - | FuturesCommand::SetSubaccountStatus { .. } - | FuturesCommand::WalletTransfer { .. } - | FuturesCommand::TradingInstruments { .. } - ) -} - -/// Returns true for WS commands that require authentication. -pub(crate) fn ws_requires_auth(cmd: &FuturesCommand) -> bool { - if let FuturesCommand::Ws { cmd } = cmd { - super::futures_ws::requires_auth(cmd) - } else { - false - } -} - -fn require_creds(creds: Option<&FuturesCredentials>) -> Result<&FuturesCredentials> { - creds.ok_or_else(|| { - KrakenError::Auth( - "Futures credentials required. Use `kraken setup` or set KRAKEN_FUTURES_API_KEY / KRAKEN_FUTURES_API_SECRET.".into(), - ) - }) -} - -const VALID_TRAILING_UNITS: &[&str] = &["percent", "quote_currency"]; -const VALID_CONTRACT_TYPES: &[&str] = &["futures_inverse", "futures_vanilla", "flexible_futures"]; - -fn validate_contract_type(ct: &str) -> Result<()> { - if !VALID_CONTRACT_TYPES.contains(&ct) { - return Err(KrakenError::Validation(format!( - "Invalid contract type: {ct}. Valid values: {}", - VALID_CONTRACT_TYPES.join(", ") - ))); +#[enum_dispatch(Execute)] +#[derive(Debug, Subcommand)] +pub(crate) enum FuturesOrderDirection { + /// Place a buy order. + Buy(Buy), + /// Place a sell order. + Sell(Sell), +} + +/// Order fields shared by buy and sell; the side comes from the variant. +#[derive(Debug, clap::Args)] +pub(crate) struct OrderRequest { + /// Futures symbol. + symbol: String, + /// Order size. + size: String, + /// Order type. + #[arg(long, default_value = "limit")] + r#type: FuturesOrderType, + /// Limit price. + #[arg(long)] + price: Option, + /// Stop price (for stop and take-profit orders). + #[arg(long)] + stop_price: Option, + /// Trigger signal: mark, index, or last. + #[arg(long)] + trigger_signal: Option, + /// Client order ID for correlation. + #[arg(long)] + client_order_id: Option, + /// Reduce-only flag. + #[arg(long)] + reduce_only: bool, + /// Trailing stop max deviation. + #[arg(long)] + trailing_stop_max_deviation: Option, + /// Trailing stop deviation unit. + #[arg(long)] + trailing_stop_deviation_unit: Option, +} + +// enum_dispatch needs a distinct type per variant, so buy and sell each wrap +// the shared fields instead of reusing one Args struct twice. +#[derive(Debug, clap::Args)] +pub(crate) struct Buy { + #[command(flatten)] + order: OrderRequest, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Sell { + #[command(flatten)] + order: OrderRequest, +} + +impl Execute for Buy { + async fn execute(self, ctx: &AppContext) -> Result { + send_futures_order(self.order, OrderSide::Buy, ctx).await + } +} + +impl Execute for Sell { + async fn execute(self, ctx: &AppContext) -> Result { + send_futures_order(self.order, OrderSide::Sell, ctx).await } - Ok(()) +} + +async fn send_futures_order( + order: OrderRequest, + side: OrderSide, + ctx: &AppContext, +) -> Result { + let (client, creds) = ctx.futures_authed()?; + let params = build_futures_order(order, side)?; + let data = client.private_post("sendorder", params, creds).await?; + Ok(parse_order_response(&data)) } /// Validates a value for safe interpolation into a URL path segment. @@ -877,122 +987,65 @@ fn validate_path_segment(value: &str, field_name: &str) -> Result<()> { Ok(()) } -/// Map CLI order type to API orderType value. -fn map_order_type_to_api(ot: &str) -> String { - match ot { - "limit" => "lmt".to_string(), - "market" => "mkt".to_string(), - "stop" => "stp".to_string(), - "take-profit" => "take_profit".to_string(), - "trailing-stop" => "trailing_stop".to_string(), - other => other.to_string(), - } -} - -fn build_futures_order(direction: &FuturesOrderDirection) -> Result> { - let ( - side, +fn build_futures_order(order: OrderRequest, side: OrderSide) -> Result> { + let OrderRequest { symbol, size, - order_type, + r#type: order_type, price, stop_price, trigger_signal, client_order_id, reduce_only, - trailing_max, - trailing_unit, - ) = match direction { - FuturesOrderDirection::Buy { - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - reduce_only, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - } => ( - "buy", - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - *reduce_only, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - ), - FuturesOrderDirection::Sell { - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - reduce_only, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - } => ( - "sell", - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - *reduce_only, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - ), - }; - - let order_type_api = map_order_type_to_api(order_type); - - if let Some(tu) = trailing_unit { - if !VALID_TRAILING_UNITS.contains(&tu.as_str()) { - return Err(KrakenError::Validation(format!( - "Invalid trailing stop deviation unit: {tu}. Valid values: {}", - VALID_TRAILING_UNITS.join(", ") - ))); - } - } + trailing_stop_max_deviation: trailing_max, + trailing_stop_deviation_unit: trailing_unit, + } = order; let mut params = HashMap::new(); - params.insert("orderType".into(), order_type_api); - params.insert("symbol".into(), symbol.to_string()); + params.insert("orderType".into(), order_type.as_api_wire().to_string()); + params.insert("symbol".into(), symbol); params.insert("side".into(), side.to_string()); - params.insert("size".into(), size.to_string()); + params.insert("size".into(), size); if let Some(p) = price { - params.insert("limitPrice".into(), p.clone()); + params.insert("limitPrice".into(), p); } if let Some(sp) = stop_price { - params.insert("stopPrice".into(), sp.clone()); + params.insert("stopPrice".into(), sp); } if let Some(ts) = trigger_signal { - params.insert("triggerSignal".into(), ts.clone()); + params.insert("triggerSignal".into(), ts.to_string()); } if let Some(coid) = client_order_id { - params.insert("cliOrdId".into(), coid.clone()); + params.insert("cliOrdId".into(), coid); } if reduce_only { params.insert("reduceOnly".into(), "true".into()); } if let Some(tm) = trailing_max { - params.insert("trailingStopMaxDeviation".into(), tm.clone()); + params.insert("trailingStopMaxDeviation".into(), tm); } if let Some(tu) = trailing_unit { - params.insert("trailingStopDeviationUnit".into(), tu.clone()); + params.insert("trailingStopDeviationUnit".into(), tu.as_wire()); } Ok(params) } +/// Shared executor for the three `history-*` endpoints, which take identical filters. +async fn fetch_history( + filter: HistoryWindow, + endpoint: &str, + ctx: &AppContext, +) -> Result { + let (client, creds) = ctx.futures_authed()?; + let params = build_history_params( + filter.since.as_deref(), + filter.before.as_deref(), + filter.sort.as_deref(), + ); + let data = client.private_get(endpoint, ¶ms, creds).await?; + Ok(CommandOutput::from_untyped(&data)) +} + fn build_history_params<'a>( since: Option<&'a str>, before: Option<&'a str>, @@ -1011,9 +1064,9 @@ fn build_history_params<'a>( params } -/// Parse orderbook response (GET orderbook) into Side | Price | Size table (asks then bids). +/// Parse orderbook response (GET orderbook) into OrderSide | Price | Size table (asks then bids). fn parse_orderbook(data: &Value) -> CommandOutput { - let headers = vec!["Side".into(), "Price".into(), "Size".into()]; + let headers = vec!["OrderSide".into(), "Price".into(), "Size".into()]; let mut rows = Vec::new(); let book = data .get("orderBook") @@ -1022,19 +1075,19 @@ fn parse_orderbook(data: &Value) -> CommandOutput { if let Some(book) = book { if let Some(asks) = book.get("asks").and_then(|a| a.as_array()) { for level in asks { - if let Some(arr) = level.as_array() { - if arr.len() >= 2 { - rows.push(vec!["Ask".into(), arr[0].to_string(), arr[1].to_string()]); - } + if let Some(arr) = level.as_array() + && arr.len() >= 2 + { + rows.push(vec!["Ask".into(), arr[0].to_string(), arr[1].to_string()]); } } } if let Some(bids) = book.get("bids").and_then(|b| b.as_array()) { for level in bids { - if let Some(arr) = level.as_array() { - if arr.len() >= 2 { - rows.push(vec!["Bid".into(), arr[0].to_string(), arr[1].to_string()]); - } + if let Some(arr) = level.as_array() + && arr.len() >= 2 + { + rows.push(vec!["Bid".into(), arr[0].to_string(), arr[1].to_string()]); } } } @@ -1085,7 +1138,7 @@ fn parse_accounts(data: &Value) -> CommandOutput { let accounts = data.get("accounts").and_then(|a| a.as_object()); if let Some(accounts) = accounts { for (account_id, account) in accounts { - let type_str = jstr(account, "type"); + let type_str = json_field(account, "type"); if let Some(balances) = account.get("balances").and_then(|b| b.as_object()) { for (currency, amount) in balances { rows.push(vec![ @@ -1122,9 +1175,9 @@ fn parse_subaccounts(data: &Value) -> CommandOutput { if let Some(subaccounts) = subaccounts { for sa in subaccounts { rows.push(vec![ - jstr(sa, "accountUid"), - jstr(sa, "email"), - jstr(sa, "fullName"), + json_field(sa, "accountUid"), + json_field(sa, "email"), + json_field(sa, "fullName"), ]); } } @@ -1148,11 +1201,11 @@ fn parse_open_orders(data: &Value) -> CommandOutput { if let Some(orders) = orders { for order in orders { rows.push(vec![ - jstr(order, "order_id"), - jstr(order, "orderType"), - jstr(order, "side"), - jstr(order, "symbol"), - jstr(order, "status"), + json_field(order, "order_id"), + json_field(order, "orderType"), + json_field(order, "side"), + json_field(order, "symbol"), + json_field(order, "status"), ]); } } @@ -1174,12 +1227,12 @@ fn parse_trade_history(data: &Value) -> CommandOutput { if let Some(history) = history { for trade in history { rows.push(vec![ - jstr(trade, "trade_id"), - jstr(trade, "time"), - jstr(trade, "side"), - jstr(trade, "price"), - jstr(trade, "size"), - jstr(trade, "type"), + json_field(trade, "trade_id"), + json_field(trade, "time"), + json_field(trade, "side"), + json_field(trade, "price"), + json_field(trade, "size"), + json_field(trade, "type"), ]); } } @@ -1198,11 +1251,11 @@ fn parse_ticker(data: &Value) -> CommandOutput { let mut rows = Vec::new(); if let Some(ticker) = data.get("ticker") { rows.push(vec![ - jstr(ticker, "symbol"), - jstr(ticker, "last"), - jstr(ticker, "bid"), - jstr(ticker, "ask"), - jstr(ticker, "vol24h"), + json_field(ticker, "symbol"), + json_field(ticker, "last"), + json_field(ticker, "bid"), + json_field(ticker, "ask"), + json_field(ticker, "vol24h"), ]); } CommandOutput::new(data.clone(), headers, rows) @@ -1222,11 +1275,11 @@ fn parse_tickers(data: &Value) -> CommandOutput { if let Some(tickers) = data.get("tickers").and_then(|t| t.as_array()) { for ticker in tickers { rows.push(vec![ - jstr(ticker, "symbol"), - jstr(ticker, "last"), - jstr(ticker, "bid"), - jstr(ticker, "ask"), - jstr(ticker, "vol24h"), + json_field(ticker, "symbol"), + json_field(ticker, "last"), + json_field(ticker, "bid"), + json_field(ticker, "ask"), + json_field(ticker, "vol24h"), ]); } } @@ -1240,7 +1293,10 @@ fn parse_unwind_queue(data: &Value) -> CommandOutput { let queue = data.get("queue").and_then(|q| q.as_array()); if let Some(queue) = queue { for item in queue { - rows.push(vec![jstr(item, "symbol"), jstr(item, "percentile")]); + rows.push(vec![ + json_field(item, "symbol"), + json_field(item, "percentile"), + ]); } } CommandOutput::new(data.clone(), headers, rows) @@ -1262,13 +1318,13 @@ fn parse_transfers(data: &Value) -> CommandOutput { if let Some(transfers) = transfers { for t in transfers { rows.push(vec![ - jstr(t, "id"), - jstr(t, "date"), - jstr(t, "from"), - jstr(t, "to"), - jstr(t, "asset"), - jstr(t, "amount"), - jstr(t, "status"), + json_field(t, "id"), + json_field(t, "date"), + json_field(t, "from"), + json_field(t, "to"), + json_field(t, "asset"), + json_field(t, "amount"), + json_field(t, "status"), ]); } } @@ -1302,7 +1358,10 @@ fn parse_feeschedules(data: &Value) -> CommandOutput { if let Some(schedules) = data.get("feeSchedules").and_then(|s| s.as_array()) { for schedule in schedules { - rows.push(vec![jstr(schedule, "name"), jstr(schedule, "uid")]); + rows.push(vec![ + json_field(schedule, "name"), + json_field(schedule, "uid"), + ]); } } CommandOutput::new(data.clone(), headers, rows) @@ -1315,7 +1374,10 @@ fn parse_pnl_preferences(data: &Value) -> CommandOutput { let prefs = data.get("preferences").and_then(|p| p.as_array()); if let Some(prefs) = prefs { for pref in prefs { - rows.push(vec![jstr(pref, "symbol"), jstr(pref, "pnlCurrency")]); + rows.push(vec![ + json_field(pref, "symbol"), + json_field(pref, "pnlCurrency"), + ]); } } CommandOutput::new(data.clone(), headers, rows) @@ -1331,7 +1393,10 @@ fn parse_leverage_preferences(data: &Value) -> CommandOutput { .and_then(|p| p.as_array()); if let Some(prefs) = prefs { for pref in prefs { - rows.push(vec![jstr(pref, "symbol"), jstr(pref, "maxLeverage")]); + rows.push(vec![ + json_field(pref, "symbol"), + json_field(pref, "maxLeverage"), + ]); } } CommandOutput::new(data.clone(), headers, rows) @@ -1356,25 +1421,25 @@ fn parse_positions(data: &Value) -> CommandOutput { if let Some(positions) = positions { for pos in positions { rows.push(vec![ - jstr(pos, "symbol"), - jstr(pos, "side"), - jstr(pos, "size"), - jstr(pos, "price"), - jstr(pos, "fillTime"), - jstr(pos, "pnlCurrency"), - jstr(pos, "unrealizedFunding"), + json_field(pos, "symbol"), + json_field(pos, "side"), + json_field(pos, "size"), + json_field(pos, "price"), + json_field(pos, "fillTime"), + json_field(pos, "pnlCurrency"), + json_field(pos, "unrealizedFunding"), ]); } } CommandOutput::new(data.clone(), headers, rows) } -/// Parse fills response (GET fills) into Fill ID | Symbol | Side | Size | Price | Time table. +/// Parse fills response (GET fills) into Fill ID | Symbol | OrderSide | Size | Price | Time table. fn parse_fills(data: &Value) -> CommandOutput { let headers = vec![ "Fill ID".into(), "Symbol".into(), - "Side".into(), + "OrderSide".into(), "Size".into(), "Price".into(), "Time".into(), @@ -1384,12 +1449,12 @@ fn parse_fills(data: &Value) -> CommandOutput { if let Some(fills) = data.get("fills").and_then(|f| f.as_array()) { for fill in fills { rows.push(vec![ - jstr(fill, "fill_id"), - jstr(fill, "symbol"), - jstr(fill, "side"), - jstr(fill, "size"), - jstr(fill, "price"), - jstr(fill, "fillTime"), + json_field(fill, "fill_id"), + json_field(fill, "symbol"), + json_field(fill, "side"), + json_field(fill, "size"), + json_field(fill, "price"), + json_field(fill, "fillTime"), ]); } } @@ -1409,10 +1474,10 @@ fn parse_notifications(data: &Value) -> CommandOutput { if let Some(notifications) = data.get("notifications").and_then(|n| n.as_array()) { for notif in notifications { rows.push(vec![ - jstr(notif, "type"), - jstr(notif, "priority"), - jstr(notif, "note"), - jstr(notif, "effectiveTime"), + json_field(notif, "type"), + json_field(notif, "priority"), + json_field(notif, "note"), + json_field(notif, "effectiveTime"), ]); } } @@ -1421,7 +1486,7 @@ fn parse_notifications(data: &Value) -> CommandOutput { /// Parse sendorder response into order_id | Result | Status table. fn parse_order_response(data: &Value) -> CommandOutput { - let result = jstr(data, "result"); + let result = json_field(data, "result"); let (order_id, status) = data .get("sendStatus") @@ -1431,7 +1496,7 @@ fn parse_order_response(data: &Value) -> CommandOutput { Value::Object(_) => v.clone(), _ => return None, }; - Some((jstr(&obj, "order_id"), jstr(&obj, "status"))) + Some((json_field(&obj, "order_id"), json_field(&obj, "status"))) }) .unwrap_or_else(|| ("-".to_string(), "-".to_string())); @@ -1472,10 +1537,10 @@ mod tests { #[test] fn build_futures_order_basic_buy() { - let dir = FuturesOrderDirection::Buy { + let order = OrderRequest { symbol: "PI_XBTUSD".into(), size: "1".into(), - r#type: "limit".into(), + r#type: FuturesOrderType::Limit, price: Some("50000".into()), stop_price: None, trigger_signal: None, @@ -1484,7 +1549,7 @@ mod tests { trailing_stop_max_deviation: None, trailing_stop_deviation_unit: None, }; - let params = build_futures_order(&dir).unwrap(); + let params = build_futures_order(order, OrderSide::Buy).unwrap(); assert_eq!(params.get("side").unwrap(), "buy"); assert_eq!(params.get("symbol").unwrap(), "PI_XBTUSD"); assert_eq!(params.get("orderType").unwrap(), "lmt"); @@ -1494,19 +1559,19 @@ mod tests { #[test] fn build_futures_order_with_stop_and_client_id() { - let dir = FuturesOrderDirection::Sell { + let order = OrderRequest { symbol: "PI_ETHUSD".into(), size: "10".into(), - r#type: "stop".into(), + r#type: FuturesOrderType::Stop, price: None, stop_price: Some("3000".into()), - trigger_signal: Some("mark".into()), + trigger_signal: Some(TriggerSignal::Mark), client_order_id: Some("my-order-123".into()), reduce_only: true, trailing_stop_max_deviation: None, trailing_stop_deviation_unit: None, }; - let params = build_futures_order(&dir).unwrap(); + let params = build_futures_order(order, OrderSide::Sell).unwrap(); assert_eq!(params.get("side").unwrap(), "sell"); assert_eq!(params.get("stopPrice").unwrap(), "3000"); assert_eq!(params.get("triggerSignal").unwrap(), "mark"); @@ -1516,19 +1581,19 @@ mod tests { #[test] fn build_futures_order_trailing_stop() { - let dir = FuturesOrderDirection::Buy { + let order = OrderRequest { symbol: "PI_XBTUSD".into(), size: "1".into(), - r#type: "stop".into(), + r#type: FuturesOrderType::Stop, price: None, stop_price: Some("49000".into()), trigger_signal: None, client_order_id: None, reduce_only: false, trailing_stop_max_deviation: Some("5".into()), - trailing_stop_deviation_unit: Some("percent".into()), + trailing_stop_deviation_unit: Some(TrailingUnit::Percent), }; - let params = build_futures_order(&dir).unwrap(); + let params = build_futures_order(order, OrderSide::Buy).unwrap(); assert_eq!(params.get("trailingStopMaxDeviation").unwrap(), "5"); assert_eq!(params.get("trailingStopDeviationUnit").unwrap(), "percent"); } diff --git a/src/commands/futures_paper.rs b/src/commands/futures_paper.rs index c6ce4be..0908cfc 100644 --- a/src/commands/futures_paper.rs +++ b/src/commands/futures_paper.rs @@ -1,275 +1,335 @@ use std::collections::{HashMap, HashSet}; use clap::Subcommand; -use serde_json::{json, Value}; +use enum_dispatch::enum_dispatch; +use serde_json::{Value, json}; +use super::Execute; +use crate::cli::AppContext; use crate::client::FuturesClient; use crate::errors::{KrakenError, Result}; use crate::futures_paper::{ - self, compute_liquidation_price, compute_unrealized_pnl, FuturesOrderType, FuturesPaperState, - MarketSnapshot, OrderParams, Side, TriggerSignal, DEFAULT_FUTURES_TAKER_FEE_RATE, - DEFAULT_MAINTENANCE_MARGIN_RATE, MAX_LEVERAGE, + self, DEFAULT_FUTURES_TAKER_FEE_RATE, DEFAULT_MAINTENANCE_MARGIN_RATE, FuturesOrderType, + FuturesPaperState, MAX_LEVERAGE, MarketSnapshot, OrderParams, PositionSide, TriggerSignal, + compute_liquidation_price, compute_unrealized_pnl, }; use crate::output::CommandOutput; +#[enum_dispatch(Execute)] #[derive(Debug, Subcommand)] pub(crate) enum FuturesPaperCommand { /// Initialize futures paper trading account. - Init { - /// Starting collateral balance (default: 10000). - #[arg(long, default_value = "10000")] - balance: f64, - /// Collateral currency (default: USD). - #[arg(long, default_value = "USD")] - currency: String, - /// Fee rate as a decimal (default: 0.0005 = 0.05%). - #[arg(long)] - fee_rate: Option, - }, + Init(Init), /// Reset futures paper account. - Reset { - #[arg(long)] - balance: Option, - #[arg(long)] - currency: Option, - #[arg(long)] - fee_rate: Option, - }, + Reset(Reset), /// Show futures paper collateral balance. - Balance, + Balance(Balance), /// Show futures paper account summary. - Status, + Status(Status), /// Place a futures paper buy (long) order. - Buy { - /// Futures symbol (e.g. PF_XBTUSD). - symbol: String, - /// Order size. - size: String, - /// Order type (default: limit). - #[arg(long, default_value = "limit", value_parser = ["limit", "market", "post", "stop", "take-profit", "ioc", "trailing-stop", "fok"])] - r#type: String, - /// Limit price. - #[arg(long)] - price: Option, - /// Stop/trigger price. - #[arg(long)] - stop_price: Option, - /// Trigger signal: mark, index, or last. - #[arg(long, value_parser = ["mark", "index", "last"])] - trigger_signal: Option, - /// Client order ID. - #[arg(long)] - client_order_id: Option, - /// Reduce-only flag. - #[arg(long)] - reduce_only: bool, - /// Leverage override for this order. - #[arg(long)] - leverage: Option, - /// Trailing stop max deviation. - #[arg(long)] - trailing_stop_max_deviation: Option, - /// Trailing stop deviation unit (percent or quote_currency). - #[arg(long)] - trailing_stop_deviation_unit: Option, - }, + Buy(Buy), /// Place a futures paper sell (short) order. - Sell { - /// Futures symbol (e.g. PF_XBTUSD). - symbol: String, - /// Order size. - size: String, - /// Order type (default: limit). - #[arg(long, default_value = "limit", value_parser = ["limit", "market", "post", "stop", "take-profit", "ioc", "trailing-stop", "fok"])] - r#type: String, - #[arg(long)] - price: Option, - #[arg(long)] - stop_price: Option, - #[arg(long, value_parser = ["mark", "index", "last"])] - trigger_signal: Option, - #[arg(long)] - client_order_id: Option, - #[arg(long)] - reduce_only: bool, - #[arg(long)] - leverage: Option, - #[arg(long)] - trailing_stop_max_deviation: Option, - #[arg(long)] - trailing_stop_deviation_unit: Option, - }, + Sell(Sell), /// Show open futures paper orders. - Orders, + Orders(Orders), /// Get status of a specific order. - OrderStatus { - /// Order ID to query. - order_id: String, - }, + OrderStatus(OrderStatus), /// Edit a resting futures paper order. - EditOrder { - /// Order ID to edit. - #[arg(long)] - order_id: String, - /// New order size. - #[arg(long)] - size: Option, - /// New limit price. - #[arg(long)] - price: Option, - /// New stop price. - #[arg(long)] - stop_price: Option, - }, + EditOrder(EditOrder), /// Cancel a futures paper order. - Cancel { - /// Exchange order ID. - #[arg(long, required_unless_present = "cli_ord_id")] - order_id: Option, - /// Client order ID. - #[arg(long, required_unless_present = "order_id")] - cli_ord_id: Option, - }, + Cancel(Cancel), /// Cancel all futures paper orders. - CancelAll { - /// Filter by symbol. - #[arg(long)] - symbol: Option, - }, + CancelAll(CancelAll), /// Place a batch of futures paper orders (JSON array). - BatchOrder { - /// Orders as JSON array or path to JSON file (prefix with @). - orders_json: String, - }, + BatchOrder(BatchOrder), /// Show open futures paper positions. - Positions, + Positions(Positions), /// Show futures paper fill history. - Fills, + Fills(Fills), /// Show futures paper account history. - History, + History(History), /// Get current leverage preferences. - Leverage { - #[arg(long)] - symbol: Option, - }, + Leverage(Leverage), /// Set leverage preference for a symbol. - SetLeverage { - /// Futures symbol. - symbol: String, - /// Max leverage value. - leverage: String, - }, + SetLeverage(SetLeverage), } -pub(crate) async fn execute( - cmd: &FuturesPaperCommand, - client: &FuturesClient, - verbose: bool, -) -> Result { - match cmd { - FuturesPaperCommand::Init { +#[derive(Debug, clap::Args)] +pub(crate) struct Init { + /// Starting collateral balance (default: 10000). + #[arg(long, default_value = "10000")] + balance: f64, + /// Collateral currency (default: USD). + #[arg(long, default_value = "USD")] + currency: String, + /// Fee rate as a decimal (default: 0.0005 = 0.05%). + #[arg(long)] + fee_rate: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Reset { + #[arg(long)] + balance: Option, + #[arg(long)] + currency: Option, + #[arg(long)] + fee_rate: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Balance {} + +#[derive(Debug, clap::Args)] +pub(crate) struct Status {} + +// Buy and sell share one order surface; each side keeps a thin wrapper because +// enum_dispatch needs a distinct type per variant. The flatten field comes +// first so the positionals (symbol, size) keep their order. +#[derive(Debug, clap::Args)] +pub(crate) struct Buy { + #[command(flatten)] + order: OrderRequest, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Sell { + #[command(flatten)] + order: OrderRequest, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct OrderRequest { + /// Futures symbol (e.g. PF_XBTUSD). + symbol: String, + /// Order size. + size: String, + /// Order type (default: limit). + #[arg(long, default_value = "limit")] + r#type: FuturesOrderType, + /// Limit price. + #[arg(long)] + price: Option, + /// Stop/trigger price. + #[arg(long)] + stop_price: Option, + /// Trigger signal: mark, index, or last. + #[arg(long)] + trigger_signal: Option, + /// Client order ID. + #[arg(long)] + client_order_id: Option, + /// Reduce-only flag. + #[arg(long)] + reduce_only: bool, + /// Leverage override for this order. + #[arg(long)] + leverage: Option, + /// Trailing stop max deviation. + #[arg(long)] + trailing_stop_max_deviation: Option, + /// Trailing stop deviation unit (percent or quote_currency). + #[arg(long)] + trailing_stop_deviation_unit: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Orders {} + +#[derive(Debug, clap::Args)] +pub(crate) struct OrderStatus { + /// Order ID to query. + order_id: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct EditOrder { + /// Order ID to edit. + #[arg(long)] + order_id: String, + /// New order size. + #[arg(long)] + size: Option, + /// New limit price. + #[arg(long)] + price: Option, + /// New stop price. + #[arg(long)] + stop_price: Option, +} + +// order_id/cli_ord_id are a required_unless_present pair; clap resolves those +// by sibling field id, so both fields must stay in this one struct. +#[derive(Debug, clap::Args)] +pub(crate) struct Cancel { + /// Exchange order ID. + #[arg(long, required_unless_present = "cli_ord_id")] + order_id: Option, + /// Client order ID. + #[arg(long, required_unless_present = "order_id")] + cli_ord_id: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct CancelAll { + /// Filter by symbol. + #[arg(long)] + symbol: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct BatchOrder { + /// Orders as JSON array or path to JSON file (prefix with @). + orders_json: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Positions {} + +#[derive(Debug, clap::Args)] +pub(crate) struct Fills {} + +#[derive(Debug, clap::Args)] +pub(crate) struct History {} + +#[derive(Debug, clap::Args)] +pub(crate) struct Leverage { + #[arg(long)] + symbol: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct SetLeverage { + /// Futures symbol. + symbol: String, + /// Max leverage value. + leverage: String, +} + +// Only the leaves that fetch live market data build an HTTP client, so +// `init`/`reset` and pure state reads stay fully offline. +impl Execute for Init { + async fn execute(self, _ctx: &AppContext) -> Result { + let Self { balance, currency, fee_rate, - } => execute_init(*balance, currency, *fee_rate), - FuturesPaperCommand::Reset { + } = self; + execute_init(balance, ¤cy, fee_rate) + } +} + +impl Execute for Reset { + async fn execute(self, _ctx: &AppContext) -> Result { + let Self { balance, currency, fee_rate, - } => execute_reset(*balance, currency.as_deref(), *fee_rate), - FuturesPaperCommand::Balance => execute_balance(client, verbose).await, - FuturesPaperCommand::Status => execute_status(client, verbose).await, - FuturesPaperCommand::Buy { - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - reduce_only, - leverage, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - } => { - execute_order( - Side::Long, - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - *reduce_only, - leverage, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - client, - verbose, - ) - .await - } - FuturesPaperCommand::Sell { - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - reduce_only, - leverage, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - } => { - execute_order( - Side::Short, - symbol, - size, - r#type, - price, - stop_price, - trigger_signal, - client_order_id, - *reduce_only, - leverage, - trailing_stop_max_deviation, - trailing_stop_deviation_unit, - client, - verbose, - ) - .await - } - FuturesPaperCommand::Orders => execute_orders(), - FuturesPaperCommand::OrderStatus { order_id } => execute_order_status(order_id), - FuturesPaperCommand::EditOrder { + } = self; + execute_reset(balance, currency.as_deref(), fee_rate) + } +} + +impl Execute for Balance { + async fn execute(self, ctx: &AppContext) -> Result { + execute_balance(ctx.futures()?).await + } +} + +impl Execute for Status { + async fn execute(self, ctx: &AppContext) -> Result { + execute_status(ctx.futures()?).await + } +} + +impl Execute for Buy { + async fn execute(self, ctx: &AppContext) -> Result { + execute_order(PositionSide::Long, self.order, ctx.futures()?).await + } +} + +impl Execute for Sell { + async fn execute(self, ctx: &AppContext) -> Result { + execute_order(PositionSide::Short, self.order, ctx.futures()?).await + } +} + +impl Execute for Orders { + async fn execute(self, _ctx: &AppContext) -> Result { + execute_orders() + } +} + +impl Execute for OrderStatus { + async fn execute(self, _ctx: &AppContext) -> Result { + execute_order_status(&self.order_id) + } +} + +impl Execute for EditOrder { + async fn execute(self, _ctx: &AppContext) -> Result { + let Self { order_id, size, price, stop_price, - } => execute_edit_order(order_id, size, price, stop_price), - FuturesPaperCommand::Cancel { + } = self; + execute_edit_order(&order_id, &size, &price, &stop_price) + } +} + +impl Execute for Cancel { + async fn execute(self, _ctx: &AppContext) -> Result { + let Self { order_id, cli_ord_id, - } => execute_cancel(order_id.as_deref(), cli_ord_id.as_deref()), - FuturesPaperCommand::CancelAll { symbol } => execute_cancel_all(symbol.as_deref()), - FuturesPaperCommand::BatchOrder { orders_json } => { - execute_batch_order(orders_json, client, verbose).await - } - FuturesPaperCommand::Positions => execute_positions(client, verbose).await, - FuturesPaperCommand::Fills => execute_fills(), - FuturesPaperCommand::History => execute_history(), - FuturesPaperCommand::Leverage { symbol } => execute_leverage(symbol.as_deref()), - FuturesPaperCommand::SetLeverage { symbol, leverage } => { - execute_set_leverage(symbol, leverage) - } + } = self; + execute_cancel(order_id.as_deref(), cli_ord_id.as_deref()) + } +} + +impl Execute for CancelAll { + async fn execute(self, _ctx: &AppContext) -> Result { + execute_cancel_all(self.symbol.as_deref()) + } +} + +impl Execute for BatchOrder { + async fn execute(self, ctx: &AppContext) -> Result { + execute_batch_order(&self.orders_json, ctx.futures()?).await + } +} + +impl Execute for Positions { + async fn execute(self, ctx: &AppContext) -> Result { + execute_positions(ctx.futures()?).await + } +} + +impl Execute for Fills { + async fn execute(self, _ctx: &AppContext) -> Result { + execute_fills() + } +} + +impl Execute for History { + async fn execute(self, _ctx: &AppContext) -> Result { + execute_history() + } +} + +impl Execute for Leverage { + async fn execute(self, _ctx: &AppContext) -> Result { + execute_leverage(self.symbol.as_deref()) } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +impl Execute for SetLeverage { + async fn execute(self, _ctx: &AppContext) -> Result { + let Self { symbol, leverage } = self; + execute_set_leverage(&symbol, &leverage) + } +} fn fp_json(mut data: Value) -> Value { if let Some(obj) = data.as_object_mut() { @@ -321,11 +381,8 @@ fn validate_symbol(symbol: &str) -> Result<()> { Ok(()) } -async fn fetch_known_perpetual_symbols( - client: &FuturesClient, - verbose: bool, -) -> Result> { - let data = client.public_get("instruments", &[], verbose).await?; +async fn fetch_known_perpetual_symbols(client: &FuturesClient) -> Result> { + let data = client.public_get("instruments", &[]).await?; let instruments = data .get("instruments") .and_then(|v| v.as_array()) @@ -352,14 +409,10 @@ fn validate_symbol_exists(symbol: &str, known_symbols: &HashSet) -> Resu Ok(()) } -async fn fetch_ticker( - client: &FuturesClient, - symbol: &str, - verbose: bool, -) -> Result { +async fn fetch_ticker(client: &FuturesClient, symbol: &str) -> Result { validate_symbol(symbol)?; let endpoint = format!("tickers/{}", symbol.to_uppercase()); - let data = client.public_get(&endpoint, &[], verbose).await?; + let data = client.public_get(&endpoint, &[]).await?; let ticker = data .get("ticker") .ok_or_else(|| KrakenError::Parse("Missing ticker data".into()))?; @@ -396,10 +449,9 @@ async fn fetch_ticker( async fn fetch_orderbook_levels( client: &FuturesClient, symbol: &str, - verbose: bool, ) -> (Vec<(f64, f64)>, Vec<(f64, f64)>) { let params = [("symbol", symbol)]; - match client.public_get("orderbook", ¶ms, verbose).await { + match client.public_get("orderbook", ¶ms).await { Ok(data) => { let mut asks = Vec::new(); let mut bids = Vec::new(); @@ -409,12 +461,12 @@ async fn fetch_orderbook_levels( let mut out = Vec::new(); if let Some(arr) = book.get(key).and_then(|v| v.as_array()) { for level in arr { - if let Some(arr) = level.as_array() { - if arr.len() >= 2 { - let price = arr[0].as_f64().unwrap_or(0.0); - let size = arr[1].as_f64().unwrap_or(0.0); - out.push((price, size)); - } + if let Some(arr) = level.as_array() + && arr.len() >= 2 + { + let price = arr[0].as_f64().unwrap_or(0.0); + let size = arr[1].as_f64().unwrap_or(0.0); + out.push((price, size)); } } } @@ -429,10 +481,10 @@ async fn fetch_orderbook_levels( } } -async fn fetch_funding_rate(client: &FuturesClient, symbol: &str, verbose: bool) -> Result { +async fn fetch_funding_rate(client: &FuturesClient, symbol: &str) -> Result { let params = [("symbol", symbol)]; let data = client - .public_get("historical-funding-rates", ¶ms, verbose) + .public_get("historical-funding-rates", ¶ms) .await?; let rates = data .get("rates") @@ -445,11 +497,7 @@ async fn fetch_funding_rate(client: &FuturesClient, symbol: &str, verbose: bool) .ok_or_else(|| KrakenError::Parse("No funding rate entries".into())) } -async fn reconcile_best_effort( - state: &mut FuturesPaperState, - client: &FuturesClient, - verbose: bool, -) { +async fn reconcile_best_effort(state: &mut FuturesPaperState, client: &FuturesClient) { let symbols: Vec = state .positions .iter() @@ -467,7 +515,7 @@ async fn reconcile_best_effort( let mut last_prices = HashMap::new(); let mut index_prices = HashMap::new(); for sym in &symbols { - if let Ok(snap) = fetch_ticker(client, sym, verbose).await { + if let Ok(snap) = fetch_ticker(client, sym).await { if snap.mark > 0.0 { mark_prices.insert(sym.clone(), snap.mark); } @@ -490,7 +538,7 @@ async fn reconcile_best_effort( let mut funding_rates = HashMap::new(); for sym in &position_symbols { - if let Ok(rate) = fetch_funding_rate(client, sym, verbose).await { + if let Ok(rate) = fetch_funding_rate(client, sym).await { funding_rates.insert(sym.clone(), rate); } } @@ -509,14 +557,10 @@ async fn reconcile_best_effort( ); if let Err(e) = futures_paper::save_state(state) { - crate::output::warn(&format!("Could not save reconciled state: {e}")); + tracing::warn!(error = %e, "could not save reconciled state"); } } -// --------------------------------------------------------------------------- -// Command handlers -// --------------------------------------------------------------------------- - fn execute_init(balance: f64, currency: &str, fee_rate: Option) -> Result { if !balance.is_finite() || balance <= 0.0 { return Err(KrakenError::Validation( @@ -566,19 +610,19 @@ fn execute_reset( currency: Option<&str>, fee_rate: Option, ) -> Result { - if let Some(b) = balance { - if !b.is_finite() || b <= 0.0 { - return Err(KrakenError::Validation( - "Starting balance must be a finite positive number".into(), - )); - } + if let Some(b) = balance + && (!b.is_finite() || b <= 0.0) + { + return Err(KrakenError::Validation( + "Starting balance must be a finite positive number".into(), + )); } - if let Some(r) = fee_rate { - if !r.is_finite() || !(0.0..=1.0).contains(&r) { - return Err(KrakenError::Validation( - "Fee rate must be between 0.0 and 1.0".into(), - )); - } + if let Some(r) = fee_rate + && (!r.is_finite() || !(0.0..=1.0).contains(&r)) + { + return Err(KrakenError::Validation( + "Fee rate must be between 0.0 and 1.0".into(), + )); } let _lock = futures_paper::StateLock::acquire()?; @@ -607,12 +651,12 @@ fn execute_reset( )) } -async fn execute_balance(client: &FuturesClient, verbose: bool) -> Result { +async fn execute_balance(client: &FuturesClient) -> Result { let _lock = futures_paper::StateLock::acquire()?; let mut state = futures_paper::load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; + reconcile_best_effort(&mut state, client).await; - let mark_prices = build_mark_prices(&state, client, verbose).await; + let mark_prices = build_mark_prices(&state, client).await; let upnl = state.unrealized_pnl(&mark_prices); let used = state.used_margin(); let available = state.available_margin(&mark_prices); @@ -645,12 +689,12 @@ async fn execute_balance(client: &FuturesClient, verbose: bool) -> Result Result { +async fn execute_status(client: &FuturesClient) -> Result { let _lock = futures_paper::StateLock::acquire()?; let mut state = futures_paper::load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; + reconcile_best_effort(&mut state, client).await; - let mark_prices = build_mark_prices(&state, client, verbose).await; + let mark_prices = build_mark_prices(&state, client).await; let upnl = state.unrealized_pnl(&mark_prices); let equity = state.collateral + upnl; let pnl_from_start = equity - state.starting_collateral; @@ -698,31 +742,32 @@ async fn execute_status(client: &FuturesClient, verbose: bool) -> Result, - stop_price_str: &Option, - trigger_signal_str: &Option, - client_order_id: &Option, - reduce_only: bool, - leverage_str: &Option, - trailing_max_str: &Option, - trailing_unit: &Option, + side: PositionSide, + order: OrderRequest, client: &FuturesClient, - verbose: bool, ) -> Result { - validate_symbol(symbol)?; - let known_symbols = fetch_known_perpetual_symbols(client, verbose).await?; - validate_symbol_exists(symbol, &known_symbols)?; + let OrderRequest { + symbol, + size: size_str, + r#type: order_type, + price: price_str, + stop_price: stop_price_str, + trigger_signal, + client_order_id, + reduce_only, + leverage: leverage_str, + trailing_stop_max_deviation: trailing_max_str, + trailing_stop_deviation_unit: trailing_unit, + } = order; + + validate_symbol(&symbol)?; + let known_symbols = fetch_known_perpetual_symbols(client).await?; + validate_symbol_exists(&symbol, &known_symbols)?; let size: f64 = size_str .parse() .map_err(|_| KrakenError::Validation(format!("Invalid size: {size_str}")))?; - let order_type = FuturesOrderType::from_str_cli(order_type_str)?; let price: Option = price_str .as_ref() .map(|p| { @@ -737,10 +782,6 @@ async fn execute_order( .map_err(|_| KrakenError::Validation(format!("Invalid stop price: {p}"))) }) .transpose()?; - let trigger_signal = trigger_signal_str - .as_ref() - .map(|s| TriggerSignal::from_str_cli(s)) - .transpose()?; let leverage: Option = leverage_str .as_ref() .map(|l| { @@ -757,28 +798,29 @@ async fn execute_order( }) .transpose()?; - if let Some(ref unit) = trailing_unit { - if unit != "percent" && unit != "quote_currency" { - return Err(KrakenError::Validation(format!( - "Invalid trailing-stop-deviation-unit '{unit}'. Use 'percent' or 'quote_currency'." - ))); - } + if let Some(unit) = &trailing_unit + && unit != "percent" + && unit != "quote_currency" + { + return Err(KrakenError::Validation(format!( + "Invalid trailing-stop-deviation-unit '{unit}'. Use 'percent' or 'quote_currency'." + ))); } - if let Some(lev) = leverage { - if !lev.is_finite() || !(1.0..=MAX_LEVERAGE).contains(&lev) { - return Err(KrakenError::Validation(format!( - "Leverage must be between 1.0 and {MAX_LEVERAGE:.0}" - ))); - } + if let Some(lev) = leverage + && (!lev.is_finite() || !(1.0..=MAX_LEVERAGE).contains(&lev)) + { + return Err(KrakenError::Validation(format!( + "Leverage must be between 1.0 and {MAX_LEVERAGE:.0}" + ))); } let _lock = futures_paper::StateLock::acquire()?; let mut state = futures_paper::load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; + reconcile_best_effort(&mut state, client).await; - let mut snapshot = fetch_ticker(client, symbol, verbose).await?; - let (ask_levels, bid_levels) = fetch_orderbook_levels(client, symbol, verbose).await; + let mut snapshot = fetch_ticker(client, &symbol).await?; + let (ask_levels, bid_levels) = fetch_orderbook_levels(client, &symbol).await; snapshot.ask_levels = ask_levels; snapshot.bid_levels = bid_levels; @@ -790,11 +832,11 @@ async fn execute_order( price, stop_price, trigger_signal, - client_order_id: client_order_id.clone(), + client_order_id, reduce_only, leverage, trailing_stop_max_deviation: trailing_max, - trailing_stop_deviation_unit: trailing_unit.clone(), + trailing_stop_deviation_unit: trailing_unit, }; let result = state.place_order(params, &snapshot)?; @@ -819,7 +861,7 @@ async fn execute_order( ("Mode".into(), "[FUTURES PAPER] Simulated Trading".into()), ("Order ID".into(), result.order_id.clone()), ("Symbol".into(), symbol.to_uppercase()), - ("Side".into(), side.to_string()), + ("PositionSide".into(), side.to_string()), ("Status".into(), result.status.to_string()), ]; @@ -858,7 +900,7 @@ fn execute_orders() -> Result { let headers = vec![ "[FP] Order ID".into(), "Symbol".into(), - "Side".into(), + "PositionSide".into(), "Type".into(), "Size".into(), "Price".into(), @@ -931,7 +973,7 @@ fn execute_order_status(order_id: &str) -> Result { ("Mode".into(), "[FUTURES PAPER] Simulated Trading".into()), ("Order ID".into(), order.id.clone()), ("Symbol".into(), order.symbol.clone()), - ("Side".into(), order.side.to_string()), + ("PositionSide".into(), order.side.to_string()), ("Type".into(), order.order_type.to_string()), ("Size".into(), format!("{:.8}", order.size)), ( @@ -1059,11 +1101,7 @@ fn execute_cancel_all(symbol: Option<&str>) -> Result { )) } -async fn execute_batch_order( - orders_json: &str, - client: &FuturesClient, - verbose: bool, -) -> Result { +async fn execute_batch_order(orders_json: &str, client: &FuturesClient) -> Result { let json_str = if let Some(path) = orders_json.strip_prefix('@') { std::fs::read_to_string(path).map_err(|e| { KrakenError::Io(std::io::Error::new( @@ -1080,7 +1118,7 @@ async fn execute_batch_order( let _lock = futures_paper::StateLock::acquire()?; let mut state = futures_paper::load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; + reconcile_best_effort(&mut state, client).await; let mut snapshots = HashMap::new(); let symbols: Vec = parsed @@ -1094,15 +1132,15 @@ async fn execute_batch_order( .into_iter() .collect(); - let known_symbols = fetch_known_perpetual_symbols(client, verbose).await?; + let known_symbols = fetch_known_perpetual_symbols(client).await?; for sym in &symbols { validate_symbol(sym)?; validate_symbol_exists(sym, &known_symbols)?; } for sym in &symbols { - if let Ok(mut snap) = fetch_ticker(client, sym, verbose).await { - let (ask_levels, bid_levels) = fetch_orderbook_levels(client, sym, verbose).await; + if let Ok(mut snap) = fetch_ticker(client, sym).await { + let (ask_levels, bid_levels) = fetch_orderbook_levels(client, sym).await; snap.ask_levels = ask_levels; snap.bid_levels = bid_levels; snapshots.insert(sym.clone(), snap); @@ -1193,12 +1231,12 @@ fn parse_batch_row(v: &Value) -> Result { .and_then(|s| s.as_str()) .ok_or_else(|| KrakenError::Validation("Batch row missing 'side'".into()))?; let side = match side_str { - "buy" | "long" => Side::Long, - "sell" | "short" => Side::Short, + "buy" | "long" => PositionSide::Long, + "sell" | "short" => PositionSide::Short, _ => { return Err(KrakenError::Validation(format!( "Invalid side '{side_str}'. Use buy/long or sell/short." - ))) + ))); } }; let size = parse_batch_f64(v, "size")? @@ -1214,7 +1252,7 @@ fn parse_batch_row(v: &Value) -> Result { None => { return Err(KrakenError::Validation( "trigger_signal must be a string (mark, index, or last)".into(), - )) + )); } }, None => None, @@ -1224,12 +1262,13 @@ fn parse_batch_row(v: &Value) -> Result { .get("trailing_stop_deviation_unit") .and_then(|s| s.as_str()) .map(|s| s.to_string()); - if let Some(ref unit) = trailing_stop_deviation_unit { - if unit != "percent" && unit != "quote_currency" { - return Err(KrakenError::Validation(format!( - "Invalid trailing_stop_deviation_unit '{unit}'. Use 'percent' or 'quote_currency'." - ))); - } + if let Some(ref unit) = trailing_stop_deviation_unit + && unit != "percent" + && unit != "quote_currency" + { + return Err(KrakenError::Validation(format!( + "Invalid trailing_stop_deviation_unit '{unit}'. Use 'percent' or 'quote_currency'." + ))); } Ok(OrderParams { @@ -1254,16 +1293,16 @@ fn parse_batch_row(v: &Value) -> Result { }) } -async fn execute_positions(client: &FuturesClient, verbose: bool) -> Result { +async fn execute_positions(client: &FuturesClient) -> Result { let _lock = futures_paper::StateLock::acquire()?; let mut state = futures_paper::load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; + reconcile_best_effort(&mut state, client).await; - let mark_prices = build_mark_prices(&state, client, verbose).await; + let mark_prices = build_mark_prices(&state, client).await; let headers = vec![ "[FP] Symbol".into(), - "Side".into(), + "PositionSide".into(), "Size".into(), "Entry".into(), "Mark".into(), @@ -1339,7 +1378,7 @@ fn execute_fills() -> Result { "[FP] Fill ID".into(), "Order ID".into(), "Symbol".into(), - "Side".into(), + "PositionSide".into(), "Size".into(), "Price".into(), "Fee".into(), @@ -1367,7 +1406,7 @@ fn execute_fills() -> Result { .map(|p| format!("{p:+.2}")) .unwrap_or_else(|| "—".into()), f.fill_type.clone(), - f.filled_at.clone(), + kraken_recording::format_instant(f.filled_at), ] }) .collect() @@ -1426,7 +1465,7 @@ fn execute_history() -> Result { h.symbol.clone().unwrap_or_else(|| "—".into()), format!("{:+.4}", h.amount), h.details.clone(), - h.timestamp.clone(), + kraken_recording::format_instant(h.timestamp), ] }) .collect() @@ -1529,7 +1568,6 @@ fn execute_set_leverage(symbol: &str, leverage_str: &str) -> Result HashMap { let mut prices = HashMap::new(); let symbols: Vec = state @@ -1541,10 +1579,10 @@ async fn build_mark_prices( .collect(); for sym in symbols { - if let Ok(snap) = fetch_ticker(client, &sym, verbose).await { - if snap.mark > 0.0 { - prices.insert(sym, snap.mark); - } + if let Ok(snap) = fetch_ticker(client, &sym).await + && snap.mark > 0.0 + { + prices.insert(sym, snap.mark); } } prices @@ -1571,10 +1609,12 @@ mod tests { fn validate_unknown_symbol_rejected() { let result = validate_symbol("BTCUSD"); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Unknown futures symbol")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Unknown futures symbol") + ); } #[test] @@ -1596,10 +1636,12 @@ mod tests { .collect(); let result = validate_symbol_exists("PF_FAKEUSD", &known); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Unknown perpetual symbol")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Unknown perpetual symbol") + ); } #[test] @@ -1622,10 +1664,12 @@ mod tests { assert!(validate_symbol("PF_NOSUCHASSET").is_ok()); let result = validate_symbol_exists("PF_NOSUCHASSET", &known); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("not found in available futures instruments")); + assert!( + result + .unwrap_err() + .to_string() + .contains("not found in available futures instruments") + ); } #[test] diff --git a/src/commands/futures_ws.rs b/src/commands/futures_ws.rs index 17d4b02..709487f 100644 --- a/src/commands/futures_ws.rs +++ b/src/commands/futures_ws.rs @@ -1,36 +1,43 @@ -/// Futures WebSocket v1 streaming commands. -/// -/// Implements challenge-based authentication for private feeds, keepalive ping -/// every 45 seconds, and bounded reconnect with exponential backoff plus -/// reconnect safety budgets. -/// -/// Public feeds: ticker, trade, book -/// Private feeds: fills, open-orders, open-positions, balances, notifications, account-log +//! Futures WebSocket streaming commands. +//! +//! Futures speaks WS v1, not the spot v2 protocol, so it cannot ride the +//! `kraken-ws` stack: private feeds authenticate by signing a server-issued +//! challenge (not a pre-minted token), liveness is a client-side ping, and — +//! unlike the spot stream's uncapped backoff — reconnects are bounded by +//! attempt and per-window safety budgets. + use std::collections::VecDeque; -use std::env; -use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use clap::Subcommand; use futures_util::{SinkExt, StreamExt}; -use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; -use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -use rustls::{ClientConfig, DigitallySignedStruct, Error as RustlsError, SignatureScheme}; +use secrecy::ExposeSecret; use serde_json::Value; use tokio::net::TcpStream; use tokio::time::Instant; +use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::{HeaderValue, Request}; -use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{ - connect_async, connect_async_tls_with_config, Connector, MaybeTlsStream, WebSocketStream, + MaybeTlsStream, WebSocketStream, connect_async, connect_async_tls_with_config, }; -use crate::client::FuturesClient; -use crate::config::FuturesCredentials; +use super::Execute; +use super::streaming::streaming_unsupported; +use crate::cli::AppContext; +use crate::config::Credentials; use crate::errors::{KrakenError, Result}; use crate::output::{self, CommandOutput}; +/// Extract the host portion of a URL for safe logging (avoids leaking full paths/query). +fn url_host(url: &str) -> &str { + let without_scheme = url.split("://").nth(1).unwrap_or(url); + without_scheme + .split(['/', '?']) + .next() + .unwrap_or(without_scheme) +} + const DEFAULT_FUTURES_WS_URL: &str = "wss://futures.kraken.com/ws/v1"; const MAX_RECONNECTS: u32 = 12; const RECONNECT_BASE_MS: u64 = 1000; @@ -113,17 +120,18 @@ fn markets(cmd: &FuturesWsCommand) -> Option<&[String]> { } } -fn resolve_futures_ws_url() -> String { - env::var("KRAKEN_FUTURES_WS_URL").unwrap_or_else(|_| DEFAULT_FUTURES_WS_URL.to_string()) -} - +/// Stream a futures WebSocket feed until the connection ends. This is an +/// interactive/streaming command handled by the dispatcher, not the shared +/// request/response executor — hence `Result<()>` rather than `CommandOutput`. pub(crate) async fn execute( cmd: &FuturesWsCommand, - _client: &FuturesClient, - creds: Option<&FuturesCredentials>, - verbose: bool, -) -> Result { - let url = resolve_futures_ws_url(); + creds: Option<&Credentials>, + ws_futures_url: Option<&str>, + accept_invalid_certs: bool, +) -> Result<()> { + // URL precedence (flag > env) is resolved by clap upstream; the default is + // applied here. The scheme is validated regardless of source. + let url = ws_futures_url.unwrap_or(DEFAULT_FUTURES_WS_URL).to_string(); crate::client::validate_url_scheme(&url)?; let feed = command_to_feed(cmd); @@ -132,13 +140,21 @@ pub(crate) async fn execute( let creds = creds.ok_or_else(|| { KrakenError::Auth("Futures credentials required for private WebSocket feeds.".into()) })?; - stream_private(feed, creds, verbose, &url).await?; + stream_private(feed, creds, &url, accept_invalid_certs).await?; } else { let markets = markets(cmd).unwrap_or(&[]); - stream_public(feed, markets, verbose, &url).await?; + stream_public(feed, markets, &url, accept_invalid_certs).await?; } - Ok(CommandOutput::message("WebSocket stream ended")) + Ok(()) +} + +impl Execute for FuturesWsCommand { + async fn execute(self, _ctx: &AppContext) -> Result { + // `futures ws` streams; `dispatch` intercepts it, so only the + // render-free executor (the MCP path) can reach this rejection. + Err(streaming_unsupported()) + } } fn build_public_subscribe(feed: &str, markets: &[String]) -> Value { @@ -153,37 +169,11 @@ fn build_public_subscribe(feed: &str, markets: &[String]) -> Value { } fn ws_handshake_request(url: &str) -> Result> { - let mut request = url - .into_client_request() - .map_err(|e| KrakenError::WebSocket(format!("Invalid WebSocket URL: {e}")))?; + let mut request = url.into_client_request()?; let headers = request.headers_mut(); - headers.insert( - "User-Agent", - HeaderValue::from_str(crate::telemetry::user_agent()) - .map_err(|e| KrakenError::WebSocket(format!("Invalid header value: {e}")))?, - ); - headers.insert( - "X-Kraken-Client", - HeaderValue::from_static(crate::telemetry::CLIENT_NAME), - ); - headers.insert( - "X-Kraken-Client-Version", - HeaderValue::from_static(crate::telemetry::CLIENT_VERSION), - ); - headers.insert( - "X-Kraken-Agent-Client", - HeaderValue::from_str(crate::telemetry::agent_client()) - .map_err(|e| KrakenError::WebSocket(format!("Invalid header value: {e}")))?, - ); - headers.insert( - "X-Kraken-Instance-Id", - HeaderValue::from_str(crate::telemetry::instance_id()) - .map_err(|e| KrakenError::WebSocket(format!("Invalid header value: {e}")))?, - ); - headers.insert( - "x-korigin", - HeaderValue::from_static(crate::telemetry::KORIGIN_WS), - ); + for (name, value) in crate::telemetry::ws_handshake_headers() { + headers.insert(name, HeaderValue::from_str(value)?); + } Ok(request) } @@ -192,23 +182,15 @@ type WsConnection = ( tokio_tungstenite::tungstenite::http::Response>>, ); -async fn ws_connect(url: &str) -> Result { - let skip_verify = env::var("KRAKEN_DANGER_ACCEPT_INVALID_CERTS") - .map(|v| matches!(v.as_str(), "1" | "true" | "yes")) - .unwrap_or(false); +async fn ws_connect(url: &str, skip_verify: bool) -> Result { let request = ws_handshake_request(url)?; let connect = if skip_verify { - eprintln!("WARNING: TLS certificate verification is DISABLED for WebSocket connections."); - let config = ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)) - .with_no_client_auth(); - let connector = Connector::Rustls(Arc::new(config)); + let connector = kraken_ws::dangerous_tls_connector(); connect_async_tls_with_config(request, None, false, Some(connector)).await } else { connect_async(request).await }; - connect.map_err(|e| KrakenError::WebSocket(format!("Connection failed: {e}"))) + Ok(connect?) } fn reconnect_jitter_ms() -> u64 { @@ -246,10 +228,13 @@ async fn enforce_reconnect_budget(reconnect_history: &mut VecDeque, lab let remaining = Duration::from_secs(RECONNECT_WINDOW_SECS).saturating_sub(oldest.elapsed()); let wait_for = remaining.saturating_add(Duration::from_millis(reconnect_jitter_ms())); - output::warn(&format!( - "{label}: reconnect safety budget reached ({MAX_RECONNECTS_PER_WINDOW}/{RECONNECT_WINDOW_SECS}s), waiting {}ms", - wait_for.as_millis() - )); + tracing::warn!( + label, + max = MAX_RECONNECTS_PER_WINDOW, + window_secs = RECONNECT_WINDOW_SECS, + wait_ms = wait_for.as_millis() as u64, + "reconnect safety budget reached" + ); tokio::time::sleep(wait_for).await; } prune_reconnect_window(reconnect_history); @@ -257,36 +242,42 @@ async fn enforce_reconnect_budget(reconnect_history: &mut VecDeque, lab reconnect_history.push_back(Instant::now()); } -async fn stream_public(feed: &str, markets: &[String], verbose: bool, url: &str) -> Result<()> { +async fn stream_public(feed: &str, markets: &[String], url: &str, skip_verify: bool) -> Result<()> { let mut reconnect_count = 0u32; let mut reconnect_history = VecDeque::new(); loop { - if verbose { - if reconnect_count > 0 { - output::verbose(&format!( - "Reconnecting to {url} (attempt {reconnect_count}/{MAX_RECONNECTS})" - )); - } else { - output::verbose(&format!("Connecting to {url}")); - } + if reconnect_count > 0 { + tracing::debug!( + feed, + host = url_host(url), + reconnect_count, + max = MAX_RECONNECTS, + "reconnecting" + ); + } else { + tracing::info!(feed, host = url_host(url), "connecting"); } - let ws_result = ws_connect(url).await; + let ws_result = ws_connect(url, skip_verify).await; let (mut ws, _) = match ws_result { Ok(conn) => conn, Err(e) => { if reconnect_count >= MAX_RECONNECTS { - return Err(KrakenError::WebSocket(format!( + return Err(KrakenError::websocket(format!( "Connection failed after {MAX_RECONNECTS} reconnect attempts: {e}" ))); } reconnect_count += 1; enforce_reconnect_budget(&mut reconnect_history, "futures-ws-public").await; let backoff = reconnect_backoff_ms(reconnect_count); - output::warn(&format!( - "Futures WS connection failed: {e}, retrying in {backoff}ms" - )); + tracing::warn!( + feed, + reconnect_count, + backoff_ms = backoff, + error = %e, + "connection failed, retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; continue; } @@ -298,13 +289,11 @@ async fn stream_public(feed: &str, markets: &[String], verbose: bool, url: &str) let msg = serde_json::to_string(&subscribe).map_err(|e| KrakenError::Parse(e.to_string()))?; - if verbose { - output::verbose(&format!("Subscribing: {msg}")); - } + tracing::info!(feed, "subscribing"); - ws.send(Message::Text(msg)) + ws.send(Message::Text(msg.into())) .await - .map_err(|e| KrakenError::WebSocket(format!("Send failed: {e}")))?; + .map_err(|e| KrakenError::websocket(format!("Send failed: {e}")))?; let shutdown = tokio::signal::ctrl_c(); tokio::pin!(shutdown); @@ -318,21 +307,17 @@ async fn stream_public(feed: &str, markets: &[String], verbose: bool, url: &str) Some(Ok(Message::Text(text))) => { if let Some(err_msg) = handle_futures_ws_message(&text) { let _ = ws.close(None).await; - return Err(KrakenError::WebSocket(format!( + return Err(KrakenError::websocket(format!( "Futures WS error: {err_msg}" ))); } } Some(Ok(Message::Close(_))) => { - if verbose { - output::verbose("Futures WS closed by server"); - } + tracing::debug!(feed, "closed by server"); break true; } Some(Err(e)) => { - if verbose { - output::verbose(&format!("Futures WS stream error: {e}")); - } + tracing::debug!(feed, error = %e, "stream error"); break true; } None => break true, @@ -342,14 +327,12 @@ async fn stream_public(feed: &str, markets: &[String], verbose: bool, url: &str) _ = ping_interval.tick() => { let ping = serde_json::json!({"event": "ping"}); let payload = serde_json::to_string(&ping).unwrap_or_default(); - if ws.send(Message::Text(payload)).await.is_err() { + if ws.send(Message::Text(payload.into())).await.is_err() { break true; } } _ = &mut shutdown => { - if verbose { - output::verbose("Shutting down Futures WS connection"); - } + tracing::info!(feed, "shutting down connection"); let _ = ws.close(None).await; return Ok(()); } @@ -362,15 +345,24 @@ async fn stream_public(feed: &str, markets: &[String], verbose: bool, url: &str) } reconnect_count += 1; if reconnect_count > MAX_RECONNECTS { - return Err(KrakenError::WebSocket(format!( + tracing::error!( + feed, + max = MAX_RECONNECTS, + "connection lost after max reconnects" + ); + return Err(KrakenError::websocket(format!( "Futures WS connection lost after {MAX_RECONNECTS} reconnect attempts" ))); } enforce_reconnect_budget(&mut reconnect_history, "futures-ws-public").await; let backoff = reconnect_backoff_ms(reconnect_count); - output::warn(&format!( - "Futures WS disconnected, reconnecting in {backoff}ms ({reconnect_count}/{MAX_RECONNECTS})" - )); + tracing::warn!( + feed, + reconnect_count, + max = MAX_RECONNECTS, + backoff_ms = backoff, + "disconnected, reconnecting" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } } @@ -378,39 +370,45 @@ async fn stream_public(feed: &str, markets: &[String], verbose: bool, url: &str) async fn stream_private( feed: &str, - creds: &FuturesCredentials, - verbose: bool, + creds: &Credentials, url: &str, + skip_verify: bool, ) -> Result<()> { let mut reconnect_count = 0u32; let mut reconnect_history = VecDeque::new(); loop { - if verbose { - if reconnect_count > 0 { - output::verbose(&format!( - "Reconnecting to {url} (attempt {reconnect_count}/{MAX_RECONNECTS})" - )); - } else { - output::verbose(&format!("Connecting to {url}")); - } + if reconnect_count > 0 { + tracing::debug!( + feed, + host = url_host(url), + reconnect_count, + max = MAX_RECONNECTS, + "reconnecting" + ); + } else { + tracing::info!(feed, host = url_host(url), "connecting"); } - let ws_result = ws_connect(url).await; + let ws_result = ws_connect(url, skip_verify).await; let (mut ws, _) = match ws_result { Ok(conn) => conn, Err(e) => { if reconnect_count >= MAX_RECONNECTS { - return Err(KrakenError::WebSocket(format!( + return Err(KrakenError::websocket(format!( "Connection failed after {MAX_RECONNECTS} reconnect attempts: {e}" ))); } reconnect_count += 1; enforce_reconnect_budget(&mut reconnect_history, "futures-ws-private").await; let backoff = reconnect_backoff_ms(reconnect_count); - output::warn(&format!( - "Futures WS connection failed: {e}, retrying in {backoff}ms" - )); + tracing::warn!( + feed, + reconnect_count, + backoff_ms = backoff, + error = %e, + "connection failed, retrying" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; continue; } @@ -418,32 +416,27 @@ async fn stream_private( let connected_at = Instant::now(); - // Step 1: Request challenge let challenge_request = serde_json::json!({ "event": "challenge", - "api_key": creds.api_key, + "api_key": creds.api_key.expose_secret(), }); let challenge_msg = serde_json::to_string(&challenge_request) .map_err(|e| KrakenError::Parse(e.to_string()))?; - if verbose { - output::verbose("Requesting challenge for Futures WS auth"); - } + tracing::debug!(feed, "requesting challenge for auth"); - ws.send(Message::Text(challenge_msg)) + ws.send(Message::Text(challenge_msg.into())) .await - .map_err(|e| KrakenError::WebSocket(format!("Send challenge failed: {e}")))?; + .map_err(|e| KrakenError::websocket(format!("Send challenge failed: {e}")))?; - // Step 2: Receive challenge - let challenge = receive_challenge(&mut ws, verbose).await?; + let challenge = receive_challenge(&mut ws).await?; - // Step 3: Sign challenge and subscribe let signed = sign_challenge(&challenge, &creds.api_secret)?; let subscribe = serde_json::json!({ "event": "subscribe", "feed": feed, - "api_key": creds.api_key, + "api_key": creds.api_key.expose_secret(), "original_challenge": challenge, "signed_challenge": signed, }); @@ -451,15 +444,11 @@ async fn stream_private( let sub_msg = serde_json::to_string(&subscribe).map_err(|e| KrakenError::Parse(e.to_string()))?; - if verbose { - output::verbose(&format!( - "Subscribing to private feed '{feed}' with signed challenge" - )); - } + tracing::info!(feed, "subscribing (private, signed challenge)"); - ws.send(Message::Text(sub_msg)) + ws.send(Message::Text(sub_msg.into())) .await - .map_err(|e| KrakenError::WebSocket(format!("Send subscribe failed: {e}")))?; + .map_err(|e| KrakenError::websocket(format!("Send subscribe failed: {e}")))?; let shutdown = tokio::signal::ctrl_c(); tokio::pin!(shutdown); @@ -473,21 +462,17 @@ async fn stream_private( Some(Ok(Message::Text(text))) => { if let Some(err_msg) = handle_futures_ws_message(&text) { let _ = ws.close(None).await; - return Err(KrakenError::WebSocket(format!( + return Err(KrakenError::websocket(format!( "Futures WS error: {err_msg}" ))); } } Some(Ok(Message::Close(_))) => { - if verbose { - output::verbose("Futures WS closed by server"); - } + tracing::debug!(feed, "closed by server"); break true; } Some(Err(e)) => { - if verbose { - output::verbose(&format!("Futures WS stream error: {e}")); - } + tracing::debug!(feed, error = %e, "stream error"); break true; } None => break true, @@ -497,14 +482,12 @@ async fn stream_private( _ = ping_interval.tick() => { let ping = serde_json::json!({"event": "ping"}); let payload = serde_json::to_string(&ping).unwrap_or_default(); - if ws.send(Message::Text(payload)).await.is_err() { + if ws.send(Message::Text(payload.into())).await.is_err() { break true; } } _ = &mut shutdown => { - if verbose { - output::verbose("Shutting down Futures WS connection"); - } + tracing::info!(feed, "shutting down connection"); let _ = ws.close(None).await; return Ok(()); } @@ -517,24 +500,30 @@ async fn stream_private( } reconnect_count += 1; if reconnect_count > MAX_RECONNECTS { - return Err(KrakenError::WebSocket(format!( + tracing::error!( + feed, + max = MAX_RECONNECTS, + "connection lost after max reconnects" + ); + return Err(KrakenError::websocket(format!( "Futures WS connection lost after {MAX_RECONNECTS} reconnect attempts" ))); } enforce_reconnect_budget(&mut reconnect_history, "futures-ws-private").await; let backoff = reconnect_backoff_ms(reconnect_count); - output::warn(&format!( - "Futures WS disconnected, reconnecting in {backoff}ms ({reconnect_count}/{MAX_RECONNECTS})" - )); + tracing::warn!( + feed, + reconnect_count, + max = MAX_RECONNECTS, + backoff_ms = backoff, + "disconnected, reconnecting" + ); tokio::time::sleep(Duration::from_millis(backoff)).await; } } } -async fn receive_challenge( - ws: &mut WebSocketStream>, - verbose: bool, -) -> Result { +async fn receive_challenge(ws: &mut WebSocketStream>) -> Result { let timeout = tokio::time::sleep(Duration::from_secs(10)); tokio::pin!(timeout); @@ -545,29 +534,26 @@ async fn receive_challenge( Some(Ok(Message::Text(text))) => { if let Ok(val) = serde_json::from_str::(&text) { let event = val.get("event").and_then(|e| e.as_str()).unwrap_or(""); - if event == "challenge" { - if let Some(message) = val.get("message").and_then(|m| m.as_str()) { - if verbose { - output::verbose("Challenge received"); - } + if event == "challenge" + && let Some(message) = val.get("message").and_then(|m| m.as_str()) { + tracing::debug!("challenge received"); return Ok(message.to_string()); } - } if event == "error" { let err_msg = val.get("message").and_then(|m| m.as_str()).unwrap_or("unknown"); - return Err(KrakenError::WebSocket(format!( + return Err(KrakenError::websocket(format!( "Challenge request failed: {err_msg}" ))); } } } Some(Ok(Message::Close(_))) | None => { - return Err(KrakenError::WebSocket( - "Connection closed before challenge received".into(), + return Err(KrakenError::websocket( + "Connection closed before challenge received", )); } Some(Err(e)) => { - return Err(KrakenError::WebSocket(format!( + return Err(KrakenError::websocket(format!( "Stream error waiting for challenge: {e}" ))); } @@ -575,8 +561,8 @@ async fn receive_challenge( } } _ = &mut timeout => { - return Err(KrakenError::WebSocket( - "Timed out waiting for challenge response".into(), + return Err(KrakenError::websocket( + "Timed out waiting for challenge response", )); } } @@ -589,15 +575,15 @@ async fn receive_challenge( /// Result is base64-encoded. pub(crate) fn sign_challenge( challenge: &str, - api_secret: &crate::config::SecretValue, + api_secret: &secrecy::SecretString, ) -> Result { - use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; - use hmac::{Hmac, Mac}; + use base64::engine::general_purpose::STANDARD as BASE64; + use hmac::{Hmac, KeyInit, Mac}; use sha2::{Digest, Sha256, Sha512}; let secret_bytes = BASE64 - .decode(api_secret.expose()) + .decode(api_secret.expose_secret()) .map_err(|e| KrakenError::Auth(format!("Failed to decode Futures API secret: {e}")))?; let sha_digest = Sha256::digest(challenge.as_bytes()); @@ -610,7 +596,9 @@ pub(crate) fn sign_challenge( Ok(BASE64.encode(result)) } -/// Returns `Some(error_message)` when the server sends an error event, `None` otherwise. +/// Route one inbound frame: control events (pong/subscribed/challenge/info) are +/// swallowed, data frames render as JSONL on stdout, and a server `error` event +/// returns its message for the caller to fail the stream on. fn handle_futures_ws_message(text: &str) -> Option { if let Ok(val) = serde_json::from_str::(text) { let event = val.get("event").and_then(|e| e.as_str()).unwrap_or(""); @@ -624,67 +612,18 @@ fn handle_futures_ws_message(text: &str) -> Option { if event == "pong" || event == "subscribed" || event == "challenge" || event == "info" { return None; } - output::json::render_ndjson(&val); + output::json::render_jsonl(&val); } None } -#[derive(Debug)] -struct NoVerify; - -impl ServerCertVerifier for NoVerify { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> std::result::Result { - Ok(ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> std::result::Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - SignatureScheme::RSA_PKCS1_SHA256, - SignatureScheme::RSA_PKCS1_SHA384, - SignatureScheme::RSA_PKCS1_SHA512, - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ECDSA_NISTP384_SHA384, - SignatureScheme::ECDSA_NISTP521_SHA512, - SignatureScheme::RSA_PSS_SHA256, - SignatureScheme::RSA_PSS_SHA384, - SignatureScheme::RSA_PSS_SHA512, - SignatureScheme::ED25519, - SignatureScheme::ED448, - ] - } -} - #[cfg(test)] mod tests { - use super::*; - use crate::config::SecretValue; - use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; + use base64::engine::general_purpose::STANDARD as BASE64; + use secrecy::SecretString; + + use super::*; #[test] fn ws_handshake_includes_korigin() { @@ -698,7 +637,7 @@ mod tests { #[test] fn sign_challenge_deterministic() { - let secret = SecretValue::new(BASE64.encode(b"futures_ws_secret_key_32chars!!")); + let secret = SecretString::from(BASE64.encode(b"futures_ws_secret_key_32chars!!")); let challenge = "test-challenge-uuid-12345"; let sig1 = sign_challenge(challenge, &secret).unwrap(); let sig2 = sign_challenge(challenge, &secret).unwrap(); @@ -708,7 +647,7 @@ mod tests { #[test] fn sign_challenge_different_inputs_different_outputs() { - let secret = SecretValue::new(BASE64.encode(b"futures_ws_secret_key_32chars!!")); + let secret = SecretString::from(BASE64.encode(b"futures_ws_secret_key_32chars!!")); let sig1 = sign_challenge("challenge-a", &secret).unwrap(); let sig2 = sign_challenge("challenge-b", &secret).unwrap(); assert_ne!(sig1, sig2); @@ -716,7 +655,7 @@ mod tests { #[test] fn sign_challenge_rejects_invalid_base64_secret() { - let secret = SecretValue::new("not-valid-base64!!!".to_string()); + let secret = SecretString::from("not-valid-base64!!!".to_string()); assert!(sign_challenge("test", &secret).is_err()); } diff --git a/src/commands/helpers.rs b/src/commands/helpers.rs deleted file mode 100644 index a0c00cc..0000000 --- a/src/commands/helpers.rs +++ /dev/null @@ -1,54 +0,0 @@ -/// Shared helper functions used across multiple command modules. -use serde_json::Value; - -use crate::errors::{KrakenError, Result}; -use crate::output::CommandOutput; - -/// Extract a string value from a JSON object by key, returning "-" on miss. -pub(crate) fn jstr(val: &Value, key: &str) -> String { - val.get(key) - .map(|v| match v { - Value::String(s) => s.clone(), - other => other.to_string(), - }) - .unwrap_or_else(|| "-".to_string()) -} - -/// Parse a generic JSON value into key-value pairs for rendering. -pub(crate) fn parse_generic(data: &Value) -> CommandOutput { - let pairs: Vec<(String, String)> = if let Some(obj) = data.as_object() { - obj.iter() - .map(|(k, v)| { - let val = match v { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - (k.clone(), val) - }) - .collect() - } else if let Some(arr) = data.as_array() { - arr.iter() - .enumerate() - .map(|(i, v)| (format!("[{i}]"), v.to_string())) - .collect() - } else { - vec![("Result".into(), data.to_string())] - }; - CommandOutput::key_value(pairs, data.clone()) -} - -/// Prompt user to confirm a destructive operation; abort on decline. -pub(crate) fn confirm_destructive(prompt: &str) -> Result<()> { - let confirmed = dialoguer::Confirm::new() - .with_prompt(prompt) - .default(false) - .interact() - .map_err(|e| KrakenError::Config(format!("Prompt error: {e}")))?; - - if !confirmed { - return Err(KrakenError::Validation( - "Operation cancelled by user".into(), - )); - } - Ok(()) -} diff --git a/src/commands/journal_mirror.rs b/src/commands/journal_mirror.rs new file mode 100644 index 0000000..546949c --- /dev/null +++ b/src/commands/journal_mirror.rs @@ -0,0 +1,279 @@ +//! Local evidence mirror for live venue activity. +//! +//! The venue remains authoritative; transaction-ID reconciliation repairs +//! missed local observations. + +use std::collections::BTreeMap; +use std::collections::HashMap; + +use kraken_core::OrderSide; +use kraken_paper::account::{Origin, PaperAccount}; +use kraken_paper::{AccountEvent, PaperOrder, PaperOrderType, VenueSnapshot, parse_pair}; +use rust_decimal::Decimal; + +use crate::cli::AppContext; +use crate::config; +use crate::errors::Result; + +/// The real account's journal: `/journal.jsonl` — the same +/// file name a workspace journal carries, at the unscoped root. +pub(crate) fn master_journal_path() -> Result { + Ok(config::config_dir()?.join(kraken_workspace::JOURNAL_FILE)) +} + +/// Open the mirror, anchoring a fresh journal with the venue's balance +/// snapshot. Opens (and locks) BEFORE the venue call by design: a busy +/// journal refuses before any money moves, and the writer lock serializes +/// concurrent trades. The anchor fetch is best-effort — an offline snapshot +/// is disclosed as incomplete, never guessed and never blocking. +pub(crate) async fn open_attached(ctx: &AppContext) -> Result { + open_attached_at(master_journal_path()?, ctx).await +} + +/// [`open_attached`] with a caller-chosen journal — the seam unit tests +/// drive, since the real path is fixed under the user's config dir. +pub(crate) async fn open_attached_at( + journal: std::path::PathBuf, + ctx: &AppContext, +) -> Result { + let mut account = PaperAccount::open_at(journal, Origin::from_mcp_mode(ctx.mcp_mode))?; + if !account.is_initialized() { + let snapshot = fetch_snapshot(ctx).await; + let records = account.stamp(vec![AccountEvent::Attached(snapshot)]); + account.append_if_uninitialized(&records)?; + } + Ok(account) +} + +async fn fetch_snapshot(ctx: &AppContext) -> VenueSnapshot { + match venue_balances(ctx).await { + Ok(balances) => VenueSnapshot { + balances, + complete: true, + anchor: None, + }, + Err(err) => { + tracing::warn!(%err, "could not fetch the venue balance snapshot; anchoring incomplete"); + VenueSnapshot { + balances: BTreeMap::new(), + complete: false, + anchor: None, + } + } + } +} + +async fn venue_balances(ctx: &AppContext) -> Result> { + let (client, creds, otp) = ctx.spot_authed()?; + let data = client + .private_post("Balance", HashMap::new(), creds, otp, true) + .await?; + let mut balances = BTreeMap::new(); + if let Some(map) = data.as_object() { + for (asset, amount) in map { + if let Some(amount) = amount.as_str().and_then(|a| a.parse::().ok()) { + balances.insert(asset.clone(), amount); + } + } + } + Ok(balances) +} + +/// Record a venue-accepted order. Best-effort by design: the venue holds +/// the truth, and a local disk fault must never unreport a real order — +/// reconciliation heals the gap by txid. +pub(crate) fn mirror_submitted( + account: &mut PaperAccount, + txid: &str, + side: OrderSide, + pair: &str, + order_type: PaperOrderType, + volume: Decimal, + price: Option, +) { + let order = match observed_order(txid, side, pair, order_type, volume, price) { + Ok(order) => order, + Err(err) => { + tracing::warn!(txid, %err, "venue order accepted but not mirrorable"); + return; + } + }; + let records = account.stamp(vec![AccountEvent::OrderSubmitted { order }]); + account.audit(&records); +} + +/// Record a venue-accepted cancel for an order the mirror knows. An unknown +/// txid (an order predating the mirror) is skipped — reconciliation owns it. +pub(crate) fn mirror_cancelled(account: &mut PaperAccount, txid: &str) { + let Some(order) = account + .state() + .ok() + .and_then(|state| state.open_orders.iter().find(|o| o.id == txid).cloned()) + else { + tracing::warn!( + txid, + "cancel accepted for an order the mirror never saw; left to reconciliation" + ); + return; + }; + let records = account.stamp(vec![AccountEvent::OrderCancelled { order }]); + account.audit(&records); +} + +/// A venue order in the journal's order shape, honestly partial: the venue +/// owns reservations (amount zero) and a market order's price is unknown +/// until reconciliation observes the fill. +fn observed_order( + txid: &str, + side: OrderSide, + pair: &str, + order_type: PaperOrderType, + volume: Decimal, + price: Option, +) -> Result { + let (api_pair, base, quote) = parse_pair(pair)?; + Ok(PaperOrder { + id: txid.to_string(), + pair: api_pair, + base, + quote: quote.clone(), + side, + volume, + price: price.unwrap_or(Decimal::ZERO), + order_type, + reserved_asset: quote, + reserved_amount: Decimal::ZERO, + created_at: chrono::Utc::now(), + }) +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use rust_decimal_macros::dec; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use super::*; + use crate::config::{CredentialSource, Credentials}; + + fn ctx(api_url: Option, with_creds: bool) -> AppContext { + AppContext { + format: crate::output::OutputFormat::Json, + api_url, + futures_url: None, + ws_public_url: None, + ws_auth_url: None, + ws_l3_url: None, + ws_futures_url: None, + ws_reconnect_base_ms: None, + spot_credentials: with_creds.then(|| Credentials { + api_key: secrecy::SecretString::from("test-api-key"), + api_secret: secrecy::SecretString::from( + base64::engine::general_purpose::STANDARD + .encode(b"test_secret_key_bytes_32_chars!!"), + ), + source: CredentialSource::Flag, + }), + futures_credentials: None, + api_secret: None, + otp: None, + workspace: None, + force: true, + accept_invalid_certs: false, + allow_any_url_host: true, + mcp_mode: false, + monitor: None, + spot_client: std::sync::OnceLock::new(), + futures_client: std::sync::OnceLock::new(), + } + } + + async fn balance_venue() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/0/private/Balance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "error": [], + "result": { "ZUSD": "1000.00", "XXBT": "0.5" } + }))) + .mount(&server) + .await; + server + } + + fn journal_events(journal: &std::path::Path) -> Vec { + std::fs::read_to_string(journal) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } + + #[tokio::test] + async fn attach_anchors_a_fresh_journal_with_the_venue_snapshot() { + let server = balance_venue().await; + let dir = tempfile::tempdir().unwrap(); + let journal = dir.path().join("journal.jsonl"); + let ctx = ctx(Some(server.uri()), true); + drop(open_attached_at(journal.clone(), &ctx).await.unwrap()); + let events = journal_events(&journal); + assert_eq!(events[0]["event"], "attached"); + assert_eq!(events[0]["complete"], true); + assert_eq!(events[0]["balances"]["ZUSD"], "1000.00"); + } + + #[tokio::test] + async fn attach_without_a_reachable_venue_discloses_an_incomplete_anchor() { + let dir = tempfile::tempdir().unwrap(); + let journal = dir.path().join("journal.jsonl"); + // No credentials: the anchor fetch fails, the attach must not. + let ctx = ctx(None, false); + drop(open_attached_at(journal.clone(), &ctx).await.unwrap()); + let events = journal_events(&journal); + assert_eq!(events[0]["event"], "attached"); + assert_eq!(events[0]["complete"], false); + } + + #[tokio::test] + async fn reopening_the_mirror_never_reanchors() { + let server = balance_venue().await; + let dir = tempfile::tempdir().unwrap(); + let journal = dir.path().join("journal.jsonl"); + let ctx = ctx(Some(server.uri()), true); + drop(open_attached_at(journal.clone(), &ctx).await.unwrap()); + drop(open_attached_at(journal.clone(), &ctx).await.unwrap()); + let attaches = journal_events(&journal) + .iter() + .filter(|e| e["event"] == "attached") + .count(); + assert_eq!(attaches, 1, "one anchor per journal lifetime"); + } + + #[tokio::test] + async fn mirror_cancelled_closes_a_known_order_and_skips_an_unknown_txid() { + let dir = tempfile::tempdir().unwrap(); + let journal = dir.path().join("journal.jsonl"); + let ctx = ctx(None, false); + let mut account = open_attached_at(journal.clone(), &ctx).await.unwrap(); + mirror_submitted( + &mut account, + "OTXID-KNOWN-000001", + OrderSide::Buy, + "BTC/USD", + PaperOrderType::Limit, + dec!(0.01), + Some(dec!(100)), + ); + mirror_cancelled(&mut account, "OTXID-NEVER-SEEN00"); + mirror_cancelled(&mut account, "OTXID-KNOWN-000001"); + let events = journal_events(&journal); + let cancelled: Vec<_> = events + .iter() + .filter(|e| e["event"] == "order_cancelled") + .collect(); + assert_eq!(cancelled.len(), 1, "unknown txid left to reconciliation"); + assert_eq!(cancelled[0]["order"]["id"], "OTXID-KNOWN-000001"); + } +} diff --git a/src/commands/lab.rs b/src/commands/lab.rs new file mode 100644 index 0000000..521879d --- /dev/null +++ b/src/commands/lab.rs @@ -0,0 +1,955 @@ +//! CLI and MCP adapters for [`kraken_lab::Lab`]. + +use clap::Subcommand; +use enum_dispatch::enum_dispatch; +use kraken_lab::{ + Check, Comparison, Criteria, CurveSummary, Experiment, Judged, Lab, Metrics, NextAction, + OutcomeResult, PlannedSession, ScoreSource, Scorecard, ScoredSession, SessionResult, + TradeStats, +}; +use rust_decimal::Decimal; + +use super::Execute; +use crate::cli::AppContext; +use crate::errors::Result; +use crate::output::{CommandOutput, rounded}; + +#[enum_dispatch(Execute)] +#[derive(Debug, Subcommand)] +pub(crate) enum LabCommand { + /// Score a stopped session: the explain-pnl waterfall plus mechanical + /// metrics (return, max drawdown, trade counts) over the marked curve. + Score(Score), + /// Pre-register an experiment: freeze a hypothesis and its success + /// criteria (hash-sealed) before any session. + New(New), + /// Show a frozen experiment, verifying its seal. + Show(Show), + /// Compare every session of a frozen experiment side by side: each session + /// re-scored from disk and judged against the sealed criteria. + Compare(Compare), + /// What the sealed session plan says to do now: the exact start command for + /// the next run, the session still in progress, or "plan complete". + Next(Next), +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Score { + /// Which session: `latest`, an ordinal (`s3`), or a label. + #[arg(long, default_value = "latest")] + session: String, + /// Judge the scorecard against a frozen experiment's criteria; the + /// output gains a mechanical pass/fail verdict. + #[arg(long)] + experiment: Option, +} + +// A loss-tolerant return floor (e.g. `--min-return-pct -5`) is a valid +// criterion, so a leading-minus number must bind as the option's value rather +// than trip clap into reading it as an unknown flag. +#[derive(Debug, clap::Args)] +#[command(allow_negative_numbers = true)] +pub(crate) struct New { + /// Experiment name (session charset; becomes session names). + name: String, + /// The falsifiable claim being tested. + #[arg(long)] + hypothesis: String, + /// The strategy under test, if one is named. + #[arg(long)] + strategy: Option, + /// Success: return of at least this percent. + #[arg(long)] + min_return_pct: Option, + /// Success: worst drop relative to its peak (max percentage + /// drawdown) of at most this percent. + // The two maxima can anchor on different peaks, so the pct is its + // own statement, never a ratio of the absolute drop shown left. + #[arg(long)] + max_drawdown_pct: Option, + /// Success: at least this many fills (guards a pass-by-not-trading). + #[arg(long)] + min_fills: Option, + /// Pre-register a session (repeatable, ordered): `live:` or + /// `replay:`. Replay tapes must exist and be finalized — their + /// content hash is sealed with the spec. + #[arg(long = "session")] + sessions: Vec, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Show { + /// Experiment name. + name: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Compare { + /// Experiment name. + name: String, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Next { + /// Experiment name. + name: String, +} + +/// The scope's lab with `tape:` refs pointed at the global shared library +/// (market memory is never workspace-scoped). +fn lab_in(ctx: &AppContext) -> Result { + Ok(Lab::new(ctx.scoped_base()?).with_library(crate::config::config_dir()?)) +} + +impl Execute for Score { + async fn execute(self, ctx: &AppContext) -> Result { + let spec = self + .session + .parse() + .unwrap_or(kraken_workspace::session::SessionRefSpec::Latest); + super::session::ensure_session_stopped(ctx, &self.session)?; + let (scope, _) = super::session::scope_dir(ctx)?; + let (session_id, _) = kraken_workspace::session::resolve(&scope, &spec)?; + let scored = Lab::new(&scope) + .with_library(crate::config::config_dir()?) + .score(session_id.ordinal(), self.experiment.as_deref())?; + let mut out = output(&scored)?; + out.stamp_session(&session_id.to_string()); + if let Some(workspace) = ctx.workspace.as_deref() { + out.stamp_workspace(workspace); + } + Ok(out) + } +} + +impl Execute for New { + async fn execute(self, ctx: &AppContext) -> Result { + let experiment = lab_in(ctx)?.freeze( + &self.name, + &self.hypothesis, + self.strategy, + Criteria { + min_return_pct: self.min_return_pct, + max_drawdown_pct: self.max_drawdown_pct, + min_fills: self.min_fills, + }, + &self.sessions, + )?; + experiment_output(&experiment, "frozen") + } +} + +impl Execute for Show { + async fn execute(self, ctx: &AppContext) -> Result { + let experiment = lab_in(ctx)?.show(&self.name)?; + experiment_output(&experiment, "verified") + } +} + +impl Execute for Compare { + async fn execute(self, ctx: &AppContext) -> Result { + let comparison = lab_in(ctx)?.compare(&self.name)?; + comparison_output(&comparison) + } +} + +impl Execute for Next { + async fn execute(self, ctx: &AppContext) -> Result { + let action = lab_in(ctx)?.next(&self.name)?; + next_output(&self.name, &action) + } +} + +fn next_output(experiment: &str, action: &NextAction) -> Result { + let data = serde_json::json!({ + "group": "lab", + "type": "next", + "experiment": experiment, + "state": action.state, + "session": action.session, + "source": action.source, + "command": action.command, + "reason": action.reason, + }); + let mut pairs = vec![ + ("Experiment".to_string(), experiment.to_string()), + ("State".to_string(), action.state.as_str().to_string()), + ("Reason".to_string(), action.reason.clone()), + ]; + if let Some(run) = &action.session { + pairs.push(("Session".to_string(), run.clone())); + } + if let Some(command) = &action.command { + pairs.push(("Command".to_string(), command.clone())); + } + Ok(CommandOutput::key_value(pairs, data)) +} + +/// Sessions cover different tapes, so only the pre-registered verdict checks +/// have cross-session meaning. Dropped once tape identity is explicit. +const COMPARISON_CAVEAT: &str = "sessions cover different market windows; verdicts judge each session \ + against the frozen criteria — metric rows are context, never a cross-session comparison"; + +/// The table line failed columns land on; `comparison_rows` keeps it trailing. +const ERROR_LINE: &str = "Error"; + +fn comparison_output(comparison: &Comparison) -> Result { + let pass_count = comparison.pass_count(); + let total = comparison.total(); + let sessions = comparison + .outcomes + .iter() + .map(session_entry) + .collect::>>()?; + let data = serde_json::json!({ + "group": "lab", + "type": "comparison", + "experiment": comparison.experiment, + "frozen": comparison.frozen, + "sessions": sessions, + "pass_count": pass_count, + "total": total, + "caveats": [COMPARISON_CAVEAT], + }); + + if comparison.outcomes.is_empty() { + return Ok(CommandOutput::key_value( + vec![ + ("Experiment".to_string(), comparison.experiment.clone()), + ("Sessions".to_string(), "none".to_string()), + ("Pass count".to_string(), "0 of 0".to_string()), + ], + data, + )); + } + let headers = std::iter::once("Line".to_string()) + .chain( + comparison + .outcomes + .iter() + .map(|outcome| outcome.session.session.clone()), + ) + .collect(); + // Comparison-wide notes ride below the table: in a per-session column they'd + // read as the leftmost session's own value. + let footer = vec![ + format!("Pass count: {pass_count} of {total}"), + format!("Caveat: {COMPARISON_CAVEAT}"), + ]; + Ok( + CommandOutput::new(data, headers, comparison_rows(&comparison.outcomes)) + .with_footer(footer), + ) +} + +fn session_entry(outcome: &SessionResult) -> Result { + let mut entry = serde_json::Map::new(); + entry.insert("session".into(), serde_json::json!(outcome.session.session)); + entry.insert("n".into(), serde_json::json!(outcome.session.ordinal)); + match &outcome.result { + OutcomeResult::Scored { card, verdict } => { + entry.insert("verdict".into(), serde_json::to_value(verdict)?); + // The anchor and curve ride along so an agent never needs N + // separate `lab score` calls to see completeness per session. + entry.insert("anchor".into(), serde_json::to_value(&card.anchor)?); + entry.insert("metrics".into(), serde_json::to_value(&card.metrics)?); + entry.insert("trades".into(), serde_json::to_value(&card.trades)?); + entry.insert("curve".into(), serde_json::to_value(&card.curve)?); + // First-class provenance: the promotion gate reads source.kind + // here instead of string-matching the caveats for "replayed from". + entry.insert("source".into(), serde_json::to_value(&card.source)?); + entry.insert("caveats".into(), serde_json::to_value(&card.caveats)?); + } + OutcomeResult::Failed { error } => { + entry.insert("error".into(), serde_json::json!(error)); + entry.insert("caveats".into(), serde_json::json!([])); + } + } + Ok(serde_json::Value::Object(entry)) +} + +/// One row per line, one value column per session; verdict lines lead, context +/// lines follow in first-seen order, failures land on a trailing Error line. +fn comparison_rows(outcomes: &[SessionResult]) -> Vec> { + let per_column: Vec> = outcomes + .iter() + .map(|outcome| column_pairs(&outcome.result)) + .collect(); + + let mut lines: Vec<&str> = Vec::new(); + for (line, _) in per_column.iter().flatten() { + if line != ERROR_LINE && !lines.contains(&line.as_str()) { + lines.push(line); + } + } + if per_column + .iter() + .flatten() + .any(|(line, _)| line == ERROR_LINE) + { + lines.push(ERROR_LINE); + } + + lines + .iter() + .map(|line| { + std::iter::once((*line).to_string()) + .chain(per_column.iter().map(|pairs| { + pairs + .iter() + .find(|(l, _)| l == line) + .map_or_else(|| "—".to_string(), |(_, value)| value.clone()) + })) + .collect() + }) + .collect() +} + +/// A session's (line, value) cells, reusing the scorecard row builders so cells +/// render exactly as `lab score` — new metric rows surface here automatically. +fn column_pairs(result: &OutcomeResult) -> Vec<(String, String)> { + match result { + OutcomeResult::Scored { card, verdict } => { + let mut pairs = vec![( + "Verdict".to_string(), + if verdict.pass { "PASS" } else { "FAIL" }.to_string(), + )]; + // The frozen threshold is identical in every column, so it lives + // in the shared line label. + pairs.extend(verdict.checks.iter().map(|check| { + ( + format!("{} (threshold {})", check.criterion, check.threshold), + check_cell(check), + ) + })); + let context = [source_row(card), total_row(card)] + .into_iter() + .chain(metric_rows(&card.metrics)) + .chain([trades_row(&card.trades), curve_row(&card.curve)]); + pairs.extend(context.map(|row| (row[0].clone(), row[1].clone()))); + // One joined line: comparison_rows keys cells by line name. + if !card.caveats.is_empty() { + pairs.push(("Session caveats".to_string(), card.caveats.join("; "))); + } + pairs + } + OutcomeResult::Failed { error } => vec![ + ("Verdict".to_string(), "error".to_string()), + (ERROR_LINE.to_string(), error.clone()), + ], + } +} + +fn check_cell(check: &Check) -> String { + format!( + "{} ({})", + if check.pass { "pass" } else { "fail" }, + observed(check) + ) +} + +fn observed(check: &Check) -> String { + check + .observed + .map_or_else(|| "unknown".to_string(), |v| v.to_string()) +} + +/// Field/Value rows for a sealed experiment; `state` says what just +/// happened to the seal ("frozen" at creation, "verified" on show). +fn experiment_output(experiment: &Experiment, state: &str) -> Result { + let mut pairs = vec![ + ("Experiment".to_string(), experiment.name.clone()), + ("Hypothesis".to_string(), experiment.hypothesis.clone()), + ]; + if let Some(strategy) = &experiment.strategy { + pairs.push(("Strategy".to_string(), strategy.clone())); + } + if let Some(pct) = experiment.criteria.min_return_pct { + pairs.push(("Min return".to_string(), format!("{pct}%"))); + } + if let Some(pct) = experiment.criteria.max_drawdown_pct { + pairs.push(("Max drawdown".to_string(), format!("{pct}%"))); + } + if let Some(fills) = experiment.criteria.min_fills { + pairs.push(("Min fills".to_string(), fills.to_string())); + } + if let Some(plan) = &experiment.session_plan { + for (position, leg) in plan.0.iter().enumerate() { + let value = match leg { + PlannedSession::Live { window } => format!("live {window}"), + PlannedSession::Replay { tape, content_hash } => { + format!("replay {tape} ({content_hash})") + } + }; + pairs.push((format!("Plan {}", position + 1), value)); + } + } + pairs.push(( + "Created".to_string(), + kraken_recording::format_instant(experiment.created_at), + )); + pairs.push(( + "Seal".to_string(), + format!("{} ({state})", experiment.frozen), + )); + Ok(CommandOutput::key_value( + pairs, + serde_json::to_value(experiment)?, + )) +} + +fn output(scored: &ScoredSession) -> Result { + let card = &scored.card; + let headers = ["Line", "Value", "Explanation"].map(String::from).to_vec(); + let mut rows = vec![source_row(card), total_row(card)]; + rows.extend(metric_rows(&card.metrics)); + rows.push(trades_row(&card.trades)); + rows.push(curve_row(&card.curve)); + if let Some(judged) = &scored.judged { + rows.extend(verdict_rows(judged)); + } + rows.extend(card.caveats.iter().map(|caveat| caveat_row(caveat))); + + // The Scorecard stays the payload; a judged run adds the experiment name + // and verdict as additive top-level fields. + let mut data = serde_json::to_value(card)?; + if let (Some(obj), Some(judged)) = (data.as_object_mut(), &scored.judged) { + obj.insert("experiment".into(), serde_json::json!(judged.experiment)); + obj.insert("verdict".into(), serde_json::to_value(&judged.verdict)?); + } + Ok(CommandOutput::new(data, headers, rows)) +} + +fn verdict_rows(judged: &Judged) -> Vec> { + let mut rows = vec![vec![ + "Verdict".to_string(), + if judged.verdict.pass { "PASS" } else { "FAIL" }.to_string(), + format!( + "against experiment '{}' ({})", + judged.experiment, judged.frozen + ), + ]]; + rows.extend(judged.verdict.checks.iter().map(|check| { + vec![ + "Check".to_string(), + if check.pass { "pass" } else { "fail" }.to_string(), + format!( + "{}: threshold {}, observed {}", + check.criterion, + check.threshold, + observed(check) + ), + ] + })); + rows +} + +/// The session's market-data provenance, read from the scorecard (never asserted). +fn source_row(card: &Scorecard) -> Vec { + let (value, explanation) = match &card.source { + ScoreSource::Live { + symbols, + window_secs, + } => ( + // The actual exposure rides in the value cell so `lab compare` + // (which keeps only values) shows a cut-short live leg too. + window_secs.map_or_else( + || "live".to_string(), + |secs| { + format!( + "live · {}", + humantime::format_duration(std::time::Duration::from_secs(secs)) + ) + }, + ), + if symbols.is_empty() { + "live market".to_string() + } else { + format!("live market: {}", symbols.join(", ")) + }, + ), + ScoreSource::Replay { tape, speed } => { + (format!("replay @{speed}x"), format!("recorded {tape}")) + } + }; + vec!["Source".to_string(), value, explanation] +} + +fn total_row(card: &Scorecard) -> Vec { + vec![ + "Total".to_string(), + signed(card.anchor.total_pnl), + format!( + "ended at {:.2} {} from a {:.2} start (as reported at session stop)", + rounded(card.anchor.final_value, 2), + card.anchor.currency, + rounded(card.anchor.starting_balance, 2) + ), + ] +} + +/// The Return cell at the row's 0.01% resolution. A real gain or loss too +/// small to show keeps its sign and is flagged sub-resolution (`-<0.01%` / +/// `+<0.01%`) rather than collapsing to a signed zero; `+0.00%` is reserved +/// for an exactly-zero return. The money rows use `signed`, whose deliberate +/// clamp to `+0.00` is the opposite convention. +fn return_cell(pct: Decimal) -> String { + let shown = rounded(pct, 2); + if shown.is_zero() && !pct.is_zero() { + let sign = if pct.is_sign_negative() { '-' } else { '+' }; + return format!("{sign}<0.01%"); + } + format!("{shown:+.2}%") +} + +fn metric_rows(metrics: &Metrics) -> Vec> { + let mut rows = Vec::new(); + rows.push(vec![ + "Return".to_string(), + metrics + .return_pct + .map_or_else(|| "?".to_string(), return_cell), + "total P&L relative to the epoch's starting balance".to_string(), + ]); + rows.push(vec![ + "Max drawdown".to_string(), + metrics + .max_drawdown + .map_or_else(|| "—".to_string(), |dd| format!("-{:.2}", rounded(dd, 2))), + // The two maxima can anchor on different peaks, so the pct is its + // own statement, never a ratio of the absolute drop shown left. + metrics.max_drawdown_pct.map_or_else( + || "largest peak-to-trough drop on the marked curve".to_string(), + |pct| { + format!( + "largest peak-to-trough drop on the marked curve; worst drop vs its peak: \ + {:.2}%", + rounded(pct, 2) + ) + }, + ), + ]); + if let Some(fees) = metrics.fees { + rows.push(vec![ + "Fees".to_string(), + signed(fees), + "the waterfall's fees line, carried verbatim".to_string(), + ]); + } + if let Some(friction) = metrics.friction { + rows.push(vec![ + "Friction".to_string(), + signed(friction), + "spread + slippage from the waterfall".to_string(), + ]); + } + if let Some(turnover) = metrics.turnover_pct { + rows.push(vec![ + "Turnover".to_string(), + format!("{:.2}%", rounded(turnover, 2)), + "total converted fill notional over the starting balance".to_string(), + ]); + } + if let Some(hit_rate) = metrics.hit_rate { + rows.push(vec![ + "Hit rate".to_string(), + format!("{:.0}%", rounded(hit_rate * Decimal::ONE_HUNDRED, 0)), + "closed FIFO round trips won net of pro-rata fees; open lots are never graded" + .to_string(), + ]); + } + if let Some(profit_factor) = metrics.profit_factor { + rows.push(vec![ + "Profit factor".to_string(), + format!("{:.2}", rounded(profit_factor, 2)), + "gross profit over gross loss across closed round trips".to_string(), + ]); + } + if let Some(sensitivity) = metrics.slippage_sensitivity { + rows.push(vec![ + "Slippage sensitivity".to_string(), + format!("{} per bp", signed(sensitivity)), + "P&L change per basis point of extra slippage (analytic, assumes linearity)" + .to_string(), + ]); + } + rows +} + +fn trades_row(trades: &TradeStats) -> Vec { + vec![ + "Trades".to_string(), + trades.fills.to_string(), + format!( + "{} buy(s), {} sell(s); {} with a logged reason, {} counterfactual missed fill(s)", + trades.buys, trades.sells, trades.decided, trades.missed_fills + ), + ] +} + +fn curve_row(curve: &CurveSummary) -> Vec { + let span = curve + .first_value + .zip(curve.last_value) + .map(|(first, last)| { + format!( + "{:.2} → {:.2} {}", + rounded(first, 2), + rounded(last, 2), + curve.currency + ) + }) + .unwrap_or_else(|| "no points".to_string()); + let completeness = if curve.complete { + String::new() + } else { + format!( + "; unmarked holdings: {}", + curve + .unmarked + .iter() + .map(String::as_str) + .collect::>() + .join(", ") + ) + }; + vec![ + "Curve".to_string(), + format!("{} point(s)", curve.points), + format!("{span}{completeness}"), + ] +} + +fn caveat_row(caveat: &str) -> Vec { + vec!["Caveat".to_string(), "—".to_string(), caveat.to_string()] +} + +/// Signed money for the Value column. A rounded zero clamps to +0.00 so a +/// break-even line never reads as a loss. +fn signed(amount: Decimal) -> String { + let cents = rounded(amount, 2); + let amount = if cents.is_zero() { + Decimal::ZERO + } else { + cents + }; + format!("{amount:+.2}") +} + +#[cfg(test)] +mod tests { + use kraken_lab::SessionRef; + use kraken_paper::OrderSide; + use kraken_paper::account::Origin; + use kraken_session::manifest::SessionOutcome; + use kraken_session::timeline::fixtures::{self, SessionBuilder}; + use rust_decimal_macros::dec; + + use super::*; + + // Regression: a signed return floor must survive clap parsing. Without + // `allow_negative_numbers`, `--min-return-pct -5` fails with "unexpected + // argument '-5'" and the user has to discover the `=` form. + #[test] + fn min_return_pct_accepts_a_negative_threshold() { + use clap::Parser; + #[derive(Parser)] + struct TestCli { + #[command(subcommand)] + cmd: LabCommand, + } + let cli = TestCli::try_parse_from([ + "kraken", + "new", + "exp", + "--hypothesis", + "h", + "--min-return-pct", + "-5", + ]) + .expect("negative --min-return-pct parses"); + let LabCommand::New(new) = cli.cmd else { + panic!("expected the `new` subcommand"); + }; + assert_eq!(new.min_return_pct, Some(dec!(-5))); + } + + fn scorecard() -> Scorecard { + let mut builder = SessionBuilder::with_rates(dec!(10_000), dec!(0.0026), dec!(0.0)); + builder.ticker( + "2026-01-01T00:00:01Z", + "BTC/USD", + dec!(50_000), + dec!(50_000), + ); + builder.market_fill( + "2026-01-01T00:00:02Z", + Origin::Cli, + OrderSide::Buy, + "BTCUSD", + dec!(0.1), + (dec!(50_000), dec!(50_000)), + ); + let mut manifest = fixtures::manifest(vec![]); + manifest.summary = Some(SessionOutcome { + ended_at: "2026-01-01T00:00:04+00:00".parse().unwrap(), + final_value: dec!(10_187), + pnl: dec!(187), + }); + kraken_lab::score(&builder.session(None, manifest)).unwrap() + } + + fn experiment(min_return_pct: Decimal) -> Experiment { + Experiment { + name: "m1".into(), + hypothesis: "h".into(), + strategy: None, + criteria: Criteria { + min_return_pct: Some(min_return_pct), + max_drawdown_pct: None, + min_fills: None, + }, + created_at: "2026-01-01T00:00:00Z".parse().unwrap(), + session_plan: None, + frozen: "sha256:test".into(), + } + } + + fn run(n: u32) -> SessionRef { + SessionRef { + ordinal: n, + session: format!("s{n}"), + } + } + + fn scored_outcome(experiment: &Experiment, n: u32) -> SessionResult { + let card = scorecard(); + let verdict = experiment.criteria.evaluate(&card); + SessionResult { + session: run(n), + result: OutcomeResult::Scored { + card: Box::new(card), + verdict, + }, + } + } + + fn failed_outcome(n: u32, error: &str) -> SessionResult { + SessionResult { + session: run(n), + result: OutcomeResult::Failed { + error: error.to_string(), + }, + } + } + + fn comparison(experiment: &Experiment, outcomes: Vec) -> Comparison { + Comparison { + experiment: experiment.name.clone(), + frozen: experiment.frozen.clone(), + outcomes, + } + } + + #[test] + fn json_payload_is_the_scorecard_verbatim() { + let scored = ScoredSession { + card: scorecard(), + judged: None, + }; + let out = output(&scored).unwrap(); + assert_eq!( + out.data, + serde_json::to_value(&scored.card).unwrap(), + "the -o json payload is the crate's serialized Scorecard, untouched" + ); + } + + #[test] + fn table_leads_with_source_then_the_anchor_total() { + let scored = ScoredSession { + card: scorecard(), + judged: None, + }; + let out = output(&scored).unwrap(); + let rows = &out.rows; + // Provenance first (the fixture has no source block ⇒ live), then the + // anchor total, then the metric/context rows. + assert_eq!(rows[0][0], "Source"); + assert_eq!(rows[0][1], "live"); + assert_eq!(rows[1][0], "Total"); + assert_eq!(rows[1][1], "+187.00"); + let lines: Vec<&str> = rows.iter().map(|row| row[0].as_str()).collect(); + for expected in ["Return", "Max drawdown", "Fees", "Trades", "Curve"] { + assert!(lines.contains(&expected), "{expected} missing in {lines:?}"); + } + } + + #[test] + fn return_row_renders_the_percentage() { + let scored = ScoredSession { + card: scorecard(), + judged: None, + }; + let out = output(&scored).unwrap(); + let ret = out + .rows + .iter() + .find(|row| row[0] == "Return") + .expect("return row") + .clone(); + assert_eq!(ret[1], "+1.87%"); + } + + /// Below the row's 0.01% resolution a real gain or loss keeps its sign and + /// is flagged sub-resolution, so it never masquerades as break-even. + #[test] + fn return_cell_flags_direction_below_resolution() { + assert_eq!(return_cell(dec!(-0.0046)), "-<0.01%"); + assert_eq!(return_cell(dec!(0.0046)), "+<0.01%"); + } + + /// `+0.00%` reads only for an exactly-zero return, never a rounded loss. + #[test] + fn return_cell_break_even_is_exact_zero_only() { + assert_eq!(return_cell(dec!(0)), "+0.00%"); + } + + #[test] + fn comparison_json_pins_the_top_level_keys() { + let experiment = experiment(dec!(1.0)); + let comparison = comparison( + &experiment, + vec![ + scored_outcome(&experiment, 1), + failed_outcome(2, "validation: no final summary"), + ], + ); + let data = comparison_output(&comparison).unwrap().data; + assert_eq!(data["group"], "lab"); + assert_eq!(data["type"], "comparison"); + assert_eq!(data["experiment"], "m1"); + assert_eq!(data["frozen"], "sha256:test"); + assert_eq!(data["pass_count"], 1); + assert_eq!(data["total"], 2); + assert_eq!(data["sessions"][0]["session"], "s1"); + assert_eq!(data["sessions"][0]["n"], 1); + assert_eq!(data["sessions"][1]["error"], "validation: no final summary"); + assert!( + data["sessions"][1].get("verdict").is_none(), + "a failed column carries no verdict" + ); + assert_eq!(data["caveats"][0], COMPARISON_CAVEAT); + } + + /// One card on both sides: pins pass-through, not fixture determinism. + #[test] + fn comparison_cells_serialize_byte_equal_to_the_source_scorecard() { + let experiment = experiment(dec!(1.0)); + let card = scorecard(); + let verdict = experiment.criteria.evaluate(&card); + let expected = [ + ("metrics", serde_json::to_value(&card.metrics).unwrap()), + ("trades", serde_json::to_value(&card.trades).unwrap()), + ("verdict", serde_json::to_value(&verdict).unwrap()), + ("caveats", serde_json::to_value(&card.caveats).unwrap()), + ]; + let entry = session_entry(&SessionResult { + session: run(1), + result: OutcomeResult::Scored { + card: Box::new(card), + verdict, + }, + }) + .unwrap(); + for (key, expected) in expected { + assert_eq!( + serde_json::to_vec(&entry[key]).unwrap(), + serde_json::to_vec(&expected).unwrap(), + "{key} must cross into the comparison verbatim" + ); + } + } + + #[test] + fn pass_count_counts_only_scored_passing_columns() { + let passing = experiment(dec!(1.0)); + let failing = experiment(dec!(100.0)); + let comparison = comparison( + &passing, + vec![ + scored_outcome(&passing, 1), + scored_outcome(&failing, 2), + failed_outcome(3, "validation: unstopped"), + ], + ); + let data = comparison_output(&comparison).unwrap().data; + assert_eq!(data["pass_count"], 1); + assert_eq!(data["total"], 3); + } + + #[test] + fn pass_count_reaches_total_when_every_scored_column_passes() { + let passing = experiment(dec!(1.0)); + let comparison = comparison( + &passing, + vec![scored_outcome(&passing, 1), scored_outcome(&passing, 2)], + ); + let data = comparison_output(&comparison).unwrap().data; + assert_eq!(data["pass_count"], 2); + assert_eq!(data["total"], 2); + } + + #[test] + fn comparison_of_no_sessions_is_empty_not_an_error() { + let experiment = experiment(dec!(1.0)); + let out = comparison_output(&comparison(&experiment, vec![])).unwrap(); + assert_eq!(out.data["sessions"], serde_json::json!([])); + assert_eq!(out.data["pass_count"], 0); + assert_eq!(out.data["total"], 0); + } + + #[test] + fn comparison_table_leads_with_verdicts_and_names_failures() { + let experiment = experiment(dec!(1.0)); + let comparison = comparison( + &experiment, + vec![ + scored_outcome(&experiment, 1), + failed_outcome(2, "validation: no final summary"), + ], + ); + let out = comparison_output(&comparison).unwrap(); + assert_eq!(out.headers, ["Line", "s1", "s2"]); + assert_eq!(out.rows[0][0], "Verdict"); + assert_eq!(out.rows[0][1], "PASS"); + assert_eq!(out.rows[0][2], "error"); + // Raw Decimal display, matching `lab score`'s check rows exactly. + assert_eq!( + out.rows[1], + ["min_return_pct (threshold 1.0)", "pass (1.8700)", "—"] + ); + let error_row = out + .rows + .iter() + .find(|row| row[0] == "Error") + .expect("error row"); + assert_eq!(error_row[1], "—"); + assert_eq!(error_row[2], "validation: no final summary"); + // Comparison-wide notes leave the table body and land in the footer, + // so they never sit under a session's header and read as its value. + assert!( + out.rows + .iter() + .all(|row| row[0] != "Pass count" && row[0] != "Caveat"), + "comparison-wide notes belong in the footer, not a per-session row" + ); + assert_eq!( + out.footer, + [ + "Pass count: 1 of 2".to_string(), + format!("Caveat: {COMPARISON_CAVEAT}"), + ] + ); + } +} diff --git a/src/commands/market.rs b/src/commands/market.rs index 96cd245..684fa13 100644 --- a/src/commands/market.rs +++ b/src/commands/market.rs @@ -1,279 +1,367 @@ /// Public market data commands — no auth required. use clap::Subcommand; +use enum_dispatch::enum_dispatch; use serde_json::Value; -use super::helpers::jstr; -use crate::client::SpotClient; -use crate::errors::{KrakenError, Result}; +use super::Execute; +use super::schema; +use super::value_types::{AssetClass, CliWire, GroupedDepth, Grouping}; +use crate::cli::AppContext; +use crate::errors::Result; use crate::output::CommandOutput; +use crate::output::json_field; +#[enum_dispatch(Execute)] #[derive(Debug, Subcommand)] pub(crate) enum MarketCommand { /// Get system status and trading mode. - Status, + Status(Status), /// Get server time. - ServerTime, + ServerTime(ServerTime), /// Get asset info. - Assets { - /// Comma-separated list of assets to query. - #[arg(long)] - asset: Option, - /// Asset class filter. - #[arg(long, alias = "aclass")] - asset_class: Option, - }, + Assets(Assets), /// Get tradable asset pairs. - Pairs { - /// Comma-separated pairs to query. - #[arg(long)] - pair: Option, - /// Info level: info, leverage, fees, margin. - #[arg(long)] - info: Option, - /// Asset class filter. - #[arg(long, alias = "aclass")] - asset_class: Option, - }, + Pairs(Pairs), /// Get ticker information for one or more pairs. - Ticker { - /// One or more trading pairs. - pairs: Vec, - /// Asset class filter. - #[arg(long, value_parser = ["tokenized_asset", "forex"])] - asset_class: Option, - }, + Ticker(Ticker), /// Get OHLC candle data. - Ohlc { - /// Trading pair. - pair: String, - /// Candle interval in minutes (1, 5, 15, 30, 60, 240, 1440, 10080, 21600). - #[arg(long, default_value = "60")] - interval: u32, - /// Unix timestamp to fetch data since. - #[arg(long)] - since: Option, - /// Asset class filter. - #[arg(long, value_parser = ["tokenized_asset", "forex"])] - asset_class: Option, - }, + Ohlc(Ohlc), /// Get L2 order book. - Orderbook { - /// Trading pair. - pair: String, - /// Number of price levels (max 500). - #[arg(long, default_value = "25")] - count: u32, - /// Asset class filter. - #[arg(long, value_parser = ["tokenized_asset"])] - asset_class: Option, - }, - /// Get L3 order book (authenticated). - OrderbookL3 { - /// Trading pair. - pair: String, - /// Number of price levels per side (0 for full book). - #[arg(long, default_value = "100", value_parser = ["0", "10", "25", "100", "250", "1000"])] - depth: String, - }, + Orderbook(Orderbook), /// Get grouped order book. - OrderbookGrouped { - /// Trading pair. - pair: String, - /// Number of price levels per side. - #[arg(long, default_value = "10", value_parser = ["10", "25", "100", "250", "1000"])] - depth: String, - /// Tick levels within each price level (bids rounded down, asks up). - #[arg(long, default_value = "1", value_parser = ["1", "5", "10", "25", "50", "100", "250", "500", "1000"])] - grouping: String, - }, + OrderbookGrouped(OrderbookGrouped), /// Get recent trades. - Trades { - /// Trading pair. - pair: String, - /// Fetch trades since this timestamp. - #[arg(long)] - since: Option, - /// Maximum number of trades. - #[arg(long, default_value = "1000")] - count: u32, - /// Asset class filter. - #[arg(long, value_parser = ["tokenized_asset"])] - asset_class: Option, - }, + Trades(Trades), /// Get recent spreads. - Spreads { - /// Trading pair. - pair: String, - /// Fetch spreads since this timestamp. - #[arg(long)] - since: Option, - /// Asset class filter. - #[arg(long, value_parser = ["tokenized_asset"])] - asset_class: Option, - }, + Spreads(Spreads), } -pub(crate) async fn execute( - cmd: &MarketCommand, - client: &SpotClient, - verbose: bool, -) -> Result { - match cmd { - MarketCommand::Status => { - let data = client.public_get("SystemStatus", &[], verbose).await?; - let status = data - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let timestamp = data.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); - Ok(CommandOutput::key_value( - vec![ - ("Status".into(), status.to_string()), - ("Timestamp".into(), timestamp.to_string()), - ], - data, - )) - } - MarketCommand::ServerTime => { - let data = client.public_get("Time", &[], verbose).await?; - let unixtime = data.get("unixtime").and_then(|v| v.as_u64()).unwrap_or(0); - let rfc1123 = data.get("rfc1123").and_then(|v| v.as_str()).unwrap_or(""); - Ok(CommandOutput::key_value( - vec![ - ("Unix Time".into(), unixtime.to_string()), - ("RFC 1123".into(), rfc1123.to_string()), - ], - data, - )) +#[derive(Debug, clap::Args)] +pub(crate) struct Status {} + +#[derive(Debug, clap::Args)] +pub(crate) struct ServerTime {} + +#[derive(Debug, clap::Args)] +pub(crate) struct Assets { + /// Comma-separated list of assets to query. + #[arg(long)] + asset: Option, + /// Asset class filter. + #[arg(long, alias = "aclass")] + asset_class: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Pairs { + /// Comma-separated pairs to query. + #[arg(long)] + pair: Option, + /// Info level: info, leverage, fees, margin. + #[arg(long)] + info: Option, + /// Asset class filter. + #[arg(long, alias = "aclass")] + asset_class: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Ticker { + /// One or more trading pairs. + #[arg(num_args = 1..)] + pairs: Vec, + /// Asset class filter. + #[arg(long)] + asset_class: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Ohlc { + /// Trading pair. + pair: String, + /// Candle interval in minutes (1, 5, 15, 30, 60, 240, 1440, 10080, 21600). + #[arg(long, default_value = "60")] + interval: u32, + /// Unix timestamp to fetch data since. + #[arg(long)] + since: Option, + /// Asset class filter. + #[arg(long)] + asset_class: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Orderbook { + /// Trading pair. + pair: String, + /// Number of price levels (max 500). + #[arg(long, default_value = "25")] + count: u32, + /// Asset class filter. + #[arg(long)] + asset_class: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct OrderbookGrouped { + /// Trading pair. + pair: String, + /// Number of price levels per side. + #[arg(long, default_value = "10")] + depth: GroupedDepth, + /// Tick levels within each price level (bids rounded down, asks up). + #[arg(long, default_value = "1")] + grouping: Grouping, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Trades { + /// Trading pair. + pair: String, + /// Fetch trades since this timestamp. + #[arg(long)] + since: Option, + /// Maximum number of trades. + #[arg(long, default_value = "1000")] + count: u32, + /// Asset class filter. + #[arg(long)] + asset_class: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Spreads { + /// Trading pair. + pair: String, + /// Fetch spreads since this timestamp. + #[arg(long)] + since: Option, + /// Asset class filter. + #[arg(long)] + asset_class: Option, +} + +impl Execute for Status { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let data = client.public_get("SystemStatus", &[]).await?; + let status = data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let timestamp = data.get("timestamp").and_then(|v| v.as_str()).unwrap_or(""); + Ok(CommandOutput::key_value( + vec![ + ("Status".into(), status.to_string()), + ("Timestamp".into(), timestamp.to_string()), + ], + data, + )) + } +} + +impl Execute for ServerTime { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + // Already schema-shaped on the wire: pure passthrough. + let data = client.public_get("Time", &[]).await?; + let unixtime = data.get("unixtime").and_then(|v| v.as_u64()).unwrap_or(0); + let rfc1123 = data.get("rfc1123").and_then(|v| v.as_str()).unwrap_or(""); + Ok(CommandOutput::key_value( + vec![ + ("Unix Time".into(), unixtime.to_string()), + ("RFC 1123".into(), rfc1123.to_string()), + ], + data, + )) + } +} + +impl Execute for Assets { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { asset, asset_class } = self; + let mut params = Vec::new(); + if let Some(a) = &asset { + params.push(("asset", a.as_str())); } - MarketCommand::Assets { asset, asset_class } => { - let mut params = Vec::new(); - if let Some(a) = asset { - params.push(("asset", a.as_str())); - } - if let Some(ac) = asset_class { - params.push(("aclass", ac.as_str())); - } - let data = client.public_get("Assets", ¶ms, verbose).await?; - let (headers, rows) = parse_asset_table(&data); - Ok(CommandOutput::new(data, headers, rows)) + let ac_wire; + if let Some(ac) = asset_class { + ac_wire = ac.as_wire(); + params.push(("aclass", ac_wire.as_str())); } - MarketCommand::Pairs { + let mut data = client.public_get("Assets", ¶ms).await?; + schema::assets(&mut data); + let (headers, rows) = parse_asset_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +impl Execute for Pairs { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { pair, info, asset_class, - } => { - let mut params = Vec::new(); - if let Some(p) = pair { - params.push(("pair", p.as_str())); - } - if let Some(i) = info { - params.push(("info", i.as_str())); - } - if let Some(ac) = asset_class { - params.push(("aclass", ac.as_str())); - } - let data = client.public_get("AssetPairs", ¶ms, verbose).await?; - let (headers, rows) = parse_pairs_table(&data); - Ok(CommandOutput::new(data, headers, rows)) + } = self; + let mut params = Vec::new(); + if let Some(p) = &pair { + params.push(("pair", p.as_str())); } - MarketCommand::Ticker { pairs, asset_class } => { - let pair_str = pairs.join(","); - let mut params = vec![("pair", &*pair_str)]; - if let Some(ac) = asset_class { - params.push(("asset_class", ac.as_str())); - } - let data = client.public_get("Ticker", ¶ms, verbose).await?; - let (headers, rows) = parse_ticker_table(&data); - Ok(CommandOutput::new(data, headers, rows)) + if let Some(i) = &info { + params.push(("info", i.as_str())); + } + let ac_wire; + if let Some(ac) = asset_class { + ac_wire = ac.as_wire(); + params.push(("aclass", ac_wire.as_str())); + } + let mut data = client.public_get("AssetPairs", ¶ms).await?; + schema::asset_pairs(&mut data); + let (headers, rows) = parse_pairs_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +impl Execute for Ticker { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { pairs, asset_class } = self; + let pair_str = pairs.join(","); + let mut params = vec![("pair", &*pair_str)]; + let ac_wire; + if let Some(ac) = asset_class { + ac_wire = ac.as_wire(); + params.push(("asset_class", ac_wire.as_str())); } - MarketCommand::Ohlc { + let mut data = client.public_get("Ticker", ¶ms).await?; + schema::ticker(&mut data); + let (headers, rows) = parse_ticker_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +impl Execute for Ohlc { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { pair, interval, since, asset_class, - } => { - let interval_str = interval.to_string(); - let mut params = vec![("pair", pair.as_str()), ("interval", &interval_str)]; - if let Some(s) = since { - params.push(("since", s.as_str())); - } - if let Some(ac) = asset_class { - params.push(("asset_class", ac.as_str())); - } - let data = client.public_get("OHLC", ¶ms, verbose).await?; - let (headers, rows) = parse_ohlc_table(&data, pair); - Ok(CommandOutput::new(data, headers, rows)) + } = self; + let interval_str = interval.to_string(); + let mut params = vec![("pair", pair.as_str()), ("interval", &interval_str)]; + if let Some(s) = &since { + params.push(("since", s.as_str())); + } + let ac_wire; + if let Some(ac) = asset_class { + ac_wire = ac.as_wire(); + params.push(("asset_class", ac_wire.as_str())); } - MarketCommand::Orderbook { + let mut data = client.public_get("OHLC", ¶ms).await?; + schema::ohlc(&mut data, &pair); + let (headers, rows) = parse_ohlc_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +impl Execute for Orderbook { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { pair, count, asset_class, - } => { - let count_str = count.to_string(); - let mut params = vec![("pair", pair.as_str()), ("count", &count_str)]; - if let Some(ac) = asset_class { - params.push(("asset_class", ac.as_str())); - } - let data = client.public_get("Depth", ¶ms, verbose).await?; - let (headers, rows) = parse_orderbook_table(&data, pair); - Ok(CommandOutput::new(data, headers, rows)) + } = self; + let count_str = count.to_string(); + let mut params = vec![("pair", pair.as_str()), ("count", &count_str)]; + let ac_wire; + if let Some(ac) = asset_class { + ac_wire = ac.as_wire(); + params.push(("asset_class", ac_wire.as_str())); } - MarketCommand::OrderbookL3 { .. } => Err(KrakenError::Validation( - "L3 orderbook requires authentication; use the top-level dispatch".into(), - )), - MarketCommand::OrderbookGrouped { + let mut data = client.public_get("Depth", ¶ms).await?; + schema::orderbook(&mut data); + let (headers, rows) = parse_orderbook_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +impl Execute for OrderbookGrouped { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { pair, depth, grouping, - } => { - let mut params = vec![("pair", pair.as_str()), ("depth", depth.as_str())]; - if grouping != "1" { - params.push(("grouping", grouping.as_str())); - } - let data = client.public_get("GroupedBook", ¶ms, verbose).await?; - Ok(CommandOutput::new( - data.clone(), - vec!["Data".into()], - vec![vec![ - "Grouped orderbook data returned (see JSON output)".into() - ]], - )) + } = self; + let depth_wire = depth.as_wire(); + let grouping_wire = grouping.as_wire(); + let mut params = vec![("pair", pair.as_str()), ("depth", depth_wire.as_str())]; + if grouping != Grouping::G1 { + params.push(("grouping", grouping_wire.as_str())); } - MarketCommand::Trades { + let data = client.public_get("GroupedBook", ¶ms).await?; + Ok(CommandOutput::new( + data, + vec!["Data".into()], + vec![vec![ + "Grouped orderbook data returned (see JSON output)".into(), + ]], + )) + } +} + +impl Execute for Trades { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { pair, since, count, asset_class, - } => { - let count_str = count.to_string(); - let mut params = vec![("pair", pair.as_str()), ("count", &count_str)]; - if let Some(s) = since { - params.push(("since", s.as_str())); - } - if let Some(ac) = asset_class { - params.push(("asset_class", ac.as_str())); - } - let data = client.public_get("Trades", ¶ms, verbose).await?; - let (headers, rows) = parse_trades_table(&data, pair); - Ok(CommandOutput::new(data, headers, rows)) + } = self; + let count_str = count.to_string(); + let mut params = vec![("pair", pair.as_str()), ("count", &count_str)]; + if let Some(s) = &since { + params.push(("since", s.as_str())); + } + let ac_wire; + if let Some(ac) = asset_class { + ac_wire = ac.as_wire(); + params.push(("asset_class", ac_wire.as_str())); } - MarketCommand::Spreads { + let mut data = client.public_get("Trades", ¶ms).await?; + schema::trades(&mut data, &pair); + let (headers, rows) = parse_trades_table(&data); + Ok(CommandOutput::new(data, headers, rows)) + } +} + +impl Execute for Spreads { + async fn execute(self, ctx: &AppContext) -> Result { + let client = ctx.spot()?; + let Self { pair, since, asset_class, - } => { - let mut params = vec![("pair", pair.as_str())]; - if let Some(s) = since { - params.push(("since", s.as_str())); - } - if let Some(ac) = asset_class { - params.push(("asset_class", ac.as_str())); - } - let data = client.public_get("Spread", ¶ms, verbose).await?; - let (headers, rows) = parse_spreads_table(&data, pair); - Ok(CommandOutput::new(data, headers, rows)) + } = self; + let mut params = vec![("pair", pair.as_str())]; + if let Some(s) = &since { + params.push(("since", s.as_str())); + } + let ac_wire; + if let Some(ac) = asset_class { + ac_wire = ac.as_wire(); + params.push(("asset_class", ac_wire.as_str())); } + let mut data = client.public_get("Spread", ¶ms).await?; + schema::spreads(&mut data, &pair); + let (headers, rows) = parse_spreads_table(&data); + Ok(CommandOutput::new(data, headers, rows)) } } @@ -290,10 +378,10 @@ fn parse_asset_table(data: &Value) -> (Vec, Vec>) { for (key, val) in obj { rows.push(vec![ key.clone(), - jstr(val, "altname"), - jstr(val, "decimals"), - jstr(val, "display_decimals"), - jstr(val, "status"), + json_field(val, "altname"), + json_field(val, "decimals"), + json_field(val, "display_decimals"), + json_field(val, "status"), ]); } } @@ -304,20 +392,20 @@ fn parse_asset_table(data: &Value) -> (Vec, Vec>) { fn parse_pairs_table(data: &Value) -> (Vec, Vec>) { let headers = vec![ "Pair".into(), - "Altname".into(), - "Base".into(), - "Quote".into(), "Status".into(), + "Tick Size".into(), + "Order Min".into(), + "Cost Min".into(), ]; let mut rows = Vec::new(); if let Some(obj) = data.as_object() { for (key, val) in obj { rows.push(vec![ key.clone(), - jstr(val, "altname"), - jstr(val, "base"), - jstr(val, "quote"), - jstr(val, "status"), + json_field(val, "status"), + json_field(val, "tick_size"), + json_field(val, "ordermin"), + json_field(val, "costmin"), ]); } } @@ -338,51 +426,21 @@ fn parse_ticker_table(data: &Value) -> (Vec, Vec>) { let mut rows = Vec::new(); if let Some(obj) = data.as_object() { for (key, val) in obj { - let ask = val - .get("a") - .and_then(|a| a.get(0)) - .and_then(|v| v.as_str()) - .unwrap_or("-"); - let bid = val - .get("b") - .and_then(|a| a.get(0)) - .and_then(|v| v.as_str()) - .unwrap_or("-"); - let last = val - .get("c") - .and_then(|a| a.get(0)) - .and_then(|v| v.as_str()) - .unwrap_or("-"); - let vol = val - .get("v") - .and_then(|a| a.get(1)) - .and_then(|v| v.as_str()) - .unwrap_or("-"); - let high = val - .get("h") - .and_then(|a| a.get(1)) - .and_then(|v| v.as_str()) - .unwrap_or("-"); - let low = val - .get("l") - .and_then(|a| a.get(1)) - .and_then(|v| v.as_str()) - .unwrap_or("-"); rows.push(vec![ key.clone(), - ask.to_string(), - bid.to_string(), - last.to_string(), - vol.to_string(), - high.to_string(), - low.to_string(), + json_field(val, "ask_price"), + json_field(val, "bid_price"), + json_field(val, "last_price"), + json_field(val, "volume_24h"), + json_field(val, "high_24h"), + json_field(val, "low_24h"), ]); } } (headers, rows) } -fn parse_ohlc_table(data: &Value, pair: &str) -> (Vec, Vec>) { +fn parse_ohlc_table(data: &Value) -> (Vec, Vec>) { let headers = vec![ "Time".into(), "Open".into(), @@ -392,60 +450,45 @@ fn parse_ohlc_table(data: &Value, pair: &str) -> (Vec, Vec>) "Volume".into(), ]; let mut rows = Vec::new(); - - let candles = data.get(pair).or_else(|| { - data.as_object() - .and_then(|obj| obj.values().find(|v| v.is_array())) - }); - - if let Some(Value::Array(arr)) = candles { - for candle in arr { - if let Value::Array(c) = candle { - let time = c.first().map(jval).unwrap_or_default(); - let open = c.get(1).map(jval).unwrap_or_default(); - let high = c.get(2).map(jval).unwrap_or_default(); - let low = c.get(3).map(jval).unwrap_or_default(); - let close = c.get(4).map(jval).unwrap_or_default(); - let vol = c.get(6).map(jval).unwrap_or_default(); - rows.push(vec![time, open, high, low, close, vol]); - } + if let Some(Value::Array(candles)) = data.get("candles") { + for candle in candles { + rows.push(vec![ + json_field(candle, "time"), + json_field(candle, "open"), + json_field(candle, "high"), + json_field(candle, "low"), + json_field(candle, "close"), + json_field(candle, "volume"), + ]); } } (headers, rows) } -fn parse_orderbook_table(data: &Value, pair: &str) -> (Vec, Vec>) { +/// Table shows top-of-book only (10 levels a side); the JSON payload carries +/// the full requested depth. +fn parse_orderbook_table(data: &Value) -> (Vec, Vec>) { + const TABLE_LEVELS: usize = 10; let headers = vec!["Side".into(), "Price".into(), "Volume".into()]; let mut rows = Vec::new(); - - let book = data - .get(pair) - .or_else(|| data.as_object().and_then(|obj| obj.values().next())); - - if let Some(book) = book { - if let Some(Value::Array(asks)) = book.get("asks") { - for ask in asks.iter().take(10) { - if let Value::Array(a) = ask { - let price = a.first().map(jval).unwrap_or_default(); - let vol = a.get(1).map(jval).unwrap_or_default(); - rows.push(vec!["ASK".into(), price, vol]); - } - } - } - if let Some(Value::Array(bids)) = book.get("bids") { - for bid in bids.iter().take(10) { - if let Value::Array(b) = bid { - let price = b.first().map(jval).unwrap_or_default(); - let vol = b.get(1).map(jval).unwrap_or_default(); - rows.push(vec!["BID".into(), price, vol]); - } + for (side, label) in [("asks", "ASK"), ("bids", "BID")] { + if let Some(Value::Array(levels)) = data.get(side) { + for level in levels.iter().take(TABLE_LEVELS) { + rows.push(vec![ + label.into(), + json_field(level, "price"), + json_field(level, "volume"), + ]); } } } (headers, rows) } -fn parse_trades_table(data: &Value, pair: &str) -> (Vec, Vec>) { +/// Table shows the newest 20 trades (Kraken returns oldest-first); the JSON +/// payload carries the full result plus the `last` pagination cursor. +fn parse_trades_table(data: &Value) -> (Vec, Vec>) { + const TABLE_TRADES: usize = 20; let headers = vec![ "Price".into(), "Volume".into(), @@ -454,85 +497,38 @@ fn parse_trades_table(data: &Value, pair: &str) -> (Vec, Vec "Type".into(), ]; let mut rows = Vec::new(); - - let trades = data.get(pair).or_else(|| { - data.as_object() - .and_then(|obj| obj.values().find(|v| v.is_array())) - }); - - if let Some(Value::Array(arr)) = trades { - for trade in arr.iter().rev().take(20) { - if let Value::Array(t) = trade { - let price = t.first().map(jval).unwrap_or_default(); - let vol = t.get(1).map(jval).unwrap_or_default(); - let time = t.get(2).map(jval).unwrap_or_default(); - let side = t - .get(3) - .and_then(|v| v.as_str()) - .map(|s| if s == "b" { "buy" } else { "sell" }) - .unwrap_or("-"); - let order_type = t - .get(4) - .and_then(|v| v.as_str()) - .map(|s| if s == "l" { "limit" } else { "market" }) - .unwrap_or("-"); - rows.push(vec![ - price, - vol, - time, - side.to_string(), - order_type.to_string(), - ]); - } + if let Some(Value::Array(trades)) = data.get("trades") { + for trade in trades.iter().rev().take(TABLE_TRADES) { + // JSON keeps the raw wire token (`l`/`m`); the human table + // spells it out. + let order_type = match trade.get("order_type").and_then(Value::as_str) { + Some("l") => "limit".into(), + Some("m") => "market".into(), + _ => json_field(trade, "order_type"), + }; + rows.push(vec![ + json_field(trade, "price"), + json_field(trade, "volume"), + json_field(trade, "time"), + json_field(trade, "side"), + order_type, + ]); } } (headers, rows) } -fn parse_spreads_table(data: &Value, pair: &str) -> (Vec, Vec>) { +fn parse_spreads_table(data: &Value) -> (Vec, Vec>) { let headers = vec!["Time".into(), "Bid".into(), "Ask".into()]; let mut rows = Vec::new(); - - let spreads = data.get(pair).or_else(|| { - data.as_object() - .and_then(|obj| obj.values().find(|v| v.is_array())) - }); - - if let Some(Value::Array(arr)) = spreads { - for spread in arr.iter().rev().take(20) { - if let Value::Array(s) = spread { - let time = s.first().map(jval).unwrap_or_default(); - let bid = s.get(1).map(jval).unwrap_or_default(); - let ask = s.get(2).map(jval).unwrap_or_default(); - rows.push(vec![time, bid, ask]); - } + if let Some(Value::Array(spreads)) = data.get("spreads") { + for spread in spreads.iter().rev().take(20) { + rows.push(vec![ + json_field(spread, "time"), + json_field(spread, "bid"), + json_field(spread, "ask"), + ]); } } (headers, rows) } - -fn jval(v: &Value) -> String { - match v { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - other => other.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn orderbook_l3_returns_error_not_panic() { - let client = SpotClient::new(Some("https://api.kraken.com")).unwrap(); - let cmd = MarketCommand::OrderbookL3 { - pair: "BTCUSD".into(), - depth: "100".into(), - }; - let result = execute(&cmd, &client, false).await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!(msg.contains("L3 orderbook requires authentication")); - } -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8ebbd4b..c75143a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,15 +1,185 @@ -/// Command modules for each functional group of the Kraken CLI. +//! The CLI command tree: [`Command`] composes the per-domain subcommand enums +//! declared here, and [`Execute`] is the contract every variant fulfils. The +//! entry points live in [`executor`]; the inline-rendering commands and their +//! [`streaming::Run`] contract in [`streaming`]. + +use clap::Subcommand; +use enum_dispatch::enum_dispatch; + +use crate::cli::AppContext; +use crate::errors::Result; +use crate::output::CommandOutput; + +/// Uniform execution: each command resolves the resources it needs from the +/// context (clients, credentials, OTP, force) and runs, returning structured +/// output. `#[enum_dispatch]` derives every enum-level impl from the leaf +/// impls in the submodules, so no routing match exists on this path. +/// +/// Consumes the command: leaves move their parsed args into request payloads, +/// which a `&self` receiver would force to clone. +/// +/// Crate-internal + static-dispatch only, so native `async fn` in a trait +/// needs no `async-trait` crate. +// +// Declared ABOVE the `mod` declarations deliberately: enum_dispatch emits each +// enum's generated impl at whichever side of the trait/enum pair expands +// second. The trait must therefore expand first, so the impls land inside the +// child modules — where the leaf types are in scope — not here. +#[enum_dispatch] +trait Execute { + async fn execute(self, ctx: &AppContext) -> Result; +} + pub(crate) mod account; pub(crate) mod auth; pub(crate) mod earn; +mod executor; +pub(crate) mod explain; +pub(crate) mod feedback; pub(crate) mod funding; pub(crate) mod futures; pub(crate) mod futures_paper; pub(crate) mod futures_ws; -pub(crate) mod helpers; +pub(crate) mod journal_mirror; +pub(crate) mod lab; pub(crate) mod market; pub(crate) mod paper; +pub(crate) mod playground; +pub(crate) mod record; +pub(crate) mod replay; +pub(crate) mod replay_venue; +pub(crate) mod schema; +pub(crate) mod session; +pub(crate) mod setup; +pub(crate) mod streamd; +mod streaming; pub(crate) mod subaccount; +pub(crate) mod subscription; +pub(crate) mod tape; pub(crate) mod trade; -pub(crate) mod utility; -pub(crate) mod websocket; +pub(crate) mod value_types; +pub(crate) mod workspace; +pub(crate) mod workspace_guard; +pub(crate) mod ws; + +use self::account::AccountCommand; +use self::auth::AuthCommand; +use self::earn::EarnCommand; +pub use self::executor::run; +pub(crate) use self::executor::{dispatch, execute_command}; +use self::explain::ExplainCommand; +use self::feedback::Feedback; +use self::funding::{DepositCommand, WalletTransfer, Withdraw, WithdrawalCommand}; +use self::futures::FuturesCommand; +use self::lab::LabCommand; +use self::market::MarketCommand; +use self::paper::PaperCommand; +use self::playground::PlaygroundCommand; +use self::session::SessionCommand; +use self::streaming::StreamingCommand; +use self::subaccount::SubaccountCommand; +use self::tape::TapeCommand; +use self::trade::OrderCommand; +use self::workspace::WorkspaceCommand; + +// One value exists per process, parsed once at startup and dispatched immediately, +// so the size gap between the fat order variants and the unit variants costs +// nothing worth boxing for. +// +// `enum_dispatch` requires every variant to be a newtype over an [`Execute`] +// type — a struct variant here fails the macro, not the CLI surface. It also +// generates `From`/`TryInto` impls for every variant, so a +// hand-written conversion collides (E0119) with code you can't see. +#[allow(clippy::large_enum_variant)] +#[enum_dispatch(Execute)] +#[derive(Subcommand)] +pub(crate) enum Command { + /// Public market data: ticker, orderbook, ohlc, trades, spreads, status. + #[command(flatten)] + Market(MarketCommand), + /// Private account data: balances, orders, history, exports, L3 book. + #[command(flatten)] + Account(AccountCommand), + /// Manage API credentials. + #[command(subcommand)] + Auth(AuthCommand), + /// Market memory: list every tape in the library. + #[command(subcommand)] + Tape(TapeCommand), + /// Deposit methods and addresses. + #[command(subcommand)] + Deposit(DepositCommand), + /// Earn/staking commands. + #[command(subcommand)] + Earn(EarnCommand), + /// Explain a recorded session in plain terms. + #[command(subcommand)] + Explain(ExplainCommand), + /// Submit product feedback (feature request, friction, or bug). + #[command(long_about = feedback::LONG_ABOUT)] + Feedback(Feedback), + /// Futures trading and market data. + #[command(subcommand)] + Futures(FuturesCommand), + /// Autoresearch Lab: score recorded sessions against their own tape. + #[command(subcommand)] + Lab(LabCommand), + /// Place and manage spot orders. + #[command(subcommand)] + Order(OrderCommand), + /// Spot paper trading (simulated, no real money). For futures paper trading, use `kraken futures paper`. + #[command(subcommand)] + Paper(PaperCommand), + /// Subaccount management. + #[command(subcommand)] + Subaccount(SubaccountCommand), + /// Transfer between wallets. + WalletTransfer(WalletTransfer), + /// Make a withdrawal. + Withdraw(Withdraw), + /// Withdrawal methods. + #[command(subcommand)] + Withdrawal(WithdrawalCommand), + /// Strategy workspaces: the account, created and funded once. + #[command(subcommand)] + Workspace(WorkspaceCommand), + /// Interactive/streaming commands: ws, streamd, record, replay, setup, shell, mcp. + #[command(flatten)] + Streaming(StreamingCommand), + /// Sessions: recorded windows over the account journal. + #[command(subcommand)] + Session(SessionCommand), + /// One-command demo: a recorded session in the `playground` paper workspace. + Playground(PlaygroundCommand), +} + +#[cfg(test)] +mod tests { + /// The paper surface must stay auth-free: it trades no real funds, so a + /// credentials reference appearing in it is a wiring bug. Scanned from a + /// sibling module so the assertion needles never match themselves. + #[test] + fn test_paper_commands_no_auth_imports() { + let source = include_str!("paper.rs"); + assert!( + !source.contains("Credentials"), + "paper.rs must not reference Credentials" + ); + assert!( + !source.contains("private_post"), + "paper.rs must not reference private_post" + ); + assert!( + !source.contains("build_spot_authed"), + "paper.rs must not reference build_spot_authed" + ); + assert!( + !source.contains("resolve_credentials"), + "paper.rs must not reference resolve_credentials" + ); + assert!( + !source.contains("AuthConfig"), + "paper.rs must not reference AuthConfig" + ); + } +} diff --git a/src/commands/paper.rs b/src/commands/paper.rs index f697fab..bebbd4c 100644 --- a/src/commands/paper.rs +++ b/src/commands/paper.rs @@ -1,376 +1,780 @@ use std::collections::{HashMap, HashSet}; +use std::path::Path; use clap::Subcommand; -use serde_json::{json, Value}; +use enum_dispatch::enum_dispatch; +use kraken_core::OrderSide; +use kraken_paper::account::{Origin, PaperAccount}; +use kraken_paper::{ + AccountEvent, BALANCE_DUST, CommandEntry, CommandOutcome, OrderRejection, PaperState, + PaperTrade, parse_pair, +}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use serde_json::{Value, json}; +use super::Execute; +use crate::cli::AppContext; use crate::client::SpotClient; use crate::errors::{KrakenError, Result}; -use crate::output::{self, CommandOutput}; -use crate::paper::{ - load_state, migrate_legacy_state, paper_state_path, parse_pair, save_state, OrderSide, - PaperConfig, PaperState, PaperTrade, -}; -use crate::{build_spot_client, AppContext}; - -const DUST_THRESHOLD: f64 = 1e-12; +use crate::output::{CommandOutput, rounded}; +use crate::session; +use crate::session::decision::DecisionKind; #[derive(Debug, Subcommand)] pub(crate) enum PaperCommand { - /// Initialize paper trading account. - Init { - /// Starting balance (default: 10000). - #[arg(long, default_value = "10000")] - balance: f64, - /// Starting currency (default: USD). - #[arg(long, default_value = "USD")] - currency: String, - /// Fee rate as a decimal (default: 0.0026 = 0.26% Kraken Starter tier). - #[arg(long)] - fee_rate: Option, - /// Slippage rate as a decimal (default: 0.0 = no slippage simulation). - #[arg(long, alias = "slippage")] - slippage_rate: Option, - }, - /// Reset paper account. Optionally override balance, currency, or fee rate. - Reset { - /// New starting balance (default: keep previous). - #[arg(long)] - balance: Option, - /// New starting currency (default: keep previous). - #[arg(long)] - currency: Option, - /// New fee rate (default: keep previous). - #[arg(long)] - fee_rate: Option, - /// Slippage rate as a decimal (default: keep previous). - #[arg(long, alias = "slippage")] - slippage_rate: Option, - }, - /// Show paper balances. - Balance, + /// Create AND fund a paper workspace — `kraken workspace create --mode + /// paper` under another name. + Init(Init), + /// Return the workspace to its starting capital — `kraken workspace + /// reset` under another name. + Reset(Reset), + /// Workspace balances — `kraken workspace balance` under another name. + Balance(Balance), + /// Workspace summary with P&L — `kraken workspace status` under another + /// name. + Status(Status), + #[command(flatten)] + Trade(PaperTradeCommand), +} + +// The lifecycle verbs above are aliases kept for muscle memory: each Execute +// impl constructs the corresponding `workspace` command and runs it, so there +// is exactly one implementation. They live outside `Transact` deliberately — +// the workspace machinery opens the journal itself, and running it under the +// trade path's already-held writer lock would self-reject. + +#[enum_dispatch(Transact)] +#[derive(Debug, Subcommand)] +pub(crate) enum PaperTradeCommand { /// Place a paper buy order. - Buy { - /// Trading pair (e.g., BTCUSD). - pair: String, - /// Order volume. - volume: String, - /// Order type: market (default) or limit. - #[arg(long, default_value = "market")] - r#type: String, - /// Limit price (required for limit orders). - #[arg(long)] - price: Option, - }, + Buy(Buy), /// Place a paper sell order. - Sell { - /// Trading pair (e.g., BTCUSD). - pair: String, - /// Order volume. - volume: String, - /// Order type: market (default) or limit. - #[arg(long, default_value = "market")] - r#type: String, - /// Limit price (required for limit orders). - #[arg(long)] - price: Option, - }, + Sell(Sell), /// Show open paper limit orders. - Orders, + Orders(Orders), /// Cancel a paper limit order. - Cancel { - /// Order ID (e.g., PAPER-00001). - order_id: String, - }, + Cancel(Cancel), /// Cancel all paper limit orders. - CancelAll, + CancelAll(CancelAll), /// Show paper trade history. - History, - /// Show paper account summary with P&L. - Status, + History(History), +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Init { + /// Workspace name to create (defaults to the active workspace, else + /// the global paper account). + name: Option, + /// Starting capital (default: 10000). + #[arg(long, alias = "balance", default_value = "10000")] + capital: Decimal, + /// Capital currency. + #[arg(long, default_value = "USD")] + currency: String, + /// Fee rate as a decimal (default: 0.0026 = 0.26% Kraken Starter tier). + #[arg(long)] + fee_rate: Option, + /// Slippage rate as a decimal (default: 0.0 = no slippage simulation). + #[arg(long, alias = "slippage")] + slippage_rate: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Reset { + /// Workspace name (defaults to the active workspace, else the global + /// paper account). + name: Option, + /// Re-parameterize on reset: new starting capital (public-compatible + /// alias: --balance). + #[arg(long, alias = "balance")] + capital: Option, + /// New capital currency. + #[arg(long)] + currency: Option, + /// New fee rate as a decimal. + #[arg(long)] + fee_rate: Option, + /// New slippage rate as a decimal. + #[arg(long, alias = "slippage")] + slippage_rate: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Balance { + /// Workspace name (defaults to the active workspace, else the global + /// paper account). + name: Option, +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Status { + /// Workspace name (defaults to the active workspace, else the global + /// paper account). + name: Option, } -pub(crate) async fn execute( - cmd: &PaperCommand, +/// The conventional workspace behind the bare paper family — a normal +/// workspace, just implicitly named, so the global account stays a real +/// contract-carrying account. +pub(crate) const GLOBAL_PAPER_WORKSPACE: &str = "global"; + +/// The paper family always addresses a paper workspace — unlike the +/// `workspace` verbs it never falls back to the real account. With no name +/// and no active workspace it uses the global paper account, said out loud: +/// an implicit target must never be a silent one. +fn paper_target(name: Option, ctx: &AppContext) -> String { + name.or_else(|| ctx.workspace.clone()).unwrap_or_else(|| { + warn_global_account(); + GLOBAL_PAPER_WORKSPACE.to_string() + }) +} + +/// The contract whose pair policy governs this trade: the active workspace, +/// or the global paper account for bare invocations. Every paper trade now +/// carries a contract — the policy-free session accounts retired with runs. +fn trade_policy(ctx: &AppContext) -> Result> { + let name = ctx.workspace.as_deref().unwrap_or(GLOBAL_PAPER_WORKSPACE); + let name: kraken_workspace::WorkspaceName = name.parse()?; + let workspaces = kraken_workspace::Workspaces::new(crate::config::config_dir()?); + Ok(Some(workspaces.manifest(&name)?)) +} + +fn warn_global_account() { + eprintln!( + "note: no workspace set — using the global paper account \ + ('{GLOBAL_PAPER_WORKSPACE}'); name one or set KRAKEN_WORKSPACE" + ); +} + +/// The scope root for bare paper trading: the global paper account's tree, +/// verified — trading never auto-creates an account, so an unfunded global +/// account refuses with the init hint instead of conjuring a journal. +fn global_paper_base() -> Result { + let base = crate::config::config_dir()?; + let name: kraken_workspace::WorkspaceName = GLOBAL_PAPER_WORKSPACE.parse()?; + if let Err(err) = kraken_workspace::Workspaces::new(&base).ensure_exists(&name) { + return Err(match err { + kraken_workspace::WorkspaceError::NotFound(_) => KrakenError::Validation( + "No workspace set and no global paper account yet. Run 'kraken paper init' \ + first, or set KRAKEN_WORKSPACE." + .into(), + ), + other => other.into(), + }); + } + warn_global_account(); + Ok(kraken_workspace::workspace_dir(&base, &name)) +} + +// --- The mode-routed surface: the order verbs run these same +// engine transactions under the workspace the guard resolved, so one +// implementation serves both vocabularies. + +/// The order facts the routed verbs carry — a borrow-shaped mirror of the +/// paper `OrderRequest`, constructed from `trade.rs` args. +pub(crate) struct RoutedOrder<'a> { + pub(crate) side: OrderSide, + pub(crate) pair: &'a str, + pub(crate) volume: &'a str, + pub(crate) order_type: &'a str, + pub(crate) price: Option<&'a str>, + pub(crate) reason: Option<&'a str>, +} + +fn routed_base(manifest: &kraken_workspace::WorkspaceManifest) -> Result { + let name: kraken_workspace::WorkspaceName = manifest.name.parse()?; + Ok(kraken_workspace::workspace_dir( + &crate::config::config_dir()?, + &name, + )) +} + +fn routed_account( ctx: &AppContext, - verbose: bool, -) -> Result { - match cmd { - PaperCommand::Init { - balance, - currency, - fee_rate, - slippage_rate, - } => execute_init(*balance, currency, *fee_rate, *slippage_rate), - PaperCommand::Reset { - balance, - currency, - fee_rate, - slippage_rate, - } => { - let client = build_spot_client(ctx)?; - execute_reset( - balance.as_ref().copied(), - currency.as_deref(), - fee_rate.as_ref().copied(), - slippage_rate.as_ref().copied(), - &client, - verbose, - ) - .await - } - PaperCommand::Balance => { - let client = build_spot_client(ctx)?; - execute_balance(&client, verbose).await - } - PaperCommand::Buy { - pair, - volume, - r#type, - price, - } => { - let client = build_spot_client(ctx)?; - execute_trade( - OrderSide::Buy, - pair, - volume, - r#type, - price, - &client, - verbose, - ) - .await - } - PaperCommand::Sell { - pair, - volume, - r#type, - price, - } => { - let client = build_spot_client(ctx)?; - execute_trade( - OrderSide::Sell, - pair, + manifest: &kraken_workspace::WorkspaceManifest, +) -> Result<(std::path::PathBuf, PaperAccount)> { + let base = routed_base(manifest)?; + let account = PaperAccount::open_at( + global_events_path(&base), + Origin::from_mcp_mode(ctx.mcp_mode), + )?; + Ok((base, account)) +} + +fn routed_entry(order: &RoutedOrder<'_>) -> CommandEntry { + CommandEntry { + name: order.side.to_string(), + pair: Some(order.pair.to_uppercase()), + side: Some(order.side), + volume: order.volume.parse().ok(), + order_type: Some(order.order_type.to_lowercase()), + price: order.price.and_then(|p| p.parse().ok()), + ..CommandEntry::default() + } +} + +/// One routed order against a paper workspace: the same open → +/// place → failure-audit transaction the paper verbs run, returning the +/// typed placement for the caller's envelope. +pub(crate) async fn place_routed_order( + ctx: &AppContext, + manifest: &kraken_workspace::WorkspaceManifest, + order: RoutedOrder<'_>, +) -> Result { + let (base, mut account) = routed_account(ctx, manifest)?; + warn_when_no_active_session(&base); + let request = PaperOrderRequest { + side: order.side, + pair: order.pair, + volume: order.volume, + order_type: order.order_type, + price: order.price, + reason: order.reason, + }; + let entry = routed_entry(&order); + // The scope's active session owns the pricing context: a replay session's trades + // fill off its tape, never live REST. + let mut venue = Venue::resolve(ctx, &base)?; + let decisions = order.reason.map(|_| reason_target(&base)).transpose()?; + let result = place_order_on_account( + request, + Some(manifest), + decisions.as_deref(), + &mut account, + entry, + &mut venue, + ) + .await; + // The same failure audit the paper verbs leave: a rejected order is an + // event on the record, best-effort so it never shadows the error. + if let Err(err) = &result { + let mut entry = routed_entry(&order); + entry.outcome = CommandOutcome::Err { + category: err.category().to_string(), + }; + let mut events = Vec::new(); + if let Ok(volume) = order.volume.parse::() { + events.push(AccountEvent::OrderRejected(OrderRejection { + side: order.side, + pair: order.pair.to_uppercase(), volume, - r#type, - price, - &client, - verbose, - ) - .await + price: order.price.and_then(|p| p.parse().ok()), + category: err.category().to_string(), + })); } - PaperCommand::Orders => { - let client = build_spot_client(ctx)?; - execute_orders(&client, verbose).await - } - PaperCommand::Cancel { order_id } => { - let client = build_spot_client(ctx)?; - execute_cancel(order_id, &client, verbose).await + events.push(AccountEvent::Command(entry)); + account.audit(&account.stamp(events)); + } + result +} + +/// `--validate` in a paper workspace: the exact decide path — params, pair +/// policy, funds, market pricing — committing nothing. +pub(crate) async fn validate_routed_order( + ctx: &AppContext, + manifest: &kraken_workspace::WorkspaceManifest, + order: RoutedOrder<'_>, +) -> Result<()> { + let (_, account) = routed_account(ctx, manifest)?; + let (normalized, base_asset, quote) = parse_pair(order.pair)?; + kraken_workspace::policy::ensure_pair_allowed(manifest, &format!("{base_asset}/{quote}"))?; + let volume: Decimal = order + .volume + .parse() + .map_err(|_| KrakenError::Validation(format!("Invalid volume: {}", order.volume)))?; + let state = account.state()?.clone(); + if validate_order_type(order.order_type)? { + let price_val = order + .price + .ok_or_else(|| KrakenError::Validation("Limit orders require --price".into()))?; + let price: Decimal = price_val + .parse() + .map_err(|_| KrakenError::Validation(format!("Invalid price: {price_val}")))?; + state.decide_limit_order(order.side, order.pair, volume, price)?; + } else { + let venue = Venue::resolve(ctx, &routed_base(manifest)?)?; + let (ask, bid) = venue.ticker_price(&normalized).await?; + state.decide_market_order(order.side, order.pair, volume, ask, bid)?; + } + Ok(()) +} + +pub(crate) async fn cancel_on_workspace( + ctx: &AppContext, + manifest: &kraken_workspace::WorkspaceManifest, + order_id: &str, + reason: Option<&str>, +) -> Result { + let (base, mut account) = routed_account(ctx, manifest)?; + let entry = CommandEntry { + name: "cancel".into(), + order_id: Some(order_id.to_string()), + ..CommandEntry::default() + }; + let decisions = reason.map(|_| reason_target(&base)).transpose()?; + execute_cancel( + order_id, + &mut account, + entry, + &mut Venue::resolve(ctx, &base)?, + decisions.as_deref(), + reason, + ) + .await +} + +pub(crate) async fn cancel_all_on_workspace( + ctx: &AppContext, + manifest: &kraken_workspace::WorkspaceManifest, +) -> Result { + let (base, mut account) = routed_account(ctx, manifest)?; + let entry = CommandEntry { + name: "cancel_all".into(), + ..CommandEntry::default() + }; + execute_cancel_all(&mut account, entry, &mut Venue::resolve(ctx, &base)?).await +} + +pub(crate) async fn orders_on_workspace( + ctx: &AppContext, + manifest: &kraken_workspace::WorkspaceManifest, +) -> Result { + let (base, mut account) = routed_account(ctx, manifest)?; + let entry = CommandEntry { + name: "orders".into(), + ..CommandEntry::default() + }; + execute_orders(&mut account, entry, &mut Venue::resolve(ctx, &base)?).await +} + +pub(crate) async fn history_on_workspace( + ctx: &AppContext, + manifest: &kraken_workspace::WorkspaceManifest, +) -> Result { + let (base, mut account) = routed_account(ctx, manifest)?; + let entry = CommandEntry { + name: "history".into(), + ..CommandEntry::default() + }; + execute_history(&mut account, entry, &mut Venue::resolve(ctx, &base)?).await +} + +impl Execute for Init { + async fn execute(self, ctx: &AppContext) -> Result { + super::workspace::Create { + name: paper_target(self.name, ctx), + capital: self.capital, + currency: self.currency, + mode: kraken_workspace::WorkspaceMode::Paper, + fee_rate: self.fee_rate, + slippage_rate: self.slippage_rate, + allow_pairs: None, } - PaperCommand::CancelAll => { - let client = build_spot_client(ctx)?; - execute_cancel_all(&client, verbose).await + .execute(ctx) + .await + } +} + +impl Execute for Reset { + async fn execute(self, ctx: &AppContext) -> Result { + super::workspace::Reset { + name: Some(paper_target(self.name, ctx)), + capital: self.capital, + currency: self.currency, + fee_rate: self.fee_rate, + slippage_rate: self.slippage_rate, } - PaperCommand::History => { - let client = build_spot_client(ctx)?; - execute_history(&client, verbose).await + .execute(ctx) + .await + } +} + +impl Execute for Balance { + async fn execute(self, ctx: &AppContext) -> Result { + super::workspace::Balance { + name: Some(paper_target(self.name, ctx)), } - PaperCommand::Status => { - let client = build_spot_client(ctx)?; - execute_status(&client, verbose).await + .execute(ctx) + .await + } +} + +impl Execute for Status { + async fn execute(self, ctx: &AppContext) -> Result { + super::workspace::Status { + name: Some(paper_target(self.name, ctx)), } + .execute(ctx) + .await } } -fn execute_init(balance: f64, currency: &str, fee_rate: Option, slippage_rate: Option) -> Result { - use crate::paper::{DEFAULT_FEE_RATE, DEFAULT_SLIPPAGE_RATE}; +// The Args structs below carry no struct-level rustdoc on purpose: clap folds +// type-level docs into `--help`, and the about lines must keep coming from the +// enum variants alone so the help text stays exactly as it was. + +// Buy and sell take the same order parameters, but `enum_dispatch` needs a +// distinct type per variant — so thin wrappers flatten this one definition. +#[derive(Debug, clap::Args)] +pub(crate) struct OrderRequest { + /// Trading pair (e.g., BTCUSD). + pair: String, + /// Order volume. + volume: String, + /// Order type: market (default) or limit. + #[arg(long, default_value = "market")] + r#type: String, + /// Limit price (required for limit orders). + #[arg(long)] + price: Option, + /// Rationale to log to the playground session's decision log (requires --session). + #[arg(long)] + reason: Option, +} - let migrated = migrate_legacy_state()?; - let path = paper_state_path()?; - if path.exists() { - let msg = if migrated { - "Existing paper account found and migrated to new location. Use 'kraken paper reset' to start fresh." - } else { - "Paper account already initialized. Use 'kraken paper reset' to start over." - }; - return Err(KrakenError::Validation(msg.into())); - } - if !balance.is_finite() || balance <= 0.0 { - return Err(KrakenError::Validation( - "Starting balance must be a finite positive number".into(), - )); +impl OrderRequest { + fn request(&self, side: OrderSide) -> PaperOrderRequest<'_> { + PaperOrderRequest { + side, + pair: &self.pair, + volume: &self.volume, + order_type: &self.r#type, + price: self.price.as_deref(), + reason: self.reason.as_deref(), + } } +} - let rate = fee_rate.unwrap_or(DEFAULT_FEE_RATE); - if !rate.is_finite() || !(0.0..=1.0).contains(&rate) { - return Err(KrakenError::Validation( - "Fee rate must be between 0.0 and 1.0 (e.g., 0.0026 for 0.26%)".into(), - )); - } +/// Resolves the scoped journal; an unfunded flat account remains epoch-less. +pub(crate) fn global_events_path(base: &Path) -> std::path::PathBuf { + base.join(kraken_workspace::JOURNAL_FILE) +} - let slip = slippage_rate.unwrap_or(DEFAULT_SLIPPAGE_RATE); - validate_slippage_rate(slip)?; +#[derive(Debug, clap::Args)] +pub(crate) struct Buy { + #[command(flatten)] + trade: OrderRequest, +} - let state = PaperState::with_config(PaperConfig { - balance, - currency: currency.to_string(), - fee_rate: rate, - slippage_rate: slip, - }); - save_state(&state)?; +#[derive(Debug, clap::Args)] +pub(crate) struct Sell { + #[command(flatten)] + trade: OrderRequest, +} - let cur = currency.to_uppercase(); - let fee_pct = format!("{:.2}%", rate * 100.0); - let slip_pct = format!("{:.2}%", slip * 100.0); - let pairs = vec![ - ("Mode".into(), "[PAPER] Simulated Trading".into()), - ("Action".into(), "Account initialized".into()), - ("Starting Balance".into(), format!("{balance:.2} {cur}")), - ("Fee Rate".into(), fee_pct), - ("Slippage Rate".into(), slip_pct), - ]; +#[derive(Debug, clap::Args)] +pub(crate) struct Orders {} - Ok(CommandOutput::key_value( - pairs, - paper_json(json!({ - "action": "initialized", - "starting_balance": balance, - "starting_currency": cur, - "fee_rate": rate, - "slippage_rate": slip, - })), - )) +#[derive(Debug, clap::Args)] +pub(crate) struct Cancel { + /// Order ID (e.g., PAPER-00001). + order_id: String, } -async fn reconcile_best_effort( - state: &mut PaperState, - client: &SpotClient, - verbose: bool, -) -> Vec { - match reconcile_and_persist(state, client, verbose).await { - Ok(fills) => fills, - Err(err) => { - output::warn(&format!("Could not sync pending orders: {err}")); - Vec::new() +#[derive(Debug, clap::Args)] +pub(crate) struct CancelAll {} + +#[derive(Debug, clap::Args)] +pub(crate) struct History {} + +/// Where a paper command's prices come from. Live accounts reconcile and +/// value against public REST; a scope whose ACTIVE run replays a tape folds +/// that recorded source and never touches the network. +pub(crate) enum Venue<'a> { + Rest(&'a SpotClient), + Replay(Box), +} + +impl<'a> Venue<'a> { + pub(crate) fn resolve(ctx: &'a AppContext, scope: &Path) -> Result { + match super::replay_venue::ReplayVenue::resolve_active(scope)? { + Some(replay) => Ok(Self::Replay(Box::new(replay))), + None => Ok(Self::Rest(ctx.spot()?)), } } -} -async fn execute_reset( - balance: Option, - currency: Option<&str>, - fee_rate: Option, - slippage_rate: Option, - client: &SpotClient, - verbose: bool, -) -> Result { - if let Some(b) = balance { - if !b.is_finite() || b <= 0.0 { - return Err(KrakenError::Validation( - "Starting balance must be a finite positive number".into(), - )); + /// The venue for an account addressed by name from outside its scope — + /// `workspace status ` marks the journal off live REST; only the + /// scope's own commands ride an active replay session's tape. + pub(crate) fn resolve_ambient(ctx: &'a AppContext) -> Result { + Ok(Self::Rest(ctx.spot()?)) + } + + /// Live: best-effort REST reconcile — an offline machine must not fail a + /// read command. Replay: the fold IS the venue; its failure means the + /// source tape is unusable, which no command may hide. + async fn reconcile(&mut self, account: &mut PaperAccount) -> Result<()> { + match self { + Self::Rest(client) => { + reconcile_best_effort(account, client).await; + Ok(()) + } + Self::Replay(venue) => venue.fold_due(account), } } - if let Some(r) = fee_rate { - if !r.is_finite() || !(0.0..=1.0).contains(&r) { - return Err(KrakenError::Validation( - "Fee rate must be between 0.0 and 1.0 (e.g., 0.0026 for 0.26%)".into(), - )); + + async fn ticker_price(&self, pair: &str) -> Result<(Decimal, Decimal)> { + match self { + Self::Rest(client) => fetch_ticker_price(client, pair).await, + Self::Replay(venue) => venue.ticker_price(pair), } } - if let Some(s) = slippage_rate { - validate_slippage_rate(s)?; + + async fn value(&self, state: &PaperState) -> (Decimal, bool) { + match self { + Self::Rest(client) => value_account(client, state).await, + Self::Replay(venue) => venue.value(state), + } } +} - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; - state.reset_with(balance, currency, fee_rate, slippage_rate); - save_state(&state)?; +pub(crate) async fn execute(cmd: &PaperTradeCommand, ctx: &AppContext) -> Result { + // The scope root is resolved once here and threaded down: every path the + // command touches (journal, decision logs, replay tapes) must live in the + // same tree, so no handler may re-derive it. + let ambient_global = ctx.workspace.is_none(); + let base = if ambient_global { + global_paper_base()? + } else { + ctx.scoped_base()? + }; + // Open the account once; the handle holds the log's writer lock for the + // whole command, so read→decide→append is one transaction across processes. + let mut account = PaperAccount::open_at( + global_events_path(&base), + Origin::from_mcp_mode(ctx.mcp_mode), + )?; + + // Successful handlers append their own batches; the wrapper owns the + // failure audit, best-effort so it can never shadow the command's error. + let mut result = cmd.transact(&mut account, entry_for(cmd), ctx, &base).await; + if let Err(err) = &result { + let mut entry = entry_for(cmd); + entry.outcome = CommandOutcome::Err { + category: err.category().to_string(), + }; + let mut events = Vec::new(); + if let Some(rejection) = rejection_for(cmd, err) { + events.push(AccountEvent::OrderRejected(rejection)); + } + events.push(AccountEvent::Command(entry)); + account.audit(&account.stamp(events)); + } + // An implicit target is loud in the payload too, not just on stderr. + if let (Ok(output), true) = (result.as_mut(), ambient_global) { + output.stamp_workspace(GLOBAL_PAPER_WORKSPACE); + } + result +} - let bal = state.starting_balance; - let cur = state.starting_currency.clone(); - let fee_pct = format!("{:.2}%", state.fee_rate * 100.0); +impl Execute for PaperCommand { + async fn execute(self, ctx: &AppContext) -> Result { + match self { + Self::Init(cmd) => cmd.execute(ctx).await, + Self::Reset(cmd) => cmd.execute(ctx).await, + Self::Balance(cmd) => cmd.execute(ctx).await, + Self::Status(cmd) => cmd.execute(ctx).await, + Self::Trade(cmd) => execute(&cmd, ctx).await, + } + } +} - let pairs = vec![ - ("Mode".into(), "[PAPER] Simulated Trading".into()), - ("Action".into(), "Account reset".into()), - ("Starting Balance".into(), format!("{bal:.2} {cur}")), - ("Fee Rate".into(), fee_pct), - ("Slippage Rate".into(), format!("{:.2}%", state.slippage_rate * 100.0)) - ]; +/// Runs one command inside the account's single open transaction: [`execute`] +/// opens the account once (writer lock for the whole command), resolves the +/// scope root, and derives the audit entry before delegating here. Each impl +/// resolves its own [`Venue`] because only the commands that reconcile +/// against prices need one — `Init`/`Reset` stay fully offline on every venue. +#[enum_dispatch] +trait Transact { + async fn transact( + &self, + account: &mut PaperAccount, + entry: CommandEntry, + ctx: &AppContext, + base: &Path, + ) -> Result; +} - Ok(CommandOutput::key_value( - pairs, - paper_json(json!({ - "action": "reset", - "starting_balance": bal, - "starting_currency": cur, - "fee_rate": state.fee_rate, - "slippage_rate": state.slippage_rate, - })), - )) +impl Transact for Buy { + async fn transact( + &self, + account: &mut PaperAccount, + entry: CommandEntry, + ctx: &AppContext, + base: &Path, + ) -> Result { + execute_trade( + self.trade.request(OrderSide::Buy), + trade_policy(ctx)?.as_ref(), + base, + account, + entry, + &mut Venue::resolve(ctx, base)?, + ) + .await + } } -async fn execute_balance(client: &SpotClient, verbose: bool) -> Result { - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; +impl Transact for Sell { + async fn transact( + &self, + account: &mut PaperAccount, + entry: CommandEntry, + ctx: &AppContext, + base: &Path, + ) -> Result { + execute_trade( + self.trade.request(OrderSide::Sell), + trade_policy(ctx)?.as_ref(), + base, + account, + entry, + &mut Venue::resolve(ctx, base)?, + ) + .await + } +} - let mut sorted_assets: Vec<_> = state - .balances - .iter() - .filter(|(_, &v)| v.abs() > DUST_THRESHOLD) - .collect(); - sorted_assets.sort_by_key(|(a, _)| *a); +impl Transact for Orders { + async fn transact( + &self, + account: &mut PaperAccount, + entry: CommandEntry, + ctx: &AppContext, + base: &Path, + ) -> Result { + execute_orders(account, entry, &mut Venue::resolve(ctx, base)?).await + } +} - let headers = vec![ - "[PAPER] Asset".into(), - "Total".into(), - "Reserved".into(), - "Available".into(), - ]; - let rows: Vec> = sorted_assets - .iter() - .map(|(asset, &total)| { - let reserved = state.reserved.get(*asset).copied().unwrap_or(0.0); - let available = state.available_balance(asset); - vec![ - asset.to_string(), - format!("{total:.8}"), - format!("{reserved:.8}"), - format!("{available:.8}"), - ] - }) - .collect(); +impl Transact for Cancel { + async fn transact( + &self, + account: &mut PaperAccount, + entry: CommandEntry, + ctx: &AppContext, + base: &Path, + ) -> Result { + execute_cancel( + &self.order_id, + account, + entry, + &mut Venue::resolve(ctx, base)?, + None, + None, + ) + .await + } +} - let json_balances: HashMap<&str, Value> = sorted_assets - .iter() - .map(|(asset, &total)| { - let reserved = state.reserved.get(*asset).copied().unwrap_or(0.0); - let available = state.available_balance(asset); - ( - asset.as_str(), - json!({ - "total": total, - "reserved": reserved, - "available": available, - }), - ) - }) - .collect(); +impl Transact for CancelAll { + async fn transact( + &self, + account: &mut PaperAccount, + entry: CommandEntry, + ctx: &AppContext, + base: &Path, + ) -> Result { + execute_cancel_all(account, entry, &mut Venue::resolve(ctx, base)?).await + } +} - Ok(CommandOutput::new( - paper_json(json!({ "balances": json_balances })), - headers, - rows, - )) +impl Transact for History { + async fn transact( + &self, + account: &mut PaperAccount, + entry: CommandEntry, + ctx: &AppContext, + base: &Path, + ) -> Result { + execute_history(account, entry, &mut Venue::resolve(ctx, base)?).await + } } -fn validate_slippage_rate(rate: f64) -> Result<()> { - if !rate.is_finite() || !(0.0..=1.0).contains(&rate) { - return Err(KrakenError::Validation( - "Slippage rate must be between 0.0 and 1.0 (e.g., 0.001 for 0.1%)".into(), - )); +/// The audit entry for a command, prefilled from its typed args. The explicit +/// per-variant match is the structural allowlist: only these named fields can +/// ever reach the log, and free text (`--reason`) is deliberately not among +/// them. +fn entry_for(cmd: &PaperTradeCommand) -> CommandEntry { + match cmd { + PaperTradeCommand::Buy(Buy { trade }) => trade_entry(trade, OrderSide::Buy), + PaperTradeCommand::Sell(Sell { trade }) => trade_entry(trade, OrderSide::Sell), + PaperTradeCommand::Cancel(Cancel { order_id }) => CommandEntry { + name: "cancel".into(), + order_id: Some(order_id.clone()), + ..CommandEntry::default() + }, + PaperTradeCommand::CancelAll(CancelAll {}) => CommandEntry { + name: "cancel_all".into(), + ..CommandEntry::default() + }, + PaperTradeCommand::Orders(Orders {}) => CommandEntry { + name: "orders".into(), + ..CommandEntry::default() + }, + PaperTradeCommand::History(History {}) => CommandEntry { + name: "history".into(), + ..CommandEntry::default() + }, + } +} + +/// The audit entry for a buy/sell, side bound explicitly at the variant arm so +/// no future trade-like variant can inherit a guessed side. +fn trade_entry(trade: &OrderRequest, side: OrderSide) -> CommandEntry { + // `..` skips exactly one field: `reason`, the free text kept off + // the log by design. + let OrderRequest { + pair, + volume, + r#type, + price, + .. + } = trade; + CommandEntry { + name: side.to_string(), + pair: Some(pair.to_uppercase()), + side: Some(side), + volume: volume.parse().ok(), + order_type: Some(r#type.to_lowercase()), + price: price.as_deref().and_then(|p| p.parse().ok()), + ..CommandEntry::default() } - Ok(()) +} + +/// The rejection event a failed buy/sell leaves on the record, when the +/// request was well-formed enough to describe one. +fn rejection_for(cmd: &PaperTradeCommand, err: &KrakenError) -> Option { + let (trade, side) = match cmd { + PaperTradeCommand::Buy(Buy { trade }) => (trade, OrderSide::Buy), + PaperTradeCommand::Sell(Sell { trade }) => (trade, OrderSide::Sell), + _ => return None, + }; + Some(OrderRejection { + side, + pair: trade.pair.to_uppercase(), + volume: trade.volume.parse().ok()?, + price: trade.price.as_deref().and_then(|p| p.parse().ok()), + category: err.category().to_string(), + }) +} + +async fn reconcile_best_effort(account: &mut PaperAccount, client: &SpotClient) -> Vec { + match reconcile_and_persist(account, client).await { + Ok(fills) => fills, + Err(err) => { + tracing::warn!(%err, "could not sync pending orders"); + Vec::new() + } + } +} + +/// Append a read-only command's audit entry, best-effort: observation must +/// never fail the command it observes. +fn audit_read(account: &mut PaperAccount, state: &PaperState, mut entry: CommandEntry) { + entry.balances = Some(state.balances.clone()); + account.audit(&account.stamp(vec![AccountEvent::Command(entry)])); } fn validate_order_type(order_type_str: &str) -> Result { @@ -385,19 +789,154 @@ fn validate_order_type(order_type_str: &str) -> Result { } } -async fn execute_trade( +/// A spot paper order as the user expressed it on the command line, before +/// parsing or validation — one value for the `Buy`/`Sell` impls to build. +pub(crate) struct PaperOrderRequest<'a> { + side: OrderSide, + pair: &'a str, + volume: &'a str, + order_type: &'a str, + price: Option<&'a str>, + /// Rationale to record in the session's decision log, if given. + reason: Option<&'a str>, +} + +/// A free function, not `From`: both types now live in other crates, so the +/// orphan rule forbids the impl — and the mapping is command-layer policy +/// anyway (which decision a trade records is this front-end's call). +fn decision_kind(side: OrderSide) -> DecisionKind { + match side { + OrderSide::Buy => DecisionKind::Buy, + OrderSide::Sell => DecisionKind::Sell, + } +} + +/// Where a rationale lands in this scope: the ACTIVE session's own decision log +/// when one is recording — the log `read_window` joins to fills — else +/// beside the journal, disclosed as run-less rationale. Never refuses: a +/// reason always has somewhere honest to go. +pub(crate) fn reason_target(scope: &Path) -> Result { + match kraken_workspace::session::active(scope)? { + Some((session_id, _)) => Ok(kraken_session::session::session_decisions_path( + scope, + session_id.ordinal(), + )), + None => Ok(scope.join(kraken_workspace::DECISIONS_FILE)), + } +} + +/// A paper buy/sell with no session recording lands on the account but inside no +/// session window, so `run show`, `explain pnl`, and `lab score` will never +/// attribute it. Warn — a trade meant for a session must not be silently orphaned +/// onto a stopped or aborted one. Advisory only: trading the account without a +/// run is a supported flow, so this never refuses. +fn warn_when_no_active_session(scope: &Path) { + if let Ok(None) = kraken_workspace::session::active(scope) { + tracing::warn!( + "no active session; this trade is recorded on the account but not in any session window — \ + start one with 'kraken session start' to capture it" + ); + } +} + +/// Decision logging for the routed live path (the real account's scope is +/// the config root), keyed by the venue txid. +pub(crate) fn log_routed_decision( side: OrderSide, pair: &str, - volume_str: &str, - order_type_str: &str, - price_str: &Option, - client: &SpotClient, - verbose: bool, -) -> Result { - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; + order_id: &str, + reason: &str, +) -> Result<()> { + let scope = crate::config::config_dir()?; + log_decision_at(&reason_target(&scope)?, side, pair, order_id, reason) +} + +fn log_decision_at( + path: &Path, + side: OrderSide, + pair: &str, + order_id: &str, + reason: &str, +) -> Result<()> { + session::decision::append( + path, + decision_kind(side), + Some(pair.to_string()), + reason.to_string(), + Some(order_id.to_string()), + )?; + Ok(()) +} + +/// A cancel's rationale (`DecisionKind::Cancel`), keyed by the cancelled +/// order's id and pair — the analogue of [`log_decision_at`] for pulling a +/// resting order. +fn log_cancel_at(path: &Path, pair: &str, order_id: &str, reason: &str) -> Result<()> { + session::decision::append( + path, + DecisionKind::Cancel, + Some(pair.to_string()), + reason.to_string(), + Some(order_id.to_string()), + )?; + Ok(()) +} + +/// Decision logging for a cancel on the routed live path (the real account's +/// scope is the config root). A master cancel names a txid, not a pair, so the +/// symbol is unknown. +pub(crate) fn log_routed_cancel_decision(order_id: &str, reason: &str) -> Result<()> { + let scope = crate::config::config_dir()?; + session::decision::append( + &reason_target(&scope)?, + DecisionKind::Cancel, + None, + reason.to_string(), + Some(order_id.to_string()), + )?; + Ok(()) +} - let volume: f64 = volume_str +/// The typed outcome of one order placed on the engine — the shared +/// transaction result behind the paper verbs and the mode-routed order +/// verbs. The caller chooses the envelope. +pub(crate) enum PaperPlacement { + Limit { order: kraken_paper::PaperOrder }, + Market { trade: PaperTrade }, +} + +/// Decide, commit, and decision-log one order against an open account: the +/// engine transaction both trading surfaces share. Rendering stays with the +/// caller, so the paper verbs keep their receipts byte-identical while the +/// routed verbs wrap the same facts in the unified envelope. +pub(crate) async fn place_order_on_account( + request: PaperOrderRequest<'_>, + policy: Option<&kraken_workspace::WorkspaceManifest>, + decisions: Option<&Path>, + account: &mut PaperAccount, + mut entry: CommandEntry, + venue: &mut Venue<'_>, +) -> Result { + let PaperOrderRequest { + side, + pair, + volume: volume_str, + order_type: order_type_str, + price: price_str, + reason, + } = request; + + // Account permission policy: a denied pair fails before any + // pricing or engine work, in the engine's own pair grammar so the check + // can never disagree with fills. + if let Some(manifest) = policy { + let (_, base_asset, quote) = parse_pair(pair)?; + kraken_workspace::policy::ensure_pair_allowed(manifest, &format!("{base_asset}/{quote}"))?; + } + + venue.reconcile(account).await?; + + let volume: Decimal = volume_str .parse() .map_err(|_| KrakenError::Validation(format!("Invalid volume: {volume_str}")))?; @@ -405,78 +944,154 @@ async fn execute_trade( if is_limit { let price_val = price_str - .as_ref() .ok_or_else(|| KrakenError::Validation("Limit orders require --price".into()))?; - let price: f64 = price_val + let price: Decimal = price_val .parse() .map_err(|_| KrakenError::Validation(format!("Invalid price: {price_val}")))?; - let order_id = state.place_limit_order(side, pair, volume, price)?; - save_state(&state)?; - - let (normalized, _, _) = parse_pair(pair)?; - let pairs = vec![ - ("Mode".into(), "[PAPER] Simulated Trading".into()), - ("Action".into(), format!("Limit {side} order placed")), - ("Order ID".into(), order_id.clone()), - ("Pair".into(), normalized), - ("Volume".into(), format!("{volume:.8}")), - ("Price".into(), format!("{price:.2}")), - ]; - - Ok(CommandOutput::key_value( - pairs, - paper_json(json!({ - "action": "limit_order_placed", - "order_id": order_id, - "side": format!("{side}").to_lowercase(), - "pair": pair.to_uppercase(), - "volume": volume, - "price": price, - })), - )) + let order = account + .state()? + .decide_limit_order(side, pair, volume, price)?; + let order_id = order.id.clone(); + entry.outcome = CommandOutcome::Ok { + order_ids: vec![order_id.clone()], + trade_ids: Vec::new(), + }; + account.commit( + vec![AccountEvent::OrderSubmitted { + order: order.clone(), + }], + entry, + )?; + + if let (Some(path), Some(reason)) = (decisions, reason) { + // Best-effort: the order is already durably logged, and reporting + // a placed order as an error would invite a duplicate retry. + if let Err(e) = log_decision_at(path, side, pair, &order_id, reason) { + tracing::warn!(order_id = %order_id, error = %e, "limit order placed but decision-log append failed"); + } + } + Ok(PaperPlacement::Limit { order }) } else { let (normalized, _, _) = parse_pair(pair)?; - let (ask, bid) = fetch_ticker_price(client, &normalized, verbose).await?; - let trade = state.place_market_order(side, pair, volume, ask, bid)?; - save_state(&state)?; - - let pairs = vec![ - ("Mode".into(), "[PAPER] Simulated Trading".into()), - ("Action".into(), format!("Market {side} executed")), - ("Trade ID".into(), trade.id.clone()), - ("Pair".into(), trade.pair.clone()), - ("Volume".into(), format!("{:.8}", trade.volume)), - ("Price".into(), format!("{:.2}", trade.price)), - ("Fee".into(), format!("{:.4}", trade.fee)), - ("Cost".into(), format!("{:.2}", trade.cost)), - ]; - - Ok(CommandOutput::key_value( - pairs, - paper_json(json!({ - "action": "market_order_filled", - "trade_id": trade.id, - "order_id": trade.order_id, - "side": format!("{side}").to_lowercase(), - "pair": trade.pair, - "volume": trade.volume, - "price": trade.price, - "fee": trade.fee, - "cost": trade.cost, - })), - )) - } -} - -async fn execute_orders(client: &SpotClient, verbose: bool) -> Result { - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; + let (ask, bid) = venue.ticker_price(&normalized).await?; + let trade = account + .state()? + .decide_market_order(side, pair, volume, ask, bid)?; + entry.outcome = CommandOutcome::Ok { + order_ids: vec![trade.order_id.clone()], + trade_ids: vec![trade.id.clone()], + }; + account.commit( + vec![AccountEvent::OrderFilled { + trade: trade.clone(), + }], + entry, + )?; + + if let (Some(path), Some(reason)) = (decisions, reason) { + // Best-effort: the fill is already durably logged, and reporting + // a placed order as an error would invite a duplicate retry. + if let Err(e) = log_decision_at(path, side, pair, &trade.order_id, reason) { + tracing::warn!(order_id = %trade.order_id, error = %e, "fill logged but decision-log append failed"); + } + } + Ok(PaperPlacement::Market { trade }) + } +} + +async fn execute_trade( + request: PaperOrderRequest<'_>, + policy: Option<&kraken_workspace::WorkspaceManifest>, + base: &Path, + account: &mut PaperAccount, + entry: CommandEntry, + venue: &mut Venue<'_>, +) -> Result { + let side = request.side; + warn_when_no_active_session(base); + // Resolve the decision target up front, so a failing run probe surfaces + // before a trade executes whose rationale would then be orphaned. + let decisions = request.reason.map(|_| reason_target(base)).transpose()?; + let placement = + place_order_on_account(request, policy, decisions.as_deref(), account, entry, venue) + .await?; + let mut out = match placement { + PaperPlacement::Limit { order } => { + let pairs = vec![ + ("Mode".into(), "[PAPER] Simulated Trading".into()), + ("Action".into(), format!("Limit {side} order placed")), + ("Order ID".into(), order.id.clone()), + ("Pair".into(), order.pair.clone()), + ("Volume".into(), format!("{:.8}", rounded(order.volume, 8))), + ("Price".into(), format!("{:.2}", rounded(order.price, 2))), + ]; + CommandOutput::key_value( + pairs, + paper_json(json!({ + "action": "limit_order_placed", + "order_id": order.id, + "side": side, + "pair": order.pair, + "volume": order.volume, + "price": order.price, + })), + ) + } + PaperPlacement::Market { trade } => { + let pairs = vec![ + ("Mode".into(), "[PAPER] Simulated Trading".into()), + ("Action".into(), format!("Market {side} executed")), + ("Trade ID".into(), trade.id.clone()), + ("Pair".into(), trade.pair.clone()), + ("Volume".into(), format!("{:.8}", rounded(trade.volume, 8))), + ("Price".into(), format!("{:.2}", rounded(trade.price, 2))), + ("Fee".into(), format!("{:.4}", rounded(trade.fee, 4))), + ("Cost".into(), format!("{:.2}", rounded(trade.cost, 2))), + ]; + CommandOutput::key_value( + pairs, + paper_json(json!({ + "action": "market_order_filled", + "trade_id": trade.id, + "order_id": trade.order_id, + "side": side, + "pair": trade.pair, + "volume": trade.volume, + "price": trade.price, + "fee": trade.fee, + "cost": trade.cost, + })), + ) + } + }; + // Show where the fill landed — the same Run/Workspace rows `explain pnl` + // stamps: the active session (or `none` when the trade is outside any session), + // and the workspace account it hit. + let run = match kraken_workspace::session::active(base) { + Ok(Some((session_id, _))) => session_id.to_string(), + _ => "none".to_string(), + }; + out.stamp_session(&run); + if let Some(manifest) = policy { + out.stamp_workspace(&manifest.name); + } + Ok(out) +} + +async fn execute_orders( + account: &mut PaperAccount, + entry: CommandEntry, + venue: &mut Venue<'_>, +) -> Result { + venue.reconcile(account).await?; + let state = account.state()?.clone(); + audit_read(account, &state, entry); let headers = vec![ "[PAPER] Order ID".into(), "Pair".into(), - "Side".into(), + "OrderSide".into(), "Type".into(), "Volume".into(), "Price".into(), @@ -505,10 +1120,10 @@ async fn execute_orders(client: &SpotClient, verbose: bool) -> Result Result Result, + decisions: Option<&Path>, + reason: Option<&str>, ) -> Result { - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; - - let order = state.cancel_order(order_id)?; - save_state(&state)?; + venue.reconcile(account).await?; + let order = account.state()?.decide_cancel(order_id)?; + entry.outcome = CommandOutcome::Ok { + order_ids: vec![order.id.clone()], + trade_ids: Vec::new(), + }; + account.commit( + vec![AccountEvent::OrderCancelled { + order: order.clone(), + }], + entry, + )?; + + if let (Some(path), Some(reason)) = (decisions, reason) { + // Best-effort, matching the order path: the cancel is already durably + // committed, and reporting it as an error would invite a retry. + if let Err(e) = log_cancel_at(path, &order.pair, &order.id, reason) { + tracing::warn!(order_id = %order.id, error = %e, "order cancelled but decision-log append failed"); + } + } let pairs = vec![ ("Mode".into(), "[PAPER] Simulated Trading".into()), ("Action".into(), "Order cancelled".into()), ("Order ID".into(), order.id.clone()), ("Pair".into(), order.pair.clone()), - ("Side".into(), format!("{}", order.side)), + ("OrderSide".into(), format!("{}", order.side)), ( "Released".into(), - format!("{:.8} {}", order.reserved_amount, order.reserved_asset), + format!( + "{:.8} {}", + rounded(order.reserved_amount, 8), + order.reserved_asset + ), ), ]; @@ -571,19 +1208,31 @@ async fn execute_cancel( "action": "order_cancelled", "order_id": order.id, "pair": order.pair, - "side": format!("{}", order.side).to_lowercase(), + "side": order.side, "released_amount": order.reserved_amount, "released_asset": order.reserved_asset, })), )) } -async fn execute_cancel_all(client: &SpotClient, verbose: bool) -> Result { - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; - - let cancelled = state.cancel_all_orders(); - save_state(&state)?; +async fn execute_cancel_all( + account: &mut PaperAccount, + mut entry: CommandEntry, + venue: &mut Venue<'_>, +) -> Result { + venue.reconcile(account).await?; + let cancelled = account.state()?.open_orders.clone(); + entry.outcome = CommandOutcome::Ok { + order_ids: cancelled.iter().map(|o| o.id.clone()).collect(), + trade_ids: Vec::new(), + }; + let events = cancelled + .iter() + .map(|order| AccountEvent::OrderCancelled { + order: order.clone(), + }) + .collect(); + account.commit(events, entry)?; let count = cancelled.len(); let pairs = vec![ @@ -601,16 +1250,21 @@ async fn execute_cancel_all(client: &SpotClient, verbose: bool) -> Result Result { - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; +async fn execute_history( + account: &mut PaperAccount, + entry: CommandEntry, + venue: &mut Venue<'_>, +) -> Result { + venue.reconcile(account).await?; + let state = account.state()?.clone(); + audit_read(account, &state, entry); let headers = vec![ "[PAPER] ID".into(), "Order ID".into(), "Status".into(), "Pair".into(), - "Side".into(), + "OrderSide".into(), "Volume".into(), "Price".into(), "Fee".into(), @@ -630,11 +1284,11 @@ async fn execute_history(client: &SpotClient, verbose: bool) -> Result Result Result Result Result Result { - let mut state = load_state()?; - reconcile_best_effort(&mut state, client, verbose).await; +pub(crate) async fn execute_status( + account: &mut PaperAccount, + entry: CommandEntry, + venue: &mut Venue<'_>, +) -> Result { + venue.reconcile(account).await?; + let state = account.state()?.clone(); + audit_read(account, &state, entry); let sc = state.starting_currency.clone(); - - let val_pairs: Vec = state - .balances - .iter() - .filter(|(a, &amt)| *a != &sc && amt > DUST_THRESHOLD) - .map(|(a, _)| format!("{a}{sc}")) - .collect(); - - let (portfolio_value, valuation_complete) = if val_pairs.is_empty() { - (state.balances.get(&sc).copied().unwrap_or(0.0), true) - } else { - match fetch_ticker_prices(client, &val_pairs, verbose).await { - Ok(prices) => state.compute_portfolio_value(&prices), - Err(err) => { - output::warn(&format!("Could not fetch prices for valuation: {err}")); - (state.balances.get(&sc).copied().unwrap_or(0.0), false) - } - } - }; + let (portfolio_value, valuation_complete) = venue.value(&state).await; let pnl = portfolio_value - state.starting_balance; - let pnl_pct = if state.starting_balance > 0.0 { - (pnl / state.starting_balance) * 100.0 + let pnl_pct = if state.starting_balance > Decimal::ZERO { + (pnl / state.starting_balance) * dec!(100) } else { - 0.0 + Decimal::ZERO }; let partial_marker = if valuation_complete { "" } else { " (partial)" }; @@ -753,20 +1394,27 @@ async fn execute_status(client: &SpotClient, verbose: bool) -> Result Result DUST_THRESHOLD) + .filter(|&(_, &v)| v > BALANCE_DUST) .count() .to_string(), ), @@ -798,6 +1446,36 @@ async fn execute_status(client: &SpotClient, verbose: bool) -> Result (Decimal, bool) { + let sc = &state.starting_currency; + let val_pairs: Vec = state + .balances + .iter() + .filter(|&(a, &amt)| a != sc && amt > BALANCE_DUST) + .map(|(a, _)| format!("{a}{sc}")) + .collect(); + if val_pairs.is_empty() { + return ( + state.balances.get(sc).copied().unwrap_or(Decimal::ZERO), + true, + ); + } + match fetch_ticker_prices(client, &val_pairs).await { + Ok(prices) => state.compute_portfolio_value(&prices), + Err(err) => { + tracing::warn!(%err, "could not fetch prices for valuation"); + ( + state.balances.get(sc).copied().unwrap_or(Decimal::ZERO), + false, + ) + } + } +} + fn paper_json(mut data: Value) -> Value { if let Some(obj) = data.as_object_mut() { obj.insert("mode".to_string(), json!("paper")); @@ -805,10 +1483,8 @@ fn paper_json(mut data: Value) -> Value { data } -async fn fetch_ticker_price(client: &SpotClient, pair: &str, verbose: bool) -> Result<(f64, f64)> { - let data = client - .public_get("Ticker", &[("pair", pair)], verbose) - .await?; +async fn fetch_ticker_price(client: &SpotClient, pair: &str) -> Result<(Decimal, Decimal)> { + let data = client.public_get("Ticker", &[("pair", pair)]).await?; let obj = data .as_object() .ok_or_else(|| KrakenError::Parse(format!("Unexpected ticker response for {pair}")))?; @@ -821,33 +1497,32 @@ async fn fetch_ticker_price(client: &SpotClient, pair: &str, verbose: bool) -> R Ok((ask, bid)) } -fn extract_price(val: &Value, field: &str, pair: &str) -> Result { +fn extract_price(val: &Value, field: &str, pair: &str) -> Result { val.get(field) .and_then(|v| v.as_array()) .and_then(|arr| arr.first()) .and_then(|v| v.as_str()) .ok_or_else(|| KrakenError::Parse(format!("Missing {field} price in ticker for {pair}")))? - .parse::() + .parse::() .map_err(|e| KrakenError::Parse(format!("Invalid {field} price in ticker for {pair}: {e}"))) } async fn fetch_ticker_prices( client: &SpotClient, pairs: &[String], - verbose: bool, -) -> Result> { +) -> Result> { if pairs.is_empty() { return Ok(HashMap::new()); } let pair_param = pairs.join(","); let data = client - .public_get("Ticker", &[("pair", pair_param.as_str())], verbose) + .public_get("Ticker", &[("pair", pair_param.as_str())]) .await?; let obj = data .as_object() .ok_or_else(|| KrakenError::Parse("Unexpected ticker response format".into()))?; - let mut response_prices: Vec<(String, String, f64, f64)> = Vec::new(); + let mut response_prices: Vec<(String, String, Decimal, Decimal)> = Vec::new(); for (key, val) in obj { let ask = extract_price(val, "a", key)?; let bid = extract_price(val, "b", key)?; @@ -874,28 +1549,43 @@ async fn fetch_ticker_prices( } async fn reconcile_and_persist( - state: &mut PaperState, + account: &mut PaperAccount, client: &SpotClient, - verbose: bool, ) -> Result> { - if state.open_orders.is_empty() { + // Nothing to reconcile before an epoch — and "not initialized" belongs to + // the handler's state gate, not to a best-effort sync warning. + if !account.is_initialized() { + return Ok(Vec::new()); + } + let draft = account.state()?; + if draft.open_orders.is_empty() { return Ok(Vec::new()); } - let unique_pairs: Vec = state + let unique_pairs: Vec = draft .open_orders .iter() .map(|o| o.pair.clone()) .collect::>() .into_iter() .collect(); - let prices = fetch_ticker_prices(client, &unique_pairs, verbose).await?; - let fills = state.check_pending_orders(&prices); - if !fills.is_empty() { - save_state(state)?; - if verbose { - output::verbose(&format!("{} limit order(s) filled", fills.len())); - } + let prices = fetch_ticker_prices(client, &unique_pairs).await?; + let fills = draft.decide_pending_fills(&prices); + if fills.is_empty() { + return Ok(fills); } + // Log before folding: a fill enters the account's state only once it is + // durable (append advances the projection after the fsync). On append + // failure the orders simply stay open and reconcile retries later. + let records = account.stamp( + fills + .iter() + .map(|trade| AccountEvent::OrderFilled { + trade: trade.clone(), + }) + .collect(), + ); + account.append(&records)?; + tracing::debug!(filled = fills.len(), "limit orders filled"); Ok(fills) } @@ -911,8 +1601,8 @@ mod tests { }); let ask = extract_price(&val, "a", "BTCUSD").unwrap(); let bid = extract_price(&val, "b", "BTCUSD").unwrap(); - assert!((ask - 50000.0).abs() < 1e-10); - assert!((bid - 49999.0).abs() < 1e-10); + assert!((ask - dec!(50000.0)).abs() < dec!(0.0000000001)); + assert!((bid - dec!(49999.0)).abs() < dec!(0.0000000001)); } #[test] @@ -973,29 +1663,51 @@ mod tests { } #[test] - fn test_slippage_validation_negative() { - assert!(validate_slippage_rate(-0.001).is_err()); - } - - #[test] - fn test_slippage_validation_above_one() { - assert!(validate_slippage_rate(1.001).is_err()); - } - - #[test] - fn test_slippage_validation_nan() { - assert!(validate_slippage_rate(f64::NAN).is_err()); - } - - #[test] - fn test_slippage_validation_infinity() { - assert!(validate_slippage_rate(f64::INFINITY).is_err()); + fn non_finite_args_never_reach_the_log() { + // "inf"/"NaN" were representable as f64 and needed an explicit gate; + // `Decimal` refuses them at parse time, so the log stays loadable by + // construction. + assert!("inf".parse::().is_err()); + assert!("NaN".parse::().is_err()); + assert_eq!("0.5".parse::().ok(), Some(dec!(0.5))); + + let cmd = PaperTradeCommand::Buy(Buy { + trade: OrderRequest { + pair: "BTCUSD".into(), + volume: "inf".into(), + r#type: "market".into(), + price: None, + reason: None, + }, + }); + let err = KrakenError::Validation("volume".into()); + assert!(rejection_for(&cmd, &err).is_none()); + assert_eq!(entry_for(&cmd).volume, None); } #[test] - fn test_slippage_validation_zero_and_one_are_valid() { - assert!(validate_slippage_rate(0.0).is_ok()); - assert!(validate_slippage_rate(1.0).is_ok()); + fn audit_entry_side_matches_the_variant() { + // Regression: the side was once inferred as "sell" for any non-Buy + // variant; it is now bound at each variant arm. The lowercase tokens + // are the journal contract. + let trade = || OrderRequest { + pair: "BTCUSD".into(), + volume: "0.5".into(), + r#type: "market".into(), + price: None, + reason: None, + }; + let buy = entry_for(&PaperTradeCommand::Buy(Buy { trade: trade() })); + assert_eq!(buy.side, Some(OrderSide::Buy)); + assert_eq!(buy.name, "buy"); + let sell = entry_for(&PaperTradeCommand::Sell(Sell { trade: trade() })); + assert_eq!(sell.side, Some(OrderSide::Sell)); + assert_eq!(sell.name, "sell"); + + let err = KrakenError::Validation("x".into()); + let rejection = rejection_for(&PaperTradeCommand::Sell(Sell { trade: trade() }), &err) + .expect("well-formed sell describes a rejection"); + assert_eq!(rejection.side, OrderSide::Sell); } #[test] diff --git a/src/commands/playground.rs b/src/commands/playground.rs new file mode 100644 index 0000000..f3808c6 --- /dev/null +++ b/src/commands/playground.rs @@ -0,0 +1,105 @@ +//! One-command demo composed from ordinary workspaces and sessions. +//! +//! Reuse never re-funds the paper workspace, and every created artifact remains +//! manageable through the standard command surface. + +use kraken_paper::account::Origin; +use kraken_workspace::{CreateSpec, WorkspaceError, WorkspaceMode, Workspaces}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +use super::Execute; +use crate::cli::AppContext; +use crate::config; +use crate::errors::{KrakenError, Result}; +use crate::output::CommandOutput; + +/// The demo workspace's fixed identity: one well-known name, paper, seeded +/// once. Reuse never re-funds — the account's history is the demo's value. +const PLAYGROUND_WORKSPACE: &str = "playground"; +/// The demo account's one-time seed, in [`PLAYGROUND_CURRENCY`]. +const PLAYGROUND_CAPITAL: Decimal = dec!(10_000); +const PLAYGROUND_CURRENCY: &str = "USD"; + +#[derive(Debug, clap::Args)] +pub(crate) struct PlaygroundCommand { + /// Comma-separated trading pairs to record and demo against. + #[arg(long, value_delimiter = ',', default_value = "BTC/USD")] + symbols: Vec, + /// The template (recipe skill) driving the demo; recorded as the session's + /// strategy label so scoring can attribute it. + #[arg(long, default_value = "recipe-playground-dca")] + template: String, + /// How long the demo session records before closing itself. + #[arg(long = "for", default_value = "1h")] + window: String, +} + +impl Execute for PlaygroundCommand { + async fn execute(self, _ctx: &AppContext) -> Result { + // `playground` streams (it is a session start); `dispatch` intercepts it, + // so only the render-free executor (the MCP path) reaches this. + Err(super::streaming::streaming_unsupported()) + } +} + +/// Provision-or-reuse the demo workspace, say how to watch, and start the +/// demo session in it. Composes existing seams only: no new state, no engine. +pub(crate) async fn demo(args: &PlaygroundCommand, ctx: &AppContext) -> Result<()> { + let name: kraken_workspace::WorkspaceName = PLAYGROUND_WORKSPACE.parse()?; + let workspaces = Workspaces::new(config::config_dir()?); + match workspaces.manifest(&name) { + // Reuse as-is: never re-fund an account that exists. Only a paper + // account qualifies — a live manifest under the demo's name (contract- + // accepted by the manifest but not executable today) must refuse at + // provision, not surface later as a session error. + Ok(manifest) if manifest.mode == WorkspaceMode::Paper => {} + Ok(manifest) => { + return Err(KrakenError::Validation(format!( + "the '{PLAYGROUND_WORKSPACE}' workspace exists in {} mode; the demo \ + trades simulated fills only — remove or rename workspaces/{PLAYGROUND_WORKSPACE} to run it", + manifest.mode + ))); + } + Err(WorkspaceError::NotFound(_)) => { + workspaces.create( + CreateSpec { + name: name.clone(), + capital: PLAYGROUND_CAPITAL, + currency: PLAYGROUND_CURRENCY.to_string(), + mode: WorkspaceMode::Paper, + fee_rate: kraken_paper::DEFAULT_FEE_RATE, + slippage_rate: kraken_paper::DEFAULT_SLIPPAGE_RATE, + allowed_pairs: None, + }, + Origin::from_mcp_mode(ctx.mcp_mode), + )?; + eprintln!( + "playground workspace created: {PLAYGROUND_CAPITAL} {PLAYGROUND_CURRENCY} paper capital" + ); + } + Err(err) => return Err(err.into()), + } + + // The demo's guidance goes to stderr — stdout carries the session's own + // machine-parseable stream: the lifecycle lines (session_started / + // session_stopped) and the live tape echo between them. The trade example + // names the first recorded symbol: a fill on an unrecorded pair would + // leave the window without marks to score it by. + let symbol = args.symbols.first().map_or("BTC/USD", String::as_str); + eprintln!("watch it: export KRAKEN_WORKSPACE={PLAYGROUND_WORKSPACE}"); + eprintln!(" kraken session show # window, status, valuation"); + eprintln!(" kraken workspace status # the account itself"); + eprintln!("trade it: kraken order buy {symbol} 0.001 --type market --reason \"...\""); + eprintln!("stop early: kraken session stop"); + + let start = super::session::Start::demo( + args.symbols.clone(), + args.template.clone(), + args.window.clone(), + ); + // The demo threads its workspace explicitly — no env required — by + // rescoping the context exactly like a `--workspace playground` line. + let scoped = ctx.rescope(Some(PLAYGROUND_WORKSPACE.to_string())); + super::session::start(&start, &scoped).await +} diff --git a/src/commands/record.rs b/src/commands/record.rs new file mode 100644 index 0000000..eca24af --- /dev/null +++ b/src/commands/record.rs @@ -0,0 +1,255 @@ +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use clap::Args; +use itertools::Itertools; +use kraken_core::{SubscribableChannel, WsSubscription}; +use kraken_recording::RecordingDeclaration; +use kraken_recording::schema; + +use crate::cli::AppContext; +use crate::commands::subscription::{ + CHANNEL_OPTIONS_NOTE, SubscriptionOptions, plan_subscriptions, +}; +use crate::config; +use crate::errors::Result; +use crate::record::{RecordFormat, SinkTarget, resolve_targets}; +use crate::sink::fanout::Fanout; + +#[derive(Debug, Args)] +#[command(after_help = CHANNEL_OPTIONS_NOTE)] +pub(crate) struct RecordCommand { + /// Comma-separated trading pairs to record (e.g. BTC/USD,ETH/USD); repeatable. + #[arg(long, value_delimiter = ',')] + pub(crate) symbols: Vec, + /// Comma-separated channels to capture (e.g. trades,book,ohlc); repeatable. + #[arg(long, value_delimiter = ',', ignore_case = true)] + pub(crate) channels: Vec, + /// Per-channel subscribe options, shared with `kraken streamd`. + #[command(flatten)] + pub(crate) options: SubscriptionOptions, + /// Sink targets: any combination of duckdb, jsonl, stdout (comma-separated). + /// + /// `duckdb`/`jsonl` always write their native on-disk format; `-o`/`--output` + /// (table|json) controls only the `stdout` echo, never the durable backends. + #[arg(long, value_enum, value_delimiter = ',', default_value = "duckdb")] + pub(crate) to: Vec, + /// Tape name — names the single tape file all symbols are recorded into. + /// (`--dataset` remains as a compatibility alias.) + #[arg(long, alias = "dataset", default_value = "default")] + pub(crate) tape: String, +} + +/// Plan what record captures: market-data channels only. Any channel the recording +/// schema can't store (e.g. the `executions`/`balances` account feeds) is rejected up +/// front — before planning a subscription or opening an authenticated socket. +fn plan(cmd: &RecordCommand) -> Result> { + kraken_recording::ensure_recordable(&cmd.channels)?; + plan_subscriptions(&cmd.symbols, &cmd.channels, &cmd.options) +} + +/// Run `kraken record`: plan the subscriptions, open the tape up front, then stream +/// through the shared [`stream::run`](crate::stream::run) driver into the durable +/// [`Sink`](kraken_recording::Sink) backend. The same `plan → build sink → +/// stream` flow `streamd` takes — record only swaps the stdout sink for a persisting +/// one, routing lifecycle diagnostics to stderr so stdout stays clean. Socket drops are +/// retried by the engine indefinitely (paced backoff) — the session ends on shutdown, a fatal +/// sink error, or every subscription being rejected; the result folds that with the +/// sink's finalize (stamp/checkpoint) outcome. +pub(crate) async fn execute(cmd: &RecordCommand, ctx: &AppContext) -> Result<()> { + // Derive the tape metadata from the planned subscriptions, then stream them. + let ws_subscriptions = plan(cmd)?; + + // Market memory is global (never workspace-scoped): every tape recorded + // anywhere is available to every workspace's lab, so the library compounds. + let base = config::config_dir()?; + // Reduce the name to one safe path segment so it cannot escape the tape library. + let tape_id = kraken_recording::sanitize_id(&cmd.tape); + let meta = RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: ws_subscriptions + .iter() + .flat_map(|sub| sub.symbols().iter().cloned()) + .unique() + .collect(), + channels: ws_subscriptions + .iter() + .map(|sub| sub.channel().to_string()) + .unique() + .collect(), + window_start: Utc::now(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + }; + + // Open every requested sink up front (a busy tape or incompatible schema fails here, + // before any socket opens), then stream through the shared `stream::run` driver into the + // fan-out, which persists to each durable backend and best-effort echoes to stdout. + let plan = resolve_targets(&cmd.to); + let tapes: Vec = plan + .durables + .iter() + .map(|backend| kraken_recording::resolve(&base, &tape_id, backend.extension())) + .collect(); + let fanout = Fanout::build(&plan, &meta, ctx.format, |backend| { + kraken_recording::resolve(&base, &tape_id, backend.extension()) + })?; + + // Every sink is open now (locks acquired, schemas ensured), so announce capture start on + // stderr — one line per durable backend — before the socket opens. It never prints for a + // run that failed to open; stdout stays reserved for the data itself. + for (backend, path) in plan.durables.iter().zip(&tapes) { + eprintln!("{}", started_line(path, &meta, *backend)); + } + + let result = crate::stream::run(ws_subscriptions, None, None, fanout, None, ctx).await; + + // Only a session that actually finalized gets the summary: a failed session's stderr must read + // started → error, not started → summary, or a wrapper tailing the lifecycle records a + // completed capture for a recording that never streamed. + if result.is_ok() { + // The captured-frame count (identical across mirrored backends, so read + // it from the first) turns a quiet-market tape from a silent unknown + // into an honest "0 events". + let events = tapes + .first() + .and_then(|path| kraken_recording::frame_count(path)); + eprintln!("{}", summary_line(&tapes, events)); + } + + result +} + +/// A one-line `record` notice for stderr announcing capture has begun — emitted once the +/// tape is open (lock acquired, schema ensured) and streaming is about to start, so it +/// never prints for a session that failed to open. Mirrors [`summary_line`]; stdout stays data. +fn started_line(tape: &Path, meta: &RecordingDeclaration, format: RecordFormat) -> String { + serde_json::json!({ + "event": "record", + "type": "started", + "ts": Utc::now(), + "tape": tape.display().to_string(), + "format": format.extension(), + "symbols": meta.symbols, + "channels": meta.channels, + }) + .to_string() +} + +/// A one-line `record` summary for stderr naming where the capture landed — stdout is +/// reserved for the data itself. +fn summary_line(tapes: &[PathBuf], events: Option) -> String { + let tapes: Vec = tapes.iter().map(|p| p.display().to_string()).collect(); + serde_json::json!({ + "event": "record", + "type": "summary", + "ts": Utc::now(), + "tapes": tapes, + "events": events, + }) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::KrakenError; + + fn args(symbols: &[&str], channels: &[SubscribableChannel]) -> RecordCommand { + RecordCommand { + symbols: symbols.iter().map(|s| (*s).to_string()).collect(), + channels: channels.to_vec(), + options: SubscriptionOptions::default(), + to: vec![SinkTarget::Jsonl], + tape: "ds".into(), + } + } + + /// Resolve a record command into the subscriptions it streams, mirroring streamd. + fn subscriptions(cmd: &RecordCommand) -> Vec { + plan(cmd).expect("expected a streaming plan") + } + + #[test] + fn started_line_names_the_tape_and_what_is_recorded() { + let meta = RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".into(), "ETH/USD".into()], + channels: vec!["ticker".into()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + }; + let line = started_line(Path::new("/data/test.duckdb"), &meta, RecordFormat::Duckdb); + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["event"], "record"); + assert_eq!(v["type"], "started"); + assert_eq!(v["tape"], "/data/test.duckdb"); + assert_eq!(v["format"], "duckdb"); + assert_eq!(v["symbols"], serde_json::json!(["BTC/USD", "ETH/USD"])); + assert_eq!(v["channels"], serde_json::json!(["ticker"])); + } + + #[test] + fn dedupes_repeated_channels() { + let subs = subscriptions(&args( + &["BTC/USD"], + &[SubscribableChannel::Trade, SubscribableChannel::Trade], + )); + assert_eq!(subs.len(), 1); + } + + #[test] + fn rejects_empty_channels_and_symbolless_market_channel() { + assert!(matches!( + plan(&args(&["BTC/USD"], &[])).unwrap_err(), + KrakenError::Validation(_) + )); + assert!(matches!( + plan(&args(&[], &[SubscribableChannel::Trade])).unwrap_err(), + KrakenError::Validation(_) + )); + } + + #[test] + fn tape_name_is_sanitized_to_one_segment() { + // `--tape` names one file; a traversal attempt is reduced to a single safe + // path segment so it cannot escape the tape library. + assert!(!kraken_recording::sanitize_id("../../etc/evil").contains('/')); + } + + /// Full capture pipeline without a socket: a typed frame -> Sink::record -> + /// DuckdbSink -> read back. Proves the sink path reaches DuckDB. + #[cfg(feature = "record-duckdb")] + #[test] + fn capture_pipeline_persists_typed_frames() { + use kraken_core::ChannelMessage; + use kraken_recording::DuckdbSink; + use kraken_recording::Sink; + use kraken_recording::frames::duckdb::Db; + use kraken_recording::{CaptureSink, RecordingDeclaration, RecordingIntegrity}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("recordings").join("BTC-USD.duckdb"); + let meta = RecordingDeclaration { + source: schema::SOURCE.to_string(), + symbols: vec!["BTC/USD".to_string()], + channels: vec!["trades".to_string()], + window_start: Utc::now(), + cli_version: "test".to_string(), + }; + + let mut sink = DuckdbSink::open(&path, &meta).unwrap(); + + let frame = r#"{"channel":"trade","type":"update","data":[{"symbol":"BTC/USD","side":"sell","price":42.5,"qty":1.25,"ord_type":"limit","trade_id":99,"timestamp":"2026-01-01T00:00:00.000000Z"}]}"#; + let data = ChannelMessage::parse(frame).expect("trade frame parses"); + sink.record(&[data]).unwrap(); + sink.finalize(RecordingIntegrity::now()).unwrap(); + + let db = Db::open(&path).unwrap(); + assert_eq!(db.scalar_u64("SELECT count(*) FROM trades").unwrap(), 1); + assert_eq!( + db.scalar_u64("SELECT count(*) FROM trades WHERE qty = 1.25") + .unwrap(), + 1 + ); + } +} diff --git a/src/commands/replay.rs b/src/commands/replay.rs new file mode 100644 index 0000000..a28df30 --- /dev/null +++ b/src/commands/replay.rs @@ -0,0 +1,247 @@ +//! Command adapter for [`kraken_replay::replay_events`]. + +use chrono::Utc; +use kraken_recording::CaptureState; +use kraken_replay::{MAX_SPEED, MIN_SPEED, PlaybackClock, replay_events}; +use kraken_session::timeline::SessionTimeline; +use kraken_session::timeline::event::TimelineEvent; + +use crate::cli::AppContext; +use crate::errors::Result; +use crate::output::summary::Summarize; +use crate::output::{self, OutputFormat}; +use crate::stream; + +#[derive(Debug, clap::Args)] +pub(crate) struct ReplayCommand { + /// Which session: `latest`, an ordinal (`s3`), or a label. + #[arg(long, default_value = "latest")] + session: String, + /// Playback speed multiplier: 1 = real time (recorded gaps reproduced), + /// 2 = twice as fast, 0.5 = half speed. Accepts 0.01 to 1000. + #[arg(long, default_value_t = 1.0, value_parser = parse_speed)] + speed: f64, +} + +fn parse_speed(raw: &str) -> std::result::Result { + let speed: f64 = raw + .parse() + .map_err(|_| format!("'{raw}' is not a number"))?; + if !speed.is_finite() || !(MIN_SPEED..=MAX_SPEED).contains(&speed) { + return Err(format!("speed must be within {MIN_SPEED} to {MAX_SPEED}")); + } + Ok(speed) +} + +pub(crate) async fn execute(cmd: &ReplayCommand, ctx: &AppContext) -> Result<()> { + super::session::ensure_session_replayable(ctx, &cmd.session)?; + let (session_id, session) = super::session::read_session_window(ctx, &cmd.session)?; + eprintln!( + "{}", + started_line(&session_id.to_string(), cmd.speed, &session) + ); + + let mut emitted: u64 = 0; + let result = match session.events.first() { + // A provisioned session with nothing recorded yet replays as an + // empty stream, not an error — started/summary still bracket it. + None => Ok(()), + Some(first) => { + let clock = PlaybackClock::anchored(first.at, cmd.speed); + // Deliberately held across the pacing sleeps: stdout belongs to + // the replay for the whole playback (an interleaved writer would + // corrupt the NDJSON), and the engine's only await is the sleep, + // so cancellation can only release it at a line boundary. + let mut out = std::io::stdout().lock(); + let render = |event: &TimelineEvent| render_line(ctx.format, event); + tokio::select! { + result = replay_events(&session.events, clock, &mut out, render, &mut emitted) => { + result.map_err(Into::into) + } + () = stream::shutdown_signal() => Ok(()), + } + } + }; + eprintln!("{}", summary_line(emitted)); + result +} + +/// Render one event for `format`: JSON is the timeline's pinned wire shape +/// emitted verbatim; table one `stream_line` per event, mirroring +/// [`render_data`](crate::sink::stdout) for live streams. Pure — the +/// playback engine owns writing and flushing. +fn render_line( + format: OutputFormat, + event: &TimelineEvent, +) -> std::result::Result { + match format { + OutputFormat::Json => serde_json::to_string(event), + OutputFormat::Table => { + let at = kraken_recording::format_instant(event.at); + let label = event.track_label(); + Ok(output::table::stream_line(&[ + ("at", &at), + ("track", &label), + ("data", &event.payload.summary()), + ])) + } + } +} + +/// The stderr lifecycle notice announcing playback, mirroring `record`'s +/// started/summary convention (src/commands/record.rs) — stdout stays +/// reserved for the events themselves. +fn started_line(run: &str, speed: f64, session: &SessionTimeline) -> String { + serde_json::json!({ + "event": "replay", + "type": "started", + "ts": Utc::now(), + "session": run, + "events": session.events.len(), + "speed": speed, + "capture": capture_field(session), + }) + .to_string() +} + +/// The started line's capture health: the replay must say which of the +/// three [`CaptureState`]s it plays back rather than pretend a hole-free +/// record. +fn capture_field(session: &SessionTimeline) -> serde_json::Value { + match CaptureState::of(session.capture.as_ref()) { + CaptureState::Absent => serde_json::json!({ "state": "none" }), + CaptureState::Unfinalized(_) => serde_json::json!({ "state": "unfinalized" }), + CaptureState::Finalized(report, summary) => serde_json::json!({ + "state": "finalized", + "window_start": report.meta.window_start, + "window_end": summary.window_end, + "events_dropped": summary.events_dropped, + "reconnect_count": summary.reconnect_count, + }), + } +} + +fn summary_line(emitted: u64) -> String { + serde_json::json!({ + "event": "replay", + "type": "summary", + "ts": Utc::now(), + "emitted": emitted, + }) + .to_string() +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use kraken_recording::parse_instant; + use kraken_recording::{RecordingDeclaration, RecordingIntegrity, RecordingManifest}; + + use super::*; + use crate::session::decision::{Decision, DecisionKind}; + use crate::session::timeline::fixtures; + + /// Parse wrapper: `ReplayCommand` is `clap::Args`, so tests flatten it + /// into a throwaway parser the way `lib.rs` flattens it into the CLI. + #[derive(Parser)] + struct TestCli { + #[command(flatten)] + cmd: ReplayCommand, + } + + fn speed_of(args: &[&str]) -> std::result::Result { + TestCli::try_parse_from(args).map(|cli| cli.cmd.speed) + } + + fn decision_event(ts: &str, seq: i64) -> TimelineEvent { + TimelineEvent::decision( + parse_instant(ts).unwrap(), + seq, + Decision { + timestamp: ts.parse().unwrap(), + kind: DecisionKind::Skip, + symbol: None, + reason: "pin".to_string(), + order_id: None, + }, + ) + } + + #[test] + fn speed_accepts_the_bounded_range_and_defaults_to_real_time() { + assert_eq!(speed_of(&["t"]).unwrap(), 1.0); + assert_eq!(speed_of(&["t", "--speed", "0.01"]).unwrap(), MIN_SPEED); + assert_eq!(speed_of(&["t", "--speed", "1"]).unwrap(), 1.0); + assert_eq!(speed_of(&["t", "--speed", "1000"]).unwrap(), MAX_SPEED); + } + + #[test] + fn speed_rejects_zero_negative_nonfinite_and_out_of_range() { + for bad in ["0", "-1", "inf", "nan", "0.009", "1001", "abc"] { + assert!(speed_of(&["t", "--speed", bad]).is_err(), "accepted {bad}"); + } + } + + #[test] + fn json_line_matches_the_timeline_wire_shape() { + let event = decision_event("2026-01-01T00:00:00Z", 3); + let line = render_line(OutputFormat::Json, &event).unwrap(); + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["seq"], 3); + assert_eq!(value["kind"], "skip"); + assert_eq!(value["reason"], "pin"); + assert!(value.get("payload").is_none(), "no discriminant wrapper"); + } + + #[test] + fn table_line_shows_at_track_and_summary() { + let event = decision_event("2026-01-01T00:00:00Z", 1); + let line = render_line(OutputFormat::Table, &event).unwrap(); + for needle in ["at: 2026-01-01T00:00:00", "track: decision", "kind:skip"] { + assert!(line.contains(needle), "{needle} missing in {line}"); + } + } + + #[test] + fn started_line_reports_the_three_capture_states() { + let meta = RecordingDeclaration { + source: "test".into(), + symbols: vec!["BTC/USD".into()], + channels: vec!["ticker".into()], + window_start: "2026-01-01T00:00:00Z".parse().unwrap(), + cli_version: "test".into(), + }; + let report = |summary| RecordingManifest { + schema_version: "3.0".into(), + meta: meta.clone(), + summary, + }; + let line_for = |capture| { + let session = SessionTimeline { + events: Vec::new(), + capture, + manifest: fixtures::manifest(vec![]), + newer_records_skipped: 0, + }; + serde_json::from_str::(&started_line("s1", 2.0, &session)).unwrap() + }; + + let none = line_for(None); + assert_eq!(none["capture"]["state"], "none"); + assert_eq!(none["session"], "s1"); + assert_eq!(none["speed"], 2.0); + + let unfinalized = line_for(Some(report(None))); + assert_eq!(unfinalized["capture"]["state"], "unfinalized"); + + let finalized = line_for(Some(report(Some(RecordingIntegrity { + window_end: "2026-01-01T01:00:00Z".parse().unwrap(), + events_dropped: 7, + reconnect_count: 1, + ..RecordingIntegrity::now() + })))); + assert_eq!(finalized["capture"]["state"], "finalized"); + assert_eq!(finalized["capture"]["events_dropped"], 7); + assert_eq!(finalized["capture"]["window_start"], "2026-01-01T00:00:00Z"); + } +} diff --git a/src/commands/replay_venue.rs b/src/commands/replay_venue.rs new file mode 100644 index 0000000..8749b73 --- /dev/null +++ b/src/commands/replay_venue.rs @@ -0,0 +1,420 @@ +//! Deterministic paper execution against a recorded tape. +//! +//! The journal lock serializes lazy fills, while the pump writes a separate +//! session tape. A cursor only prevents replaying already-decided frames. + +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use kraken_lab::mark; +use kraken_paper::account::PaperAccount; +use kraken_paper::{AccountEvent, PaperState}; +use kraken_recording::{Source, TapeRef, TapeSource, write_json_atomic}; +use kraken_session::manifest::SessionSource; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use crate::errors::{KrakenError, Result}; + +/// The venue cursor's file name inside a session directory. +pub(crate) const VENUE_CURSOR_FILE: &str = "venue_cursor.json"; + +/// A replay session's offline venue: the affine map, the source tape, and +/// the mark table the fold warms. Live runs never construct one. +pub(crate) struct ReplayVenue { + source: SessionSource, + source_path: PathBuf, + cursor_path: PathBuf, + table: mark::Table, + /// Wall-clock instant the fold must not advance past — a closed + /// window's recorded end. `None` while the session is live: fold to now. + ceiling: Option>, +} + +/// Last folded tape position, in source-tape coordinates. Ordering matches +/// the reader's `(at, seq)` yield order. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +struct Cursor { + at: DateTime, + seq: i64, +} + +impl ReplayVenue { + /// `Some` iff the scope's ACTIVE run replays a tape (its session.json + /// carries a source block). No active session, or a live-market run, is a + /// plain REST venue. + pub(crate) fn resolve_active(scope: &Path) -> Result> { + let Some((session_id, manifest)) = kraken_workspace::session::active(scope)? else { + return Ok(None); + }; + let Some(source) = manifest.source else { + return Ok(None); + }; + // An active session's window is open, so no ceiling: fold to now. + Self::for_run( + &crate::config::config_dir()?, + scope, + &kraken_session::session::session_dir(scope, session_id.ordinal()), + source, + manifest.window.ended_at, + ) + .map(Some) + } + + /// The venue for a session whose source block is already in hand + /// (run stop/show, which have loaded session.json anyway). The cursor lives + /// in the session's own directory — one cursor per window, never shared. + pub(crate) fn for_run( + library: &Path, + scope: &Path, + session_dir: &Path, + source: SessionSource, + ceiling: Option>, + ) -> Result { + let tape: TapeRef = source.tape.parse()?; + let source_path = kraken_replay::resolve_source_path(library, scope, &tape)?; + Ok(Self { + source, + source_path, + cursor_path: session_dir.join(VENUE_CURSOR_FILE), + table: mark::Table::default(), + ceiling, + }) + } + + /// Fold the source tape up to the current session instant: every due + /// frame warms the marks; frames past the cursor also decide resting + /// fills, journaled at their session-time instants (tape-time + /// attribution stays derivable through the affine map). + pub(crate) fn fold_due(&mut self, account: &mut PaperAccount) -> Result<()> { + let due = self.source.tape_time(self.ceiling.unwrap_or_else(Utc::now)); + let cursor: Option = read_cursor(&self.cursor_path)?; + let reader = TapeSource::open(&self.source_path)?; + let mut last = cursor; + for event in reader.read()? { + if event.at > due { + break; + } + let moved = self.table.observe(&event.frame); + let past_cursor = cursor.is_none_or(|c| (event.at, event.seq) > (c.at, c.seq)); + if past_cursor { + if moved { + self.settle_fills(account, self.source.session_time(event.at))?; + } + last = Some(Cursor { + at: event.at, + seq: event.seq, + }); + } + } + // An order placed after the last mark move can already cross the + // standing marks — the live venue would fill it on the next + // reconcile, so the fold settles once more at the due instant. + self.settle_fills(account, self.source.session_time(due))?; + if last != cursor { + write_json_atomic(&self.cursor_path, &last)?; + } + Ok(()) + } + + /// Decide and journal any fills the current marks trigger, stamped `at`. + fn settle_fills(&self, account: &mut PaperAccount, at: DateTime) -> Result<()> { + if !account.is_initialized() { + return Ok(()); + } + let state = account.state()?; + // The vast majority of frames settle nothing (market-order and DCA + // strategies rest no orders). Skip building the per-symbol price map — + // one allocation and a `format!` per mark — when there is nothing that + // could fill. + if state.open_orders.is_empty() { + return Ok(()); + } + let prices = self.table.to_prices(&state.starting_currency); + let fills = state.decide_pending_fills(&prices); + if fills.is_empty() { + return Ok(()); + } + let events = fills + .into_iter() + .map(|mut trade| { + trade.filled_at = at; + AccountEvent::OrderFilled { trade } + }) + .collect(); + let records = account.stamp_at(events, at); + Ok(account.append(&records)?) + } + + /// The pair's current tape mark as `(ask, bid)`. Mid both sides — the + /// top-of-book proxy the scorecard's replay caveats disclose. + pub(crate) fn ticker_price(&self, pair: &str) -> Result<(Decimal, Decimal)> { + let (base, quote) = kraken_paper::canonical_key(pair).ok_or_else(|| { + KrakenError::Validation(format!("Unrecognized trading pair '{pair}'")) + })?; + self.table + .to_prices("e) + .get(&format!("{base}{quote}")) + .copied() + .ok_or_else(|| { + KrakenError::Validation(format!( + "no recorded {pair} mark at this tape position yet; wait for the replay \ + to reach a price frame (source {})", + self.source.tape + )) + }) + } + + /// Value the account from the folded marks — never REST: a live price + /// against a recorded tape would be a lie. + pub(crate) fn value(&self, state: &PaperState) -> (Decimal, bool) { + let prices = self.table.to_prices(&state.starting_currency); + state.compute_portfolio_value(&prices) + } +} + +fn read_cursor(path: &Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(data) => Ok(serde_json::from_str(&data) + .map_err(|e| KrakenError::Parse(format!("damaged venue cursor: {e}")))?), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use kraken_paper::account::Origin; + use kraken_paper::{OrderSide, PaperConfig}; + use rust_decimal_macros::dec; + + use super::*; + + fn ticker_line(ts: &str, ask: f64, bid: f64) -> String { + serde_json::json!({ + "channel": "ticker", "type": "update", + "data": [{ + "symbol": "BTC/USD", "bid": bid, "bid_qty": 1.0, "ask": ask, "ask_qty": 1.0, + "last": bid, "volume": 100.0, "vwap": bid, "low": bid, "high": ask, + "change": 0.0, "change_pct": 0.0, "timestamp": ts + }] + }) + .to_string() + } + + /// A finalized three-frame BTC/USD tape: 50k → 48k → 52k. + fn write_tape(base: &Path) { + let dir = kraken_recording::tapes_root(base); + std::fs::create_dir_all(&dir).unwrap(); + let lines = [ + ticker_line("2026-01-01T00:00:00Z", 50_000.0, 50_000.0), + ticker_line("2026-01-01T00:00:10Z", 48_000.0, 48_000.0), + ticker_line("2026-01-01T00:00:20Z", 52_000.0, 52_000.0), + ] + .join("\n"); + std::fs::write(dir.join("tape.jsonl"), lines + "\n").unwrap(); + std::fs::write( + dir.join("tape.jsonl.meta.json"), + serde_json::json!({ + "schema_version": kraken_recording::schema::SCHEMA_VERSION, + "source": "kraken-spot-ws-v2", + "symbols": ["BTC/USD"], + "channels": ["ticker"], + "window_start": "2026-01-01T00:00:00Z", + "cli_version": "0.0.0-test", + "window_end": "2026-01-01T00:00:20Z", + "events_dropped": 0, + "frames_unparsed": 0, + "reconnect_count": 0 + }) + .to_string(), + ) + .unwrap(); + } + + /// A session whose whole tape is already due: `started_at` sits an hour + /// in the past, so `tape_time(now)` is far beyond the last frame. + fn venue_and_account(base: &Path) -> (ReplayVenue, PaperAccount) { + let session_dir = kraken_session::session::session_dir(base, 1); + std::fs::create_dir_all(&session_dir).unwrap(); + let source = SessionSource { + tape: "tape:tape".to_string(), + speed: 1.0, + anchor: "2026-01-01T00:00:00Z".parse().unwrap(), + started_at: Utc::now() - Duration::hours(1), + content_hash: None, + }; + let venue = ReplayVenue::for_run(base, base, &session_dir, source, None).unwrap(); + let mut account = PaperAccount::open_at(base.join("journal.jsonl"), Origin::Cli).unwrap(); + let init = AccountEvent::Initialized(PaperConfig { + balance: dec!(10_000), + currency: "USD".to_string(), + fee_rate: dec!(0), + slippage_rate: dec!(0), + }); + let records = account.stamp(vec![init]); + account.append_if_uninitialized(&records).unwrap(); + (venue, account) + } + + #[test] + fn fold_settles_a_crossing_limit_order_at_tape_time_exactly_once() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_tape(base); + let (mut venue, mut account) = venue_and_account(base); + + // Resting buy at 49k: the 48k frame crosses it; the 52k one must not + // refill it. + let order = account + .state() + .unwrap() + .decide_limit_order(OrderSide::Buy, "BTC/USD", dec!(0.1), dec!(49_000)) + .unwrap(); + let records = account.stamp(vec![AccountEvent::OrderSubmitted { order }]); + account.append(&records).unwrap(); + + venue.fold_due(&mut account).unwrap(); + let state = account.state().unwrap(); + assert_eq!(state.filled_trades.len(), 1, "one fill from the 48k frame"); + let fill = &state.filled_trades[0]; + assert_eq!(fill.volume, dec!(0.1)); + // The fill lands at the frame's session-time instant, not the fold's + // wall clock: 10 tape seconds after started_at. + let expected = venue + .source + .session_time("2026-01-01T00:00:10Z".parse().unwrap()); + assert_eq!(fill.filled_at, expected); + } + + #[test] + fn refolding_never_duplicates_fills() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_tape(base); + let (mut venue, mut account) = venue_and_account(base); + let order = account + .state() + .unwrap() + .decide_limit_order(OrderSide::Buy, "BTC/USD", dec!(0.1), dec!(49_000)) + .unwrap(); + let records = account.stamp(vec![AccountEvent::OrderSubmitted { order }]); + account.append(&records).unwrap(); + venue.fold_due(&mut account).unwrap(); + assert_eq!(account.state().unwrap().filled_trades.len(), 1); + + // Fresh venue (as a new command process would build): the cursor + // keeps folded frames folded. + let mut venue = ReplayVenue::for_run( + base, + base, + &kraken_session::session::session_dir(base, 1), + SessionSource { + tape: "tape:tape".to_string(), + speed: 1.0, + anchor: "2026-01-01T00:00:00Z".parse().unwrap(), + started_at: Utc::now() - Duration::hours(1), + content_hash: None, + }, + None, + ) + .unwrap(); + venue.fold_due(&mut account).unwrap(); + assert_eq!( + account.state().unwrap().filled_trades.len(), + 1, + "cursor prevents re-deciding folded frames" + ); + } + + #[test] + fn a_finalized_session_does_not_fold_past_its_recorded_end() { + // The bug: after stop, a paper command re-folds up to tape_time(now) + // and journals a fill from a frame the session never reached. The ceiling + // (the session's recorded end) must cap the fold so the sealed journal + // stays reproducible. + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_tape(base); + let session_dir = kraken_session::session::session_dir(base, 1); + std::fs::create_dir_all(&session_dir).unwrap(); + let started_at = Utc::now() - Duration::hours(1); + let source = SessionSource { + tape: "tape:tape".to_string(), + speed: 1.0, + anchor: "2026-01-01T00:00:00Z".parse().unwrap(), + started_at, + content_hash: None, + }; + // Cap 5 tape-seconds in — before the t+10s (48k) frame that crosses. + let ceiling = Some(started_at + Duration::seconds(5)); + let mut venue = ReplayVenue::for_run(base, base, &session_dir, source, ceiling).unwrap(); + let mut account = PaperAccount::open_at(base.join("journal.jsonl"), Origin::Cli).unwrap(); + let init = AccountEvent::Initialized(PaperConfig { + balance: dec!(10_000), + currency: "USD".to_string(), + fee_rate: dec!(0), + slippage_rate: dec!(0), + }); + let records = account.stamp(vec![init]); + account.append_if_uninitialized(&records).unwrap(); + let order = account + .state() + .unwrap() + .decide_limit_order(OrderSide::Buy, "BTC/USD", dec!(0.1), dec!(49_000)) + .unwrap(); + let records = account.stamp(vec![AccountEvent::OrderSubmitted { order }]); + account.append(&records).unwrap(); + + venue.fold_due(&mut account).unwrap(); + + assert!( + account.state().unwrap().filled_trades.is_empty(), + "the 48k frame is past the ceiling, so the resting buy must not fill" + ); + } + + #[test] + fn ticker_price_is_the_folded_mark_and_absent_marks_are_refused() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_tape(base); + let (mut venue, mut account) = venue_and_account(base); + + assert!( + venue.ticker_price("BTC/USD").is_err(), + "no fold yet, no mark" + ); + venue.fold_due(&mut account).unwrap(); + let (ask, bid) = venue.ticker_price("BTC/USD").unwrap(); + // Mid both sides — the top-of-book proxy. The mark rides the same + // 3-observation median guard as the scoring fold: median(50k, 48k, + // 52k) = 50k, so pricing and scoring can never disagree on a mark. + assert_eq!((ask, bid), (dec!(50_000), dec!(50_000))); + assert!(venue.ticker_price("ETH/USD").is_err()); + } + + #[test] + fn value_marks_holdings_from_the_tape_never_rest() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + write_tape(base); + let (mut venue, mut account) = venue_and_account(base); + let order = account + .state() + .unwrap() + .decide_limit_order(OrderSide::Buy, "BTC/USD", dec!(0.1), dec!(49_000)) + .unwrap(); + let records = account.stamp(vec![AccountEvent::OrderSubmitted { order }]); + account.append(&records).unwrap(); + venue.fold_due(&mut account).unwrap(); + + let (value, complete) = venue.value(account.state().unwrap()); + // The fill costs 0.1 × 49k (the two-observation warm-up mean when the + // 48k frame crossed); the end mark is the 3-frame median 50k: + // 10_000 − 4_900 + 0.1 × 50_000 = 10_100. + assert_eq!(value, dec!(10_100)); + assert!(complete); + } +} diff --git a/src/commands/schema.rs b/src/commands/schema.rs new file mode 100644 index 0000000..f169a89 --- /dev/null +++ b/src/commands/schema.rs @@ -0,0 +1,1083 @@ +//! The CLI's response schema, applied to raw wire payloads in one place. +//! +//! Kraken's wire format answers with cryptic short keys (`eb`, `vol`, +//! `descr.type`), legacy asset codes (`ZUSD`, `XXBT`), decimal strings, and +//! positional arrays. Every transform here reshapes such a payload into the +//! CLI's stable output schema: readable field names, money as JSON numbers, +//! order/ledger timestamps as strings (the wire token verbatim, never an +//! f64 re-encode), modern asset codes, positional market arrays as named +//! objects, and wire `null`s omitted. +//! +//! Two preservation rules bound every transform: +//! - Unknown wire *fields* pass through verbatim instead of being dropped, +//! so data added by flags like `trades-history --ledgers` — or by Kraken +//! later — always survives. The one exception is the AssetPairs +//! legacy-alias strip (`altname`/`wsname`/`base`/`quote`/`lot`): a pair's +//! modern identity is its map key, and the deprecated aliases would +//! contradict it. +//! - Unknown wire *tokens* in enum-valued fields (an order `status` or +//! ledger `type` Kraken adds later) stay verbatim — the account's own +//! money records are never narrowed to a placeholder. + +use std::str::FromStr; + +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use serde_json::{Map, Number, Value}; + +/// How one known field is published in the output schema. +enum Kind { + /// Verbatim value. + Keep, + /// Decimal wire string → JSON number (the workspace's rust_decimal + /// `serde-float` form; trailing zeros collapse, e.g. `"10000.00"` → `1e4`). + Money, + /// Unix-seconds wire number → its verbatim string (no f64 re-encode). + Time, + /// Legacy asset code → modern form (`XXBT` → `BTC`). + Asset, + /// Lenient trigger decode: only `"last"`/`"index"` survive; any other + /// shape is omitted. + Trigger, +} + +/// One published field: wire key, output key, value form. +type FieldSpec = (&'static str, &'static str, Kind); + +const TRADE_BALANCE_SPEC: &[FieldSpec] = &[ + ("eb", "equivalent_balance", Kind::Money), + ("tb", "trade_balance", Kind::Money), + ("m", "margin_amount", Kind::Money), + ("n", "unrealized_pnl", Kind::Money), + ("c", "cost_basis", Kind::Money), + ("v", "floating_valuation", Kind::Money), + ("e", "equity", Kind::Money), + ("mf", "free_margin", Kind::Money), + ("ml", "margin_level_pct", Kind::Money), + ("uv", "unrealized_value", Kind::Money), + ("mfo", "free_margin_original", Kind::Money), +]; + +const EXTENDED_BALANCE_ENTRY_SPEC: &[FieldSpec] = &[ + ("balance", "balance", Kind::Money), + ("hold_trade", "hold_trade", Kind::Money), + ("credit", "credit", Kind::Money), + ("credit_used", "credit_used", Kind::Money), +]; + +/// One OpenOrders/ClosedOrders row. +const ORDER_SPEC: &[FieldSpec] = &[ + ("refid", "refid", Kind::Keep), + ("userref", "userref", Kind::Keep), + ("cl_ord_id", "cl_ord_id", Kind::Keep), + ("status", "status", Kind::Keep), + ("opentm", "opentm", Kind::Time), + ("closetm", "closetm", Kind::Time), + ("starttm", "starttm", Kind::Time), + ("expiretm", "expiretm", Kind::Time), + ("vol", "volume", Kind::Money), + ("vol_exec", "volume_executed", Kind::Money), + ("cost", "cost", Kind::Money), + ("fee", "fee", Kind::Money), + ("price", "price", Kind::Money), + ("stopprice", "stopprice", Kind::Money), + ("limitprice", "limitprice", Kind::Money), + ("misc", "misc", Kind::Keep), + ("oflags", "oflags", Kind::Keep), + ("trades", "trades", Kind::Keep), + ("time_in_force", "time_in_force", Kind::Keep), + ("reason", "reason", Kind::Keep), + ("trigger", "trigger", Kind::Trigger), +]; + +/// The `descr` object on an order row; `type` is buy/sell, published as +/// `side`. +const ORDER_DESCR_SPEC: &[FieldSpec] = &[ + ("pair", "pair", Kind::Keep), + ("aclass", "aclass", Kind::Keep), + ("type", "side", Kind::Keep), + ("ordertype", "ordertype", Kind::Keep), + ("price", "price", Kind::Money), + ("price2", "price2", Kind::Money), + ("leverage", "leverage", Kind::Keep), + ("order", "order", Kind::Keep), + ("close", "close", Kind::Keep), +]; + +/// One TradesHistory row. `vol` deliberately stays unrenamed here (unlike +/// order rows' `volume`) — each endpoint's published field set is a pinned +/// contract, not a cross-endpoint unification. +const TRADE_SPEC: &[FieldSpec] = &[ + ("ordertxid", "ordertxid", Kind::Keep), + ("postxid", "postxid", Kind::Keep), + ("posstatus", "posstatus", Kind::Keep), + ("pair", "pair", Kind::Keep), + ("aclass", "aclass", Kind::Keep), + ("time", "time", Kind::Time), + ("type", "side", Kind::Keep), + ("ordertype", "ordertype", Kind::Keep), + ("tradeordertype", "tradeordertype", Kind::Keep), + ("price", "price", Kind::Money), + ("cost", "cost", Kind::Money), + ("fee", "fee", Kind::Money), + ("vol", "vol", Kind::Money), + ("margin", "margin", Kind::Money), + ("leverage", "leverage", Kind::Money), + ("misc", "misc", Kind::Keep), + ("trade_id", "trade_id", Kind::Keep), + ("maker", "maker", Kind::Keep), +]; + +/// One OpenPositions row. `class` (this endpoint's anomaly for `aclass`) +/// becomes `asset_class`; `rollovertm` stays a string. +const POSITION_SPEC: &[FieldSpec] = &[ + ("ordertxid", "ordertxid", Kind::Keep), + ("posstatus", "posstatus", Kind::Keep), + ("pair", "pair", Kind::Keep), + ("class", "asset_class", Kind::Keep), + ("time", "time", Kind::Time), + ("type", "side", Kind::Keep), + ("ordertype", "ordertype", Kind::Keep), + ("cost", "cost", Kind::Money), + ("fee", "fee", Kind::Money), + ("vol", "vol", Kind::Money), + ("vol_closed", "vol_closed", Kind::Money), + ("margin", "margin", Kind::Money), + ("terms", "terms", Kind::Keep), + ("rollovertm", "rollovertm", Kind::Keep), + ("misc", "misc", Kind::Keep), + ("oflags", "oflags", Kind::Keep), + ("value", "value", Kind::Money), + ("net", "net", Kind::Money), +]; + +/// One Ledgers row. +const LEDGER_SPEC: &[FieldSpec] = &[ + ("refid", "refid", Kind::Keep), + ("time", "time", Kind::Time), + ("type", "ledger_type", Kind::Keep), + ("subtype", "subtype", Kind::Keep), + ("aclass", "aclass", Kind::Keep), + ("asset", "asset", Kind::Asset), + ("amount", "amount", Kind::Money), + ("fee", "fee", Kind::Money), + ("balance", "balance", Kind::Money), +]; + +/// TradeVolume. The `fees`/`fees_maker` schedules pass through untouched. +const TRADE_VOLUME_SPEC: &[FieldSpec] = &[ + ("currency", "currency", Kind::Asset), + ("volume", "volume", Kind::Money), +]; + +pub(crate) fn trade_balance(data: &mut Value) { + apply_spec(data, TRADE_BALANCE_SPEC); +} + +/// Balance: a bare `{ asset: amount }` map — modern asset keys, numeric amounts. +pub(crate) fn balance(data: &mut Value) { + normalize_asset_keys(data); + if let Some(obj) = data.as_object_mut() { + for amount in obj.values_mut() { + money_in_place(amount); + } + } +} + +/// BalanceEx: a bare `{ asset: entry }` map — modern asset keys, numeric +/// amounts, credit fields only for credit-enabled keys (wire-absent stays absent). +pub(crate) fn extended_balance(data: &mut Value) { + normalize_asset_keys(data); + for_each_row(data, &[], |entry| { + apply_spec(entry, EXTENDED_BALANCE_ENTRY_SPEC); + }); +} + +/// OpenOrders/ClosedOrders: rows under `open`/`closed`; the wrapper (and +/// ClosedOrders' `count`) stays. `time_in_force` is always present in the +/// schema (default GTC), so it is injected when absent. +pub(crate) fn order_rows(data: &mut Value) { + for_each_row(data, &["open", "closed"], |row| { + apply_spec(row, ORDER_SPEC); + if let Some(descr) = row.get_mut("descr") { + apply_spec(descr, ORDER_DESCR_SPEC); + } + if let Some(obj) = row.as_object_mut() { + obj.entry("time_in_force") + .or_insert_with(|| Value::String("gtc".into())); + } + }); +} + +/// TradesHistory: rows under `trades`; `count` stays. +pub(crate) fn trade_rows(data: &mut Value) { + for_each_row(data, &["trades"], |row| apply_spec(row, TRADE_SPEC)); +} + +/// OpenPositions: a bare `{ position_id: row }` map. The `--consolidation` +/// array shape has no pinned schema, so arrays pass through verbatim rather +/// than getting a speculative one. +pub(crate) fn position_rows(data: &mut Value) { + if data.is_array() { + return; + } + for_each_row(data, &[], |row| apply_spec(row, POSITION_SPEC)); +} + +/// Ledgers (`{ ledger: {...}, count }`) and QueryLedgers (bare map). +pub(crate) fn ledger_rows(data: &mut Value) { + for_each_row(data, &["ledger"], |row| apply_spec(row, LEDGER_SPEC)); +} + +pub(crate) fn trade_volume(data: &mut Value) { + apply_spec(data, TRADE_VOLUME_SPEC); +} + +// ---- Market REST -------------------------------------------------------------- +// +// Kraken's market endpoints answer in positional arrays keyed by a wire pair +// name; each transform below reshapes them into named objects. Rows that +// don't match the expected positional shape pass through verbatim — a +// malformed row must never destroy the payload. + +/// OHLC: `{ "": [[t,o,h,l,c,vwap,vol,count],…], last }` → +/// `{ pair, candles: [{time, open, high, low, close, vwap, volume, count}], last }` +/// — `pair` echoes the request string. +pub(crate) fn ohlc(data: &mut Value, pair: &str) { + reshape_paged_rows(data, pair, "candles", |row| { + let cells = row.as_array()?; + let mut candle = Map::new(); + candle.insert("time".into(), cells.first()?.clone()); + candle.insert("open".into(), money(cells.get(1)?.clone())); + candle.insert("high".into(), money(cells.get(2)?.clone())); + candle.insert("low".into(), money(cells.get(3)?.clone())); + candle.insert("close".into(), money(cells.get(4)?.clone())); + candle.insert("vwap".into(), money(cells.get(5)?.clone())); + candle.insert("volume".into(), money(cells.get(6)?.clone())); + candle.insert("count".into(), cells.get(7)?.clone()); + Some(Value::Object(candle)) + }); +} + +/// Spreads: rows `[time, bid, ask]` → `{time, bid, ask}`; the integer `last` +/// cursor becomes a string. +pub(crate) fn spreads(data: &mut Value, pair: &str) { + reshape_paged_rows(data, pair, "spreads", |row| { + let cells = row.as_array()?; + let mut spread = Map::new(); + spread.insert("time".into(), cells.first()?.clone()); + spread.insert("bid".into(), money(cells.get(1)?.clone())); + spread.insert("ask".into(), money(cells.get(2)?.clone())); + Some(Value::Object(spread)) + }); + if let Some(last) = data.get_mut("last") { + *last = timestamp(std::mem::take(last)); + } +} + +/// Trades: rows `[price, volume, time, side, type, misc, trade_id]` → named +/// objects — `side` decoded from `b`/`s`, `order_type` kept as the raw wire +/// token (`"l"`/`"m"`), `time` kept verbatim as a string. +pub(crate) fn trades(data: &mut Value, pair: &str) { + reshape_paged_rows(data, pair, "trades", |row| { + let cells = row.as_array()?; + let side = match cells.get(3)?.as_str()? { + "b" => "buy", + "s" => "sell", + _ => "unknown", + }; + let mut trade = Map::new(); + trade.insert("price".into(), money(cells.first()?.clone())); + trade.insert("volume".into(), money(cells.get(1)?.clone())); + trade.insert("time".into(), timestamp(cells.get(2)?.clone())); + trade.insert("side".into(), Value::String(side.into())); + trade.insert("order_type".into(), cells.get(4)?.clone()); + trade.insert("misc".into(), cells.get(5)?.clone()); + trade.insert("trade_id".into(), cells.get(6)?.clone()); + Some(Value::Object(trade)) + }); +} + +/// Ticker: each pair's `a`/`b`/`c`/`v`/`p`/`t`/`l`/`h`/`o` arrays → named +/// fields. Map keys stay as Kraken returns them. +pub(crate) fn ticker(data: &mut Value) { + let Some(pairs) = data.as_object_mut() else { + return; + }; + for row in pairs.values_mut() { + explode_array( + row, + "a", + &["ask_price", "ask_whole_lot_volume", "ask_lot_volume"], + true, + ); + explode_array( + row, + "b", + &["bid_price", "bid_whole_lot_volume", "bid_lot_volume"], + true, + ); + explode_array(row, "c", &["last_price", "last_volume"], true); + explode_array(row, "v", &["volume_today", "volume_24h"], true); + explode_array(row, "p", &["vwap_today", "vwap_24h"], true); + explode_array(row, "t", &["trades_today", "trades_24h"], false); + explode_array(row, "l", &["low_today", "low_24h"], true); + explode_array(row, "h", &["high_today", "high_24h"], true); + if let Some(obj) = row.as_object_mut() + && let Some(open) = obj.remove("o") + { + obj.insert("open".into(), money(open)); + } + } +} + +/// Orderbook: `{ "": { asks: [[p,v,t],…], bids: […] } }` — the pair +/// wrapper is dropped and each level becomes `{price, volume, timestamp}`. +pub(crate) fn orderbook(data: &mut Value) { + let Some(obj) = data.as_object_mut() else { + return; + }; + let Some(book_key) = obj + .iter() + .find(|(_, v)| v.get("asks").is_some() || v.get("bids").is_some()) + .map(|(k, _)| k.clone()) + else { + return; + }; + let Some(mut book) = obj.remove(&book_key) else { + return; + }; + for side in ["asks", "bids"] { + if let Some(Value::Array(levels)) = book.get_mut(side) { + for level in levels.iter_mut() { + let Some(cells) = level.as_array() else { + continue; + }; + let (Some(price), Some(volume), Some(ts)) = + (cells.first(), cells.get(1), cells.get(2)) + else { + continue; + }; + let mut named = Map::new(); + named.insert("price".into(), money(price.clone())); + named.insert("volume".into(), money(volume.clone())); + named.insert("timestamp".into(), ts.clone()); + *level = Value::Object(named); + } + } + } + *data = book; +} + +/// Assets: keys normalised to modern codes; per-asset fields keep their +/// wire names. +pub(crate) fn assets(data: &mut Value) { + normalize_asset_keys(data); +} + +/// AssetPairs: slashless wire keys are rebuilt to `BASE/QUOTE` from the row's +/// legacy codes, the legacy aliases are stripped (see module doc), decimal +/// strings become numbers, and `fees` tiers become `{volume, fee_pct}`. +pub(crate) fn asset_pairs(data: &mut Value) { + let Some(obj) = data.as_object_mut() else { + return; + }; + let entries: Map = std::mem::take(obj) + .into_iter() + .map(|(key, mut row)| { + let key = modern_pair_key(key, &row); + reshape_pair_row(&mut row); + (key, row) + }) + .collect(); + *obj = entries; +} + +/// An already-slashed key stays verbatim; a slashless key is rebuilt from +/// the row's `base`/`quote` when both exist. +fn modern_pair_key(key: String, row: &Value) -> String { + if key.contains('/') { + return key; + } + let code = |field: &str| { + row.get(field) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + }; + match (code("base"), code("quote")) { + (Some(base), Some(quote)) => { + format!("{}/{}", normalise_asset(base), normalise_asset(quote)) + } + _ => key, + } +} + +fn reshape_pair_row(row: &mut Value) { + let Some(obj) = row.as_object_mut() else { + return; + }; + // The legacy-alias strip (modern identity lives in the map key), plus + // `lot` — a deprecated constant (`"unit"`). + for legacy in ["altname", "wsname", "base", "quote", "lot"] { + obj.remove(legacy); + } + for decimal_field in ["tick_size", "ordermin", "costmin"] { + if let Some(v) = obj.get_mut(decimal_field) { + *v = money(std::mem::take(v)); + } + } + if let Some(v) = obj.get_mut("fee_volume_currency") { + *v = asset(std::mem::take(v)); + } + for tiers_field in ["fees", "fees_maker"] { + if let Some(Value::Array(tiers)) = obj.get_mut(tiers_field) { + for tier in tiers.iter_mut() { + let Some(cells) = tier.as_array() else { + continue; + }; + let (Some(volume), Some(fee_pct)) = (cells.first(), cells.get(1)) else { + continue; + }; + let mut named = Map::new(); + named.insert("volume".into(), money(volume.clone())); + named.insert("fee_pct".into(), money(fee_pct.clone())); + *tier = Value::Object(named); + } + } + } +} + +/// The `{ "": rows, last }` pattern shared by OHLC/Trades/Spreads: +/// replace the wire pair key with `pair` (echoing the request string) and +/// the row array with named `rows_key` objects. A row the mapper rejects +/// passes through verbatim. +fn reshape_paged_rows( + data: &mut Value, + pair: &str, + rows_key: &str, + map_row: impl Fn(&Value) -> Option, +) { + let Some(obj) = data.as_object_mut() else { + return; + }; + let Some(wire_key) = obj.keys().find(|k| *k != "last").cloned() else { + return; + }; + let Some(mut rows) = obj.remove(&wire_key) else { + return; + }; + if let Some(rows) = rows.as_array_mut() { + for row in rows.iter_mut() { + if let Some(named) = map_row(row) { + *row = named; + } + } + } + obj.insert("pair".into(), Value::String(pair.to_string())); + obj.insert(rows_key.to_string(), rows); +} + +/// Split one of Ticker's positional arrays into named fields. Money fields go +/// through [`money`]; counters stay verbatim. A non-array value is reinserted +/// untouched. +fn explode_array(row: &mut Value, wire_key: &str, names: &[&str], is_money: bool) { + let Some(obj) = row.as_object_mut() else { + return; + }; + let Some(v) = obj.remove(wire_key) else { + return; + }; + let Value::Array(cells) = v else { + obj.insert(wire_key.to_string(), v); + return; + }; + for (cell, name) in cells.into_iter().zip(names) { + let value = if is_money { money(cell) } else { cell }; + obj.insert((*name).to_string(), value); + } +} + +// ---- WS stream ------------------------------------------------------------------ + +/// The `balances` channel is the one WS channel whose published keys differ +/// from the wire: an entry's ledger `type` serializes as `ledger_type`, a +/// wallet's flavour as `wallet_type`. Applied to the serialized frame at the +/// stdout boundary only — recorded tapes and the in-process typed model keep +/// the wire shape (`balances` is not recordable anyway). +pub(crate) fn ws_balances(frame: &mut Value) { + let Some(entries) = frame.get_mut("data").and_then(Value::as_array_mut) else { + return; + }; + for entry in entries { + rename_key(entry, "type", "ledger_type"); + if let Some(wallets) = entry.get_mut("wallets").and_then(Value::as_array_mut) { + for wallet in wallets { + rename_key(wallet, "type", "wallet_type"); + } + } + } +} + +fn rename_key(value: &mut Value, wire: &str, out: &str) { + if let Some(obj) = value.as_object_mut() + && let Some(v) = obj.remove(wire) + { + obj.insert(out.to_string(), v); + } +} + +/// Apply a field spec to one object: rename, convert, and omit wire `null`s +/// (optional fields publish as absent). Unknown keys pass through. +fn apply_spec(value: &mut Value, spec: &[FieldSpec]) { + let Some(obj) = value.as_object_mut() else { + return; + }; + for (wire, out, kind) in spec { + let Some(v) = obj.remove(*wire) else { continue }; + if v.is_null() { + continue; + } + // A `None` from convert (unrecognized Trigger) is omitted. + if let Some(converted) = convert(v, kind) { + obj.insert((*out).to_string(), converted); + } + } +} + +fn convert(v: Value, kind: &Kind) -> Option { + match kind { + Kind::Keep => Some(v), + Kind::Money => Some(money(v)), + Kind::Time => Some(timestamp(v)), + Kind::Asset => Some(asset(v)), + Kind::Trigger => match v { + Value::String(ref s) if s == "last" || s == "index" => Some(v), + _ => None, + }, + } +} + +/// Decimal wire value → JSON number, exactly as rust_decimal's `serde-float` +/// feature serializes (`Decimal::to_f64`): strings are parsed, and wire +/// *integers* (fee-tier volumes) re-encode as floats (`50000` → `50000.0`). +/// Unparseable values stay verbatim — a malformed field must never destroy +/// the payload. +fn money(v: Value) -> Value { + match v { + Value::String(s) => Decimal::from_str(&s) + .ok() + .and_then(|d| d.to_f64()) + .and_then(Number::from_f64) + .map_or_else(|| Value::String(s), Value::Number), + Value::Number(n) if !n.is_f64() => n + .as_f64() + .and_then(Number::from_f64) + .map_or_else(|| Value::Number(n), Value::Number), + other => other, + } +} + +fn money_in_place(v: &mut Value) { + *v = money(std::mem::take(v)); +} + +/// Wire unix-seconds number → its verbatim string (`Number::to_string`); +/// the fractional timestamp is never re-encoded through an f64. +fn timestamp(v: Value) -> Value { + match v { + Value::Number(n) => Value::String(n.to_string()), + other => other, + } +} + +fn asset(v: Value) -> Value { + match v { + Value::String(s) => Value::String(normalise_asset(&s).to_string()), + other => other, + } +} + +/// Rebuild a `{ asset: ... }` map with modern asset-code keys. +fn normalize_asset_keys(data: &mut Value) { + let Some(obj) = data.as_object_mut() else { + return; + }; + let entries: Map = std::mem::take(obj) + .into_iter() + .map(|(k, v)| (normalise_asset(&k).to_string(), v)) + .collect(); + *obj = entries; +} + +/// Visit every row of a keyed-rows response: rows live under the first present +/// `nested` key, or at the top level when `nested` finds nothing (bare-map +/// endpoints like QueryLedgers). +fn for_each_row(data: &mut Value, nested: &[&str], mut visit: impl FnMut(&mut Value)) { + for key in nested { + if let Some(container) = data.get_mut(*key) { + visit_rows(container, &mut visit); + return; + } + } + visit_rows(data, &mut visit); +} + +fn visit_rows(container: &mut Value, visit: &mut impl FnMut(&mut Value)) { + match container { + Value::Object(rows) => rows.values_mut().for_each(&mut *visit), + Value::Array(rows) => rows.iter_mut().for_each(visit), + _ => {} + } +} + +/// Kraken's legacy X/Z-heritage asset codes → modern form. Table-driven, +/// not a leading-char strip: modern codes that start with X/Z (XRP, XLM, +/// ZEC) pass through; only the finite legacy set maps. +fn normalise_asset(raw: &str) -> &str { + match raw { + "XXBT" => "BTC", + "XBT" => "BTC", + "XETC" => "ETC", + "XETH" => "ETH", + "XLTC" => "LTC", + "XMLN" => "MLN", + "XREP" => "REP", + "XXDG" => "XDG", + "XXLM" => "XLM", + "XXMR" => "XMR", + "XXRP" => "XRP", + "XZEC" => "ZEC", + "ZARS" => "ARS", + "ZAUD" => "AUD", + "ZCAD" => "CAD", + "ZCLP" => "CLP", + "ZCOP" => "COP", + "ZDKK" => "DKK", + "ZEUR" => "EUR", + "ZGBP" => "GBP", + "ZGEL" => "GEL", + "ZGHS" => "GHS", + "ZJPY" => "JPY", + "ZLKR" => "LKR", + "ZMXN" => "MXN", + "ZPLN" => "PLN", + "ZSEK" => "SEK", + "ZUGX" => "UGX", + "ZUSD" => "USD", + "ZVND" => "VND", + "ZXOF" => "XOF", + "KFEE" => "FEE", + other => other, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn trade_balance_acronyms_become_names_and_money_becomes_numbers() { + let mut data = json!({ + "eb": "10839.7418", "tb": "10682.5000", "m": "0.0000", + "n": "0.0000", "c": "0.0000", "v": "0.0000", "e": "10682.5000", + "mf": "10682.5000", "ml": "268.42" + }); + trade_balance(&mut data); + assert_eq!( + data, + json!({ + "equivalent_balance": 10839.7418, "trade_balance": 10682.5, + "margin_amount": 0.0, "unrealized_pnl": 0.0, "cost_basis": 0.0, + "floating_valuation": 0.0, "equity": 10682.5, + "free_margin": 10682.5, "margin_level_pct": 268.42 + }) + ); + assert!( + data.get("unrealized_value").is_none(), + "absent margin-only fields stay absent" + ); + } + + // The wire fixture is a live-captured OpenOrders row (2026-06-04); the + // expected output is its published schema form. + #[test] + fn open_order_row_matches_the_schema_of_the_live_fixture() { + let mut data = json!({ + "open": { + "OABC12-XYZ34-KLMN56": { + "cl_ord_id": "9ce3b826-7c70-4bb6-a89e-d6370e5378f6", + "cost": "0.00000", + "descr": { + "aclass": "forex", "close": "", "leverage": "none", + "order": "buy 0.00010000 XBTUSDC @ limit 20000.00", + "ordertype": "limit", "pair": "XBTUSDC", + "price": "20000.00", "price2": "0", "type": "buy" + }, + "expiretm": 0, "fee": "0.00000", "limitprice": "0.00000", "misc": "", + "oflags": "post,fciq", "opentm": 1780582233.729133_f64, "price": "0.00000", + "refid": null, "starttm": 0, "status": "open", "stopprice": "0.00000", + "time_in_force": "gtc", "userref": null, + "vol": "0.00010000", "vol_exec": "0.00000000" + } + } + }); + order_rows(&mut data); + assert_eq!( + data["open"]["OABC12-XYZ34-KLMN56"], + json!({ + "cl_ord_id": "9ce3b826-7c70-4bb6-a89e-d6370e5378f6", + "cost": 0.0, + "descr": { + "aclass": "forex", "close": "", "leverage": "none", + "order": "buy 0.00010000 XBTUSDC @ limit 20000.00", + "ordertype": "limit", "pair": "XBTUSDC", + "price": 20000.0, "price2": 0.0, "side": "buy" + }, + "expiretm": "0", "fee": 0.0, "limitprice": 0.0, "misc": "", + "oflags": "post,fciq", "opentm": "1780582233.729133", "price": 0.0, + "starttm": "0", "status": "open", "stopprice": 0.0, + "time_in_force": "gtc", + "volume": 0.0001, "volume_executed": 0.0 + }), + "null refid/userref omitted, timestamps stringified, money numeric" + ); + } + + #[test] + fn order_row_without_time_in_force_gets_the_gtc_default() { + let mut data = json!({ "open": { "TX": { "vol": "1", "descr": {} } } }); + order_rows(&mut data); + assert_eq!(data["open"]["TX"]["time_in_force"], "gtc"); + } + + #[test] + fn unrecognized_trigger_is_omitted_by_the_lenient_decode() { + let mut data = json!({ + "open": { + "A": { "trigger": 42, "descr": {} }, + "B": { "trigger": "index", "descr": {} } + } + }); + order_rows(&mut data); + assert!(data["open"]["A"].get("trigger").is_none()); + assert_eq!(data["open"]["B"]["trigger"], "index"); + } + + #[test] + fn trade_history_row_renames_side_and_keeps_flag_added_extras() { + let mut data = json!({ + "trades": { + "THVRQM-33VKH-UCI7BS": { + "ordertxid": "OQCLML-BW3P3-BUCMWZ", "postxid": "TKH2SE-M7IF5-CFI7LT", + "pair": "XXBTZUSD", "aclass": "forex", "time": 1688667796.8802, + "type": "buy", "ordertype": "limit", "tradeordertype": "limit", + "price": "30010.00000", "cost": "600.20000", "fee": "0.60020", + "vol": "0.02000000", "margin": "0.00000", "leverage": "0", + "misc": "", "trade_id": 93748276, "maker": true, + "ledgers": ["L4UESK-KG3EQ-UFO4T5"] + } + }, + "count": 2346 + }); + trade_rows(&mut data); + let row = &data["trades"]["THVRQM-33VKH-UCI7BS"]; + assert_eq!(row["side"], "buy"); + assert_eq!( + row["vol"], + json!(0.02), + "`vol` stays unrenamed on trade rows" + ); + assert_eq!(row["time"], "1688667796.8802"); + assert_eq!(row["trade_id"], 93748276); + assert_eq!(row["maker"], true); + assert_eq!( + row["ledgers"], + json!(["L4UESK-KG3EQ-UFO4T5"]), + "--ledgers data survives on the legacy route" + ); + assert_eq!(data["count"], 2346); + } + + #[test] + fn position_row_renames_side_and_the_anomalous_class_key() { + let mut data = json!({ + "TF5GVO-T7ZZ2-6NBKBI": { + "ordertxid": "OLWNFG-LLH4R-D6SFFP", "posstatus": "open", + "pair": "XXBTZUSD", "class": "forex", "time": 1605280097.8294, + "type": "buy", "ordertype": "limit", + "cost": "104610.52842", "fee": "289.06565", "vol": "8.82412861", + "vol_closed": "0.20200000", "margin": "20922.10568", + "terms": "0.0100% per 4 hours", "rollovertm": "1616672637", + "misc": "", "oflags": "", "net": "+5.32" + } + }); + position_rows(&mut data); + let row = &data["TF5GVO-T7ZZ2-6NBKBI"]; + assert_eq!(row["side"], "buy"); + assert_eq!(row["asset_class"], "forex"); + assert_eq!(row["vol"], json!(8.82412861)); + assert_eq!(row["time"], "1605280097.8294"); + assert_eq!(row["rollovertm"], "1616672637", "rollovertm stays a string"); + assert_eq!(row["net"], json!(5.32), "docalcs P&L is numeric"); + } + + #[test] + fn consolidated_position_arrays_pass_through_verbatim() { + // No pinned schema exists for `--consolidation` output, so no + // speculative reshape is applied. + let original = json!([{ "pair": "XXBTZUSD", "type": "buy", "positions": "2" }]); + let mut data = original.clone(); + position_rows(&mut data); + assert_eq!(data, original); + } + + #[test] + fn ledger_row_normalizes_asset_and_renames_type() { + let mut data = json!({ + "ledger": { + "L4UESK-KG3EQ-UFO4T5": { + "refid": "TJKLXF-PGMUI-4NTLXU", "time": 1688464484.1787, + "type": "trade", "subtype": "", "aclass": "currency", + "asset": "ZGBP", "amount": "24.5000", "fee": "0.0490", + "balance": "459567.9171" + } + }, + "count": 1 + }); + ledger_rows(&mut data); + let row = &data["ledger"]["L4UESK-KG3EQ-UFO4T5"]; + assert_eq!(row["ledger_type"], "trade"); + assert_eq!(row["asset"], "GBP"); + assert_eq!(row["amount"], json!(24.5)); + assert_eq!(row["time"], "1688464484.1787"); + } + + #[test] + fn query_ledgers_bare_map_rows_are_reshaped_at_the_top_level() { + let mut data = json!({ + "L4UESK-KG3EQ-UFO4T5": { "type": "trade", "asset": "XXBT", "amount": "1.1" } + }); + ledger_rows(&mut data); + assert_eq!(data["L4UESK-KG3EQ-UFO4T5"]["asset"], "BTC"); + assert_eq!(data["L4UESK-KG3EQ-UFO4T5"]["ledger_type"], "trade"); + } + + #[test] + fn balance_map_gets_modern_keys_and_numeric_amounts() { + let mut data = json!({ "ZUSD": "25435.21", "XXBT": "1219.1", "USDC": "5.00" }); + balance(&mut data); + assert_eq!(data, json!({ "USD": 25435.21, "BTC": 1219.1, "USDC": 5.0 })); + } + + // Pins the same schema the typed branch's wiremock test pins for + // balance-ex: modern keys, numeric money, absent credit stays absent. + #[test] + fn extended_balance_matches_the_typed_balance_ex_schema() { + let mut data = json!({ + "ZUSD": { "balance": "25435.21", "hold_trade": "8.0", "credit": "10000.00" }, + "XXBT": { "balance": "1219.1", "hold_trade": "0.5" } + }); + extended_balance(&mut data); + assert_eq!(data["USD"]["balance"], json!(25435.21)); + assert_eq!(data["USD"]["credit"], json!(10000.0)); + assert_eq!(data["BTC"]["hold_trade"], json!(0.5)); + assert!( + data["BTC"].get("credit").is_none(), + "absent credit must be omitted, not null: {data:#}" + ); + } + + #[test] + fn trade_volume_normalizes_currency_and_keeps_fee_schedules() { + let mut data = json!({ + "currency": "ZUSD", "volume": "200709587.4223", + "fees": { "XXBTZUSD": { "fee": "0.1000" } } + }); + trade_volume(&mut data); + assert_eq!(data["currency"], "USD"); + assert_eq!(data["volume"], json!(200709587.4223)); + assert!( + data.get("fees").is_some(), + "fee schedules survive on the legacy route" + ); + } + + #[test] + fn unparseable_money_stays_verbatim() { + let mut data = json!({ "eb": "not-a-number" }); + trade_balance(&mut data); + assert_eq!(data["equivalent_balance"], "not-a-number"); + } + + #[test] + fn ohlc_arrays_become_named_candles_with_the_request_pair() { + let mut data = json!({ + "XXBTZUSD": [[1688671200, "30306.1", "30306.2", "30305.7", "30305.7", "30306.1", "3.39243896", 23]], + "last": 1688672160 + }); + ohlc(&mut data, "BTCUSD"); + assert_eq!( + data, + json!({ + "pair": "BTCUSD", + "candles": [{ + "time": 1688671200, "open": 30306.1, "high": 30306.2, + "low": 30305.7, "close": 30305.7, "vwap": 30306.1, + "volume": 3.39243896, "count": 23 + }], + "last": 1688672160 + }) + ); + } + + #[test] + fn spreads_rows_are_named_and_the_last_cursor_becomes_a_string() { + let mut data = json!({ + "XXBTZUSD": [[1688671834, "30292.10000", "30297.50000"]], + "last": 1688672106 + }); + spreads(&mut data, "BTCUSD"); + assert_eq!( + data["spreads"][0], + json!({ "time": 1688671834, "bid": 30292.1, "ask": 30297.5 }) + ); + assert_eq!(data["last"], "1688672106"); + assert_eq!(data["pair"], "BTCUSD"); + } + + #[test] + fn trades_side_decodes_and_order_type_stays_the_wire_token() { + let mut data = json!({ + "XXBTZUSD": [["30243.40000", "0.34499092", 1688669597.8277_f64, "b", "m", "", 61044952]], + "last": "1688671969993150842" + }); + trades(&mut data, "BTCUSD"); + assert_eq!( + data["trades"][0], + json!({ + "price": 30243.4, "volume": 0.34499092, "time": "1688669597.8277", + "side": "buy", "order_type": "m", "misc": "", "trade_id": 61044952 + }), + "side decodes b→buy; order_type keeps the raw wire token" + ); + assert_eq!( + data["last"], "1688671969993150842", + "cursor already a string" + ); + } + + #[test] + fn ticker_positional_arrays_become_named_fields() { + let mut data = json!({ + "XXBTZUSD": { + "a": ["30300.10000", "1", "1.000"], "b": ["30300.00000", "1", "1.000"], + "c": ["30303.20000", "0.00067643"], "v": ["4083.67001100", "4412.73601799"], + "p": ["30706.77771", "30689.13205"], "t": [34619, 38907], + "l": ["29868.30000", "29868.30000"], "h": ["31631.00000", "31631.00000"], + "o": "30502.80000" + } + }); + ticker(&mut data); + assert_eq!( + data["XXBTZUSD"], + json!({ + "ask_price": 30300.1, "ask_whole_lot_volume": 1.0, "ask_lot_volume": 1.0, + "bid_price": 30300.0, "bid_whole_lot_volume": 1.0, "bid_lot_volume": 1.0, + "last_price": 30303.2, "last_volume": 0.00067643, + "volume_today": 4083.670011, "volume_24h": 4412.73601799, + "vwap_today": 30706.77771, "vwap_24h": 30689.13205, + "trades_today": 34619, "trades_24h": 38907, + "low_today": 29868.3, "low_24h": 29868.3, + "high_today": 31631.0, "high_24h": 31631.0, + "open": 30502.8 + }) + ); + } + + #[test] + fn orderbook_drops_the_pair_wrapper_and_names_levels() { + let mut data = json!({ + "XXBTZUSD": { + "asks": [["30384.10000", "2.059", 1688671659]], + "bids": [["30297.40000", "2.500", 1688671258]] + } + }); + orderbook(&mut data); + assert_eq!( + data, + json!({ + "asks": [{ "price": 30384.1, "volume": 2.059, "timestamp": 1688671659 }], + "bids": [{ "price": 30297.4, "volume": 2.5, "timestamp": 1688671258 }] + }) + ); + } + + #[test] + fn asset_pairs_rebuild_modern_keys_and_strip_legacy_aliases() { + let mut data = json!({ + "XXBTZUSD": { + "altname": "XBTUSD", "wsname": "XBT/USD", + "aclass_base": "currency", "base": "XXBT", + "aclass_quote": "currency", "quote": "ZUSD", "lot": "unit", + "tick_size": "0.10000", "ordermin": "0.00005", "costmin": "0.5", + "pair_decimals": 1, "fees": [[0, 0.26], [50000, 0.24]], + "fee_volume_currency": "ZUSD", "status": "online" + } + }); + asset_pairs(&mut data); + let row = &data["BTC/USD"]; + assert!( + !row.is_null(), + "slashless key rebuilt from base/quote: {data:#}" + ); + for legacy in ["altname", "wsname", "base", "quote", "lot"] { + assert!(row.get(legacy).is_none(), "{legacy} must be stripped"); + } + assert_eq!(row["tick_size"], json!(0.1)); + assert_eq!( + row["fees"][1], + json!({ "volume": 50000.0, "fee_pct": 0.24 }) + ); + assert_eq!(row["fee_volume_currency"], "USD"); + assert_eq!(row["pair_decimals"], 1); + } + + #[test] + fn already_slashed_pair_keys_stay_verbatim() { + let mut data = json!({ "RENDER/USD": { "aclass_base": "currency", "tick_size": "0.001" } }); + asset_pairs(&mut data); + assert_eq!(data["RENDER/USD"]["tick_size"], json!(0.001)); + } + + #[test] + fn ws_balances_renames_entry_and_wallet_type_keys() { + let mut frame = json!({ + "channel": "balances", "type": "snapshot", + "data": [ + { "asset": "BTC", "balance": 1.5, + "wallets": [{ "type": "earn", "id": "flexible", "balance": 1.5 }] }, + { "asset": "USD", "balance": 100.0, "type": "trade", "category": "trade" } + ], + "sequence": 9 + }); + ws_balances(&mut frame); + assert_eq!(frame["data"][0]["wallets"][0]["wallet_type"], "earn"); + assert!(frame["data"][0]["wallets"][0].get("type").is_none()); + assert_eq!(frame["data"][1]["ledger_type"], "trade"); + assert!(frame["data"][1].get("type").is_none()); + assert_eq!( + frame["type"], "snapshot", + "the frame-envelope type (snapshot/update) is untouched" + ); + } + + #[test] + fn unknown_wire_fields_pass_through_untouched() { + let mut data = json!({ "eb": "1.0", "brand_new_wire_field": {"x": 1} }); + trade_balance(&mut data); + assert_eq!(data["brand_new_wire_field"], json!({"x": 1})); + assert_eq!(data["equivalent_balance"], json!(1.0)); + } +} diff --git a/src/commands/session.rs b/src/commands/session.rs new file mode 100644 index 0000000..1b210e4 --- /dev/null +++ b/src/commands/session.rs @@ -0,0 +1,2004 @@ +//! Recorded windows over a scope's shared account journal. +//! +//! Start and stop markers delimit evidence while trading continues through the +//! same journal; live stops reconcile venue history before closing. + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; +use clap::Subcommand; +use enum_dispatch::enum_dispatch; +use itertools::Itertools; +use kraken_core::SubscribableChannel; +use kraken_paper::account::{Origin, PaperAccount}; +use kraken_paper::{AccountEvent, CommandEntry, PaperState, VenueSnapshot}; +use kraken_recording::RecordingDeclaration; +use kraken_recording::schema; +use kraken_session::session::{ + START_MARKER, STOP_MARKER, session_decisions_path, session_dir, session_file_path, tape_file, +}; +use kraken_workspace::session::{ + SessionId, SessionRecord, SessionRefSpec, SessionStatus, VenueFill, +}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +use super::{Execute, workspace_guard}; +use crate::cli::AppContext; +use crate::commands::paper::value_account; +use crate::commands::subscription::{ + CHANNEL_OPTIONS_NOTE, SubscriptionOptions, plan_subscriptions, +}; +use crate::config; +use crate::errors::{KrakenError, Result}; +use crate::output::{CommandOutput, rounded}; +use crate::process; +use crate::record::{RecordFormat, SinkPlan, SinkTarget, resolve_targets}; +use crate::session::decision::{Decision, DecisionKind}; +use crate::session::manifest::{ + PaperRef, RecordingBackend, RecordingRef, SessionManifest, SessionOutcome, SessionState, + SessionWindow, StrategyRef, +}; +use crate::sink::fanout::Fanout; +use crate::stream; +use crate::telemetry::CLIENT_VERSION; + +/// Run lifecycle commands: `start` streams (handled by the interactive +/// dispatcher); everything else is request/response and routes through the +/// shared executor. +#[enum_dispatch(Execute)] +#[derive(Debug, Subcommand)] +pub(crate) enum SessionCommand { + /// Open a session window on the account journal and record its market tape. + #[command(after_help = CHANNEL_OPTIONS_NOTE)] + Start(Start), + + #[command(flatten)] + Request(SessionRequestCommand), +} + +#[derive(Debug, clap::Args)] +pub(crate) struct Start { + /// Comma-separated trading pairs to record (e.g. BTC/USD,ETH/USD); repeatable. + #[arg(long, value_delimiter = ',')] + symbols: Vec, + /// Comma-separated market-data channels to capture (ticker, trade, book, ohlc); repeatable. + #[arg(long, value_delimiter = ',', ignore_case = true)] + channels: Vec, + /// Per-channel subscribe options, shared with `kraken record`. + #[command(flatten)] + options: SubscriptionOptions, + /// Sink targets: any combination of duckdb, jsonl, stdout (comma-separated). + /// + /// `duckdb`/`jsonl` always write their native on-disk format; `-o`/`--output` + /// (table|json) controls only the `stdout` echo, never the durable backends. + #[arg( + long, + value_enum, + value_delimiter = ',', + default_value = "stdout,jsonl" + )] + to: Vec, + /// Human handle for this session, resolvable anywhere a session ref is + /// (`--session