dexterm-cli 0.1.7 → 0.2.1
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/spectator.js +31 -3
- package/dist/packages/agents/src/tiers.js +12 -0
- package/dist/packages/agents/src/tools.js +13 -5
- package/dist/packages/autotrade/assets/limits.schema.json +569 -0
- package/dist/packages/autotrade/examples/executor/README.md +70 -0
- package/dist/packages/autotrade/examples/executor/jsonl-tail-executor.mjs +57 -0
- package/dist/packages/autotrade/examples/executor/kit.mjs +144 -0
- package/dist/packages/autotrade/examples/executor/signal-cmd-executor.mjs +25 -0
- package/dist/packages/autotrade/examples/executor/telegram-bot-recipe.md +47 -0
- package/dist/packages/autotrade/examples/executor/webhook-executor.mjs +47 -0
- package/dist/packages/autotrade/src/backtest.js +7 -2
- package/dist/packages/autotrade/src/chat/config.js +4 -2
- package/dist/packages/autotrade/src/chat/control.js +1 -0
- package/dist/packages/autotrade/src/chat/notify.js +16 -6
- package/dist/packages/autotrade/src/chat/secrets.js +25 -18
- package/dist/packages/autotrade/src/chat/service.js +3 -0
- package/dist/packages/autotrade/src/engine.js +16 -2
- package/dist/packages/autotrade/src/executor-health.js +217 -0
- package/dist/packages/autotrade/src/fleet.js +5 -2
- package/dist/packages/autotrade/src/forward.js +355 -0
- package/dist/packages/autotrade/src/index.js +21 -0
- package/dist/packages/autotrade/src/journey.js +477 -0
- package/dist/packages/autotrade/src/limits.js +682 -0
- package/dist/packages/autotrade/src/outbox.js +394 -0
- package/dist/packages/autotrade/src/paper-limits.js +87 -0
- package/dist/packages/autotrade/src/real-mode.js +688 -0
- package/dist/packages/autotrade/src/receipts.js +330 -0
- package/dist/packages/autotrade/src/report.js +168 -0
- package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
- package/dist/packages/autotrade/src/rugproof.js +231 -0
- package/dist/packages/autotrade/src/runner.js +194 -28
- package/dist/packages/autotrade/src/service-guard.js +125 -0
- package/dist/packages/autotrade/src/service-install.js +215 -0
- package/dist/packages/autotrade/src/service-recover.js +232 -0
- package/dist/packages/autotrade/src/service.js +289 -0
- package/dist/packages/autotrade/src/sessions.js +16 -7
- package/dist/packages/autotrade/src/signal-channels.js +312 -0
- package/dist/packages/autotrade/src/signal.js +196 -0
- package/dist/packages/autotrade/src/strategy-stats.js +344 -0
- package/dist/packages/autotrade/src/strategy.js +15 -2
- package/dist/packages/autotrade/src/trade-ledger.js +2 -2
- package/dist/packages/classifier/src/calibrate.js +138 -0
- package/dist/packages/classifier/src/classify.js +107 -0
- package/dist/packages/classifier/src/config.js +220 -0
- package/dist/packages/classifier/src/dossier.js +95 -0
- package/dist/packages/classifier/src/hardware.js +63 -0
- package/dist/packages/classifier/src/index.js +18 -0
- package/dist/packages/classifier/src/models.js +111 -0
- package/dist/packages/classifier/src/providers/heuristic.js +77 -0
- package/dist/packages/classifier/src/providers/http.js +63 -0
- package/dist/packages/classifier/src/providers/jev-open.js +86 -0
- package/dist/packages/classifier/src/providers/llm.js +89 -0
- package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
- package/dist/packages/classifier/src/questions.js +97 -0
- package/dist/packages/classifier/src/registry.js +42 -0
- package/dist/packages/classifier/src/snapshot.js +31 -0
- package/dist/packages/classifier/src/systemone.js +88 -0
- package/dist/packages/classifier/src/types.js +18 -0
- package/dist/packages/cli/assets/dash/dash.css +139 -0
- package/dist/packages/cli/assets/dash/dash.js +200 -0
- package/dist/packages/cli/assets/dash/icon.svg +1 -0
- package/dist/packages/cli/assets/dash/index.html +36 -0
- package/dist/packages/cli/src/analyst/backend.js +3 -2
- package/dist/packages/cli/src/analyst/codex.js +3 -2
- package/dist/packages/cli/src/app-help-gen.js +26 -2
- package/dist/packages/cli/src/autotrade-cmd/audit.js +19 -2
- package/dist/packages/cli/src/autotrade-cmd/backtest.js +21 -4
- package/dist/packages/cli/src/autotrade-cmd/executor.js +91 -0
- package/dist/packages/cli/src/autotrade-cmd/fleet.js +16 -5
- package/dist/packages/cli/src/autotrade-cmd/forward.js +283 -0
- package/dist/packages/cli/src/autotrade-cmd/journey.js +208 -0
- package/dist/packages/cli/src/autotrade-cmd/limits.js +393 -0
- package/dist/packages/cli/src/autotrade-cmd/receipt.js +63 -0
- package/dist/packages/cli/src/autotrade-cmd/report.js +79 -0
- package/dist/packages/cli/src/autotrade-cmd/run.js +268 -14
- package/dist/packages/cli/src/autotrade-cmd/service.js +873 -0
- package/dist/packages/cli/src/autotrade-cmd/signal.js +824 -0
- package/dist/packages/cli/src/autotrade-cmd.js +50 -4
- package/dist/packages/cli/src/classifier-cmd.js +353 -0
- package/dist/packages/cli/src/classifier-live.js +81 -0
- package/dist/packages/cli/src/dash/data.js +217 -0
- package/dist/packages/cli/src/dash/server.js +92 -0
- package/dist/packages/cli/src/dash-cmd.js +113 -0
- package/dist/packages/cli/src/doctor.js +34 -0
- package/dist/packages/cli/src/ledger-cmd.js +4 -2
- package/dist/packages/cli/src/live/screens/token-live.js +4 -0
- package/dist/packages/cli/src/live/signals.js +8 -0
- package/dist/packages/cli/src/main.js +98 -6
- package/dist/packages/cli/src/mcp-follow.js +21 -1
- package/dist/packages/cli/src/narratives-cmd.js +147 -0
- package/dist/packages/cli/src/narratives.js +114 -0
- package/dist/packages/cli/src/overlay/model.js +147 -0
- package/dist/packages/cli/src/overlay/page.js +146 -0
- package/dist/packages/cli/src/overlay/pnl.js +75 -0
- package/dist/packages/cli/src/overlay/server.js +170 -0
- package/dist/packages/cli/src/overlay-cmd.js +367 -0
- package/dist/packages/cli/src/pnl-card.js +4 -0
- package/dist/packages/cli/src/record-cmd.js +82 -11
- package/dist/packages/cli/src/rules-cmd.js +2 -1
- package/dist/packages/cli/src/rules.js +24 -4
- package/dist/packages/cli/src/screens/autotrade-journey.js +134 -0
- package/dist/packages/cli/src/screens/autotrade-keys.js +13 -0
- package/dist/packages/cli/src/screens/autotrade-screen.js +57 -6
- package/dist/packages/cli/src/screens/autotrade-ui.js +17 -7
- package/dist/packages/cli/src/screens/autotrade-worker.js +4 -2
- package/dist/packages/cli/src/screens/narrative.js +154 -2
- package/dist/packages/cli/src/state.js +1 -0
- package/dist/packages/cli/src/telegram-cmd.js +4 -1
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/custom/src/rules.js +5 -1
- package/dist/packages/data/src/trade/ledger/schema.js +1 -1
- package/dist/packages/mcp/assets/kb/app-help/autotrade-executor-kit.md +44 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-journey.md +46 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-limits.md +48 -0
- package/dist/packages/mcp/assets/kb/app-help/autotrade-real-signal.md +45 -0
- package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
- package/dist/packages/mcp/assets/kb/app-help/live-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -0
- package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/trade-ledger.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-change-a-strategy-by-chat.md +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-on-the-q-screen.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-running-it-all-day.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-stopping-it.md +2 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +59 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-is-paper-self-directed.md +9 -2
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade-what-it-will-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
- 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-copy-and-paste-without-surprises.md +0 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-1-ask-for-a-strategy.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-2-backtest-it-held-out-split.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-3-forward-test-it-on-live-data.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-4-promote-it-to-always-on-paper.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-5-real-only-if-you-choose.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +41 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-report.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-what-the-analyst-knows.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +26 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
- package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
- 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 +1 -0
- 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 +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
- package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-executor-health.md +38 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-paper-vs-real.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-digest.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-executor-kit.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-the-whole-path.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-what-dexterm-does-and-never-does.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real-your-limits.md +77 -0
- package/dist/packages/mcp/assets/kb/docs/docs-going-real.md +24 -0
- package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
- package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-changed-in-0-1-7.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-real-mode-your-strategy-s-trades-to-your.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-the-short-version.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-using-a-trading-bot-with-dexterm.md +0 -2
- package/dist/packages/mcp/assets/kb/docs/docs-live-trading-what-dexterm-does-not-do.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
- package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
- package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-receipts-what-your-executor-did.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-safety.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-set-it-up.md +47 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-channels.md +42 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-the-signal.md +48 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +37 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-will-not-do.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode-where-you-see-it.md +34 -0
- package/dist/packages/mcp/assets/kb/docs/docs-real-mode.md +27 -0
- package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
- package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
- package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
- package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
- package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
- package/dist/packages/mcp/src/autotrade-tools.js +168 -5
- package/dist/packages/mcp/src/engine/restrictions.js +3 -3
- package/dist/packages/mcp/src/index.js +1 -1
- package/dist/packages/mcp/src/ledger-tools.js +155 -7
- package/dist/packages/mcp/src/server.js +125 -10
- package/dist/packages/mcp/src/spectator-sink.js +27 -0
- package/dist/packages/narratives/src/cluster.js +193 -0
- package/dist/packages/narratives/src/embed.js +184 -0
- package/dist/packages/narratives/src/export.js +60 -0
- package/dist/packages/narratives/src/index.js +10 -0
- package/dist/packages/narratives/src/label.js +48 -0
- package/dist/packages/narratives/src/normalize.js +179 -0
- package/dist/packages/narratives/src/recorder.js +93 -0
- package/dist/packages/narratives/src/tracker.js +328 -0
- package/dist/packages/recorder/src/exit-probes.js +215 -0
- package/dist/packages/recorder/src/index.js +1 -0
- package/dist/packages/recorder/src/recorder.js +11 -1
- package/dist/packages/recorder/src/retention.js +4 -0
- package/dist/packages/recorder/src/schema.js +20 -1
- package/dist/packages/tui/src/keymap.js +12 -0
- package/package.json +3 -1
- package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// dexterm autotrade — real mode (task-1645, 0.2.0 card A). dexterm still holds NO key and signs nothing.
|
|
2
|
+
//
|
|
3
|
+
// A strategy runs real only when ALL of these hold, checked by the runner before its first tick:
|
|
4
|
+
// 1. its document says `mode: "real"` (import, `new` and every model proposal force or keep paper: `/mode` is on
|
|
5
|
+
// the model deny-list)
|
|
6
|
+
// 2. $DEXTERM_HOME/autotrade/real/arming.json arms that strategy id AT THAT HASH (any edit to the strategy, by
|
|
7
|
+
// anyone, changes the hash and voids the arming). Arming is `dexterm autotrade signal arm <id>`: a real terminal
|
|
8
|
+
// only, the evidence panel (backtest, forward test, caps, channels), a typed phrase with a one-time code, and a
|
|
9
|
+
// journal line. No MCP tool, no decision verb, no chat command can arm.
|
|
10
|
+
// 3. the global real-mode kill switch is off ($DEXTERM_HOME/autotrade/real/kill.json; unreadable = engaged)
|
|
11
|
+
// 4. the hard caps file exists ($DEXTERM_HOME/autotrade/real/caps.json): per-trade SOL, daily SOL, buys per day,
|
|
12
|
+
// open positions. Raising any cap needs the typed confirmation; lowering does not.
|
|
13
|
+
// 5. at least one channel an executor can read is set up (webhook, command hook, JSONL stream, MCP feed)
|
|
14
|
+
//
|
|
15
|
+
// Then RealSignalExecutor wraps the run's paper executor: every fill of the paper model (the monitor's own view, so
|
|
16
|
+
// clamps, exits, monitor and ledger are exactly those of paper) is first written to the outbox as a trade-fire
|
|
17
|
+
// signal, and only then booked. A buy over a cap is clamped to the per-trade cap, or refused at the daily caps; a
|
|
18
|
+
// signal that cannot be written is a refused order (the book never holds what no signal announced). With the kill
|
|
19
|
+
// switch on, no signal of any kind goes out except one final `halted`.
|
|
20
|
+
//
|
|
21
|
+
// Receipts (receipts.ts) move a position from `unconfirmed` to executed / rejected / failed; they are data and
|
|
22
|
+
// never trigger an order. The dead-man: every signal carries a TTL; a run that ends (or halts) sends `halted`
|
|
23
|
+
// listing its outstanding nonces; a run that died without one gets it from the next outbox open (`deadManSweep`).
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
25
|
+
import { randomBytes } from 'node:crypto';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { strategyHash } from "./strategy.js";
|
|
28
|
+
import { writeJsonAtomic } from "./ledger.js";
|
|
29
|
+
import { localDay } from "./chat/audit.js";
|
|
30
|
+
import { allOutboxSignals, EXECUTOR_CHANNELS, Outbox, realDir } from "./outbox.js";
|
|
31
|
+
import { ACCOUNT_ID_RE, buildHaltedSignal, buildTradeSignal, clampTtl } from "./signal.js";
|
|
32
|
+
import { ReceiptsTail, divergenceOf, readReceipts } from "./receipts.js";
|
|
33
|
+
import { DEFAULT_LIMITS, buyPause, checkBuy, checkSell, effectiveLimits, emptyLimits, limitField, limitsForRealRun, limitsStamp, loadLimits, loadRiskState, planLimitsSave, raises, recordBuy, recordSell, saveLimits, saveRiskState, strictestOf, } from "./limits.js";
|
|
34
|
+
import { executorHealth } from "./executor-health.js";
|
|
35
|
+
export const ARMING_SCHEMA = 'dexterm-real-arming/1';
|
|
36
|
+
export const CAPS_SCHEMA = 'dexterm-real-caps/1';
|
|
37
|
+
export const armingPath = (home) => join(realDir(home), 'arming.json');
|
|
38
|
+
/** card A's caps file: read once and migrated into limits.json (limits.ts). */
|
|
39
|
+
export const capsPath = (home) => join(realDir(home), 'caps.json');
|
|
40
|
+
export const killPath = (home) => join(realDir(home), 'kill.json');
|
|
41
|
+
export const realJournalPath = (home) => join(realDir(home), 'journal.jsonl');
|
|
42
|
+
export function journalReal(home, line) {
|
|
43
|
+
mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
|
|
44
|
+
appendFileSync(realJournalPath(home), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
export function readRealJournal(home) {
|
|
47
|
+
if (!existsSync(realJournalPath(home)))
|
|
48
|
+
return [];
|
|
49
|
+
return readFileSync(realJournalPath(home), 'utf8').split('\n').filter(Boolean).flatMap((l) => { try {
|
|
50
|
+
return [JSON.parse(l)];
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
} });
|
|
55
|
+
}
|
|
56
|
+
/** The placeholder defaults (limits.ts DEFAULT_LIMITS): arming asks for your own numbers first. */
|
|
57
|
+
export const DEFAULT_REAL_CAPS = { perTradeSol: DEFAULT_LIMITS.perTradeSol, dailySol: DEFAULT_LIMITS.dailySol, buysPerDay: DEFAULT_LIMITS.buysPerDay, maxOpenPositions: DEFAULT_LIMITS.maxOpenPositions };
|
|
58
|
+
const CAP_KEYS = ['perTradeSol', 'dailySol', 'buysPerDay', 'maxOpenPositions'];
|
|
59
|
+
export function capsProblem(c) {
|
|
60
|
+
for (const k of CAP_KEYS) {
|
|
61
|
+
const f = limitField(k);
|
|
62
|
+
const v = c[k];
|
|
63
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < f.lo || v > f.hi)
|
|
64
|
+
return `${k} must be a number from ${f.lo} up (${f.unit})`;
|
|
65
|
+
if (f.int && !Number.isInteger(v))
|
|
66
|
+
return `${k} must be a whole number`;
|
|
67
|
+
}
|
|
68
|
+
if (c.dailySol < c.perTradeSol)
|
|
69
|
+
return 'dailySol must be at least perTradeSol';
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** The four caps as the effective `global` values (Infinity when a limit is turned off). */
|
|
73
|
+
export function capsOfValues(v) {
|
|
74
|
+
return { perTradeSol: v.perTradeSol ?? Infinity, dailySol: v.dailySol ?? Infinity, buysPerDay: v.buysPerDay ?? Infinity, maxOpenPositions: v.maxOpenPositions ?? Infinity };
|
|
75
|
+
}
|
|
76
|
+
export function loadCaps(home) {
|
|
77
|
+
const l = loadLimits(home);
|
|
78
|
+
if (!l.ok)
|
|
79
|
+
return { ok: false, why: l.missing ? 'no real-mode limits are set (dexterm autotrade limits set …, or arm a strategy: it asks for your numbers)' : l.why };
|
|
80
|
+
return { ok: true, caps: capsOfValues(effectiveLimits(l.doc, { strategyId: '-', accountId: '-', mode: 'real' }).values) };
|
|
81
|
+
}
|
|
82
|
+
/** Which caps the new set raises (needs the typed local confirmation). */
|
|
83
|
+
export function capsRaised(before, after) {
|
|
84
|
+
if (!before)
|
|
85
|
+
return Object.keys(after);
|
|
86
|
+
return Object.keys(after).filter((k) => after[k] > before[k]);
|
|
87
|
+
}
|
|
88
|
+
/** The limits document `signal caps set` would save: the four caps into the file's `global` (the rest as it is). */
|
|
89
|
+
function capsDoc(home, next) {
|
|
90
|
+
const cur = loadLimits(home);
|
|
91
|
+
const doc = cur.ok ? structuredClone(cur.doc) : emptyLimits();
|
|
92
|
+
for (const k of CAP_KEYS)
|
|
93
|
+
doc.global[k] = next[k];
|
|
94
|
+
delete doc.migratedFrom;
|
|
95
|
+
return doc;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* task-1654 r3 (verify F2): what `signal caps set` would save, against dexterm's last journaled save, so any hand
|
|
99
|
+
* edit of limits.json that raises something (in any scope) is shown and confirmed with the caps, never carried along.
|
|
100
|
+
*/
|
|
101
|
+
export function planCaps(home, next) {
|
|
102
|
+
const p = capsProblem(next);
|
|
103
|
+
return p ? { error: p } : planLimitsSave(home, capsDoc(home, next));
|
|
104
|
+
}
|
|
105
|
+
/** `signal caps set`: `confirmed` is the list of raises (planCaps) the user typed the phrase for. */
|
|
106
|
+
export function saveCaps(home, next, o) {
|
|
107
|
+
const p = capsProblem(next);
|
|
108
|
+
if (p)
|
|
109
|
+
return { ok: false, why: p };
|
|
110
|
+
const r = saveLimits(home, capsDoc(home, next), { confirmed: o.confirmed, baseline: o.baseline, action: 'caps-set', now: o.now });
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
const up = raises(r.changes ?? []).filter((c) => !o.confirmed.includes(`${c.scope}.${c.key}`)).map((c) => (c.scope === 'global' ? String(c.key) : `${c.scope}.${c.key}`));
|
|
113
|
+
return { ok: false, why: up.length && !r.issues ? `raising ${up.join(', ')} needs the typed confirmation on a real terminal` : r.why };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, raised: raises(r.changes).map((c) => String(c.key)) };
|
|
116
|
+
}
|
|
117
|
+
// ───────────────────────────── kill switch ─────────────────────────────
|
|
118
|
+
/** null = off. A kill file that cannot be read counts as engaged (fail closed). */
|
|
119
|
+
export function realKill(home) {
|
|
120
|
+
const p = killPath(home);
|
|
121
|
+
if (!existsSync(p))
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
const d = JSON.parse(readFileSync(p, 'utf8'));
|
|
125
|
+
return { reason: String(d.reason ?? 'kill').slice(0, 80), at: Number(d.at) || 0 };
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { reason: 'kill file unreadable', at: 0 };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function engageRealKill(home, reason, by = 'cli', now = Date.now()) {
|
|
132
|
+
mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
|
|
133
|
+
writeJsonAtomic(killPath(home), { reason: reason.slice(0, 80), at: now, by });
|
|
134
|
+
journalReal(home, { t: now, action: 'kill', by, data: { reason: reason.slice(0, 80) } });
|
|
135
|
+
}
|
|
136
|
+
/** Lifting the kill switch needs the typed confirmation (the CLI asks on a real terminal). */
|
|
137
|
+
export function releaseRealKill(home, o) {
|
|
138
|
+
if (!o.confirmed)
|
|
139
|
+
return { ok: false, why: 'lifting the real-mode kill switch needs the typed confirmation on a real terminal' };
|
|
140
|
+
rmSync(killPath(home), { force: true });
|
|
141
|
+
journalReal(home, { t: o.now ?? Date.now(), action: 'unkill', by: 'local-terminal', data: {} });
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
export function loadArmings(home) {
|
|
145
|
+
if (!existsSync(armingPath(home)))
|
|
146
|
+
return {};
|
|
147
|
+
try {
|
|
148
|
+
const d = JSON.parse(readFileSync(armingPath(home), 'utf8'));
|
|
149
|
+
return d.schema === ARMING_SCHEMA && d.armed && typeof d.armed === 'object' ? d.armed : {};
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export function saveArming(home, a) {
|
|
156
|
+
if (!ACCOUNT_ID_RE.test(a.accountId))
|
|
157
|
+
throw new Error('account id: lowercase letters, digits, - and _ (32 max)');
|
|
158
|
+
const all = loadArmings(home);
|
|
159
|
+
all[a.strategyId] = a;
|
|
160
|
+
mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
|
|
161
|
+
writeJsonAtomic(armingPath(home), { schema: ARMING_SCHEMA, armed: all });
|
|
162
|
+
journalReal(home, { t: a.armedAt, action: 'arm', by: 'local-terminal', data: { strategyId: a.strategyId, strategyHash: a.strategyHash, version: a.version, accountId: a.accountId, caps: a.caps, channels: a.channels } });
|
|
163
|
+
}
|
|
164
|
+
export function disarm(home, id, reason, now = Date.now()) {
|
|
165
|
+
const all = loadArmings(home);
|
|
166
|
+
if (!all[id])
|
|
167
|
+
return false;
|
|
168
|
+
delete all[id];
|
|
169
|
+
writeJsonAtomic(armingPath(home), { schema: ARMING_SCHEMA, armed: all });
|
|
170
|
+
journalReal(home, { t: now, action: 'disarm', by: 'cli', data: { strategyId: id, reason: reason.slice(0, 80) } });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
/** A one-time code for the typed confirmation (like the mandate ConfirmGate's challenge code: a recording cannot be replayed). */
|
|
174
|
+
export function newChallenge(rng = () => randomBytes(4)) {
|
|
175
|
+
return [...rng()].map((b) => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'[b % 32]).join('').slice(0, 4);
|
|
176
|
+
}
|
|
177
|
+
export const confirmPhrase = (action, id, code) => `${action} ${id} ${code}`;
|
|
178
|
+
export const phraseMatches = (typed, action, id, code) => typed.trim() === confirmPhrase(action, id, code);
|
|
179
|
+
export const codeHash = (code) => strategyHash({ confirm: code }).slice(0, 16);
|
|
180
|
+
/** The strategy as it would be in paper mode: its backtests were run under this hash. */
|
|
181
|
+
export const paperTwinHash = (s) => strategyHash({ ...s, mode: 'paper' });
|
|
182
|
+
/** Everything the runner checks before a real run's first tick (and the CLI before it takes the run lock). */
|
|
183
|
+
export function realModeCheck(home, s, channels) {
|
|
184
|
+
if (s.mode !== 'real')
|
|
185
|
+
return { ok: false, why: `strategy ${s.id} is mode "${s.mode}", not "real"` };
|
|
186
|
+
const a = loadArmings(home)[s.id];
|
|
187
|
+
if (!a)
|
|
188
|
+
return { ok: false, why: `strategy ${s.id} is not armed for real mode (dexterm autotrade signal arm ${s.id}, on your own terminal)` };
|
|
189
|
+
if (a.strategyHash !== s.hash)
|
|
190
|
+
return { ok: false, why: `strategy ${s.id} changed since it was armed (hash ${s.hash.slice(0, 8)} vs armed ${a.strategyHash.slice(0, 8)}): arm it again` };
|
|
191
|
+
const k = realKill(home);
|
|
192
|
+
if (k)
|
|
193
|
+
return { ok: false, why: `the real-mode kill switch is on (${k.reason}); lift it with dexterm autotrade signal unkill on your own terminal` };
|
|
194
|
+
// task-1654: the limits file (no silent defaults: it exists because you saved your numbers or armed with them); a
|
|
195
|
+
// file changed outside dexterm that raises anything is refused here.
|
|
196
|
+
const l = limitsForRealRun(home);
|
|
197
|
+
if (!l.ok)
|
|
198
|
+
return { ok: false, why: l.why };
|
|
199
|
+
if (!channels.some((n) => EXECUTOR_CHANNELS.includes(n)))
|
|
200
|
+
return { ok: false, why: 'no channel your executor can read is set up (webhook, signal-cmd, jsonl or mcp); the Telegram copy alone is not enough because a bot cannot read another bot' };
|
|
201
|
+
const limits = effectiveLimits(l.doc, { strategyId: s.id, accountId: a.accountId, mode: 'real' });
|
|
202
|
+
return { ok: true, arming: a, caps: capsOfValues(limits.values), limits };
|
|
203
|
+
}
|
|
204
|
+
// ───────────────────────────── holdings (r2) ─────────────────────────────
|
|
205
|
+
//
|
|
206
|
+
// What dexterm counts as held for real, across runs: $DEXTERM_HOME/autotrade/real/held.json, rewritten atomically on
|
|
207
|
+
// every buy and sell signal and at a run's end (then with the book's view of each position, so its exits resume
|
|
208
|
+
// where they were). It is separate from the outbox, so compaction cannot lose it. A missing or unreadable file is
|
|
209
|
+
// rebuilt from every outbox file on the home (live + compacted archives) - never treated as "nothing held".
|
|
210
|
+
//
|
|
211
|
+
// Rules it carries: the open-position cap counts every holding on the home; a mint held by another strategy or
|
|
212
|
+
// account is not bought again; the next real run of the same strategy + account takes its holdings over (exits keep
|
|
213
|
+
// firing); `halted` lists them in `open`. Only a sell signal, or `signal release` (typed, on a real terminal), ends one.
|
|
214
|
+
export const HELD_SCHEMA = 'dexterm-real-held/1';
|
|
215
|
+
export const heldPath = (home) => join(realDir(home), 'held.json');
|
|
216
|
+
export function loadHoldings(home) {
|
|
217
|
+
const p = heldPath(home);
|
|
218
|
+
if (existsSync(p)) {
|
|
219
|
+
try {
|
|
220
|
+
const d = JSON.parse(readFileSync(p, 'utf8'));
|
|
221
|
+
if (d.schema === HELD_SCHEMA && d.holdings && typeof d.holdings === 'object')
|
|
222
|
+
return { holdings: d.holdings, source: 'file' };
|
|
223
|
+
}
|
|
224
|
+
catch { /* rebuilt below */ }
|
|
225
|
+
}
|
|
226
|
+
return { holdings: holdingsFromSignals(allOutboxSignals(home)), source: 'rebuilt' };
|
|
227
|
+
}
|
|
228
|
+
export function saveHoldings(home, holdings, now = Date.now()) {
|
|
229
|
+
mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
|
|
230
|
+
writeJsonAtomic(heldPath(home), { schema: HELD_SCHEMA, updatedAt: now, holdings });
|
|
231
|
+
}
|
|
232
|
+
/** Replay trade signals (oldest first) into holdings: buys add, sells take their fraction, a full sell ends one. */
|
|
233
|
+
export function holdingsFromSignals(signals) {
|
|
234
|
+
const out = {};
|
|
235
|
+
for (const s of [...signals].sort((a, b) => a.nonce - b.nonce)) {
|
|
236
|
+
if (s.type !== 'trade')
|
|
237
|
+
continue;
|
|
238
|
+
const h = out[s.mint];
|
|
239
|
+
const px = s.price.usd ?? 0;
|
|
240
|
+
if (s.side === 'buy') {
|
|
241
|
+
const tokens = px > 0 ? s.sizeUsd / px : 0;
|
|
242
|
+
if (h) {
|
|
243
|
+
h.entryPrice = h.tokens + tokens > 0 ? (h.entryPrice * h.tokens + px * tokens) / (h.tokens + tokens) : px;
|
|
244
|
+
h.tokens += tokens;
|
|
245
|
+
h.costUsd += s.sizeUsd;
|
|
246
|
+
h.nonce = s.nonce;
|
|
247
|
+
h.runId = s.runId;
|
|
248
|
+
h.at = s.issuedAt;
|
|
249
|
+
}
|
|
250
|
+
else
|
|
251
|
+
out[s.mint] = { mint: s.mint, symbol: s.symbol, strategyId: s.strategy.id, accountId: s.accountId, runId: s.runId, nonce: s.nonce, tokens, costUsd: s.sizeUsd, realizedUsd: 0, entryPrice: px, openedAt: s.issuedAt, at: s.issuedAt };
|
|
252
|
+
}
|
|
253
|
+
else if (h) {
|
|
254
|
+
const frac = s.sellFraction ?? 1;
|
|
255
|
+
if (frac >= 0.999) {
|
|
256
|
+
delete out[s.mint];
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
h.tokens *= 1 - frac;
|
|
260
|
+
h.realizedUsd += s.sizeUsd;
|
|
261
|
+
h.runId = s.runId;
|
|
262
|
+
h.at = s.issuedAt;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
/** A placeholder tick at the last known price, until the live feed sends a real one. */
|
|
268
|
+
function placeholderTick(mint, symbol, t, price) {
|
|
269
|
+
return {
|
|
270
|
+
mint, t, kind: 'live', symbol, launchpad: 'unknown', createdAt: null, price, mcap: null, mcapResolved: null, mcapFlagged: false,
|
|
271
|
+
liq: null, vol5m: null, vol1h: null, vol24: null, buys5m: null, sells5m: null, buys24: null, sells24: null,
|
|
272
|
+
chg5m: null, chg1h: null, chg6h: null, chg24h: null, holders: null, top10: null, dev: null, mintAuth: null, freezeAuth: null, org: null,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The position a run takes over. From the book's saved view when there is one (no unfilled ladder tranche: nothing is
|
|
277
|
+
* added after a restart), else rebuilt from the signals (entry = average signal price, peak = entry).
|
|
278
|
+
*/
|
|
279
|
+
export function positionOfHolding(h, defaultProfile) {
|
|
280
|
+
if (h.position)
|
|
281
|
+
return { ...h.position, tokens: h.tokens, ladder: [], sessionId: null, wakeSent: {}, status: 'open', closedAt: null, exitReason: null };
|
|
282
|
+
return {
|
|
283
|
+
id: `real-${h.nonce}`, mint: h.mint, symbol: h.symbol, profileId: defaultProfile, openedAt: h.openedAt, sessionId: null,
|
|
284
|
+
tokens: h.tokens, origTokens: h.tokens, costUsd: h.costUsd, realizedUsd: h.realizedUsd, entryPrice: h.entryPrice, peakPrice: h.entryPrice, troughPrice: h.entryPrice,
|
|
285
|
+
tpFilled: 0, trailArmed: false, ladder: [], fills: [], status: 'open', stuck: false, closedAt: null, exitReason: null,
|
|
286
|
+
liqWindow: [], lowTapeSince: null, lastTick: placeholderTick(h.mint, h.symbol, h.at, h.entryPrice), wakeSent: {},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** `signal release <mint>`: forget a holding the user closed by hand. Typed confirmation only; journaled. */
|
|
290
|
+
export function releaseHolding(home, mint, o) {
|
|
291
|
+
if (!o.confirmed)
|
|
292
|
+
return { ok: false, why: 'releasing a real holding needs the typed confirmation on a real terminal (it frees room under the open-position cap)' };
|
|
293
|
+
const { holdings } = loadHoldings(home);
|
|
294
|
+
const h = holdings[mint];
|
|
295
|
+
if (!h)
|
|
296
|
+
return { ok: false, why: `no real holding on ${mint}` };
|
|
297
|
+
delete holdings[mint];
|
|
298
|
+
saveHoldings(home, holdings, o.now);
|
|
299
|
+
journalReal(home, { t: o.now ?? Date.now(), action: 'release-holding', by: 'local-terminal', data: { mint, symbol: h.symbol, strategyId: h.strategyId, accountId: h.accountId, nonce: h.nonce } });
|
|
300
|
+
return { ok: true, holding: h };
|
|
301
|
+
}
|
|
302
|
+
const finite = (v) => (v !== undefined && Number.isFinite(v) ? v : undefined);
|
|
303
|
+
export class RealSignalExecutor {
|
|
304
|
+
name;
|
|
305
|
+
o;
|
|
306
|
+
now;
|
|
307
|
+
/** r2: every real holding on the home (all runs, strategies, accounts), persisted in held.json on each change. */
|
|
308
|
+
holdings;
|
|
309
|
+
holdingsSource;
|
|
310
|
+
confirm = new Map();
|
|
311
|
+
tail;
|
|
312
|
+
receiptCount = 0;
|
|
313
|
+
halted = false;
|
|
314
|
+
killSeen = null;
|
|
315
|
+
// task-1654
|
|
316
|
+
startedAt;
|
|
317
|
+
stamp;
|
|
318
|
+
fileLimits = null;
|
|
319
|
+
limitsBroken = null;
|
|
320
|
+
recentReceipts = [];
|
|
321
|
+
silent = false;
|
|
322
|
+
watchedAt = -Infinity;
|
|
323
|
+
constructor(o) {
|
|
324
|
+
this.o = o;
|
|
325
|
+
this.now = o.now ?? Date.now;
|
|
326
|
+
this.name = `real-signal(${o.inner.name})`;
|
|
327
|
+
this.tail = new ReceiptsTail(o.home, (n) => this.o.outbox.get(n));
|
|
328
|
+
const l = loadHoldings(o.home);
|
|
329
|
+
this.holdings = l.holdings;
|
|
330
|
+
this.holdingsSource = l.source;
|
|
331
|
+
if (l.source === 'rebuilt' && Object.keys(l.holdings).length) {
|
|
332
|
+
saveHoldings(o.home, this.holdings, this.now());
|
|
333
|
+
journalReal(o.home, { t: this.now(), action: 'holdings-rebuilt', by: 'runner', data: { mints: Object.keys(l.holdings) } });
|
|
334
|
+
}
|
|
335
|
+
this.startedAt = this.now();
|
|
336
|
+
this.stamp = limitsStamp(o.home);
|
|
337
|
+
// Receipts already on file before this run: the newest few count as signs of life and for the lag.
|
|
338
|
+
try {
|
|
339
|
+
this.recentReceipts = readReceipts(o.home).receipts.slice(-20);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
this.recentReceipts = [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
get accountId() { return this.o.arming.accountId; }
|
|
346
|
+
mine(h) { return h.strategyId === this.o.strategy.id && h.accountId === this.o.arming.accountId; }
|
|
347
|
+
save() { saveHoldings(this.o.home, this.holdings, this.now()); }
|
|
348
|
+
say(line, action, data = {}) {
|
|
349
|
+
try {
|
|
350
|
+
journalReal(this.o.home, { t: this.now(), action, by: 'runner', data: { line, ...data } });
|
|
351
|
+
}
|
|
352
|
+
catch { /* the line still goes out */ }
|
|
353
|
+
try {
|
|
354
|
+
this.o.notify?.(line);
|
|
355
|
+
}
|
|
356
|
+
catch { /* a notify hook never stops a signal */ }
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* task-1654: the limits in force now. With start limits: the stricter of them and limits.json now (re-read when it
|
|
360
|
+
* changes; an unreadable or invalid file refuses buys until fixed). Without: card A's four caps.
|
|
361
|
+
*/
|
|
362
|
+
limitsNow() {
|
|
363
|
+
if (!this.o.limits) {
|
|
364
|
+
const c = this.o.caps;
|
|
365
|
+
return Object.fromEntries(['perTradeSol', 'dailySol', 'buysPerDay', 'maxOpenPositions'].map((k) => [k, finite(c[k])]).filter(([, v]) => v !== undefined));
|
|
366
|
+
}
|
|
367
|
+
const st = limitsStamp(this.o.home);
|
|
368
|
+
if (st !== this.stamp) {
|
|
369
|
+
this.stamp = st;
|
|
370
|
+
const l = loadLimits(this.o.home);
|
|
371
|
+
if (!l.ok) {
|
|
372
|
+
this.limitsBroken = l.why;
|
|
373
|
+
this.fileLimits = null;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
this.limitsBroken = null;
|
|
377
|
+
this.fileLimits = effectiveLimits(l.doc, { strategyId: this.o.strategy.id, accountId: this.o.arming.accountId, mode: 'real' }).values;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return this.fileLimits ? strictestOf(this.o.limits, this.fileLimits) : this.o.limits;
|
|
381
|
+
}
|
|
382
|
+
/** task-1654: the stricter of the exit profile's holding cap and the maxHoldMinutes limit (the runner applies it). */
|
|
383
|
+
maxHoldMinutes() { return this.limitsNow().maxHoldMinutes ?? null; }
|
|
384
|
+
ttl(L) { return Math.min(this.o.ttlSec, L.signalTtlSec ?? Infinity); }
|
|
385
|
+
slip(L) {
|
|
386
|
+
return { slippageBps: Math.min(this.o.strategy.execution.slippageBps, L.maxSlippageBps ?? Infinity), maxImpactPct: Math.min(this.o.strategy.execution.maxPriceImpactPct, L.maxPriceImpactPct ?? Infinity) };
|
|
387
|
+
}
|
|
388
|
+
/** task-1654: how the executor looks (heartbeat, receipts, webhook/command acks this run; the run start is the floor). */
|
|
389
|
+
executorHealth(L = this.limitsNow()) {
|
|
390
|
+
const ackAt = this.o.outbox.channelHealth().filter((c) => c.name === 'webhook' || c.name === 'cmd').reduce((a, c) => (c.lastOkAt && (!a || c.lastOkAt > a) ? c.lastOkAt : a), null);
|
|
391
|
+
return executorHealth(this.o.home, { now: this.now(), windowMin: L.executorSilenceMin ?? null, ackAt, since: this.startedAt, signal: (n) => this.o.outbox.get(n), receipts: this.recentReceipts });
|
|
392
|
+
}
|
|
393
|
+
/** Track the silence pause; say it once when it starts and once when the executor is heard again. */
|
|
394
|
+
watchExecutor(L) {
|
|
395
|
+
const h = this.executorHealth(L);
|
|
396
|
+
if (h.alive === false && !this.silent) {
|
|
397
|
+
this.silent = true;
|
|
398
|
+
this.say(`EXECUTOR SILENT: nothing heard from your executor for ${Math.round((h.silentSec ?? 0) / 60)} min (executorSilenceMin ${L.executorSilenceMin}): new real BUY signals are paused; exits still go out. Check it, then dexterm autotrade signal test`, 'executor-silent', { silentSec: h.silentSec, lastSeenBy: h.lastSeenBy });
|
|
399
|
+
}
|
|
400
|
+
else if (h.alive === true && this.silent) {
|
|
401
|
+
this.silent = false;
|
|
402
|
+
this.say(`EXECUTOR BACK (${h.lastSeenBy}): new real buy signals resume`, 'executor-back', { lastSeenBy: h.lastSeenBy });
|
|
403
|
+
}
|
|
404
|
+
return h;
|
|
405
|
+
}
|
|
406
|
+
/** The positions an earlier run of this strategy + account left open: the runner adopts them so their exits fire. */
|
|
407
|
+
inherited() {
|
|
408
|
+
return Object.values(this.holdings).filter((h) => this.mine(h)).map((h) => positionOfHolding(h, this.o.strategy.exits.default));
|
|
409
|
+
}
|
|
410
|
+
/** task-1679: what the `halted` signal lists as open (every real holding on this run's account), for the end print. */
|
|
411
|
+
openOnAccount() { return Object.values(this.holdings).filter((h) => h.accountId === this.o.arming.accountId); }
|
|
412
|
+
/** Holdings of other strategies or accounts: they count against the cap, but this run does not trade them. */
|
|
413
|
+
heldElsewhere() { return Object.values(this.holdings).filter((h) => !this.mine(h)); }
|
|
414
|
+
/** The run's end (before `halted`): save the book's view of what this run still holds, so the next run resumes it. */
|
|
415
|
+
carry(open) {
|
|
416
|
+
let changed = false;
|
|
417
|
+
for (const p of open) {
|
|
418
|
+
const h = this.holdings[p.mint];
|
|
419
|
+
if (!h || !this.mine(h))
|
|
420
|
+
continue;
|
|
421
|
+
h.position = JSON.parse(JSON.stringify({ ...p, ladder: [], sessionId: null, wakeSent: {} }));
|
|
422
|
+
h.tokens = p.tokens;
|
|
423
|
+
h.runId = this.o.runId;
|
|
424
|
+
h.at = this.now();
|
|
425
|
+
changed = true;
|
|
426
|
+
}
|
|
427
|
+
if (changed)
|
|
428
|
+
this.save();
|
|
429
|
+
}
|
|
430
|
+
header(nonce, ttlSec = this.o.ttlSec) {
|
|
431
|
+
return { nonce, now: this.now(), ttlSec, runId: this.o.runId, accountId: this.o.arming.accountId, strategy: { id: this.o.strategy.id, version: this.o.strategy.version, hash: this.o.strategy.hash } };
|
|
432
|
+
}
|
|
433
|
+
day() {
|
|
434
|
+
const r = loadRiskState(this.o.home, this.now());
|
|
435
|
+
return { day: r.day, buySol: r.buySol, buys: r.buys };
|
|
436
|
+
}
|
|
437
|
+
/** Why no signal may go out now, or null. */
|
|
438
|
+
blocked() {
|
|
439
|
+
if (this.halted)
|
|
440
|
+
return 'real-run-ended';
|
|
441
|
+
const k = realKill(this.o.home);
|
|
442
|
+
return k ? 'real-kill' : null;
|
|
443
|
+
}
|
|
444
|
+
emit(sig) {
|
|
445
|
+
this.o.outbox.enqueue(sig);
|
|
446
|
+
void this.o.outbox.pump().catch(() => { });
|
|
447
|
+
}
|
|
448
|
+
/** A state file that could not be written AFTER the signal went out: the fill stands (task-1654, verifier R7). */
|
|
449
|
+
stateError(what, e) {
|
|
450
|
+
try {
|
|
451
|
+
journalReal(this.o.home, { t: this.now(), action: 'state-write-failed', by: 'runner', data: { what, error: e.message.slice(0, 120) } });
|
|
452
|
+
}
|
|
453
|
+
catch { /* nothing more to do */ }
|
|
454
|
+
}
|
|
455
|
+
async buy(k, usd, ctx) {
|
|
456
|
+
const b = this.blocked();
|
|
457
|
+
if (b)
|
|
458
|
+
return { refused: b };
|
|
459
|
+
const solUsd = this.o.solUsd();
|
|
460
|
+
if (!solUsd || !(solUsd > 0))
|
|
461
|
+
return { refused: 'real-sol-price-unknown' };
|
|
462
|
+
const L = this.limitsNow();
|
|
463
|
+
if (this.limitsBroken)
|
|
464
|
+
return { refused: 'real-limits-unreadable' };
|
|
465
|
+
if (this.o.limits && this.watchExecutor(L).alive === false)
|
|
466
|
+
return { refused: 'real-executor-silent' };
|
|
467
|
+
const prior = this.holdings[k.mint];
|
|
468
|
+
if (prior && !this.mine(prior))
|
|
469
|
+
return { refused: 'real-held-elsewhere' };
|
|
470
|
+
if (!prior && L.maxOpenPositions !== undefined && Object.keys(this.holdings).length >= L.maxOpenPositions)
|
|
471
|
+
return { refused: 'real-max-open' };
|
|
472
|
+
const now = this.now();
|
|
473
|
+
const risk = loadRiskState(this.o.home, now);
|
|
474
|
+
const openCostUsd = Object.values(this.holdings).reduce((a, h) => a + Math.max(0, h.costUsd - (h.costOutUsd ?? 0)), 0);
|
|
475
|
+
const v = checkBuy(L, risk, { now, solUsd, usd, bookUsd: this.o.bookUsd(), mint: k.mint, liqUsd: k.liq, mcapUsd: k.mcapResolved ?? k.mcap, positionCostUsd: prior ? Math.max(0, prior.costUsd - (prior.costOutUsd ?? 0)) : 0, openCostUsd });
|
|
476
|
+
if (!v.ok)
|
|
477
|
+
return { refused: v.refused };
|
|
478
|
+
const size = v.usd;
|
|
479
|
+
const ttl = this.ttl(L);
|
|
480
|
+
const sl = this.slip(L);
|
|
481
|
+
const input = (sizeUsd, model = null) => ({ side: 'buy', mint: k.mint, symbol: k.symbol, sizeUsd, solUsd, bookUsd: this.o.bookUsd(), priceUsd: k.price, mcap: k.mcapResolved ?? k.mcap, liqUsd: k.liq, ...sl, model });
|
|
482
|
+
const built = this.build(() => buildTradeSignal(input(size), this.header(1, ttl)));
|
|
483
|
+
if ('refused' in built)
|
|
484
|
+
return built;
|
|
485
|
+
const f = await this.o.inner.buy(k, size, ctx);
|
|
486
|
+
if ('refused' in f)
|
|
487
|
+
return f;
|
|
488
|
+
let sig;
|
|
489
|
+
try {
|
|
490
|
+
const nonce = this.o.outbox.allocateNonce();
|
|
491
|
+
sig = buildTradeSignal(input(f.usd, { priceUsd: f.price, tokens: f.tokens }), this.header(nonce, ttl));
|
|
492
|
+
this.emit(sig);
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
return { refused: `real-outbox-unwritable:${e.message.slice(0, 60)}` };
|
|
496
|
+
}
|
|
497
|
+
// The signal is queued (fsync'd) from here: the fill stands even if a state file cannot be written.
|
|
498
|
+
const t = this.now();
|
|
499
|
+
try {
|
|
500
|
+
const h = this.holdings[k.mint];
|
|
501
|
+
if (h) {
|
|
502
|
+
h.entryPrice = (h.entryPrice * h.tokens + f.price * f.tokens) / (h.tokens + f.tokens);
|
|
503
|
+
h.tokens += f.tokens;
|
|
504
|
+
h.costUsd += f.usd;
|
|
505
|
+
h.nonce = sig.nonce;
|
|
506
|
+
h.runId = this.o.runId;
|
|
507
|
+
h.at = t;
|
|
508
|
+
}
|
|
509
|
+
else
|
|
510
|
+
this.holdings[k.mint] = { mint: k.mint, symbol: sig.type === 'trade' ? sig.symbol : k.symbol, strategyId: this.o.strategy.id, accountId: this.o.arming.accountId, runId: this.o.runId, nonce: sig.nonce, tokens: f.tokens, costUsd: f.usd, realizedUsd: 0, entryPrice: f.price, openedAt: k.t, at: t };
|
|
511
|
+
this.save();
|
|
512
|
+
}
|
|
513
|
+
catch (e) {
|
|
514
|
+
this.stateError('held.json', e);
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
recordBuy(risk, k.mint, f.usd / solUsd, t);
|
|
518
|
+
saveRiskState(this.o.home, risk);
|
|
519
|
+
}
|
|
520
|
+
catch (e) {
|
|
521
|
+
this.stateError('risk.json', e);
|
|
522
|
+
}
|
|
523
|
+
this.confirm.set(sig.nonce, 'unconfirmed');
|
|
524
|
+
return { ...f, signal: { nonce: sig.nonce, id: sig.id }, ...(v.clamps.length ? { limitClamps: v.clamps } : {}) };
|
|
525
|
+
}
|
|
526
|
+
async sell(k, tokens, reason) {
|
|
527
|
+
const b = this.blocked();
|
|
528
|
+
if (b)
|
|
529
|
+
return { refused: b };
|
|
530
|
+
const solUsd = this.o.solUsd();
|
|
531
|
+
if (!solUsd || !(solUsd > 0))
|
|
532
|
+
return { refused: 'real-sol-price-unknown' };
|
|
533
|
+
const L = this.limitsNow();
|
|
534
|
+
const now = this.now();
|
|
535
|
+
const risk = loadRiskState(this.o.home, now);
|
|
536
|
+
const cs = checkSell(L, risk, now);
|
|
537
|
+
if (!cs.ok)
|
|
538
|
+
return { refused: cs.refused };
|
|
539
|
+
const ttl = this.ttl(L);
|
|
540
|
+
const sl = this.slip(L);
|
|
541
|
+
const input = (sizeUsd, sellFraction, model = null) => ({ side: 'sell', mint: k.mint, symbol: k.symbol, sizeUsd, solUsd, bookUsd: this.o.bookUsd(), sellFraction, priceUsd: k.price, mcap: k.mcapResolved ?? k.mcap, liqUsd: k.liq, exitReason: reason, ...sl, model });
|
|
542
|
+
const built = this.build(() => buildTradeSignal(input(0, 1), this.header(1, ttl)));
|
|
543
|
+
if ('refused' in built)
|
|
544
|
+
return built;
|
|
545
|
+
const f = await this.o.inner.sell(k, tokens, reason);
|
|
546
|
+
if ('refused' in f)
|
|
547
|
+
return f;
|
|
548
|
+
const h = this.holdings[k.mint];
|
|
549
|
+
const fraction = h && h.tokens > 0 ? Math.min(1, f.tokens / h.tokens) : 1;
|
|
550
|
+
let sig;
|
|
551
|
+
try {
|
|
552
|
+
const nonce = this.o.outbox.allocateNonce();
|
|
553
|
+
sig = buildTradeSignal(input(Math.max(0, f.usd), fraction, { priceUsd: f.price, tokens: f.tokens }), this.header(nonce, ttl));
|
|
554
|
+
this.emit(sig);
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
return { refused: `real-outbox-unwritable:${e.message.slice(0, 60)}` };
|
|
558
|
+
}
|
|
559
|
+
// The signal is queued from here: state writes that fail are journaled, the fill stands.
|
|
560
|
+
let pnlUsd = 0;
|
|
561
|
+
let closedPnlUsd = null;
|
|
562
|
+
if (h) {
|
|
563
|
+
const openCost = Math.max(0, h.costUsd - (h.costOutUsd ?? 0));
|
|
564
|
+
const portion = openCost * fraction;
|
|
565
|
+
pnlUsd = Math.max(0, f.usd) - portion;
|
|
566
|
+
h.tokens -= f.tokens;
|
|
567
|
+
h.realizedUsd += Math.max(0, f.usd);
|
|
568
|
+
h.costOutUsd = (h.costOutUsd ?? 0) + portion;
|
|
569
|
+
h.runId = this.o.runId;
|
|
570
|
+
h.at = this.now();
|
|
571
|
+
if (h.tokens <= 1e-12 || fraction >= 0.999) {
|
|
572
|
+
closedPnlUsd = h.realizedUsd - h.costUsd;
|
|
573
|
+
delete this.holdings[k.mint];
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
this.save();
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
this.stateError('held.json', e);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const tripped = recordSell(L, risk, { pnlSol: pnlUsd / solUsd, closedPnlSol: closedPnlUsd === null ? null : closedPnlUsd / solUsd, now: this.now() });
|
|
584
|
+
saveRiskState(this.o.home, risk);
|
|
585
|
+
for (const line of tripped)
|
|
586
|
+
this.say(`REAL LIMIT: ${line}`, 'limit-tripped', {});
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
this.stateError('risk.json', e);
|
|
590
|
+
}
|
|
591
|
+
this.confirm.set(sig.nonce, 'unconfirmed');
|
|
592
|
+
return { ...f, signal: { nonce: sig.nonce, id: sig.id } };
|
|
593
|
+
}
|
|
594
|
+
/** A dry build first (nonce 1): a coin whose data cannot make a valid signal is refused before a nonce is spent. */
|
|
595
|
+
build(f) {
|
|
596
|
+
try {
|
|
597
|
+
f();
|
|
598
|
+
return { ok: true };
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
return { refused: `real-signal-invalid:${e.message.replace(/^signal: /, '').slice(0, 40)}` };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Once per tick (the runner calls it): new receipts (ledger rows, with their paper-vs-real divergence), the
|
|
606
|
+
* executor's health (task-1654: the silence pause), and a kill switch that was turned on (the run halts, and the
|
|
607
|
+
* one `halted` signal goes out).
|
|
608
|
+
*/
|
|
609
|
+
step() {
|
|
610
|
+
const receipts = this.tail.poll().map((r) => ({ ...r, divergence: divergenceOf(this.o.outbox.get(r.nonce), r) }));
|
|
611
|
+
for (const r of receipts) {
|
|
612
|
+
this.confirm.set(r.nonce, r.status);
|
|
613
|
+
this.receiptCount++;
|
|
614
|
+
this.recentReceipts.push(r);
|
|
615
|
+
}
|
|
616
|
+
if (this.recentReceipts.length > 20)
|
|
617
|
+
this.recentReceipts = this.recentReceipts.slice(-20);
|
|
618
|
+
// The silence watch, at most every 5 s of the run's clock (a buy always checks it right before it goes out).
|
|
619
|
+
if (this.o.limits && !this.halted && this.now() - this.watchedAt >= 5000) {
|
|
620
|
+
this.watchedAt = this.now();
|
|
621
|
+
this.watchExecutor(this.limitsNow());
|
|
622
|
+
}
|
|
623
|
+
const k = realKill(this.o.home);
|
|
624
|
+
if (k && !this.killSeen) {
|
|
625
|
+
this.killSeen = k.reason;
|
|
626
|
+
this.sendHalted(`kill:${k.reason}`);
|
|
627
|
+
}
|
|
628
|
+
void this.o.outbox.pump().catch(() => { });
|
|
629
|
+
return { receipts, halt: k ? 'real-kill' : null };
|
|
630
|
+
}
|
|
631
|
+
/** The run's last signal (end of run, halt, kill). Idempotent. Allowed with the kill switch on. */
|
|
632
|
+
sendHalted(reason) {
|
|
633
|
+
if (this.halted)
|
|
634
|
+
return;
|
|
635
|
+
this.halted = true;
|
|
636
|
+
try {
|
|
637
|
+
const nonce = this.o.outbox.allocateNonce();
|
|
638
|
+
const open = this.openOnAccount().map((h) => h.mint);
|
|
639
|
+
this.emit(buildHaltedSignal(reason, this.o.outbox.outstanding(this.o.runId), { ...this.header(nonce), ttlSec: clampTtl(600) }, open));
|
|
640
|
+
this.o.outbox.markRun(this.o.runId, 'closed', { reason: reason.slice(0, 60) });
|
|
641
|
+
}
|
|
642
|
+
catch { /* the next outbox open sends it (deadManSweep) */ }
|
|
643
|
+
}
|
|
644
|
+
confirmOf(mint) {
|
|
645
|
+
const h = this.holdings[mint];
|
|
646
|
+
return h ? this.confirm.get(h.nonce) ?? 'unconfirmed' : null;
|
|
647
|
+
}
|
|
648
|
+
status() {
|
|
649
|
+
const L = this.limitsNow();
|
|
650
|
+
const now = this.now();
|
|
651
|
+
const r = loadRiskState(this.o.home, now);
|
|
652
|
+
const pause = this.limitsBroken ? 'limits.json unreadable' : buyPause(L, r, now)?.detail ?? null;
|
|
653
|
+
const ex = this.executorHealth(L);
|
|
654
|
+
return {
|
|
655
|
+
mode: 'real', accountId: this.o.arming.accountId, killed: realKill(this.o.home)?.reason ?? null, caps: capsOfValues(L), limits: L, day: { day: r.day, buySol: r.buySol, buys: r.buys },
|
|
656
|
+
risk: { dayPnlSol: r.dayPnlSol, cumPnlSol: r.cumPnlSol, lossStreak: r.lossStreak, pausedUntil: r.pausedUntil, breaker: r.breaker, buyPause: pause ?? (this.o.limits && ex.alive === false ? 'executor silent' : null) },
|
|
657
|
+
executor: ex,
|
|
658
|
+
outboxDepth: this.o.outbox.depth(), lastNonce: this.o.outbox.lastNonceIssued, channels: this.o.outbox.channelHealth(),
|
|
659
|
+
positions: Object.values(this.holdings).map((h) => ({ mint: h.mint, symbol: h.symbol, state: this.confirm.get(h.nonce) ?? 'unconfirmed', nonce: h.nonce, ...(this.mine(h) ? {} : { heldBy: h.strategyId }) })),
|
|
660
|
+
receipts: { total: this.receiptCount, invalid: this.tail.invalid, orphan: this.tail.orphan },
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** Open the run: mark it in the outbox (so a crash is detectable) after sweeping runs that died without `halted`. */
|
|
665
|
+
export function openRealRun(outbox, runId, s, accountId, now = Date.now()) {
|
|
666
|
+
const swept = deadManSweep(outbox, now, runId);
|
|
667
|
+
outbox.markRun(runId, 'open', { strategy: { id: s.id, version: s.version, hash: s.hash }, accountId });
|
|
668
|
+
return { swept };
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* The dead-man for a runner that stopped without its `halted` signal (a crash, a kill -9, a power cut): every run
|
|
672
|
+
* still marked open (except `exceptRunId`) gets `halted` reason "runner-lost" with its outstanding nonces, then is
|
|
673
|
+
* closed. Run on every outbox open by the real-mode runner and by `dexterm autotrade signal flush`; the always-on
|
|
674
|
+
* service runs it on start too.
|
|
675
|
+
*/
|
|
676
|
+
export function deadManSweep(outbox, now = Date.now(), exceptRunId) {
|
|
677
|
+
const out = [];
|
|
678
|
+
for (const r of outbox.unclosedRuns()) {
|
|
679
|
+
if (r.runId === exceptRunId || !r.accountId || !r.strategy)
|
|
680
|
+
continue;
|
|
681
|
+
const nonce = outbox.allocateNonce();
|
|
682
|
+
const open = Object.values(loadHoldings(outbox.home).holdings).filter((h) => h.accountId === r.accountId).map((h) => h.mint);
|
|
683
|
+
outbox.enqueue(buildHaltedSignal('runner-lost', outbox.outstanding(r.runId), { nonce, now, ttlSec: 600, runId: r.runId, accountId: r.accountId, strategy: r.strategy }, open));
|
|
684
|
+
outbox.markRun(r.runId, 'closed', { reason: 'runner-lost' });
|
|
685
|
+
out.push(r.runId);
|
|
686
|
+
}
|
|
687
|
+
return out;
|
|
688
|
+
}
|