@senpi/runtime 3.0.108 → 3.0.111

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 (241) hide show
  1. package/dist/actions/action-events.d.ts +21 -7
  2. package/dist/actions/action-events.d.ts.map +1 -1
  3. package/dist/actions/action-events.js +54 -26
  4. package/dist/actions/action-events.js.map +1 -1
  5. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  6. package/dist/actions/action-runtime-module.js +38 -7
  7. package/dist/actions/action-runtime-module.js.map +1 -1
  8. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  9. package/dist/actions/close-position/close-position.action.js +122 -15
  10. package/dist/actions/close-position/close-position.action.js.map +1 -1
  11. package/dist/actions/decision-engine.d.ts.map +1 -1
  12. package/dist/actions/decision-engine.js +15 -2
  13. package/dist/actions/decision-engine.js.map +1 -1
  14. package/dist/actions/event-derivation.d.ts +71 -2
  15. package/dist/actions/event-derivation.d.ts.map +1 -1
  16. package/dist/actions/event-derivation.js +251 -8
  17. package/dist/actions/event-derivation.js.map +1 -1
  18. package/dist/actions/open-position/open-position.action.d.ts +28 -2
  19. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  20. package/dist/actions/open-position/open-position.action.js +220 -52
  21. package/dist/actions/open-position/open-position.action.js.map +1 -1
  22. package/dist/actions/position-tracker/position-tracker.action.d.ts +2 -1
  23. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  24. package/dist/actions/position-tracker/position-tracker.action.js +62 -17
  25. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  26. package/dist/actions/skipped-signal-results.d.ts +25 -0
  27. package/dist/actions/skipped-signal-results.d.ts.map +1 -0
  28. package/dist/actions/skipped-signal-results.js +71 -0
  29. package/dist/actions/skipped-signal-results.js.map +1 -0
  30. package/dist/actions/store/types.d.ts +9 -2
  31. package/dist/actions/store/types.d.ts.map +1 -1
  32. package/dist/actions/store/types.js.map +1 -1
  33. package/dist/actions/types.d.ts +13 -0
  34. package/dist/actions/types.d.ts.map +1 -1
  35. package/dist/actions/types.js +13 -0
  36. package/dist/actions/types.js.map +1 -1
  37. package/dist/cli/guide-commands.d.ts.map +1 -1
  38. package/dist/cli/guide-commands.js +3 -0
  39. package/dist/cli/guide-commands.js.map +1 -1
  40. package/dist/constants/dsl/index.d.ts +15 -0
  41. package/dist/constants/dsl/index.d.ts.map +1 -1
  42. package/dist/constants/dsl/index.js +15 -0
  43. package/dist/constants/dsl/index.js.map +1 -1
  44. package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
  45. package/dist/dsl/engine-client/event-mapping.js +14 -0
  46. package/dist/dsl/engine-client/event-mapping.js.map +1 -1
  47. package/dist/dsl/events/build-closed-event.d.ts +16 -1
  48. package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
  49. package/dist/dsl/events/build-closed-event.js +6 -0
  50. package/dist/dsl/events/build-closed-event.js.map +1 -1
  51. package/dist/dsl/events/handlers.d.ts +7 -0
  52. package/dist/dsl/events/handlers.d.ts.map +1 -1
  53. package/dist/dsl/events/handlers.js +37 -7
  54. package/dist/dsl/events/handlers.js.map +1 -1
  55. package/dist/dsl/monitor/create-monitor.d.ts +6 -2
  56. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  57. package/dist/dsl/monitor/create-monitor.js +15 -9
  58. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  59. package/dist/dsl/monitor/dsl-failure-events.d.ts +2 -0
  60. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  61. package/dist/dsl/monitor/dsl-failure-events.js +2 -1
  62. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  63. package/dist/dsl/monitor/process-one-position.d.ts +1 -0
  64. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  65. package/dist/dsl/monitor/process-one-position.js +72 -29
  66. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  67. package/dist/dsl/plugin/index.d.ts +8 -2
  68. package/dist/dsl/plugin/index.d.ts.map +1 -1
  69. package/dist/dsl/plugin/index.js +8 -6
  70. package/dist/dsl/plugin/index.js.map +1 -1
  71. package/dist/health/types.d.ts +13 -0
  72. package/dist/health/types.d.ts.map +1 -1
  73. package/dist/health/types.js +12 -0
  74. package/dist/health/types.js.map +1 -1
  75. package/dist/index.d.ts +16 -4
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +230 -39
  78. package/dist/index.js.map +1 -1
  79. package/dist/runtime/action-registry.d.ts.map +1 -1
  80. package/dist/runtime/action-registry.js +4 -3
  81. package/dist/runtime/action-registry.js.map +1 -1
  82. package/dist/runtime/domain-events.d.ts.map +1 -1
  83. package/dist/runtime/domain-events.js +7 -0
  84. package/dist/runtime/domain-events.js.map +1 -1
  85. package/dist/runtime/lifecycle-events.d.ts +9 -1
  86. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  87. package/dist/runtime/lifecycle-events.js +11 -2
  88. package/dist/runtime/lifecycle-events.js.map +1 -1
  89. package/dist/runtime/reconcile-run.d.ts +13 -3
  90. package/dist/runtime/reconcile-run.d.ts.map +1 -1
  91. package/dist/runtime/reconcile-run.js +4 -3
  92. package/dist/runtime/reconcile-run.js.map +1 -1
  93. package/dist/runtime/reconcile-state.d.ts +8 -0
  94. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  95. package/dist/runtime/reconcile-state.js +4 -1
  96. package/dist/runtime/reconcile-state.js.map +1 -1
  97. package/dist/runtime/run.d.ts +1 -0
  98. package/dist/runtime/run.d.ts.map +1 -1
  99. package/dist/runtime/run.js.map +1 -1
  100. package/dist/runtime/runtime-schema.js +3 -3
  101. package/dist/runtime/runtime-schema.js.map +1 -1
  102. package/dist/runtime/runtime.d.ts +1 -0
  103. package/dist/runtime/runtime.d.ts.map +1 -1
  104. package/dist/runtime/runtime.js +27 -3
  105. package/dist/runtime/runtime.js.map +1 -1
  106. package/dist/runtime/supervisor/long-running-supervisor.d.ts +13 -0
  107. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  108. package/dist/runtime/supervisor/long-running-supervisor.js +1 -0
  109. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  110. package/dist/runtime/validate-dsl-position-tracking.d.ts.map +1 -1
  111. package/dist/runtime/validate-dsl-position-tracking.js +2 -1
  112. package/dist/runtime/validate-dsl-position-tracking.js.map +1 -1
  113. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  114. package/dist/runtime-api/handlers/ingest-signals.js +1 -0
  115. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  116. package/dist/runtime-api/handlers/types.d.ts +5 -0
  117. package/dist/runtime-api/handlers/types.d.ts.map +1 -1
  118. package/dist/runtime-api/intake/ingest-request.d.ts +17 -0
  119. package/dist/runtime-api/intake/ingest-request.d.ts.map +1 -0
  120. package/dist/runtime-api/intake/ingest-request.js +34 -0
  121. package/dist/runtime-api/intake/ingest-request.js.map +1 -0
  122. package/dist/runtime-api/intake/intake-events.d.ts +1 -1
  123. package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
  124. package/dist/runtime-api/intake/intake-events.js +9 -3
  125. package/dist/runtime-api/intake/intake-events.js.map +1 -1
  126. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  127. package/dist/runtime-api/intake/intake-mount.js +4 -19
  128. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  129. package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -1
  130. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  131. package/dist/runtime-api/intake/scanner-launcher.js +15 -0
  132. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  133. package/dist/runtime-api/intake/types.d.ts +6 -0
  134. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  135. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  136. package/dist/runtime-api/routes/signals.js +2 -1
  137. package/dist/runtime-api/routes/signals.js.map +1 -1
  138. package/dist/runtime-api/routes/signals.schema.d.ts +1 -0
  139. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  140. package/dist/runtime-api/routes/signals.schema.js +6 -0
  141. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  142. package/dist/scanners/engine/engine.d.ts +22 -1
  143. package/dist/scanners/engine/engine.d.ts.map +1 -1
  144. package/dist/scanners/engine/engine.js +78 -11
  145. package/dist/scanners/engine/engine.js.map +1 -1
  146. package/dist/scanners/engine/lifecycle.d.ts +7 -1
  147. package/dist/scanners/engine/lifecycle.d.ts.map +1 -1
  148. package/dist/scanners/events.d.ts +4 -0
  149. package/dist/scanners/events.d.ts.map +1 -1
  150. package/dist/scanners/events.js +2 -0
  151. package/dist/scanners/events.js.map +1 -1
  152. package/dist/scanners/external/scaffold/delivery.py +136 -14
  153. package/dist/scanners/external/scaffold/scaffold.py +61 -8
  154. package/dist/scanners/external/scaffold/ulid.py +58 -0
  155. package/dist/scanners/external-scanner-receiver.d.ts +17 -0
  156. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  157. package/dist/scanners/external-scanner-receiver.js +8 -0
  158. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  159. package/dist/scanners/runtime-module.d.ts +1 -0
  160. package/dist/scanners/runtime-module.d.ts.map +1 -1
  161. package/dist/scanners/runtime-module.js +59 -9
  162. package/dist/scanners/runtime-module.js.map +1 -1
  163. package/dist/scripts/run-dsl-standalone-mock.js +6 -2
  164. package/dist/scripts/run-dsl-standalone-mock.js.map +1 -1
  165. package/dist/senpi/client.d.ts +16 -3
  166. package/dist/senpi/client.d.ts.map +1 -1
  167. package/dist/senpi/client.js +44 -13
  168. package/dist/senpi/client.js.map +1 -1
  169. package/dist/strategy/strategy-state.d.ts +9 -1
  170. package/dist/strategy/strategy-state.d.ts.map +1 -1
  171. package/dist/strategy/strategy-state.js +7 -2
  172. package/dist/strategy/strategy-state.js.map +1 -1
  173. package/dist/telemetry/attributes.d.ts +20 -0
  174. package/dist/telemetry/attributes.d.ts.map +1 -1
  175. package/dist/telemetry/attributes.js +24 -0
  176. package/dist/telemetry/attributes.js.map +1 -1
  177. package/dist/telemetry/event-builders.d.ts +81 -6
  178. package/dist/telemetry/event-builders.d.ts.map +1 -1
  179. package/dist/telemetry/event-builders.js +141 -17
  180. package/dist/telemetry/event-builders.js.map +1 -1
  181. package/dist/telemetry/event-store.d.ts +6 -0
  182. package/dist/telemetry/event-store.d.ts.map +1 -1
  183. package/dist/telemetry/event-store.js +12 -8
  184. package/dist/telemetry/event-store.js.map +1 -1
  185. package/dist/telemetry/live-telemetry.d.ts +192 -0
  186. package/dist/telemetry/live-telemetry.d.ts.map +1 -0
  187. package/dist/telemetry/live-telemetry.js +676 -0
  188. package/dist/telemetry/live-telemetry.js.map +1 -0
  189. package/dist/telemetry/metrics.d.ts +14 -2
  190. package/dist/telemetry/metrics.d.ts.map +1 -1
  191. package/dist/telemetry/metrics.js +19 -4
  192. package/dist/telemetry/metrics.js.map +1 -1
  193. package/dist/telemetry/scanner-stats.d.ts +143 -0
  194. package/dist/telemetry/scanner-stats.d.ts.map +1 -0
  195. package/dist/telemetry/scanner-stats.js +426 -0
  196. package/dist/telemetry/scanner-stats.js.map +1 -0
  197. package/dist/telemetry/spans/redact-pii.d.ts.map +1 -1
  198. package/dist/telemetry/spans/redact-pii.js +18 -2
  199. package/dist/telemetry/spans/redact-pii.js.map +1 -1
  200. package/dist/telemetry/strategy-topology.d.ts +99 -0
  201. package/dist/telemetry/strategy-topology.d.ts.map +1 -0
  202. package/dist/telemetry/strategy-topology.js +93 -0
  203. package/dist/telemetry/strategy-topology.js.map +1 -0
  204. package/dist/telemetry/telemetry-publisher.d.ts +63 -0
  205. package/dist/telemetry/telemetry-publisher.d.ts.map +1 -0
  206. package/dist/telemetry/telemetry-publisher.js +229 -0
  207. package/dist/telemetry/telemetry-publisher.js.map +1 -0
  208. package/dist/telemetry/timeline-buffer.d.ts +95 -0
  209. package/dist/telemetry/timeline-buffer.d.ts.map +1 -0
  210. package/dist/telemetry/timeline-buffer.js +237 -0
  211. package/dist/telemetry/timeline-buffer.js.map +1 -0
  212. package/dist/telemetry/tracing.d.ts +9 -0
  213. package/dist/telemetry/tracing.d.ts.map +1 -1
  214. package/dist/telemetry/tracing.js +36 -0
  215. package/dist/telemetry/tracing.js.map +1 -1
  216. package/dist/types/dsl/index.d.ts +11 -0
  217. package/dist/types/dsl/index.d.ts.map +1 -1
  218. package/dist/types/scanner.d.ts +15 -0
  219. package/dist/types/scanner.d.ts.map +1 -1
  220. package/dist/types/strategy.d.ts +5 -1
  221. package/dist/types/strategy.d.ts.map +1 -1
  222. package/dist/utils/event-catalog.d.ts +166 -0
  223. package/dist/utils/event-catalog.d.ts.map +1 -1
  224. package/dist/utils/event-catalog.js +18 -0
  225. package/dist/utils/event-catalog.js.map +1 -1
  226. package/dist/utils/logger.d.ts +4 -1
  227. package/dist/utils/logger.d.ts.map +1 -1
  228. package/dist/utils/logger.js.map +1 -1
  229. package/dist/utils/telemetry-required-log.d.ts +27 -0
  230. package/dist/utils/telemetry-required-log.d.ts.map +1 -0
  231. package/dist/utils/telemetry-required-log.js +68 -0
  232. package/dist/utils/telemetry-required-log.js.map +1 -0
  233. package/dist/utils/ulid.d.ts +9 -0
  234. package/dist/utils/ulid.d.ts.map +1 -0
  235. package/dist/utils/ulid.js +43 -0
  236. package/dist/utils/ulid.js.map +1 -0
  237. package/dist/validate/recipe-checks.d.ts.map +1 -1
  238. package/dist/validate/recipe-checks.js +2 -2
  239. package/dist/validate/recipe-checks.js.map +1 -1
  240. package/openclaw.plugin.json +1 -1
  241. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { ACTION_TYPE } from '../types.js';
