@senpi/runtime 3.0.0-dev.openclaw-upgrade-mai.20260623210452 → 3.0.0-dev.runtime-phase-2.20260623210537

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.
Files changed (196) hide show
  1. package/README.md +9 -10
  2. package/dist/actions/decision-engine.d.ts.map +1 -1
  3. package/dist/actions/decision-engine.js +0 -3
  4. package/dist/actions/decision-engine.js.map +1 -1
  5. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  6. package/dist/actions/open-position/open-position.action.js +2 -6
  7. package/dist/actions/open-position/open-position.action.js.map +1 -1
  8. package/dist/cli/guide-commands.d.ts.map +1 -1
  9. package/dist/cli/guide-commands.js +56 -43
  10. package/dist/cli/guide-commands.js.map +1 -1
  11. package/dist/cli/senpi-commands.d.ts.map +1 -1
  12. package/dist/cli/senpi-commands.js +108 -0
  13. package/dist/cli/senpi-commands.js.map +1 -1
  14. package/dist/constants/dsl/index.d.ts +0 -2
  15. package/dist/constants/dsl/index.d.ts.map +1 -1
  16. package/dist/constants/dsl/index.js +0 -2
  17. package/dist/constants/dsl/index.js.map +1 -1
  18. package/dist/dsl/engine-client/config-mapping.d.ts +0 -7
  19. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  20. package/dist/dsl/engine-client/config-mapping.js +2 -16
  21. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  22. package/dist/dsl/events/handlers.d.ts +2 -18
  23. package/dist/dsl/events/handlers.d.ts.map +1 -1
  24. package/dist/dsl/events/handlers.js +213 -302
  25. package/dist/dsl/events/handlers.js.map +1 -1
  26. package/dist/dsl/monitor/create-monitor.d.ts +0 -15
  27. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  28. package/dist/dsl/monitor/create-monitor.js +9 -44
  29. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  30. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  31. package/dist/dsl/monitor/process-one-position.js +1 -24
  32. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  33. package/dist/dsl/plugin/index.d.ts +1 -16
  34. package/dist/dsl/plugin/index.d.ts.map +1 -1
  35. package/dist/dsl/plugin/index.js +4 -35
  36. package/dist/dsl/plugin/index.js.map +1 -1
  37. package/dist/dsl/plugin/options.d.ts +0 -18
  38. package/dist/dsl/plugin/options.d.ts.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +0 -4
  41. package/dist/index.js.map +1 -1
  42. package/dist/runtime/create-scanner-module.d.ts +3 -4
  43. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  44. package/dist/runtime/create-scanner-module.js +5 -14
  45. package/dist/runtime/create-scanner-module.js.map +1 -1
  46. package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
  47. package/dist/runtime/create-scanner-providers-for-run.js +1 -12
  48. package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
  49. package/dist/runtime/load-runtime-config.js +1 -1
  50. package/dist/runtime/load-runtime-config.js.map +1 -1
  51. package/dist/runtime/map-strategies-registration.js +8 -7
  52. package/dist/runtime/map-strategies-registration.js.map +1 -1
  53. package/dist/runtime/map-strategies.d.ts.map +1 -1
  54. package/dist/runtime/map-strategies.js +3 -12
  55. package/dist/runtime/map-strategies.js.map +1 -1
  56. package/dist/runtime/reconcile-state.d.ts +0 -44
  57. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  58. package/dist/runtime/reconcile-state.js +44 -80
  59. package/dist/runtime/reconcile-state.js.map +1 -1
  60. package/dist/runtime/risk-guard.d.ts.map +1 -1
  61. package/dist/runtime/risk-guard.js +12 -17
  62. package/dist/runtime/risk-guard.js.map +1 -1
  63. package/dist/runtime/runtime-schema.d.ts +995 -2855
  64. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  65. package/dist/runtime/runtime-schema.js +40 -133
  66. package/dist/runtime/runtime-schema.js.map +1 -1
  67. package/dist/runtime/runtime.d.ts +0 -26
  68. package/dist/runtime/runtime.d.ts.map +1 -1
  69. package/dist/runtime/runtime.js +5 -233
  70. package/dist/runtime/runtime.js.map +1 -1
  71. package/dist/runtime/scanner-registry.d.ts +1 -1
  72. package/dist/runtime/scanner-registry.d.ts.map +1 -1
  73. package/dist/runtime/scanner-registry.js +12 -3
  74. package/dist/runtime/scanner-registry.js.map +1 -1
  75. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  76. package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
  77. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  78. package/dist/runtime/supervisor/orphan-sweep.js +1 -1
  79. package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
  80. package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
  81. package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
  82. package/dist/runtime/supervisor/supervisor-utils.js +1 -1
  83. package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +36 -28
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  88. package/dist/runtime-api/intake/process-signal-post.js +0 -0
  89. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  90. package/dist/runtime-api/intake/scanner-launcher.d.ts +4 -18
  91. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  92. package/dist/runtime-api/intake/scanner-launcher.js +3 -24
  93. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  94. package/dist/runtime-api/intake/types.d.ts +0 -6
  95. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  96. package/dist/runtime-api/routes/signals.schema.d.ts +0 -1
  97. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  98. package/dist/runtime-api/routes/signals.schema.js +0 -5
  99. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  100. package/dist/scanners/external/liquidation-watchdog/README.md +107 -0
  101. package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +48 -0
  102. package/dist/scanners/external/liquidation-watchdog/producer.mjs +336 -0
  103. package/dist/scanners/external/momentum/README.md +148 -0
  104. package/dist/scanners/external/momentum/momentum.yaml +116 -0
  105. package/dist/scanners/external/momentum/producer.mjs +251 -0
  106. package/dist/scanners/external/scaffold/__main__.py +24 -65
  107. package/dist/scanners/external/scaffold/delivery.py +22 -56
  108. package/dist/scanners/external/scaffold/entrypoint_loader.py +48 -42
  109. package/dist/scanners/external/scaffold/journal.py +2 -13
  110. package/dist/scanners/external/scaffold/scaffold.py +128 -159
  111. package/dist/scanners/external/scaffold/signal_envelope.py +34 -177
  112. package/dist/scanners/external/shared/ingest.mjs +112 -0
  113. package/dist/scanners/external-scanner-receiver.d.ts +0 -6
  114. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  115. package/dist/scanners/external-scanner-receiver.js +1 -14
  116. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  117. package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
  118. package/dist/scripts/run-dsl-standalone-live.js +3 -5
  119. package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
  120. package/dist/senpi/client.d.ts +0 -12
  121. package/dist/senpi/client.d.ts.map +1 -1
  122. package/dist/senpi/client.js +2 -31
  123. package/dist/senpi/client.js.map +1 -1
  124. package/dist/state/state-manager.d.ts +0 -17
  125. package/dist/state/state-manager.d.ts.map +1 -1
  126. package/dist/state/state-manager.js +0 -28
  127. package/dist/state/state-manager.js.map +1 -1
  128. package/dist/strategy/strategy-state.d.ts.map +1 -1
  129. package/dist/strategy/strategy-state.js +1 -7
  130. package/dist/strategy/strategy-state.js.map +1 -1
  131. package/dist/tracking/constants.d.ts.map +1 -1
  132. package/dist/tracking/constants.js +0 -2
  133. package/dist/tracking/constants.js.map +1 -1
  134. package/dist/types/dsl/index.d.ts +1 -1
  135. package/dist/types/dsl/index.d.ts.map +1 -1
  136. package/dist/types/scanner.d.ts +0 -7
  137. package/dist/types/scanner.d.ts.map +1 -1
  138. package/examples/README.md +89 -0
  139. package/examples/scanners-consumer-quickstart.mjs +134 -0
  140. package/examples/scanners-emerging-movers-v4.mjs +287 -0
  141. package/examples/scanners-liquidation-watchdog.mjs +341 -0
  142. package/examples/scanners-live-mcp.mjs +208 -0
  143. package/examples/scanners-multi-strategy.mjs +165 -0
  144. package/examples/scanners-position-tracker-e2e.mjs +225 -0
  145. package/examples/scanners-scheduled-events.mjs +96 -0
  146. package/examples/scanners-senpi-provider-client.mjs +182 -0
  147. package/examples/scanners-single-strategy.mjs +198 -0
  148. package/examples/strategies/README.md +12 -17
  149. package/examples/strategies/dsl-showcase.yaml +68 -0
  150. package/examples/strategies/dsl.yaml +51 -0
  151. package/examples/strategies/fox-minimal-loosened.yaml +240 -0
  152. package/examples/strategies/fox-minimal.yaml +242 -0
  153. package/examples/strategies/minimal.yaml +46 -0
  154. package/examples/strategies/momentum/how-it-works.md +48 -0
  155. package/examples/strategies/momentum/momentum-guarded.yaml +161 -0
  156. package/examples/strategies/risk-showcase.yaml +93 -0
  157. package/examples/strategies/viper.yaml +67 -0
  158. package/examples/strategies/wolf.yaml +72 -0
  159. package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +123 -0
  160. package/package.json +1 -1
  161. package/dist/runtime/reconcile-decide.d.ts +0 -73
  162. package/dist/runtime/reconcile-decide.d.ts.map +0 -1
  163. package/dist/runtime/reconcile-decide.js +0 -166
  164. package/dist/runtime/reconcile-decide.js.map +0 -1
  165. package/dist/runtime/reconcile-run.d.ts +0 -62
  166. package/dist/runtime/reconcile-run.d.ts.map +0 -1
  167. package/dist/runtime/reconcile-run.js +0 -173
  168. package/dist/runtime/reconcile-run.js.map +0 -1
  169. package/dist/scanners/external/scaffold/ctx.py +0 -78
  170. package/dist/scanners/external/scaffold/requirements.txt +0 -11
  171. package/dist/scanners/external/scaffold/senpi_mcp.py +0 -250
  172. package/dist/scanners/external/scaffold/state_manager.py +0 -175
  173. package/dist/utils/keyed-mutex.d.ts +0 -37
  174. package/dist/utils/keyed-mutex.d.ts.map +0 -1
  175. package/dist/utils/keyed-mutex.js +0 -77
  176. package/dist/utils/keyed-mutex.js.map +0 -1
  177. package/examples/strategies/iguana/README.md +0 -135
  178. package/examples/strategies/iguana/recipe.yaml +0 -137
  179. package/examples/strategies/iguana/scanners/scan.py +0 -243
  180. package/examples/strategies/iguana/scanners/scoring.py +0 -152
  181. package/examples/strategies/raptor/README.md +0 -156
  182. package/examples/strategies/raptor/recipe.yaml +0 -154
  183. package/examples/strategies/raptor/scanners/scan.py +0 -349
  184. package/examples/strategies/raptor/scanners/scoring.py +0 -452
  185. package/examples/strategies/spider/README.md +0 -97
  186. package/examples/strategies/spider/scalp/recipe.yaml +0 -129
  187. package/examples/strategies/spider/scalp/scanners/scan.py +0 -219
  188. package/examples/strategies/spider/scalp/scanners/scoring.py +0 -179
  189. package/examples/strategies/spider/swing/recipe.yaml +0 -141
  190. package/examples/strategies/spider/swing/scanners/scan.py +0 -334
  191. package/examples/strategies/spider/swing/scanners/scoring.py +0 -184
  192. package/examples/strategies/turbine/README.md +0 -167
  193. package/examples/strategies/turbine/recipe-runners.yaml +0 -123
  194. package/examples/strategies/turbine/recipe-volume.yaml +0 -129
  195. package/examples/strategies/turbine/scanners/scan.py +0 -283
  196. package/examples/strategies/turbine/scanners/scoring.py +0 -190
