@senpi/runtime 1.1.0-dev.runtime-phase-2.20260609132603 → 1.1.0-dev.runtime-phase-2.20260610125851

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 (100) hide show
  1. package/dist/actions/close-position/close-position.action.d.ts +0 -16
  2. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  3. package/dist/actions/close-position/close-position.action.js +38 -299
  4. package/dist/actions/close-position/close-position.action.js.map +1 -1
  5. package/dist/actions/decision-engine.d.ts +0 -2
  6. package/dist/actions/decision-engine.d.ts.map +1 -1
  7. package/dist/actions/decision-engine.js.map +1 -1
  8. package/dist/actions/llm-decision/factory.d.ts +0 -3
  9. package/dist/actions/llm-decision/factory.d.ts.map +1 -1
  10. package/dist/actions/llm-decision/factory.js +0 -1
  11. package/dist/actions/llm-decision/factory.js.map +1 -1
  12. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts +0 -13
  13. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts.map +1 -1
  14. package/dist/actions/llm-decision/openclaw-llm-decision.js +68 -195
  15. package/dist/actions/llm-decision/openclaw-llm-decision.js.map +1 -1
  16. package/dist/actions/open-position/open-position.action.d.ts +0 -12
  17. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  18. package/dist/actions/open-position/open-position.action.js +123 -346
  19. package/dist/actions/open-position/open-position.action.js.map +1 -1
  20. package/dist/actions/position-tracker/position-tracker.action.d.ts +0 -6
  21. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  22. package/dist/actions/position-tracker/position-tracker.action.js +0 -48
  23. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  24. package/dist/runtime/create-actions.d.ts.map +1 -1
  25. package/dist/runtime/create-actions.js +0 -2
  26. package/dist/runtime/create-actions.js.map +1 -1
  27. package/dist/runtime/create-runtime-context.d.ts +0 -2
  28. package/dist/runtime/create-runtime-context.d.ts.map +1 -1
  29. package/dist/runtime/create-runtime-context.js +0 -1
  30. package/dist/runtime/create-runtime-context.js.map +1 -1
  31. package/dist/runtime/create-scanner-module.d.ts +1 -1
  32. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  33. package/dist/runtime/create-scanner-module.js +1 -2
  34. package/dist/runtime/create-scanner-module.js.map +1 -1
  35. package/dist/runtime/runtime-schema.d.ts +0 -24
  36. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  37. package/dist/runtime/runtime-schema.js +0 -1
  38. package/dist/runtime/runtime-schema.js.map +1 -1
  39. package/dist/runtime/runtime.d.ts +0 -5
  40. package/dist/runtime/runtime.d.ts.map +1 -1
  41. package/dist/runtime/runtime.js +4 -127
  42. package/dist/runtime/runtime.js.map +1 -1
  43. package/dist/scanners/engine/data-providers.d.ts +0 -2
  44. package/dist/scanners/engine/data-providers.d.ts.map +1 -1
  45. package/dist/scanners/engine/data-providers.js.map +1 -1
  46. package/dist/scanners/engine/engine.d.ts.map +1 -1
  47. package/dist/scanners/engine/engine.js +9 -68
  48. package/dist/scanners/engine/engine.js.map +1 -1
  49. package/dist/scanners/runtime-module.d.ts +0 -2
  50. package/dist/scanners/runtime-module.d.ts.map +1 -1
  51. package/dist/scanners/runtime-module.js +0 -1
  52. package/dist/scanners/runtime-module.js.map +1 -1
  53. package/dist/senpi/client.d.ts +2 -2
  54. package/dist/senpi/client.d.ts.map +1 -1
  55. package/dist/senpi/client.js +20 -120
  56. package/dist/senpi/client.js.map +1 -1
  57. package/dist/senpi/types.d.ts +1 -68
  58. package/dist/senpi/types.d.ts.map +1 -1
  59. package/dist/strategy/strategy-state.d.ts +1 -1
  60. package/dist/strategy/strategy-state.d.ts.map +1 -1
  61. package/dist/tracking/spans/plugin-span-tracker.d.ts.map +1 -1
  62. package/dist/tracking/spans/plugin-span-tracker.js +24 -4
  63. package/dist/tracking/spans/plugin-span-tracker.js.map +1 -1
  64. package/dist/tracking/spans/redact-pii.d.ts +0 -10
  65. package/dist/tracking/spans/redact-pii.d.ts.map +1 -1
  66. package/dist/tracking/spans/redact-pii.js +2 -80
  67. package/dist/tracking/spans/redact-pii.js.map +1 -1
  68. package/dist/types/runtime.d.ts +0 -3
  69. package/dist/types/runtime.d.ts.map +1 -1
  70. package/dist/types/strategy.d.ts +2 -2
  71. package/dist/types/strategy.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/dist/actions/otel-helpers.d.ts +0 -65
  74. package/dist/actions/otel-helpers.d.ts.map +0 -1
  75. package/dist/actions/otel-helpers.js +0 -122
  76. package/dist/actions/otel-helpers.js.map +0 -1
  77. package/dist/otel/context.d.ts +0 -24
  78. package/dist/otel/context.d.ts.map +0 -1
  79. package/dist/otel/context.js +0 -29
  80. package/dist/otel/context.js.map +0 -1
  81. package/dist/otel/emitter.d.ts +0 -73
  82. package/dist/otel/emitter.d.ts.map +0 -1
  83. package/dist/otel/emitter.js +0 -248
  84. package/dist/otel/emitter.js.map +0 -1
  85. package/dist/otel/encode.d.ts +0 -48
  86. package/dist/otel/encode.d.ts.map +0 -1
  87. package/dist/otel/encode.js +0 -100
  88. package/dist/otel/encode.js.map +0 -1
  89. package/dist/otel/index.d.ts +0 -13
  90. package/dist/otel/index.d.ts.map +0 -1
  91. package/dist/otel/index.js +0 -10
  92. package/dist/otel/index.js.map +0 -1
  93. package/dist/otel/transport.d.ts +0 -57
  94. package/dist/otel/transport.d.ts.map +0 -1
  95. package/dist/otel/transport.js +0 -208
  96. package/dist/otel/transport.js.map +0 -1
  97. package/dist/otel/types.d.ts +0 -62
  98. package/dist/otel/types.d.ts.map +0 -1
  99. package/dist/otel/types.js +0 -2
  100. package/dist/otel/types.js.map +0 -1