1
2
  import { decisionMetaFromDecision } from '../decision-meta.js';
2
3
  import { DecisionEngine, LlmDecisionStrategy, NoDecisionStrategy, RuleDecisionStrategy } from '../decision-engine.js';
3
4
  import { HOOK_EVENT } from '../../types/hooks.js';
@@ -6,21 +7,57 @@ import { emitDecisionErrorNotification } from '../decision-error-notification.js
6
7
  import { emitEvent } from '../event-derivation.js';
7
8
  import { buildRuntimePausedEvent } from '../../runtime/lifecycle-events.js';
8
9
  import { resolveLeverage } from '../../leverage/resolve-leverage.js';
9
- import { SenpiTimeoutError } from '../../senpi/client.js';
10
+ import { SenpiTimeoutError, isOutcomeUnknownCode } from '../../senpi/client.js';
10
11
  import { resolveMarginWithSource } from './resolve-margin.js';
11
12
  import { createLogger } from '../../utils/logger.js';
12
13
  import { positionRegistryKey } from '../../runtime/position-id-registry.js';
13
14
  import { resolveOpenOrderType, resolveFeeOptimizedLimitOptions } from '../../utils/resolve-order-type.js';
14
15
  import { emitActionResultEvents, emitOrderPlaced, emitOrderFilled, emitOrderFailed, buildActionEventContext } from '../action-events.js';
