@senpi/runtime 3.0.42 → 3.0.44

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.
@@ -1 +1 @@
1
- {"version":3,"file":"formatter.d.ts","sourceRoot":"","sources":["../../../src/dsl/notifications/formatter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EACV,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AASpE;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,eAAe,GAAG,gBAAgB,CAgBrE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,oBAAoB,GAAG,gBAAgB,CAkB/E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,iBAAiB,GAAG,gBAAgB,CAqCzE"}
1
+ {"version":3,"file":"formatter.d.ts","sourceRoot":"","sources":["../../../src/dsl/notifications/formatter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EACV,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AASpE;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,eAAe,GAAG,gBAAgB,CAgBrE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,oBAAoB,GAAG,gBAAgB,CAkB/E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,iBAAiB,GAAG,gBAAgB,CA2CzE"}
@@ -76,12 +76,18 @@ export function formatDslSlUpdated(e) {
76
76
  // entry the number is a locked-in GAIN, not a loss, so we omit the clause rather
77
77
  // than print a nonsensical "Max. loss is now +8%" (the positive-case wording is an
78
78
  // open product decision). We also omit it when entry or leverage is missing/unusable.
79
+ // Same validity bar as the high-water clause above (`> 0`): a negative entry, a zero/absent
80
+ // stop or a non-positive leverage means corrupt upstream data — omit the clause rather than
81
+ // print a confidently precise nonsense figure ("Max. loss is now -993.0%").
79
82
  const { entryPrice, leverage } = e;
80
83
  const canCompute = typeof entryPrice === "number" &&
81
84
  Number.isFinite(entryPrice) &&
82
- entryPrice !== 0 &&
85
+ entryPrice > 0 &&
83
86
  typeof leverage === "number" &&
84
- Number.isFinite(leverage);
87
+ Number.isFinite(leverage) &&
88
+ leverage > 0 &&
89
+ Number.isFinite(e.newSLPrice) &&
90
+ e.newSLPrice > 0;
85
91
  let maxLoss = "";
86
92
  if (canCompute) {
87
93
  const sign = e.direction === "SHORT" ? -1 : 1;
@@ -1 +1 @@
1
- {"version":3,"file":"formatter.js","sourceRoot":"","sources":["../../../src/dsl/notifications/formatter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAQH,OAAO,EACL,WAAW,EACX,SAAS,EACT,MAAM,EACN,WAAW,EACX,cAAc,GACf,MAAM,mBAAmB,CAAC;AAE3B;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,CAAkB;IACjD,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC;IACtB,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC;IAC7D,gFAAgF;IAChF,+DAA+D;IAC/D,MAAM,OAAO,GACX,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,UAAU,KAAK,CAAC;QACjD,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,GAAG,GAAG;QAC9D,CAAC,CAAC,CAAC,CAAC;IACR,MAAM,IAAI,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;IACrC,OAAO;QACL,QAAQ,EAAE,oBAAoB,QAAQ,EAAE;QACxC,KAAK,EACH,2BAA2B,QAAQ,KAAK;YACxC,WAAW,WAAW,CAAC,CAAC,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,IAAI,wCAAwC;KAC/F,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,CAAuB;IAC3D,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC;IACtB,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC;IAC7B,MAAM,SAAS,GAAG,CAAC,CAAC,eAAe,IAAI,CAAC,CAAC;IACzC,MAAM,SAAS,GAAG,MAAM,GAAG,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC;IAC7C,MAAM,MAAM,GAAG,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC;IACjC,MAAM,EAAE,GAAG,CAAC,CAAC,YAAY,IAAI,CAAC,CAAC;IAC/B,MAAM,UAAU,GAAG,MAAM,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC;IAC3C,MAAM,SAAS,GAAG,CAAC,CAAC,SAAS,IAAI,MAAM,CAAC;IACxC,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC;IAC3D,OAAO;QACL,QAAQ,EAAE,iBAAiB,SAAS,CAAC,SAAS,CAAC,OAAO,QAAQ,EAAE;QAChE,KAAK,EACH,SAAS,QAAQ,oBAAoB,SAAS,CAAC,SAAS,CAAC,KAAK,MAAM,CAAC,SAAS,CAAC,WAAW;YAC1F,YAAY,MAAM,CAAC,EAAE,CAAC,cAAc,WAAW,CAAC,CAAC,CAAC,aAAa,EAAE,KAAK,CAAC,KAAK;YAC5E,qBAAqB,SAAS,CAAC,SAAS,CAAC,uBAAuB;YAChE,YAAY,SAAS,CAAC,UAAU,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,yBAAyB;KAChF,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,CAAoB;IACrD,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC;IACtB,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,IAAI,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC;IACnE,MAAM,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAC/C,MAAM,OAAO,GAAG,CAAC,CAAC,cAAc,CAAC;IACjC,MAAM,EAAE,GACN,OAAO,OAAO,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,OAAO,GAAG,CAAC;QACpE,CAAC,CAAC,YAAY,WAAW,CAAC,OAAO,EAAE,KAAK,CAAC,IAAI;QAC7C,CAAC,CAAC,EAAE,CAAC;IACT,iFAAiF;IACjF,gFAAgF;IAChF,gFAAgF;IAChF,iFAAiF;IACjF,mFAAmF;IACnF,sFAAsF;IACtF,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAC;IACnC,MAAM,UAAU,GACd,OAAO,UAAU,KAAK,QAAQ;QAC9B,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC;QAC3B,UAAU,KAAK,CAAC;QAChB,OAAO,QAAQ,KAAK,QAAQ;QAC5B,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC5B,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,IAAI,UAAU,EAAE,CAAC;QACf,MAAM,IAAI,GAAG,CAAC,CAAC,SAAS,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC9C,MAAM,SAAS,GACb,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,GAAG,UAAU,CAAC,GAAG,UAAU,CAAC,GAAG,QAAQ,GAAG,GAAG,CAAC;QACrE,IAAI,SAAS,GAAG,CAAC,EAAE,CAAC;YAClB,OAAO,GAAG,sBAAsB,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC;QACvD,CAAC;IACH,CAAC;IACD,OAAO;QACL,QAAQ,EAAE,GAAG,QAAQ,WAAW,KAAK,EAAE;QACvC,KAAK,EACH,MAAM,QAAQ,UAAU,EAAE,sBAAsB,KAAK,GAAG,OAAO,MAAM;YACrE,uCAAuC;KAC1C,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"formatter.js","sourceRoot":"","sources":["../../../src/dsl/notifications/formatter.ts"],"names":[],"mappings":"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"}
@@ -25,6 +25,18 @@ Response normalization MATCHES producer.mjs `createSenpiToolClient`:
25
25
  2. structuredContent present -> return it.
26
26
  3. else first text content -> json.loads it (fallback to the raw text).
27
27
  4. else -> return content as-is.
28
+ 5. PLUS (scaffold-only, beyond producer.mjs): market-data numeric cast —
29
+ Hyperliquid returns candle o/h/l/c/v and price-map values as STRINGS,
30
+ and authored scanners doing arithmetic on them die on every tick. For
31
+ the market tools (see _NUMERIC_CASTERS) the known numeric fields are
32
+ cast to numbers before the payload reaches scan(); originals are kept
33
+ under a sibling `_raw` key. Candle rows / price entries that do not
34
+ cast are dropped — logged (`senpi_mcp_cast_dropped`, with asset and
35
+ per-interval / per-key detail) AND marked on the payload
36
+ (`_cast_dropped`) so scan() can react — matching the TS producer path
37
+ (normalizeCandleRow + invalidRows in
38
+ src/scanners/providers/adapter.ts). Everything else passes through
39
+ byte-identical.
28
40
 
29
41
  The `mcp` package is LAZY-IMPORTED on first connect (NOT at module top), so
30
42
  `import scaffold.senpi_mcp` works even where `mcp` is not installed. Missing creds
@@ -48,6 +60,7 @@ from __future__ import annotations
48
60
 
49
61
  import asyncio
50
62
  import json
63
+ import math
51
64
  import threading
52
65
  from typing import Any, Iterable, Optional
53
66
 
@@ -101,6 +114,248 @@ MUTATION_TOOLS: frozenset[str] = frozenset(
101
114
  PRODUCER_WRITE_ALLOWLIST: frozenset[str] = frozenset()
102
115
 
103
116
 
117
+ # --------------------------------------------------------------------------
118
+ # Market-data numeric cast — scanner code receives numbers, not strings.
119
+ # --------------------------------------------------------------------------
120
+ #
121
+ # Hyperliquid's API (and therefore the Senpi MCP market tools) returns candle
122
+ # o/h/l/c/v and price-map values as STRINGS. Every authored scanner must
123
+ # otherwise rediscover this and hand-roll a float() helper; four independent
124
+ # scanners shipped arithmetic-DOA on it in one review window. Casting here —
125
+ # the single chokepoint every scanner market read passes through — makes
126
+ # authored code quality irrelevant to that failure mode.
127
+ #
128
+ # Semantics mirror the TS producer path (asNumber in src/utils/response.ts +
129
+ # normalizeCandleRow/invalidRows in src/scanners/providers/adapter.ts):
130
+ # finite numbers pass through, numeric strings cast, candle rows whose
131
+ # REQUIRED fields don't cast are dropped and counted. Enrichment sections
132
+ # (asset_context, order_book, funding_history) cast valid values in place and
133
+ # leave uncastable ones untouched — no data loss on optional context.
134
+ # Originals of every rewritten section are preserved under a sibling `_raw`
135
+ # key so precision-sensitive code can still reach the exact API strings.
136
+
137
+ # Candle fields that MUST cast for the row to survive (t/o/h/l/c/v — the six
138
+ # the TS normalizeCandleRow requires) vs. informational ones cast when valid.
139
+ _CANDLE_REQUIRED_FIELDS = ("t", "o", "h", "l", "c", "v")
140
+ _CANDLE_OPTIONAL_FIELDS = ("T", "n")
141
+
142
+ # Known numeric fields of the Hyperliquid assetCtx embedded as asset_context.
143
+ _ASSET_CONTEXT_NUMERIC_FIELDS = (
144
+ "markPx",
145
+ "midPx",
146
+ "prevDayPx",
147
+ "oraclePx",
148
+ "funding",
149
+ "openInterest",
150
+ "dayNtlVlm",
151
+ "dayBaseVlm",
152
+ "premium",
153
+ )
154
+
155
+
156
+ def _as_number(value: Any) -> Optional[float]:
157
+ """asNumber (src/utils/response.ts) ported: finite numbers pass through
158
+ unchanged, numeric strings cast to float, everything else -> None.
159
+ Deliberate divergences from JS: `""` is rejected (JS coerces it to 0), and
160
+ integers beyond float range are rejected (json.loads yields arbitrary-
161
+ precision ints; math.isfinite would raise OverflowError on them)."""
162
+ if isinstance(value, bool):
163
+ return None
164
+ if isinstance(value, (int, float)):
165
+ try:
166
+ return value if math.isfinite(value) else None
167
+ except OverflowError:
168
+ return None
169
+ if isinstance(value, str):
170
+ try:
171
+ number = float(value)
172
+ except ValueError:
173
+ return None
174
+ return number if math.isfinite(number) else None
175
+ return None
176
+
177
+
178
+ def _record_candle_drop(stats: dict, interval: str, count: int = 1) -> None:
179
+ stats["dropped_candle_rows"] += count
180
+ stats["intervals"][interval] = stats["intervals"].get(interval, 0) + count
181
+
182
+
183
+ def _cast_candle_series(series: list, interval: str, stats: dict) -> list:
184
+ """New candle list with o/h/l/c/v (+t) numeric; uncastable rows dropped."""
185
+ out = []
186
+ for row in series:
187
+ if not isinstance(row, dict):
188
+ _record_candle_drop(stats, interval)
189
+ continue
190
+ cast_row = dict(row)
191
+ ok = True
192
+ for key in _CANDLE_REQUIRED_FIELDS:
193
+ number = _as_number(row.get(key))
194
+ if number is None:
195
+ ok = False
196
+ break
197
+ cast_row[key] = number
198
+ if not ok:
199
+ _record_candle_drop(stats, interval)
200
+ continue
201
+ for key in _CANDLE_OPTIONAL_FIELDS:
202
+ if key in row:
203
+ number = _as_number(row[key])
204
+ if number is not None:
205
+ cast_row[key] = number
206
+ out.append(cast_row)
207
+ return out
208
+
209
+
210
+ def _cast_if_numeric(container: dict, keys: Iterable[str]) -> dict:
211
+ """Copy of `container` with each named field cast when castable; uncastable
212
+ values stay as-is (enrichment sections never lose data)."""
213
+ out = dict(container)
214
+ for key in keys:
215
+ if key in container:
216
+ number = _as_number(container[key])
217
+ if number is not None:
218
+ out[key] = number
219
+ return out
220
+
221
+
222
+ def _cast_asset_context(context: Any) -> Any:
223
+ if not isinstance(context, dict):
224
+ return context
225
+ out = _cast_if_numeric(context, _ASSET_CONTEXT_NUMERIC_FIELDS)
226
+ impact = context.get("impactPxs")
227
+ if isinstance(impact, list):
228
+ out["impactPxs"] = [
229
+ n if (n := _as_number(px)) is not None else px for px in impact
230
+ ]
231
+ return out
232
+
233
+
234
+ def _cast_order_book(book: Any) -> Any:
235
+ if not isinstance(book, dict) or not isinstance(book.get("levels"), list):
236
+ return book
237
+ out = dict(book)
238
+ out["levels"] = [
239
+ [_cast_if_numeric(lvl, ("px", "sz")) if isinstance(lvl, dict) else lvl for lvl in side]
240
+ if isinstance(side, list)
241
+ else side
242
+ for side in book["levels"]
243
+ ]
244
+ return out
245
+
246
+
247
+ def _cast_asset_data(container: dict, stats: dict) -> dict:
248
+ """market_get_asset_data: candles (drop-invalid) + enrichment sections."""
249
+ cast: dict[str, Any] = {}
250
+ candles = container.get("candles")
251
+ if isinstance(candles, dict):
252
+ cast_candles: dict[str, Any] = {}
253
+ for interval, series in candles.items():
254
+ if not isinstance(series, list):
255
+ # A null/garbage series must not reach scan() as a string/None
256
+ # hole in the guarantee — omit the key and count it, matching
257
+ # the TS path (adapter.ts invalidRows on a non-array series).
258
+ _record_candle_drop(stats, interval)
259
+ continue
260
+ cast_candles[interval] = _cast_candle_series(series, interval, stats)
261
+ cast["candles"] = cast_candles
262
+ if "asset_context" in container:
263
+ new_context = _cast_asset_context(container["asset_context"])
264
+ if new_context is not container["asset_context"]:
265
+ cast["asset_context"] = new_context
266
+ if "order_book" in container:
267
+ new_book = _cast_order_book(container["order_book"])
268
+ if new_book is not container["order_book"]:
269
+ cast["order_book"] = new_book
270
+ history = container.get("funding_history")
271
+ if isinstance(history, list):
272
+ cast["funding_history"] = [
273
+ _cast_if_numeric(entry, ("fundingRate", "premium")) if isinstance(entry, dict) else entry
274
+ for entry in history
275
+ ]
276
+ return cast
277
+
278
+
279
+ def _cast_prices(container: dict, stats: dict) -> dict:
280
+ """market_get_prices: every value in the prices map; uncastable keys drop."""
281
+ prices = container.get("prices")
282
+ if not isinstance(prices, dict):
283
+ return {}
284
+ out = {}
285
+ for key, value in prices.items():
286
+ number = _as_number(value)
287
+ if number is None:
288
+ stats["dropped_price_keys"] += 1
289
+ if len(stats["price_keys"]) < _MAX_REPORTED_PRICE_KEYS:
290
+ stats["price_keys"].append(key)
291
+ continue
292
+ out[key] = number
293
+ cast: dict[str, Any] = {"prices": out}
294
+ # Keep the MCP's sibling `count` true to the surviving map after drops.
295
+ if stats["dropped_price_keys"] and "count" in container:
296
+ cast["count"] = len(out)
297
+ return cast
298
+
299
+
300
+ _NUMERIC_CASTERS = {
301
+ "market_get_asset_data": _cast_asset_data,
302
+ "market_get_prices": _cast_prices,
303
+ }
304
+
305
+ # Cap on dropped price-key names carried in the log event / payload marker —
306
+ # diagnosability without an unbounded list on a pathological response.
307
+ _MAX_REPORTED_PRICE_KEYS = 10
308
+
309
+
310
+ def _cast_market_numerics(name: str, payload: Any, args: Optional[dict] = None) -> Any:
311
+ """Apply the tool's numeric cast to an unwrapped payload (envelope-aware).
312
+
313
+ Rewritten sections land back on the payload with their originals under
314
+ `_raw` (written once, never clobbering a pre-existing key). Drops are
315
+ visible three ways: the `senpi_mcp_cast_dropped` log event (with the asset
316
+ and per-interval / per-key detail), a `_cast_dropped` marker on the
317
+ container so scan() can react (the TS path's CandleResult.error analogue),
318
+ and the originals under `_raw`. Anything unrecognized passes through
319
+ untouched.
320
+ """
321
+ caster = _NUMERIC_CASTERS.get(name)
322
+ if caster is None or not isinstance(payload, dict):
323
+ return payload
324
+
325
+ # createResponse envelope: sections live under `data` when a boolean
326
+ # `success` sits beside it (matches unwrapEnvelope in response.ts); bare
327
+ # payloads keep sections at the top level. Error envelopes have no data
328
+ # dict -> no-op.
329
+ container = payload
330
+ if isinstance(payload.get("success"), bool) and isinstance(payload.get("data"), dict):
331
+ container = payload["data"]
332
+
333
+ stats: dict[str, Any] = {
334
+ "dropped_candle_rows": 0,
335
+ "dropped_price_keys": 0,
336
+ "intervals": {},
337
+ "price_keys": [],
338
+ }
339
+ cast_sections = caster(container, stats)
340
+ if cast_sections:
341
+ raw = {key: container[key] for key in cast_sections if key in container}
342
+ container.update(cast_sections)
343
+ if "_raw" not in container:
344
+ container["_raw"] = raw
345
+
346
+ dropped = {key: value for key, value in stats.items() if value}
347
+ if dropped:
348
+ container["_cast_dropped"] = dict(dropped)
349
+ asset = (args or {}).get("asset")
350
+ log_event(
351
+ "senpi_mcp_cast_dropped",
352
+ tool=name,
353
+ **({"asset": asset} if isinstance(asset, str) else {}),
354
+ **dropped,
355
+ )
356
+ return payload
357
+
358
+
104
359
  class SenpiMcpClient:
105
360
  """Sync MCP client over the official async SDK with one persistent session.
106
361
 
@@ -158,7 +413,7 @@ class SenpiMcpClient:
158
413
 
159
414
  self._ensure_connected()
160
415
  result = self._run(self._session.call_tool(name, arguments=args))
161
- return self._unwrap(name, result)
416
+ return _cast_market_numerics(name, self._unwrap(name, result), args)
162
417
  except Exception as exc:
163
418
  # Catch every failure — mutation block, missing creds, the lazy
164
419
  # `import mcp`, transport errors, tool `isError` — log it, then re-raise
@@ -2,7 +2,7 @@
2
2
  "id": "runtime",
3
3
  "name": "Senpi Runtime",
4
4
  "description": "Senpi runtime plugin for OpenClaw. Orchestrates scanners, LLM-based trade decisions, and a DSL trailing stop-loss exit engine.",
5
- "version": "3.0.42",
5
+ "version": "3.0.44",
6
6
  "commandAliases": [
7
7
  {
8
8
  "name": "senpi"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senpi/runtime",
3
- "version": "3.0.42",
3
+ "version": "3.0.44",
4
4
  "description": "Senpi runtime plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",