@@ -8,57 +8,11 @@ import { resolveMargin } from './resolve-margin.js';
8
8
  import { createLogger } from '../../utils/logger.js';
9
9
  import { resolveOpenOrderType, resolveFeeOptimizedLimitOptions } from '../../utils/resolve-order-type.js';
10
10
  import { emitTrackingDecision, emitTrackingActionResult } from '../tracking-helpers.js';
11
- import { buildSignalAttrs, decisionAttrs, decisionErrorAttrs, emitBatchRejected, setPositionId, withSignalSpanScope, } from '../otel-helpers.js';
12
- import { randomUUID } from 'node:crypto';
13
11
  const logger = createLogger("[actions][open-position]");
14
12
  /** Hyperliquid minimum notional value (margin × leverage) required to open a position. */
15
13
  const MIN_NOTIONAL_VALUE = 10;
16
14
  /** Minimum leverage floor — positions cannot go below 1x. */
17
15
  const MIN_LEVERAGE = 1;
18
- // ─── Pure attr-shape helpers ─────────────────────────────────────────────────
19
- /**
20
- * Flatten a senpi `createPosition` response into the queryable subset of
21
- * `action.open` span attrs. Extracted (per D5) so the senpi-response shape
22
- * evolves in one place rather than scattered across the action body.
23
- */
24
- function flattenOpenResultAttrs(result) {
25
- const submitted = result.orders[0];
26
- return {
27
- "action.success": result.success,
28
- "action.outcome": result.success ? "submitted" : "failed",
29
- "action.order_id": submitted?.orderId,
30
- "action.entry_price": submitted?.entryPrice,
31
- "action.size": submitted?.size,
32
- "action.filled": submitted?.filled,
33
- "action.failure_reason": submitted?.error,
34
- "action.fill_type": submitted?.executionAsMaker === true
35
- ? "maker"
36
- : submitted?.executionAsMaker === false
37
- ? "taker"
38
- : "unfilled",
39
- "action.metadata.trace_id": result.metadata?.traceId,
40
- "action.metadata.execution_id": result.metadata?.executionId,
41
- ...(submitted?.mainOrder ? {
42
- "action.main_order.status": submitted.mainOrder.status,
43
- "action.main_order.avg_price": submitted.mainOrder.avgPrice,
44
- "action.main_order.requested_size": submitted.mainOrder.requestedSize,
45
- } : {}),
46
- ...(submitted?.stopLoss ? {
47
- "action.stop_loss.enabled": submitted.stopLoss.enabled,
48
- "action.stop_loss.order_id": submitted.stopLoss.orderId,
49
- "action.stop_loss.status": submitted.stopLoss.status,
50
- "action.stop_loss.trigger_price": submitted.stopLoss.triggerPrice,
51
- "action.stop_loss.error": submitted.stopLoss.error,
52
- } : {}),
53
- ...(submitted?.takeProfit ? {
54
- "action.take_profit.enabled": submitted.takeProfit.enabled,
55
- "action.take_profit.order_id": submitted.takeProfit.orderId,
56
- "action.take_profit.status": submitted.takeProfit.status,
57
- "action.take_profit.trigger_price": submitted.takeProfit.triggerPrice,
58
- "action.take_profit.error": submitted.takeProfit.error,
59
- } : {}),
60
- };
61
- }
62
16
  // ─── Open Position Action ───────────────────────────────────────────────────