16
+ import { skippedSignalResults } from '../skipped-signal-results.js';
15
17
  import { withSpan } from '../../telemetry/tracing.js';
16
18
  import { SpanStatusCode } from '@opentelemetry/api';
17
19
  import { classifyToolError } from '../../senpi/tool-errors.js';
18
20
  import { hasOpenPositionIn } from '../../strategy/strategy-state.js';
19
21
  import { createPendingOpensLedger, pendingOpenKey } from '../../runtime/pending-opens-ledger.js';
20
- import { recordSignalOutcome } from '../../telemetry/metrics.js';
21
22
  const logger = createLogger("[actions][open-position]");
22
23
  /** Hyperliquid minimum notional value (margin × leverage) required to open a position. */
23
24
  const MIN_NOTIONAL_VALUE = 10;
25
+ /**
26
+ * Wall-clock bound on the clearing-house reads this action makes.
27
+ *
28
+ * These reads run inside the runtime's `reconcile` key-lock, which the DSL monitor's own
29
+ * reconcile waits on. The client's retry ladder bounds attempt COUNT, not time — three 45s
30
+ * attempts plus backoff is ~2.5 min of held lock, and it holds exactly when the MCP is
31
+ * degraded, which is when the exit engine can least afford to wait. 20s sits under the DSL
32
+ * tick's 30s default (a recipe on a faster tick loses at most one tick) while leaving a
33
+ * healthy read, which lands in 1–5s, several attempts of headroom.
34
+ */
35
+ export const CLEARINGHOUSE_READ_TIMEOUT_MS = 20_000;
36
+ /**
37
+ * A deadline for one clearing-house read. `AbortSignal.timeout` would do, but its timer is
38
+ * invisible to test fake-timers and never cleared — this one is both controllable and
39
+ * cancelled on the happy path, so a bounded read leaves nothing pending behind it.
40
+ */
41
+ function readDeadline(ms) {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => {
44
+ controller.abort(new Error(`clearinghouse read exceeded ${ms}ms`));
45
+ }, ms);
46
+ return { signal: controller.signal, clear: () => clearTimeout(timer) };
47
+ }
48
+ /** Run one clearing-house read under {@link CLEARINGHOUSE_READ_TIMEOUT_MS}. Fails closed. */
49
+ async function readBookBounded(strategyState) {
50
+ const deadline = readDeadline(CLEARINGHOUSE_READ_TIMEOUT_MS);
51
+ try {
52
+ return await strategyState.fetchClearinghouseState({
53
+ retryUnreadable: true,
54
+ signal: deadline.signal,
55
+ });
56
+ }
57
+ finally {
58
+ deadline.clear();
59
+ }
60
+ }
24
61
  /** Minimum leverage floor — positions cannot go below 1x. */
