dexterm-cli 0.1.7 → 0.2.0
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 +3 -1
- package/dist/packages/agents/src/tiers.js +9 -0
- package/dist/packages/agents/src/tools.js +10 -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 +2 -1
- 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 +3 -0
- package/dist/packages/autotrade/src/forward.js +355 -0
- package/dist/packages/autotrade/src/index.js +19 -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/runner.js +62 -11
- package/dist/packages/autotrade/src/service-guard.js +125 -0
- package/dist/packages/autotrade/src/service-install.js +213 -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/signal-channels.js +312 -0
- package/dist/packages/autotrade/src/signal.js +196 -0
- package/dist/packages/autotrade/src/strategy.js +3 -1
- package/dist/packages/autotrade/src/trade-ledger.js +2 -2
- 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 +14 -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 +8 -4
- 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 +257 -13
- 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/ledger-cmd.js +4 -2
- package/dist/packages/cli/src/pnl-card.js +4 -0
- 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/telegram-cmd.js +4 -1
- package/dist/packages/cli/src/version.js +1 -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/live-trading.md +1 -1
- package/dist/packages/mcp/assets/kb/app-help/screen-autotrade.md +12 -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-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 +57 -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-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 +39 -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 +24 -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 +1 -1
- 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-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-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 +35 -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-telegram-what-it-is.md +1 -1
- package/dist/packages/mcp/assets/kb/entries/autotrade.md +4 -2
- 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 +21 -10
- package/dist/packages/tui/src/keymap.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
// `dexterm autotrade signal …` (task-1645, 0.2.0 card A): real mode and the LIVE TRADE FIRE signal. dexterm holds NO
|
|
2
|
+
// key, signs nothing and builds no transaction: in mode real, every trade the strategy decides becomes a signal your
|
|
3
|
+
// OWN executor (a Telegram trading bot of your choice, or any script) may act on.
|
|
4
|
+
//
|
|
5
|
+
// status what is armed, the caps, the kill switch, the channels, the outbox, unconfirmed signals, drift
|
|
6
|
+
// channels set the channels in connectors.json (next to `dexterm telegram setup`): webhook, signal-cmd, jsonl, mcp,
|
|
7
|
+
// the Telegram copy, the TTL
|
|
8
|
+
// secret the webhook's HMAC secret: generate into a 0600 file for your executor, set from stdin, remove; it is
|
|
9
|
+
// stored in the OS keychain store, never printed, never an argument
|
|
10
|
+
// arm/disarm arm needs a REAL terminal: the evidence panel, then a typed phrase with a one-time code; journaled.
|
|
11
|
+
// No MCP tool, no model decision and no chat command reaches this code path. task-1679: below the
|
|
12
|
+
// evidence floor (a completed forward test with minForwardTrades closed paper trades + a backtest) or
|
|
13
|
+
// past a missing rung of the ladder, arming refuses unless the user types a separate OVERRIDE phrase
|
|
14
|
+
// first (journaled `evidence-override`). No flag, env var, pipe or proposal can give it.
|
|
15
|
+
// caps hard caps outside the model; raising one needs the typed phrase, lowering does not
|
|
16
|
+
// kill/unkill the global real-mode kill switch (unkill needs the typed phrase)
|
|
17
|
+
// held what dexterm counts as held for real (all runs; the open-position cap counts these); `release <mint>`
|
|
18
|
+
// forgets one you closed yourself (typed phrase: it frees room under the cap)
|
|
19
|
+
// test/flush a `test` signal through every channel; deliver what is pending and send `halted` for a lost run
|
|
20
|
+
// tail the newest signals and their delivery and receipt state
|
|
21
|
+
// reconcile read-only: a PUBLIC wallet address's SOL balance and holdings vs the real-mode book
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
23
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { dextermHome } from '../../../custom/src/index.js';
|
|
26
|
+
import { DEFAULT_REAL_CAPS, EXECUTOR_CHANNELS, Outbox, buildTestSignal, capsProblem, capsRaised, channelsFromConfig, codeHash, confirmPhrase, deadManSweep, disarm, driftReport, enabledChannels, engageRealKill, evidenceFloor, heldFromSignals, journalReal, journeyView, markRealApplied, renderFloor, hmacStore, loadArmings, loadCaps, loadHoldings, releaseHolding, allOutboxSignals, loadSignalsConfig, newChallenge, paperTwinHash, phraseMatches, readReceipts, realKill, reconcileWallet, releaseRealKill, planCaps, saveArming, saveCaps, saveSignalsConfig, argvProblem, webhookUrlProblem, ACCOUNT_ID_RE, runLockPath, capsOfValues, divergenceLines, divergenceReport, effectiveLimits, emptyLimits, executorHealth, healthLine, limitsHash, limitsLines, limitsSummary, loadLimits, changeLine, planLimitsSave, raises, saveLimits, readRealJournal, } from '../../../autotrade/src/index.js';
|
|
27
|
+
import { parseArgs, storeFor } from "./strategies.js";
|
|
28
|
+
import { confirmPlan, limitsWizard } from "./limits.js";
|
|
29
|
+
export const SIGNAL_USAGE = [
|
|
30
|
+
'dexterm autotrade signal status [--json]',
|
|
31
|
+
'dexterm autotrade signal channels [--webhook URL|off] [--signal-cmd JSON-ARGV|off] [--jsonl PATH|on|off] [--jsonl-signed on|off] [--mcp on|off] [--telegram on|off] [--ttl SEC] [--executor-port N|off] [--json]',
|
|
32
|
+
'dexterm autotrade signal secret generate --out FILE | secret set | secret remove',
|
|
33
|
+
'dexterm autotrade signal arm <id> [--account ID] (a real terminal: evidence panel + typed phrase)',
|
|
34
|
+
'dexterm autotrade signal disarm <id>',
|
|
35
|
+
'dexterm autotrade signal caps [set [--per-trade-sol X] [--daily-sol Y] [--buys-per-day N] [--max-open N]] [--json] (four of the limits: dexterm autotrade limits)',
|
|
36
|
+
'dexterm autotrade signal kill [--reason TEXT] | unkill',
|
|
37
|
+
'dexterm autotrade signal held [--json] | release <mint> (release: a real terminal + typed phrase)',
|
|
38
|
+
'dexterm autotrade signal test [--account ID] [--wait SEC] [--json] | flush | tail [--n 20] [--json]',
|
|
39
|
+
'dexterm autotrade signal reconcile --wallet PUBLIC_ADDRESS [--json]',
|
|
40
|
+
].join('\n');
|
|
41
|
+
const BOOL = ['json'];
|
|
42
|
+
const out = (io, s) => { io.stdout.write(`${s}\n`); };
|
|
43
|
+
const err = (io, s) => { io.stderr.write(`dexterm autotrade signal: ${s}\n`); };
|
|
44
|
+
export function isTty(io, deps) { return deps.tty ?? io.stdin?.isTTY === true; }
|
|
45
|
+
/** One visible line from the REAL terminal (a pipe is refused before this is reached). */
|
|
46
|
+
export async function typedLine(io, deps, prompt) {
|
|
47
|
+
if (deps.typed)
|
|
48
|
+
return deps.typed(prompt);
|
|
49
|
+
io.stderr.write(prompt);
|
|
50
|
+
const stdin = io.stdin;
|
|
51
|
+
if (!stdin)
|
|
52
|
+
return null;
|
|
53
|
+
const { createInterface } = await import('node:readline');
|
|
54
|
+
const rl = createInterface({ input: stdin, terminal: false });
|
|
55
|
+
try {
|
|
56
|
+
return await new Promise((resolve) => { rl.once('line', (l) => resolve(l)); rl.once('close', () => resolve(null)); });
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
rl.close();
|
|
60
|
+
stdin.pause?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The typed confirmation: only on a real terminal (never a pipe, an env var or a flag), with a fresh one-time code a
|
|
65
|
+
* model could not have known. Returns the code (for the journal hash) or null.
|
|
66
|
+
*/
|
|
67
|
+
export async function confirmTyped(io, deps, action, subject) {
|
|
68
|
+
if (!isTty(io, deps)) {
|
|
69
|
+
err(io, `${action.toLowerCase()} needs your typed confirmation on a real terminal (a pipe, a flag or an agent cannot give it)`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const code = (deps.code ?? newChallenge)();
|
|
73
|
+
const want = confirmPhrase(action, subject, code);
|
|
74
|
+
const got = await typedLine(io, deps, `type exactly "${want}" to confirm (anything else cancels): `);
|
|
75
|
+
if (!got || !phraseMatches(got, action, subject, code)) {
|
|
76
|
+
err(io, 'not confirmed; nothing changed');
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return code;
|
|
80
|
+
}
|
|
81
|
+
function channelLines(sc, home) {
|
|
82
|
+
const hasSecret = (() => { try {
|
|
83
|
+
return hmacStore(home, sc).get() !== null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
} })();
|
|
88
|
+
return [
|
|
89
|
+
` webhook ${sc.webhook.enabled ? `${sc.webhook.url} · HMAC ${hasSecret ? 'on' : 'OFF (dexterm autotrade signal secret generate --out FILE)'}` : 'off'}`,
|
|
90
|
+
` cmd ${sc.cmd.enabled ? JSON.stringify(sc.cmd.argv) : 'off'}`,
|
|
91
|
+
` jsonl ${sc.jsonl.enabled ? `${sc.jsonl.path ?? 'autotrade/real/signals.jsonl (default)'}${sc.jsonl.signed ? ` · signed lines (HMAC ${hasSecret ? 'on' : 'OFF: no secret yet'})` : ''}` : 'off'}`,
|
|
92
|
+
` mcp ${sc.mcp.enabled ? 'on (MCP tool trade_fire_feed reads autotrade/real/mcp-feed.jsonl)' : 'off'}`,
|
|
93
|
+
` telegram ${sc.telegram.enabled ? 'copy on (to your paired chat; for you, not for the executor)' : 'copy off'}`,
|
|
94
|
+
` ttl ${sc.ttlSec} s (your executor must ignore a signal after it)`,
|
|
95
|
+
` listener ${sc.executor.listenPort ? `127.0.0.1:${sc.executor.listenPort} during a real run (POST /heartbeat, /receipt${hasSecret ? ', HMAC required' : ''})` : 'off (executors append to autotrade/real/executor.jsonl and receipts.jsonl)'}`,
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
const capNum = (v, unit) => (Number.isFinite(v) ? `${v}${unit}` : `off${unit ? '' : ''}`);
|
|
99
|
+
function capsLine(c) { return `per trade ${capNum(c.perTradeSol, ' SOL')} · per day ${capNum(c.dailySol, ' SOL')} · ${capNum(c.buysPerDay, '')} buys/day · ${capNum(c.maxOpenPositions, '')} open`; }
|
|
100
|
+
export async function signalCmd(argv, io, deps = {}) {
|
|
101
|
+
const sub = argv[0];
|
|
102
|
+
const rest = argv.slice(1);
|
|
103
|
+
const home = dextermHome(io.env);
|
|
104
|
+
const now = deps.now ?? Date.now;
|
|
105
|
+
switch (sub) {
|
|
106
|
+
case undefined:
|
|
107
|
+
case 'help':
|
|
108
|
+
out(io, SIGNAL_USAGE);
|
|
109
|
+
return 0;
|
|
110
|
+
case 'status': return statusCmd(rest, io, home, now);
|
|
111
|
+
case 'channels': return channelsCmd(rest, io, home);
|
|
112
|
+
case 'secret': return secretCmd(rest, io, home);
|
|
113
|
+
case 'arm': return armCmd(rest, io, home, deps, now);
|
|
114
|
+
case 'disarm': {
|
|
115
|
+
const id = rest[0];
|
|
116
|
+
if (!id) {
|
|
117
|
+
err(io, 'usage: dexterm autotrade signal disarm <id>');
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
const had = disarm(home, id, 'cli', now());
|
|
121
|
+
const store = storeFor(io);
|
|
122
|
+
const raw = store.raw(id);
|
|
123
|
+
if (raw?.mode === 'real') {
|
|
124
|
+
const r = store.patch(id, [{ op: 'replace', path: '/mode', value: 'paper' }], { actor: 'user:signal-disarm', why: 'real mode disarmed' });
|
|
125
|
+
if (!r.ok) {
|
|
126
|
+
err(io, r.errors.map((e) => e.msg).join('; '));
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
out(io, had || raw?.mode === 'real' ? `disarmed ${id}: it is mode paper again and sends no signal` : `${id} was not armed`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
case 'caps': return capsCmd(rest, io, home, deps, now);
|
|
134
|
+
case 'kill': {
|
|
135
|
+
const { opts } = parseArgs(rest);
|
|
136
|
+
engageRealKill(home, typeof opts.reason === 'string' ? opts.reason : 'kill', 'cli', now());
|
|
137
|
+
out(io, 'REAL-MODE KILL SWITCH ON: no trade-fire signal goes out, sells included (a running real run halts at its next tick and sends one final "halted"); lift it with dexterm autotrade signal unkill on your own terminal');
|
|
138
|
+
// task-1679 (0.2.0 decision): the kill switch never closes a position; say which ones are still open.
|
|
139
|
+
const held = Object.values(loadHoldings(home).holdings);
|
|
140
|
+
if (held.length)
|
|
141
|
+
out(io, `REAL POSITIONS STILL OPEN (${held.length}): ${held.map((h) => `${h.symbol} ${h.mint} (${h.strategyId}, account ${h.accountId})`).join(', ')}: these positions are still open: manage them in your executor (dexterm sends no signal for them while the kill switch is on)`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
case 'unkill': {
|
|
145
|
+
if (!realKill(home)) {
|
|
146
|
+
out(io, 'the real-mode kill switch is off');
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
const code = await confirmTyped(io, deps, 'UNKILL', 'real');
|
|
150
|
+
if (!code)
|
|
151
|
+
return 1;
|
|
152
|
+
const r = releaseRealKill(home, { confirmed: true, now: now() });
|
|
153
|
+
if (!r.ok) {
|
|
154
|
+
err(io, r.why);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
out(io, 'real-mode kill switch off (journaled); armed strategies can start again');
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
case 'held': return heldCmd(rest, io, home);
|
|
161
|
+
case 'release': {
|
|
162
|
+
const mint = rest[0];
|
|
163
|
+
if (!mint) {
|
|
164
|
+
err(io, 'usage: dexterm autotrade signal release <mint>');
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
if (!loadHoldings(home).holdings[mint]) {
|
|
168
|
+
err(io, `no real holding on ${mint}`);
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const code = await confirmTyped(io, deps, 'RELEASE', mint);
|
|
172
|
+
if (!code)
|
|
173
|
+
return 1;
|
|
174
|
+
const r = releaseHolding(home, mint, { confirmed: true, now: now() });
|
|
175
|
+
if (!r.ok) {
|
|
176
|
+
err(io, r.why);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
out(io, `released ${r.holding.symbol} (${mint}) from the real holdings (journaled): dexterm no longer counts it or sends its exits`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
case 'test':
|
|
183
|
+
case 'flush': return deliverCmd(sub, rest, io, home, deps, now);
|
|
184
|
+
case 'tail': return tailCmd(rest, io, home, now);
|
|
185
|
+
case 'reconcile': return reconcileCmd(rest, io, home, deps);
|
|
186
|
+
default:
|
|
187
|
+
err(io, `unknown subcommand ${JSON.stringify(sub)}\n${SIGNAL_USAGE}`);
|
|
188
|
+
return 2;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function readOutbox(home) { return Outbox.open(home, { channels: [], noLock: true }); }
|
|
192
|
+
function statusCmd(argv, io, home, now) {
|
|
193
|
+
const { opts } = parseArgs(argv, BOOL);
|
|
194
|
+
const sc = loadSignalsConfig(home);
|
|
195
|
+
const caps = loadCaps(home);
|
|
196
|
+
const ob = readOutbox(home);
|
|
197
|
+
const rec = readReceipts(home);
|
|
198
|
+
// task-1654: the limits, the executor's health, the risk state behind the pauses, paper vs real fills.
|
|
199
|
+
const lim = loadLimits(home);
|
|
200
|
+
const gEff = effectiveLimits(lim.ok ? lim.doc : null, { strategyId: '-', accountId: '-', mode: 'real' });
|
|
201
|
+
const exec = executorHealth(home, { now: now(), windowMin: gEff.values.executorSilenceMin ?? null, signal: (n) => ob.get(n), receipts: rec.receipts });
|
|
202
|
+
const div = divergenceReport(ob.signals(), rec.receipts);
|
|
203
|
+
const d = driftReport(ob.signals(), rec.receipts, now());
|
|
204
|
+
const armed = Object.values(loadArmings(home));
|
|
205
|
+
const held = Object.values(loadHoldings(home).holdings);
|
|
206
|
+
// The run that is going right now (its runner holds the run lock) is open, not lost.
|
|
207
|
+
const liveRun = (() => {
|
|
208
|
+
try {
|
|
209
|
+
const l = JSON.parse(readFileSync(runLockPath(home), 'utf8'));
|
|
210
|
+
if (typeof l.pid !== 'number' || typeof l.runId !== 'string')
|
|
211
|
+
return null;
|
|
212
|
+
try {
|
|
213
|
+
process.kill(l.pid, 0);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
if (e.code !== 'EPERM')
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return l.runId;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
})();
|
|
225
|
+
const view = {
|
|
226
|
+
armed: armed.map((a) => ({ id: a.strategyId, version: a.version, hash: a.strategyHash, accountId: a.accountId, armedAt: new Date(a.armedAt).toISOString() })),
|
|
227
|
+
caps: caps.ok ? caps.caps : null, capsProblem: caps.ok ? null : caps.why, kill: realKill(home), channels: enabledChannels(sc.config), problems: sc.problems,
|
|
228
|
+
outbox: { depth: ob.depth(), lastNonce: ob.lastNonceIssued, unclosedRuns: ob.unclosedRuns().map((r) => r.runId).filter((id) => id !== liveRun), corrupt: ob.corrupt, running: liveRun },
|
|
229
|
+
receipts: { total: rec.receipts.length, invalid: rec.invalid, orphan: d.orphanReceipts }, drift: { counts: d.counts, unconfirmed: d.unconfirmed, sizeDrift: d.sizeDrift },
|
|
230
|
+
held: held.map((h) => ({ mint: h.mint, symbol: h.symbol, strategyId: h.strategyId, accountId: h.accountId, nonce: h.nonce, costUsd: Math.round(h.costUsd * 100) / 100 })),
|
|
231
|
+
limits: { ok: lim.ok, problem: lim.ok ? null : lim.why, unjournaled: lim.ok ? lim.unjournaled : null, effective: gEff.values, from: gEff.from },
|
|
232
|
+
executor: exec,
|
|
233
|
+
divergence: { ...div, rows: undefined },
|
|
234
|
+
};
|
|
235
|
+
if (opts.json) {
|
|
236
|
+
out(io, JSON.stringify(view, null, 2));
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
out(io, 'real mode · LIVE TRADE FIRE signals to YOUR executor · dexterm holds no key and signs nothing · experimental, not financial advice');
|
|
240
|
+
out(io, ` armed ${armed.length ? armed.map((a) => `${a.strategyId} v${a.version} (${a.strategyHash.slice(0, 8)}) account ${a.accountId}`).join('; ') : 'nothing (dexterm autotrade signal arm <id>, on your own terminal)'}`);
|
|
241
|
+
out(io, ` caps ${caps.ok ? capsLine(caps.caps) : caps.why}`);
|
|
242
|
+
out(io, ` limits ${lim.ok ? `${limitsSummary(gEff.values)}${lim.unjournaled ? ' · limits.json changed outside dexterm: dexterm autotrade limits diff' : ''}` : lim.why} (dexterm autotrade limits show)`);
|
|
243
|
+
out(io, ` executor ${healthLine(exec)}`);
|
|
244
|
+
out(io, ` kill ${view.kill ? `ON (${view.kill.reason})` : 'off'}`);
|
|
245
|
+
for (const l of channelLines(sc.config, home))
|
|
246
|
+
out(io, l);
|
|
247
|
+
for (const p of sc.problems)
|
|
248
|
+
out(io, ` OFF ${p}`);
|
|
249
|
+
out(io, ` outbox ${view.outbox.depth} pending · last nonce ${view.outbox.lastNonce}${liveRun ? ` · run ${liveRun} is running` : ''}${view.outbox.unclosedRuns.length ? ` · ${view.outbox.unclosedRuns.length} run(s) ended without "halted" (dexterm autotrade signal flush)` : ''}`);
|
|
250
|
+
out(io, ` held ${held.length ? `${held.length} of ${caps.ok ? caps.caps.maxOpenPositions : '?'} open: ${held.map((h) => `${h.symbol} (${h.strategyId})`).join(', ')}; the next real run of each strategy keeps its exits` : 'nothing'}`);
|
|
251
|
+
if (div.receipted)
|
|
252
|
+
out(io, ` fills ${divergenceLines(div)[1]?.trim() ?? divergenceLines(div)[0]}`);
|
|
253
|
+
out(io, ` receipts ${rec.receipts.length} · executed ${d.counts.executed} · rejected ${d.counts.rejected} · failed ${d.counts.failed} · unconfirmed ${d.unconfirmed}${d.sizeDrift ? ` · size drift on ${d.sizeDrift}` : ''}${d.orphanReceipts ? ` · ${d.orphanReceipts} orphan` : ''}${rec.invalid ? ` · ${rec.invalid} invalid lines` : ''}`);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
function channelsCmd(argv, io, home) {
|
|
257
|
+
const { opts } = parseArgs(argv, BOOL);
|
|
258
|
+
const cur = loadSignalsConfig(home).config;
|
|
259
|
+
const next = structuredClone(cur);
|
|
260
|
+
const onOff = (v, name) => (v === 'on' ? true : v === 'off' ? false : (err(io, `--${name} on|off`), null));
|
|
261
|
+
if (opts.webhook !== undefined) {
|
|
262
|
+
if (opts.webhook === 'off')
|
|
263
|
+
next.webhook.enabled = false;
|
|
264
|
+
else {
|
|
265
|
+
const u = String(opts.webhook);
|
|
266
|
+
const p = webhookUrlProblem(u);
|
|
267
|
+
if (p) {
|
|
268
|
+
err(io, `--webhook: ${p}`);
|
|
269
|
+
return 2;
|
|
270
|
+
}
|
|
271
|
+
next.webhook = { ...next.webhook, enabled: true, url: u };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (opts['signal-cmd'] !== undefined) {
|
|
275
|
+
if (opts['signal-cmd'] === 'off')
|
|
276
|
+
next.cmd.enabled = false;
|
|
277
|
+
else {
|
|
278
|
+
let argvJ;
|
|
279
|
+
try {
|
|
280
|
+
argvJ = JSON.parse(String(opts['signal-cmd']));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
argvJ = String(opts['signal-cmd']).startsWith('/') && !/\s/.test(String(opts['signal-cmd'])) ? [String(opts['signal-cmd'])] : null;
|
|
284
|
+
}
|
|
285
|
+
const p = argvProblem(argvJ);
|
|
286
|
+
if (p) {
|
|
287
|
+
err(io, `--signal-cmd: ${p} (a JSON array like '["/usr/local/bin/my-executor","--fast"]'; there is no shell)`);
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
next.cmd = { enabled: true, argv: argvJ };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (opts.jsonl !== undefined) {
|
|
294
|
+
if (opts.jsonl === 'off')
|
|
295
|
+
next.jsonl.enabled = false;
|
|
296
|
+
else if (opts.jsonl === 'on' || opts.jsonl === true)
|
|
297
|
+
next.jsonl.enabled = true;
|
|
298
|
+
else {
|
|
299
|
+
const p = String(opts.jsonl);
|
|
300
|
+
if (!p.startsWith('/')) {
|
|
301
|
+
err(io, '--jsonl: an absolute path, on, or off');
|
|
302
|
+
return 2;
|
|
303
|
+
}
|
|
304
|
+
next.jsonl = { ...next.jsonl, enabled: true, path: p };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (opts['jsonl-signed'] !== undefined) {
|
|
308
|
+
const v = onOff(opts['jsonl-signed'], 'jsonl-signed');
|
|
309
|
+
if (v === null)
|
|
310
|
+
return 2;
|
|
311
|
+
next.jsonl.signed = v;
|
|
312
|
+
}
|
|
313
|
+
if (opts['executor-port'] !== undefined) {
|
|
314
|
+
if (opts['executor-port'] === 'off')
|
|
315
|
+
next.executor.listenPort = null;
|
|
316
|
+
else {
|
|
317
|
+
const n = Number(opts['executor-port']);
|
|
318
|
+
if (!Number.isInteger(n) || n < 1024 || n > 65535) {
|
|
319
|
+
err(io, '--executor-port: 1024 to 65535, or off');
|
|
320
|
+
return 2;
|
|
321
|
+
}
|
|
322
|
+
next.executor.listenPort = n;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (opts.mcp !== undefined) {
|
|
326
|
+
const v = onOff(opts.mcp, 'mcp');
|
|
327
|
+
if (v === null)
|
|
328
|
+
return 2;
|
|
329
|
+
next.mcp.enabled = v;
|
|
330
|
+
}
|
|
331
|
+
if (opts.telegram !== undefined) {
|
|
332
|
+
const v = onOff(opts.telegram, 'telegram');
|
|
333
|
+
if (v === null)
|
|
334
|
+
return 2;
|
|
335
|
+
next.telegram.enabled = v;
|
|
336
|
+
}
|
|
337
|
+
if (opts.ttl !== undefined) {
|
|
338
|
+
const n = Number(opts.ttl);
|
|
339
|
+
if (!Number.isFinite(n)) {
|
|
340
|
+
err(io, '--ttl: seconds (10 to 600)');
|
|
341
|
+
return 2;
|
|
342
|
+
}
|
|
343
|
+
next.ttlSec = Math.min(600, Math.max(10, Math.round(n)));
|
|
344
|
+
}
|
|
345
|
+
const changed = JSON.stringify(next) !== JSON.stringify(cur);
|
|
346
|
+
if (changed) {
|
|
347
|
+
try {
|
|
348
|
+
saveSignalsConfig(home, next);
|
|
349
|
+
}
|
|
350
|
+
catch (e) {
|
|
351
|
+
err(io, e.message);
|
|
352
|
+
return 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (opts.json) {
|
|
356
|
+
out(io, JSON.stringify({ changed, channels: enabledChannels(next), config: next }, null, 2));
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
out(io, `${changed ? 'saved' : 'unchanged'} · trade-fire channels (connectors.json, next to your Telegram setup):`);
|
|
360
|
+
for (const l of channelLines(next, home))
|
|
361
|
+
out(io, l);
|
|
362
|
+
if (!enabledChannels(next).some((c) => EXECUTOR_CHANNELS.includes(c)))
|
|
363
|
+
out(io, ' note: real mode needs at least one channel your executor can read (webhook, signal-cmd, jsonl or mcp); a bot cannot read another bot\'s Telegram messages');
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
function secretCmd(argv, io, home) {
|
|
367
|
+
const sc = loadSignalsConfig(home).config;
|
|
368
|
+
const store = hmacStore(home, sc, io.env);
|
|
369
|
+
if (argv[0] === 'remove') {
|
|
370
|
+
store.remove();
|
|
371
|
+
out(io, 'webhook HMAC secret removed (the webhook goes out unsigned until you set a new one)');
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
const save = (v) => {
|
|
375
|
+
store.set(v);
|
|
376
|
+
try {
|
|
377
|
+
saveSignalsConfig(home, { ...sc, webhook: { ...sc.webhook, secretStore: store.kind === 'memory' ? null : store.kind } });
|
|
378
|
+
}
|
|
379
|
+
catch { /* the store kind is re-detected next time */ }
|
|
380
|
+
};
|
|
381
|
+
if (argv[0] === 'generate') {
|
|
382
|
+
const { opts } = parseArgs(argv.slice(1));
|
|
383
|
+
if (typeof opts.out !== 'string' || !opts.out.startsWith('/')) {
|
|
384
|
+
err(io, 'secret generate --out /absolute/file (0600; your executor reads it there; the secret is never printed)');
|
|
385
|
+
return 2;
|
|
386
|
+
}
|
|
387
|
+
const v = randomBytes(32).toString('base64url');
|
|
388
|
+
try {
|
|
389
|
+
writeFileSync(opts.out, `${v}\n`, { mode: 0o600, flag: 'wx' });
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
err(io, `cannot create ${opts.out}: ${e.code ?? e.message} (it must not exist yet)`);
|
|
393
|
+
return 1;
|
|
394
|
+
}
|
|
395
|
+
save(v);
|
|
396
|
+
out(io, `webhook HMAC secret generated: stored in ${store.kind}, and written to ${opts.out} (0600) for your executor; it is not printed`);
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
if (argv[0] === 'set') {
|
|
400
|
+
return (async () => {
|
|
401
|
+
const stdin = io.stdin;
|
|
402
|
+
if (!stdin || stdin.isTTY) {
|
|
403
|
+
err(io, 'secret set reads the secret from a pipe (never an argument): dexterm autotrade signal secret set < FILE');
|
|
404
|
+
return 2;
|
|
405
|
+
}
|
|
406
|
+
let buf = '';
|
|
407
|
+
stdin.setEncoding('utf8');
|
|
408
|
+
for await (const c of stdin)
|
|
409
|
+
buf += c;
|
|
410
|
+
const v = buf.trim();
|
|
411
|
+
if (!/^[A-Za-z0-9:_-]{16,128}$/.test(v)) {
|
|
412
|
+
err(io, 'the secret is 16 to 128 characters of A-Z a-z 0-9 : _ -');
|
|
413
|
+
return 2;
|
|
414
|
+
}
|
|
415
|
+
save(v);
|
|
416
|
+
out(io, `webhook HMAC secret stored in ${store.kind}; it is never printed`);
|
|
417
|
+
return 0;
|
|
418
|
+
})();
|
|
419
|
+
}
|
|
420
|
+
err(io, 'secret generate --out FILE | secret set | secret remove');
|
|
421
|
+
return 2;
|
|
422
|
+
}
|
|
423
|
+
/** The latest backtest of a strategy (its paper twin hash) and a forward-test report, if any, as panel lines. */
|
|
424
|
+
function evidence(store, s, home, before) {
|
|
425
|
+
// The paper version being armed (before the mode switch and its version bump), then the real version's paper twin.
|
|
426
|
+
const all = store.backtests();
|
|
427
|
+
const bt = (before ? all[before.hash] : undefined) ?? all[paperTwinHash(s)] ?? all[s.hash];
|
|
428
|
+
const lines = [bt
|
|
429
|
+
? ` backtest ${bt.trades} trades · net ${bt.netPct >= 0 ? '+' : ''}${bt.netPct}% · max drawdown ${bt.maxDrawdownPct}% · ${bt.tape} · ${new Date(bt.t).toISOString().slice(0, 10)} (paper replay, modelled fills)`
|
|
430
|
+
: ` backtest NONE on record for this version: run dexterm autotrade backtest ${s.id} first`];
|
|
431
|
+
const fwd = forwardReport(home, s.id);
|
|
432
|
+
lines.push(fwd ? ` forward ${fwd}` : ' forward no forward-test report on record');
|
|
433
|
+
// task-1647: the journey (evidence on these exact rules) and whether arming now skips a rung of the ladder.
|
|
434
|
+
const view = journeyView(home, s, { armedHash: null });
|
|
435
|
+
if (view.backtest)
|
|
436
|
+
lines.push(` held-out ${view.backtest.verdict}`);
|
|
437
|
+
lines.push(` journey stage ${view.stage} (rules ${view.rulesHash.slice(0, 8)})${skipsLadder(home, s) ? ` · SKIPS THE LADDER: ${skipsLadder(home, s)} is missing (dexterm autotrade journey ${s.id})` : ''}`);
|
|
438
|
+
// task-1654: how optimistic paper fills were, from the receipts so far (all real strategies on this machine).
|
|
439
|
+
const dv = divergenceReport(allOutboxSignals(home), readReceipts(home).receipts);
|
|
440
|
+
if (dv.executed)
|
|
441
|
+
lines.push(` fills paper vs real over ${dv.executed} executed fill(s): slippage median ${dv.slippage.medianPct ?? '?'}% · ${dv.partial} partial · ${dv.rejected} rejected (${dv.note})`);
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
/** The missing rung when arming `s` now would skip the ladder (it is below always-on), or null. */
|
|
445
|
+
function skipsLadder(home, s) {
|
|
446
|
+
const view = journeyView(home, s, { armedHash: null });
|
|
447
|
+
return view.stage === 'always-on' ? null : view.next?.stage ?? '?';
|
|
448
|
+
}
|
|
449
|
+
/** A forward-test summary line from autotrade/forward/<id>.json (written by the forward-test runner), if present. */
|
|
450
|
+
function forwardReport(home, id) {
|
|
451
|
+
try {
|
|
452
|
+
const d = JSON.parse(readFileSync(join(home, 'autotrade', 'forward', `${id}.json`), 'utf8'));
|
|
453
|
+
if (typeof d.summary === 'string')
|
|
454
|
+
return d.summary.replace(/[^\x20-\x7e]/g, '').slice(0, 160);
|
|
455
|
+
if (typeof d.trades === 'number')
|
|
456
|
+
return `${d.trades} trades · net ${Number(d.netPct ?? 0)}%${typeof d.days === 'number' ? ` over ${d.days} days` : ''} (paper, live data)`;
|
|
457
|
+
}
|
|
458
|
+
catch { /* none */ }
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
async function armCmd(argv, io, home, deps, now) {
|
|
462
|
+
const { pos, opts } = parseArgs(argv);
|
|
463
|
+
const id = pos[0];
|
|
464
|
+
if (!id) {
|
|
465
|
+
err(io, 'usage: dexterm autotrade signal arm <id> [--account ID]');
|
|
466
|
+
return 2;
|
|
467
|
+
}
|
|
468
|
+
if (!isTty(io, deps)) {
|
|
469
|
+
err(io, 'arming real mode needs your typed confirmation on a real terminal (a pipe, a flag, an agent or MCP cannot give it)');
|
|
470
|
+
return 2;
|
|
471
|
+
}
|
|
472
|
+
const accountId = typeof opts.account === 'string' ? opts.account : 'main';
|
|
473
|
+
if (!ACCOUNT_ID_RE.test(accountId)) {
|
|
474
|
+
err(io, '--account: lowercase letters, digits, - and _ (32 max)');
|
|
475
|
+
return 2;
|
|
476
|
+
}
|
|
477
|
+
const store = storeFor(io);
|
|
478
|
+
const got = store.get(id);
|
|
479
|
+
if (!got) {
|
|
480
|
+
err(io, `no strategy ${JSON.stringify(id)}`);
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
if (got.source === 'preset') {
|
|
484
|
+
err(io, `${id} is a shipped preset: copy it first (dexterm autotrade new my-${id} --from ${id}), then arm the copy`);
|
|
485
|
+
return 1;
|
|
486
|
+
}
|
|
487
|
+
if (!got.ok) {
|
|
488
|
+
err(io, `${id} does not validate: ${got.errors.map((e) => `${e.path} ${e.msg}`).join('; ')}`);
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
if (got.strategy.mode === 'live') {
|
|
492
|
+
err(io, `${id} is mode "live" (the removed signer): set it to paper first`);
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
// What it will be once armed: the same document in mode real, as a new patch version (its hash binds the arming).
|
|
496
|
+
const rawDoc = store.raw(id);
|
|
497
|
+
const already = got.strategy.mode === 'real';
|
|
498
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(rawDoc.version));
|
|
499
|
+
const nextVersion = already || !m ? String(rawDoc.version) : `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
|
|
500
|
+
const { validateStrategy } = await import('../../../autotrade/src/index.js');
|
|
501
|
+
const v = validateStrategy({ ...rawDoc, mode: 'real', version: nextVersion });
|
|
502
|
+
if (!v.ok) {
|
|
503
|
+
err(io, v.errors.map((e) => e.msg).join('; '));
|
|
504
|
+
return 1;
|
|
505
|
+
}
|
|
506
|
+
const s = v.strategy;
|
|
507
|
+
const sc = loadSignalsConfig(home);
|
|
508
|
+
const chans = enabledChannels(sc.config);
|
|
509
|
+
if (!chans.some((c) => EXECUTOR_CHANNELS.includes(c))) {
|
|
510
|
+
err(io, 'set up a channel your executor can read first (dexterm autotrade signal channels --webhook URL | --signal-cmd JSON | --jsonl on | --mcp on)');
|
|
511
|
+
return 1;
|
|
512
|
+
}
|
|
513
|
+
const k = realKill(home);
|
|
514
|
+
if (k) {
|
|
515
|
+
err(io, `the real-mode kill switch is on (${k.reason}): dexterm autotrade signal unkill first`);
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
// task-1654: no silent defaults. No limits saved yet: the wizard asks for YOUR numbers first. Either way the panel
|
|
519
|
+
// shows the effective limits for this strategy + account, and the typed phrase confirms them.
|
|
520
|
+
const lim = loadLimits(home);
|
|
521
|
+
let doc;
|
|
522
|
+
let saveDoc = false;
|
|
523
|
+
if (!lim.ok && lim.missing) {
|
|
524
|
+
const g = await limitsWizard(io, deps);
|
|
525
|
+
if (!g) {
|
|
526
|
+
err(io, 'not armed: real mode needs your own limits first (nothing saved)');
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
doc = { ...emptyLimits(), global: g };
|
|
530
|
+
saveDoc = true;
|
|
531
|
+
}
|
|
532
|
+
else if (!lim.ok) {
|
|
533
|
+
err(io, `${lim.why}; fix the limits first (dexterm autotrade limits show)`);
|
|
534
|
+
return 1;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
if (lim.unjournaled && raises(lim.unjournaled).length) {
|
|
538
|
+
err(io, 'limits.json was changed outside dexterm and raises limits: dexterm autotrade limits diff, then limits accept, first');
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
doc = lim.doc;
|
|
542
|
+
saveDoc = lim.source === 'migrated';
|
|
543
|
+
}
|
|
544
|
+
const eff = effectiveLimits(doc, { strategyId: s.id, accountId, mode: 'real' });
|
|
545
|
+
const caps = capsOfValues(eff.values);
|
|
546
|
+
out(io, `REAL MODE · arm ${s.id} v${s.version} (${s.hash.slice(0, 8)}) · account ${accountId}`);
|
|
547
|
+
out(io, ' every buy and sell this strategy decides becomes a LIVE TRADE FIRE signal for YOUR executor, which may spend real money.');
|
|
548
|
+
out(io, ' dexterm holds no key and signs nothing; the same clamps, exits and ledger as paper. Experimental; not financial advice.');
|
|
549
|
+
for (const l of evidence(store, s, home, got.strategy))
|
|
550
|
+
out(io, l);
|
|
551
|
+
out(io, ` caps ${capsLine(caps)} · hard, outside the model; any edit to the strategy disarms it`);
|
|
552
|
+
out(io, ` limits in force for ${s.id} on account ${accountId} (the stricter scope wins; the defaults are placeholders, your numbers win):`);
|
|
553
|
+
for (const l of limitsLines(eff))
|
|
554
|
+
out(io, ` ${l}`);
|
|
555
|
+
const ex = executorHealth(home, { now: now(), windowMin: eff.values.executorSilenceMin ?? null });
|
|
556
|
+
out(io, ` executor ${ex.lastSeenAt ? healthLine(ex) : 'never heard from: start your executor (examples/executor in the app) and run dexterm autotrade signal test first'}`);
|
|
557
|
+
for (const l of channelLines(sc.config, home))
|
|
558
|
+
out(io, l);
|
|
559
|
+
// task-1679 (0.2.0 decision): the evidence floor (the user's minForwardTrades for this strategy + account) and the ladder.
|
|
560
|
+
// Below either, the arming refuses unless the user types a separate OVERRIDE phrase now; it is journaled.
|
|
561
|
+
const floor = evidenceFloor(home, s, eff.values.minForwardTrades ?? 0);
|
|
562
|
+
for (const l of renderFloor(floor))
|
|
563
|
+
out(io, l);
|
|
564
|
+
const skipped = skipsLadder(home, s);
|
|
565
|
+
let override;
|
|
566
|
+
if (!floor.ok || skipped) {
|
|
567
|
+
out(io, ` REFUSED unless you override: ${[skipped ? `arming now SKIPS THE LADDER (${skipped} is missing)` : '', floor.ok ? '' : 'the evidence floor is not met'].filter(Boolean).join(' and ')}.`);
|
|
568
|
+
out(io, ' Arming without that evidence is your call alone: type the OVERRIDE phrase to go on (it is journaled), anything else cancels.');
|
|
569
|
+
const oc = await confirmTyped(io, deps, 'OVERRIDE', s.id);
|
|
570
|
+
if (!oc) {
|
|
571
|
+
err(io, `not armed: do the missing step first (dexterm autotrade journey ${s.id})`);
|
|
572
|
+
return 1;
|
|
573
|
+
}
|
|
574
|
+
override = { skipsLadder: skipped, floor: floor.problems, minTrades: floor.minTrades, codeHash: codeHash(oc) };
|
|
575
|
+
journalReal(home, { t: now(), action: 'evidence-override', by: 'local-terminal', data: { strategyId: s.id, strategyHash: s.hash, version: s.version, accountId, ...override } });
|
|
576
|
+
out(io, ' override typed (journaled); now the arming phrase.');
|
|
577
|
+
}
|
|
578
|
+
// task-1654 r2 (verify F1): the wizard's save is planned against dexterm's last journaled save (a deleted file's
|
|
579
|
+
// journal still counts), and every raise it makes is listed here, so the phrase confirms exactly what is saved.
|
|
580
|
+
const plan = saveDoc ? planLimitsSave(home, doc) : null;
|
|
581
|
+
if (plan?.before && plan.raised.length) {
|
|
582
|
+
out(io, ' saving these RAISES limits against dexterm\'s last saved limits:');
|
|
583
|
+
for (const c of plan.changes.filter((x) => x.kind === 'raise'))
|
|
584
|
+
out(io, ` ${changeLine(c)}`);
|
|
585
|
+
}
|
|
586
|
+
out(io, ' typing the phrase confirms these limits and arms the strategy.');
|
|
587
|
+
const code = await confirmTyped(io, deps, 'REAL', s.id);
|
|
588
|
+
if (!code)
|
|
589
|
+
return 1;
|
|
590
|
+
if (plan) {
|
|
591
|
+
const r = saveLimits(home, doc, { confirmed: plan.raised, baseline: plan.baseline, now: now() });
|
|
592
|
+
if (!r.ok) {
|
|
593
|
+
err(io, r.why);
|
|
594
|
+
return 1;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (!already) {
|
|
598
|
+
const r = store.patch(id, [{ op: 'replace', path: '/mode', value: 'real' }, { op: 'replace', path: '/version', value: nextVersion }], { actor: 'user:signal-arm', why: 'real mode armed on the local terminal' });
|
|
599
|
+
if (!r.ok) {
|
|
600
|
+
err(io, r.errors.map((e) => e.msg).join('; '));
|
|
601
|
+
return 1;
|
|
602
|
+
}
|
|
603
|
+
if (r.strategy.hash !== s.hash) {
|
|
604
|
+
err(io, 'the strategy changed while arming; nothing armed');
|
|
605
|
+
return 1;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
saveArming(home, { strategyId: s.id, strategyHash: s.hash, version: s.version, accountId, armedAt: now(), caps, limits: eff.values, limitsHash: limitsHash(doc), channels: chans, evidence: [...evidence(store, s, home, got.strategy), ...renderFloor(floor)].map((l) => l.trim()), confirmation: { method: 'typed-tty', codeHash: codeHash(code) }, ...(override ? { override } : {}) });
|
|
609
|
+
const promoted = markRealApplied(home, s.id, s.hash, s.version, 'user:signal-arm', now());
|
|
610
|
+
if (promoted.length)
|
|
611
|
+
out(io, `promotion ${promoted.join(', ')} → real: applied by this arming (journaled)`);
|
|
612
|
+
out(io, `armed ${s.id} for real mode (journaled). Start it with: dexterm autotrade run ${s.id} --real`);
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
async function capsCmd(argv, io, home, deps, now) {
|
|
616
|
+
const { pos, opts } = parseArgs(argv, BOOL);
|
|
617
|
+
const cur = loadCaps(home);
|
|
618
|
+
if (pos[0] !== 'set') {
|
|
619
|
+
if (opts.json) {
|
|
620
|
+
out(io, JSON.stringify(cur.ok ? cur.caps : { error: cur.why }));
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
623
|
+
out(io, cur.ok ? `real-mode caps · ${capsLine(cur.caps)}` : cur.why);
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
const base = cur.ok ? cur.caps : DEFAULT_REAL_CAPS;
|
|
627
|
+
const n = (k, d) => (opts[k] === undefined ? d : Number(opts[k]));
|
|
628
|
+
const next = { perTradeSol: n('per-trade-sol', base.perTradeSol), dailySol: n('daily-sol', base.dailySol), buysPerDay: n('buys-per-day', base.buysPerDay), maxOpenPositions: n('max-open', base.maxOpenPositions) };
|
|
629
|
+
const p = capsProblem(next);
|
|
630
|
+
if (p) {
|
|
631
|
+
err(io, p);
|
|
632
|
+
return 2;
|
|
633
|
+
}
|
|
634
|
+
// task-1654 r3 (verify F2): the caps save is planned like any limits save (from dexterm's last journaled save), so
|
|
635
|
+
// a hand edit of limits.json that raises anything, in any scope, is listed here and the phrase covers every line.
|
|
636
|
+
const plan = planCaps(home, next);
|
|
637
|
+
if ('error' in plan) {
|
|
638
|
+
err(io, plan.error);
|
|
639
|
+
return 2;
|
|
640
|
+
}
|
|
641
|
+
const raised = capsRaised(cur.ok ? cur.caps : null, next);
|
|
642
|
+
if (raised.length)
|
|
643
|
+
out(io, `raising ${raised.join(', ')}: ${capsLine(next)} (was ${cur.ok ? capsLine(cur.caps) : 'not set'})`);
|
|
644
|
+
const l = loadLimits(home);
|
|
645
|
+
let confirmed = [];
|
|
646
|
+
if (plan.raised.length) {
|
|
647
|
+
const c = await confirmPlan(io, deps, plan, l.ok ? l.unjournaled : null, 'caps');
|
|
648
|
+
if (!c)
|
|
649
|
+
return 1;
|
|
650
|
+
confirmed = c;
|
|
651
|
+
}
|
|
652
|
+
const r = saveCaps(home, next, { confirmed, baseline: plan.baseline, now: now() });
|
|
653
|
+
if (!r.ok) {
|
|
654
|
+
err(io, r.why);
|
|
655
|
+
return 1;
|
|
656
|
+
}
|
|
657
|
+
out(io, `real-mode caps saved (journaled) · ${capsLine(next)}`);
|
|
658
|
+
return 0;
|
|
659
|
+
}
|
|
660
|
+
async function deliverCmd(sub, argv, io, home, deps, now) {
|
|
661
|
+
const { opts } = parseArgs(argv, BOOL);
|
|
662
|
+
const sc = loadSignalsConfig(home).config;
|
|
663
|
+
const built = channelsFromConfig(home, sc, { fetch: deps.fetch, env: io.env, now });
|
|
664
|
+
// task-1654: `signal test` times each channel's delivery of the test signal and then waits for receipts.
|
|
665
|
+
const timing = new Map();
|
|
666
|
+
let testNonce = 0;
|
|
667
|
+
const channels = built.channels.map((c) => ({
|
|
668
|
+
name: c.name,
|
|
669
|
+
async deliver(sig, line) {
|
|
670
|
+
const t0 = performance.now();
|
|
671
|
+
const r = await c.deliver(sig, line);
|
|
672
|
+
if (sig.nonce === testNonce)
|
|
673
|
+
timing.set(c.name, { ms: Math.round(performance.now() - t0), ok: r.ok, why: r.ok ? null : r.why, duplicate: r.ok && !!r.duplicate });
|
|
674
|
+
return r;
|
|
675
|
+
},
|
|
676
|
+
}));
|
|
677
|
+
const defer = built.defer;
|
|
678
|
+
let ob;
|
|
679
|
+
try {
|
|
680
|
+
ob = Outbox.open(home, { channels, defer, now });
|
|
681
|
+
}
|
|
682
|
+
catch (e) {
|
|
683
|
+
err(io, e.message);
|
|
684
|
+
return 1;
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
const swept = deadManSweep(ob, now());
|
|
688
|
+
if (swept.length)
|
|
689
|
+
out(io, `sent "halted" (runner-lost) for ${swept.length} run(s) that ended without one`);
|
|
690
|
+
if (sub === 'test') {
|
|
691
|
+
const armed = Object.values(loadArmings(home));
|
|
692
|
+
const accountId = typeof opts.account === 'string' ? opts.account : armed[0]?.accountId ?? 'main';
|
|
693
|
+
if (!ACCOUNT_ID_RE.test(accountId)) {
|
|
694
|
+
err(io, '--account: lowercase letters, digits, - and _');
|
|
695
|
+
return 2;
|
|
696
|
+
}
|
|
697
|
+
testNonce = ob.allocateNonce();
|
|
698
|
+
ob.enqueue(buildTestSignal({ nonce: testNonce, now: now(), ttlSec: sc.ttlSec, runId: 'signal-test', accountId, strategy: { id: 'signal-test', version: '0.0.0', hash: '0'.repeat(16) } }));
|
|
699
|
+
}
|
|
700
|
+
const r = await ob.pump();
|
|
701
|
+
if (sub === 'flush') {
|
|
702
|
+
out(io, `flushed · acked ${r.acked} · retry later ${r.retried} · rejected ${r.rejected} · expired ${r.expired}${defer.length ? ' · the Telegram copy waits for the next run' : ''}`);
|
|
703
|
+
for (const h of ob.channelHealth())
|
|
704
|
+
if (h.lastErr)
|
|
705
|
+
out(io, ` ${h.name}: ${h.lastErr}`);
|
|
706
|
+
return r.rejected || r.retried ? 1 : 0;
|
|
707
|
+
}
|
|
708
|
+
// Receipts for the test nonce (any status: the round trip works), up to --wait seconds (default 3).
|
|
709
|
+
const waitMs = deps.receiptWaitMs ?? Math.max(0, Math.min(60, Number(opts.wait ?? 3) || 0)) * 1000;
|
|
710
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
711
|
+
const t0 = now();
|
|
712
|
+
let rx = readReceipts(home).receipts.filter((x) => x.nonce === testNonce);
|
|
713
|
+
while (!rx.length && now() - t0 < waitMs) {
|
|
714
|
+
await sleep(250);
|
|
715
|
+
rx = readReceipts(home).receipts.filter((x) => x.nonce === testNonce);
|
|
716
|
+
}
|
|
717
|
+
const sent = ob.get(testNonce);
|
|
718
|
+
const rows = [...enabledChannels(sc)].map((name) => {
|
|
719
|
+
const t = timing.get(name);
|
|
720
|
+
const state = defer.includes(name) ? 'deferred (sent with the next real run)' : t ? (t.ok ? (t.duplicate ? 'acked (duplicate)' : 'acked') : `failed: ${t.why}`) : 'not attempted (an older signal is still pending on this channel: dexterm autotrade signal flush)';
|
|
721
|
+
return { channel: name, executorReadable: EXECUTOR_CHANNELS.includes(name), delivered: !!t?.ok, state, latencyMs: t?.ms ?? null };
|
|
722
|
+
});
|
|
723
|
+
const receipts = rx.map((x) => ({ status: x.status, executor: x.executor, reason: x.reason, afterMs: Math.max(0, x.at - sent.issuedAt) }));
|
|
724
|
+
if (opts.json) {
|
|
725
|
+
out(io, JSON.stringify({ test: true, nonce: testNonce, id: sent.id, accountId: sent.accountId, channels: rows, receipts, waitedMs: now() - t0 }, null, 2));
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
out(io, `TEST signal #${testNonce} (test:true, no mint: nothing tradable) · account ${sent.accountId}`);
|
|
729
|
+
for (const row of rows)
|
|
730
|
+
out(io, ` ${row.channel.padEnd(9)} ${row.state}${row.latencyMs !== null ? ` · ${row.latencyMs} ms` : ''}${row.executorReadable ? '' : ' · for you, not the executor'}`);
|
|
731
|
+
if (receipts.length)
|
|
732
|
+
for (const x of receipts)
|
|
733
|
+
out(io, ` receipt ${x.status}${x.executor ? ` from ${x.executor}` : ''}${x.reason ? ` (${x.reason})` : ''} · ${Math.round(x.afterMs / 100) / 10} s after the signal`);
|
|
734
|
+
else
|
|
735
|
+
out(io, ` receipt none within ${Math.round(waitMs / 1000)} s (the reference kit writes one for a test signal; yours may not, which is fine)`);
|
|
736
|
+
}
|
|
737
|
+
const execRows = rows.filter((x) => x.executorReadable);
|
|
738
|
+
return execRows.length && execRows.every((x) => x.delivered) ? 0 : 1;
|
|
739
|
+
}
|
|
740
|
+
finally {
|
|
741
|
+
ob.close();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function tailCmd(argv, io, home, now) {
|
|
745
|
+
const { opts } = parseArgs(argv, BOOL);
|
|
746
|
+
const n = Math.max(1, Math.min(200, Number(opts.n) || 20));
|
|
747
|
+
const ob = readOutbox(home);
|
|
748
|
+
const d = driftReport(ob.signals(), readReceipts(home).receipts, now());
|
|
749
|
+
const rows = ob.signals().slice(-n).map((s) => {
|
|
750
|
+
const dr = d.rows.find((r) => r.nonce === s.nonce);
|
|
751
|
+
return { nonce: s.nonce, type: s.type, side: s.type === 'trade' ? s.side : null, symbol: s.type === 'trade' ? s.symbol : null, sizeSol: s.type === 'trade' ? s.sizeSol : null, runId: s.runId, accountId: s.accountId, issuedAt: s.issuedAt, delivery: ob.stateOf(s.nonce), receipt: dr?.state ?? null, sizeDriftPct: dr?.sizeDriftPct ?? null };
|
|
752
|
+
});
|
|
753
|
+
if (opts.json) {
|
|
754
|
+
out(io, JSON.stringify(rows, null, 2));
|
|
755
|
+
return 0;
|
|
756
|
+
}
|
|
757
|
+
if (!rows.length) {
|
|
758
|
+
out(io, 'no trade-fire signals yet');
|
|
759
|
+
return 0;
|
|
760
|
+
}
|
|
761
|
+
for (const r of rows)
|
|
762
|
+
out(io, `#${r.nonce} ${new Date(r.issuedAt).toISOString().slice(11, 19)} ${r.type === 'trade' ? `${r.side.toUpperCase()} ${r.symbol} ${r.sizeSol} SOL` : r.type.toUpperCase()} · delivery ${r.delivery}${r.receipt ? ` · ${r.receipt}` : ''}${r.sizeDriftPct !== null ? ` · size drift ${r.sizeDriftPct}%` : ''}`);
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
function heldCmd(argv, io, home) {
|
|
766
|
+
const { opts } = parseArgs(argv, BOOL);
|
|
767
|
+
const l = loadHoldings(home);
|
|
768
|
+
const rows = Object.values(l.holdings);
|
|
769
|
+
if (opts.json) {
|
|
770
|
+
out(io, JSON.stringify({ source: l.source, holdings: rows.map((h) => ({ ...h, position: undefined, carried: !!h.position })) }, null, 2));
|
|
771
|
+
return 0;
|
|
772
|
+
}
|
|
773
|
+
if (!rows.length) {
|
|
774
|
+
out(io, 'no real holdings');
|
|
775
|
+
return 0;
|
|
776
|
+
}
|
|
777
|
+
out(io, `real holdings (${rows.length}; the open-position cap counts all of them${l.source === 'rebuilt' ? '; rebuilt from the outbox' : ''}):`);
|
|
778
|
+
for (const h of rows)
|
|
779
|
+
out(io, ` ${h.symbol} ${h.mint} · ${h.strategyId} · account ${h.accountId} · cost $${h.costUsd.toFixed(2)} · last signal #${h.nonce}${h.position ? ' · exits resume from the saved view' : ''}`);
|
|
780
|
+
out(io, 'the next real run of the same strategy takes each over and keeps its exits; closed it yourself? dexterm autotrade signal release <mint>');
|
|
781
|
+
return 0;
|
|
782
|
+
}
|
|
783
|
+
async function reconcileCmd(argv, io, home, deps) {
|
|
784
|
+
const { opts } = parseArgs(argv, BOOL);
|
|
785
|
+
if (typeof opts.wallet !== 'string') {
|
|
786
|
+
err(io, 'reconcile --wallet PUBLIC_ADDRESS (a public address; never a key)');
|
|
787
|
+
return 2;
|
|
788
|
+
}
|
|
789
|
+
// r2: the persisted holdings (all runs; they survive outbox compaction), not just the live outbox's signals.
|
|
790
|
+
const heldMints = Object.keys(loadHoldings(home).holdings);
|
|
791
|
+
const book = { held: heldMints, closed: heldFromSignals(allOutboxSignals(home)).closed.filter((m) => !heldMints.includes(m)) };
|
|
792
|
+
let rpc = deps.rpc;
|
|
793
|
+
if (!rpc) {
|
|
794
|
+
const { resolveRpcUrl } = await import('../../../data/src/index.js');
|
|
795
|
+
const url = resolveRpcUrl({ env: io.env }).url;
|
|
796
|
+
let id = 0;
|
|
797
|
+
rpc = async (method, params) => {
|
|
798
|
+
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), redirect: 'error' });
|
|
799
|
+
const j = (await res.json());
|
|
800
|
+
if (j.error)
|
|
801
|
+
throw new Error(String(j.error.message ?? 'rpc error').slice(0, 120));
|
|
802
|
+
return j.result;
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
let r;
|
|
806
|
+
try {
|
|
807
|
+
r = await reconcileWallet(opts.wallet, rpc, book);
|
|
808
|
+
}
|
|
809
|
+
catch (e) {
|
|
810
|
+
err(io, e.message);
|
|
811
|
+
return 2;
|
|
812
|
+
}
|
|
813
|
+
if (opts.json) {
|
|
814
|
+
out(io, JSON.stringify(r, null, 2));
|
|
815
|
+
return r.errors.length ? 1 : 0;
|
|
816
|
+
}
|
|
817
|
+
out(io, `reconcile (read-only) · wallet ${r.wallet.slice(0, 4)}…${r.wallet.slice(-4)} · SOL ${r.sol ?? '?'} · ${Object.keys(r.holdings).length} token holdings`);
|
|
818
|
+
out(io, ` real-mode book holds ${book.held.length} mint(s); missing from the wallet: ${r.missing.length ? r.missing.join(', ') : 'none'}`);
|
|
819
|
+
if (r.stillHeld.length)
|
|
820
|
+
out(io, ` closed in the book but still in the wallet: ${r.stillHeld.join(', ')}`);
|
|
821
|
+
for (const e of r.errors)
|
|
822
|
+
out(io, ` could not read: ${e}`);
|
|
823
|
+
return r.errors.length ? 1 : 0;
|
|
824
|
+
}
|