@senpi/runtime 3.0.0-dev.runtime-phase-2.20260623210537 → 3.0.0-dev.runtime-phase-2.20260625112547

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 (214) hide show
  1. package/README.md +10 -9
  2. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  3. package/dist/actions/action-runtime-module.js +1 -1
  4. package/dist/actions/action-runtime-module.js.map +1 -1
  5. package/dist/actions/decision-engine.d.ts.map +1 -1
  6. package/dist/actions/decision-engine.js +3 -0
  7. package/dist/actions/decision-engine.js.map +1 -1
  8. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  9. package/dist/actions/open-position/open-position.action.js +54 -14
  10. package/dist/actions/open-position/open-position.action.js.map +1 -1
  11. package/dist/actions/types.d.ts +10 -2
  12. package/dist/actions/types.d.ts.map +1 -1
  13. package/dist/actions/types.js +8 -0
  14. package/dist/actions/types.js.map +1 -1
  15. package/dist/cli/guide-commands.d.ts.map +1 -1
  16. package/dist/cli/guide-commands.js +80 -74
  17. package/dist/cli/guide-commands.js.map +1 -1
  18. package/dist/cli/senpi-commands.d.ts.map +1 -1
  19. package/dist/cli/senpi-commands.js +0 -108
  20. package/dist/cli/senpi-commands.js.map +1 -1
  21. package/dist/constants/dsl/index.d.ts +2 -0
  22. package/dist/constants/dsl/index.d.ts.map +1 -1
  23. package/dist/constants/dsl/index.js +2 -0
  24. package/dist/constants/dsl/index.js.map +1 -1
  25. package/dist/dsl/config/parse-tiers.d.ts.map +1 -1
  26. package/dist/dsl/config/parse-tiers.js +6 -2
  27. package/dist/dsl/config/parse-tiers.js.map +1 -1
  28. package/dist/dsl/engine-client/config-mapping.d.ts +7 -0
  29. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  30. package/dist/dsl/engine-client/config-mapping.js +16 -2
  31. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  32. package/dist/dsl/events/handlers.d.ts +18 -2
  33. package/dist/dsl/events/handlers.d.ts.map +1 -1
  34. package/dist/dsl/events/handlers.js +302 -213
  35. package/dist/dsl/events/handlers.js.map +1 -1
  36. package/dist/dsl/monitor/create-monitor.d.ts +15 -0
  37. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  38. package/dist/dsl/monitor/create-monitor.js +46 -9
  39. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  40. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  41. package/dist/dsl/monitor/process-one-position.js +138 -77
  42. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  43. package/dist/dsl/plugin/index.d.ts +16 -1
  44. package/dist/dsl/plugin/index.d.ts.map +1 -1
  45. package/dist/dsl/plugin/index.js +35 -4
  46. package/dist/dsl/plugin/index.js.map +1 -1
  47. package/dist/dsl/plugin/options.d.ts +18 -0
  48. package/dist/dsl/plugin/options.d.ts.map +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/runtime/create-scanner-module.d.ts +4 -3
  53. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  54. package/dist/runtime/create-scanner-module.js +14 -5
  55. package/dist/runtime/create-scanner-module.js.map +1 -1
  56. package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
  57. package/dist/runtime/create-scanner-providers-for-run.js +12 -1
  58. package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
  59. package/dist/runtime/load-runtime-config.js +1 -1
  60. package/dist/runtime/load-runtime-config.js.map +1 -1
  61. package/dist/runtime/map-strategies-registration.js +7 -8
  62. package/dist/runtime/map-strategies-registration.js.map +1 -1
  63. package/dist/runtime/map-strategies.d.ts.map +1 -1
  64. package/dist/runtime/map-strategies.js +12 -3
  65. package/dist/runtime/map-strategies.js.map +1 -1
  66. package/dist/runtime/reconcile-decide.d.ts +73 -0
  67. package/dist/runtime/reconcile-decide.d.ts.map +1 -0
  68. package/dist/runtime/reconcile-decide.js +186 -0
  69. package/dist/runtime/reconcile-decide.js.map +1 -0
  70. package/dist/runtime/reconcile-run.d.ts +62 -0
  71. package/dist/runtime/reconcile-run.d.ts.map +1 -0
  72. package/dist/runtime/reconcile-run.js +173 -0
  73. package/dist/runtime/reconcile-run.js.map +1 -0
  74. package/dist/runtime/reconcile-state.d.ts +44 -0
  75. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  76. package/dist/runtime/reconcile-state.js +80 -44
  77. package/dist/runtime/reconcile-state.js.map +1 -1
  78. package/dist/runtime/risk-guard.d.ts.map +1 -1
  79. package/dist/runtime/risk-guard.js +17 -12
  80. package/dist/runtime/risk-guard.js.map +1 -1
  81. package/dist/runtime/runtime-schema.d.ts +2912 -1052
  82. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  83. package/dist/runtime/runtime-schema.js +136 -42
  84. package/dist/runtime/runtime-schema.js.map +1 -1
  85. package/dist/runtime/runtime.d.ts +26 -0
  86. package/dist/runtime/runtime.d.ts.map +1 -1
  87. package/dist/runtime/runtime.js +254 -8
  88. package/dist/runtime/runtime.js.map +1 -1
  89. package/dist/runtime/scanner-registry.d.ts +1 -1
  90. package/dist/runtime/scanner-registry.d.ts.map +1 -1
  91. package/dist/runtime/scanner-registry.js +3 -12
  92. package/dist/runtime/scanner-registry.js.map +1 -1
  93. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  94. package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
  95. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  96. package/dist/runtime/supervisor/orphan-sweep.js +1 -1
  97. package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
  98. package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
  99. package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
  100. package/dist/runtime/supervisor/supervisor-utils.js +1 -1
  101. package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
  102. package/dist/runtime-api/intake/acceptance-record.d.ts +10 -0
  103. package/dist/runtime-api/intake/acceptance-record.d.ts.map +1 -1
  104. package/dist/runtime-api/intake/acceptance-record.js +28 -0
  105. package/dist/runtime-api/intake/acceptance-record.js.map +1 -1
  106. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  107. package/dist/runtime-api/intake/intake-mount.js +28 -36
  108. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  109. package/dist/runtime-api/intake/process-signal-post.d.ts +20 -1
  110. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  111. package/dist/runtime-api/intake/process-signal-post.js +57 -6
  112. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  113. package/dist/runtime-api/intake/scanner-launcher.d.ts +18 -4
  114. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  115. package/dist/runtime-api/intake/scanner-launcher.js +24 -3
  116. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  117. package/dist/runtime-api/intake/types.d.ts +16 -0
  118. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  119. package/dist/runtime-api/routes/signals.schema.d.ts +4 -0
  120. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  121. package/dist/runtime-api/routes/signals.schema.js +12 -0
  122. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  123. package/dist/scanners/external/scaffold/__main__.py +65 -24
  124. package/dist/scanners/external/scaffold/ctx.py +78 -0
  125. package/dist/scanners/external/scaffold/delivery.py +56 -22
  126. package/dist/scanners/external/scaffold/entrypoint_loader.py +42 -48
  127. package/dist/scanners/external/scaffold/journal.py +13 -2
  128. package/dist/scanners/external/scaffold/requirements.txt +11 -0
  129. package/dist/scanners/external/scaffold/scaffold.py +159 -128
  130. package/dist/scanners/external/scaffold/senpi_mcp.py +250 -0
  131. package/dist/scanners/external/scaffold/signal_envelope.py +177 -34
  132. package/dist/scanners/external/scaffold/state_manager.py +175 -0
  133. package/dist/scanners/external-scanner-receiver.d.ts +6 -0
  134. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  135. package/dist/scanners/external-scanner-receiver.js +14 -1
  136. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  137. package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
  138. package/dist/scripts/run-dsl-standalone-live.js +5 -3
  139. package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
  140. package/dist/senpi/client.d.ts +37 -4
  141. package/dist/senpi/client.d.ts.map +1 -1
  142. package/dist/senpi/client.js +101 -24
  143. package/dist/senpi/client.js.map +1 -1
  144. package/dist/state/state-manager.d.ts +17 -0
  145. package/dist/state/state-manager.d.ts.map +1 -1
  146. package/dist/state/state-manager.js +50 -4
  147. package/dist/state/state-manager.js.map +1 -1
  148. package/dist/strategy/strategy-state.d.ts +9 -1
  149. package/dist/strategy/strategy-state.d.ts.map +1 -1
  150. package/dist/strategy/strategy-state.js +62 -9
  151. package/dist/strategy/strategy-state.js.map +1 -1
  152. package/dist/tracking/constants.d.ts.map +1 -1
  153. package/dist/tracking/constants.js +2 -0
  154. package/dist/tracking/constants.js.map +1 -1
  155. package/dist/types/dsl/index.d.ts +1 -1
  156. package/dist/types/dsl/index.d.ts.map +1 -1
  157. package/dist/types/scanner.d.ts +7 -0
  158. package/dist/types/scanner.d.ts.map +1 -1
  159. package/dist/types/strategy.d.ts +3 -1
  160. package/dist/types/strategy.d.ts.map +1 -1
  161. package/dist/utils/keyed-mutex.d.ts +37 -0
  162. package/dist/utils/keyed-mutex.d.ts.map +1 -0
  163. package/dist/utils/keyed-mutex.js +77 -0
  164. package/dist/utils/keyed-mutex.js.map +1 -0
  165. package/examples/strategies/README.md +17 -12
  166. package/examples/strategies/iguana/README.md +135 -0
  167. package/examples/strategies/iguana/recipe.yaml +137 -0
  168. package/examples/strategies/iguana/scanners/scan.py +243 -0
  169. package/examples/strategies/iguana/scanners/scoring.py +152 -0
  170. package/examples/strategies/raptor/README.md +156 -0
  171. package/examples/strategies/raptor/recipe.yaml +154 -0
  172. package/examples/strategies/raptor/scanners/scan.py +349 -0
  173. package/examples/strategies/raptor/scanners/scoring.py +452 -0
  174. package/examples/strategies/spider/README.md +89 -0
  175. package/examples/strategies/spider/scalp/recipe.yaml +129 -0
  176. package/examples/strategies/spider/scalp/scanners/scan.py +219 -0
  177. package/examples/strategies/spider/scalp/scanners/scoring.py +179 -0
  178. package/examples/strategies/spider/swing/recipe.yaml +137 -0
  179. package/examples/strategies/spider/swing/scanners/scan.py +334 -0
  180. package/examples/strategies/spider/swing/scanners/scoring.py +184 -0
  181. package/examples/strategies/turbine/README.md +166 -0
  182. package/examples/strategies/turbine/recipe-runners.yaml +123 -0
  183. package/examples/strategies/turbine/recipe-volume.yaml +129 -0
  184. package/examples/strategies/turbine/scanners/scan.py +283 -0
  185. package/examples/strategies/turbine/scanners/scoring.py +190 -0
  186. package/package.json +1 -1
  187. package/dist/scanners/external/liquidation-watchdog/README.md +0 -107
  188. package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +0 -48
  189. package/dist/scanners/external/liquidation-watchdog/producer.mjs +0 -336
  190. package/dist/scanners/external/momentum/README.md +0 -148
  191. package/dist/scanners/external/momentum/momentum.yaml +0 -116
  192. package/dist/scanners/external/momentum/producer.mjs +0 -251
  193. package/dist/scanners/external/shared/ingest.mjs +0 -112
  194. package/examples/README.md +0 -89
  195. package/examples/scanners-consumer-quickstart.mjs +0 -134
  196. package/examples/scanners-emerging-movers-v4.mjs +0 -287
  197. package/examples/scanners-liquidation-watchdog.mjs +0 -341
  198. package/examples/scanners-live-mcp.mjs +0 -208
  199. package/examples/scanners-multi-strategy.mjs +0 -165
  200. package/examples/scanners-position-tracker-e2e.mjs +0 -225
  201. package/examples/scanners-scheduled-events.mjs +0 -96
  202. package/examples/scanners-senpi-provider-client.mjs +0 -182
  203. package/examples/scanners-single-strategy.mjs +0 -198
  204. package/examples/strategies/dsl-showcase.yaml +0 -68
  205. package/examples/strategies/dsl.yaml +0 -51
  206. package/examples/strategies/fox-minimal-loosened.yaml +0 -240
  207. package/examples/strategies/fox-minimal.yaml +0 -242
  208. package/examples/strategies/minimal.yaml +0 -46
  209. package/examples/strategies/momentum/how-it-works.md +0 -48
  210. package/examples/strategies/momentum/momentum-guarded.yaml +0 -161
  211. package/examples/strategies/risk-showcase.yaml +0 -93
  212. package/examples/strategies/viper.yaml +0 -67
  213. package/examples/strategies/wolf.yaml +0 -72
  214. package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +0 -123