25
62
  const MIN_LEVERAGE = 1;
26
63
  /**
@@ -41,7 +78,7 @@ export class OpenPositionAction {
41
78
  config;
42
79
  context;
43
80
  name;
44
- actionType = 'OPEN_POSITION';
81
+ actionType = ACTION_TYPE.openPosition;
45
82
  decisionEngine;
46
83
  /**
47
84
  * Ledger of last resort for a context that carries none (standalone scripts, older call
@@ -124,13 +161,23 @@ export class OpenPositionAction {
124
161
  // re-submitting on every signal.
125
162
  if (this.context.backendPauseRegistry?.isPaused(address)) {
126
163
  logger.warn('Skipping signals: strategy is paused or closed on the backend', { wallet: address });
127
- return {
164
+ const pausedResults = skippedSignalResults(scanResult.signals, 'strategy_backend_paused', Date.now());
165
+ const pausedResult = {
128
166
  success: true,
129
167
  action_type: this.actionType,
130
168
  summary: 'Strategy is paused or closed on the backend — skipping signals until it is resumed',
131
169
  skipped: true,
132
170
  skipReason: 'strategy_backend_paused',
171
+ data: {
172
+ results: pausedResults,
173
+ submittedCount: 0,
174
+ failedCount: 0,
175
+ skippedCount: pausedResults.length,
176
+ },
133
177
  };
178
+ // No decision ran yet, so the pass carries no `_decisionMeta` and yields no `decision.made`.
179
+ emitActionResultEvents(this.eventCtx(), event, address, pausedResult, { scanResult });
180
+ return pausedResult;
134
181
  }
135
182
  // The exchange view for THIS pass. Read here, once, and threaded through the rest of it.
136
183
  //
@@ -140,9 +187,11 @@ export class OpenPositionAction {
140
187
  // froze the slot gate, the dedupe check and the margin budget at whatever the first
141
188
  // signal after boot happened to see: a full snapshot skipped every later signal forever,
142
189
  // an empty one let the slot cap and the dedupe never bind at all.
143
- const view = await strategyState.fetchClearinghouseState({ retryUnreadable: true });
190
+ const view = await this.readClearinghouseView(strategyState, address);
144
191
  if (view === undefined) {
145
- return this.skipClearinghouseUnknown(event, address, [], undefined, undefined, { scanResult });
192
+ return this.skipClearinghouseUnknown(event, address,
193
+ // No decision has run, so the scan's own signals are the only attributable inputs.
194
+ skippedSignalResults(scanResult.signals, 'clearinghouse_unknown', Date.now()), undefined, undefined, undefined, { scanResult });
146
195
  }
147
196
  const snapshot = { view, fetchedAt: Date.now() };
148
197
  // Early exit: no slots. Counted as snapshot positions PLUS opens this runtime has already
@@ -163,19 +212,30 @@ export class OpenPositionAction {
163
212
  const slots = strategyState.config.slots;
164
213
  const openCount = view.positions.length + pending.length;
165
214
  if (openCount >= slots) {
215
+ // The log is edge-triggered and rate-limited: a full book on a high-signal-rate strategy
216
+ // is the loudest path there is, and the fact is one about the strategy, not one per
217
+ // candidate. The per-signal rows below are the event plane, which is bounded per pass.
166
218
  this.logBookFull(address, openCount, slots, pending.map((p) => p.coin), view.positions);
167
- // The per-signal `results[]`/event fan-out is deliberately NOT built here: a full book
168
- // on a high-signal-rate strategy is the loudest path there is, and the outcome is one
169
- // fact about the strategy, not one per candidate. The counter still carries it.
170
- recordSignalOutcome({ result: 'skipped', reasonCode: 'no_slots' });
171
- return {
219
+ const noSlotResults = skippedSignalResults(scanResult.signals, 'no_slots', Date.now());
220
+ const noSlotResult = {
172
221
  success: true,
173
222
  action_type: this.actionType,
174
223
  summary: `No slots available (${openCount}/${slots} open)`,
175
224
  skipped: true,
176
225
  skipReason: 'no_slots',
177
- data: { openCount, slots, pendingCount: pending.length },
226
+ data: {
227
+ results: noSlotResults,
228
+ submittedCount: 0,
229
+ failedCount: 0,
230
+ skippedCount: noSlotResults.length,
231
+ openCount,
232
+ slots,
233
+ pendingCount: pending.length,
234
+ },
178
235
  };
236
+ // No decision ran yet, so the pass carries no `_decisionMeta` and yields no `decision.made`.
237
+ emitActionResultEvents(this.eventCtx(), event, address, noSlotResult, { scanResult });
238
+ return noSlotResult;
179
239
  }
180
240
  // A pass with room re-arms the edge, so the next time the book fills it is logged again.
181
241
  this.noSlotsLog.full = false;
@@ -190,17 +250,43 @@ export class OpenPositionAction {
190
250
  ? (address, scannerId) => this.context.scannerModule.getScannerContext(address, scannerId)
191
251
  : undefined,
192
252
  };
193
- const contextData = this.config.context?.length
194
- ? await this.context.contextBuilder.build(this.config.context, providerRequest)
195
- : {};
196
- logger.info('context data', contextData);
197
- // Interpolate prompt template with resolved context + action params
198
- const interpolatedPrompt = this.config.decision_prompt
199
- ? interpolatePrompt(this.config.decision_prompt, {
200
- ...contextData,
201
- ...(this.config.params ?? {}),
202
- })
203
- : undefined;
253
+ // A context provider or the prompt template can throw before any decision exists. Both are
254
+ // preparation for the same decision, so they fail as one unit: the scan signals are recorded as
255
+ // failures and the pass returns, instead of the throw escaping to the caller unrecorded.
256
+ let contextData;
257
+ let interpolatedPrompt;
258
+ try {
259
+ contextData = this.config.context?.length
260
+ ? await this.context.contextBuilder.build(this.config.context, providerRequest)
261
+ : {};
262
+ logger.info('context data', contextData);
263
+ // Interpolate prompt template with resolved context + action params
264
+ interpolatedPrompt = this.config.decision_prompt
265
+ ? interpolatePrompt(this.config.decision_prompt, {
266
+ ...contextData,
267
+ ...(this.config.params ?? {}),
268
+ })
269
+ : undefined;
270
+ }
271
+ catch (error) {
272
+ const message = error instanceof Error ? error.message : String(error);
273
+ logger.error('context build failed', error, { wallet: address });
274
+ const contextErrorResults = skippedSignalResults(scanResult.signals, 'context_error', Date.now(), { success: false, error: message });
275
+ const contextErrorResult = {
276
+ success: false,
277
+ action_type: this.actionType,
278
+ summary: `Context build failed: ${message}`,
279
+ error: message,
280
+ data: {
281
+ results: contextErrorResults,
282
+ submittedCount: 0,
283
+ failedCount: contextErrorResults.length,
284
+ skippedCount: 0,
285
+ },
286
+ };
287
+ emitActionResultEvents(this.eventCtx(), event, address, contextErrorResult, { scanResult });
288
+ return contextErrorResult;
289
+ }
204
290
  // Get decision
205
291
  let decision;
206
292
  try {
@@ -216,12 +302,20 @@ export class OpenPositionAction {
216
302
  const message = error instanceof Error ? error.message : String(error);
217
303
  await emitDecisionErrorNotification(this.context, message);
218
304
  const eventCtx = this.eventCtx();
305
+ // The decision produced nothing usable, so the scan signals are the only attributable inputs.
306
+ // The rows are failures, not skips: `success: false` plus the error message, counted under
307
+ // `failedCount` so the pass does not read as benign on the span or the stored record.
308
+ const errorResults = skippedSignalResults(scanResult.signals, 'decision_error', Date.now(), { success: false, error: message });
219
309
  const errorResult = {
220
310
  success: false,
221
311
  action_type: this.actionType,
222
312
  summary: `Decision failed: ${message}`,
223
313
  error: message,
224
314
  data: {
315
+ results: errorResults,
316
+ submittedCount: 0,
317
+ failedCount: errorResults.length,
318
+ skippedCount: 0,
225
319
  _decisionMeta: {
226
320
  durationMs: 0,
227
321
  tokensUsed: null,
@@ -245,6 +339,7 @@ export class OpenPositionAction {
245
339
  // Check decision
246
340
  if (!decision.execute) {
247
341
  logger.info('decision no execute', decision);
342
+ const noExecuteResults = skippedSignalResults(scanResult.signals, 'decision_no_execute', Date.now());
248
343
  const rejectedResult = {
249
344
  success: true,
250
345
  action_type: this.actionType,
@@ -252,6 +347,10 @@ export class OpenPositionAction {
252
347
  skipped: true,
253
348
  skipReason: 'decision_no_execute',
254
349
  data: {
350
+ results: noExecuteResults,
351
+ submittedCount: 0,
352
+ failedCount: 0,
353
+ skippedCount: noExecuteResults.length,
255
354
  confidence: decision.confidence,
256
355
  reasoning: decision.reasoning,
257
356
  _decisionMeta: decisionMetaFromDecision(this.config, decision),
@@ -264,6 +363,7 @@ export class OpenPositionAction {
264
363
  // Check confidence threshold
265
364
  if (isBelowConfidence) {
266
365
  logger.info('confidence below threshold', { confidence: decision.confidence, threshold: minConfidence });
366
+ const belowConfResults = skippedSignalResults(scanResult.signals, 'below_confidence', Date.now());
267
367
  const belowConfResult = {
268
368
  success: true,
269
369
  action_type: this.actionType,
@@ -271,6 +371,10 @@ export class OpenPositionAction {
271
371
  skipped: true,
272
372
  skipReason: 'below_confidence',
273
373
  data: {
374
+ results: belowConfResults,
375
+ submittedCount: 0,
376
+ failedCount: 0,
377
+ skippedCount: belowConfResults.length,
274
378
  confidence: decision.confidence,
275
379
  threshold: minConfidence,
276
380
  _decisionMeta: decisionMetaFromDecision(this.config, decision),
@@ -284,12 +388,23 @@ export class OpenPositionAction {
284
388
  const signals = this.normalizeSignals(decision.payload);
285
389
  const meta = decisionMetaFromDecision(this.config, decision);
286
390
  if (!signals.length) {
391
+ // `success: false` plus the error code on each row, matching the batch result and its
392
+ // `decision_error`/`context_error` siblings: the decision returned something unusable, so
393
+ // the rows classify as errors rather than as a benign skip indistinguishable from no_slots.
394
+ const invalidPayloadResults = skippedSignalResults(scanResult.signals, 'invalid_payload', Date.now(), { success: false, error: 'invalid_payload' });
287
395
  const invalidPayloadResult = {
288
396
  success: false,
289
397
  action_type: this.actionType,
290
398
  summary: 'Invalid decision payload: no actionable signals',
291
399
  error: 'invalid_payload',
292
- data: { _decisionMeta: meta, _decisionPrompt: interpolatedPrompt ?? null },
400
+ data: {
401
+ results: invalidPayloadResults,
402
+ submittedCount: 0,
403
+ failedCount: invalidPayloadResults.length,
404
+ skippedCount: 0,
405
+ _decisionMeta: meta,
406
+ _decisionPrompt: interpolatedPrompt ?? null,
407
+ },
293
408
  };
294
409
  emitActionResultEvents(eventCtx, event, address, invalidPayloadResult, trackingExtras);
295
410
  return invalidPayloadResult;
@@ -300,6 +415,27 @@ export class OpenPositionAction {
300
415
  pendingOpens() {
301
416
  return this.context.pendingOpensLedger ?? this.ownPendingOpens;
302
417
  }
418
+ /**
419
+ * One clearinghouse read, fail-closed on both an unreadable response and a throw. A throw is
420
+ * the same situation as an unreadable response — an unknown book — so it resolves undefined
421
+ * and reaches the skip below rather than escaping the action with no per-signal outcome
422
+ * recorded. Shared by the gate at the top of `handle` and the stale-view refetch in the
423
+ * signal loop.
424
+ */
425
+ /**
426
+ * The bounded book read, failing closed. A read that throws — including one the deadline
427
+ * aborts — leaves the exchange view unknown, which the callers treat as a skip rather than
428
+ * trading against numbers nobody read.
429
+ */
430
+ async readClearinghouseView(strategyState, address) {
431
+ try {
432
+ return await readBookBounded(strategyState);
433
+ }
434
+ catch (error) {
435
+ logger.warn('Clearinghouse fetch threw; treating the exchange view as unknown', error, { wallet: address });
436
+ return undefined;
437
+ }
438
+ }
303
439
  /**
304
440
  * Edge-triggered full-book log: once on the transition into full, then at most once per
305
441
  * {@link NO_SLOTS_LOG_INTERVAL_MS} with the count it suppressed in between.
@@ -336,18 +472,12 @@ export class OpenPositionAction {
336
472
  * rather than risk a duplicate or an over-margined open.
337
473
  *
338
474
  * Shared by both read sites (the gate at the top of `handle`, and the stale-view refetch in
339
- * the signal loop). `sorted` is empty at the early site — there is no decision, hence no
340
- * candidates, yet — which is why the entries are built from it rather than assumed.
475
+ * the signal loop). The rows come from the caller because the two sites attribute to
476
+ * different inputs: the scan's own signals before a decision exists, the decision's
477
+ * candidates after. `riskSnapshot` is undefined at the early site, where no gate snapshot
478
+ * has been taken yet.
341
479
  */