63
17
  export class OpenPositionAction {
64
18
  config;
@@ -125,7 +79,16 @@ export class OpenPositionAction {
125
79
  error: 'strategy_address_mismatch',
126
80
  };
127
81
  }
128
- // Early exit: no slots
82
+ // Early exit: no slots.
83
+ // NOTE re "zero slots skips cap alerts": we intentionally do NOT evaluate risk
84
+ // halts/cap self-pause here. It would add a per-signal getPnlHistoryWithSnapshot
85
+ // MCP call on the full-book path (costly on a full + high-signal-rate strategy),
86
+ // and that halt data is fetched nowhere else to reuse (only risk-guard.checkGate
87
+ // fetches it; the DSL monitor fetches clearinghouse = different data). Self-pause
88
+ // alerts still fire via the normal open path once a slot frees and a signal
89
+ // arrives (delayed, not lost). The durable fix — a periodic lifecycle driven off
90
+ // the monitor tick / bus events (also catches a fully-silent producer) — is
91
+ // roadmap §5 (the merge), deliberately not blended into this §4 short-term PR.
129
92
  if (!strategyState.getAvailableSlots?.()) {
130
93
  return {
131
94
  success: true,
@@ -157,19 +120,15 @@ export class OpenPositionAction {
157
120
  ...(this.config.params ?? {}),
158
121
  })
159
122
  : undefined;
160
- const fullContext = { ...contextData, scanResult };
161
- const contextKeys = Object.keys(fullContext);
162
- const minConfidence = this.config.min_confidence ?? 1;
163
123
  // Get decision
164
124
  let decision;
165
125
  try {
166
126
  decision = await this.decisionEngine.decide(this.config.decision_mode, {
167
127
  prompt: interpolatedPrompt,
168
- context: fullContext,
128
+ context: { ...contextData, scanResult },
169
129
  model: this.config.decision_model,
170
130
  actionType: this.actionType,
171
131
  event,
172
- minConfidence,
173
132
  });
174
133
  }
175
134
  catch (error) {
@@ -201,29 +160,13 @@ export class OpenPositionAction {
201
160
  },
202
161
  };
203
162
  emitTrackingActionResult(trackingCtx, event, address, errorResult);
204
- const errAttrs = decisionErrorAttrs({
205
- mode: this.config.decision_mode,
206
- message,
207
- minConfidence,
208
- actionType: this.actionType,
209
- model: this.config.decision_model,
210
- contextKeys,
211
- });
212
- emitBatchRejected(this.context.otel, scanResult, "decision_error", "OPEN", errAttrs, error);
213
163
  return errorResult;
214
164
  }
215
165
  logger.info('decision', decision);
216
166
  const trackingCtx = this.trackingCtx();
167
+ const minConfidence = this.config.min_confidence ?? 1;
217
168
  const isBelowConfidence = decision.execute && decision.confidence < minConfidence;
218
169
  emitTrackingDecision(trackingCtx, event, address, scanResult, decision, minConfidence, interpolatedPrompt, contextData, isBelowConfidence ? 'below_confidence' : undefined);
