@senpi/runtime 1.1.0-dev.runtime-phase-2.20260609123811 → 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 (100) 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 +346 -123
  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/runtime-schema.d.ts +24 -0
  64. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  65. package/dist/runtime/runtime-schema.js +1 -0
  66. package/dist/runtime/runtime-schema.js.map +1 -1
  67. package/dist/runtime/runtime.d.ts +5 -0
  68. package/dist/runtime/runtime.d.ts.map +1 -1
  69. package/dist/runtime/runtime.js +127 -4
  70. package/dist/runtime/runtime.js.map +1 -1
  71. package/dist/scanners/engine/data-providers.d.ts +2 -0
  72. package/dist/scanners/engine/data-providers.d.ts.map +1 -1
  73. package/dist/scanners/engine/data-providers.js.map +1 -1
  74. package/dist/scanners/engine/engine.d.ts.map +1 -1
  75. package/dist/scanners/engine/engine.js +68 -9
  76. package/dist/scanners/engine/engine.js.map +1 -1
  77. package/dist/scanners/runtime-module.d.ts +2 -0
  78. package/dist/scanners/runtime-module.d.ts.map +1 -1
  79. package/dist/scanners/runtime-module.js +1 -0
  80. package/dist/scanners/runtime-module.js.map +1 -1
  81. package/dist/senpi/client.d.ts +2 -2
  82. package/dist/senpi/client.d.ts.map +1 -1
  83. package/dist/senpi/client.js +120 -20
  84. package/dist/senpi/client.js.map +1 -1
  85. package/dist/senpi/types.d.ts +68 -1
  86. package/dist/senpi/types.d.ts.map +1 -1
  87. package/dist/strategy/strategy-state.d.ts +1 -1
  88. package/dist/strategy/strategy-state.d.ts.map +1 -1
  89. package/dist/tracking/spans/plugin-span-tracker.d.ts.map +1 -1
  90. package/dist/tracking/spans/plugin-span-tracker.js +4 -24
  91. package/dist/tracking/spans/plugin-span-tracker.js.map +1 -1
  92. package/dist/tracking/spans/redact-pii.d.ts +10 -0
  93. package/dist/tracking/spans/redact-pii.d.ts.map +1 -1
  94. package/dist/tracking/spans/redact-pii.js +80 -2
  95. package/dist/tracking/spans/redact-pii.js.map +1 -1
  96. package/dist/types/runtime.d.ts +3 -0
  97. package/dist/types/runtime.d.ts.map +1 -1
  98. package/dist/types/strategy.d.ts +2 -2
  99. package/dist/types/strategy.d.ts.map +1 -1
  100. 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;
@@ -79,16 +125,7 @@ export class OpenPositionAction {
79
125
  error: 'strategy_address_mismatch',
80
126
  };
81
127
  }
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.
128
+ // Early exit: no slots
92
129
  if (!strategyState.getAvailableSlots?.()) {
93
130
  return {
94
131
  success: true,
@@ -120,15 +157,19 @@ export class OpenPositionAction {
120
157
  ...(this.config.params ?? {}),
121
158
  })
122
159
  : undefined;
160
+ const fullContext = { ...contextData, scanResult };
161
+ const contextKeys = Object.keys(fullContext);
162
+ const minConfidence = this.config.min_confidence ?? 1;
123
163
  // Get decision
124
164
  let decision;
125
165
  try {
126
166
  decision = await this.decisionEngine.decide(this.config.decision_mode, {
127
167
  prompt: interpolatedPrompt,
128
- context: { ...contextData, scanResult },
168
+ context: fullContext,
129
169
  model: this.config.decision_model,
130
170
  actionType: this.actionType,
131
171
  event,
172
+ minConfidence,
132
173
  });
133
174
  }
134
175
  catch (error) {
@@ -160,13 +201,29 @@ export class OpenPositionAction {
160
201
  },
161
202
  };
162
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);
163
213
  return errorResult;
164
214
  }
165
215
  logger.info('decision', decision);
166
216
  const trackingCtx = this.trackingCtx();
167
- const minConfidence = this.config.min_confidence ?? 1;
168
217
  const isBelowConfidence = decision.execute && decision.confidence < minConfidence;
169
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
+ });
170
227
  // Check decision
171
228
  if (!decision.execute) {
172
229
  logger.info('decision no execute', decision);
@@ -184,6 +241,7 @@ export class OpenPositionAction {
184
241
  },
185
242
  };
186
243
  emitTrackingActionResult(trackingCtx, event, address, rejectedResult);
244
+ emitBatchRejected(this.context.otel, scanResult, "rejected_by_decision", "OPEN", decAttrs);
187
245
  return rejectedResult;
188
246
  }
189
247
  // Check confidence threshold
@@ -203,12 +261,17 @@ export class OpenPositionAction {
203
261
  },
204
262
  };
205
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
+ });
206
268
  return belowConfResult;
207
269
  }
208
270
  // ── Unified signals path ──
209
271
  const signals = this.normalizeSignals(decision.payload);
210
272
  const meta = decisionMetaFromDecision(this.config, decision);