342
- skipClearinghouseUnknown(event, address, sorted, meta, decisionPrompt, trackingExtras) {
343
- const skippedResults = sorted.map((signal) => ({
344
- asset: signal.asset,
345
- direction: signal.direction,
346
- dex: typeof signal.dex === 'string' && signal.dex.trim() !== '' ? signal.dex.trim() : 'main',
347
- score: signal.score,
348
- success: true,
349
- reason: 'clearinghouse_unknown',
350
- }));
480
+ skipClearinghouseUnknown(event, address, skippedResults, meta, riskSnapshot, decisionPrompt, trackingExtras) {
351
481
  logger.error('Skipping open: clearinghouse view unknown after retries', {
352
482
  wallet: address,
353
483
  skippedCount: skippedResults.length,
@@ -365,6 +495,7 @@ export class OpenPositionAction {
365
495
  failedCount: 0,
366
496
  skippedCount: skippedResults.length,
367
497
  ...(meta ? { _decisionMeta: meta } : {}),
498
+ ...(riskSnapshot ? { _riskSnapshot: riskSnapshot } : {}),
368
499
  _decisionPrompt: decisionPrompt ?? null,
369
500
  },
370
501
  };
@@ -416,12 +547,24 @@ export class OpenPositionAction {
416
547
  // trusts is one it read itself, on this pass.
417
548
  if (Date.now() - snapshot.fetchedAt <= CLEARINGHOUSE_VIEW_MAX_AGE_MS)
418
549
  return snapshot.view;
419
- return strategyState.fetchClearinghouseState({ retryUnreadable: true });
550
+ return this.readClearinghouseView(strategyState, address);
420
551
  })(),
421
552
  ]);
