@senpi/runtime 3.0.6-dev.runtime-phase-2.20260630134145 → 3.0.6-dev.runtime-phase-2.20260630134327

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 (140) hide show
  1. package/dist/actions/close-position/close-position.action.d.ts +1 -1
  2. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  3. package/dist/actions/close-position/close-position.action.js +109 -78
  4. package/dist/actions/close-position/close-position.action.js.map +1 -1
  5. package/dist/actions/decision-engine.d.ts.map +1 -1
  6. package/dist/actions/decision-engine.js +40 -28
  7. package/dist/actions/decision-engine.js.map +1 -1
  8. package/dist/actions/event-derivation.d.ts +60 -0
  9. package/dist/actions/event-derivation.d.ts.map +1 -0
  10. package/dist/actions/event-derivation.js +179 -0
  11. package/dist/actions/event-derivation.js.map +1 -0
  12. package/dist/actions/llm-decision/anthropic-llm-decision.d.ts.map +1 -1
  13. package/dist/actions/llm-decision/anthropic-llm-decision.js +1 -2
  14. package/dist/actions/llm-decision/anthropic-llm-decision.js.map +1 -1
  15. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts.map +1 -1
  16. package/dist/actions/llm-decision/openclaw-llm-decision.js +1 -2
  17. package/dist/actions/llm-decision/openclaw-llm-decision.js.map +1 -1
  18. package/dist/actions/open-position/open-position.action.d.ts +1 -1
  19. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  20. package/dist/actions/open-position/open-position.action.js +283 -217
  21. package/dist/actions/open-position/open-position.action.js.map +1 -1
  22. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  23. package/dist/actions/position-tracker/position-tracker.action.js +2 -4
  24. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  25. package/dist/actions/tracking-helpers.d.ts +41 -3
  26. package/dist/actions/tracking-helpers.d.ts.map +1 -1
  27. package/dist/actions/tracking-helpers.js +100 -11
  28. package/dist/actions/tracking-helpers.js.map +1 -1
  29. package/dist/dsl/monitor/create-monitor.d.ts +3 -0
  30. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  31. package/dist/dsl/monitor/create-monitor.js +76 -28
  32. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  33. package/dist/dsl/monitor/process-one-position.d.ts +2 -1
  34. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  35. package/dist/dsl/monitor/process-one-position.js +84 -38
  36. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  37. package/dist/dsl/plugin/index.d.ts +10 -0
  38. package/dist/dsl/plugin/index.d.ts.map +1 -1
  39. package/dist/dsl/plugin/index.js +42 -4
  40. package/dist/dsl/plugin/index.js.map +1 -1
  41. package/dist/dsl/plugin/options.d.ts +3 -0
  42. package/dist/dsl/plugin/options.d.ts.map +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/recording/otel/attributes.d.ts +2 -0
  47. package/dist/recording/otel/attributes.d.ts.map +1 -1
  48. package/dist/recording/otel/attributes.js +1 -1
  49. package/dist/recording/otel/attributes.js.map +1 -1
  50. package/dist/recording/otel/mappers.d.ts.map +1 -1
  51. package/dist/recording/otel/mappers.js +1 -0
  52. package/dist/recording/otel/mappers.js.map +1 -1
  53. package/dist/recording/records.d.ts +7 -1
  54. package/dist/recording/records.d.ts.map +1 -1
  55. package/dist/recording/wiring/record-builders.d.ts.map +1 -1
  56. package/dist/recording/wiring/record-builders.js +5 -2
  57. package/dist/recording/wiring/record-builders.js.map +1 -1
  58. package/dist/runtime/auto-update/coordinator.js +2 -2
  59. package/dist/runtime/auto-update/coordinator.js.map +1 -1
  60. package/dist/runtime/create-actions.d.ts.map +1 -1
  61. package/dist/runtime/create-actions.js +40 -15
  62. package/dist/runtime/create-actions.js.map +1 -1
  63. package/dist/runtime/create-scanner-module.d.ts +2 -1
  64. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  65. package/dist/runtime/create-scanner-module.js +2 -1
  66. package/dist/runtime/create-scanner-module.js.map +1 -1
  67. package/dist/runtime/domain-events.d.ts +43 -0
  68. package/dist/runtime/domain-events.d.ts.map +1 -0
  69. package/dist/runtime/domain-events.js +181 -0
  70. package/dist/runtime/domain-events.js.map +1 -0
  71. package/dist/runtime/lifecycle-events.d.ts +43 -0
  72. package/dist/runtime/lifecycle-events.d.ts.map +1 -0
  73. package/dist/runtime/lifecycle-events.js +139 -0
  74. package/dist/runtime/lifecycle-events.js.map +1 -0
  75. package/dist/runtime/notifications.js +2 -2
  76. package/dist/runtime/notifications.js.map +1 -1
  77. package/dist/runtime/runtime-schema.d.ts +120 -120
  78. package/dist/runtime/runtime.d.ts +11 -0
  79. package/dist/runtime/runtime.d.ts.map +1 -1
  80. package/dist/runtime/runtime.js +159 -88
  81. package/dist/runtime/runtime.js.map +1 -1
  82. package/dist/runtime/strategy-lifecycle.d.ts.map +1 -1
  83. package/dist/runtime/strategy-lifecycle.js +13 -1
  84. package/dist/runtime/strategy-lifecycle.js.map +1 -1
  85. package/dist/runtime-api/routes/signals.d.ts +1 -1
  86. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  87. package/dist/runtime-api/routes/signals.js +2 -8
  88. package/dist/runtime-api/routes/signals.js.map +1 -1
  89. package/dist/scanners/engine/data-providers.d.ts +3 -0
  90. package/dist/scanners/engine/data-providers.d.ts.map +1 -1
  91. package/dist/scanners/engine/data-providers.js.map +1 -1
  92. package/dist/scanners/engine/engine.d.ts +18 -0
  93. package/dist/scanners/engine/engine.d.ts.map +1 -1
  94. package/dist/scanners/engine/engine.js +93 -2
  95. package/dist/scanners/engine/engine.js.map +1 -1
  96. package/dist/scanners/engine/input-resolver.d.ts.map +1 -1
  97. package/dist/scanners/engine/input-resolver.js +9 -7
  98. package/dist/scanners/engine/input-resolver.js.map +1 -1
  99. package/dist/scanners/runtime-module.d.ts +3 -0
  100. package/dist/scanners/runtime-module.d.ts.map +1 -1
  101. package/dist/scanners/runtime-module.js +1 -0
  102. package/dist/scanners/runtime-module.js.map +1 -1
  103. package/dist/senpi/client.d.ts.map +1 -1
  104. package/dist/senpi/client.js +7 -5
  105. package/dist/senpi/client.js.map +1 -1
  106. package/dist/skills-manager/coordinator.js +3 -3
  107. package/dist/skills-manager/coordinator.js.map +1 -1
  108. package/dist/telemetry/otel-sdk.d.ts +8 -2
  109. package/dist/telemetry/otel-sdk.d.ts.map +1 -1
  110. package/dist/telemetry/otel-sdk.js +17 -4
  111. package/dist/telemetry/otel-sdk.js.map +1 -1
  112. package/dist/telemetry/trace-identity.d.ts +32 -0
  113. package/dist/telemetry/trace-identity.d.ts.map +1 -0
  114. package/dist/telemetry/trace-identity.js +59 -0
  115. package/dist/telemetry/trace-identity.js.map +1 -0
  116. package/dist/telemetry/tracing.d.ts +32 -0
  117. package/dist/telemetry/tracing.d.ts.map +1 -0
  118. package/dist/telemetry/tracing.js +70 -0
  119. package/dist/telemetry/tracing.js.map +1 -0
  120. package/dist/tracking/context.d.ts +6 -0
  121. package/dist/tracking/context.d.ts.map +1 -1
  122. package/dist/tracking/context.js.map +1 -1
  123. package/dist/tracking/spans/plugin-span-tracker.d.ts.map +1 -1
  124. package/dist/tracking/spans/plugin-span-tracker.js +12 -12
  125. package/dist/tracking/spans/plugin-span-tracker.js.map +1 -1
  126. package/dist/tracking/spans/redact-pii.d.ts +3 -3
  127. package/dist/tracking/spans/redact-pii.d.ts.map +1 -1
  128. package/dist/tracking/spans/redact-pii.js +6 -7
  129. package/dist/tracking/spans/redact-pii.js.map +1 -1
  130. package/dist/types/scanner.d.ts +2 -0
  131. package/dist/types/scanner.d.ts.map +1 -1
  132. package/dist/utils/event-catalog.d.ts +269 -0
  133. package/dist/utils/event-catalog.d.ts.map +1 -0
  134. package/dist/utils/event-catalog.js +91 -0
  135. package/dist/utils/event-catalog.js.map +1 -0
  136. package/dist/utils/logger.d.ts +58 -5
  137. package/dist/utils/logger.d.ts.map +1 -1
  138. package/dist/utils/logger.js +359 -27
  139. package/dist/utils/logger.js.map +1 -1
  140. package/package.json +5 -1