219
- const decAttrs = decisionAttrs({
220
- mode: this.config.decision_mode,
221
- decision,
222
- minConfidence,
223
- actionType: this.actionType,
224
- model: this.config.decision_model,
225
- contextKeys,
226
- });
227
170
  // Check decision
228
171
  if (!decision.execute) {
229
172
  logger.info('decision no execute', decision);
@@ -241,7 +184,6 @@ export class OpenPositionAction {
241
184
  },
242
185
  };
243
186
  emitTrackingActionResult(trackingCtx, event, address, rejectedResult);
244
- emitBatchRejected(this.context.otel, scanResult, "rejected_by_decision", "OPEN", decAttrs);
245
187
  return rejectedResult;
246
188
  }
247
189
  // Check confidence threshold
@@ -261,17 +203,12 @@ export class OpenPositionAction {
261
203
  },
262
204
  };
263
205
  emitTrackingActionResult(trackingCtx, event, address, belowConfResult);
264
- emitBatchRejected(this.context.otel, scanResult, "rejected_by_confidence", "OPEN", {
265
- ...decAttrs,
266
- "decision.skipped_reason": "below_confidence",
267
- });
268
206
  return belowConfResult;
269
207
  }
270
208
  // ── Unified signals path ──
271
209
  const signals = this.normalizeSignals(decision.payload);
272
210
  const meta = decisionMetaFromDecision(this.config, decision);
273
211
  if (!signals.length) {
274
- emitBatchRejected(this.context.otel, scanResult, "invalid_payload", "OPEN", decAttrs);
275
212
  return {
276
213
  success: false,
277
214
  action_type: this.actionType,
@@ -280,7 +217,7 @@ export class OpenPositionAction {
280
217
  data: { _decisionMeta: meta, _decisionPrompt: interpolatedPrompt ?? null },
281
218
  };
282
219
  }
283
- return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt, decAttrs);
220
+ return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt);
284
221
  }
285
222
  normalizeSignals(payload) {
286
223
  if (Array.isArray(payload.signals))
@@ -295,10 +232,8 @@ export class OpenPositionAction {
295
232
  }
296
233
  return [];
297
234
  }