422
553
  riskSnapshot = riskSnapResult;
423
554
  if (clearinghouseView === undefined) {
424
- return this.skipClearinghouseUnknown(event, address, sorted, meta, decisionPrompt, trackingExtras);
555
+ const skippedResults = sorted.map((signal) => ({
556
+ asset: signal.asset,
557
+ direction: signal.direction,
558
+ dex: typeof signal.dex === 'string' && signal.dex.trim() !== '' ? signal.dex.trim() : 'main',
559
+ score: signal.score,
560
+ success: true,
561
+ reason: 'clearinghouse_unknown',
562
+ // Telemetry join back to this signal's scan entry — see `pushResult` in the signal loop.
563
+ ...(typeof signal.__signalIndex === 'number' ? { __signalIndex: signal.__signalIndex } : {}),
564
+ }));
565
+ // The risk snapshot resolved alongside the view, so it is in hand here. An absent
566
+ // snapshot tells telemetry the gates never ran, which on this path is false.
567
+ return this.skipClearinghouseUnknown(event, address, skippedResults, meta, riskSnapshot, decisionPrompt, trackingExtras);
425
568
  }
426
569
  // ONE snapshot decides everything below: slots, dedupe and margin. Mixing a second read
427
570
  // in mid-batch is how the counts stopped agreeing with each other in the first place.
