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

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 (112) hide show
  1. package/dist/actions/close-position/close-position.action.d.ts +16 -0
  2. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  3. package/dist/actions/close-position/close-position.action.js +299 -38
  4. package/dist/actions/close-position/close-position.action.js.map +1 -1
  5. package/dist/actions/decision-engine.d.ts +2 -0
  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 +3 -0
  9. package/dist/actions/llm-decision/factory.d.ts.map +1 -1
  10. package/dist/actions/llm-decision/factory.js +1 -0
  11. package/dist/actions/llm-decision/factory.js.map +1 -1
  12. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts +13 -0
  13. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts.map +1 -1
  14. package/dist/actions/llm-decision/openclaw-llm-decision.js +195 -68
  15. package/dist/actions/llm-decision/openclaw-llm-decision.js.map +1 -1
  16. package/dist/actions/open-position/open-position.action.d.ts +12 -0
  17. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  18. package/dist/actions/open-position/open-position.action.js +356 -109
  19. package/dist/actions/open-position/open-position.action.js.map +1 -1
  20. package/dist/actions/otel-helpers.d.ts +65 -0
  21. package/dist/actions/otel-helpers.d.ts.map +1 -0
  22. package/dist/actions/otel-helpers.js +122 -0
  23. package/dist/actions/otel-helpers.js.map +1 -0
  24. package/dist/actions/position-tracker/position-tracker.action.d.ts +6 -0
  25. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  26. package/dist/actions/position-tracker/position-tracker.action.js +48 -0
  27. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  28. package/dist/otel/context.d.ts +24 -0
  29. package/dist/otel/context.d.ts.map +1 -0
  30. package/dist/otel/context.js +29 -0
  31. package/dist/otel/context.js.map +1 -0
  32. package/dist/otel/emitter.d.ts +73 -0
  33. package/dist/otel/emitter.d.ts.map +1 -0
  34. package/dist/otel/emitter.js +248 -0
  35. package/dist/otel/emitter.js.map +1 -0
  36. package/dist/otel/encode.d.ts +48 -0
  37. package/dist/otel/encode.d.ts.map +1 -0
  38. package/dist/otel/encode.js +100 -0
  39. package/dist/otel/encode.js.map +1 -0
  40. package/dist/otel/index.d.ts +13 -0
  41. package/dist/otel/index.d.ts.map +1 -0
  42. package/dist/otel/index.js +10 -0
  43. package/dist/otel/index.js.map +1 -0
  44. package/dist/otel/transport.d.ts +57 -0
  45. package/dist/otel/transport.d.ts.map +1 -0
  46. package/dist/otel/transport.js +208 -0
  47. package/dist/otel/transport.js.map +1 -0
  48. package/dist/otel/types.d.ts +62 -0
  49. package/dist/otel/types.d.ts.map +1 -0
  50. package/dist/otel/types.js +2 -0
  51. package/dist/otel/types.js.map +1 -0
  52. package/dist/runtime/create-actions.d.ts.map +1 -1
  53. package/dist/runtime/create-actions.js +2 -0
  54. package/dist/runtime/create-actions.js.map +1 -1
  55. package/dist/runtime/create-runtime-context.d.ts +2 -0
  56. package/dist/runtime/create-runtime-context.d.ts.map +1 -1
  57. package/dist/runtime/create-runtime-context.js +1 -0
  58. package/dist/runtime/create-runtime-context.js.map +1 -1
  59. package/dist/runtime/create-scanner-module.d.ts +1 -1
  60. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  61. package/dist/runtime/create-scanner-module.js +2 -1
  62. package/dist/runtime/create-scanner-module.js.map +1 -1
  63. package/dist/runtime/risk-guard.d.ts +10 -0
  64. package/dist/runtime/risk-guard.d.ts.map +1 -1
  65. package/dist/runtime/risk-guard.js +6 -12
  66. package/dist/runtime/risk-guard.js.map +1 -1
  67. package/dist/runtime/runtime-schema.d.ts +24 -0
  68. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  69. package/dist/runtime/runtime-schema.js +1 -0
  70. package/dist/runtime/runtime-schema.js.map +1 -1
  71. package/dist/runtime/runtime.d.ts +5 -0
  72. package/dist/runtime/runtime.d.ts.map +1 -1
  73. package/dist/runtime/runtime.js +140 -4
  74. package/dist/runtime/runtime.js.map +1 -1
  75. package/dist/runtime/strategy-lifecycle.d.ts +45 -0
  76. package/dist/runtime/strategy-lifecycle.d.ts.map +1 -0
  77. package/dist/runtime/strategy-lifecycle.js +117 -0
  78. package/dist/runtime/strategy-lifecycle.js.map +1 -0
  79. package/dist/scanners/engine/data-providers.d.ts +2 -0
  80. package/dist/scanners/engine/data-providers.d.ts.map +1 -1
  81. package/dist/scanners/engine/data-providers.js.map +1 -1
  82. package/dist/scanners/engine/engine.d.ts.map +1 -1
  83. package/dist/scanners/engine/engine.js +68 -9
  84. package/dist/scanners/engine/engine.js.map +1 -1
  85. package/dist/scanners/runtime-module.d.ts +2 -0
  86. package/dist/scanners/runtime-module.d.ts.map +1 -1
  87. package/dist/scanners/runtime-module.js +1 -0
  88. package/dist/scanners/runtime-module.js.map +1 -1
  89. package/dist/senpi/client.d.ts +2 -2
  90. package/dist/senpi/client.d.ts.map +1 -1
  91. package/dist/senpi/client.js +120 -20
  92. package/dist/senpi/client.js.map +1 -1
  93. package/dist/senpi/types.d.ts +68 -1
  94. package/dist/senpi/types.d.ts.map +1 -1
  95. package/dist/strategy/strategy-state.d.ts +1 -1
  96. package/dist/strategy/strategy-state.d.ts.map +1 -1
  97. package/dist/tracking/spans/plugin-span-tracker.d.ts.map +1 -1
  98. package/dist/tracking/spans/plugin-span-tracker.js +4 -24
  99. package/dist/tracking/spans/plugin-span-tracker.js.map +1 -1
  100. package/dist/tracking/spans/redact-pii.d.ts +10 -0
  101. package/dist/tracking/spans/redact-pii.d.ts.map +1 -1
  102. package/dist/tracking/spans/redact-pii.js +80 -2
  103. package/dist/tracking/spans/redact-pii.js.map +1 -1
  104. package/dist/types/hooks.d.ts +1 -0
  105. package/dist/types/hooks.d.ts.map +1 -1
  106. package/dist/types/hooks.js +1 -0
  107. package/dist/types/hooks.js.map +1 -1
  108. package/dist/types/runtime.d.ts +3 -0
  109. package/dist/types/runtime.d.ts.map +1 -1
  110. package/dist/types/strategy.d.ts +2 -2
  111. package/dist/types/strategy.d.ts.map +1 -1
  112. package/package.json +1 -1
