dexterm-cli 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/agents/src/index.js +2 -0
- package/dist/packages/agents/src/schema.js +22 -1
- package/dist/packages/agents/src/spectator.js +54 -0
- package/dist/packages/agents/src/story.js +532 -0
- package/dist/packages/agents/src/tiers.js +28 -2
- package/dist/packages/agents/src/tools.js +31 -2
- package/dist/packages/autotrade/assets/exit-profile.schema.json +492 -0
- package/dist/packages/autotrade/assets/profiles/moonbag.json +46 -0
- package/dist/packages/autotrade/assets/profiles/runner.json +88 -0
- package/dist/packages/autotrade/assets/profiles/scalp.json +88 -0
- package/dist/packages/autotrade/assets/profiles/swing.json +88 -0
- package/dist/packages/autotrade/assets/strategies/moonbag.json +101 -0
- package/dist/packages/autotrade/assets/strategies/runner.json +101 -0
- package/dist/packages/autotrade/assets/strategies/sample-dip-ladder.json +188 -0
- package/dist/packages/autotrade/assets/strategies/scalp.json +98 -0
- package/dist/packages/autotrade/assets/strategies/swing.json +97 -0
- package/dist/packages/autotrade/assets/strategies/yolo.json +157 -0
- package/dist/packages/autotrade/assets/tapes/yolo-rehearsal.json +1 -0
- package/dist/packages/autotrade/src/account.js +74 -0
- package/dist/packages/autotrade/src/backtest.js +357 -0
- package/dist/packages/autotrade/src/basis-text.js +32 -0
- package/dist/packages/autotrade/src/chat/approvals.js +484 -0
- package/dist/packages/autotrade/src/chat/audit.js +140 -0
- package/dist/packages/autotrade/src/chat/config.js +93 -0
- package/dist/packages/autotrade/src/chat/connector.js +24 -0
- package/dist/packages/autotrade/src/chat/control.js +103 -0
- package/dist/packages/autotrade/src/chat/fake.js +100 -0
- package/dist/packages/autotrade/src/chat/format.js +71 -0
- package/dist/packages/autotrade/src/chat/hub.js +187 -0
- package/dist/packages/autotrade/src/chat/index.js +18 -0
- package/dist/packages/autotrade/src/chat/notify.js +292 -0
- package/dist/packages/autotrade/src/chat/pairing.js +46 -0
- package/dist/packages/autotrade/src/chat/redact.js +48 -0
- package/dist/packages/autotrade/src/chat/secrets.js +99 -0
- package/dist/packages/autotrade/src/chat/service.js +248 -0
- package/dist/packages/autotrade/src/chat/telegram.js +290 -0
- package/dist/packages/autotrade/src/desk-executor.js +188 -0
- package/dist/packages/autotrade/src/engine.js +387 -0
- package/dist/packages/autotrade/src/execution-port.js +97 -0
- package/dist/packages/autotrade/src/fleet-config.js +169 -0
- package/dist/packages/autotrade/src/fleet-control.js +276 -0
- package/dist/packages/autotrade/src/fleet.js +639 -0
- package/dist/packages/autotrade/src/index.js +53 -0
- package/dist/packages/autotrade/src/ledger.js +108 -0
- package/dist/packages/autotrade/src/live-source.js +182 -0
- package/dist/packages/autotrade/src/monitor-daemon.js +715 -0
- package/dist/packages/autotrade/src/monitor.js +190 -0
- package/dist/packages/autotrade/src/ollama-sessions.js +137 -0
- package/dist/packages/autotrade/src/paper-accounts.js +72 -0
- package/dist/packages/autotrade/src/replay.js +198 -0
- package/dist/packages/autotrade/src/runner-config.js +99 -0
- package/dist/packages/autotrade/src/runner.js +872 -0
- package/dist/packages/autotrade/src/safety.js +243 -0
- package/dist/packages/autotrade/src/session.js +269 -0
- package/dist/packages/autotrade/src/sessions.js +503 -0
- package/dist/packages/autotrade/src/store.js +250 -0
- package/dist/packages/autotrade/src/strategy.js +256 -0
- package/dist/packages/autotrade/src/trade-ledger.js +241 -0
- package/dist/packages/autotrade/src/yolo.js +140 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +4 -4
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +1 -1
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +15 -8
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +1 -1
- package/dist/packages/cli/src/agent-cmd.js +6 -4
- package/dist/packages/cli/src/agent-spectator.js +17 -1
- package/dist/packages/cli/src/ai-cmd.js +412 -0
- package/dist/packages/cli/src/analyst/backend.js +105 -15
- package/dist/packages/cli/src/analyst/codex.js +14 -9
- package/dist/packages/cli/src/analyst/larp-gauge.js +60 -0
- package/dist/packages/cli/src/analyst/ollama.js +263 -0
- package/dist/packages/cli/src/analyst/session.js +17 -3
- package/dist/packages/cli/src/app-help-gen.js +51 -15
- package/dist/packages/cli/src/autotrade-cmd/audit.js +57 -0
- package/dist/packages/cli/src/autotrade-cmd/backtest.js +199 -0
- package/dist/packages/cli/src/autotrade-cmd/flatten.js +64 -0
- package/dist/packages/cli/src/autotrade-cmd/fleet.js +397 -0
- package/dist/packages/cli/src/autotrade-cmd/requests.js +79 -0
- package/dist/packages/cli/src/autotrade-cmd/run.js +413 -0
- package/dist/packages/cli/src/autotrade-cmd/stop.js +76 -0
- package/dist/packages/cli/src/autotrade-cmd/strategies.js +198 -0
- package/dist/packages/cli/src/autotrade-cmd/strategy-control.js +174 -0
- package/dist/packages/cli/src/autotrade-cmd/telegram-host.js +101 -0
- package/dist/packages/cli/src/autotrade-cmd.js +168 -0
- package/dist/packages/cli/src/backtest-cmd.js +13 -2
- package/dist/packages/cli/src/display.js +208 -23
- package/dist/packages/cli/src/doctor.js +62 -3
- package/dist/packages/cli/src/export.js +10 -2
- package/dist/packages/cli/src/index.js +1 -1
- package/dist/packages/cli/src/ledger-cmd.js +288 -0
- package/dist/packages/cli/src/live/chart-fills.js +46 -0
- package/dist/packages/cli/src/live/dev-history.js +31 -15
- package/dist/packages/cli/src/live/index.js +28 -90
- package/dist/packages/cli/src/live/screens/newpairs-live.js +1 -1
- package/dist/packages/cli/src/live/screens/token-live.js +48 -15
- package/dist/packages/cli/src/live/screens/watchlist-live.js +1 -1
- package/dist/packages/cli/src/live/trade.js +30 -453
- package/dist/packages/cli/src/live/watchlist.js +1 -1
- package/dist/packages/cli/src/main.js +290 -199
- package/dist/packages/cli/src/mcp-cmd.js +7 -1
- package/dist/packages/cli/src/mcp-follow.js +103 -6
- package/dist/packages/cli/src/plan-cmd.js +15 -5
- package/dist/packages/cli/src/pnl-app.js +31 -21
- package/dist/packages/cli/src/pnl-card.js +158 -21
- package/dist/packages/cli/src/popup.js +38 -6
- package/dist/packages/cli/src/record-cmd.js +141 -20
- package/dist/packages/cli/src/scan/ui.js +7 -1
- package/dist/packages/cli/src/screens/agent-hud.js +773 -0
- package/dist/packages/cli/src/screens/analyst-session.js +11 -4
- package/dist/packages/cli/src/screens/analyst.js +3 -2
- package/dist/packages/cli/src/screens/autotrade-keys.js +169 -0
- package/dist/packages/cli/src/screens/autotrade-proposals.js +123 -0
- package/dist/packages/cli/src/screens/autotrade-screen.js +725 -0
- package/dist/packages/cli/src/screens/autotrade-ui.js +640 -0
- package/dist/packages/cli/src/screens/autotrade-worker.js +89 -0
- package/dist/packages/cli/src/screens/brain.js +634 -0
- package/dist/packages/cli/src/screens/newpairs.js +1 -1
- package/dist/packages/cli/src/screens/overlay.js +5 -2
- package/dist/packages/cli/src/screens/plans.js +5 -17
- package/dist/packages/cli/src/screens/pnl-chip.js +144 -0
- package/dist/packages/cli/src/screens/positions.js +6 -28
- package/dist/packages/cli/src/screens/screener.js +1 -0
- package/dist/packages/cli/src/screens/ticket.js +38 -205
- package/dist/packages/cli/src/screens/token.js +18 -9
- package/dist/packages/cli/src/screens/trending.js +1 -1
- package/dist/packages/cli/src/state.js +88 -18
- package/dist/packages/cli/src/telegram-cmd.js +451 -0
- package/dist/packages/cli/src/trade.js +3 -128
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wallet-cmd.js +39 -340
- package/dist/packages/cli/src/wallet-command.js +8 -11
- package/dist/packages/cli/src/widgets.js +0 -12
- package/dist/packages/custom/src/config.js +20 -2
- package/dist/packages/custom/src/schema.js +41 -10
- package/dist/packages/custom/src/workspace.js +3 -3
- package/dist/packages/data/src/basis.js +191 -0
- package/dist/packages/data/src/detail.js +53 -2
- package/dist/packages/data/src/index.js +2 -14
- package/dist/packages/data/src/larp/score.js +220 -0
- package/dist/packages/data/src/larp/x-account.js +316 -0
- package/dist/packages/data/src/store.js +14 -5
- package/dist/packages/data/src/trade/agent/paper-desk.js +300 -23
- package/dist/packages/data/src/trade/agent/rendezvous.js +3 -2
- package/dist/packages/data/src/trade/bridge.js +4 -102
- package/dist/packages/data/src/trade/ledger/export.js +101 -0
- package/dist/packages/data/src/trade/ledger/file.js +135 -0
- package/dist/packages/data/src/trade/ledger/fold.js +191 -0
- package/dist/packages/data/src/trade/ledger/index.js +9 -0
- package/dist/packages/data/src/trade/ledger/schema.js +83 -0
- package/dist/packages/data/src/trade/ledger/sources.js +307 -0
- package/dist/packages/data/src/trade/ledger/stats.js +123 -0
- package/dist/packages/data/src/trade/ledger/tags.js +116 -0
- package/dist/packages/data/src/trade/ledger/view.js +51 -0
- package/dist/packages/data/src/trade/plans/engine.js +7 -0
- package/dist/packages/data/src/trade/plans/index.js +0 -1
- package/dist/packages/data/src/trade/plans/mandate.js +28 -15
- package/dist/packages/data/src/trade/plans/service.js +4 -28
- package/dist/packages/mcp/assets/kb/app-help/autotrade-chat.md +48 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-parallel.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-runner.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-stop.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-strategies.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-telegram.md +40 -0
- package/dist/packages/mcp/assets/kb/app-help/chart-trade-markers.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/dev-history.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/larp-check.md +5 -1
- package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +4 -10
- package/dist/packages/mcp/assets/kb/app-help/live-trading.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/live-yolo.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/order-plans.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +5 -2
- package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +217 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-token.md +6 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +3 -0
- package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +2 -1
- package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +54 -0
- package/dist/packages/mcp/assets/kb/app-help/use-local-model.md +38 -0
- package/dist/packages/mcp/assets/kb/docs/docs-agents-agent-paper-trading.md +7 -3
- package/dist/packages/mcp/assets/kb/docs/docs-agents-reading-your-trade-ledger.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-agents-untrusted-by-default.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-if-claude-is-missing.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-starting-a-run.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-use-a-local-model-instead.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-analyst-what-it-sees.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-backtests-and-their-limits.md +56 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +49 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-checking-the-record-of-a-run.md +28 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-comparing-them.md +41 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-exits-and-wakes.md +76 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-how-a-stop-reaches-the-runner.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-one-strategy-or-all-of-them.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-paper-accounts.md +50 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-presets.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-sessions-and-their-limits.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-several-strategies-at-once.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-strategies-and-presets.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-autotrade-json-file.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-heartbeat.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-run-file-and-the-heartbeat.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-runner-and-candidate-sessions.md +45 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-each-session-shows-first.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-costs-claude-usage-per-session.md +58 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +43 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-which-account-day-caps-and-the-usage-guard.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-your-strategies.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +22 -0
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-chart-markers.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-chart-y-axis.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-environment-variables.md +4 -1
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-files-on-disk.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-price-display-mode.md +9 -7
- package/dist/packages/mcp/assets/kb/docs/docs-copy-trading-stage-2-the-one-key-mirror-ticket-a-proposal.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-copy-trading-stage-3-capped-auto-mirror-default-off-less.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-copy-trading-what-is-proven-what-is-not.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-data-sources-sources.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-first-run.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-running-the-tests.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +60 -0
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-verdict.md +0 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-what-it-looks-at.md +4 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +26 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-stopping.md +28 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading.md +23 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-another-machine.md +28 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-autotrade-on-a-local-model.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-check-your-model-dexterm-ai-probe.md +38 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-how-we-checked.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-models-without-tool-calling.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-pick-the-engine-globally-or-per-surface.md +43 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-restriction-profiles.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-set-it-up-in-two-minutes.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-troubleshooting.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-what-runs-on-a-local-model.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-what-stays-outside-the-model.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-which-model.md +38 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama-your-own-agent-on-the-local-model.md +28 -0
- package/dist/packages/mcp/assets/kb/docs/docs-ollama.md +28 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-letting-an-agent-paper-trade-experimental.md +3 -4
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-link-a-wallet-read-only.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-order-plans-and-standing-mandates.md +4 -13
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-panic.md +4 -7
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-paper-only-mandates.md +40 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-pnl-cards.md +7 -7
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-the-safety-model.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-the-ticket.md +3 -4
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-the-trade-ledger.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-the-wallet.md +4 -5
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-trade-moments.md +4 -5
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +4 -5
- package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-buy-and-sell-markers.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-market-cap-axis.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-popup-chart-what-it-shows.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-scan-the-read.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-autotrade-paper.md +26 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-dev-history.md +10 -7
- package/dist/packages/mcp/assets/kb/docs/docs-screens-order-plans.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-screens-token-screen.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-follow-over-mcp-in-the-running-terminal.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-limits.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-agent-hud-spectator-v2.md +58 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-brain-view-and-the-pnl-chip.md +46 -0
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-the-three-surfaces.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-approve-one-tap-entries.md +50 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-control-the-commands.md +43 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-if-something-goes-wrong.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-notify-what-reaches-your-phone.md +41 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-set-it-up.md +47 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-the-record.md +26 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-will-not-do.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram.md +23 -0
- package/dist/packages/mcp/assets/kb/docs/docs-troubleshooting-common-questions.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-troubleshooting-doctor-lines.md +4 -0
- package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-voice.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-yolo-1-replay-it-on-your-own-history.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-yolo-2-run-it-on-paper.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-yolo-3-watch-it-and-stop-it.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-yolo-the-rules.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-yolo-what-it-is.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-yolo-what-it-will-not-do.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-yolo.md +26 -0
- package/dist/packages/mcp/assets/kb/entries/autotrade.md +38 -0
- package/dist/packages/mcp/src/app-help.js +1 -1
- package/dist/packages/mcp/src/autotrade-tools.js +944 -0
- package/dist/packages/mcp/src/backtest-requests.js +71 -0
- package/dist/packages/mcp/src/engine/config.js +159 -0
- package/dist/packages/mcp/src/engine/index.js +10 -0
- package/dist/packages/mcp/src/engine/loop.js +101 -0
- package/dist/packages/mcp/src/engine/mcp-client.js +123 -0
- package/dist/packages/mcp/src/engine/ollama.js +258 -0
- package/dist/packages/mcp/src/engine/probe.js +110 -0
- package/dist/packages/mcp/src/engine/reason.js +35 -0
- package/dist/packages/mcp/src/engine/restrictions.js +111 -0
- package/dist/packages/mcp/src/index.js +7 -1
- package/dist/packages/mcp/src/ledger-tools.js +346 -0
- package/dist/packages/mcp/src/paper-agent.js +5 -2
- package/dist/packages/mcp/src/server.js +285 -36
- package/dist/packages/mcp/src/spectator-sink.js +85 -0
- package/dist/packages/packs/src/backtest.js +3 -1
- package/dist/packages/packs/src/index.js +1 -0
- package/dist/packages/packs/src/larp-score-backtest.js +119 -0
- package/dist/packages/popup/src/pages/chart-lib.js +169 -35
- package/dist/packages/popup/src/pages/chart.css +18 -0
- package/dist/packages/popup/src/pages/chart.html +6 -1
- package/dist/packages/popup/src/pages/chart.js +136 -10
- package/dist/packages/popup/src/pages/spectator.css +45 -0
- package/dist/packages/popup/src/pages/spectator.html +1 -0
- package/dist/packages/popup/src/pages/spectator.js +69 -0
- package/dist/packages/popup/src/protocol.js +4 -0
- package/dist/packages/popup/src/server.js +9 -1
- package/dist/packages/recorder/src/dev-history.js +7 -3
- package/dist/packages/recorder/src/index.js +2 -0
- package/dist/packages/recorder/src/recorder.js +63 -6
- package/dist/packages/recorder/src/retention.js +100 -0
- package/dist/packages/recorder/src/schema.js +34 -1
- package/dist/packages/recorder/src/ticks.js +237 -0
- package/dist/packages/tui/src/canvas.js +7 -0
- package/dist/packages/tui/src/fmt.js +53 -0
- package/dist/packages/tui/src/index.js +1 -0
- package/dist/packages/tui/src/keymap.js +25 -1
- package/dist/packages/tui/src/markers.js +219 -0
- package/dist/packages/tui/src/widgets.js +158 -6
- package/package.json +2 -1
- package/dist/packages/data/src/trade/caps.js +0 -323
- package/dist/packages/data/src/trade/executor.js +0 -933
- package/dist/packages/data/src/trade/plans/executor-live.js +0 -220
- package/dist/packages/data/src/trade/rpc-exec.js +0 -229
- package/dist/packages/data/src/trade/signer/client.js +0 -197
- package/dist/packages/data/src/trade/signer/fast-wallet.js +0 -154
- package/dist/packages/data/src/trade/signer/host.js +0 -97
- package/dist/packages/data/src/trade/signer/main.js +0 -121
- package/dist/packages/data/src/trade/signer/probe-builder.js +0 -85
- package/dist/packages/data/src/trade/signer/protocol.js +0 -92
- package/dist/packages/data/src/trade/signer/service.js +0 -400
- package/dist/packages/data/src/trade/signer/sol-usd-feed.js +0 -98
- package/dist/packages/data/src/trade/sol-usd.js +0 -86
- package/dist/packages/data/src/trade/swap-build.js +0 -108
- package/dist/packages/data/src/trade/token-delta.js +0 -201
- package/dist/packages/data/vendor/solana-web3/LICENSE +0 -20
- package/dist/packages/data/vendor/solana-web3/NOTICE.md +0 -13
- package/dist/packages/data/vendor/solana-web3/index.iife.min.js +0 -20
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-a-live-ticket.md +0 -42
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-before-you-press-size-vs-caps.md +0 -34
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-devnet-signer-mainnet-token.md +0 -35
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-jupiter-rate-limits.md +0 -33
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-link-a-wallet.md +0 -32
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-live-trading-devnet-first.md +0 -30
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-mainnet.md +0 -31
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-the-live-fire-path-devnet-the-fast-wallet.md +0 -41
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-the-signer-process.md +0 -34
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-what-the-live-path-keeps.md +0 -37
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading-your-first-live-trade.md +0 -41
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
// @dexterm/mcp — the AUTOTRADE chat tools (task-1507 AT-5, AUTOTRADE-SPEC §2a). PAPER ONLY.
|
|
2
|
+
//
|
|
3
|
+
// The analyst (and any MCP agent) changes a strategy ONLY through these seven tools:
|
|
4
|
+
//
|
|
5
|
+
// strategy_list observe ids, names, versions, hashes, mode, last backtest line, pending proposals
|
|
6
|
+
// strategy_get observe one document (+ its exit profiles resolved) and its validation warnings
|
|
7
|
+
// strategy_validate observe {ok, errors[{path,msg}], warnings[]} for a whole document
|
|
8
|
+
// strategy_propose_edit propose JSON-patch ops (<= 20) on a user strategy -> a PROPOSAL, never applied
|
|
9
|
+
// strategy_propose_new propose a new strategy from a preset (+ ops) -> a PROPOSAL, never applied
|
|
10
|
+
// strategy_backtest observe a strategy or a proposal on recorded history (days <= 14), read-only, rate-limited
|
|
11
|
+
// autotrade_status observe the paper runner's status file, strategies and pending proposals
|
|
12
|
+
//
|
|
13
|
+
// A proposal is a file in `$DEXTERM_HOME/strategies/.proposals/`. Nothing in this module writes a strategy:
|
|
14
|
+
// the user applies a proposal on the Q screen with a keypress (a LOOSER one needs a second keypress, the
|
|
15
|
+
// "less safe" popup), and that path re-checks everything here from scratch (`checkProposal`) instead of trusting
|
|
16
|
+
// the stored file. The model deny-list (`STRATEGY_DENIED_FOR_MODELS`) is checked on the document AFTER the ops,
|
|
17
|
+
// so replacing a parent object cannot smuggle a change to `/mode`, `/bankroll`, `/agent/budgetUsdPerDay` or
|
|
18
|
+
// `/execution/minExitLiquidityUsd` past it.
|
|
19
|
+
//
|
|
20
|
+
// @dexterm/autotrade imports @dexterm/mcp (the firewall), so the server loads the engine lazily (`loadEngine`);
|
|
21
|
+
// the CLI hands its own static import to the same functions.
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { firewallText } from '../../data/src/index.js';
|
|
26
|
+
import { firewall } from "./firewall.js";
|
|
27
|
+
import { dextermHome } from "./user-content.js";
|
|
28
|
+
export async function loadEngine() {
|
|
29
|
+
const [at, custom] = await Promise.all([import('../../autotrade/src/index.js'), import('../../custom/src/index.js')]);
|
|
30
|
+
return { at, applyOps: custom.applyOps };
|
|
31
|
+
}
|
|
32
|
+
export const STRATEGY_READ_TOOLS = ['strategy_list', 'strategy_get', 'strategy_validate', 'strategy_backtest', 'autotrade_status'];
|
|
33
|
+
/** Write-shaped (a proposal file), so they are not on the read-only surface a skill may name. */
|
|
34
|
+
export const STRATEGY_PROPOSE_TOOLS = ['strategy_propose_edit', 'strategy_propose_new'];
|
|
35
|
+
export const AUTOTRADE_TOOLS = [...STRATEGY_READ_TOOLS, ...STRATEGY_PROPOSE_TOOLS];
|
|
36
|
+
/**
|
|
37
|
+
* task-1529, parallel strategies (`dexterm autotrade run --all --paper`): two reads, and start / stop of ONE strategy
|
|
38
|
+
* listed in the user's autotrade.json (a per-strategy control file; paper only, its own config: a tool can never add
|
|
39
|
+
* a strategy, change a bankroll or a cap, or start anything that is not paper). Start / stop are write-shaped: not on
|
|
40
|
+
* the read-only surface a skill may name, and an observe-only session (AI scan) never gets them.
|
|
41
|
+
*/
|
|
42
|
+
export const PARALLEL_READ_TOOLS = ['autotrade_parallel_list', 'autotrade_parallel_compare'];
|
|
43
|
+
export const PARALLEL_CONTROL_TOOLS = ['autotrade_parallel_start', 'autotrade_parallel_stop'];
|
|
44
|
+
export const PARALLEL_TOOLS = [...PARALLEL_READ_TOOLS, ...PARALLEL_CONTROL_TOOLS];
|
|
45
|
+
export const MAX_PROPOSAL_OPS = 20;
|
|
46
|
+
export const BACKTEST_MAX_DAYS = 14;
|
|
47
|
+
/** Backtests: one at a time, at least this far apart, and at most this many per hour per server process. */
|
|
48
|
+
export const BACKTEST_RATE = { minGapMs: 20_000, perHour: 12 };
|
|
49
|
+
/** Proposals: at most this many pending at once (oldest are not dropped: the model is told to wait). */
|
|
50
|
+
export const MAX_PENDING_PROPOSALS = 20;
|
|
51
|
+
export const PROPOSAL_SCHEMA = 'dexterm-strategy-proposal/1';
|
|
52
|
+
const DAY_MS = 86_400_000;
|
|
53
|
+
// ───────────────────────────── the model deny-list ─────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* Fields only the USER may change (same idea as custom `DENIED_FOR_MODELS`). A model can never switch a strategy
|
|
56
|
+
* to live, change the book, raise its own daily spend or lower the floor under which a bag counts as stuck.
|
|
57
|
+
*/
|
|
58
|
+
export const STRATEGY_DENIED_FOR_MODELS = [
|
|
59
|
+
{ pointer: '/mode', why: 'only you can change the mode (a model can never switch a strategy to live)' },
|
|
60
|
+
{ pointer: '/bankroll', why: 'only you can change the book' },
|
|
61
|
+
{ pointer: '/agent/budgetUsdPerDay', why: 'only you can change the daily spend (a model can never raise its own budget)' },
|
|
62
|
+
{ pointer: '/execution/minExitLiquidityUsd', why: 'only you can change the stuck-bag floor' },
|
|
63
|
+
];
|
|
64
|
+
/** Set by the store, never by an op: the id names the file, the version is bumped on apply. */
|
|
65
|
+
const STORE_OWNED = ['/id', '/kind', '/schema', '/version'];
|
|
66
|
+
const under = (path, pointer) => path === pointer || path.startsWith(`${pointer}/`);
|
|
67
|
+
export function pointerGet(doc, pointer) {
|
|
68
|
+
if (pointer === '')
|
|
69
|
+
return doc;
|
|
70
|
+
let cur = doc;
|
|
71
|
+
for (const raw of pointer.slice(1).split('/')) {
|
|
72
|
+
const k = raw.replaceAll('~1', '/').replaceAll('~0', '~');
|
|
73
|
+
if (cur === null || typeof cur !== 'object')
|
|
74
|
+
return undefined;
|
|
75
|
+
cur = cur[k];
|
|
76
|
+
}
|
|
77
|
+
return cur;
|
|
78
|
+
}
|
|
79
|
+
export function deepEqual(a, b) {
|
|
80
|
+
if (a === b)
|
|
81
|
+
return true;
|
|
82
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
|
|
83
|
+
return false;
|
|
84
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
85
|
+
return false;
|
|
86
|
+
const ka = Object.keys(a).filter((k) => a[k] !== undefined);
|
|
87
|
+
const kb = Object.keys(b).filter((k) => b[k] !== undefined);
|
|
88
|
+
if (ka.length !== kb.length)
|
|
89
|
+
return false;
|
|
90
|
+
return ka.every((k) => deepEqual(a[k], b[k]));
|
|
91
|
+
}
|
|
92
|
+
/** Shape check of model-supplied ops: 1..20 add/replace/remove ops with a JSON-pointer path and a bounded value. */
|
|
93
|
+
export function opsShapeErrors(ops, o = {}) {
|
|
94
|
+
if (!Array.isArray(ops))
|
|
95
|
+
return [{ path: '', msg: 'ops must be a list of JSON-patch ops' }];
|
|
96
|
+
if (ops.length === 0 && !o.allowEmpty)
|
|
97
|
+
return [{ path: '', msg: 'no ops' }];
|
|
98
|
+
if (ops.length > MAX_PROPOSAL_OPS)
|
|
99
|
+
return [{ path: '', msg: `${ops.length} ops is more than ${MAX_PROPOSAL_OPS} in one proposal` }];
|
|
100
|
+
const errors = [];
|
|
101
|
+
ops.forEach((raw, i) => {
|
|
102
|
+
const op = raw;
|
|
103
|
+
if (!op || typeof op !== 'object') {
|
|
104
|
+
errors.push({ path: `/ops/${i}`, msg: 'not an op object' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!['add', 'replace', 'remove'].includes(String(op.op)))
|
|
108
|
+
errors.push({ path: `/ops/${i}`, msg: `op ${JSON.stringify(op.op)} is not allowed (add, replace or remove)` });
|
|
109
|
+
if (typeof op.path !== 'string' || !/^(\/[^/]*)+$/.test(op.path) || op.path.length > 200)
|
|
110
|
+
errors.push({ path: `/ops/${i}`, msg: 'path must be a JSON pointer like /risk/maxConcurrent' });
|
|
111
|
+
if (op.op !== 'remove' && !Object.hasOwn(op, 'value'))
|
|
112
|
+
errors.push({ path: `/ops/${i}`, msg: `${String(op.op)} needs a value` });
|
|
113
|
+
if (JSON.stringify(op.value ?? null).length > 20_000)
|
|
114
|
+
errors.push({ path: `/ops/${i}`, msg: 'value too large' });
|
|
115
|
+
const extra = Object.keys(op).filter((k) => !['op', 'path', 'value'].includes(k));
|
|
116
|
+
if (extra.length)
|
|
117
|
+
errors.push({ path: `/ops/${i}`, msg: `unknown op field ${extra[0]}` });
|
|
118
|
+
});
|
|
119
|
+
return errors;
|
|
120
|
+
}
|
|
121
|
+
/** Model policy on the op paths (before applying): store-owned and user-only fields are refused by name. */
|
|
122
|
+
export function opsPolicyErrors(ops) {
|
|
123
|
+
const errors = [];
|
|
124
|
+
for (const op of ops) {
|
|
125
|
+
if (op.path === '') {
|
|
126
|
+
errors.push({ path: '', msg: 'the whole document cannot be replaced by a proposal' });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const owned = STORE_OWNED.find((p) => under(op.path, p));
|
|
130
|
+
if (owned)
|
|
131
|
+
errors.push({ path: op.path, msg: `${owned} is set by the strategy store, not by a proposal` });
|
|
132
|
+
const denied = STRATEGY_DENIED_FOR_MODELS.find((d) => under(op.path, d.pointer));
|
|
133
|
+
if (denied)
|
|
134
|
+
errors.push({ path: op.path, msg: denied.why });
|
|
135
|
+
}
|
|
136
|
+
return errors;
|
|
137
|
+
}
|
|
138
|
+
/** Model policy on the result: every user-only field must be unchanged between the base and the proposed document. */
|
|
139
|
+
export function deniedChanges(before, after) {
|
|
140
|
+
return STRATEGY_DENIED_FOR_MODELS
|
|
141
|
+
.filter((d) => !deepEqual(pointerGet(before, d.pointer), pointerGet(after, d.pointer)))
|
|
142
|
+
.map((d) => ({ path: d.pointer, msg: d.why }));
|
|
143
|
+
}
|
|
144
|
+
const P = '/exits/profiles/[^/]+';
|
|
145
|
+
const r = (s) => new RegExp(`^${s}$`);
|
|
146
|
+
/**
|
|
147
|
+
* Which way is less safe for every risk number in a resolved strategy: more size, more exposure, more loss, fewer
|
|
148
|
+
* or later stops, fewer safety gates, more spend, a more optimistic fill model. A changed field that is in neither
|
|
149
|
+
* this list nor NEUTRAL_PATHS counts as LOOSER (fail closed), and so does a value a rule cannot compare (null, NaN,
|
|
150
|
+
* a string where a number belongs): chat `/use` and live gate on this comparator.
|
|
151
|
+
*/
|
|
152
|
+
export const RISK_RULES = [
|
|
153
|
+
{ re: r('/mode'), kind: 'enum', order: ['paper', 'live'] },
|
|
154
|
+
// bankroll: every %-of-book cap scales with it
|
|
155
|
+
{ re: r('/bankroll/startUsd'), kind: 'num', looser: 'up' },
|
|
156
|
+
{ re: r('/bankroll/source'), kind: 'enum', order: [] },
|
|
157
|
+
// averaging down: a ladder that appears is looser; any change inside one too (more tranches, deeper, later)
|
|
158
|
+
{ re: r('/sizing/ladder'), kind: 'added' },
|
|
159
|
+
{ re: r('/sizing/ladder/.+'), kind: 'any' },
|
|
160
|
+
// entry
|
|
161
|
+
{ re: r('/entry/ageHours/min'), kind: 'num', looser: 'down' },
|
|
162
|
+
{ re: r('/entry/ageHours/max'), kind: 'num', looser: 'up', nullLoosest: true },
|
|
163
|
+
{ re: r('/entry/safety/(maxTop10Pct|maxDevPct|maxMcapSplitX)'), kind: 'num', looser: 'up', nullLoosest: true },
|
|
164
|
+
{ re: r('/entry/safety/requireNo(Mint|Freeze)Authority'), kind: 'bool', looserWhen: false },
|
|
165
|
+
{ re: r('/entry/safety/youngVertical'), kind: 'gone' },
|
|
166
|
+
{ re: r('/entry/safety/youngVertical/maxAgeHours'), kind: 'num', looser: 'down' },
|
|
167
|
+
{ re: r('/entry/safety/youngVertical/maxH1Pct'), kind: 'num', looser: 'up' },
|
|
168
|
+
{ re: r('/entry/safety/unknownHolders'), kind: 'enum', order: ['reject', 'allow'] },
|
|
169
|
+
// task-1532: the LARP gate (removing it, or a higher allowed score, or allowing an unknown result is looser)
|
|
170
|
+
{ re: r('/entry/safety/larp'), kind: 'gone' },
|
|
171
|
+
{ re: r('/entry/safety/larp/maxScore'), kind: 'num', looser: 'up' },
|
|
172
|
+
{ re: r('/entry/safety/larp/unknown'), kind: 'enum', order: ['reject', 'allow'] },
|
|
173
|
+
{ re: r('/entry/triggerMode'), kind: 'enum', order: ['all', 'any'] },
|
|
174
|
+
{ re: r('/entry/decide'), kind: 'enum', order: ['agent+approve', 'agent', 'auto'] },
|
|
175
|
+
// sizing
|
|
176
|
+
{ re: r('/sizing/mode'), kind: 'enum', order: [] }, // any change of mode: compare the sizes yourself
|
|
177
|
+
{ re: r('/sizing/(fixedUsd|pct|riskPct|maxPctOfBankroll|maxUsd|minUsd)'), kind: 'num', looser: 'up' },
|
|
178
|
+
{ re: r('/sizing/confidence/(minPct|maxPct|default)'), kind: 'num', looser: 'up' },
|
|
179
|
+
// risk
|
|
180
|
+
{ re: r('/risk/perTrade/maxLossPctOfBankroll'), kind: 'num', looser: 'up' },
|
|
181
|
+
{ re: r('/risk/perTrade/assumedLossPctNoStop'), kind: 'num', looser: 'down' },
|
|
182
|
+
{ re: r('/risk/dailyLossHaltPct'), kind: 'num', looser: 'up' },
|
|
183
|
+
{ re: r('/risk/drawdown/(sizeDownAtPct|sizeDownMult|recoverBelowPct|haltAtPct)'), kind: 'num', looser: 'up' },
|
|
184
|
+
{ re: r('/risk/(maxConcurrent|maxExposurePct|maxEntriesPerDay)'), kind: 'num', looser: 'up' },
|
|
185
|
+
{ re: r('/risk/lossStreak/losses'), kind: 'num', looser: 'up' },
|
|
186
|
+
{ re: r('/risk/(lossStreak|perCoin)/cooldownMin'), kind: 'num', looser: 'down' },
|
|
187
|
+
{ re: r('/risk/perCoin/maxEntries'), kind: 'num', looser: 'up' },
|
|
188
|
+
{ re: r('/risk/recoveredBags'), kind: 'added' },
|
|
189
|
+
{ re: r('/risk/recoveredBags/maxBags'), kind: 'num', looser: 'up' },
|
|
190
|
+
{ re: r('/risk/recoveredBags/countAsOpen'), kind: 'bool', looserWhen: false },
|
|
191
|
+
// agent spend
|
|
192
|
+
{ re: r('/agent/(maxSessions|budgetUsdPerSession|budgetUsdPerDay|maxTurns|sessionMaxMin)'), kind: 'num', looser: 'up' },
|
|
193
|
+
{ re: r('/agent/deltaEverySec'), kind: 'num', looser: 'down' },
|
|
194
|
+
{ re: r('/agent/respawnOnWake'), kind: 'bool', looserWhen: true },
|
|
195
|
+
// execution (a cheaper or faster fill model is a rosier backtest)
|
|
196
|
+
{ re: r('/execution/(slippageBps|maxPriceImpactPct)'), kind: 'num', looser: 'up' },
|
|
197
|
+
{ re: r('/execution/(feeBps|networkFeeUsd|latencyMs|minExitLiquidityUsd)'), kind: 'num', looser: 'down' },
|
|
198
|
+
// exit profiles (by id)
|
|
199
|
+
{ re: r(`${P}/sizing/maxSizePctOfBankroll`), kind: 'num', looser: 'up' },
|
|
200
|
+
{ re: r(`${P}/fit/minLiquidityUsd`), kind: 'num', looser: 'down' },
|
|
201
|
+
{ re: r(`${P}/hardStop/enabled`), kind: 'bool', looserWhen: false },
|
|
202
|
+
{ re: r(`${P}/hardStop/atPct`), kind: 'num', looser: 'down', nullLoosest: true },
|
|
203
|
+
{ re: r(`${P}/(trailingStop|timeStop|costRecovery|bagTrail)`), kind: 'gone' },
|
|
204
|
+
{ re: r(`${P}/(trailingStop|bagTrail)/(trailPct|armAtPct)`), kind: 'num', looser: 'up' },
|
|
205
|
+
{ re: r(`${P}/timeStop/minutes`), kind: 'num', looser: 'up' },
|
|
206
|
+
{ re: r(`${P}/timeStop/ifBelowPct`), kind: 'num', looser: 'down' },
|
|
207
|
+
{ re: r(`${P}/timeStop/action`), kind: 'enum', order: ['exit', 'tighten', 'review'] },
|
|
208
|
+
{ re: r(`${P}/thesisBreak/liquidityDropPct`), kind: 'num', looser: 'up' },
|
|
209
|
+
{ re: r(`${P}/thesisBreak/mcapDivergenceX`), kind: 'num', looser: 'up', nullLoosest: true },
|
|
210
|
+
{ re: r(`${P}/thesisBreak/devSell/exitOnAnyDevSell`), kind: 'bool', looserWhen: false },
|
|
211
|
+
{ re: r(`${P}/thesisBreak/devSell/topHolderSellPctOfBag`), kind: 'num', looser: 'up', nullLoosest: true },
|
|
212
|
+
{ re: r(`${P}/thesisBreak/volumeDeath`), kind: 'gone' },
|
|
213
|
+
{ re: r(`${P}/thesisBreak/volumeDeath/m5TradesBelow`), kind: 'num', looser: 'down' },
|
|
214
|
+
{ re: r(`${P}/thesisBreak/volumeDeath/forMinutes`), kind: 'num', looser: 'up' },
|
|
215
|
+
{ re: r(`${P}/reentry/allowed`), kind: 'bool', looserWhen: true },
|
|
216
|
+
{ re: r(`${P}/reentry/cooldownMinutes`), kind: 'num', looser: 'down' },
|
|
217
|
+
{ re: r(`${P}/reentry/maxEntriesPerCoinPerSession`), kind: 'num', looser: 'up' },
|
|
218
|
+
{ re: r(`${P}/carry/allowed`), kind: 'bool', looserWhen: true },
|
|
219
|
+
{ re: r(`${P}/carry/requiresTpFilled`), kind: 'num', looser: 'down' },
|
|
220
|
+
{ re: r(`${P}/carry/maxRemainderPctOfBankroll`), kind: 'num', looser: 'up' },
|
|
221
|
+
{ re: r(`${P}/wake/noAnswer`), kind: 'enum', order: ['exit', 'hold'] },
|
|
222
|
+
{ re: r(`${P}/maxHoldMinutes`), kind: 'num', looser: 'up', nullLoosest: true },
|
|
223
|
+
{ re: r(`${P}/costRecovery/atPct`), kind: 'num', looser: 'up' },
|
|
224
|
+
{ re: r(`${P}/costRecovery/maxSellFrac`), kind: 'num', looser: 'down' },
|
|
225
|
+
];
|
|
226
|
+
/** Changes that carry no risk (names, prose, versions), and paths another comparator in riskDelta already judges. */
|
|
227
|
+
export const NEUTRAL_PATHS = [
|
|
228
|
+
r('/(kind|schema|id|name|version|description)'), r('/notes(/.*)?'), r('/highRisk'), // a label: the caps and the clamp decide the same
|
|
229
|
+
r(`${P}/(kind|id|name|version|description)`), r(`${P}/notes(/.*)?`), r(`${P}/fit/(summary|signals(/.*)?)`),
|
|
230
|
+
r(`${P}/hardStop/why`), r(`${P}/reentry/requires`),
|
|
231
|
+
// judged above: the screen, the triggers, the model, the default profile, the re-profile edges
|
|
232
|
+
r('/entry/screen'), r('/entry/triggers(/.*)?'), r('/agent/model'), r('/exits/default'), r(`${P}/reprofileTo(/.*)?`),
|
|
233
|
+
];
|
|
234
|
+
/** Every node of a JSON value by pointer (objects and arrays too), in document order. */
|
|
235
|
+
export function flatten(v, at = '', out = new Map()) {
|
|
236
|
+
out.set(at, v);
|
|
237
|
+
if (v && typeof v === 'object') {
|
|
238
|
+
for (const [k, x] of Array.isArray(v) ? v.entries() : Object.entries(v)) {
|
|
239
|
+
if (x === undefined)
|
|
240
|
+
continue;
|
|
241
|
+
flatten(x, `${at}/${String(k).replaceAll('~', '~0').replaceAll('/', '~1')}`, out);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
const isLeaf = (v) => v === null || typeof v !== 'object';
|
|
247
|
+
const show = (v) => (v === undefined ? '(none)' : typeof v === 'string' ? v : JSON.stringify(v));
|
|
248
|
+
/** A view of a resolved strategy for diffs and risk rules: no hash, profiles keyed by id. */
|
|
249
|
+
export function riskView(s) {
|
|
250
|
+
const { hash: _h, ...rest } = s;
|
|
251
|
+
return rest;
|
|
252
|
+
}
|
|
253
|
+
const MONEY = /^-?\d+(\.\d+)?[KMB]?$/i;
|
|
254
|
+
const money = (s) => { const m = s.match(/^(-?\d+(?:\.\d+)?)([KMB]?)$/i); if (!m)
|
|
255
|
+
return NaN; return Number(m[1]) * { '': 1, K: 1e3, M: 1e6, B: 1e9 }[m[2].toUpperCase()]; };
|
|
256
|
+
/** The screen filter: a dropped clause is looser, an added clause tighter; a moved threshold by its direction. */
|
|
257
|
+
export function screenDelta(before, after) {
|
|
258
|
+
if (before === after)
|
|
259
|
+
return [];
|
|
260
|
+
const split = (s) => s.trim().split(/\s+/).filter(Boolean);
|
|
261
|
+
// `field op value` triples or single tags (`!flag:mint`, `launchpad:pump`)
|
|
262
|
+
const clauses = (s) => {
|
|
263
|
+
const t = split(s);
|
|
264
|
+
const out = [];
|
|
265
|
+
for (let i = 0; i < t.length; i++) {
|
|
266
|
+
if (/^(>=|<=|!=|>|<|=)$/.test(t[i + 1] ?? '') && t[i + 2] !== undefined) {
|
|
267
|
+
out.push(`${t[i]} ${t[i + 1]} ${t[i + 2]}`);
|
|
268
|
+
i += 2;
|
|
269
|
+
}
|
|
270
|
+
else
|
|
271
|
+
out.push(t[i]);
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
};
|
|
275
|
+
const a = clauses(before);
|
|
276
|
+
const b = clauses(after);
|
|
277
|
+
const reasons = [];
|
|
278
|
+
const left = b.filter((c) => !a.includes(c));
|
|
279
|
+
for (const c of a.filter((x) => !b.includes(x))) {
|
|
280
|
+
const [f, op, v] = c.split(' ');
|
|
281
|
+
const twin = op && MONEY.test(v ?? '') ? left.find((x) => { const [f2, op2, v2] = x.split(' '); return f2 === f && op2 === op && MONEY.test(v2 ?? ''); }) : undefined;
|
|
282
|
+
if (twin) {
|
|
283
|
+
left.splice(left.indexOf(twin), 1);
|
|
284
|
+
const from = money(v);
|
|
285
|
+
const to = money(twin.split(' ')[2]);
|
|
286
|
+
const looser = op.startsWith('>') ? to < from : op.startsWith('<') ? to > from : true;
|
|
287
|
+
reasons.push({ path: '/entry/screen', from: c, to: twin, dir: looser ? 'looser' : 'tighter' });
|
|
288
|
+
}
|
|
289
|
+
else
|
|
290
|
+
reasons.push({ path: '/entry/screen', from: c, to: '(removed)', dir: 'looser', note: 'a screen clause was dropped' });
|
|
291
|
+
}
|
|
292
|
+
for (const c of left)
|
|
293
|
+
reasons.push({ path: '/entry/screen', from: '(none)', to: c, dir: 'tighter', note: 'a screen clause was added' });
|
|
294
|
+
return reasons;
|
|
295
|
+
}
|
|
296
|
+
const TRIGGER_NUMS = { momentum: ['m5MinPct', 'buySellMin'], narrative: ['minHeat'], wallet: ['minBuyUsd'] };
|
|
297
|
+
/** The entry triggers: with `any`, an added trigger kind is looser; with `all`, a removed one; lower thresholds are looser. */
|
|
298
|
+
export function triggerDelta(before, after) {
|
|
299
|
+
if (deepEqual(before.triggers, after.triggers))
|
|
300
|
+
return [];
|
|
301
|
+
const reasons = [];
|
|
302
|
+
const kinds = (e) => new Map(e.triggers.map((t) => [t.kind, t]));
|
|
303
|
+
// Two triggers of one kind cannot be compared by kind (the Map keeps the last): any such change fails closed.
|
|
304
|
+
const dup = (e) => new Set(e.triggers.map((t) => t.kind)).size !== e.triggers.length;
|
|
305
|
+
if (dup(before) || dup(after))
|
|
306
|
+
return [{ path: '/entry/triggers', from: show(before.triggers), to: show(after.triggers), dir: 'looser', note: 'two triggers of one kind: counts as less safe' }];
|
|
307
|
+
const a = kinds(before);
|
|
308
|
+
const b = kinds(after);
|
|
309
|
+
const mode = after.triggerMode;
|
|
310
|
+
for (const [k] of b)
|
|
311
|
+
if (!a.has(k))
|
|
312
|
+
reasons.push({ path: '/entry/triggers', from: '(none)', to: k, dir: mode === 'any' ? 'looser' : 'tighter', note: `a ${k} trigger was added (${mode})` });
|
|
313
|
+
for (const [k] of a)
|
|
314
|
+
if (!b.has(k))
|
|
315
|
+
reasons.push({ path: '/entry/triggers', from: k, to: '(removed)', dir: mode === 'all' ? 'looser' : 'tighter', note: `the ${k} trigger was removed (${mode})` });
|
|
316
|
+
for (const [k, ta] of a) {
|
|
317
|
+
const tb = b.get(k);
|
|
318
|
+
if (!tb)
|
|
319
|
+
continue;
|
|
320
|
+
for (const f of TRIGGER_NUMS[k] ?? []) {
|
|
321
|
+
const x = Number(ta[f]);
|
|
322
|
+
const y = Number(tb[f]);
|
|
323
|
+
if (Object.is(x, y))
|
|
324
|
+
continue;
|
|
325
|
+
// A value that cannot be compared fails closed.
|
|
326
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
327
|
+
reasons.push({ path: `/entry/triggers/${k}/${f}`, from: show(ta[f]), to: show(tb[f]), dir: 'looser', note: 'not a comparable number' });
|
|
328
|
+
else
|
|
329
|
+
reasons.push({ path: `/entry/triggers/${k}/${f}`, from: String(x), to: String(y), dir: y < x ? 'looser' : 'tighter' });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return reasons;
|
|
333
|
+
}
|
|
334
|
+
const MODEL_COST = (m) => (/haiku/i.test(m) ? 0 : /sonnet/i.test(m) ? 1 : /opus/i.test(m) ? 2 : 3);
|
|
335
|
+
/**
|
|
336
|
+
* Compare two resolved strategies: every risk number that got less safe is a `looser` reason, every one that got
|
|
337
|
+
* safer a `tighter` reason. One looser reason makes the whole edit LOOSER (it then needs the second keypress).
|
|
338
|
+
*/
|
|
339
|
+
export function riskDelta(before, after) {
|
|
340
|
+
const reasons = [];
|
|
341
|
+
const fa = flatten(riskView(before));
|
|
342
|
+
const fb = flatten(riskView(after));
|
|
343
|
+
const pa = Object.keys(before.exits.profiles);
|
|
344
|
+
const pb = Object.keys(after.exits.profiles);
|
|
345
|
+
for (const id of pb)
|
|
346
|
+
if (!pa.includes(id))
|
|
347
|
+
reasons.push({ path: `/exits/profiles/${id}`, from: '(none)', to: id, dir: 'looser', note: 'a new exit profile the agent may use' });
|
|
348
|
+
const bothProfiles = (p) => { const m = p.match(/^\/exits\/profiles\/([^/]+)/); return !m || (pa.includes(m[1]) && pb.includes(m[1])); };
|
|
349
|
+
if (before.exits.default !== after.exits.default)
|
|
350
|
+
reasons.push({ path: '/exits/default', from: before.exits.default, to: after.exits.default, dir: 'looser', note: 'the default exit profile changed (review its exits)' });
|
|
351
|
+
for (const id of pa.filter((x) => pb.includes(x))) {
|
|
352
|
+
const ea = before.exits.profiles[id].reprofileTo;
|
|
353
|
+
const eb = after.exits.profiles[id].reprofileTo;
|
|
354
|
+
for (const t of eb)
|
|
355
|
+
if (!ea.includes(t))
|
|
356
|
+
reasons.push({ path: `/exits/profiles/${id}/reprofileTo`, from: '(none)', to: t, dir: 'looser', note: 'a new re-profile edge' });
|
|
357
|
+
}
|
|
358
|
+
reasons.push(...screenDelta(before.entry.screen, after.entry.screen));
|
|
359
|
+
reasons.push(...triggerDelta(before.entry, after.entry));
|
|
360
|
+
if (before.agent.model !== after.agent.model && MODEL_COST(after.agent.model) !== MODEL_COST(before.agent.model)) {
|
|
361
|
+
reasons.push({ path: '/agent/model', from: before.agent.model, to: after.agent.model, dir: MODEL_COST(after.agent.model) > MODEL_COST(before.agent.model) ? 'looser' : 'tighter', note: 'session spend' });
|
|
362
|
+
}
|
|
363
|
+
const paths = [...new Set([...fa.keys(), ...fb.keys()])].filter(bothProfiles);
|
|
364
|
+
const has = (v) => v !== null && v !== undefined;
|
|
365
|
+
const isObj = (v) => v !== null && typeof v === 'object';
|
|
366
|
+
// A leaf under an object that a gone/added rule judges as a whole (a stop removed, a ladder added) is not judged again.
|
|
367
|
+
const judgedAbove = (p) => {
|
|
368
|
+
for (let q = p.slice(0, p.lastIndexOf('/')); q; q = q.slice(0, q.lastIndexOf('/'))) {
|
|
369
|
+
const ru = RISK_RULES.find((x) => x.re.test(q));
|
|
370
|
+
if (ru && (ru.kind === 'gone' || ru.kind === 'added') && has(fa.get(q)) !== has(fb.get(q)))
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
};
|
|
375
|
+
const failClosed = (p, x, y, note) => { reasons.push({ path: p, from: show(x), to: show(y), dir: 'looser', note }); };
|
|
376
|
+
for (const p of paths) {
|
|
377
|
+
const x = fa.get(p);
|
|
378
|
+
const y = fb.get(p);
|
|
379
|
+
if (deepEqual(x, y))
|
|
380
|
+
continue;
|
|
381
|
+
const rule = RISK_RULES.find((ru) => ru.re.test(p));
|
|
382
|
+
if (!rule) {
|
|
383
|
+
// Fail closed: a changed field with no rule is less safe, unless it is prose or judged elsewhere. Objects and
|
|
384
|
+
// arrays are judged by their leaves.
|
|
385
|
+
if (p === '' || NEUTRAL_PATHS.some((re) => re.test(p)) || (isObj(x) && isObj(y)) || judgedAbove(p))
|
|
386
|
+
continue;
|
|
387
|
+
if (isObj(x) || isObj(y)) {
|
|
388
|
+
if (has(x) && has(y))
|
|
389
|
+
failClosed(p, x, y, 'changed shape: no risk rule');
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
failClosed(p, x, y, 'no risk rule for this field: counts as less safe');
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
let dir = null;
|
|
396
|
+
if (rule.kind === 'num') {
|
|
397
|
+
const inf = rule.looser === 'up' ? Infinity : -Infinity;
|
|
398
|
+
const n = (v) => (typeof v === 'number' ? v : (v === null || v === undefined) && rule.nullLoosest ? inf : NaN);
|
|
399
|
+
const a = n(x);
|
|
400
|
+
const b = n(y);
|
|
401
|
+
if (a === b)
|
|
402
|
+
continue;
|
|
403
|
+
if (Number.isNaN(a) || Number.isNaN(b)) {
|
|
404
|
+
if (!judgedAbove(p))
|
|
405
|
+
failClosed(p, x, y, 'not a comparable number');
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
dir = (rule.looser === 'up' ? b > a : b < a) ? 'looser' : 'tighter';
|
|
409
|
+
}
|
|
410
|
+
else if (rule.kind === 'bool') {
|
|
411
|
+
if (typeof x !== 'boolean' || typeof y !== 'boolean') {
|
|
412
|
+
if (!judgedAbove(p))
|
|
413
|
+
failClosed(p, x, y, 'not a comparable yes/no');
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
dir = y === rule.looserWhen ? 'looser' : 'tighter';
|
|
417
|
+
}
|
|
418
|
+
else if (rule.kind === 'any') {
|
|
419
|
+
if (isObj(x) && isObj(y))
|
|
420
|
+
continue;
|
|
421
|
+
if (!judgedAbove(p))
|
|
422
|
+
failClosed(p, x, y, 'any change here is less safe');
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
else if (rule.kind === 'enum') {
|
|
426
|
+
const rank = (v) => { const i = rule.order.indexOf(String(v)); return i < 0 ? rule.order.length : i; };
|
|
427
|
+
dir = rule.order.length === 0 || rank(y) > rank(x) ? 'looser' : rank(y) < rank(x) ? 'tighter' : null;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
if (has(x) === has(y))
|
|
431
|
+
continue;
|
|
432
|
+
dir = has(x) === (rule.kind === 'gone') ? 'looser' : 'tighter';
|
|
433
|
+
reasons.push({ path: p, from: has(x) ? 'on' : 'off', to: has(y) ? 'on' : 'off', dir, note: has(x) ? 'removed' : 'added' });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (dir)
|
|
437
|
+
reasons.push({ path: p, from: show(x), to: show(y), dir });
|
|
438
|
+
}
|
|
439
|
+
const delta = reasons.some((x) => x.dir === 'looser') ? 'looser' : reasons.some((x) => x.dir === 'tighter') ? 'tighter' : 'neutral';
|
|
440
|
+
return { delta, reasons };
|
|
441
|
+
}
|
|
442
|
+
/** Red/green field lines between two resolved strategies (profiles by id, so inlining a builtin shows nothing). */
|
|
443
|
+
export function diffLines(before, after, max = 60) {
|
|
444
|
+
const fa = before ? flatten(riskView(before)) : new Map();
|
|
445
|
+
const fb = flatten(riskView(after));
|
|
446
|
+
const out = [];
|
|
447
|
+
const paths = [...new Set([...fa.keys(), ...fb.keys()])];
|
|
448
|
+
for (const p of paths) {
|
|
449
|
+
const x = fa.get(p);
|
|
450
|
+
const y = fb.get(p);
|
|
451
|
+
const lx = fa.has(p) && isLeaf(x);
|
|
452
|
+
const ly = fb.has(p) && isLeaf(y);
|
|
453
|
+
if (!lx && !ly)
|
|
454
|
+
continue;
|
|
455
|
+
if (lx && ly && x === y)
|
|
456
|
+
continue;
|
|
457
|
+
if (p === '/version')
|
|
458
|
+
continue; // the store bumps it on apply; the card shows the versions in its title
|
|
459
|
+
if (lx)
|
|
460
|
+
out.push(`- ${p} ${show(x)}`);
|
|
461
|
+
if (ly)
|
|
462
|
+
out.push(`+ ${p} ${show(y)}`);
|
|
463
|
+
}
|
|
464
|
+
if (out.length > max)
|
|
465
|
+
return [...out.slice(0, max), `… ${out.length - max} more changed lines`];
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
export const proposalsDir = (home) => join(home, 'strategies', '.proposals');
|
|
469
|
+
const PROPOSAL_ID = /^sp_[a-z0-9]{6,12}_[a-f0-9]{8}$/;
|
|
470
|
+
export const isProposalId = (x) => typeof x === 'string' && PROPOSAL_ID.test(x);
|
|
471
|
+
/** Clean model-authored text for the terminal: no control/bidi characters, one line, bounded. */
|
|
472
|
+
export function cleanText(s, max = 200) {
|
|
473
|
+
return firewallText(s === undefined || s === null ? '' : String(s), { max }).text.replace(/\s+/g, ' ').trim();
|
|
474
|
+
}
|
|
475
|
+
/** The proposal files. The MCP tools create pending ones; only the user's keypress path resolves them. */
|
|
476
|
+
export class ProposalFiles {
|
|
477
|
+
dir;
|
|
478
|
+
constructor(home) { this.dir = proposalsDir(home); }
|
|
479
|
+
path(id) {
|
|
480
|
+
if (!isProposalId(id))
|
|
481
|
+
throw new Error(`bad proposal id ${JSON.stringify(id)}`);
|
|
482
|
+
return join(this.dir, `${id}.json`);
|
|
483
|
+
}
|
|
484
|
+
get(id) {
|
|
485
|
+
if (!isProposalId(id))
|
|
486
|
+
return null;
|
|
487
|
+
const p = this.path(id);
|
|
488
|
+
if (!existsSync(p))
|
|
489
|
+
return null;
|
|
490
|
+
try {
|
|
491
|
+
const r = JSON.parse(readFileSync(p, 'utf8'));
|
|
492
|
+
return r && r.schema === PROPOSAL_SCHEMA && r.proposalId === id ? r : null;
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** Newest first. */
|
|
499
|
+
list(filter = {}) {
|
|
500
|
+
if (!existsSync(this.dir))
|
|
501
|
+
return [];
|
|
502
|
+
const out = [];
|
|
503
|
+
for (const f of readdirSync(this.dir)) {
|
|
504
|
+
if (!f.endsWith('.json'))
|
|
505
|
+
continue;
|
|
506
|
+
const r = this.get(f.slice(0, -5));
|
|
507
|
+
if (!r)
|
|
508
|
+
continue;
|
|
509
|
+
if (filter.status && r.status !== filter.status)
|
|
510
|
+
continue;
|
|
511
|
+
if (filter.id && r.id !== filter.id)
|
|
512
|
+
continue;
|
|
513
|
+
out.push(r);
|
|
514
|
+
}
|
|
515
|
+
return out.sort((a, b) => b.createdAt - a.createdAt || (a.proposalId < b.proposalId ? 1 : -1));
|
|
516
|
+
}
|
|
517
|
+
write(rec) {
|
|
518
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
519
|
+
const p = this.path(rec.proposalId);
|
|
520
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
521
|
+
writeFileSync(tmp, JSON.stringify(rec, null, 2) + '\n', { mode: 0o600 });
|
|
522
|
+
renameSync(tmp, p);
|
|
523
|
+
}
|
|
524
|
+
/** Mark a proposal applied or dismissed. Called by the user's keypress path only. */
|
|
525
|
+
resolve(id, status, extra = {}, now = Date.now()) {
|
|
526
|
+
const r = this.get(id);
|
|
527
|
+
if (!r)
|
|
528
|
+
return null;
|
|
529
|
+
const next = { ...r, ...extra, status, resolvedAt: now };
|
|
530
|
+
this.write(next);
|
|
531
|
+
return next;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
export function newProposalId(now = Date.now()) {
|
|
535
|
+
return `sp_${now.toString(36)}_${randomBytes(4).toString('hex')}`;
|
|
536
|
+
}
|
|
537
|
+
const bad = (stage, errors, warnings = []) => ({ ok: false, stage, errors, warnings });
|
|
538
|
+
/**
|
|
539
|
+
* Inline the builtin profile a tuning op reaches into: `replace /exits/profiles/0/trailingStop/trailPct` on a
|
|
540
|
+
* `"builtin:swing"` reference first replaces the reference with a copy of the swing profile, so the op has a field
|
|
541
|
+
* to change. The inline ops are part of what the store applies (and of the 20-op limit).
|
|
542
|
+
*/
|
|
543
|
+
function inlineOps(raw, ops, library) {
|
|
544
|
+
const out = [];
|
|
545
|
+
const done = new Set();
|
|
546
|
+
for (const op of ops) {
|
|
547
|
+
const m = op.path.match(/^\/exits\/profiles\/(\d+)\/./);
|
|
548
|
+
if (!m)
|
|
549
|
+
continue;
|
|
550
|
+
const i = Number(m[1]);
|
|
551
|
+
const ref = raw.exits?.profiles?.[i];
|
|
552
|
+
if (done.has(i) || typeof ref !== 'string' || !ref.startsWith('builtin:'))
|
|
553
|
+
continue;
|
|
554
|
+
const p = library[ref.slice('builtin:'.length)];
|
|
555
|
+
if (!p)
|
|
556
|
+
continue;
|
|
557
|
+
out.push({ op: 'replace', path: `/exits/profiles/${i}`, value: structuredClone(p) });
|
|
558
|
+
done.add(i);
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Build (or re-check, at apply time) a proposal from scratch: the base document, the model policy on the paths and
|
|
564
|
+
* on the result, the validator, the risk delta and the diff. Pure apart from reading the strategy store.
|
|
565
|
+
*/
|
|
566
|
+
export function checkProposal(eng, store, input) {
|
|
567
|
+
const { at } = eng;
|
|
568
|
+
const shape = opsShapeErrors(input.ops, { allowEmpty: input.kind === 'new' });
|
|
569
|
+
if (shape.length)
|
|
570
|
+
return bad('input', shape);
|
|
571
|
+
const ops = input.ops;
|
|
572
|
+
if (typeof input.id !== 'string' || !at.STRATEGY_ID.test(input.id))
|
|
573
|
+
return bad('input', [{ path: '/id', msg: 'id must be lowercase letters, digits and dashes (2-32 chars, starting with a letter)' }]);
|
|
574
|
+
const policy = opsPolicyErrors(ops);
|
|
575
|
+
if (policy.length)
|
|
576
|
+
return bad('policy', policy);
|
|
577
|
+
const library = at.builtinProfiles();
|
|
578
|
+
let baseRaw;
|
|
579
|
+
let start;
|
|
580
|
+
if (input.kind === 'edit') {
|
|
581
|
+
const got = store.get(input.id);
|
|
582
|
+
if (!got)
|
|
583
|
+
return bad('input', [{ path: '/id', msg: `no strategy ${input.id} (strategy_list shows the ids)` }]);
|
|
584
|
+
if (got.source !== 'user')
|
|
585
|
+
return bad('input', [{ path: '/id', msg: `${input.id} is a shipped preset (read-only): use strategy_propose_new to make your own copy first` }]);
|
|
586
|
+
if (!got.ok)
|
|
587
|
+
return bad('input', [{ path: '/id', msg: `the stored ${input.id} does not validate (${got.errors.map(at.fmtIssue).join('; ')}); fix it on the Q screen first` }]);
|
|
588
|
+
const raw = store.raw(input.id);
|
|
589
|
+
if (!raw)
|
|
590
|
+
return bad('input', [{ path: '/id', msg: `${input.id} is not readable` }]);
|
|
591
|
+
baseRaw = raw;
|
|
592
|
+
start = raw;
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
const from = input.from ?? '';
|
|
596
|
+
if (!at.STRATEGY_ID.test(from))
|
|
597
|
+
return bad('input', [{ path: '/fromPreset', msg: 'fromPreset must be a preset id (strategy_list shows them)' }]);
|
|
598
|
+
const raw = store.raw(from);
|
|
599
|
+
if (!raw)
|
|
600
|
+
return bad('input', [{ path: '/fromPreset', msg: `no preset or strategy ${from}` }]);
|
|
601
|
+
if (store.has(input.id) || store.presetIds().includes(input.id))
|
|
602
|
+
return bad('input', [{ path: '/id', msg: `a strategy ${input.id} already exists (pick another id)` }]);
|
|
603
|
+
const name = cleanText(input.name, 80) || `${raw.name} (copy)`;
|
|
604
|
+
baseRaw = raw;
|
|
605
|
+
// the store's own fields, then always paper: a new strategy from chat can never start live
|
|
606
|
+
start = { ...raw, id: input.id, name, version: '1.0.0', mode: 'paper' };
|
|
607
|
+
}
|
|
608
|
+
const beforeCheck = at.validateStrategy(baseRaw, library);
|
|
609
|
+
if (!beforeCheck.ok)
|
|
610
|
+
return bad('input', [{ path: '', msg: `the base strategy does not validate: ${beforeCheck.errors.map(at.fmtIssue).join('; ')}` }]);
|
|
611
|
+
const effectiveOps = [...inlineOps(start, ops, library), ...ops];
|
|
612
|
+
if (effectiveOps.length > MAX_PROPOSAL_OPS)
|
|
613
|
+
return bad('input', [{ path: '', msg: `${ops.length} ops plus ${effectiveOps.length - ops.length} inlined builtin profile(s) is more than ${MAX_PROPOSAL_OPS}: propose fewer changes at once` }]);
|
|
614
|
+
let afterRaw;
|
|
615
|
+
try {
|
|
616
|
+
afterRaw = eng.applyOps(start, effectiveOps).doc;
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
return bad('input', [{ path: '', msg: e.message }]);
|
|
620
|
+
}
|
|
621
|
+
if (input.kind === 'edit')
|
|
622
|
+
afterRaw = { ...afterRaw, version: at.bumpPatch(baseRaw.version) };
|
|
623
|
+
const denied = deniedChanges(input.kind === 'new' ? { ...baseRaw, mode: 'paper' } : baseRaw, afterRaw);
|
|
624
|
+
if (denied.length)
|
|
625
|
+
return bad('policy', denied);
|
|
626
|
+
const check = at.validateStrategy(afterRaw, library);
|
|
627
|
+
if (!check.ok)
|
|
628
|
+
return bad('validation', check.errors, check.warnings);
|
|
629
|
+
const { delta, reasons } = riskDelta(beforeCheck.strategy, check.strategy);
|
|
630
|
+
const diff = diffLines(beforeCheck.strategy, check.strategy);
|
|
631
|
+
if (input.kind === 'new')
|
|
632
|
+
diff.unshift(`+ new strategy ${input.id} from ${input.from}`);
|
|
633
|
+
return {
|
|
634
|
+
ok: true, baseRaw: input.kind === 'edit' ? baseRaw : null, afterRaw, before: beforeCheck.strategy, after: check.strategy, effectiveOps,
|
|
635
|
+
validation: { ok: true, errors: [], warnings: check.warnings }, riskDelta: delta, reasons, diff, baseVersion: baseRaw.version,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/** The display card the Q screen shows (AT-6 `StrategyProposal`). */
|
|
639
|
+
export function proposalCardView(r) {
|
|
640
|
+
return { proposalId: r.proposalId, id: r.kind === 'new' ? `${r.id} (new)` : r.id, beforeHash: r.beforeHash, afterHash: r.afterHash, diff: r.diff, validation: { ok: r.validation.ok, errors: r.validation.errors }, riskDelta: r.riskDelta, why: r.why };
|
|
641
|
+
}
|
|
642
|
+
// ───────────────────────────── the runner status file (read-only) ─────────────────────────────
|
|
643
|
+
export const RUNNER_STATUS_SCHEMA = 'dexterm-autotrade-runner/1';
|
|
644
|
+
/** The runner's rendezvous (`runnerRendezvousPath`, written by `dexterm autotrade run`), read-only. */
|
|
645
|
+
function runnerStatus(at, home, now) {
|
|
646
|
+
const p = at.runnerRendezvousPath(home);
|
|
647
|
+
if (!existsSync(p))
|
|
648
|
+
return { state: 'not running', note: 'no paper runner is running (start one with `dexterm autotrade run <strategy> --paper`)' };
|
|
649
|
+
let d;
|
|
650
|
+
try {
|
|
651
|
+
d = JSON.parse(readFileSync(p, 'utf8'));
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return { state: 'unreadable', note: 'the runner status file is not JSON' };
|
|
655
|
+
}
|
|
656
|
+
if (d.schema !== at.RUNNER_RENDEZVOUS_SCHEMA)
|
|
657
|
+
return { state: 'unreadable', note: `unknown status file shape ${JSON.stringify(d.schema)}` };
|
|
658
|
+
if (d.mode !== 'paper')
|
|
659
|
+
return { state: 'refused', note: 'the status file does not say paper; only paper runners exist' };
|
|
660
|
+
const list = (k) => (Array.isArray(d[k]) ? d[k].slice(0, 20) : []);
|
|
661
|
+
const sym = (x) => firewall(x, 'runner:symbol', 'symbol');
|
|
662
|
+
const hb = typeof d.heartbeatAt === 'number' ? d.heartbeatAt : null;
|
|
663
|
+
return {
|
|
664
|
+
paper: true, state: d.state, haltReason: typeof d.haltReason === 'string' ? cleanText(d.haltReason) : null,
|
|
665
|
+
heartbeatAgeSec: hb ? Math.round((now - hb) / 1000) : null, stale: hb ? now - hb > 30_000 : true,
|
|
666
|
+
strategy: d.strategy ?? null, book: d.book ?? null, spend: d.spend ?? null,
|
|
667
|
+
candidates: list('candidates').map((c) => ({ mint: c.mint, symbol: sym(c.symbol), ageSec: c.ageSec, matched: c.matched, session: c.session ?? null })),
|
|
668
|
+
sessions: list('sessions').map((s) => ({ id: s.id, mint: s.mint, symbol: sym(s.symbol), turns: s.turns, costUsd: s.costUsd, budgetUsd: s.budgetUsd, lastDecision: typeof s.lastDecision === 'string' ? cleanText(s.lastDecision, 120) : null })),
|
|
669
|
+
positions: list('positions').map((x) => ({ mint: x.mint, symbol: sym(x.symbol), profile: x.profile, costUsd: x.costUsd, pnlUsd: x.pnlUsd, pnlPct: x.pnlPct, next: typeof x.next === 'string' ? cleanText(x.next, 80) : null, freeBag: x.freeBag ?? false })),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
// ───────────────────────────── the tools ─────────────────────────────
|
|
673
|
+
const TEXT_KEYS = new Set(['name', 'description', 'why', 'summary', 'signals', 'requires', 'notes']);
|
|
674
|
+
/** Strategy files can come from a shared pack: their free text reaches a model only through the firewall. */
|
|
675
|
+
function firewallDoc(v, source, key = '') {
|
|
676
|
+
if (typeof v === 'string')
|
|
677
|
+
return TEXT_KEYS.has(key) ? firewall(v, source, 'description') : v;
|
|
678
|
+
if (Array.isArray(v))
|
|
679
|
+
return v.map((x) => firewallDoc(x, source, key));
|
|
680
|
+
if (v && typeof v === 'object')
|
|
681
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, firewallDoc(x, source, k)]));
|
|
682
|
+
return v;
|
|
683
|
+
}
|
|
684
|
+
export class AutotradeTools {
|
|
685
|
+
env;
|
|
686
|
+
actor;
|
|
687
|
+
now;
|
|
688
|
+
opts;
|
|
689
|
+
engine = null;
|
|
690
|
+
backtestBusy = false;
|
|
691
|
+
backtestTimes = [];
|
|
692
|
+
proposalTimes = [];
|
|
693
|
+
constructor(o = {}) {
|
|
694
|
+
this.opts = o;
|
|
695
|
+
this.env = o.env ?? process.env;
|
|
696
|
+
this.actor = o.actor && /^[a-z][a-z0-9:._-]{0,63}$/.test(o.actor) ? o.actor : 'mcp-agent';
|
|
697
|
+
this.now = o.now ?? Date.now;
|
|
698
|
+
}
|
|
699
|
+
home() { return dextermHome(this.env); }
|
|
700
|
+
async eng() { return (this.engine ??= await (this.opts.engine ?? loadEngine)()); }
|
|
701
|
+
async store() { const { at } = await this.eng(); return new at.StrategyStore({ home: this.home(), now: this.now }); }
|
|
702
|
+
files() { return new ProposalFiles(this.home()); }
|
|
703
|
+
async call(name, args) {
|
|
704
|
+
try {
|
|
705
|
+
switch (name) {
|
|
706
|
+
case 'strategy_list': return await this.list();
|
|
707
|
+
case 'strategy_get': return await this.get(args);
|
|
708
|
+
case 'strategy_validate': return await this.validate(args);
|
|
709
|
+
case 'strategy_propose_edit': return await this.propose({ kind: 'edit', id: String(args.id ?? ''), ops: args.ops, why: args.why });
|
|
710
|
+
case 'strategy_propose_new': return await this.propose({ kind: 'new', id: String(args.id ?? ''), from: String(args.fromPreset ?? ''), name: args.name, ops: args.ops ?? [], why: args.why });
|
|
711
|
+
case 'strategy_backtest': return await this.backtest(args);
|
|
712
|
+
case 'autotrade_status': return await this.status();
|
|
713
|
+
case 'autotrade_parallel_list': return await this.parallelList();
|
|
714
|
+
case 'autotrade_parallel_compare': return await this.parallelCompare();
|
|
715
|
+
case 'autotrade_parallel_start': return await this.parallelControl(args, 'start');
|
|
716
|
+
case 'autotrade_parallel_stop': return await this.parallelControl(args, args.pauseOnly === true ? 'pause' : 'stop');
|
|
717
|
+
default: return { error: `unknown autotrade tool ${name}` };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch (e) {
|
|
721
|
+
return { error: cleanText(e.message, 300) };
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async list() {
|
|
725
|
+
const store = await this.store();
|
|
726
|
+
const pending = this.files().list({ status: 'pending' });
|
|
727
|
+
const strategies = store.list().map((e) => ({
|
|
728
|
+
id: e.id, source: e.source, ok: e.ok, name: e.name ? firewall(e.name, `strategy:${e.id}`, 'name') : null, version: e.version ?? null, hash: e.hash ?? null,
|
|
729
|
+
mode: e.mode ?? null, profiles: e.profiles ?? [], errors: e.errors ?? [],
|
|
730
|
+
lastBacktest: e.lastBacktest ? `net $${e.lastBacktest.netUsd} (${e.lastBacktest.netPct}%) · max drawdown ${e.lastBacktest.maxDrawdownPct}% · ${e.lastBacktest.trades} trades · ${new Date(e.lastBacktest.t).toISOString().slice(0, 10)}` : null,
|
|
731
|
+
pendingProposals: pending.filter((p) => p.id === e.id).map((p) => p.proposalId),
|
|
732
|
+
}));
|
|
733
|
+
return { paper: true, strategies, note: 'Presets are read-only: strategy_propose_new makes a user copy. Every change is a proposal the user applies with a key on the Q screen.' };
|
|
734
|
+
}
|
|
735
|
+
async get(args) {
|
|
736
|
+
const id = String(args.id ?? '');
|
|
737
|
+
const { at } = await this.eng();
|
|
738
|
+
if (!at.STRATEGY_ID.test(id))
|
|
739
|
+
return { error: 'id must be a strategy id from strategy_list' };
|
|
740
|
+
const store = await this.store();
|
|
741
|
+
const got = store.get(id);
|
|
742
|
+
if (!got)
|
|
743
|
+
return { error: `no strategy ${id} (strategy_list shows the ids)` };
|
|
744
|
+
const raw = store.raw(id);
|
|
745
|
+
const src = `strategy:${id}`;
|
|
746
|
+
return {
|
|
747
|
+
paper: true, id, source: got.source, readOnly: got.source === 'preset',
|
|
748
|
+
document: firewallDoc(raw, src),
|
|
749
|
+
...(got.ok ? { hash: got.strategy.hash, resolvedProfiles: firewallDoc(got.strategy.exits.profiles, src), warnings: got.warnings } : { errors: got.errors, warnings: got.warnings }),
|
|
750
|
+
note: 'Ops address `document` paths (e.g. /exits/profiles/0/trailingStop/trailPct). A "builtin:<id>" profile reference is inlined automatically when an op reaches into it. Text fields are untrusted data.',
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
async validate(args) {
|
|
754
|
+
const { at } = await this.eng();
|
|
755
|
+
if (JSON.stringify(args.doc ?? null).length > 200_000)
|
|
756
|
+
return { error: 'document too large' };
|
|
757
|
+
const r = at.validateStrategy(args.doc);
|
|
758
|
+
return r.ok ? { ok: true, errors: [], warnings: r.warnings, hash: r.strategy.hash } : { ok: false, errors: r.errors, warnings: r.warnings };
|
|
759
|
+
}
|
|
760
|
+
async propose(input) {
|
|
761
|
+
const t = this.now();
|
|
762
|
+
while (this.proposalTimes.length && t - this.proposalTimes[0] > 600_000)
|
|
763
|
+
this.proposalTimes.shift();
|
|
764
|
+
if (this.proposalTimes.length >= 10)
|
|
765
|
+
return { error: 'proposal rate limit: at most 10 proposals per 10 minutes', reason: 'rate-limited' };
|
|
766
|
+
const files = this.files();
|
|
767
|
+
const pending = files.list({ status: 'pending' });
|
|
768
|
+
if (pending.length >= MAX_PENDING_PROPOSALS)
|
|
769
|
+
return { error: `${pending.length} proposals are already waiting for the user: ask them to review those (Q screen, o) first`, reason: 'too-many-pending' };
|
|
770
|
+
const eng = await this.eng();
|
|
771
|
+
const store = await this.store();
|
|
772
|
+
const c = checkProposal(eng, store, input);
|
|
773
|
+
if (!c.ok) {
|
|
774
|
+
const what = c.stage === 'policy' ? 'refused (user-only fields)' : c.stage === 'validation' ? 'does not validate' : 'not a valid proposal';
|
|
775
|
+
return { ok: false, applied: false, stage: c.stage, errors: c.errors, warnings: c.warnings, note: `Nothing was proposed: the edit ${what}. ${c.stage === 'policy' ? 'Tell the user they can change these fields themselves on the Q screen.' : 'Fix the ops and propose again.'}` };
|
|
776
|
+
}
|
|
777
|
+
const ops = input.ops;
|
|
778
|
+
const twin = pending.find((p) => p.kind === input.kind && p.id === input.id && deepEqual(p.ops, ops) && p.beforeHash === c.before.hash);
|
|
779
|
+
if (twin)
|
|
780
|
+
return { ...this.view(twin), duplicate: true };
|
|
781
|
+
const rec = {
|
|
782
|
+
schema: PROPOSAL_SCHEMA, proposalId: newProposalId(t), kind: input.kind, id: input.id,
|
|
783
|
+
...(input.kind === 'new' ? { from: input.from, name: c.afterRaw.name } : {}),
|
|
784
|
+
actor: this.actor, createdAt: t, why: cleanText(input.why) || '(no reason given)', ops, effectiveOps: c.effectiveOps,
|
|
785
|
+
baseVersion: c.baseVersion, beforeHash: c.before.hash, afterHash: c.after.hash, diff: c.diff,
|
|
786
|
+
validation: c.validation, riskDelta: c.riskDelta, reasons: c.reasons, status: 'pending',
|
|
787
|
+
};
|
|
788
|
+
files.write(rec);
|
|
789
|
+
this.proposalTimes.push(t);
|
|
790
|
+
return this.view(rec);
|
|
791
|
+
}
|
|
792
|
+
view(r) {
|
|
793
|
+
return {
|
|
794
|
+
ok: true, applied: false, proposalId: r.proposalId, kind: r.kind, id: r.id, beforeHash: r.beforeHash, afterHash: r.afterHash,
|
|
795
|
+
diff: r.diff, validation: r.validation, riskDelta: r.riskDelta, reasons: r.reasons,
|
|
796
|
+
note: `This is a PROPOSAL and it is NOT applied. The user opens the Q screen, presses o to see the diff card and y to apply it${r.riskDelta === 'looser' ? '; it is LOOSER (less safe), so it needs a second y on the "less safe" popup' : ''}. You cannot apply it. strategy_backtest with this proposalId compares it with the current version.`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
async backtest(args) {
|
|
800
|
+
const days = args.days === undefined ? 7 : Number(args.days);
|
|
801
|
+
if (!Number.isInteger(days) || days < 1 || days > BACKTEST_MAX_DAYS)
|
|
802
|
+
return { error: `days must be a whole number from 1 to ${BACKTEST_MAX_DAYS}` };
|
|
803
|
+
if (args.tape !== undefined && args.tape !== 'recorded')
|
|
804
|
+
return { error: 'tape must be "recorded" (the recorder history)' };
|
|
805
|
+
if ((args.id === undefined) === (args.proposalId === undefined))
|
|
806
|
+
return { error: 'pass exactly one of id or proposalId' };
|
|
807
|
+
const t = this.now();
|
|
808
|
+
if (this.backtestBusy)
|
|
809
|
+
return { error: 'a backtest is already running; ask again when it finishes', reason: 'rate-limited' };
|
|
810
|
+
while (this.backtestTimes.length && t - this.backtestTimes[0] > 3_600_000)
|
|
811
|
+
this.backtestTimes.shift();
|
|
812
|
+
const last = this.backtestTimes[this.backtestTimes.length - 1];
|
|
813
|
+
if (last !== undefined && t - last < BACKTEST_RATE.minGapMs)
|
|
814
|
+
return { error: `backtests are at least ${BACKTEST_RATE.minGapMs / 1000} s apart; wait ${Math.ceil((BACKTEST_RATE.minGapMs - (t - last)) / 1000)} s`, reason: 'rate-limited' };
|
|
815
|
+
if (this.backtestTimes.length >= BACKTEST_RATE.perHour)
|
|
816
|
+
return { error: `at most ${BACKTEST_RATE.perHour} backtests per hour`, reason: 'rate-limited' };
|
|
817
|
+
const eng = await this.eng();
|
|
818
|
+
const { at } = eng;
|
|
819
|
+
const store = await this.store();
|
|
820
|
+
const subjects = [];
|
|
821
|
+
if (args.proposalId !== undefined) {
|
|
822
|
+
const rec = this.files().get(String(args.proposalId));
|
|
823
|
+
if (!rec)
|
|
824
|
+
return { error: `no proposal ${String(args.proposalId)}` };
|
|
825
|
+
const c = checkProposal(eng, store, { kind: rec.kind, id: rec.id, ops: rec.ops, from: rec.from, name: rec.name, why: rec.why });
|
|
826
|
+
if (!c.ok)
|
|
827
|
+
return { error: `the proposal no longer checks out: ${c.errors.map(at.fmtIssue).join('; ')}` };
|
|
828
|
+
subjects.push({ label: 'current', s: c.before }, { label: 'proposed', s: c.after });
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
const id = String(args.id);
|
|
832
|
+
if (!at.STRATEGY_ID.test(id))
|
|
833
|
+
return { error: 'id must be a strategy id from strategy_list' };
|
|
834
|
+
const got = store.get(id);
|
|
835
|
+
if (!got)
|
|
836
|
+
return { error: `no strategy ${id}` };
|
|
837
|
+
if (!got.ok)
|
|
838
|
+
return { error: `${id} does not validate: ${got.errors.map(at.fmtIssue).join('; ')}` };
|
|
839
|
+
subjects.push({ label: id, s: got.strategy });
|
|
840
|
+
}
|
|
841
|
+
this.backtestBusy = true;
|
|
842
|
+
this.backtestTimes.push(t);
|
|
843
|
+
try {
|
|
844
|
+
const until = t;
|
|
845
|
+
let tape;
|
|
846
|
+
if (this.opts.tape)
|
|
847
|
+
tape = this.opts.tape({ days, until });
|
|
848
|
+
else {
|
|
849
|
+
const db = this.opts.historyDb?.() ?? null;
|
|
850
|
+
if (!db)
|
|
851
|
+
return { error: 'no recorded history to backtest on (the recorder DB is missing; run the app with the recorder on first)' };
|
|
852
|
+
tape = at.tapeFromDoc(at.tapeFromDb(db, { since: until - days * DAY_MS, until, minPeakLiqUsd: 20_000, spanHours: 80 }));
|
|
853
|
+
}
|
|
854
|
+
const results = [];
|
|
855
|
+
for (const sub of subjects) {
|
|
856
|
+
const { report } = await at.backtest(sub.s, tape);
|
|
857
|
+
results.push({ label: sub.label, ...compactReport(report) });
|
|
858
|
+
}
|
|
859
|
+
return { paper: true, readOnly: true, days, tape: { source: tape.source, mints: tape.mints.length, ticks: tape.ticks.length }, results, limits: at.DATA_LIMITS, note: 'A backtest on recorded history with modelled fills, not a prediction. Nothing was saved. Quote the trade count: small n means little.' };
|
|
860
|
+
}
|
|
861
|
+
finally {
|
|
862
|
+
this.backtestBusy = false;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async status() {
|
|
866
|
+
const { at } = await this.eng();
|
|
867
|
+
const store = await this.store();
|
|
868
|
+
const pending = this.files().list({ status: 'pending' });
|
|
869
|
+
return {
|
|
870
|
+
paper: true,
|
|
871
|
+
runner: runnerStatus(at, this.home(), this.now()),
|
|
872
|
+
strategies: store.list().length,
|
|
873
|
+
pendingProposals: pending.map((p) => ({ proposalId: p.proposalId, id: p.id, kind: p.kind, riskDelta: p.riskDelta, createdAt: new Date(p.createdAt).toISOString() })),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
// ───────────────────────────── parallel strategies (task-1529) ─────────────────────────────
|
|
877
|
+
/** autotrade.json's strategies and, while `run --all` runs, each one's state. */
|
|
878
|
+
async parallelList() {
|
|
879
|
+
const { at } = await this.eng();
|
|
880
|
+
const home = this.home();
|
|
881
|
+
const cfg = at.loadFleetConfig(home);
|
|
882
|
+
const st = at.readFleetStatus(home, this.now());
|
|
883
|
+
const running = at.activeRunner(home)?.runId ?? null;
|
|
884
|
+
const live = st.ok && running === st.status.fleetId ? st.status : null;
|
|
885
|
+
const configured = cfg.ok ? cfg.config.strategies.map((m) => ({ id: m.id, basedOn: m.preset ?? 'a strategy file', bankroll: m.bankroll ?? null, account: m.account ?? null, mode: m.mode, enabled: m.enabled, caps: m.caps })) : [];
|
|
886
|
+
return {
|
|
887
|
+
paper: true,
|
|
888
|
+
config: cfg.ok ? { ok: true, strategies: configured, global: cfg.config.global, accounts: cfg.config.accounts ?? [] } : { ok: false, errors: cfg.errors.map((e) => cleanText(e, 200)) },
|
|
889
|
+
running: live ? { fleetId: live.fleetId, state: live.state, stale: st.ok && st.stale, strategies: live.members.map((m) => ({ id: m.id, runId: m.runId, state: m.state, haltReason: m.haltReason ? cleanText(m.haltReason, 80) : null, basedOn: `${m.basedOn.id} v${m.basedOn.version}`, bankrollUsd: m.bankroll.usd, account: typeof m.account === 'string' ? cleanText(m.account, 40) : null, open: m.open.length, candidates: m.candidates, sessions: m.sessions })) } : null,
|
|
890
|
+
note: live ? 'autotrade_parallel_compare shows the side-by-side numbers. Paper only, simulated fills; not financial advice.' : 'No parallel run is active. The user starts one in a terminal: `dexterm autotrade run --all --paper` (strategies come from autotrade/autotrade.json, which only the user edits).',
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
async parallelCompare() {
|
|
894
|
+
const { at } = await this.eng();
|
|
895
|
+
const home = this.home();
|
|
896
|
+
const st = at.readFleetStatus(home, this.now());
|
|
897
|
+
if (!st.ok)
|
|
898
|
+
return { paper: true, running: false, note: cleanText(st.why, 200) };
|
|
899
|
+
const running = at.activeRunner(home)?.runId === st.status.fleetId;
|
|
900
|
+
const sym = (x) => firewall(x, 'runner:symbol', 'symbol');
|
|
901
|
+
return {
|
|
902
|
+
paper: true, running, fleetId: st.status.fleetId, stale: running && st.stale, source: cleanText(st.status.source, 120),
|
|
903
|
+
strategies: at.compareRows(st.status), totals: at.fleetTotals(st.status), global: st.status.global,
|
|
904
|
+
missedRunners: st.status.members.flatMap((m) => m.stats.missedSample.map((x) => ({ strategy: m.id, symbol: sym(x.symbol), x: x.x }))),
|
|
905
|
+
table: at.compareTable(at.compareRows(st.status)),
|
|
906
|
+
// task-1538: shared paper accounts (several strategies on one simulated wallet); a strategy row's P/L is its own share.
|
|
907
|
+
accounts: (st.status.accounts ?? []).map((a) => ({ ...a, id: cleanText(a.id, 40), strategies: a.strategies.map((x) => cleanText(x, 40)) })),
|
|
908
|
+
note: 'Paper, simulated fills on real market data; a short run is not evidence. Quote the closed-trade count next to any win rate. Not financial advice.',
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/** Start or stop (or pause) ONE strategy of the running parallel run. Nothing else changes; never live. */
|
|
912
|
+
async parallelControl(args, action) {
|
|
913
|
+
const { at } = await this.eng();
|
|
914
|
+
const id = String(args.id ?? '');
|
|
915
|
+
if (!at.STRATEGY_ID.test(id))
|
|
916
|
+
return { error: 'id must be a strategy id from autotrade_parallel_list' };
|
|
917
|
+
const home = this.home();
|
|
918
|
+
const st = at.readFleetStatus(home, this.now());
|
|
919
|
+
if (!st.ok || at.activeRunner(home)?.runId !== st.status.fleetId)
|
|
920
|
+
return { error: 'no parallel run is active; the user starts one in a terminal (`dexterm autotrade run --all --paper`)' };
|
|
921
|
+
const m = st.status.members.find((x) => x.id === id);
|
|
922
|
+
// Paper only: a status that names anything else is refused (only paper strategies exist in this build).
|
|
923
|
+
if (m && m.mode !== 'paper')
|
|
924
|
+
return { error: `${id} is not a paper strategy; only the user can start or stop it, on their computer` };
|
|
925
|
+
const w = at.requestMemberControl(home, id, action, 'mcp', this.now());
|
|
926
|
+
if (!w.ok)
|
|
927
|
+
return { error: cleanText(w.why, 300) };
|
|
928
|
+
return {
|
|
929
|
+
paper: true, requested: action, strategy: id, already: w.already,
|
|
930
|
+
note: action === 'start' ? `${id} starts at the next tick with its own bankroll (or its shared paper account) and caps from autotrade.json; the other strategies are not touched.`
|
|
931
|
+
: action === 'pause' ? `${id} takes no new entries until resumed (the user resumes with \`dexterm autotrade resume --strategy ${id}\`); its open positions keep their exits.`
|
|
932
|
+
: `${id} stops at its next tick: no new entries, its sessions close, its mechanical exits keep running until it holds nothing. The other strategies keep running. A stop is final for that run.`,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function compactReport(r) {
|
|
937
|
+
const wr = r.winRate ? `${Math.round(r.winRate.value * 100)}% [${Math.round(r.winRate.lo * 100)}–${Math.round(r.winRate.hi * 100)}%]` : 'n/a';
|
|
938
|
+
return {
|
|
939
|
+
line: `net $${r.netUsd} (${r.netPct}%) · win ${wr} · max drawdown ${r.maxDrawdownPct}% · ${r.trades} trades · ${r.tail.rugs} rugs · ${r.stuck} stuck`,
|
|
940
|
+
strategy: r.strategy, trades: r.trades, wins: r.wins, losses: r.losses, winRate: r.winRate, netUsd: r.netUsd, netPct: r.netPct,
|
|
941
|
+
profitFactor: r.profitFactor, maxDrawdownPct: r.maxDrawdownPct, worstPct: r.worstPct, bestPct: r.bestPct, stuck: r.stuck, openAtEnd: r.openAtEnd,
|
|
942
|
+
tail: r.tail, byExit: r.byExit, byProfile: r.byProfile, clamps: r.clamps, refusals: r.refusals,
|
|
943
|
+
};
|
|
944
|
+
}
|