@@ -8,7 +8,9 @@ import { resolveMarginWithSource } from './resolve-margin.js';
8
8
  import { createLogger } from '../../utils/logger.js';
9
9
  import { positionRegistryKey } from '../../tracking/position-id-registry.js';
10
10
  import { resolveOpenOrderType, resolveFeeOptimizedLimitOptions } from '../../utils/resolve-order-type.js';
11
- import { emitTrackingDecision, emitTrackingActionResult, emitOrderPlaced, emitOrderFilled, emitOrderFailed } from '../tracking-helpers.js';
11
+ import { emitTrackingDecision, emitTrackingActionResult, emitOrderPlaced, emitOrderFilled, emitOrderFailed, buildActionEventContext } from '../tracking-helpers.js';
12
+ import { withSpan } from '../../telemetry/tracing.js';
13
+ import { SpanStatusCode } from '@opentelemetry/api';
12
14
  const logger = createLogger("[actions][open-position]");
13
15
  /** Hyperliquid minimum notional value (margin × leverage) required to open a position. */
14
16
  const MIN_NOTIONAL_VALUE = 10;
@@ -30,15 +32,16 @@ export class OpenPositionAction {
30
32
  this.decisionEngine.register(new RuleDecisionStrategy());
31
33
  this.decisionEngine.register(new NoDecisionStrategy());
32
34
  }