298
- async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt, decAttrs) {
235
+ async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt) {
299
236
  const address = event.address;
300
- const eventScanResult = event.data?.scanResult;
301
- const scannerId = eventScanResult?.scannerId;
302
237
  const orderType = resolveOpenOrderType(this.config.params?.order_type);
303
238
  const feeOptimizedLimitOptions = orderType === 'FEE_OPTIMIZED_LIMIT'
304
239
  ? resolveFeeOptimizedLimitOptions(this.config.params?.fee_optimized_limit_options)
@@ -336,137 +271,38 @@ export class OpenPositionAction {
336
271
  withdrawable = withdrawableResult;
337
272
  let remainingSlots = strategyState.getAvailableSlots?.() ?? 0;
338
273
  // Free-margin pre-flight budget (P1). Undefined when withdrawable is unknown
339
- // (no clearinghouse read) — in that case we do NOT block, preserving prior
340
- // behavior. Decremented per submitted signal so later signals in the same
341
- // batch are evaluated against the margin already consumed by earlier ones.
274
+ // (no clearinghouse read) — in that case we do NOT block, preserving prior behavior.
275
+ // Decremented per submitted signal so later signals in the same batch are
276
+ // evaluated against the margin already consumed by earlier ones.
342
277
  let remainingMargin = withdrawable;
343
278
  const results = [];
344
279
  let submittedCount = 0;
345
280
  let failedCount = 0;
346
281
  for (const signal of sorted) {
347
- try {
348
- const outcome = await this.processOpenSignal({
349
- signal,
350
- event,
351
- scannerId,
352
- strategyState,
353
- orderType,
354
- feeOptimizedLimitOptions,
355
- reasoning,
356
- decAttrs,
357
- withdrawable,
358
- remainingMargin,
359
- remainingSlots,
360
- });
361
- results.push(outcome.result);
362
- if (outcome.kind === 'submitted') {
363
- submittedCount++;
364
- remainingSlots--;
365
- // Decrement the free-margin budget only on a real submit, so later
366
- // signals are evaluated against margin already consumed (P1).
367
- if (remainingMargin != null && typeof outcome.marginConsumed === 'number') {
368
- remainingMargin -= outcome.marginConsumed;
369
- }
370
- }
371
- else if (outcome.kind === 'failed') {
372
- failedCount++;
373
- }
374
- }
375
- catch {
376
- // withSignalSpanScope re-throws after stamping `exception` outcome /
377
- // recording the exception event on the span. The per-signal failed
378
- // result has already been pushed inside processOpenSignal; the
379
- // failure must not interrupt the loop over remaining signals.
380
- }
381
- }
382
- const skippedCount = results.length - submittedCount - failedCount;
383
- const actionResult = {
384
- success: submittedCount > 0 || failedCount === 0,
385
- action_type: this.actionType,
386
- summary: `Open submit: ${submittedCount} submitted, ${failedCount} failed, ${skippedCount} skipped`,
387
- data: {
388
- results,
389
- submittedCount,
390
- failedCount,
391
- skippedCount,
392
- ...(meta ? { _decisionMeta: meta } : {}),
393
- ...(riskSnapshot ? { _riskSnapshot: riskSnapshot } : {}),
394
- _decisionPrompt: decisionPrompt ?? null,
395
- },
396
- };
397
- emitTrackingActionResult(this.trackingCtx(), event, address, actionResult);
398
- return actionResult;
399
- }
400
- /**
401
- * Process one signal end-to-end: gates → leverage/margin resolution →
402
- * senpi.createPosition → hook fire. Opens a per-signal `signal.evaluated`
403
- * funnel span (via `withSignalSpanScope`) and stamps the per-signal `position.id`
404
- * on mint. Returns an outcome the caller applies to counters + results.
405
- *
406
- * The exception path inside the senpi.createPosition try/catch sets a
407
- * 'failed' outcome and lets `withSignalSpanScope` complete normally — the
408
- * outer for-loop's catch in `handleSignals` only fires on truly unexpected
409
- * throws.
410
- */
411
- async processOpenSignal(opts) {
412
- const { signal, event, scannerId, strategyState, orderType, feeOptimizedLimitOptions, reasoning, decAttrs, withdrawable, remainingMargin, remainingSlots } = opts;
413
- const address = event.address;
414
- const asset = signal.asset;
415
- const direction = signal.direction;
416
- const signalScore = signal.score;
417
- const signalDex = signal.dex;
418
- const submittedDex = typeof signalDex === "string" && signalDex.trim() !== "" ? signalDex.trim() : "main";
419
- const dexForLookup = submittedDex === "main" ? undefined : submittedDex;
420
- const signalAttrs = {
421
- ...buildSignalAttrs(scannerId, {
422
- asset,
423
- direction: direction ?? null,
424
- score: signalScore,
425
- signalType: signal.signalType,
426
- factors: signal.factors,
427
- meta: signal.meta,
428
- }, "OPEN"),
429
- ...(decAttrs ?? {}),
430
- };
431
- // The per-signal ALS frame (opened by withSignalSpanScope) carries
432
- // `currentSpan` so descendant spans/logs inherit parent automatically;
433
- // `positionId` is set on mint and never leaks to the next iteration
434
- // (H1/H2/B06). The closure mutates `outcome` so we can return it after
435
- // the scope completes.
436
- let outcome = {
437
- kind: 'failed',
438
- result: { asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'unknown' },
439
- };
440
- await withSignalSpanScope(this.context.otel, signalAttrs, async (signalSpan) => {
441
- const endSignal = (label, status) => {
442
- signalSpan.setAttr("signal.outcome", label);
443
- signalSpan.end(status ? { statusCode: status } : undefined);
444
- };
282
+ const asset = signal.asset;
283
+ const direction = signal.direction;
284
+ const signalScore = signal.score;
285
+ const signalDex = signal.dex;
286
+ const submittedDex = typeof signalDex === "string" && signalDex.trim() !== "" ? signalDex.trim() : "main";
287
+ const dexForLookup = submittedDex === "main" ? undefined : submittedDex;
445
288
  if (remainingSlots <= 0) {
446
- outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots' } };
447
- endSignal("no_slots");
448
- return;
289
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots' });
290
+ continue;
449
291
  }
450
292
  if (!direction || (direction !== 'LONG' && direction !== 'SHORT')) {
451
- outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction' } };
452
- endSignal("invalid_direction", "Error");
453
- return;
293
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction' });
294
+ failedCount++;
295
+ continue;
454
296
  }
455
297
  if (strategyState.hasOpenPosition(asset, dexForLookup)) {
456
- outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists' } };
457
- endSignal("position_already_exists");
458
- return;
298
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists' });
299
+ continue;
459
300
  }
460
- // Risk guard per-open (real-time via MCP, with per-asset cooldown).
461
- // C11: outcome + reason stamped on signal.evaluated directly; the
462
- // separate risk_gate.evaluated log was redundant.
301
+ // Risk guard per-open (real-time via MCP, with per-asset cooldown)
463
302
  const gate = await this.context.riskGuard.checkGate(address, { candidateAsset: asset });
464
- signalSpan.setAttr("gate.outcome", gate.gate);
465
- signalSpan.setAttr("gate.reason", gate.reason);
466
303
  if (gate.gate !== 'OPEN') {
467
- outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}` } };
468
- endSignal(`rejected_by_gate_${gate.gate.toLowerCase()}`);
469
- return;
304
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}` });
305
+ continue;
470
306
  }
471
307
  // Leverage: LLM override > config-based resolution
472
308
  const llmLeverage = typeof signal.leverage === 'number' ? signal.leverage : undefined;
@@ -502,181 +338,122 @@ export class OpenPositionAction {
502
338
  slots: strategyState.config.slots,
503
339
  withdrawable,
504
340
  });