211
273
  if (!signals.length) {
274
+ emitBatchRejected(this.context.otel, scanResult, "invalid_payload", "OPEN", decAttrs);
212
275
  return {
213
276
  success: false,
214
277
  action_type: this.actionType,
@@ -217,7 +280,7 @@ export class OpenPositionAction {
217
280
  data: { _decisionMeta: meta, _decisionPrompt: interpolatedPrompt ?? null },
218
281
  };
219
282
  }
220
- return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt);
283
+ return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt, decAttrs);
221
284
  }
222
285
  normalizeSignals(payload) {
223
286
  if (Array.isArray(payload.signals))
@@ -232,8 +295,10 @@ export class OpenPositionAction {
232
295
  }
233
296
  return [];
234
297
  }
235
- async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt) {
298
+ async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt, decAttrs) {
236
299
  const address = event.address;
300
+ const eventScanResult = event.data?.scanResult;
301
+ const scannerId = eventScanResult?.scannerId;
237
302
  const orderType = resolveOpenOrderType(this.config.params?.order_type);
238
303
  const feeOptimizedLimitOptions = orderType === 'FEE_OPTIMIZED_LIMIT'
239
304
  ? resolveFeeOptimizedLimitOptions(this.config.params?.fee_optimized_limit_options)
@@ -271,38 +336,137 @@ export class OpenPositionAction {
271
336
  withdrawable = withdrawableResult;
272
337
  let remainingSlots = strategyState.getAvailableSlots?.() ?? 0;
273
338
  // Free-margin pre-flight budget (P1). Undefined when withdrawable is unknown
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.
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.
277
342
  let remainingMargin = withdrawable;
278
343
  const results = [];
279
344
  let submittedCount = 0;
280
345
  let failedCount = 0;
281
346
  for (const signal of sorted) {
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;
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
+ };
288
445
  if (remainingSlots <= 0) {
289
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots' });
290
- continue;
446
+ outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots' } };
447
+ endSignal("no_slots");
448
+ return;
291
449
  }
292
450
  if (!direction || (direction !== 'LONG' && direction !== 'SHORT')) {
293
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction' });
294
- failedCount++;
295
- continue;
451
+ outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction' } };
452
+ endSignal("invalid_direction", "Error");
453
+ return;
296
454
  }
297
455
  if (strategyState.hasOpenPosition(asset, dexForLookup)) {
298
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists' });
299
- 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;
300
459
  }
301
- // 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.
302
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);
303
466
  if (gate.gate !== 'OPEN') {
304
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}` });
305
- 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;
306
470
  }
307
471
  // Leverage: LLM override > config-based resolution
308
472
  const llmLeverage = typeof signal.leverage === 'number' ? signal.leverage : undefined;
@@ -338,122 +502,181 @@ export class OpenPositionAction {
338
502
  slots: strategyState.config.slots,
339
503
  withdrawable,
340
504
  });
341
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured' });
342
- failedCount++;
343
- 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;
344
508
  }
345
509
  const notionalValue = marginAmount * leverage;
346
510
  if (notionalValue < MIN_NOTIONAL_VALUE) {
347
511
  logger.warn(`Skipping ${asset}: notional value $${notionalValue.toFixed(2)} ` +
348
512
  `(margin $${marginAmount.toFixed(2)} × ${leverage}x) is below minimum $${MIN_NOTIONAL_VALUE}`);
349
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, notionalValue, success: false, reason: 'below_min_notional' });
350
- failedCount++;
351
- 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;
352
516
  }
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.
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.
356
521
  if (remainingMargin != null && remainingMargin < marginAmount) {
357
522
  logger.warn(`Skipping ${asset}: insufficient free margin ` +
358
523
  `($${remainingMargin.toFixed(2)} available < $${marginAmount.toFixed(2)} required)`);
359
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, requiredMarginAmount: marginAmount, availableMarginAmount: remainingMargin, success: true, reason: 'insufficient_margin' });
360
- continue;
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;
361
527
  }
362
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;
363
566
  try {
364
567
  logger.info('opening position', { wallet: address, asset, direction });
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,
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 },
378
593
  });
594
+ actionSpan.setAttrs(flattenOpenResultAttrs(result));
595
+ actionSpan.end({ statusCode: result.success ? "Ok" : "Error" });
596
+ resultSuccess = result.success;
597
+ submittedOrder = result.orders[0];
379
598
  if (!result.success) {
380
599
  const orderErrors = result.orders
381
600
  .filter((o) => o.error)
382
601
  .map((o) => `${o.coin}: ${o.error}`)
383
602
  .join('; ');
384
603
  const detail = result.error || orderErrors || 'unknown error';
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;
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;
392
624
  }
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;
428
625
  }
429
626
  catch (error) {
430
627
  const message = error instanceof Error ? error.message : String(error);
431
628
  logger.error('open failed', { asset, err: message });
432
- results.push({
433
- asset, direction, dex: submittedDex, score: signalScore,
434
- leverage, marginAmount, orderType,
435
- success: false, reason: 'exception', error: message,
436
- });
437
- 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;
438
642
  }
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;
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;
457
680
  }
458
681
  }
459
682
  // ─── Factory & Manifest ─────────────────────────────────────────────────────