33
- trackingCtx() {
34
- return {
35
+ eventCtx() {
36
+ return buildActionEventContext({
35
37
  hookSystem: this.context.hookSystem,
36
38
  actionName: this.name,
37
39
  actionType: this.actionType,
38
40
  decisionMode: this.config.decision_mode,
39
41
  decisionModel: this.config.decision_model,
40
42
  minConfidence: this.config.min_confidence,
41
- };
43
+ positionIdRegistry: this.context.positionIdRegistry,
44
+ });
42
45
  }
43
46
  async handle(event) {
44
47
  // Telemetry: pass start = "action began handling the scan event" (NOT "LLM call dispatched").
@@ -145,8 +148,8 @@ export class OpenPositionAction {
145
148
  model: this.config.decision_model,
146
149
  });
147
150
  await this.context.notifications.notify(notificationMessage).catch(() => { });
148
- const trackingCtx = this.trackingCtx();
149
- emitTrackingDecision(trackingCtx, event, address, scanResult, { error: message }, undefined, interpolatedPrompt, contextData);
151
+ const eventCtx = this.eventCtx();
152
+ emitTrackingDecision(eventCtx, event, address, scanResult, { error: message }, undefined, interpolatedPrompt, contextData);
150
153
  const errorResult = {
151
154
  success: false,
152
155
  action_type: this.actionType,
@@ -165,15 +168,15 @@ export class OpenPositionAction {
165
168
  _decisionPrompt: interpolatedPrompt ?? null,
166
169
  },
167
170
  };
168
- emitTrackingActionResult(trackingCtx, event, address, errorResult, { scanResult, decisionStartedAt, decisionEndedAt });
171
+ emitTrackingActionResult(eventCtx, event, address, errorResult, { scanResult, decisionStartedAt, decisionEndedAt });
169
172
  return errorResult;
170
173
  }
171
174
  logger.info('decision', decision);
172
- const trackingCtx = this.trackingCtx();
175
+ const eventCtx = this.eventCtx();
173
176
  const minConfidence = this.config.min_confidence ?? 1;
174
177
  const trackingExtras = { scanResult, decisionStartedAt, decisionEndedAt, minConfidence };
175
178
  const isBelowConfidence = decision.execute && decision.confidence < minConfidence;
176
- emitTrackingDecision(trackingCtx, event, address, scanResult, decision, minConfidence, interpolatedPrompt, contextData, isBelowConfidence ? 'below_confidence' : undefined);
179
+ emitTrackingDecision(eventCtx, event, address, scanResult, decision, minConfidence, interpolatedPrompt, contextData, isBelowConfidence ? 'below_confidence' : undefined);
177
180
  // Check decision
178
181
  if (!decision.execute) {
179
182
  logger.info('decision no execute', decision);
@@ -190,7 +193,7 @@ export class OpenPositionAction {
190
193
  _decisionPrompt: interpolatedPrompt ?? null,
191
194
  },
192
195
  };
193
- emitTrackingActionResult(trackingCtx, event, address, rejectedResult, trackingExtras);
196
+ emitTrackingActionResult(eventCtx, event, address, rejectedResult, trackingExtras);
194
197
  return rejectedResult;
195
198
  }
196
199
  // Check confidence threshold
@@ -209,7 +212,7 @@ export class OpenPositionAction {
209
212
  _decisionPrompt: interpolatedPrompt ?? null,
210
213
  },
211
214
  };
212
- emitTrackingActionResult(trackingCtx, event, address, belowConfResult, trackingExtras);
215
+ emitTrackingActionResult(eventCtx, event, address, belowConfResult, trackingExtras);
213
216
  return belowConfResult;
214
217
  }
215
218
  // ── Unified signals path ──
@@ -223,7 +226,7 @@ export class OpenPositionAction {
223
226
  error: 'invalid_payload',
224
227
  data: { _decisionMeta: meta, _decisionPrompt: interpolatedPrompt ?? null },
225
228
  };
226
- emitTrackingActionResult(trackingCtx, event, address, invalidPayloadResult, trackingExtras);
229
+ emitTrackingActionResult(eventCtx, event, address, invalidPayloadResult, trackingExtras);
227
230
  return invalidPayloadResult;
228
231
  }
229
232
  return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt, trackingExtras);
@@ -310,7 +313,7 @@ export class OpenPositionAction {
310
313
  _decisionPrompt: decisionPrompt ?? null,
311
314
  },
312
315
  };
313
- emitTrackingActionResult(this.trackingCtx(), event, address, skipResult, trackingExtras);
316
+ emitTrackingActionResult(this.eventCtx(), event, address, skipResult, trackingExtras);
314
317
  return skipResult;
315
318
  }
316
319
  const withdrawable = clearinghouseView.withdrawable;