@@ -0,0 +1,190 @@
1
+ """TURBINE — pure rotation/direction/spread functions (port of turbine-producer.py v3.2.2).
2
+
3
+ Ported VERBATIM from senpi-skills/turbine/scripts/turbine-producer.py (choose_direction,
4
+ pick_rotation_asset, spread parsing) and turbine_config.py (normalize_coin_key). No I/O, no
5
+ MCP, no daemon — pure and unit-testable. `scan.py` fetches per-asset market data via
6
+ ctx.senpi_mcp and hands it to these helpers.
7
+
8
+ Both turbine legs (volume + runners) share THIS scoring module: the source ran the identical
9
+ volume-rotation alpha on both wallets and let the wallet boundary (DSL preset + sizing) select
10
+ behavior. So there is no per-leg branch here — only sizing/exit differ, and those live in the
11
+ recipe YAMLs, not in the signal logic.
12
+
13
+ Source thresholds preserved exactly:
14
+ - Universe MAIN = [BTC, ETH, SOL, HYPE]; XYZ = [xyz:BRENTOIL, xyz:GOLD, xyz:SPX].
15
+ - Direction = funding fade: LONG_CROWDED/LONG_HEAVY -> SHORT (funding_fade_short),
16
+ SHORT_CROWDED/SHORT_HEAVY -> LONG (funding_fade_long), else random LONG/SHORT
17
+ (alternate_neutral).
18
+ - Rotation: probabilistic pool pick (random.random() < xyz_weight -> XYZ pool, else MAIN),
19
+ then a deterministic rotation index inside the pool; skip held + 90s post-close cooldown;
20
+ fall through to the OTHER pool if the chosen one is exhausted.
21
+ - Spread gate: main <= spread_main_bps (3), xyz <= spread_xyz_bps (10). spread_bps from the
22
+ top-of-book mid: ((ask - bid) / mid) * 10000.
23
+ """
24
+
25
+ import random
26
+ import time
27
+
28
+
29
+ # ── Universe (shared by both wallets) ──
30
+
31
+ VOLUME_XYZ = ["xyz:BRENTOIL", "xyz:GOLD", "xyz:SPX"]
32
+ VOLUME_MAIN = ["BTC", "ETH", "SOL", "HYPE"]
33
+
34
+ POST_CLOSE_COOLDOWN_SECONDS = 90
35
+
36
+
37
+ def is_xyz(asset):
38
+ return asset.startswith("xyz:")
39
+
40
+
41
+ def normalize_coin_key(coin):
42
+ """Canonicalize a coin string for held_keys / cooldown sets.
43
+
44
+ Port of turbine_config.normalize_coin_key (v3.2.2): preserve the xyz: prefix so that
45
+ main:HYPE and xyz:HYPE are distinct slot occupiers. Lowercase the prefix, uppercase the
46
+ symbol; bare symbols are uppercased.
47
+ """
48
+ if not coin:
49
+ return ""
50
+ s = coin.strip()
51
+ if ":" in s:
52
+ prefix, _, symbol = s.partition(":")
53
+ return f"{prefix.lower()}:{symbol.upper()}"
54
+ return s.upper()
55
+
56
+
57
+ def safe_float(v, d=0.0):
58
+ try:
59
+ return float(v)
60
+ except (TypeError, ValueError):
61
+ return d
62
+
63
+
64
+ # ── Direction: funding fade (verbatim from producer.choose_direction) ──
65
+
66
+ def choose_direction(regime, rng=random):
67
+ """Funding fade. Crowded longs -> SHORT, crowded shorts -> LONG, flat -> coin flip.
68
+
69
+ Returns (direction, thesis). `rng` is injectable for deterministic tests; the source used
70
+ the module-level `random`.
71
+ """
72
+ r = (regime or "").upper()
73
+ if r in ("LONG_CROWDED", "LONG_HEAVY"):
74
+ return "SHORT", "funding_fade_short"
75
+ if r in ("SHORT_CROWDED", "SHORT_HEAVY"):
76
+ return "LONG", "funding_fade_long"
77
+ return rng.choice(["LONG", "SHORT"]), "alternate_neutral"
78
+
79
+
80
+ # ── Spread parsing (verbatim from producer.query_asset_data) ──
81
+
82
+ def _lvl_px(lvl):
83
+ if isinstance(lvl, dict):
84
+ return safe_float(lvl.get("px", lvl.get("price", 0)))
85
+ if isinstance(lvl, list) and lvl:
86
+ return safe_float(lvl[0])
87
+ return 0.0
88
+
89
+
90
+ def parse_asset_data(resp):
91
+ """Extract bid/ask/mid/spread_bps + funding regime from a market_get_asset_data response.
92
+
93
+ Returns a dict or None when the order book is missing / one-sided / non-positive (the
94
+ source treated all of these as 'skip this asset'). Mirrors producer.query_asset_data
95
+ exactly: top-of-book levels, mid = (bid+ask)/2, spread_bps = ((ask-bid)/mid)*10000.
96
+ """
97
+ if not resp or not isinstance(resp, dict):
98
+ return None
99
+ ad = resp.get("data", resp)
100
+ if not isinstance(ad, dict):
101
+ return None
102
+ ob = ad.get("order_book") or ad.get("orderBook") or {}
103
+ levels = ob.get("levels", [])
104
+ if not isinstance(levels, list) or len(levels) < 2:
105
+ return None
106
+ bids, asks = levels[0], levels[1]
107
+ if not bids or not asks:
108
+ return None
109
+ bid = _lvl_px(bids[0])
110
+ ask = _lvl_px(asks[0])
111
+ if bid <= 0 or ask <= 0:
112
+ return None
113
+ mid = (bid + ask) / 2.0
114
+ spread_bps = ((ask - bid) / mid) * 10000 if mid > 0 else 999
115
+
116
+ funding_regime = (
117
+ ad.get("funding_regime") or ad.get("fundingRegime") or "UNKNOWN"
118
+ ).upper()
119
+ funding_annualized_pct = safe_float(
120
+ ad.get("funding_annualized_pct") or ad.get("fundingAnnualizedPct") or 0
121
+ )
122
+ return {
123
+ "bid": bid,
124
+ "ask": ask,
125
+ "mid": mid,
126
+ "spread_bps": round(spread_bps, 3),
127
+ "funding_regime": funding_regime,
128
+ "funding_annualized_pct": funding_annualized_pct,
129
+ }
130
+
131
+
132
+ def spread_ok(asset, spread_bps, spread_main_bps, spread_xyz_bps):
133
+ """Spread gate: xyz assets get the wider xyz cap, everything else the main cap."""
134
+ max_spread = spread_xyz_bps if is_xyz(asset) else spread_main_bps
135
+ return spread_bps <= max_spread
136
+
137
+
138
+ # ── Rotation pick (verbatim from producer.pick_rotation_asset) ──
139
+
140
+ def pick_rotation_asset(rot_idx, xyz_weight, held_set, last_closed, now=None, rng=random):
141
+ """Pick the next rotation asset.
142
+
143
+ Probabilistic pool pick (random.random() < xyz_weight -> XYZ pool, else MAIN) + a
144
+ deterministic rotation index inside the chosen pool. Skips assets already held and assets
145
+ closed within POST_CLOSE_COOLDOWN_SECONDS (90s). Falls through to the OTHER pool if the
146
+ chosen one is exhausted. Returns (asset|None, new_rot_idx).
147
+
148
+ `now`/`rng` are injectable for deterministic tests; the source used time.time() / random.
149
+ """
150
+ if now is None:
151
+ now = time.time()
152
+
153
+ def _try_pool(pool, idx):
154
+ n = len(pool)
155
+ for _ in range(n):
156
+ candidate = pool[idx % n]
157
+ idx = (idx + 1) % n
158
+ coin_key = normalize_coin_key(candidate)
159
+ if coin_key in held_set:
160
+ continue
161
+ last = last_closed.get(coin_key, {})
162
+ if last and (now - last.get("ts", 0)) < POST_CLOSE_COOLDOWN_SECONDS:
163
+ continue
164
+ return candidate, idx
165
+ return None, idx
166
+
167
+ use_xyz = rng.random() < xyz_weight
168
+ pool = VOLUME_XYZ if use_xyz else VOLUME_MAIN
169
+ asset, rot_idx = _try_pool(pool, rot_idx)
170
+ if asset is not None:
171
+ return asset, rot_idx
172
+
173
+ other = VOLUME_MAIN if use_xyz else VOLUME_XYZ
174
+ asset, rot_idx = _try_pool(other, rot_idx)
175
+ return asset, rot_idx
176
+
177
+
178
+ def clamp_leverage(desired, venue_max):
179
+ """Clamp desired leverage to the asset's Hyperliquid venue max.
180
+
181
+ Turbine's source used a fixed 5x and relied on the runtime to clamp per-asset. We carry the
182
+ same defensive clamp the spider port uses so a low-venue-cap asset never over-levers.
183
+ """
184
+ try:
185
+ venue = int(venue_max)
186
+ except (TypeError, ValueError):
187
+ venue = desired
188
+ if venue <= 0:
189
+ venue = desired
190
+ return max(1, min(int(desired), venue))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senpi/runtime",
3
- "version": "3.0.0-dev.runtime-phase-2.20260623210537",
3
+ "version": "3.0.0-dev.runtime-phase-2.20260625112547",
4
4
  "description": "Senpi runtime plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,107 +0,0 @@
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`.
@@ -1,48 +0,0 @@
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.
@@ -1,336 +0,0 @@
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
- });