505
- outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured' } };
506
- endSignal("no_margin_configured", "Error");
507
- return;
341
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured' });
342
+ failedCount++;
343
+ continue;
508
344
  }
509
345
  const notionalValue = marginAmount * leverage;
510
346
  if (notionalValue < MIN_NOTIONAL_VALUE) {
511
347
  logger.warn(`Skipping ${asset}: notional value $${notionalValue.toFixed(2)} ` +
512
348
  `(margin $${marginAmount.toFixed(2)} × ${leverage}x) is below minimum $${MIN_NOTIONAL_VALUE}`);
513
- outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, notionalValue, success: false, reason: 'below_min_notional' } };
514
- endSignal("below_min_notional", "Error");
515
- return;
349
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, notionalValue, success: false, reason: 'below_min_notional' });
350
+ failedCount++;
351
+ continue;
516
352
  }
517
- // Free-margin pre-flight (P1): if we know free margin and it can't cover
518
- // this position's margin, skip rather than fire a create the engine will
519
- // reject (CREATE_INSUFFICIENT_FUNDS). The check is skipped when free
520
- // margin is unknown (withdrawable null), preserving prior behavior.
353
+ // Free-margin pre-flight (P1): if we know free margin and it can't cover this
354
+ // position's margin, skip rather than fire a create the engine will reject
355
+ // (CREATE_INSUFFICIENT_FUNDS). Skip the check when free margin is unknown.
521
356
  if (remainingMargin != null && remainingMargin < marginAmount) {
522
357
  logger.warn(`Skipping ${asset}: insufficient free margin ` +
523
358
  `($${remainingMargin.toFixed(2)} available < $${marginAmount.toFixed(2)} required)`);
524
- outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, requiredMarginAmount: marginAmount, availableMarginAmount: remainingMargin, success: true, reason: 'insufficient_margin' } };
525
- endSignal("insufficient_margin");
526
- return;
359
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, requiredMarginAmount: marginAmount, availableMarginAmount: remainingMargin, success: true, reason: 'insufficient_margin' });
360
+ continue;
527
361
  }
528
362
  const signalReason = signal.reason ?? signal.signalType ?? reasoning;