@@ -465,16 +608,24 @@ export class OpenPositionAction {
465
608
  };
466
609
  // Telemetry: per-signal work window — every result entry below carries startedAt/endedAt.
467
610
  const signalStartedAt = Date.now();
611
+ // Telemetry join: every row this signal produces carries its index into the scan's signal
612
+ // array, so the event mapper pairs the row with THIS signal's `meta`. Without it, two signals
613
+ // on the same asset in one pass would both be read as the first one. Absent on the LLM path
614
+ // (the model authors its own signals), where the mapper falls back to matching by asset.
615
+ const signalIndex = typeof signal.__signalIndex === 'number' ? signal.__signalIndex : undefined;
616
+ const pushResult = (row) => {
617
+ results.push(signalIndex !== undefined ? { ...row, __signalIndex: signalIndex } : row);
618
+ };
468
619
  const signalDex = signal.dex;
469
620
  const submittedDex = typeof signalDex === "string" && signalDex.trim() !== "" ? signalDex.trim() : "main";
470
621
  const dexForLookup = submittedDex === "main" ? undefined : submittedDex;
471
622
  if (remainingSlots <= 0) {
472
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots', startedAt: signalStartedAt, endedAt: Date.now() });
623
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots', startedAt: signalStartedAt, endedAt: Date.now() });
473
624
  outcome("skipped", 'no_slots');
474
625
  return;
475
626
  }
476
627
  if (!direction || (direction !== 'LONG' && direction !== 'SHORT')) {
477
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction', startedAt: signalStartedAt, endedAt: Date.now() });
628
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction', startedAt: signalStartedAt, endedAt: Date.now() });
478
629
  failedCount++;
479
630
  outcome("failed", 'invalid_direction');
480
631
  return;
@@ -482,7 +633,7 @@ export class OpenPositionAction {
482
633
  // Dedupe against THIS snapshot plus the in-flight opens it is too old to show.
483
634
  const pendingKey = pendingOpenKey(asset, dexForLookup);
484
635
  if (hasOpenPositionIn(view.positions, asset, dexForLookup) || pendingKeys.has(pendingKey)) {
485
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists', startedAt: signalStartedAt, endedAt: Date.now() });
636
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists', startedAt: signalStartedAt, endedAt: Date.now() });
486
637
  outcome("skipped", 'position_already_exists');
487
638
  return;
488
639
  }
@@ -495,7 +646,7 @@ export class OpenPositionAction {
495
646
  return g;
496
647
  });
497
648
  if (gate.gate !== 'OPEN') {
498
- results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}`, startedAt: signalStartedAt, endedAt: Date.now() });
649
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}`, startedAt: signalStartedAt, endedAt: Date.now() });
499
650
  outcome("skipped", `risk_gate_${gate.gate}`);
500
651
  return;
501
652
  }
@@ -542,7 +693,7 @@ export class OpenPositionAction {
542
693
  slots: strategyState.config.slots,
543
694
  withdrawable,
544
695
  });
545
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured', startedAt: signalStartedAt, endedAt: Date.now() });
696
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured', startedAt: signalStartedAt, endedAt: Date.now() });
546
697
  failedCount++;
547
698
  outcome("failed", 'no_margin_configured');
548
699
  return;
@@ -551,7 +702,7 @@ export class OpenPositionAction {
551
702
  if (notionalValue < MIN_NOTIONAL_VALUE) {
552
703
  logger.warn(`Skipping ${asset}: notional value $${notionalValue.toFixed(2)} ` +
553
704
  `(margin $${marginAmount.toFixed(2)} × ${leverage}x) is below minimum $${MIN_NOTIONAL_VALUE}`);
554
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, marginSource, marginPct, notionalValue, success: false, reason: 'below_min_notional', startedAt: signalStartedAt, endedAt: Date.now() });
705
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, marginSource, marginPct, notionalValue, success: false, reason: 'below_min_notional', startedAt: signalStartedAt, endedAt: Date.now() });
555
706
  failedCount++;
556
707
  outcome("failed", 'below_min_notional');