@@ -324,238 +327,301 @@ export class OpenPositionAction {
324
327
  let submittedCount = 0;
325
328
  let failedCount = 0;
326
329
  for (const signal of sorted) {
327
- // Telemetry: per-signal work window — every result entry below carries startedAt/endedAt.
328
- const signalStartedAt = Date.now();
329
330
  const asset = signal.asset;
330
331
  const direction = signal.direction;
331
332
  const signalScore = signal.score;
332
- const signalDex = signal.dex;
333
- const submittedDex = typeof signalDex === "string" && signalDex.trim() !== "" ? signalDex.trim() : "main";
334
- const dexForLookup = submittedDex === "main" ? undefined : submittedDex;
335
- if (remainingSlots <= 0) {
336
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots', startedAt: signalStartedAt, endedAt: Date.now() });
337
- continue;
338
- }
339
- if (!direction || (direction !== 'LONG' && direction !== 'SHORT')) {
340
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction', startedAt: signalStartedAt, endedAt: Date.now() });
341
- failedCount++;
342
- continue;
343
- }
344
- if (strategyState.hasOpenPosition(asset, dexForLookup)) {
345
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists', startedAt: signalStartedAt, endedAt: Date.now() });
346
- continue;
347
- }
348
- // Risk guard per-open (real-time via MCP, with per-asset cooldown)
349
- const gate = await this.context.riskGuard.checkGate(address, { candidateAsset: asset });
350
- if (gate.gate !== 'OPEN') {
351
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}`, startedAt: signalStartedAt, endedAt: Date.now() });
352
- continue;
353
- }
354
- // Leverage: LLM override > config-based resolution
355
- const llmLeverage = typeof signal.leverage === 'number' ? signal.leverage : undefined;
356
- const maxLeverage = await this.context.maxLeverageCache.get(asset, address) ?? 20;
357
- const leverage = llmLeverage != null && llmLeverage > 0
358
- ? Math.max(MIN_LEVERAGE, Math.min(llmLeverage, maxLeverage))
359
- : resolveLeverage({
360
- tradingRisk: strategyState.config.tradingRisk,
361
- maxLeverage,
362
- defaultLeverage: strategyState.config.defaultLeverage,
363
- leverageMultipliers: strategyState.config.leverageMultipliers,
333
+ await withSpan("signal.evaluation", {
334
+ "senpi.signal.asset": asset,
335
+ "senpi.signal.direction": direction,
336
+ ...(typeof signalScore === "number" ? { "senpi.signal.score": signalScore } : {}),
337
+ }, async (span) => {
338
+ // Terminal outcome on this candidate's span. A failed result also shows red.
339
+ const outcome = (result, reason) => {
340
+ span.setAttribute("senpi.outcome.result", result);
341
+ span.setAttribute("senpi.outcome.reason_code", reason);
342
+ if (result === "failed")
343
+ span.setStatus({ code: SpanStatusCode.ERROR, message: reason });
344
+ };
345
+ // Telemetry: per-signal work window — every result entry below carries startedAt/endedAt.
346
+ const signalStartedAt = Date.now();
347
+ const signalDex = signal.dex;
348
+ const submittedDex = typeof signalDex === "string" && signalDex.trim() !== "" ? signalDex.trim() : "main";
349
+ const dexForLookup = submittedDex === "main" ? undefined : submittedDex;
350
+ if (remainingSlots <= 0) {
351
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots', startedAt: signalStartedAt, endedAt: Date.now() });
352
+ outcome("skipped", 'no_slots');
353
+ return;
354
+ }
355
+ if (!direction || (direction !== 'LONG' && direction !== 'SHORT')) {
356
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction', startedAt: signalStartedAt, endedAt: Date.now() });
357
+ failedCount++;
358
+ outcome("failed", 'invalid_direction');
359
+ return;
360
+ }
361
+ if (strategyState.hasOpenPosition(asset, dexForLookup)) {
362
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists', startedAt: signalStartedAt, endedAt: Date.now() });
363
+ outcome("skipped", 'position_already_exists');
364
+ return;
365
+ }
366
+ // Risk guard per-open (real-time via MCP, with per-asset cooldown)
367
+ const gate = await withSpan("risk.gate", {}, async (gateSpan) => {
368
+ const g = await this.context.riskGuard.checkGate(address, { candidateAsset: asset });
369
+ gateSpan.setAttribute("senpi.risk.gate", g.gate);
370
+ if (g.gate !== 'OPEN')
371
+ gateSpan.setStatus({ code: SpanStatusCode.ERROR, message: g.reason ?? g.gate });
372
+ return g;
364
373
  });
365
- // Margin: per-signal marginPct (% of withdrawable) > config margin_pct
366
- // > margin_per_slot > budget / slots
367
- const signalMarginPct = typeof signal.marginPct === 'number' ? signal.marginPct : undefined;
368
- const { amount: marginAmount, source: marginSource } = resolveMarginWithSource({
369
- signalMarginPct,
370
- marginPct: strategyState.config.marginPct,
371
- withdrawable,
372
- marginPerSlot: strategyState.config.marginPerSlot,
373
- budget: strategyState.config.budget,
374
- slots: strategyState.config.slots,
375
- });
376
- if (marginAmount == null || marginAmount <= 0) {
377
- logger.warn(`Skipping ${asset}: no margin source resolved. ` +
378
- `Set strategy.margin_per_slot, strategy.margin_pct, or strategy.budget + strategy.slots ` +
379
- `in the recipe, or have the LLM provide a per-signal margin override.`, {
380
- asset,
381
- wallet: address,
374
+ if (gate.gate !== 'OPEN') {
375
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}`, startedAt: signalStartedAt, endedAt: Date.now() });
376
+ outcome("skipped", `risk_gate_${gate.gate}`);
377
+ return;
378
+ }
379
+ // Leverage: LLM override > config-based resolution
380
+ const llmLeverage = typeof signal.leverage === 'number' ? signal.leverage : undefined;
381
+ const maxLeverage = await this.context.maxLeverageCache.get(asset, address) ?? 20;
382
+ const leverage = llmLeverage != null && llmLeverage > 0
383
+ ? Math.max(MIN_LEVERAGE, Math.min(llmLeverage, maxLeverage))
384
+ : resolveLeverage({
385
+ tradingRisk: strategyState.config.tradingRisk,
386
+ maxLeverage,
387
+ defaultLeverage: strategyState.config.defaultLeverage,
388
+ leverageMultipliers: strategyState.config.leverageMultipliers,
389
+ });
390
+ // Margin: per-signal marginPct (% of withdrawable) > config margin_pct
391
+ // > margin_per_slot > budget / slots
392
+ const signalMarginPct = typeof signal.marginPct === 'number' ? signal.marginPct : undefined;
393
+ const { amount: marginAmount, source: marginSource } = resolveMarginWithSource({
382
394
  signalMarginPct,
383
395
  marginPct: strategyState.config.marginPct,
396
+ withdrawable,
384
397
  marginPerSlot: strategyState.config.marginPerSlot,
385
398
  budget: strategyState.config.budget,
386
399
  slots: strategyState.config.slots,
387
- withdrawable,
388
- });
389
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured', startedAt: signalStartedAt, endedAt: Date.now() });
390
- failedCount++;
391
- continue;
392
- }
393
- const notionalValue = marginAmount * leverage;
394
- if (notionalValue < MIN_NOTIONAL_VALUE) {
395
- logger.warn(`Skipping ${asset}: notional value $${notionalValue.toFixed(2)} ` +
396
- `(margin $${marginAmount.toFixed(2)} × ${leverage}x) is below minimum $${MIN_NOTIONAL_VALUE}`);
397
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, notionalValue, success: false, reason: 'below_min_notional', startedAt: signalStartedAt, endedAt: Date.now() });
398
- failedCount++;
399
- continue;
400
- }
401
- // Free-margin pre-flight: if we know free margin and it can't cover this
402
- // position's margin, skip rather than fire a create the engine will reject
403
- // (CREATE_INSUFFICIENT_FUNDS). Skip the check when free margin is unknown.
404
- if (remainingMargin != null && remainingMargin < marginAmount) {
405
- logger.warn(`Skipping ${asset}: insufficient free margin ` +
406
- `($${remainingMargin.toFixed(2)} available < $${marginAmount.toFixed(2)} required)`);
407
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, requiredMarginAmount: marginAmount, availableMarginAmount: remainingMargin, success: true, reason: 'insufficient_margin', startedAt: signalStartedAt, endedAt: Date.now() });
408
- continue;
409
- }
410
- const signalReason = signal.reason ?? signal.signalType ?? reasoning;
411
- try {
412
- // Sizing breadcrumb: WHICH source set the margin + the numbers behind it,
413
- // so a wrong size is attributable from logs without reverse-engineering.
414
- logger.info('opening position', {
415
- wallet: address,
416
- asset,
417
- direction,
418
- source: marginSource,
419
- withdrawable,
420
- accountValue: clearinghouseView.accountValue,
421
- marginAmount,
422
- leverage,
423
- notional: notionalValue,
424
400
  });
425
- const result = await this.context.senpi.createPosition({
426
- strategyWalletAddress: address,
427
- orders: [
428
- {
429
- coin: asset,
401
+ // The applied percent — only meaningful for the two percent-of-withdrawable branches; the
402
+ // fixed/budget sources have no percent. Rides the result entry so the sizing attribution
403
+ // (source + percent) reaches the signal.outcome event, not just the structured log below.
404
+ const marginPct = marginSource === 'signal_marginPct' ? signalMarginPct
405
+ : marginSource === 'config_margin_pct' ? strategyState.config.marginPct
406
+ : undefined;
407
+ if (marginAmount == null || marginAmount <= 0) {
408
+ logger.warn(`Skipping ${asset}: no margin source resolved. ` +
409
+ `Set strategy.margin_per_slot, strategy.margin_pct, or strategy.budget + strategy.slots ` +
410
+ `in the recipe, or have the LLM provide a per-signal margin override.`, {
411
+ asset,
412
+ wallet: address,
413
+ signalMarginPct,
414
+ marginPct: strategyState.config.marginPct,
415
+ marginPerSlot: strategyState.config.marginPerSlot,
416
+ budget: strategyState.config.budget,
417
+ slots: strategyState.config.slots,
418
+ withdrawable,
419
+ });
420
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured', startedAt: signalStartedAt, endedAt: Date.now() });
421
+ failedCount++;
422
+ outcome("failed", 'no_margin_configured');
423
+ return;
424
+ }
425
+ const notionalValue = marginAmount * leverage;
426
+ if (notionalValue < MIN_NOTIONAL_VALUE) {
427
+ logger.warn(`Skipping ${asset}: notional value $${notionalValue.toFixed(2)} ` +
428
+ `(margin $${marginAmount.toFixed(2)} × ${leverage}x) is below minimum $${MIN_NOTIONAL_VALUE}`);
429
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, marginSource, marginPct, notionalValue, success: false, reason: 'below_min_notional', startedAt: signalStartedAt, endedAt: Date.now() });
430
+ failedCount++;
431
+ outcome("failed", 'below_min_notional');
432
+ return;
433
+ }
434
+ // Free-margin pre-flight: if we know free margin and it can't cover this
435
+ // position's margin, skip rather than fire a create the engine will reject
436
+ // (CREATE_INSUFFICIENT_FUNDS). Skip the check when free margin is unknown.
437
+ if (remainingMargin != null && remainingMargin < marginAmount) {
438
+ logger.warn(`Skipping ${asset}: insufficient free margin ` +
439
+ `($${remainingMargin.toFixed(2)} available < $${marginAmount.toFixed(2)} required)`);
440
+ results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, marginSource, marginPct, requiredMarginAmount: marginAmount, availableMarginAmount: remainingMargin, success: true, reason: 'insufficient_margin', startedAt: signalStartedAt, endedAt: Date.now() });
441
+ outcome("skipped", 'insufficient_margin');
442
+ return;
443
+ }
444
+ const signalReason = signal.reason ?? signal.signalType ?? reasoning;
445
+ try {
446
+ // Sizing breadcrumb: WHICH source set the margin + the numbers behind it,
447
+ // so a wrong size is attributable from logs without reverse-engineering.
448
+ logger.info('opening position', {
449
+ wallet: address,
450
+ asset,
451
+ direction,
452
+ source: marginSource,
453
+ withdrawable,
454
+ accountValue: clearinghouseView.accountValue,
455
+ marginAmount,
456
+ leverage,
457
+ notional: notionalValue,
458
+ });
459
+ const result = await withSpan("mcp.createPosition", { "senpi.signal.asset": asset, "senpi.signal.direction": direction }, async (mcpSpan) => {
460
+ const r = await this.context.senpi.createPosition({
461
+ strategyWalletAddress: address,
462
+ orders: [
463
+ {
464
+ coin: asset,
465
+ direction,
466
+ leverage,
467
+ marginAmount,
468
+ orderType,
469
+ ...(feeOptimizedLimitOptions ? { feeOptimizedLimitOptions } : {}),
470
+ },
471
+ ],
472
+ reason: signalReason,
473
+ });
474
+ if (!r.success) {
475
+ const orderErrors = r.orders
476
+ .filter((o) => o.error)
477
+ .map((o) => `${o.coin}: ${o.error}`)
478
+ .join('; ');
479
+ const detail = r.error || orderErrors || 'unknown error';
480
+ mcpSpan.addEvent("order.failed");
481
+ mcpSpan.setStatus({ code: SpanStatusCode.ERROR, message: detail });
482
+ }
483
+ else {
484
+ const o = r.orders[0];
485
+ if (o?.orderId != null)
486
+ mcpSpan.setAttribute("senpi.order.id", o.orderId);
487
+ if (o?.filled != null)
488
+ mcpSpan.setAttribute("senpi.outcome.filled", o.filled);
489
+ if (o?.executionAsMaker != null)
490
+ mcpSpan.setAttribute("senpi.outcome.execution_as_maker", o.executionAsMaker);
491
+ if (o?.entryPrice != null)
492
+ mcpSpan.setAttribute("senpi.order.entry_price", o.entryPrice);
493
+ if (o?.size != null)
494
+ mcpSpan.setAttribute("senpi.order.size", o.size);
495
+ mcpSpan.addEvent("order.placed");
496
+ if (o?.filled)
497
+ mcpSpan.addEvent("order.filled");
498
+ }
499
+ return r;
500
+ });
501
+ if (!result.success) {
502
+ const orderErrors = result.orders
503
+ .filter((o) => o.error)
504
+ .map((o) => `${o.coin}: ${o.error}`)
505
+ .join('; ');
506
+ const detail = result.error || orderErrors || 'unknown error';
507
+ results.push({
508
+ asset, direction, dex: submittedDex, score: signalScore,
509
+ leverage, marginAmount, marginSource, marginPct, orderType,
510
+ success: false, reason: 'position_open_failed', error: detail,
511
+ startedAt: signalStartedAt, endedAt: Date.now(),
512
+ });
513
+ // Task 8a: the venue rejected the open submit.
514
+ emitOrderFailed(this.context.hookSystem, event, address, {
515
+ asset,
516
+ direction,
517
+ reason: 'position_open_failed',
518
+ error: detail,
519
+ reduceOnly: false,
520
+ });
521
+ failedCount++;
522
+ outcome("failed", 'position_open_failed');
523
+ return;
524
+ }
525
+ const submittedOrder = result.orders[0];
526
+ // Mint the position.id at intent — the moment the open order is submitted,
527
+ // before the position is confirmed open — so the id rides the action.open
528
+ // emit and the open trace correlates by position.id directly (no ~60s
529
+ // bridge on the read side).
530
+ let mintedPositionId;
531
+ if (this.context.positionIdRegistry) {
532
+ try {
533
+ const minted = this.context.positionIdRegistry.mintOnIntent(positionRegistryKey(address, submittedDex, asset));
534
+ mintedPositionId = minted.positionId;
535
+ }
536
+ catch (err) {
537
+ // The order is already on the exchange — a registry failure must not land in the
538
+ // outer catch, which would mislabel this submit as an exception and skip the
539
+ // slot/margin bookkeeping.
540
+ const message = err instanceof Error ? err.message : String(err);
541
+ logger.warn('position.id mint failed; continuing with submit emit', err, { asset });
542
+ }
543
+ }
544
+ await this.context.hookSystem.fire({
545
+ type: HOOK_EVENT.ON_OPEN_ORDER_SUBMITTED,
546
+ runtimeName: event.runtimeName,
547
+ address,
548
+ data: {
549
+ asset,
430
550
  direction,
431
551
  leverage,
432
552
  marginAmount,
433
553
  orderType,
434
- ...(feeOptimizedLimitOptions ? { feeOptimizedLimitOptions } : {}),
554
+ wallet: address,
555
+ dex: submittedDex,
556
+ reasoning: signalReason,
557
+ orderId: submittedOrder?.orderId,
558
+ entryPrice: submittedOrder?.entryPrice ?? null,
559
+ size: submittedOrder?.size ?? null,
560
+ filled: submittedOrder?.filled ?? null,
561
+ __correlationId: event.data.__correlationId,
562
+ ...(mintedPositionId ? { __positionId: mintedPositionId } : {}),
435
563
  },
436
- ],
437
- reason: signalReason,
438
- });
439
- if (!result.success) {
440
- const orderErrors = result.orders
441
- .filter((o) => o.error)
442
- .map((o) => `${o.coin}: ${o.error}`)
443
- .join('; ');
444
- const detail = result.error || orderErrors || 'unknown error';
564
+ timestamp: new Date().toISOString(),
565
+ });
445
566
  results.push({
446
567
  asset, direction, dex: submittedDex, score: signalScore,
447
- leverage, marginAmount, orderType,
448
- success: false, reason: 'position_open_failed', error: detail,
568
+ leverage, marginAmount, marginSource, marginPct, orderType,
569
+ orderId: submittedOrder?.orderId ?? null,
570
+ entryPrice: submittedOrder?.entryPrice ?? null,
571
+ size: submittedOrder?.size ?? null,
572
+ filled: submittedOrder?.filled ?? null,
573
+ success: true, reason: 'submitted',
449
574
  startedAt: signalStartedAt, endedAt: Date.now(),
450
575
  });