529
- // Mint position.id at intent — before order submission, after confidence + risk gates.
530
- // The OTEL ALS frame opened by withSignalSpanScope is per-signal, so the id is stamped
531
- // on signal.evaluated, action.open, and downstream logs without leaking across signals.
532
- const positionId = randomUUID();
533
- setPositionId(positionId);
534
- signalSpan.setAttr("position.id", positionId);
535
- const actionSpan = this.context.otel.startSpan("action.open", {
536
- parent: signalSpan,
537
- attrs: {
538
- "action.intent": "OPEN",
539
- "action.asset": asset,
540
- "action.direction": direction,
541
- "action.order_type": orderType,
542
- "action.dex": submittedDex,
543
- "action.leverage": leverage,
544
- "action.margin": marginAmount,
545
- "action.risk_gate": gate.gate,
546
- ...(typeof withdrawable === "number" ? { "action.withdrawable_balance": withdrawable } : {}),
547
- },
548
- });
549
- const createRequest = {
550
- strategyWalletAddress: address,
551
- orders: [
552
- {
553
- coin: asset,
554
- direction,
555
- leverage,
556
- marginAmount,
557
- orderType,
558
- ...(feeOptimizedLimitOptions ? { feeOptimizedLimitOptions } : {}),
559
- },
560
- ],
561
- reason: signalReason,
562
- };
563
- let submittedOrder;
564
- let resultSuccess = false;
565
- let signalEnded = false;
566
363
  try {
567
364
  logger.info('opening position', { wallet: address, asset, direction });
568
- // D1: the standalone `action.order_execution` span is gone — ack
569
- // latency + senpi metadata fold onto action.open directly.
570
- const submitStart = Date.now();
571
- let result;
572
- try {
573
- result = await this.context.senpi.createPosition(createRequest);
574
- }
575
- catch (err) {
576
- actionSpan.setAttr("action.ack_latency_ms", Date.now() - submitStart);
577
- actionSpan.recordException(err);
578
- const e = err;
579
- actionSpan.setAttr("exception.type", e?.name ?? "Error");
580
- actionSpan.setAttr("action.outcome", "exception");
581
- actionSpan.end({ statusCode: "Error" });
582
- throw err;
583
- }
584
- actionSpan.setAttr("action.ack_latency_ms", Date.now() - submitStart);
585
- // Source-of-truth blob for retrospection / fields not yet extracted.
586
- // Span attrs carry the queryable subset; the log body carries the
587
- // full response. Independent retention/sampling per the design.
588
- this.context.otel.emitLog({
589
- eventName: "senpi.order_response",
590
- parent: actionSpan,
591
- attrs: { "senpi.operation": "create_position" },
592
- body: { request: createRequest, response: result },
365
+ const result = await this.context.senpi.createPosition({
366
+ strategyWalletAddress: address,
367
+ orders: [
368
+ {
369
+ coin: asset,
370
+ direction,
371
+ leverage,
372
+ marginAmount,
373
+ orderType,
374
+ ...(feeOptimizedLimitOptions ? { feeOptimizedLimitOptions } : {}),
375
+ },
376
+ ],
377
+ reason: signalReason,
593
378
  });
594
- actionSpan.setAttrs(flattenOpenResultAttrs(result));
595
- actionSpan.end({ statusCode: result.success ? "Ok" : "Error" });
596
- resultSuccess = result.success;
597
- submittedOrder = result.orders[0];
598
379
  if (!result.success) {
599
380
  const orderErrors = result.orders
600
381
  .filter((o) => o.error)
601
382
  .map((o) => `${o.coin}: ${o.error}`)
602
383
  .join('; ');
603
384
  const detail = result.error || orderErrors || 'unknown error';
604
- outcome = { kind: 'failed', result: {
605
- asset, direction, dex: submittedDex, score: signalScore,
606
- leverage, marginAmount, orderType,
607
- success: false, reason: 'position_open_failed', error: detail,
608
- } };
609
- endSignal("position_open_failed", "Error");
610
- signalEnded = true;
611
- }
612
- else {
613
- outcome = { kind: 'submitted', marginConsumed: marginAmount, result: {
614
- asset, direction, dex: submittedDex, score: signalScore,
615
- leverage, marginAmount, orderType,
616
- orderId: submittedOrder?.orderId ?? null,
617
- entryPrice: submittedOrder?.entryPrice ?? null,
618
- size: submittedOrder?.size ?? null,
619
- filled: submittedOrder?.filled ?? null,
620
- success: true, reason: 'submitted',
621
- } };
622
- endSignal("accepted", "Ok");
623
- signalEnded = true;
385
+ results.push({
386
+ asset, direction, dex: submittedDex, score: signalScore,
387
+ leverage, marginAmount, orderType,
388
+ success: false, reason: 'position_open_failed', error: detail,
389
+ });
390
+ failedCount++;
391
+ continue;
624
392
  }
393
+ const submittedOrder = result.orders[0];
394
+ await this.context.hookSystem.fire({
395
+ type: HOOK_EVENT.ON_OPEN_ORDER_SUBMITTED,
396
+ runtimeName: event.runtimeName,
397
+ address,
398
+ data: {
399
+ asset,
400
+ direction,
401
+ leverage,
402
+ marginAmount,
403
+ orderType,
404
+ wallet: address,
405
+ dex: submittedDex,
406
+ reasoning: signalReason,
407
+ orderId: submittedOrder?.orderId,
408
+ entryPrice: submittedOrder?.entryPrice ?? null,
409
+ size: submittedOrder?.size ?? null,
410
+ filled: submittedOrder?.filled ?? null,
411
+ __correlationId: event.data.__correlationId,
412
+ },
413
+ timestamp: new Date().toISOString(),
414
+ });
415
+ results.push({
416
+ asset, direction, dex: submittedDex, score: signalScore,
417
+ leverage, marginAmount, orderType,
418
+ orderId: submittedOrder?.orderId ?? null,
419
+ entryPrice: submittedOrder?.entryPrice ?? null,
420
+ size: submittedOrder?.size ?? null,
421
+ filled: submittedOrder?.filled ?? null,
422
+ success: true, reason: 'submitted',
423
+ });
424
+ submittedCount++;
425
+ remainingSlots--;
426
+ if (remainingMargin != null)
427
+ remainingMargin -= marginAmount;
625
428
  }
626
429
  catch (error) {
627
430
  const message = error instanceof Error ? error.message : String(error);
628
431
  logger.error('open failed', { asset, err: message });
629
- if (!signalEnded) {
630
- signalSpan.recordException(error);
631
- const e = error;
632
- signalSpan.setAttr("exception.type", e?.name ?? "Error");
633
- endSignal("exception", "Error");
634
- signalEnded = true;
635
- }
636
- outcome = { kind: 'failed', result: {
637
- asset, direction, dex: submittedDex, score: signalScore,
638
- leverage, marginAmount, orderType,
639
- success: false, reason: 'exception', error: message,
640
- } };
641
- return;
642
- }
643
- // B12 — hook fire happens AFTER the signal verdict is final and
644
- // OUTSIDE the senpi.createPosition try/catch. A hook-fire failure
645
- // must not flip the signal's outcome to "exception" when the order
646
- // in fact succeeded. The hook failure is logged on the action span
647
- // but never propagates.
648
- if (resultSuccess) {
649
- try {
650
- await this.context.hookSystem.fire({
651
- type: HOOK_EVENT.ON_OPEN_ORDER_SUBMITTED,
652
- runtimeName: event.runtimeName,
653
- address,
654
- data: {
655
- asset,
656
- direction,
657
- leverage,
658
- marginAmount,
659
- orderType,
660
- wallet: address,
661
- dex: submittedDex,
662
- reasoning: signalReason,
663
- orderId: submittedOrder?.orderId,
664
- entryPrice: submittedOrder?.entryPrice ?? null,
665
- size: submittedOrder?.size ?? null,
666
- filled: submittedOrder?.filled ?? null,
667
- __correlationId: event.data.__correlationId,
668
- },
669
- timestamp: new Date().toISOString(),
670
- });
671
- }
672
- catch (hookErr) {
673
- logger.warn('hook fire ON_OPEN_ORDER_SUBMITTED failed', {
674
- asset, err: hookErr instanceof Error ? hookErr.message : String(hookErr),
675
- });
676
- }
432
+ results.push({
433
+ asset, direction, dex: submittedDex, score: signalScore,
434
+ leverage, marginAmount, orderType,
435
+ success: false, reason: 'exception', error: message,
436
+ });
437
+ failedCount++;
677
438
  }
678
- });
679
- return outcome;
439
+ }
440
+ const skippedCount = results.length - submittedCount - failedCount;
441
+ const actionResult = {
442
+ success: submittedCount > 0 || failedCount === 0,
443
+ action_type: this.actionType,
444
+ summary: `Open submit: ${submittedCount} submitted, ${failedCount} failed, ${skippedCount} skipped`,
445
+ data: {
446
+ results,
447
+ submittedCount,
448
+ failedCount,
449
+ skippedCount,
450
+ ...(meta ? { _decisionMeta: meta } : {}),
451
+ ...(riskSnapshot ? { _riskSnapshot: riskSnapshot } : {}),
452
+ _decisionPrompt: decisionPrompt ?? null,
453
+ },
454
+ };
455
+ emitTrackingActionResult(this.trackingCtx(), event, address, actionResult);
456
+ return actionResult;
680
457
  }
681
458
  }
682
459
  // ─── Factory & Manifest ─────────────────────────────────────────────────────