@@ -8,11 +8,57 @@ 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';
11
13
  const logger = createLogger("[actions][open-position]");
12
14
  /** Hyperliquid minimum notional value (margin × leverage) required to open a position. */
13
15
  const MIN_NOTIONAL_VALUE = 10;
14
16
  /** Minimum leverage floor — positions cannot go below 1x. */
15
17
  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
+ }
16
62
  // ─── Open Position Action ───────────────────────────────────────────────────
17
63
  export class OpenPositionAction {
18
64
  config;
@@ -111,15 +157,19 @@ export class OpenPositionAction {
111
157
  ...(this.config.params ?? {}),
112
158
  })
113
159
  : undefined;
160
+ const fullContext = { ...contextData, scanResult };
161
+ const contextKeys = Object.keys(fullContext);
162
+ const minConfidence = this.config.min_confidence ?? 1;
114
163
  // Get decision
115
164
  let decision;
116
165
  try {
117
166
  decision = await this.decisionEngine.decide(this.config.decision_mode, {
118
167
  prompt: interpolatedPrompt,
119
- context: { ...contextData, scanResult },
168
+ context: fullContext,
120
169
  model: this.config.decision_model,
121
170
  actionType: this.actionType,
122
171
  event,
172
+ minConfidence,
123
173
  });
124
174
  }
125
175
  catch (error) {
@@ -151,13 +201,29 @@ export class OpenPositionAction {
151
201
  },
152
202
  };
153
203
  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);
154
213
  return errorResult;
155
214
  }
156
215
  logger.info('decision', decision);
157
216
  const trackingCtx = this.trackingCtx();
158
- const minConfidence = this.config.min_confidence ?? 1;
159
217
  const isBelowConfidence = decision.execute && decision.confidence < minConfidence;
160
218
  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
+ });
161
227
  // Check decision
162
228
  if (!decision.execute) {
163
229
  logger.info('decision no execute', decision);
@@ -175,6 +241,7 @@ export class OpenPositionAction {
175
241
  },
176
242
  };
177
243
  emitTrackingActionResult(trackingCtx, event, address, rejectedResult);
244
+ emitBatchRejected(this.context.otel, scanResult, "rejected_by_decision", "OPEN", decAttrs);
178
245
  return rejectedResult;
179
246
  }
180
247
  // Check confidence threshold
@@ -194,12 +261,17 @@ export class OpenPositionAction {
194
261
  },
195
262
  };
196
263
  emitTrackingActionResult(trackingCtx, event, address, belowConfResult);
