@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,116 @@
1
+ name: external-momentum
2
+ version: 1.0.0
3
+ description: >
4
+ External producer variant of the momentum scanner. Latest-candle breakout
5
+ detection runs out-of-process (see producer.mjs) and pushes signals in via
6
+ senpi.ingestExternalScannerData. An LLM gauntlet filters breakouts into
7
+ OPEN_POSITION decisions; the DSL exit engine manages trailing exits.
8
+
9
+ strategy:
10
+ wallet: "${WALLET_ADDRESS}"
11
+ budget: 500
12
+ slots: 2
13
+ margin_per_slot: 200
14
+ trading_risk: moderate
15
+ enabled: true
16
+
17
+ scanners:
18
+ # Onchain position tracker — emits position lifecycle events into the DSL
19
+ # exit engine for trailing stop-loss management.
20
+ - name: position_tracker
21
+ type: position_tracker
22
+ interval: 10s
23
+
24
+ # External scanner: signals are pushed by producer.mjs on its own cadence.
25
+ # `fields` documents the payload shape the gateway will accept.
26
+ - name: external_momentum
27
+ type: external_scanner
28
+ outputs:
29
+ signals: true
30
+ context: false
31
+ config:
32
+ fields:
33
+ sourceScannerId: { type: string, required: true }
34
+ sourceSignalType: { type: string, required: true }
35
+ sourceTimestamp: { type: number, required: true }
36
+ sourceFactors: { type: object, required: true }
37
+ sourceMeta: { type: object, required: true }
38
+
39
+ actions:
40
+ # Position lifecycle tracking — feeds DSL exit engine.
41
+ - name: position_tracker_action
42
+ action_type: POSITION_TRACKER
43
+ decision_mode: rule
44
+ scanners: [position_tracker]
45
+
46
+ # Entry: LLM gauntlet on momentum breakout signals.
47
+ - name: momentum_entry
48
+ action_type: OPEN_POSITION
49
+ decision_mode: llm
50
+ decision_model: gpt-5.1-codex
51
+ scanners: [external_momentum]
52
+ min_confidence: 6
53
+ context:
54
+ - type: signal
55
+ scanner: external_momentum
56
+ decision_prompt: |
57
+ You are a trade-decisioning evaluator. Given a MOMENTUM_BREAKOUT
58
+ signal, decide whether to open a position on it or skip.
59
+
60
+ A MOMENTUM_BREAKOUT fires when an asset's latest closed candle moves
61
+ by at least a configured threshold vs. the previous close on the
62
+ signal's timeframe — i.e. a short-term directional breakout in price.
63
+ `meta.movePct` carries the signed % change (positive = LONG bias,
64
+ negative = SHORT bias); `score` (0..1) scales with the move's
65
+ magnitude; `meta.dayNtlVlm` and `meta.maxLeverage` describe the
66
+ instrument's liquidity and leverage headroom.
67
+
68
+ SIGNAL DATA:
69
+ {{signal_external_momentum}}
70
+
71
+ ═══ GAUNTLET ═══
72
+ 1. movePct magnitude — require |movePct| >= 1.5; reject sub-threshold noise.
73
+ 2. Direction sanity — LONG only if movePct > 0, SHORT only if movePct < 0.
74
+ 3. Liquidity — meta.dayNtlVlm must be >= 1_000_000.
75
+ 4. Leverage — meta.maxLeverage must be >= 5; skip otherwise.
76
+ 5. Score — use the producer score (0..1) as a proxy for strength; require >= 0.4.
77
+ 6. One signal at a time — pick the highest-score eligible signal; skip the rest.
78
+
79
+ Respond with a single JSON object:
80
+ {
81
+ "execute": true|false,
82
+ "actionType": "OPEN_POSITION",
83
+ "confidence": 1-10,
84
+ "reasoning": "one sentence",
85
+ "payload": {
86
+ "signals": [
87
+ { "asset": "<ticker>", "direction": "LONG"|"SHORT", "reason": "one sentence" }
88
+ ]
89
+ }
90
+ }
91
+
92
+ # ── EXIT (DSL) ──
93
+ # Trailing stop-loss tuned for short-horizon momentum entries: a slightly
94
+ # tighter Phase-1 fixed floor than Fox (momentum reversals are fast), the same
95
+ # 7% retrace trigger so winners get room to breathe, and a four-tier Phase-2
96
+ # ladder that locks in progressively more of the high-water as gains extend.
97
+ exit:
98
+ engine: dsl
99
+ interval_seconds: 30
100
+ dsl_preset:
101
+ phase1:
102
+ enabled: true
103
+ max_loss_pct: 2.5
104
+ retrace_threshold: 7
105
+ consecutive_breaches_required: 3
106
+ phase2:
107
+ enabled: true
108
+ tiers:
109
+ - { trigger_pct: 6, lock_hw_pct: 35 }
110
+ - { trigger_pct: 10, lock_hw_pct: 55 }
111
+ - { trigger_pct: 15, lock_hw_pct: 70 }
112
+ - { trigger_pct: 20, lock_hw_pct: 85 }
113
+
114
+ # ── NOTIFICATIONS ──
115
+ notifications:
116
+ telegram_chat_id: "${TELEGRAM_CHAT_ID}"
@@ -0,0 +1,251 @@
1
+ /**
2
+ * External momentum producer.
3
+ *
4
+ * A standalone momentum scanner that:
5
+ * 1. Pulls instruments + latest candles from the live Senpi MCP server.
6
+ * 2. Computes last-vs-prev close % move against configurable thresholds.
7
+ * 3. Pushes MOMENTUM_BREAKOUT signals into a running runtime via the
8
+ * `openclaw senpi external-scanner ingest` CLI command.
9
+ *
10
+ * Prereqs:
11
+ * - `npm run build` so dist/ is current (used only for the MCP provider bridge).
12
+ * - A runtime installed from ./momentum.yaml is running for STRATEGY_ADDRESS.
13
+ * - `openclaw` CLI on PATH and authenticated against that runtime.
14
+ * - SENPI_API_KEY and SENPI_MCP_URL env vars set.
15
+ *
16
+ * Run: node src/scanners/external/momentum/producer.mjs
17
+ */
18
+
19
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
21
+ import { createSenpiScannerProviders } from "../../../../dist/scanners/index.js";
22
+ import { pushScannerSignals } from "../shared/ingest.mjs";
23
+
24
+ // ─── Config ─────────────────────────────────────────────────────────────────
25
+
26
+ const SENPI_API_KEY = process.env.SENPI_API_KEY;
27
+ const SENPI_MCP_URL = process.env.SENPI_MCP_URL;
28
+ const STRATEGY_ADDRESS = process.env.STRATEGY_ADDRESS;
29
+ const SCANNER_NAME =
30
+ process.env.EXTERNAL_SCANNER_NAME ?? "external_momentum";
31
+ const OPENCLAW_BIN = process.env.OPENCLAW_BIN ?? "openclaw";
32
+ const SCANNER_ID = "external-momentum-producer";
33
+
34
+ const config = {
35
+ timeframe: process.env.TIMEFRAME ?? "1h",
36
+ minMovePct: numEnv("MIN_MOVE_PCT", 1.5),
37
+ minDayVolume: numEnv("MIN_DAY_VOLUME", 1_000_000),
38
+ minLeverage: numEnv("MIN_LEVERAGE", 5),
39
+ maxCandidates: numEnv("MAX_CANDIDATES", 20),
40
+ minSignalScore: numEnv("MIN_SIGNAL_SCORE", 0.2),
41
+ };
42
+
43
+ if (!SENPI_API_KEY || !SENPI_MCP_URL || !STRATEGY_ADDRESS) {
44
+ console.error(
45
+ "Missing required env vars. Set SENPI_API_KEY, SENPI_MCP_URL, and STRATEGY_ADDRESS.\n" +
46
+ " export SENPI_API_KEY='...'\n" +
47
+ " export SENPI_MCP_URL='https://mcp.prod.senpi.ai/mcp'\n" +
48
+ " export STRATEGY_ADDRESS='0x...'",
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ function numEnv(name, fallback) {
54
+ const raw = process.env[name];
55
+ if (raw === undefined || raw === "") return fallback;
56
+ const parsed = Number(raw);
57
+ if (!Number.isFinite(parsed)) {
58
+ throw new Error(`Env ${name} must be a finite number, got "${raw}"`);
59
+ }
60
+ return parsed;
61
+ }
62
+
63
+ // ─── MCP → SenpiToolClient bridge ───────────────────────────────────────────
64
+
65
+ function createSenpiToolClient(mcpClient) {
66
+ return {
67
+ async callTool(name, args) {
68
+ const result = await mcpClient.callTool({ name, arguments: args });
69
+
70
+ if (result.isError) {
71
+ const errorText =
72
+ Array.isArray(result.content) &&
73
+ result.content.length > 0 &&
74
+ result.content[0].type === "text"
75
+ ? result.content[0].text
76
+ : JSON.stringify(result.content);
77
+ throw new Error(`MCP tool "${name}" returned error: ${errorText}`);
78
+ }
79
+
80
+ if (result.structuredContent !== undefined) {
81
+ return result.structuredContent;
82
+ }
83
+
84
+ if (Array.isArray(result.content) && result.content.length > 0) {
85
+ const first = result.content[0];
86
+ if (first.type === "text") {
87
+ try {
88
+ return JSON.parse(first.text);
89
+ } catch {
90
+ return first.text;
91
+ }
92
+ }
93
+ }
94
+
95
+ return result.content;
96
+ },
97
+ };
98
+ }
99
+
100
+ // ─── Momentum logic ─────────────────────────────────────────────────────────
101
+ // Self-contained port of src/scanners/implementations/momentum.ts. Kept free
102
+ // of any runtime-internal imports.
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 selectCandidates(instruments, cfg) {
112
+ const eligible = instruments.filter((inst) => {
113
+ if (inst.isDelisted) return false;
114
+ if ((inst.maxLeverage ?? 0) < cfg.minLeverage) return false;
115
+ const vol = inst.context?.dayNtlVlm ?? 0;
116
+ return vol >= cfg.minDayVolume;
117
+ });
118
+ eligible.sort(
119
+ (a, b) => (b.context?.dayNtlVlm ?? 0) - (a.context?.dayNtlVlm ?? 0),
120
+ );
121
+ return eligible.slice(0, cfg.maxCandidates);
122
+ }
123
+
124
+ function computeMovePct(series) {
125
+ if (!series || series.length < 2) return null;
126
+ const prev = series[series.length - 2]?.close;
127
+ const last = series[series.length - 1]?.close;
128
+ if (!Number.isFinite(prev) || !Number.isFinite(last) || prev <= 0) {
129
+ return null;
130
+ }
131
+ return { prev, last, movePct: ((last - prev) / prev) * 100 };
132
+ }
133
+
134
+ function buildSignal({ instrument, move, timeframe, now }) {
135
+ const movePct = move.movePct;
136
+ const absMove = Math.abs(movePct);
137
+ const direction = movePct > 0 ? "LONG" : "SHORT";
138
+ // Score scales from 0 at minMovePct to 1 at 5% move, clamped.
139
+ const score = clamp01((absMove - config.minMovePct) / (5 - config.minMovePct));
140
+
141
+ return {
142
+ scannerId: SCANNER_ID,
143
+ signalType: "MOMENTUM_BREAKOUT",
144
+ asset: instrument.name,
145
+ direction,
146
+ score,
147
+ timestamp: now,
148
+ factors: {
149
+ breakout: true,
150
+ large_move: absMove >= 3,
151
+ },
152
+ meta: {
153
+ timeframe,
154
+ movePct,
155
+ prev: move.prev,
156
+ last: move.last,
157
+ dayNtlVlm: instrument.context?.dayNtlVlm ?? 0,
158
+ maxLeverage: instrument.maxLeverage,
159
+ },
160
+ };
161
+ }
162
+
163
+ // ─── Main ───────────────────────────────────────────────────────────────────
164
+
165
+ async function main() {
166
+ console.log(`External momentum producer`);
167
+ console.log(` MCP: ${SENPI_MCP_URL}`);
168
+ console.log(` address: ${STRATEGY_ADDRESS}`);
169
+ console.log(` scanner: ${SCANNER_NAME}`);
170
+ console.log(` config: ${JSON.stringify(config)}`);
171
+
172
+ const transport = new StreamableHTTPClientTransport(new URL(SENPI_MCP_URL), {
173
+ requestInit: { headers: { Authorization: `Bearer ${SENPI_API_KEY}` } },
174
+ });
175
+ const mcpClient = new Client({
176
+ name: "external-momentum",
177
+ version: "0.0.1",
178
+ });
179
+ await mcpClient.connect(transport);
180
+
181
+ const providers = createSenpiScannerProviders({
182
+ client: createSenpiToolClient(mcpClient),
183
+ logLevel: "warn",
184
+ cacheTtlMs: { instruments: 0, smData: 0, candles: 0 },
185
+ candleIntervals: [config.timeframe],
186
+ maxCandleAssets: config.maxCandidates,
187
+ retries: 2,
188
+ });
189
+
190
+ try {
191
+ const instruments = await providers.getInstruments(STRATEGY_ADDRESS);
192
+ const candidates = selectCandidates(instruments, config);
193
+ console.log(
194
+ `\nInstruments: ${instruments.length}, candidates: ${candidates.length}`,
195
+ );
196
+
197
+ if (candidates.length === 0) {
198
+ console.log("No candidates after filtering — skipping ingest.");
199
+ return;
200
+ }
201
+
202
+ const candles = await providers.getCandles(STRATEGY_ADDRESS, candidates, {
203
+ scannerId: SCANNER_ID,
204
+ preferredAssets: candidates.map((c) => c.name),
205
+ });
206
+
207
+ const now = Date.now();
208
+ const signals = [];
209
+ for (const inst of candidates) {
210
+ const series = candles[inst.name]?.candles?.[config.timeframe];
211
+ const move = computeMovePct(series);
212
+ if (!move) continue;
213
+ if (Math.abs(move.movePct) < config.minMovePct) continue;
214
+ const signal = buildSignal({
215
+ instrument: inst,
216
+ move,
217
+ timeframe: config.timeframe,
218
+ now,
219
+ });
220
+ if (signal.score < config.minSignalScore) continue;
221
+ signals.push(signal);
222
+ }
223
+
224
+ console.log(`\nSignals: ${signals.length}`);
225
+ for (const s of signals) {
226
+ console.log(
227
+ ` ${s.asset} ${s.direction} move=${s.meta.movePct.toFixed(2)}% score=${s.score.toFixed(2)}`,
228
+ );
229
+ }
230
+
231
+ if (signals.length === 0) {
232
+ console.log("\nNothing to ingest — skipping gateway call.");
233
+ return;
234
+ }
235
+
236
+ const response = await pushScannerSignals({
237
+ address: STRATEGY_ADDRESS,
238
+ scanner: SCANNER_NAME,
239
+ signals,
240
+ openclawBin: OPENCLAW_BIN,
241
+ });
242
+ console.log("\nIngest response:", JSON.stringify(response));
243
+ } finally {
244
+ await transport.close();
245
+ }
246
+ }
247
+
248
+ main().catch((error) => {
249
+ console.error("\nProducer failed:", error.message ?? error);
250
+ process.exit(1);
251
+ });
@@ -1,25 +1,17 @@
1
- """Process entry for the scaffold core (v2).
1
+ """Process entry for the scaffold core.
2
2
 
3
3
  Usage: python3 -m scaffold <launch-config.json>
4
4
 
5
- The launch config JSON is the FROZEN launcher<->scaffold contract (design §4a).
6
- This side READS every §4a key the B1 launcher writes and threads them into
7
- run_scaffold. Hard cut: old key names are gone (`interval` -> `interval_seconds`,
8
- `params` -> `inputs`).
9
-
10
- Required keys (missing => fail loud, non-zero exit):
11
- path — directory added to sys.path (scanner working dir).
12
- entrypoint — module name to import from `path`.
13
- interval_seconds — tick cadence in seconds (was `interval`).
14
- default_signal_validity_seconds — fallback signal TTL (seconds); no magic default.
15
- wallet — strategy wallet (-> ctx.wallet).
16
- scanner_name — this scanner's recipe name (-> ctx.scanner_name).
17
-
18
- Optional keys:
19
- timeout_seconds — per-tick wall-clock budget (default: interval_seconds).
20
- state_path — JSON state file path (None => no persistence).
21
- inputs — tunables dict for scan() (was `params`); default {}.
22
- state_history_max_count — state series bound; default 0 = history disabled.
5
+ The launch config JSON must contain:
6
+ path — directory added to sys.path (the scanner's working directory).
7
+ entrypoint — module name to import from `path`.
8
+ interval — seconds between tick starts.
9
+
10
+ Optional fields:
11
+ timeout_seconds — per-tick wall-clock budget (default: interval).
12
+ params — tunables dict passed to scan() as the first argument.
13
+ state_path — path to the JSON state file.
14
+ max_ticks — cap on tick count (for tests).
23
15
 
24
16
  Delivery sink is wired when EXTERNAL_SCANNER_ID and SIGNAL_INGESTION_URL are set in
25
17
  the environment. If either is absent the scaffold runs with the default no-op sink
@@ -31,18 +23,15 @@ import os
31
23
  import sys
32
24
 
33
25
 
34
- def _main(argv=None) -> int:
35
- if argv is None:
36
- argv = sys.argv
37
-
38
- if len(argv) < 2:
26
+ def _main() -> int:
27
+ if len(sys.argv) < 2:
39
28
  print(
40
29
  "Usage: python3 -m scaffold <launch-config.json>",
41
30
  file=sys.stderr,
42
31
  )
43
32
  return 1
44
33
 
45
- config_path = argv[1]
34
+ config_path = sys.argv[1]
46
35
  try:
47
36
  with open(config_path, "r", encoding="utf-8") as fh:
48
37
  config = json.load(fh)
@@ -50,43 +39,22 @@ def _main(argv=None) -> int:
50
39
  print(f"[scaffold] launch config error: {exc}", file=sys.stderr)
51
40
  return 1
52
41
 
53
- # ---- Required §4a keys: missing => fail loud (no magic default) ----
54
42
  try:
55
43
  path = config["path"]
56
44
  entrypoint = config["entrypoint"]
57
- interval = float(config["interval_seconds"])
58
- default_signal_validity_seconds = int(config["default_signal_validity_seconds"])
59
- wallet = config["wallet"]
60
- scanner_name = config["scanner_name"]
45
+ interval = float(config["interval"])
61
46
  except (KeyError, TypeError, ValueError) as exc:
62
- print(
63
- f"[scaffold] launch config missing/invalid required field: {exc}",
64
- file=sys.stderr,
65
- )
47
+ print(f"[scaffold] launch config missing required field: {exc}", file=sys.stderr)
66
48
  return 1
67
49
 
68
- # ---- Optional §4a keys ----
69
50
  timeout_seconds = config.get("timeout_seconds")
70
- # Hard cut: only `inputs` is read. The legacy `params` key is NOT honored.
71
- inputs = config.get("inputs") or {}
51
+ params = config.get("params") or {}
72
52
  state_path = config.get("state_path")
73
- state_history_max_count = config.get("state_history_max_count") or 0
74
- # Per-signal data{} schema (§4a). None / absent => no scaffold-side validation
75
- # (intake still validates authoritatively). A dict turns on early, loud
76
- # scaffold-side validation that POSTs a violation to /errors.
77
- signal_data_schema = config.get("signal_data_schema")
78
- if signal_data_schema is not None and not isinstance(signal_data_schema, dict):
79
- signal_data_schema = None
80
53
  max_ticks = config.get("max_ticks")
81
54
 
82
- import scaffold as scaffold_pkg
83
- from scaffold import ScanEntrypointError, ScanSignatureError
55
+ from scaffold import ScanEntrypointError, ScanSignatureError, run_scaffold
84
56
  from scaffold.loop_primitives import log_event
85
57
 
86
- # Resolve run_scaffold via the package symbol so a test stub patched onto the
87
- # package (or this module) is picked up.
88
- run_scaffold = globals().get("run_scaffold") or scaffold_pkg.run_scaffold
89
-
90
58
  # Build the delivery sink when scanner_id and intake URL are available.
91
59
  sink = None
92
60
  scanner_id = os.environ.get("EXTERNAL_SCANNER_ID", "").strip()
@@ -99,24 +67,19 @@ def _main(argv=None) -> int:
99
67
  post_timeout = float(os.environ.get("SIGNAL_POST_TIMEOUT_SECONDS", "5"))
100
68
 
101
69
  # Derive journal path from the state file directory when available.
102
- journal_path = None
70
+ journal_path: str | None = None
103
71
  if state_path:
104
- journal_path = os.path.join(
105
- os.path.dirname(os.path.abspath(state_path)),
72
+ import os as _os
73
+ journal_path = _os.path.join(
74
+ _os.path.dirname(_os.path.abspath(state_path)),
106
75
  "delivery-journal.jsonl",
107
76
  )
108
77
 
109
- # §4a threads the per-data-key schema (`signal_data_schema`) to the
110
- # scaffold so the envelope validates each signal's `data` scaffold-side
111
- # and POSTs a violation to /errors (early, loud feedback). None ⇒ no
112
- # scaffold-side validation. The intake still performs authoritative
113
- # server-side schema validation regardless (this is additive).
114
78
  sink = build_delivery_sink(
115
79
  scanner_id=scanner_id,
116
80
  intake_url=intake_url,
81
+ interval=interval,
117
82
  journal_path=journal_path,
118
- signal_data_schema=signal_data_schema,
119
- default_signal_validity_seconds=default_signal_validity_seconds,
120
83
  timeout=post_timeout,
121
84
  )
122
85
  log_event("delivery_sink_enabled", scanner_id=scanner_id, intake_url=intake_url)
@@ -135,12 +98,8 @@ def _main(argv=None) -> int:
135
98
  entrypoint,
136
99
  interval=interval,
137
100
  timeout_seconds=timeout_seconds,
138
- inputs=inputs,
101
+ params=params,
139
102
  state_path=state_path,
140
- scanner_name=scanner_name,
141
- wallet=wallet,
142
- default_signal_validity_seconds=default_signal_validity_seconds,
143
- state_history_max_count=state_history_max_count,
144
103
  sink=sink,
145
104
  max_ticks=max_ticks,
146
105
  )
@@ -4,9 +4,8 @@ build_delivery_sink(
4
4
  *,
5
5
  scanner_id: str,
6
6
  intake_url: str,
7
+ interval: float, # seconds; valid_until default = produced_at + 2*interval
7
8
  journal_path: str | None,
8
- signal_data_schema: dict | None, # per-data-key {type, required?}; None = no validation
9
- default_signal_validity_seconds: int, # fallback signal TTL (seconds)
10
9
  timeout: float = 5.0,
11
10
  clock: Callable[[], float] = time.time,
12
11
  ) -> sink
@@ -33,7 +32,6 @@ from __future__ import annotations
33
32
 
34
33
  import sys
35
34
  import time
36
- import uuid
37
35
  from typing import Any, Callable
38
36
 
39
37
  from .http_post import ERRORS_PATH, SIGNALS_PATH, TransportError, post_json
@@ -49,14 +47,14 @@ def build_delivery_sink(
49
47
  *,
50
48
  scanner_id: str,
51
49
  intake_url: str,
50
+ interval: float,
52
51
  journal_path: str | None,
53
- signal_data_schema: dict | None,
54
- default_signal_validity_seconds: int,
55
52
  timeout: float = 5.0,
56
53
  clock: Callable[[], float] = time.time,
57
54
  ):
58
55
  """Factory: build and return the delivery sink callable."""
59
56
 
57
+ interval_ms = int(interval * 1000)
60
58
  journal = Journal(journal_path)
61
59
  replayed = [False] # mutable cell; replay runs once on first invocation
62
60
 
@@ -89,35 +87,16 @@ def build_delivery_sink(
89
87
  log_event("delivery_dropped_expired", entry_id=entry.id, asset=entry.asset)
90
88
  return to_send
91
89
 
92
- def _on_reject(reason: dict) -> None:
93
- """Loud rejection: log AND POST to /errors (design §4 — never a silent drop)."""
94
- log_event("delivery_candidate_invalid", **reason)
95
- _post_error({"type": "candidate_rejected", **reason})
96
-
97
90
  def _wrap_candidates(candidates, produced_at_ms: int) -> list[tuple]:
98
- """Wrap candidates into (signal, entry_id) pairs, skipping invalid ones.
99
-
100
- Schema-rejected candidates are NOT silently dropped: candidate_to_signal
101
- calls _on_reject, which logs AND POSTs to /errors.
102
- """
91
+ """Wrap candidates into (signal, entry_id) pairs, skipping invalid ones."""
103
92
  result = []
104
93
  for c in candidates:
105
- # Mint the stable signal_id (the journal eid) up front and thread it
106
- # through the envelope so the POSTed signal carries it. record_produced
107
- # re-uses this same id as the entry id, so wire signal_id == journal eid.
108
- eid = uuid.uuid4().hex
109
- sig = candidate_to_signal(
110
- c,
111
- produced_at_ms,
112
- signal_id=eid,
113
- signal_data_schema=signal_data_schema,
114
- default_signal_validity_seconds=default_signal_validity_seconds,
115
- on_reject=_on_reject,
116
- )
94
+ sig = candidate_to_signal(c, produced_at_ms, interval_ms)
117
95
  if sig is None:
96
+ log_event("delivery_candidate_invalid", candidate=repr(c))
118
97
  continue
119
- entry_id = journal.record_produced(sig, now_ms=produced_at_ms)
120
- result.append((sig, entry_id))
98
+ eid = journal.record_produced(sig, now_ms=produced_at_ms)
99
+ result.append((sig, eid))
121
100
  return result
122
101
 
123
102
  def _post_signals(signals: list[dict]) -> tuple[int, Any]:
@@ -146,33 +125,20 @@ def build_delivery_sink(
146
125
  if not isinstance(per_signal, list):
147
126
  per_signal = []
148
127
 
149
- # Match per-signal outcomes BY signal_id, NEVER by array position. The wire
150
- # signal_id IS the journal entry id, so a pending eid set is the lookup key.
151
- # Index-matching would cross-attribute outcomes (asset A's "accepted"
152
- # recorded against asset B) under out-of-order or partial responses —
153
- # capital-sensitive. Robust to: out-of-order, partial (fewer statuses than
154
- # signals), and unknown/missing signal_id (ignored safely, never applied by
155
- # position). An unmatched entry stays pending and is retried.
156
- pending = set(pending_eids)
157
- for sig_status in per_signal:
158
- if not isinstance(sig_status, dict):
159
- continue
160
- eid = sig_status.get("signal_id")
161
- # Missing or unknown signal_id: cannot be matched -> ignore safely.
162
- if not isinstance(eid, str) or eid not in pending:
163
- log_event("delivery_status_unmatched", signal_id=eid)
164
- continue
165
- st = sig_status.get("status")
166
- if st == "accepted":
167
- journal.record_outcome(eid, "delivered", correlationId=sig_status.get("correlationId", ""))
168
- elif st == "rejected":
169
- journal.record_outcome(eid, "rejected", reason=sig_status.get("reason", ""))
170
- elif st in ("expired", "superseded"):
171
- journal.record_outcome(eid, st)
172
- else:
173
- # Unknown status — treat as undelivered (no outcome written)
174
- log_event("delivery_unknown_status", entry_id=eid, status=st)
175
- # Entries with no matching status returned stay pending (retry next tick).
128
+ for i, eid in enumerate(pending_eids):
129
+ if i < len(per_signal):
130
+ sig_status = per_signal[i]
131
+ st = sig_status.get("status") if isinstance(sig_status, dict) else None
132
+ if st == "accepted":
133
+ journal.record_outcome(eid, "delivered", correlationId=sig_status.get("correlationId", ""))
134
+ elif st == "rejected":
135
+ journal.record_outcome(eid, "rejected", reason=sig_status.get("reason", ""))
136
+ elif st in ("expired", "superseded"):
137
+ journal.record_outcome(eid, st)
138
+ else:
139
+ # Unknown status — treat as undelivered (no outcome written)
140
+ log_event("delivery_unknown_status", entry_id=eid, status=st)
141
+ # If no per-signal status returned for this entry, leave it pending (retry)
176
142
 
177
143
  return True
178
144