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,70 @@
|
|
|
1
|
+
# dexterm executor reference kit
|
|
2
|
+
|
|
3
|
+
dexterm holds no key and signs nothing. In real mode a strategy sends a **LIVE TRADE FIRE** signal
|
|
4
|
+
(`dexterm-trade-fire/1`) for every buy and sell. **Your own executor** acts on it: a Telegram trading bot you chose,
|
|
5
|
+
or any script.
|
|
6
|
+
|
|
7
|
+
This folder holds three small executors you can wire up in minutes, so you can see the whole path work before
|
|
8
|
+
anything can spend money:
|
|
9
|
+
|
|
10
|
+
| file | channel | how dexterm reaches it |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `webhook-executor.mjs` | webhook | `dexterm autotrade signal channels --webhook http://127.0.0.1:8787/signal` |
|
|
13
|
+
| `signal-cmd-executor.mjs` | command hook | `dexterm autotrade signal channels --signal-cmd '["/abs/node","/abs/signal-cmd-executor.mjs","--account","main"]'` |
|
|
14
|
+
| `jsonl-tail-executor.mjs` | JSONL stream | `dexterm autotrade signal channels --jsonl on` (add `--jsonl-signed on` for HMAC lines) |
|
|
15
|
+
|
|
16
|
+
Each one handles every signal the same way:
|
|
17
|
+
|
|
18
|
+
1. It verifies the HMAC, when you give it the secret file. Without `--secret-file` it acts on unsigned bodies (it says `HMAC NOT checked` when it starts); before you put a real trade into `act()`, run it with `--secret-file` and `--require-hmac`.
|
|
19
|
+
2. It checks the account id.
|
|
20
|
+
3. It dedupes by nonce. Delivery is at-least-once, so a repeat is normal, and the seen set survives a restart.
|
|
21
|
+
4. It ignores anything past `expiresAt` (the TTL).
|
|
22
|
+
5. It writes a receipt to dexterm's `autotrade/real/receipts.jsonl` and a heartbeat to `autotrade/real/executor.jsonl`.
|
|
23
|
+
|
|
24
|
+
**All of them are DRY RUN.** `act()` in `kit.mjs` only logs what it WOULD do, and the receipt says so:
|
|
25
|
+
status `rejected`, reason `dry-run: would BUY …`. There is no wallet code, no key and no chain call anywhere in this
|
|
26
|
+
kit. Trading for real means replacing `act()` with your own code, or handing the order to your own bot. That is your
|
|
27
|
+
code and your wallet, never dexterm's.
|
|
28
|
+
|
|
29
|
+
## Five minutes, end to end (paper money only)
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# 1. a secret for the HMAC (written to a 0600 file, stored in your keychain, never printed)
|
|
33
|
+
dexterm autotrade signal secret generate --out ~/.dexterm-exec.secret
|
|
34
|
+
|
|
35
|
+
# 2. start the kit's webhook executor (dry run) in another terminal
|
|
36
|
+
node webhook-executor.mjs --account main --secret-file ~/.dexterm-exec.secret
|
|
37
|
+
|
|
38
|
+
# 3. point dexterm at it, then prove the round trip
|
|
39
|
+
dexterm autotrade signal channels --webhook http://127.0.0.1:8787/signal
|
|
40
|
+
dexterm autotrade signal test # per channel: delivered, latency, the kit's receipt
|
|
41
|
+
dexterm autotrade executor status # alive · last heartbeat · last receipt · lag
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`signal test` sends a signal marked `test: true` that names no mint, so there is nothing in it to trade. The kit
|
|
45
|
+
receipts it as `rejected` (`test signal: received, never traded`).
|
|
46
|
+
|
|
47
|
+
## Heartbeats and the silence pause
|
|
48
|
+
|
|
49
|
+
dexterm counts a heartbeat, a receipt, or an ack on the webhook or command hook as a sign of life. When it hears
|
|
50
|
+
nothing for longer than your `executorSilenceMin` limit (placeholder: 15 min), a real run **pauses new buy
|
|
51
|
+
signals** and tells you in the run log and on Telegram. Exits keep going out. The kit sends a heartbeat every 60 s
|
|
52
|
+
(`--heartbeat-sec`). Your own executor can append the same line, POST it to the run's loopback listener
|
|
53
|
+
(`--executor-port`), or run `dexterm autotrade executor heartbeat`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{"schema":"dexterm-executor-heartbeat/1","at":1761300000000,"executor":"my-bot","accountId":"main","dryRun":false,"lastNonce":42}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Receipts
|
|
60
|
+
|
|
61
|
+
A receipt is data only. It moves a real position from `unconfirmed` to executed / rejected / failed. With a fill
|
|
62
|
+
(`priceUsd`, `sizeSol`, `tokens`, `txSig`), dexterm also measures how far the real fill was from its own paper fill:
|
|
63
|
+
slippage, latency, partial fills and rejections. That shows in `dexterm autotrade audit`, `signal status` and
|
|
64
|
+
`dexterm autotrade report`.
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{"schema":"dexterm-trade-receipt/1","signalId":"<the signal's id>","nonce":42,"accountId":"main","status":"executed","at":1761300001200,"fill":{"priceUsd":0.00123,"sizeSol":0.25,"tokens":40650,"txSig":null},"reason":null,"executor":"my-bot"}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
See `telegram-bot-recipe.md` for the Telegram trading-bot setup. The bot executes, and dexterm never signs.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dexterm executor reference kit: the JSONL stream tail (task-1654). DRY RUN: logs what it would do; no wallet code.
|
|
3
|
+
//
|
|
4
|
+
// node jsonl-tail-executor.mjs --account main [--stream FILE] [--secret-file FILE --require-hmac] [--once] [--poll-ms 1000]
|
|
5
|
+
//
|
|
6
|
+
// Turn the stream on in dexterm (`dexterm autotrade signal channels --jsonl on`, default file
|
|
7
|
+
// autotrade/real/signals.jsonl). With `--jsonl-signed on` each line is an HMAC envelope this tail verifies; a bare
|
|
8
|
+
// line has no signature, so --require-hmac refuses it. The read offset is saved, so a restart resumes where it was.
|
|
9
|
+
// --once reads what is there and exits (handy for a test).
|
|
10
|
+
|
|
11
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { SeenStore, args, dextermHome, handle, heartbeat, readSecret, realDir, unwrapLine } from './kit.mjs';
|
|
14
|
+
|
|
15
|
+
const a = args();
|
|
16
|
+
if (!a.account) { console.error('usage: node jsonl-tail-executor.mjs --account ID [--stream FILE] [--secret-file FILE] [--once]'); process.exit(2); }
|
|
17
|
+
const home = a.home || dextermHome();
|
|
18
|
+
const stream = a.stream || join(realDir(home), 'signals.jsonl');
|
|
19
|
+
const offFile = a['offset-file'] || join(realDir(home), 'kit-tail-offset.json');
|
|
20
|
+
const seen = new SeenStore(a.state || join(realDir(home), 'kit-tail-seen.json'));
|
|
21
|
+
const secret = readSecret(a['secret-file']);
|
|
22
|
+
const name = String(a.name || 'kit-jsonl-tail').slice(0, 32);
|
|
23
|
+
const log = (s) => console.log(`[${new Date().toISOString()}] ${s}`);
|
|
24
|
+
let offset = 0;
|
|
25
|
+
try { offset = Number(JSON.parse(readFileSync(offFile, 'utf8')).offset) || 0; } catch { /* from the start */ }
|
|
26
|
+
let carry = '';
|
|
27
|
+
|
|
28
|
+
function poll() {
|
|
29
|
+
if (!existsSync(stream)) return;
|
|
30
|
+
const size = statSync(stream).size;
|
|
31
|
+
if (size < offset) { offset = 0; carry = ''; }
|
|
32
|
+
if (size === offset) return;
|
|
33
|
+
const fd = openSync(stream, 'r');
|
|
34
|
+
const buf = Buffer.alloc(size - offset);
|
|
35
|
+
try { readSync(fd, buf, 0, buf.length, offset); } finally { closeSync(fd); }
|
|
36
|
+
offset = size;
|
|
37
|
+
const lines = (carry + buf.toString('utf8')).split('\n');
|
|
38
|
+
carry = lines.pop() ?? '';
|
|
39
|
+
for (const l of lines) {
|
|
40
|
+
if (!l.trim()) continue;
|
|
41
|
+
const { body, signature } = unwrapLine(l);
|
|
42
|
+
handle(body, { accountId: a.account, secret, requireHmac: !!a['require-hmac'], signature, seen, home, executor: name, log, now: Date.now() });
|
|
43
|
+
}
|
|
44
|
+
writeFileSync(offFile, JSON.stringify({ offset: offset - Buffer.byteLength(carry) }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const beat = () => { try { heartbeat(home, { executor: name, accountId: a.account, lastNonce: seen.maxNonce || null }); } catch (e) { log(`heartbeat failed: ${e.message}`); } };
|
|
48
|
+
poll();
|
|
49
|
+
beat();
|
|
50
|
+
if (!a.once) {
|
|
51
|
+
log(`dexterm kit JSONL tail executor (DRY RUN) on ${stream} · account ${a.account}`);
|
|
52
|
+
const t1 = setInterval(poll, Math.max(100, Number(a['poll-ms'] ?? 1000)));
|
|
53
|
+
const t2 = setInterval(beat, Math.max(5, Number(a['heartbeat-sec'] ?? 60)) * 1000);
|
|
54
|
+
const stop = () => { clearInterval(t1); clearInterval(t2); process.exit(0); };
|
|
55
|
+
process.on('SIGINT', stop);
|
|
56
|
+
process.on('SIGTERM', stop);
|
|
57
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// dexterm executor reference kit: the shared part (task-1654). Plain Node (22+), no dependencies, no wallet code.
|
|
2
|
+
//
|
|
3
|
+
// Every kit executor does the same five things with a LIVE TRADE FIRE signal (`dexterm-trade-fire/1`):
|
|
4
|
+
// 1. verify the HMAC (`t=<unix s>,v1=<hex sha256 of "<t>.<body>">`) when you gave it the secret file
|
|
5
|
+
// 2. check the shape, and that the signal is for YOUR account id
|
|
6
|
+
// 3. dedupe by nonce and id (delivery is at-least-once: a repeat is normal); the seen set survives a restart
|
|
7
|
+
// 4. honor the TTL: anything past `expiresAt` is ignored, never acted on late
|
|
8
|
+
// 5. write a receipt (`dexterm-trade-receipt/1`) to dexterm's receipts.jsonl, and a heartbeat now and then
|
|
9
|
+
//
|
|
10
|
+
// It is DRY RUN, always: `act()` below only logs what it WOULD do and the receipt says so (status "rejected",
|
|
11
|
+
// reason "dry-run: …"). There is no key, no wallet and no chain call in this kit. To trade for real you replace
|
|
12
|
+
// `act()` with your own executor's code (or hand the order to the Telegram trading bot you chose); dexterm never
|
|
13
|
+
// signs anything.
|
|
14
|
+
|
|
15
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
export const SIGNAL_SCHEMA = 'dexterm-trade-fire/1';
|
|
21
|
+
export const RECEIPT_SCHEMA = 'dexterm-trade-receipt/1';
|
|
22
|
+
export const HEARTBEAT_SCHEMA = 'dexterm-executor-heartbeat/1';
|
|
23
|
+
export const ENVELOPE_SCHEMA = 'dexterm-trade-fire-envelope/1';
|
|
24
|
+
const TOLERANCE_SEC = 300;
|
|
25
|
+
const MINT = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
26
|
+
|
|
27
|
+
/** `--name value` / `--flag` argv into an object. */
|
|
28
|
+
export function args(argv = process.argv.slice(2)) {
|
|
29
|
+
const o = {};
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (!a.startsWith('--')) continue;
|
|
33
|
+
const k = a.slice(2);
|
|
34
|
+
const v = argv[i + 1];
|
|
35
|
+
if (v !== undefined && !v.startsWith('--')) { o[k] = v; i++; } else o[k] = true;
|
|
36
|
+
}
|
|
37
|
+
return o;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Where dexterm keeps its files (DEXTERM_HOME, else ~/.dexterm). */
|
|
41
|
+
export const dextermHome = (env = process.env) => env.DEXTERM_HOME || join(homedir(), '.dexterm');
|
|
42
|
+
export const realDir = (home) => join(home, 'autotrade', 'real');
|
|
43
|
+
|
|
44
|
+
/** The HMAC secret from a file (`dexterm autotrade signal secret generate --out FILE`), or null. */
|
|
45
|
+
export function readSecret(file) {
|
|
46
|
+
if (!file) return null;
|
|
47
|
+
const v = readFileSync(file, 'utf8').trim();
|
|
48
|
+
return v || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function verify(secret, header, body, nowMs = Date.now()) {
|
|
52
|
+
const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(String(header ?? ''));
|
|
53
|
+
if (!m) return { ok: false, why: 'signature-missing' };
|
|
54
|
+
const t = Number(m[1]);
|
|
55
|
+
if (Math.abs(nowMs / 1000 - t) > TOLERANCE_SEC) return { ok: false, why: 'signature-stale' };
|
|
56
|
+
const want = createHmac('sha256', secret).update(`${t}.${body}`).digest();
|
|
57
|
+
const got = Buffer.from(m[2], 'hex');
|
|
58
|
+
return got.length === want.length && timingSafeEqual(got, want) ? { ok: true } : { ok: false, why: 'signature-bad' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The seen nonces and ids, in a small JSON file so a restart does not act on a repeat. */
|
|
62
|
+
export class SeenStore {
|
|
63
|
+
constructor(file) { this.file = file; this.seen = new Set(); this.maxNonce = 0; try { const d = JSON.parse(readFileSync(file, 'utf8')); for (const x of d.seen ?? []) this.seen.add(x); this.maxNonce = Number(d.maxNonce) || 0; } catch { /* first run */ } }
|
|
64
|
+
has(s) { return this.seen.has(s.id) || this.seen.has(`n${s.nonce}`); }
|
|
65
|
+
add(s) {
|
|
66
|
+
this.seen.add(s.id); this.seen.add(`n${s.nonce}`); this.maxNonce = Math.max(this.maxNonce, s.nonce);
|
|
67
|
+
const keep = [...this.seen].slice(-4000);
|
|
68
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
69
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
70
|
+
writeFileSync(tmp, JSON.stringify({ seen: keep, maxNonce: this.maxNonce }));
|
|
71
|
+
renameSync(tmp, this.file);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check one signal body. Returns { verdict, signal?, why }: verdict is act | duplicate | expired | rejected.
|
|
77
|
+
* `secret` null = no HMAC check (only when you chose not to set one).
|
|
78
|
+
*/
|
|
79
|
+
export function check(body, o) {
|
|
80
|
+
const now = o.now ?? Date.now();
|
|
81
|
+
if (o.secret) { const v = verify(o.secret, o.signature, body, now); if (!v.ok) return { verdict: 'rejected', why: v.why }; }
|
|
82
|
+
else if (o.requireHmac) return { verdict: 'rejected', why: 'signature-required' };
|
|
83
|
+
let s;
|
|
84
|
+
try { s = JSON.parse(body); } catch { return { verdict: 'rejected', why: 'not-json' }; }
|
|
85
|
+
if (!s || s.schema !== SIGNAL_SCHEMA || !['trade', 'halted', 'test'].includes(s.type) || !Number.isSafeInteger(s.nonce) || typeof s.id !== 'string') return { verdict: 'rejected', why: 'shape' };
|
|
86
|
+
if (s.paper !== false) return { verdict: 'rejected', why: 'paper-flag' };
|
|
87
|
+
if (s.accountId !== o.accountId) return { verdict: 'rejected', why: 'wrong-account', signal: s };
|
|
88
|
+
if (o.seen.has(s)) return { verdict: 'duplicate', why: 'duplicate', signal: s };
|
|
89
|
+
if (!(now <= s.expiresAt)) return { verdict: 'expired', why: 'expired', signal: s };
|
|
90
|
+
if (s.type === 'trade' && (!MINT.test(s.mint) || (s.side !== 'buy' && s.side !== 'sell'))) return { verdict: 'rejected', why: 'shape', signal: s };
|
|
91
|
+
if (s.type === 'trade' && s.nonce <= o.seen.maxNonce) return { verdict: 'rejected', why: 'nonce-replay', signal: s };
|
|
92
|
+
return { verdict: 'act', why: null, signal: s };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What the executor does with a fresh signal. DRY RUN: it only says what it WOULD do. Replace this with your own
|
|
97
|
+
* code to trade (your wallet, your bot); keep the rest of the pipeline (verify, dedupe, TTL, receipt) as it is.
|
|
98
|
+
*/
|
|
99
|
+
export function act(s) {
|
|
100
|
+
if (s.type === 'test') return { status: 'rejected', reason: 'test signal: received, never traded' };
|
|
101
|
+
if (s.type === 'halted') return { status: null, reason: `halted (${s.reason}): would cancel ${s.outstanding.length} outstanding signal(s)` };
|
|
102
|
+
const what = s.side === 'buy' ? `would BUY ${s.sizeSol} SOL of ${s.symbol} (${s.mint}) with ${s.slippageBps} bps slippage` : `would SELL ${Math.round(s.sellFraction * 1000) / 10}% of ${s.symbol} (${s.mint}): ${s.exitReason}`;
|
|
103
|
+
return { status: 'rejected', reason: `dry-run: ${what}`.slice(0, 120) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Append a receipt line to dexterm's receipts.jsonl (the same file `dexterm autotrade receipt` writes). */
|
|
107
|
+
export function writeReceipt(home, s, r, executor, now = Date.now()) {
|
|
108
|
+
const line = { schema: RECEIPT_SCHEMA, signalId: s.id, nonce: s.nonce, accountId: s.accountId, status: r.status, at: now, fill: r.fill ?? null, reason: r.reason ?? null, executor };
|
|
109
|
+
mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
|
|
110
|
+
appendFileSync(join(realDir(home), 'receipts.jsonl'), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
|
111
|
+
return line;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Append a heartbeat to dexterm's executor.jsonl: dexterm pauses new buys when it hears nothing for too long. */
|
|
115
|
+
export function heartbeat(home, o) {
|
|
116
|
+
const hb = { schema: HEARTBEAT_SCHEMA, at: o.now ?? Date.now(), executor: o.executor, accountId: o.accountId ?? null, dryRun: true, lastNonce: o.lastNonce ?? null, note: o.note ?? null };
|
|
117
|
+
mkdirSync(realDir(home), { recursive: true, mode: 0o700 });
|
|
118
|
+
appendFileSync(join(realDir(home), 'executor.jsonl'), `${JSON.stringify(hb)}\n`, { mode: 0o600 });
|
|
119
|
+
return hb;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The whole pipeline for one body: check, act (dry run), receipt, log line. Returns the verdict. */
|
|
123
|
+
export function handle(body, o) {
|
|
124
|
+
const c = check(body, o);
|
|
125
|
+
const s = c.signal;
|
|
126
|
+
if (c.verdict === 'act') {
|
|
127
|
+
o.seen.add(s);
|
|
128
|
+
const r = act(s);
|
|
129
|
+
if (r.status && s.type !== 'halted') writeReceipt(o.home, s, r, o.executor, o.now ?? Date.now());
|
|
130
|
+
o.log(`#${s.nonce} ${s.type}${s.type === 'trade' ? ` ${s.side}` : ''}: ${r.reason}`);
|
|
131
|
+
} else o.log(`${s ? `#${s.nonce} ` : ''}${c.verdict}: ${c.why}`);
|
|
132
|
+
return c;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A stream line: a bare signal, or a signed envelope (`dexterm autotrade signal channels --jsonl-signed on`). */
|
|
136
|
+
export function unwrapLine(line) {
|
|
137
|
+
try {
|
|
138
|
+
const x = JSON.parse(line);
|
|
139
|
+
if (x && x.schema === ENVELOPE_SCHEMA && typeof x.signal === 'string') return { body: x.signal, signature: x.signature ?? null };
|
|
140
|
+
} catch { /* handled by check() */ }
|
|
141
|
+
return { body: line, signature: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const exists = existsSync;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dexterm executor reference kit: the --signal-cmd hook (task-1654). DRY RUN: logs what it would do; no wallet code.
|
|
3
|
+
//
|
|
4
|
+
// dexterm runs it once per signal, with no shell, the signal line on stdin and the HMAC in DEXTERM_SIGNAL_SIGNATURE
|
|
5
|
+
// (when a secret is set). Wire it with an absolute path:
|
|
6
|
+
// dexterm autotrade signal channels --signal-cmd '["/usr/local/bin/node","/path/to/signal-cmd-executor.mjs","--account","main","--secret-file","/path/to/secret"]'
|
|
7
|
+
//
|
|
8
|
+
// Exit codes dexterm reads: 0 acted (dry run) · 3 duplicate (counted delivered) · 2 rejected or expired (never
|
|
9
|
+
// retried) · anything else is retried. A line per signal goes to --log (default autotrade/real/kit-cmd.log).
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { SeenStore, args, dextermHome, handle, heartbeat, readSecret, realDir } from './kit.mjs';
|
|
14
|
+
|
|
15
|
+
const a = args();
|
|
16
|
+
if (!a.account) { console.error('usage: signal-cmd-executor.mjs --account ID [--secret-file FILE] [--home DIR]'); process.exit(2); }
|
|
17
|
+
const home = a.home || dextermHome();
|
|
18
|
+
const logFile = a.log || join(realDir(home), 'kit-cmd.log');
|
|
19
|
+
const log = (s) => { try { appendFileSync(logFile, `[${new Date().toISOString()}] ${s}\n`); } catch { /* the exit code still answers */ } };
|
|
20
|
+
const name = String(a.name || 'kit-signal-cmd').slice(0, 32);
|
|
21
|
+
const body = readFileSync(0, 'utf8').trim();
|
|
22
|
+
const seen = new SeenStore(a.state || join(realDir(home), 'kit-cmd-seen.json'));
|
|
23
|
+
const c = handle(body, { accountId: a.account, secret: readSecret(a['secret-file']), requireHmac: !!a['require-hmac'], signature: process.env.DEXTERM_SIGNAL_SIGNATURE ?? null, seen, home, executor: name, log, now: Date.now() });
|
|
24
|
+
try { heartbeat(home, { executor: name, accountId: a.account, lastNonce: seen.maxNonce || null }); } catch { /* optional */ }
|
|
25
|
+
process.exit(c.verdict === 'act' ? 0 : c.verdict === 'duplicate' ? 3 : 2);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Recipe: a Telegram trading bot as your executor
|
|
2
|
+
|
|
3
|
+
Many people already trade memecoins through a Telegram trading bot. That bot holds the wallet and signs the swaps.
|
|
4
|
+
dexterm can drive the decision side, but it can never be the bot and never holds the bot's key.
|
|
5
|
+
|
|
6
|
+
## Why the Telegram copy alone is not enough
|
|
7
|
+
|
|
8
|
+
dexterm's Telegram copy of each signal goes to **your** chat, for you to read. A bot cannot read another bot's
|
|
9
|
+
messages, so your trading bot never sees that copy. Something on your side has to hand each signal to the bot.
|
|
10
|
+
|
|
11
|
+
## The shape that works
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
dexterm (real mode) --signal--> your small relay (one of the kit executors) --order--> your trading bot
|
|
15
|
+
<--receipt-- <--fill--
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
1. Start from one of the kit executors, for example `webhook-executor.mjs`. It already verifies the HMAC, dedupes
|
|
19
|
+
by nonce, honours the TTL, writes receipts and sends heartbeats.
|
|
20
|
+
2. Replace `act()` in `kit.mjs` with the call your bot offers to accounts that automate it. Depending on the bot
|
|
21
|
+
that is an HTTP API, a CLI, or a message your own user account sends to it through a Telegram client library.
|
|
22
|
+
Use only what the bot documents and allows. That code, its credentials and the wallet are yours; keep them out
|
|
23
|
+
of dexterm's folders.
|
|
24
|
+
3. When the bot answers, write the receipt with the real fill: `status: "executed"` plus `priceUsd`, `sizeSol`,
|
|
25
|
+
`tokens` and `txSig`, or `rejected` / `failed` with a reason. dexterm then shows how far the real fills were
|
|
26
|
+
from paper.
|
|
27
|
+
4. Keep the relay's heartbeat running. If the relay or the bot goes quiet longer than your `executorSilenceMin`,
|
|
28
|
+
dexterm pauses new buy signals and tells you.
|
|
29
|
+
|
|
30
|
+
## Rules the relay must keep (the kit does)
|
|
31
|
+
|
|
32
|
+
- It acts only on signals for its own `accountId`.
|
|
33
|
+
- It never acts after `expiresAt`, and it never retries a stale order on its own.
|
|
34
|
+
- It dedupes by `nonce` and `id`, because the same signal can arrive twice.
|
|
35
|
+
- It treats `type: "test"` as a test: there is never an order.
|
|
36
|
+
- On `type: "halted"`, it may cancel the `outstanding` nonces that are still open on its side. `open` lists what
|
|
37
|
+
dexterm still counts as held.
|
|
38
|
+
- It sizes from its own wallet. `sizeSol` is what dexterm's limits allowed, and `sellFraction` applies to what the
|
|
39
|
+
bot really holds.
|
|
40
|
+
|
|
41
|
+
## Before any real money
|
|
42
|
+
|
|
43
|
+
- Run the relay in dry run first. `dexterm autotrade signal test` must show every channel delivered and the relay's
|
|
44
|
+
receipt.
|
|
45
|
+
- Set your own limits (`dexterm autotrade limits set …`). The defaults are placeholders: 0.25 SOL per trade, 1 SOL
|
|
46
|
+
a day, 20 buys, 3 open, stop at -0.5 SOL a day.
|
|
47
|
+
- Keep `dexterm autotrade signal kill` at hand. It stops every signal except one final `halted`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dexterm executor reference kit: the WEBHOOK receiver (task-1654). DRY RUN: logs what it would do; no wallet code.
|
|
3
|
+
//
|
|
4
|
+
// node webhook-executor.mjs --account main [--port 8787] [--secret-file FILE] [--home DIR] [--heartbeat-sec 60]
|
|
5
|
+
//
|
|
6
|
+
// Then point dexterm at it: dexterm autotrade signal channels --webhook http://127.0.0.1:8787/signal
|
|
7
|
+
// and prove the wiring: dexterm autotrade signal test
|
|
8
|
+
//
|
|
9
|
+
// Answers: 200 acted (dry run) · 409 duplicate (dexterm counts it delivered) · 410 expired · 401 bad signature ·
|
|
10
|
+
// 400 anything else it will not act on. It binds 127.0.0.1 only. Ctrl-C stops it.
|
|
11
|
+
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { SeenStore, args, dextermHome, handle, heartbeat, readSecret, realDir } from './kit.mjs';
|
|
15
|
+
|
|
16
|
+
const a = args();
|
|
17
|
+
if (!a.account) { console.error('usage: node webhook-executor.mjs --account ID [--port 8787] [--secret-file FILE] [--home DIR]'); process.exit(2); }
|
|
18
|
+
const home = a.home || dextermHome();
|
|
19
|
+
const secret = readSecret(a['secret-file']);
|
|
20
|
+
const seen = new SeenStore(a.state || join(realDir(home), 'kit-webhook-seen.json'));
|
|
21
|
+
const name = String(a.name || 'kit-webhook').slice(0, 32);
|
|
22
|
+
const log = (s) => console.log(`[${new Date().toISOString()}] ${s}`);
|
|
23
|
+
const o = { accountId: a.account, secret, requireHmac: !!a['require-hmac'], seen, home, executor: name, log };
|
|
24
|
+
|
|
25
|
+
const server = createServer((req, res) => {
|
|
26
|
+
if (req.method !== 'POST') { res.writeHead(405); res.end('POST only'); return; }
|
|
27
|
+
let body = '';
|
|
28
|
+
req.setEncoding('utf8');
|
|
29
|
+
req.on('data', (c) => { body += c; if (body.length > 64 * 1024) req.destroy(); });
|
|
30
|
+
req.on('end', () => {
|
|
31
|
+
const c = handle(body, { ...o, signature: req.headers['x-dexterm-signature'] ?? null, now: Date.now() });
|
|
32
|
+
const code = c.verdict === 'act' ? 200 : c.verdict === 'duplicate' ? 409 : c.verdict === 'expired' ? 410 : /^signature/.test(c.why ?? '') ? 401 : 400;
|
|
33
|
+
res.writeHead(code, { 'content-type': 'text/plain' });
|
|
34
|
+
res.end(c.verdict);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const beat = () => { try { heartbeat(home, { executor: name, accountId: a.account, lastNonce: seen.maxNonce || null }); } catch (e) { log(`heartbeat failed: ${e.message}`); } };
|
|
39
|
+
server.listen(Number(a.port ?? 8787), '127.0.0.1', () => {
|
|
40
|
+
const port = server.address().port;
|
|
41
|
+
log(`dexterm kit webhook executor (DRY RUN) listening on http://127.0.0.1:${port}/signal · account ${a.account} · HMAC ${secret ? 'checked' : 'NOT checked (no --secret-file)'}`);
|
|
42
|
+
beat();
|
|
43
|
+
});
|
|
44
|
+
const timer = setInterval(beat, Math.max(5, Number(a['heartbeat-sec'] ?? 60)) * 1000);
|
|
45
|
+
const stop = () => { clearInterval(timer); server.close(() => process.exit(0)); };
|
|
46
|
+
process.on('SIGINT', stop);
|
|
47
|
+
process.on('SIGTERM', stop);
|
|
@@ -32,7 +32,8 @@ export const DATA_LIMITS = [
|
|
|
32
32
|
/** A closed trade at or below this is counted as a rug (the challenge's rugAtPct). */
|
|
33
33
|
export const RUG_PCT = -80;
|
|
34
34
|
export async function backtest(strategy, tape, sessions = null) {
|
|
35
|
-
|
|
35
|
+
// task-1645: a strategy in mode real backtests as its paper twin (a backtest never emits a trade-fire signal).
|
|
36
|
+
const run = await runAutotrade({ strategy: strategy.mode === 'real' ? { ...strategy, mode: 'paper' } : strategy, tape, sessions, pacing: { mode: 'lockstep' } });
|
|
36
37
|
return { report: report(strategy, tape, run), run };
|
|
37
38
|
}
|
|
38
39
|
export function report(s, tape, run) {
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// $DEXTERM_HOME/connectors.json (0600):
|
|
4
4
|
// { schema, telegram: { enabled, bot: {id, username}, secretStore, tiers, allow: [{senderId, chatId, tiers,
|
|
5
5
|
// pairedAt}], notify: {…per event…}, dailySummaryAt, quietHours, approvals, approvalExpirySec, repriceTolPct,
|
|
6
|
-
// showAbsoluteBook } }
|
|
6
|
+
// showAbsoluteBook }, signals: {…} }
|
|
7
|
+
// task-1645: `signals` is the real-mode trade-fire channel setup (signal-channels.ts parses it); it lives here so the
|
|
8
|
+
// Telegram copy and the executor channels are configured in one place. This module only carries it through.
|
|
7
9
|
// Only local commands write it (`dexterm telegram setup|pair|revoke|approvals|notify`). From chat, exactly one
|
|
8
10
|
// field can change: `approvals` from "off" to "on" (safer); everything else, and "off", is local only.
|
|
9
11
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
@@ -70,7 +72,7 @@ export function loadConnectors(home) {
|
|
|
70
72
|
repriceTolPct: Number.isFinite(tol) && tol > 0 ? Math.min(10, tol) : 3,
|
|
71
73
|
showAbsoluteBook: t.showAbsoluteBook === true,
|
|
72
74
|
};
|
|
73
|
-
return { ok: true, config: { schema: CONNECTORS_SCHEMA, telegram }, exists: true };
|
|
75
|
+
return { ok: true, config: { schema: CONNECTORS_SCHEMA, telegram, ...(d.signals !== undefined ? { signals: d.signals } : {}) }, exists: true };
|
|
74
76
|
}
|
|
75
77
|
export function saveConnectors(home, c) {
|
|
76
78
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
@@ -45,6 +45,7 @@ export class ControlRouter {
|
|
|
45
45
|
`open ${s.open} · candidates ${s.candidates} · day P/L ${pct(s.dayPnlPct)}`,
|
|
46
46
|
`model spend today $${s.spend.todayUsd.toFixed(2)}${Number.isFinite(s.spend.dayCapUsd) ? ` of $${s.spend.dayCapUsd}` : ''}${s.allowance?.fiveHour !== null && s.allowance?.fiveHour !== undefined ? ` · 5 h window ${Math.round(s.allowance.fiveHour * 100)}%` : ''}`,
|
|
47
47
|
`approvals: ${s.approvals}`,
|
|
48
|
+
...(s.real ? [`executor: ${inert(s.real.executor, 160)}`, `limits: ${inert(s.real.limits, 160)}`, ...(s.real.pause ? [`NEW BUYS PAUSED: ${inert(s.real.pause, 120)}`] : [])] : []),
|
|
48
49
|
],
|
|
49
50
|
};
|
|
50
51
|
}
|
|
@@ -49,6 +49,9 @@ export class NotifyBus {
|
|
|
49
49
|
warned = new Set();
|
|
50
50
|
summarySentFor = '';
|
|
51
51
|
dayStart;
|
|
52
|
+
/** task-1645: a run in mode real is LIVE in every message (its trades go to the user's executor as signals). */
|
|
53
|
+
realRun = false;
|
|
54
|
+
badge() { return this.realRun ? 'LIVE' : this.o.badge; }
|
|
52
55
|
constructor(o) {
|
|
53
56
|
this.o = o;
|
|
54
57
|
this.now = o.now ?? Date.now;
|
|
@@ -77,7 +80,7 @@ export class NotifyBus {
|
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
emit(template, lines, urgent = false) {
|
|
80
|
-
this.post(template, { kind: 'text', badge: this.
|
|
83
|
+
this.post(template, { kind: 'text', badge: this.badge(), lines }, urgent);
|
|
81
84
|
}
|
|
82
85
|
post(template, msg, urgent) {
|
|
83
86
|
const now = this.now();
|
|
@@ -95,7 +98,7 @@ export class NotifyBus {
|
|
|
95
98
|
ofBook(usd) {
|
|
96
99
|
const eq = this.state?.book.equityUsd ?? 0;
|
|
97
100
|
const share = eq > 0 ? `${((usd / eq) * 100).toFixed(1)}% of book` : '% of book unknown';
|
|
98
|
-
return this.o.config().showAbsoluteBook && this.
|
|
101
|
+
return this.o.config().showAbsoluteBook && this.badge() === 'PAPER' && eq > 0 ? `${share}, book ${usdShort(eq)}` : share;
|
|
99
102
|
}
|
|
100
103
|
sym(mint, fallback) { return `"${inert(this.symbols.get(mint) ?? fallback ?? '?', 20)}" ${shortMint(mint)}`; }
|
|
101
104
|
handle(e) {
|
|
@@ -105,8 +108,9 @@ export class NotifyBus {
|
|
|
105
108
|
switch (e.kind) {
|
|
106
109
|
case 'run_start': {
|
|
107
110
|
this.highRisk = e.highRisk === true;
|
|
111
|
+
this.realRun = e.mode === 'real';
|
|
108
112
|
this.strategyLine = `${this.highRisk ? '⚠ HIGH RISK · ' : ''}${inert(e.strategy, 32)} v${inert(e.version, 12)} (${String(e.strategyHash ?? '').slice(0, 8)})`;
|
|
109
|
-
this.emit('run_start', [`RUNNER STARTED · ${this.strategyLine}`, ...(this.highRisk ? ['full-port strategy: one entry can be the whole stake, and one rug can zero it. Experimental; not financial advice.'] : []), `entries: ${e.decide === 'agent+approve' || e.approvals === 'required' ? 'each one waits for your tap' : String(e.decide) === 'auto' ? 'mechanical' : 'your agent decides'} · /status /pause /kill /help`], true);
|
|
113
|
+
this.emit('run_start', [`RUNNER STARTED · ${this.strategyLine}`, ...(this.highRisk ? ['full-port strategy: one entry can be the whole stake, and one rug can zero it. Experimental; not financial advice.'] : []), ...(this.realRun ? ['REAL MODE: every trade below also goes to YOUR executor as a LIVE TRADE FIRE signal (dexterm holds no key). Experimental; not financial advice.'] : []), `entries: ${e.decide === 'agent+approve' || e.approvals === 'required' ? 'each one waits for your tap' : String(e.decide) === 'auto' ? 'mechanical' : 'your agent decides'} · /status /pause /kill /help`], true);
|
|
110
114
|
return;
|
|
111
115
|
}
|
|
112
116
|
case 'candidate': {
|
|
@@ -171,13 +175,19 @@ export class NotifyBus {
|
|
|
171
175
|
return;
|
|
172
176
|
const caption = `CLOSED · "${inert(t.symbol, 20)}" ${shortMint(t.mint)} · ${pct(t.pnlPct)} (${t.pnlUsd >= 0 ? '+' : ''}${usdShort(t.pnlUsd)}) by ${inert(t.exitReason, 40)} · held ${Math.max(1, Math.round((t.closedAt - t.openedAt) / 60_000))} min`;
|
|
173
177
|
const card = this.o.card;
|
|
178
|
+
// task-1645: a real run's close is the monitor's modelled view, not your executor's fills: text only, no
|
|
179
|
+
// shareable card (the receipts say what really filled).
|
|
180
|
+
if (this.realRun) {
|
|
181
|
+
this.emit('closed', [`${caption} · modelled; your executor's fills may differ (dexterm autotrade receipt list)`]);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
174
184
|
if (!card) {
|
|
175
185
|
this.emit('closed', [caption]);
|
|
176
186
|
return;
|
|
177
187
|
}
|
|
178
188
|
void card(t).then((png) => {
|
|
179
189
|
if (png)
|
|
180
|
-
this.post('closed_card', { kind: 'card', badge: this.
|
|
190
|
+
this.post('closed_card', { kind: 'card', badge: this.badge(), png, caption }, false);
|
|
181
191
|
else
|
|
182
192
|
this.emit('closed', [caption]);
|
|
183
193
|
}, () => this.emit('closed', [caption]));
|
|
@@ -253,7 +263,7 @@ export class NotifyBus {
|
|
|
253
263
|
this.sentAt.shift();
|
|
254
264
|
if (this.digest.length && !quiet && this.sentAt.length < DIGEST_ABOVE_PER_MIN / 2) {
|
|
255
265
|
const lines = this.digest.splice(0);
|
|
256
|
-
this.post('digest', { kind: 'text', badge: this.
|
|
266
|
+
this.post('digest', { kind: 'text', badge: this.badge(), lines: [`DIGEST · ${lines.length} update${lines.length === 1 ? '' : 's'}`, ...lines.slice(-25)] }, true);
|
|
257
267
|
}
|
|
258
268
|
this.maybeSummary(now);
|
|
259
269
|
await this.queue;
|
|
@@ -270,7 +280,7 @@ export class NotifyBus {
|
|
|
270
280
|
if (this.summarySentFor === day)
|
|
271
281
|
return;
|
|
272
282
|
this.summarySentFor = day;
|
|
273
|
-
this.post('daily', { kind: 'text', badge: this.
|
|
283
|
+
this.post('daily', { kind: 'text', badge: this.badge(), lines: this.summaryLines() }, true);
|
|
274
284
|
}
|
|
275
285
|
summaryLines() {
|
|
276
286
|
const st = this.state;
|
|
@@ -14,12 +14,18 @@ import { join, resolve } from 'node:path';
|
|
|
14
14
|
export const SECRET_SERVICE = 'xyz.dexterm.telegram';
|
|
15
15
|
export const accountFor = (home) => `bot-token-${createHash('sha256').update(resolve(home)).digest('hex').slice(0, 12)}`;
|
|
16
16
|
const has = (bin) => spawnSync('/usr/bin/env', ['which', bin], { stdio: 'ignore' }).status === 0;
|
|
17
|
+
export const TELEGRAM_TOKEN_ITEM = { service: SECRET_SERVICE, label: 'dexterm telegram bot', file: 'telegram-bot.token', account: accountFor };
|
|
18
|
+
export const SIGNAL_HMAC_ITEM = {
|
|
19
|
+
service: 'xyz.dexterm.signal', label: 'dexterm trade-fire webhook secret', file: 'signal-webhook.secret',
|
|
20
|
+
account: (home) => `signal-hmac-${createHash('sha256').update(resolve(home)).digest('hex').slice(0, 12)}`,
|
|
21
|
+
};
|
|
17
22
|
class KeychainStore {
|
|
18
23
|
kind = 'keychain';
|
|
19
24
|
account;
|
|
20
|
-
|
|
25
|
+
item;
|
|
26
|
+
constructor(account, item = TELEGRAM_TOKEN_ITEM) { this.account = account; this.item = item; }
|
|
21
27
|
get() {
|
|
22
|
-
const r = spawnSync('security', ['find-generic-password', '-a', this.account, '-s',
|
|
28
|
+
const r = spawnSync('security', ['find-generic-password', '-a', this.account, '-s', this.item.service, '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
23
29
|
const v = r.status === 0 ? r.stdout.trim() : '';
|
|
24
30
|
return v || null;
|
|
25
31
|
}
|
|
@@ -27,32 +33,33 @@ class KeychainStore {
|
|
|
27
33
|
if (!/^[A-Za-z0-9:_-]+$/.test(value))
|
|
28
34
|
throw new Error('refusing to store a value with characters a bot token never has');
|
|
29
35
|
// The add command (with the value) goes through stdin; argv only says "-i".
|
|
30
|
-
const r = spawnSync('security', ['-i'], { input: `add-generic-password -U -a ${this.account} -s ${
|
|
36
|
+
const r = spawnSync('security', ['-i'], { input: `add-generic-password -U -a ${this.account} -s ${this.item.service} -l "${this.item.label}" -w "${value}"\n`, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe'] });
|
|
31
37
|
if (r.status !== 0 || this.get() !== value)
|
|
32
38
|
throw new Error('the macOS keychain did not store the token');
|
|
33
39
|
}
|
|
34
|
-
remove() { spawnSync('security', ['delete-generic-password', '-a', this.account, '-s',
|
|
40
|
+
remove() { spawnSync('security', ['delete-generic-password', '-a', this.account, '-s', this.item.service], { stdio: 'ignore' }); }
|
|
35
41
|
}
|
|
36
42
|
class LibsecretStore {
|
|
37
43
|
kind = 'libsecret';
|
|
38
44
|
account;
|
|
39
|
-
|
|
45
|
+
item;
|
|
46
|
+
constructor(account, item = TELEGRAM_TOKEN_ITEM) { this.account = account; this.item = item; }
|
|
40
47
|
get() {
|
|
41
|
-
const r = spawnSync('secret-tool', ['lookup', 'service',
|
|
48
|
+
const r = spawnSync('secret-tool', ['lookup', 'service', this.item.service, 'account', this.account], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
42
49
|
const v = r.status === 0 ? r.stdout.trim() : '';
|
|
43
50
|
return v || null;
|
|
44
51
|
}
|
|
45
52
|
set(value) {
|
|
46
|
-
const r = spawnSync('secret-tool', ['store',
|
|
53
|
+
const r = spawnSync('secret-tool', ['store', `--label=${this.item.label}`, 'service', this.item.service, 'account', this.account], { input: value, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe'] });
|
|
47
54
|
if (r.status !== 0 || this.get() !== value)
|
|
48
55
|
throw new Error('libsecret did not store the token');
|
|
49
56
|
}
|
|
50
|
-
remove() { spawnSync('secret-tool', ['clear', 'service',
|
|
57
|
+
remove() { spawnSync('secret-tool', ['clear', 'service', this.item.service, 'account', this.account], { stdio: 'ignore' }); }
|
|
51
58
|
}
|
|
52
59
|
class FileStore {
|
|
53
60
|
kind = 'file';
|
|
54
61
|
path;
|
|
55
|
-
constructor(home) { this.path = join(home, 'secrets',
|
|
62
|
+
constructor(home, item = TELEGRAM_TOKEN_ITEM) { this.path = join(home, 'secrets', item.file); }
|
|
56
63
|
get() {
|
|
57
64
|
if (!existsSync(this.path))
|
|
58
65
|
return null;
|
|
@@ -77,23 +84,23 @@ export class MemoryStore {
|
|
|
77
84
|
remove() { this.v = null; }
|
|
78
85
|
}
|
|
79
86
|
/** The store a kind names (the one setup recorded), for this home. */
|
|
80
|
-
export function secretStore(kind, home) {
|
|
87
|
+
export function secretStore(kind, home, item = TELEGRAM_TOKEN_ITEM) {
|
|
81
88
|
if (kind === 'keychain')
|
|
82
|
-
return new KeychainStore(
|
|
89
|
+
return new KeychainStore(item.account(home), item);
|
|
83
90
|
if (kind === 'libsecret')
|
|
84
|
-
return new LibsecretStore(
|
|
91
|
+
return new LibsecretStore(item.account(home), item);
|
|
85
92
|
if (kind === 'memory')
|
|
86
93
|
return new MemoryStore();
|
|
87
|
-
return new FileStore(home);
|
|
94
|
+
return new FileStore(home, item);
|
|
88
95
|
}
|
|
89
96
|
/** The best store on this machine: DEXTERM_SECRET_STORE wins, then the OS store, then the 0600 file. */
|
|
90
|
-
export function chooseSecretStore(home, env = process.env, platform = process.platform) {
|
|
97
|
+
export function chooseSecretStore(home, env = process.env, platform = process.platform, item = TELEGRAM_TOKEN_ITEM) {
|
|
91
98
|
const want = env.DEXTERM_SECRET_STORE;
|
|
92
99
|
if (want === 'file' || want === 'keychain' || want === 'libsecret')
|
|
93
|
-
return secretStore(want, home);
|
|
100
|
+
return secretStore(want, home, item);
|
|
94
101
|
if (platform === 'darwin' && has('security'))
|
|
95
|
-
return secretStore('keychain', home);
|
|
102
|
+
return secretStore('keychain', home, item);
|
|
96
103
|
if (platform === 'linux' && has('secret-tool'))
|
|
97
|
-
return secretStore('libsecret', home);
|
|
98
|
-
return secretStore('file', home);
|
|
104
|
+
return secretStore('libsecret', home, item);
|
|
105
|
+
return secretStore('file', home, item);
|
|
99
106
|
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { riskDelta } from '../../../mcp/src/index.js';
|
|
13
13
|
import { StrategyStore } from "../store.js";
|
|
14
14
|
import { requestControl } from "../safety.js";
|
|
15
|
+
import { healthLine } from "../executor-health.js";
|
|
16
|
+
import { limitsSummary } from "../limits.js";
|
|
15
17
|
import { badgeOf } from "../execution-port.js";
|
|
16
18
|
import { ApprovalBroker, CallbackStore, callbackDbPath } from "./approvals.js";
|
|
17
19
|
import { ChatAudit } from "./audit.js";
|
|
@@ -64,6 +66,7 @@ export class HostControlPort {
|
|
|
64
66
|
runId, state: st.state, haltReason: st.haltReason, strategy: st.strategy, open: st.positions.length, candidates: st.candidates.length,
|
|
65
67
|
dayPnlPct: start > 0 ? (st.book.realizedTodayUsd / start) * 100 : null, spend: { todayUsd: st.spend.todayUsd, dayCapUsd: st.spend.dayCapUsd }, allowance: st.spend.allowance,
|
|
66
68
|
approvals: s?.entry.decide === 'agent+approve' ? 'required by the strategy' : cfg.approvals,
|
|
69
|
+
...(st.real?.executor ? { real: { executor: healthLine(st.real.executor), limits: limitsSummary(st.real.limits ?? {}), pause: st.real.risk?.buyPause ?? null } } : {}),
|
|
67
70
|
};
|
|
68
71
|
}
|
|
69
72
|
positions() {
|