264
+ emitBatchRejected(this.context.otel, scanResult, "rejected_by_confidence", "OPEN", {
265
+ ...decAttrs,
266
+ "decision.skipped_reason": "below_confidence",
267
+ });
197
268
  return belowConfResult;
198
269
  }
199
270
  // ── Unified signals path ──
200
271
  const signals = this.normalizeSignals(decision.payload);
201
272
  const meta = decisionMetaFromDecision(this.config, decision);
202
273
  if (!signals.length) {
274
+ emitBatchRejected(this.context.otel, scanResult, "invalid_payload", "OPEN", decAttrs);
203
275
  return {
204
276
  success: false,
205
277
  action_type: this.actionType,
@@ -208,7 +280,7 @@ export class OpenPositionAction {
208
280
  data: { _decisionMeta: meta, _decisionPrompt: interpolatedPrompt ?? null },
209
281
  };
210
282
  }
211
- return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt);
283
+ return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt, decAttrs);
212
284
  }
213
285
  normalizeSignals(payload) {
214
286
  if (Array.isArray(payload.signals))
@@ -223,8 +295,10 @@ export class OpenPositionAction {
223
295
  }
224
296
  return [];
225
297
  }
226
- async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt) {
298
+ async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt, decAttrs) {
227
299
  const address = event.address;
300
+ const eventScanResult = event.data?.scanResult;
301
+ const scannerId = eventScanResult?.scannerId;
228
302
  const orderType = resolveOpenOrderType(this.config.params?.order_type);
229
303
  const feeOptimizedLimitOptions = orderType === 'FEE_OPTIMIZED_LIMIT'
230
304
  ? resolveFeeOptimizedLimitOptions(this.config.params?.fee_optimized_limit_options)
@@ -251,45 +325,148 @@ export class OpenPositionAction {
251
325
  return undefined;
252
326
  })(),
253
327
  (async () => {
254
- if (strategyState.config.marginPct != null) {
255
- const chState = strategyState.getClearinghouseState()
256
- ?? await strategyState.fetchClearinghouseState();
257
- return chState?.withdrawable;
258
- }
259
- return undefined;
328
+ // Fetch withdrawable regardless of marginPct: it sizes margin_pct AND
329
+ // feeds the free-margin pre-flight (P1) below.
330
+ const chState = strategyState.getClearinghouseState()
331
+ ?? await strategyState.fetchClearinghouseState();
332
+ return chState?.withdrawable;
260
333
  })(),
261
334
  ]);
262
335
  riskSnapshot = riskSnapResult;
263
336
  withdrawable = withdrawableResult;
264
337
  let remainingSlots = strategyState.getAvailableSlots?.() ?? 0;
338
+ // 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.
342
+ let remainingMargin = withdrawable;
265
343
  const results = [];
266
344
  let submittedCount = 0;
267
345
  let failedCount = 0;
268
346
  for (const signal of sorted) {
269
- const asset = signal.asset;
270
- const direction = signal.direction;
271
- const signalScore = signal.score;
272
- const signalDex = signal.dex;
273
- const submittedDex = typeof signalDex === "string" && signalDex.trim() !== "" ? signalDex.trim() : "main";
274
- const dexForLookup = submittedDex === "main" ? undefined : submittedDex;
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
+ };
275
445
  if (remainingSlots <= 0) {
276
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots' });
277
- continue;
446
+ outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots' } };
447
+ endSignal("no_slots");
448
+ return;
278
449
  }
279
450
  if (!direction || (direction !== 'LONG' && direction !== 'SHORT')) {
280
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction' });
281
- failedCount++;
282
- continue;
451
+ outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction' } };
452
+ endSignal("invalid_direction", "Error");
453
+ return;
283
454
  }
284
455
  if (strategyState.hasOpenPosition(asset, dexForLookup)) {
285
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists' });
286
- continue;
456
+ outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists' } };
457
+ endSignal("position_already_exists");
458
+ return;
287
459
  }
288
- // Risk guard per-open (real-time via MCP, with per-asset cooldown)
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.
289
463
  const gate = await this.context.riskGuard.checkGate(address, { candidateAsset: asset });