@@ -0,0 +1,107 @@
1
+ # External liquidation-watchdog producer
2
+
3
+ A real, standalone watchdog scanner that runs as an external producer:
4
+
5
+ 1. Pulls clearinghouse state from the live Senpi MCP server.
6
+ 2. Computes margin / liquidation-distance / ROE alerts using a self-contained
7
+ port of the watchdog logic (no dependency on the runtime's internal
8
+ `liquidationWatchdogScanner`).
9
+ 3. Pushes resulting signals into a running runtime via
10
+ `openclaw senpi external-scanner ingest` (gateway RPC `senpi.ingestExternalScannerData`).
11
+
12
+ ## Files
13
+
14
+ - `recipe.yaml` — runtime recipe declaring the matching `external_scanner`
15
+ (`external_liquidation_watchdog`) and a downstream LLM action wired to
16
+ `{{signal_external_liquidation_watchdog}}`.
17
+ - `producer.mjs` — the external producer entrypoint.
18
+
19
+ ## Prereqs
20
+
21
+ 1. Build the project so the MCP provider bridge is available:
22
+ ```bash
23
+ npm run build
24
+ ```
25
+ 2. Install the runtime from `recipe.yaml` for your strategy wallet and start it:
26
+ ```bash
27
+ openclaw senpi runtime create -p src/scanners/external/liquidation-watchdog/recipe.yaml
28
+ ```
29
+ 3. Ensure `openclaw` is on `PATH` and authenticated against the host running
30
+ that runtime.
31
+ 4. Export Senpi MCP credentials and the target wallet:
32
+ ```bash
33
+ export SENPI_API_KEY="..."
34
+ export SENPI_MCP_URL="https://mcp.prod.senpi.ai/mcp"
35
+ export STRATEGY_ADDRESS="0x..."
36
+ ```
37
+
38
+ ## Run
39
+
40
+ ```bash
41
+ node src/scanners/external/liquidation-watchdog/producer.mjs
42
+ ```
43
+
44
+ The producer fetches one clearinghouse snapshot, derives signals, and pushes
45
+ them. It exits non-zero on any rejection or signal-count mismatch. Re-run
46
+ (or wrap in `watch`/cron) for continuous monitoring.
47
+
48
+ ## Env
49
+
50
+ | Var | Default | Purpose |
51
+ |---|---|---|
52
+ | `SENPI_API_KEY` | _required_ | Senpi MCP auth |
53
+ | `SENPI_MCP_URL` | _required_ | Senpi MCP endpoint |
54
+ | `STRATEGY_ADDRESS` | _required_ | Target wallet — must match the installed runtime |
55
+ | `EXTERNAL_SCANNER_NAME` | `external_liquidation_watchdog` | Must match `scanners[].name` in the recipe |
56
+ | `OPENCLAW_BIN` | `openclaw` | Path/name of the OpenClaw CLI binary |
57
+ | `BUFFER_WARNING_PCT` | `50` | Margin buffer % below which a WARNING alert fires |
58
+ | `BUFFER_CRITICAL_PCT` | `30` | Margin buffer % below which CRITICAL + WATCHDOG_CLOSE fire |
59
+ | `LIQ_DISTANCE_WARNING_PCT` | `30` | Per-position liq distance % warning threshold |
60
+ | `ROE_DANGER_PCT` | `-15` | ROE % below which a position is flagged |
61
+
62
+ ## Expected response
63
+
64
+ ```json
65
+ {
66
+ "accepted": true,
67
+ "address": "0x...",
68
+ "scanner": "external_liquidation_watchdog",
69
+ "timestamp": 1775680200000,
70
+ "signalCount": 3,
71
+ "contextUpdated": false
72
+ }
73
+ ```
74
+
75
+ ## Live test: force a watchdog close
76
+
77
+ To verify the end-to-end close path on a real open position, loosen the
78
+ producer thresholds so any healthy position trips `WATCHDOG_CLOSE`. The
79
+ shipped `recipe.yaml` intentionally has no `exit.dsl` block, so the watchdog
80
+ is the only exit path — a close is unambiguously attributable to the producer.
81
+
82
+ ```bash
83
+ BUFFER_WARNING_PCT=99 \
84
+ BUFFER_CRITICAL_PCT=95 \
85
+ LIQ_DISTANCE_WARNING_PCT=99 \
86
+ ROE_DANGER_PCT=100 \
87
+ node src/scanners/external/liquidation-watchdog/producer.mjs
88
+ ```
89
+
90
+ Expect `accepted: true` with `signalCount >= 1` and the `watchdog_close`
91
+ action firing on the runtime. Revert the env values (and re-add a DSL preset)
92
+ before running unattended — without DSL, nothing else will close a position
93
+ between producer runs.
94
+
95
+ ## Manual e2e checklist
96
+
97
+ - Happy path: live wallet with healthy positions → producer prints `0` signals
98
+ and skips the ingest.
99
+ - Triggered path: wallet with a tight liq distance or low buffer → producer
100
+ emits `WATCHDOG_ALERT` (and `WATCHDOG_CLOSE` for critical buffer); gateway
101
+ returns `accepted: true` with matching `signalCount`.
102
+ - Unknown wallet: change `STRATEGY_ADDRESS` to a wallet without an installed
103
+ runtime → expect `NOT_FOUND`.
104
+ - Disabled scanner: stop the runtime or rename the scanner in the recipe →
105
+ expect `INVALID_REQUEST`.
106
+ - Schema rejection: edit `toExternalBatchItem` in `producer.mjs` to drop
107
+ `sourceMeta` → expect a validation `INVALID_REQUEST`.
@@ -0,0 +1,48 @@
1
+ name: external-liquidation-watchdog
2
+ version: 1.0.0
3
+ description: >
4
+ External producer variant of the liquidation watchdog. Margin / liq-distance /
5
+ ROE alerts are computed by an out-of-process producer (see producer.mjs) and
6
+ pushed in via senpi.ingestExternalScannerData. Watchdog signals drive an
7
+ emergency CLOSE_POSITION; DSL handles normal trailing exits.
8
+
9
+ strategy:
10
+ wallet: "${WALLET_ADDRESS}"
11
+ budget: 1000
12
+ slots: 3
13
+ margin_per_slot: 300
14
+ trading_risk: moderate
15
+ enabled: true
16
+
17
+ scanners:
18
+ # External scanner: signals are pushed by producer.mjs, no internal interval.
19
+ # `fields` documents the payload shape the gateway will accept from the producer.
20
+ - name: external_liquidation_watchdog
21
+ type: external_scanner
22
+ outputs:
23
+ signals: true
24
+ context: false
25
+ config:
26
+ fields:
27
+ sourceScannerId: { type: string, required: true }
28
+ sourceSignalType: { type: string, required: true }
29
+ sourceTimestamp: { type: number, required: true }
30
+ sourceFactors: { type: object, required: true }
31
+ sourceMeta: { type: object, required: true }
32
+
33
+ actions:
34
+ # Emergency close on margin danger — rule-based, no LLM in the hot path.
35
+ - name: watchdog_close
36
+ action_type: CLOSE_POSITION
37
+ decision_mode: rule
38
+ scanners: [external_liquidation_watchdog]
39
+ min_confidence: 1
40
+
41
+ # ── NOTIFICATIONS ──
42
+ notifications:
43
+ telegram_chat_id: "${TELEGRAM_CHAT_ID}"
44
+
45
+
46
+ # No `exit:` block: for a live test we want the external watchdog to be the
47
+ # ONLY exit path, so a triggered close is unambiguously attributable to the
48
+ # producer. Re-add a DSL preset before running this recipe unattended.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * External liquidation-watchdog producer.
3
+ *
4
+ * A real, standalone watchdog scanner that:
5
+ * 1. Pulls clearinghouse state + active positions from the live Senpi MCP server.
6
+ * 2. Computes margin/liquidation/ROE alerts using the migrated watchdog logic
7
+ * below (no dependency on the runtime's internal scanner).
8
+ * 3. Pushes resulting signals into a running runtime via the
9
+ * `openclaw senpi external-scanner ingest` CLI command.
10
+ *
11
+ * Prereqs:
12
+ * - `npm run build` so dist/ is current (used only for the MCP provider bridge).
13
+ * - A runtime installed from ./recipe.yaml is running for STRATEGY_ADDRESS.
14
+ * - `openclaw` CLI on PATH and authenticated against that runtime.
15
+ * - SENPI_API_KEY and SENPI_MCP_URL env vars set.
16
+ *
17
+ * Run: node src/scanners/external/liquidation-watchdog/producer.mjs
18
+ */
19
+
20
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
21
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
22
+ import { createSenpiScannerProviders } from "../../../../dist/scanners/index.js";
23
+ import { pushScannerSignals } from "../shared/ingest.mjs";
24
+
25
+ // ─── Config ─────────────────────────────────────────────────────────────────
26
+
27
+ const SENPI_API_KEY = process.env.SENPI_API_KEY;
28
+ const SENPI_MCP_URL = process.env.SENPI_MCP_URL;
29
+ const STRATEGY_ADDRESS = process.env.STRATEGY_ADDRESS;
30
+ const SCANNER_NAME =
31
+ process.env.EXTERNAL_SCANNER_NAME ?? "external_liquidation_watchdog";
32
+ const OPENCLAW_BIN = process.env.OPENCLAW_BIN ?? "openclaw";
33
+ const SCANNER_ID = "external-liquidation-watchdog-producer";
34
+
35
+ const config = {
36
+ bufferWarningPct: numEnv("BUFFER_WARNING_PCT", 50),
37
+ bufferCriticalPct: numEnv("BUFFER_CRITICAL_PCT", 30),
38
+ liqDistanceWarningPct: numEnv("LIQ_DISTANCE_WARNING_PCT", 30),
39
+ roeDangerPct: numEnv("ROE_DANGER_PCT", -15),
40
+ };
41
+
42
+ if (!SENPI_API_KEY || !SENPI_MCP_URL || !STRATEGY_ADDRESS) {
43
+ console.error(
44
+ "Missing required env vars. Set SENPI_API_KEY, SENPI_MCP_URL, and STRATEGY_ADDRESS.\n" +
45
+ " export SENPI_API_KEY='...'\n" +
46
+ " export SENPI_MCP_URL='https://mcp.prod.senpi.ai/mcp'\n" +
47
+ " export STRATEGY_ADDRESS='0x...'",
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ function numEnv(name, fallback) {
53
+ const raw = process.env[name];
54
+ if (raw === undefined || raw === "") return fallback;
55
+ const parsed = Number(raw);
56
+ if (!Number.isFinite(parsed)) {
57
+ throw new Error(`Env ${name} must be a finite number, got "${raw}"`);
58
+ }
59
+ return parsed;
60
+ }
61
+
62
+ // ─── MCP → SenpiToolClient bridge ───────────────────────────────────────────
63
+
64
+ function createSenpiToolClient(mcpClient) {
65
+ return {
66
+ async callTool(name, args) {
67
+ const result = await mcpClient.callTool({ name, arguments: args });
68
+
69
+ if (result.isError) {
70
+ const errorText =
71
+ Array.isArray(result.content) &&
72
+ result.content.length > 0 &&
73
+ result.content[0].type === "text"
74
+ ? result.content[0].text
75
+ : JSON.stringify(result.content);
76
+ throw new Error(`MCP tool "${name}" returned error: ${errorText}`);
77
+ }
78
+
79
+ if (result.structuredContent !== undefined) {
80
+ return result.structuredContent;
81
+ }
82
+
83
+ if (Array.isArray(result.content) && result.content.length > 0) {
84
+ const first = result.content[0];
85
+ if (first.type === "text") {
86
+ try {
87
+ return JSON.parse(first.text);
88
+ } catch {
89
+ return first.text;
90
+ }
91
+ }
92
+ }
93
+
94
+ return result.content;
95
+ },
96
+ };
97
+ }
98
+
99
+ // ─── Migrated watchdog logic ────────────────────────────────────────────────
100
+ // Ported from src/scanners/implementations/liquidation-watchdog.ts.
101
+ // Kept self-contained so this producer has no dependency on the internal
102
+ // scanner implementation.
103
+
104
+ function clamp01(value) {
105
+ if (!Number.isFinite(value)) return 0;
106
+ if (value < 0) return 0;
107
+ if (value > 1) return 1;
108
+ return value;
109
+ }
110
+
111
+ function resolveDirection(szi) {
112
+ return szi >= 0 ? "LONG" : "SHORT";
113
+ }
114
+
115
+ function computeMarkPrice(position) {
116
+ if (position.szi === 0) return position.entryPx;
117
+ return position.entryPx + position.unrealizedPnl / position.szi;
118
+ }
119
+
120
+ function normalizeRoe(returnOnEquity) {
121
+ return returnOnEquity * 100;
122
+ }
123
+
124
+ function normalizeWatchdogPosition(position) {
125
+ return {
126
+ asset: position.coin,
127
+ direction: resolveDirection(position.szi),
128
+ size: Math.abs(position.szi),
129
+ entryPrice: position.entryPx,
130
+ markPrice: computeMarkPrice(position),
131
+ liquidationPrice: position.liquidationPx,
132
+ unrealizedPnl: position.unrealizedPnl,
133
+ roe: normalizeRoe(position.returnOnEquity),
134
+ leverage: position.leverage,
135
+ };
136
+ }
137
+
138
+ function computeMarginBuffer(accountValue, withdrawable) {
139
+ if (accountValue <= 0) return 0;
140
+ return (withdrawable / accountValue) * 100;
141
+ }
142
+
143
+ function computeLiqDistance(markPrice, liqPrice, direction) {
144
+ if (liqPrice === null || liqPrice === undefined) return null;
145
+ if (liqPrice <= 0 || markPrice <= 0) return null;
146
+ if (direction === "LONG") {
147
+ return ((markPrice - liqPrice) / markPrice) * 100;
148
+ }
149
+ return ((liqPrice - markPrice) / markPrice) * 100;
150
+ }
151
+
152
+ function analyzeMarginHealth(chState, cfg) {
153
+ const alerts = [];
154
+ let lowestRoePosition = null;
155
+ let lowestRoe = Infinity;
156
+
157
+ const buffer = computeMarginBuffer(chState.accountValue, chState.withdrawable);
158
+
159
+ if (buffer < cfg.bufferCriticalPct) {
160
+ alerts.push({
161
+ level: "CRITICAL",
162
+ message: `Margin buffer CRITICAL: ${buffer.toFixed(1)}% (threshold: ${cfg.bufferCriticalPct}%)`,
163
+ value: buffer,
164
+ threshold: cfg.bufferCriticalPct,
165
+ });
166
+ } else if (buffer < cfg.bufferWarningPct) {
167
+ alerts.push({
168
+ level: "WARNING",
169
+ message: `Margin buffer WARNING: ${buffer.toFixed(1)}% (threshold: ${cfg.bufferWarningPct}%)`,
170
+ value: buffer,
171
+ threshold: cfg.bufferWarningPct,
172
+ });
173
+ }
174
+
175
+ for (const rawPosition of chState.positions ?? []) {
176
+ const pos = normalizeWatchdogPosition(rawPosition);
177
+
178
+ if (pos.roe < lowestRoe) {
179
+ lowestRoe = pos.roe;
180
+ lowestRoePosition = pos;
181
+ }
182
+
183
+ const liqDist = computeLiqDistance(
184
+ pos.markPrice,
185
+ pos.liquidationPrice,
186
+ pos.direction,
187
+ );
188
+ if (liqDist !== null && liqDist < cfg.liqDistanceWarningPct) {
189
+ alerts.push({
190
+ level: liqDist < cfg.liqDistanceWarningPct / 2 ? "CRITICAL" : "WARNING",
191
+ message: `Liquidation distance ${pos.asset} ${pos.direction}: ${liqDist.toFixed(1)}% (threshold: ${cfg.liqDistanceWarningPct}%)`,
192
+ asset: pos.asset,
193
+ value: liqDist,
194
+ threshold: cfg.liqDistanceWarningPct,
195
+ });
196
+ }
197
+
198
+ if (pos.roe < cfg.roeDangerPct) {
199
+ alerts.push({
200
+ level: "WARNING",
201
+ message: `ROE danger ${pos.asset}: ${pos.roe.toFixed(1)}% (threshold: ${cfg.roeDangerPct}%)`,
202
+ asset: pos.asset,
203
+ value: pos.roe,
204
+ threshold: cfg.roeDangerPct,
205
+ });
206
+ }
207
+ }
208
+
209
+ return { alerts, lowestRoePosition, marginBuffer: buffer };
210
+ }
211
+
212
+ function buildSignals({ chState, alerts, lowestRoePosition, marginBuffer, now }) {
213
+ const signals = [];
214
+
215
+ const hasCriticalBuffer = alerts.some(
216
+ (a) => a.level === "CRITICAL" && a.message.includes("Margin buffer"),
217
+ );
218
+
219
+ if (hasCriticalBuffer && lowestRoePosition) {
220
+ signals.push({
221
+ scannerId: SCANNER_ID,
222
+ signalType: "WATCHDOG_CLOSE",
223
+ asset: lowestRoePosition.asset,
224
+ direction: lowestRoePosition.direction,
225
+ score: clamp01(0.95),
226
+ timestamp: now,
227
+ factors: { critical_buffer: true, lowest_roe: true },
228
+ meta: {
229
+ reason: "critical_margin_buffer",
230
+ roe: lowestRoePosition.roe,
231
+ asset: lowestRoePosition.asset,
232
+ buffer: marginBuffer,
233
+ alerts: alerts.map((a) => a.message),
234
+ },
235
+ });
236
+ }
237
+
238
+ for (const alert of alerts) {
239
+ signals.push({
240
+ scannerId: SCANNER_ID,
241
+ signalType: "WATCHDOG_ALERT",
242
+ asset: alert.asset ?? "ACCOUNT",
243
+ direction: "LONG",
244
+ score: clamp01(alert.level === "CRITICAL" ? 0.9 : 0.5),
245
+ timestamp: now,
246
+ factors: {
247
+ critical: alert.level === "CRITICAL",
248
+ warning: alert.level === "WARNING",
249
+ },
250
+ meta: {
251
+ alertLevel: alert.level,
252
+ message: alert.message,
253
+ value: alert.value,
254
+ threshold: alert.threshold,
255
+ },
256
+ });
257
+ }
258
+
259
+ return signals;
260
+ }
261
+
262
+ // ─── Main ───────────────────────────────────────────────────────────────────
263
+
264
+ async function main() {
265
+ console.log(`External watchdog producer`);
266
+ console.log(` MCP: ${SENPI_MCP_URL}`);
267
+ console.log(` address: ${STRATEGY_ADDRESS}`);
268
+ console.log(` scanner: ${SCANNER_NAME}`);
269
+ console.log(` config: ${JSON.stringify(config)}`);
270
+
271
+ const transport = new StreamableHTTPClientTransport(new URL(SENPI_MCP_URL), {
272
+ requestInit: { headers: { Authorization: `Bearer ${SENPI_API_KEY}` } },
273
+ });
274
+ const mcpClient = new Client({
275
+ name: "external-liquidation-watchdog",
276
+ version: "0.0.1",
277
+ });
278
+ await mcpClient.connect(transport);
279
+
280
+ const providers = createSenpiScannerProviders({
281
+ client: createSenpiToolClient(mcpClient),
282
+ logLevel: "warn",
283
+ cacheTtlMs: { instruments: 0, smData: 0, candles: 0 },
284
+ retries: 2,
285
+ });
286
+
287
+ try {
288
+ const chState = await providers.getClearingHouseState(STRATEGY_ADDRESS);
289
+ if (!chState) {
290
+ console.warn("Clearinghouse state unavailable — skipping ingest.");
291
+ return;
292
+ }
293
+
294
+ console.log(
295
+ `\nClearinghouse: accountValue=$${chState.accountValue}, ` +
296
+ `withdrawable=$${chState.withdrawable}, positions=${chState.positions?.length ?? 0}`,
297
+ );
298
+
299
+ const { alerts, lowestRoePosition, marginBuffer } = analyzeMarginHealth(
300
+ chState,
301
+ config,
302
+ );
303
+ const signals = buildSignals({
304
+ chState,
305
+ alerts,
306
+ lowestRoePosition,
307
+ marginBuffer,
308
+ now: Date.now(),
309
+ });
310
+
311
+ console.log(`\nAlerts: ${alerts.length}, Signals: ${signals.length}`);
312
+ for (const a of alerts) {
313
+ console.log(` [${a.level}] ${a.message}`);
314
+ }
315
+
316
+ if (signals.length === 0) {
317
+ console.log("\nNothing to ingest — skipping gateway call.");
318
+ return;
319
+ }
320
+
321
+ const response = await pushScannerSignals({
322
+ address: STRATEGY_ADDRESS,
323
+ scanner: SCANNER_NAME,
324
+ signals,
325
+ openclawBin: OPENCLAW_BIN,
326
+ });
327
+ console.log("\nIngest response:", JSON.stringify(response));
328
+ } finally {
329
+ await transport.close();
330
+ }
331
+ }
332
+
333
+ main().catch((error) => {
334
+ console.error("\nProducer failed:", error.message ?? error);
335
+ process.exit(1);
336
+ });
@@ -0,0 +1,148 @@
1
+ # External momentum producer
2
+
3
+ A standalone momentum scanner that runs as an external producer:
4
+
5
+ 1. Pulls instruments + latest candles from the live Senpi MCP server.
6
+ 2. Computes last-vs-prev close % move per candidate using a self-contained
7
+ port of the momentum scanner logic (no dependency on the runtime's internal
8
+ `momentumScanner`).
9
+ 3. Pushes resulting `MOMENTUM_BREAKOUT` signals into a running runtime via the
10
+ `senpi.ingestExternalScannerData` gateway RPC.
11
+
12
+ The companion recipe wires those signals into an LLM `OPEN_POSITION` action,
13
+ demonstrating the full e2e path from external signal → LLM gauntlet →
14
+ Senpi MCP position open → DSL trailing-exit management.
15
+
16
+ ## Files
17
+
18
+ - `momentum.yaml` — runtime recipe declaring the matching `external_scanner`
19
+ (`external_momentum`), the `momentum_entry` LLM OPEN_POSITION action, and
20
+ a DSL exit preset tuned for short-horizon momentum trades.
21
+ - `producer.mjs` — the external producer entrypoint.
22
+
23
+ ## Prereqs
24
+
25
+ 1. Build the project so the MCP provider bridge is available:
26
+ ```bash
27
+ npm run build
28
+ ```
29
+ 2. Install the runtime from `momentum.yaml` for your strategy wallet and start it:
30
+ ```bash
31
+ openclaw senpi runtime create -p src/scanners/external/momentum/momentum.yaml
32
+ ```
33
+ 3. Ensure `openclaw` is on `PATH` and authenticated against the host running
34
+ that runtime.
35
+ 4. Export Senpi MCP credentials and the target wallet:
36
+ ```bash
37
+ export SENPI_API_KEY="..."
38
+ export SENPI_MCP_URL="https://mcp.prod.senpi.ai/mcp"
39
+ export STRATEGY_ADDRESS="0x..."
40
+ ```
41
+
42
+ ## Run
43
+
44
+ ```bash
45
+ node src/scanners/external/momentum/producer.mjs
46
+ ```
47
+
48
+ The producer fetches one snapshot (instruments + candles), derives signals,
49
+ and pushes them. It exits non-zero on any rejection or signal-count mismatch.
50
+ Re-run (or wrap in `watch`/cron) for continuous monitoring.
51
+
52
+ ## Schedule with OpenClaw cron
53
+
54
+ The producer runs **once per invocation** — it fetches a snapshot, pushes
55
+ signals, and exits. For continuous monitoring you need a scheduler. The
56
+ recommended approach is to register an OpenClaw cron immediately after
57
+ creating the runtime strategy, and let your agent wire it up.
58
+
59
+ Ask your agent something like:
60
+
61
+ > Set up an OpenClaw cron that runs
62
+ > `src/scanners/external/momentum/producer.mjs` every 5 minutes, with
63
+ > wake now, in an isolated session, and capture output to a dedicated log
64
+ > file named `external_momentum.log` for debugging.
65
+
66
+ That request maps to these properties on the cron:
67
+
68
+ - **Command:** `node /absolute/path/to/src/scanners/external/momentum/producer.mjs`
69
+ (export the same `SENPI_*` and `STRATEGY_ADDRESS` env vars the manual run uses).
70
+ - **Interval:** every X minutes — pick a cadence that matches your
71
+ `TIMEFRAME` (e.g. `*/5 * * * *` for `1h` candles is plenty; tighten only
72
+ if you actually need faster reactions).
73
+ - **Wake now:** trigger an immediate first run on creation so you don't
74
+ wait a full interval to confirm wiring.
75
+ - **Isolated session:** each tick runs in its own session so a hung or
76
+ long-running invocation can't block the next one.
77
+ - **Log capture:** redirect stdout/stderr to `<scanner-name>.log` (here:
78
+ `external_momentum.log`) so each run's accept/reject output and any
79
+ errors are inspectable after the fact.
80
+
81
+ Verify by tailing the log after the first wake-now tick — you should see
82
+ the same `accepted: true` / `signalCount` payload shown above. If signals
83
+ aren't flowing, check the log first; producer rejections and missing env
84
+ vars surface there.
85
+
86
+ ## Env
87
+
88
+ | Var | Default | Purpose |
89
+ |---|---|---|
90
+ | `SENPI_API_KEY` | _required_ | Senpi MCP auth |
91
+ | `SENPI_MCP_URL` | _required_ | Senpi MCP endpoint |
92
+ | `STRATEGY_ADDRESS` | _required_ | Target wallet — must match the installed runtime |
93
+ | `EXTERNAL_SCANNER_NAME` | `external_momentum` | Must match `scanners[].name` in the recipe |
94
+ | `OPENCLAW_BIN` | `openclaw` | Path/name of the OpenClaw CLI binary |
95
+ | `TIMEFRAME` | `1h` | Candle interval to evaluate |
96
+ | `MIN_MOVE_PCT` | `1.5` | Minimum absolute % move to emit a signal |
97
+ | `MIN_DAY_VOLUME` | `1000000` | Minimum 24h notional volume to consider an instrument |
98
+ | `MIN_LEVERAGE` | `5` | Minimum max leverage for an instrument |
99
+ | `MAX_CANDIDATES` | `20` | Top-N by volume to fetch candles for |
100
+ | `MIN_SIGNAL_SCORE` | `0.2` | Drop signals below this normalized score |
101
+
102
+ ## Expected response
103
+
104
+ ```json
105
+ {
106
+ "accepted": true,
107
+ "address": "0x...",
108
+ "scanner": "external_momentum",
109
+ "timestamp": 1775680200000,
110
+ "signalCount": 3,
111
+ "contextUpdated": false
112
+ }
113
+ ```
114
+
115
+ ## Live test: force an OPEN_POSITION
116
+
117
+ To verify the end-to-end entry path, loosen thresholds so almost any moving
118
+ asset trips a signal, then let the LLM gauntlet in `momentum.yaml` decide:
119
+
120
+ ```bash
121
+ MIN_MOVE_PCT=0.05 \
122
+ MIN_SIGNAL_SCORE=0 \
123
+ MIN_DAY_VOLUME=0 \
124
+ node src/scanners/external/momentum/producer.mjs
125
+ ```
126
+
127
+ Expect `accepted: true` with `signalCount >= 1`. In the runtime logs you
128
+ should see `momentum_entry` firing, the LLM returning `execute: true` for
129
+ the strongest-scoring eligible signal, and Senpi MCP opening the position.
130
+ The DSL preset in `momentum.yaml` then takes over for trailing-exit
131
+ management. Revert the env values before running unattended.
132
+
133
+ ## Manual e2e checklist
134
+
135
+ - Happy path: live wallet, no strong movers → producer prints `0` signals
136
+ and skips the ingest.
137
+ - Triggered path: a high-volume asset with a notable 1h move → producer
138
+ emits `MOMENTUM_BREAKOUT`; gateway returns `accepted: true` with matching
139
+ `signalCount`; runtime invokes the LLM action; position opens if the
140
+ gauntlet passes.
141
+ - DSL handoff: after a fill, confirm `openclaw senpi position list` shows
142
+ the open position and that the DSL monitor is tracking it.
143
+ - Unknown wallet: change `STRATEGY_ADDRESS` to a wallet without an installed
144
+ runtime → expect `NOT_FOUND`.
145
+ - Disabled scanner: stop the runtime or rename the scanner in the recipe →
146
+ expect `INVALID_REQUEST`.
147
+ - Schema rejection: edit `toExternalBatchItem` in `producer.mjs` to drop
148
+ `sourceMeta` → expect a validation `INVALID_REQUEST`.