451
- // Task 8a: the venue rejected the open submit.
452
- emitOrderFailed(this.context.hookSystem, event, address, {
576
+ // Task 8a order-event log (additive, fire-and-forget — see emit helpers). The open submit
577
+ // is an `order.placed`; a confirmed fill additionally emits `order.filled`.
578
+ emitOrderPlaced(this.context.hookSystem, event, address, {
579
+ orderId: submittedOrder?.orderId,
580
+ orderType,
453
581
  asset,
454
582
  direction,
455
- reason: 'position_open_failed',
456
- error: detail,
583
+ size: submittedOrder?.size,
457
584
  reduceOnly: false,
585
+ ...(mintedPositionId ? { __positionId: mintedPositionId } : {}),
458
586
  });
459
- failedCount++;
460
- continue;
461
- }
462
- const submittedOrder = result.orders[0];
463
- // Mint the position.id at intent — the moment the open order is submitted,
464
- // before the position is confirmed open — so the id rides the action.open
465
- // emit and the open trace correlates by position.id directly (no ~60s
466
- // bridge on the read side).
467
- let mintedPositionId;
468
- if (this.context.positionIdRegistry) {
469
- try {
470
- const minted = this.context.positionIdRegistry.mintOnIntent(positionRegistryKey(address, submittedDex, asset));
471
- mintedPositionId = minted.positionId;
472
- }
473
- catch (err) {
474
- // The order is already on the exchange — a registry failure must not land in the
475
- // outer catch, which would mislabel this submit as an exception and skip the
476
- // slot/margin bookkeeping.
477
- const message = err instanceof Error ? err.message : String(err);
478
- logger.warn('position.id mint failed; continuing with submit emit', { asset, err: message });
587
+ if (submittedOrder?.filled) {
588
+ emitOrderFilled(this.context.hookSystem, event, address, {
589
+ orderId: submittedOrder?.orderId,
590
+ asset,
591
+ fillPrice: submittedOrder?.entryPrice,
592
+ fillSize: submittedOrder?.size,
593
+ executionAsMaker: submittedOrder?.executionAsMaker,
594
+ ...(mintedPositionId ? { __positionId: mintedPositionId } : {}),
595
+ });
479
596
  }
597
+ submittedCount++;
598
+ remainingSlots--;
599
+ if (remainingMargin != null)
600
+ remainingMargin -= marginAmount;
601
+ outcome("submitted", 'submitted');
480
602
  }
481
- await this.context.hookSystem.fire({
482
- type: HOOK_EVENT.ON_OPEN_ORDER_SUBMITTED,
483
- runtimeName: event.runtimeName,
484
- address,
485
- data: {
603
+ catch (error) {
604
+ const message = error instanceof Error ? error.message : String(error);
605
+ logger.error('open failed', error, { asset });
606
+ results.push({
607
+ asset, direction, dex: submittedDex, score: signalScore,
608
+ leverage, marginAmount, marginSource, marginPct, orderType,
609
+ success: false, reason: 'exception', error: message,
610
+ startedAt: signalStartedAt, endedAt: Date.now(),
611
+ });
612
+ // Task 8a: the open submit threw.
613
+ emitOrderFailed(this.context.hookSystem, event, address, {
486
614
  asset,
487
615
  direction,
488
- leverage,
489
- marginAmount,
490
- orderType,
491
- wallet: address,
492
- dex: submittedDex,
493
- reasoning: signalReason,
494
- orderId: submittedOrder?.orderId,
495
- entryPrice: submittedOrder?.entryPrice ?? null,
496
- size: submittedOrder?.size ?? null,
497
- filled: submittedOrder?.filled ?? null,
498
- __correlationId: event.data.__correlationId,
499
- ...(mintedPositionId ? { __positionId: mintedPositionId } : {}),
500
- },
501
- timestamp: new Date().toISOString(),
502
- });
503
- results.push({
504
- asset, direction, dex: submittedDex, score: signalScore,
505
- leverage, marginAmount, orderType,
506
- orderId: submittedOrder?.orderId ?? null,
507
- entryPrice: submittedOrder?.entryPrice ?? null,
508
- size: submittedOrder?.size ?? null,
509
- filled: submittedOrder?.filled ?? null,
510
- success: true, reason: 'submitted',
511
- startedAt: signalStartedAt, endedAt: Date.now(),
512
- });
513
- // Task 8a order-event log (additive, fire-and-forget — see emit helpers). The open submit
514
- // is an `order.placed`; a confirmed fill additionally emits `order.filled`.
515
- emitOrderPlaced(this.context.hookSystem, event, address, {
516
- orderId: submittedOrder?.orderId,
517
- orderType,
518
- asset,
519
- direction,
520
- size: submittedOrder?.size,
521
- reduceOnly: false,
522
- ...(mintedPositionId ? { __positionId: mintedPositionId } : {}),
523
- });
524
- if (submittedOrder?.filled) {
525
- emitOrderFilled(this.context.hookSystem, event, address, {
526
- orderId: submittedOrder?.orderId,
527
- asset,
528
- fillPrice: submittedOrder?.entryPrice,
529
- fillSize: submittedOrder?.size,
530
- executionAsMaker: submittedOrder?.executionAsMaker,
531
- ...(mintedPositionId ? { __positionId: mintedPositionId } : {}),
616
+ reason: 'exception',
617
+ error: message,
618
+ errorName: error instanceof Error ? error.name : undefined,
619
+ reduceOnly: false,
532
620
  });
621
+ failedCount++;
622
+ outcome("failed", 'exception');
533
623
  }
534
- submittedCount++;
535
- remainingSlots--;
536
- if (remainingMargin != null)
537
- remainingMargin -= marginAmount;
538
- }
539
- catch (error) {
540
- const message = error instanceof Error ? error.message : String(error);
541
- logger.error('open failed', { asset, err: message });
542
- results.push({
543
- asset, direction, dex: submittedDex, score: signalScore,
544
- leverage, marginAmount, orderType,
545
- success: false, reason: 'exception', error: message,
546
- startedAt: signalStartedAt, endedAt: Date.now(),
547
- });
548
- // Task 8a: the open submit threw.
549
- emitOrderFailed(this.context.hookSystem, event, address, {
550
- asset,
551
- direction,
552
- reason: 'exception',
553
- error: message,
554
- errorName: error instanceof Error ? error.name : undefined,
555
- reduceOnly: false,
556
- });
557
- failedCount++;
558
- }
624
+ });
559
625
  }
560
626
  const skippedCount = results.length - submittedCount - failedCount;
561
627
  const actionResult = {
@@ -572,7 +638,7 @@ export class OpenPositionAction {
572
638
  _decisionPrompt: decisionPrompt ?? null,
573
639
  },
574
640
  };
575
- emitTrackingActionResult(this.trackingCtx(), event, address, actionResult, trackingExtras);
641
+ emitTrackingActionResult(this.eventCtx(), event, address, actionResult, trackingExtras);
576
642
  return actionResult;
577
643
  }
578
644
  }