464
+ signalSpan.setAttr("gate.outcome", gate.gate);
465
+ signalSpan.setAttr("gate.reason", gate.reason);
290
466
  if (gate.gate !== 'OPEN') {
291
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}` });
292
- continue;
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;
293
470
  }
294
471
  // Leverage: LLM override > config-based resolution
295
472
  const llmLeverage = typeof signal.leverage === 'number' ? signal.leverage : undefined;
@@ -325,111 +502,181 @@ export class OpenPositionAction {
325
502
  slots: strategyState.config.slots,
326
503
  withdrawable,
327
504
  });
328
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured' });
329
- failedCount++;
330
- continue;
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;
331
508
  }
332
509
  const notionalValue = marginAmount * leverage;
333
510
  if (notionalValue < MIN_NOTIONAL_VALUE) {
334
511
  logger.warn(`Skipping ${asset}: notional value $${notionalValue.toFixed(2)} ` +
335
512
  `(margin $${marginAmount.toFixed(2)} × ${leverage}x) is below minimum $${MIN_NOTIONAL_VALUE}`);
336
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, notionalValue, success: false, reason: 'below_min_notional' });
337
- failedCount++;
338
- continue;
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;
516
+ }
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.
521
+ if (remainingMargin != null && remainingMargin < marginAmount) {
522
+ logger.warn(`Skipping ${asset}: insufficient free margin ` +
523
+ `($${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;
339
527
  }
340
528
  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;
341
566
  try {
342
567
  logger.info('opening position', { wallet: address, asset, direction });
343
- const result = await this.context.senpi.createPosition({
344
- strategyWalletAddress: address,
345
- orders: [
346
- {
347
- coin: asset,
348
- direction,
349
- leverage,
350
- marginAmount,
351
- orderType,
352
- ...(feeOptimizedLimitOptions ? { feeOptimizedLimitOptions } : {}),
353
- },
354
- ],
355
- reason: signalReason,
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 },
356
593
  });
594
+ actionSpan.setAttrs(flattenOpenResultAttrs(result));
595
+ actionSpan.end({ statusCode: result.success ? "Ok" : "Error" });
596
+ resultSuccess = result.success;
597
+ submittedOrder = result.orders[0];
357
598
  if (!result.success) {
358
599
  const orderErrors = result.orders
359
600
  .filter((o) => o.error)
360
601
  .map((o) => `${o.coin}: ${o.error}`)
361
602
  .join('; ');
362
603
  const detail = result.error || orderErrors || 'unknown error';
363
- results.push({
364
- asset, direction, dex: submittedDex, score: signalScore,
365
- leverage, marginAmount, orderType,
366
- success: false, reason: 'position_open_failed', error: detail,
367
- });
368
- failedCount++;
369
- continue;
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;
370
624
  }
371
- const submittedOrder = result.orders[0];
372
- await this.context.hookSystem.fire({
373
- type: HOOK_EVENT.ON_OPEN_ORDER_SUBMITTED,
374
- runtimeName: event.runtimeName,
375
- address,
376
- data: {
377
- asset,
378
- direction,
379
- leverage,
380
- marginAmount,
381
- orderType,
382
- wallet: address,
383
- dex: submittedDex,
384
- reasoning: signalReason,
385
- orderId: submittedOrder?.orderId,
386
- entryPrice: submittedOrder?.entryPrice ?? null,
387
- size: submittedOrder?.size ?? null,
388
- filled: submittedOrder?.filled ?? null,
389
- __correlationId: event.data.__correlationId,
390
- },
391
- timestamp: new Date().toISOString(),
392
- });
393
- results.push({
394
- asset, direction, dex: submittedDex, score: signalScore,
395
- leverage, marginAmount, orderType,
396
- orderId: submittedOrder?.orderId ?? null,
397
- entryPrice: submittedOrder?.entryPrice ?? null,
398
- size: submittedOrder?.size ?? null,
399
- filled: submittedOrder?.filled ?? null,
400
- success: true, reason: 'submitted',
401
- });
402
- submittedCount++;
403
- remainingSlots--;
404
625
  }
405
626
  catch (error) {
406
627
  const message = error instanceof Error ? error.message : String(error);
407
628
  logger.error('open failed', { asset, err: message });
408
- results.push({
409
- asset, direction, dex: submittedDex, score: signalScore,
410
- leverage, marginAmount, orderType,
411
- success: false, reason: 'exception', error: message,
412
- });
413
- failedCount++;
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;
414
642
  }
415
- }
416
- const skippedCount = results.length - submittedCount - failedCount;
417
- const actionResult = {
418
- success: submittedCount > 0 || failedCount === 0,
419
- action_type: this.actionType,
420
- summary: `Open submit: ${submittedCount} submitted, ${failedCount} failed, ${skippedCount} skipped`,
421
- data: {
422
- results,
423
- submittedCount,
424
- failedCount,
425
- skippedCount,
426
- ...(meta ? { _decisionMeta: meta } : {}),
427
- ...(riskSnapshot ? { _riskSnapshot: riskSnapshot } : {}),
428
- _decisionPrompt: decisionPrompt ?? null,
429
- },
430
- };
431
- emitTrackingActionResult(this.trackingCtx(), event, address, actionResult);
432
- return actionResult;
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
+ }
677
+ }
678
+ });
679
+ return outcome;
433
680
  }
434
681
  }
435
682
  // ─── Factory & Manifest ─────────────────────────────────────────────────────