557
708
  return;
@@ -562,14 +713,14 @@ export class OpenPositionAction {
562
713
  if (remainingMargin != null && remainingMargin < marginAmount) {
563
714
  logger.warn(`Skipping ${asset}: insufficient free margin ` +
564
715
  `($${remainingMargin.toFixed(2)} available < $${marginAmount.toFixed(2)} required)`);
565
- 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() });
716
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, marginSource, marginPct, requiredMarginAmount: marginAmount, availableMarginAmount: remainingMargin, success: true, reason: 'insufficient_margin', startedAt: signalStartedAt, endedAt: Date.now() });
566
717
  outcome("skipped", 'insufficient_margin');
567
718
  return;
568
719
  }
569
720
  // A previous signal in this batch may have just armed the backend pause —
570
721
  // stop submitting the rest of the batch too.
571
722
  if (this.context.backendPauseRegistry?.isPaused(address)) {
572
- results.push({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, marginSource, marginPct, success: true, reason: 'strategy_backend_paused', startedAt: signalStartedAt, endedAt: Date.now() });
723
+ pushResult({ asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, marginSource, marginPct, success: true, reason: 'strategy_backend_paused', startedAt: signalStartedAt, endedAt: Date.now() });
573
724
  outcome("skipped", 'strategy_backend_paused');
574
725
  return;
575
726
  }
@@ -654,9 +805,23 @@ export class OpenPositionAction {
654
805
  // as unfilled. The code arrives either as the top-level envelope code or, when the
655
806
  // venue reported it per-order, only inside the joined order errors — hence both.
656
807
  const restingOrder = result.errorCode === 'CREATE_ORDER_RESTING' || /ORDER_RESTING/i.test(detail);
808
+ // The server aborting its own trading-engine call (UNAVAILABLE, or the unclassified
809
+ // INTERNAL) is not a rejection either: it says nothing about whether the venue
810
+ // accepted the order, and the next pass's book is too young to answer. Freeing the
811
+ // slot here is how one ambiguous failure becomes two positions on one slot — the
812
+ // same class this fix closes. Hold it on the ambiguous TTL, exactly as the thrown
813
+ // timeout path below does.
814
+ const outcomeUnknown = !restingOrder && isOutcomeUnknownCode(result.errorCode);
657
815
  if (restingOrder) {
658
816
  ledger.settle(pendingKey, 'resting', result.orders[0]?.orderId ?? undefined);
659
817
  }
818
+ else if (outcomeUnknown) {
819
+ logger.error('open outcome unknown — order may be live', new Error(detail), {
820
+ asset,
821
+ errorCode: result.errorCode,
822
+ });
823
+ ledger.settle(pendingKey, 'ambiguous');
824
+ }
660
825
  else {
661
826
  // A rejected submit holds nothing — free the slot and the margin immediately.
662
827
  ledger.release(pendingKey);
@@ -665,6 +830,9 @@ export class OpenPositionAction {
665
830
  if (remainingMargin != null)
666
831
  remainingMargin += marginAmount;
667
832
  }
833
+ // Same reason code the thrown timeout uses: downstream (and the agent reading it)
834
+ // must reconcile against real position state, not treat a live order as rejected.
835
+ const failureReason = outcomeUnknown ? 'timeout' : 'position_open_failed';
668
836
  // Backend says the strategy is PAUSED/CLOSED: arm the pause registry so
669
837
  // subsequent signals skip instead of re-submitting (2026-07 storm class),
670
838
  // and tell the user once. Classify with the structured envelope fields
@@ -688,22 +856,22 @@ export class OpenPositionAction {
688
856
  emitEvent(logger, () => buildRuntimePausedEvent('backend_paused', 'Backend paused', detail, address));
689
857
  }
690
858
  }
691
- results.push({
859
+ pushResult({
692
860
  asset, direction, dex: submittedDex, score: signalScore,
693
861
  leverage, marginAmount, marginSource, marginPct, orderType,
694
- success: false, reason: 'position_open_failed', error: detail,
862
+ success: false, reason: failureReason, error: detail,
695
863
  startedAt: signalStartedAt, endedAt: Date.now(),
696
864
  });
697
- // Task 8a: the venue rejected the open submit.
865
+ // Task 8a: the venue rejected the open submit (or left it unresolved).
698
866
  emitOrderFailed(this.context.hookSystem, event, address, {
699
867
  asset,
700
868
  direction,
701
- reason: 'position_open_failed',
869
+ reason: failureReason,
702
870
  error: detail,
703
871
  reduceOnly: false,
704
872
  });
705
873
  failedCount++;
706
- outcome("failed", 'position_open_failed');
874
+ outcome("failed", failureReason);
707
875
  return;
708
876
  }
709
877
  // A successful submit proves the strategy is active again — re-arm immediately.
@@ -750,7 +918,7 @@ export class OpenPositionAction {
750
918
  },
751
919
  timestamp: new Date().toISOString(),
752
920
  });
753
- results.push({
921
+ pushResult({
754
922
  asset, direction, dex: submittedDex, score: signalScore,
755
923
  leverage, marginAmount, marginSource, marginPct, orderType,
756
924
  orderId: submittedOrder?.orderId ?? null,
@@ -796,7 +964,7 @@ export class OpenPositionAction {
796
964
  // reservation stands on the ambiguous TTL so the next signal counts it rather than
797
965
  // opening a second position on the same margin.
798
966
  ledger.settle(pendingKey, 'ambiguous');
799
- results.push({
967
+ pushResult({
800
968
  asset, direction, dex: submittedDex, score: signalScore,
801
969
  leverage, marginAmount, marginSource, marginPct, orderType,
802
970
  success: false, reason, error: message,