@senpi/runtime 3.0.103 → 3.0.107

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 (209) hide show
  1. package/dist/actions/action-events.d.ts +7 -21
  2. package/dist/actions/action-events.d.ts.map +1 -1
  3. package/dist/actions/action-events.js +25 -56
  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 +1 -18
  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 +17 -89
  10. package/dist/actions/close-position/close-position.action.js.map +1 -1
  11. package/dist/actions/event-derivation.d.ts +2 -40
  12. package/dist/actions/event-derivation.d.ts.map +1 -1
  13. package/dist/actions/event-derivation.js +9 -189
  14. package/dist/actions/event-derivation.js.map +1 -1
  15. package/dist/actions/open-position/open-position.action.d.ts +33 -0
  16. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  17. package/dist/actions/open-position/open-position.action.js +223 -170
  18. package/dist/actions/open-position/open-position.action.js.map +1 -1
  19. package/dist/actions/position-tracker/position-tracker.action.d.ts +1 -2
  20. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  21. package/dist/actions/position-tracker/position-tracker.action.js +15 -50
  22. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  23. package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
  24. package/dist/dsl/engine-client/event-mapping.js +0 -15
  25. package/dist/dsl/engine-client/event-mapping.js.map +1 -1
  26. package/dist/dsl/events/build-closed-event.d.ts +1 -16
  27. package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
  28. package/dist/dsl/events/build-closed-event.js +0 -5
  29. package/dist/dsl/events/build-closed-event.js.map +1 -1
  30. package/dist/dsl/events/handlers.d.ts +0 -7
  31. package/dist/dsl/events/handlers.d.ts.map +1 -1
  32. package/dist/dsl/events/handlers.js +7 -49
  33. package/dist/dsl/events/handlers.js.map +1 -1
  34. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  35. package/dist/dsl/monitor/create-monitor.js +0 -7
  36. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  37. package/dist/dsl/monitor/dsl-failure-events.d.ts +0 -2
  38. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  39. package/dist/dsl/monitor/dsl-failure-events.js +1 -2
  40. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  41. package/dist/dsl/monitor/process-one-position.d.ts +0 -6
  42. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  43. package/dist/dsl/monitor/process-one-position.js +21 -55
  44. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  45. package/dist/health/types.d.ts +0 -6
  46. package/dist/health/types.d.ts.map +1 -1
  47. package/dist/health/types.js.map +1 -1
  48. package/dist/index.d.ts +4 -16
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +47 -245
  51. package/dist/index.js.map +1 -1
  52. package/dist/runtime/__tests__/helpers/runtime-gateway-harness.d.ts +6 -0
  53. package/dist/runtime/__tests__/helpers/runtime-gateway-harness.d.ts.map +1 -1
  54. package/dist/runtime/__tests__/helpers/runtime-gateway-harness.js +25 -0
  55. package/dist/runtime/__tests__/helpers/runtime-gateway-harness.js.map +1 -1
  56. package/dist/runtime/create-runtime-context.d.ts.map +1 -1
  57. package/dist/runtime/create-runtime-context.js +5 -0
  58. package/dist/runtime/create-runtime-context.js.map +1 -1
  59. package/dist/runtime/domain-events.d.ts.map +1 -1
  60. package/dist/runtime/domain-events.js +0 -7
  61. package/dist/runtime/domain-events.js.map +1 -1
  62. package/dist/runtime/lifecycle-events.d.ts +1 -9
  63. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  64. package/dist/runtime/lifecycle-events.js +2 -11
  65. package/dist/runtime/lifecycle-events.js.map +1 -1
  66. package/dist/runtime/pending-opens-ledger.d.ts +93 -0
  67. package/dist/runtime/pending-opens-ledger.d.ts.map +1 -0
  68. package/dist/runtime/pending-opens-ledger.js +118 -0
  69. package/dist/runtime/pending-opens-ledger.js.map +1 -0
  70. package/dist/runtime/reconcile-run.d.ts +0 -2
  71. package/dist/runtime/reconcile-run.d.ts.map +1 -1
  72. package/dist/runtime/reconcile-run.js +1 -2
  73. package/dist/runtime/reconcile-run.js.map +1 -1
  74. package/dist/runtime/reconcile-state.d.ts +0 -8
  75. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  76. package/dist/runtime/reconcile-state.js +1 -3
  77. package/dist/runtime/reconcile-state.js.map +1 -1
  78. package/dist/runtime/runtime.d.ts +5 -5
  79. package/dist/runtime/runtime.d.ts.map +1 -1
  80. package/dist/runtime/runtime.js +20 -44
  81. package/dist/runtime/runtime.js.map +1 -1
  82. package/dist/runtime/strategy-registry.d.ts +1 -0
  83. package/dist/runtime/strategy-registry.d.ts.map +1 -1
  84. package/dist/runtime/strategy-registry.js +19 -0
  85. package/dist/runtime/strategy-registry.js.map +1 -1
  86. package/dist/runtime/supervisor/long-running-supervisor.d.ts +0 -8
  87. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  88. package/dist/runtime/supervisor/long-running-supervisor.js +0 -1
  89. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  90. package/dist/runtime/update-apply-deps.d.ts +6 -0
  91. package/dist/runtime/update-apply-deps.d.ts.map +1 -1
  92. package/dist/runtime/update-apply-deps.js +8 -19
  93. package/dist/runtime/update-apply-deps.js.map +1 -1
  94. package/dist/runtime-api/intake/intake-events.d.ts +3 -3
  95. package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
  96. package/dist/runtime-api/intake/intake-events.js +3 -21
  97. package/dist/runtime-api/intake/intake-events.js.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.d.ts +2 -0
  99. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  100. package/dist/runtime-api/intake/intake-mount.js +10 -57
  101. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  102. package/dist/runtime-api/intake/process-signal-post.d.ts +0 -11
  103. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  104. package/dist/runtime-api/intake/process-signal-post.js +8 -39
  105. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  106. package/dist/runtime-api/intake/scaffold-error-router.d.ts +43 -0
  107. package/dist/runtime-api/intake/scaffold-error-router.d.ts.map +1 -0
  108. package/dist/runtime-api/intake/scaffold-error-router.js +63 -0
  109. package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -0
  110. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  111. package/dist/runtime-api/intake/scanner-launcher.js +0 -12
  112. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  113. package/dist/runtime-api/intake/types.d.ts +1 -22
  114. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  115. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  116. package/dist/runtime-api/routes/signals.js +1 -7
  117. package/dist/runtime-api/routes/signals.js.map +1 -1
  118. package/dist/runtime-api/routes/signals.schema.d.ts +0 -1
  119. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  120. package/dist/runtime-api/routes/signals.schema.js +0 -6
  121. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  122. package/dist/scanners/engine/engine.d.ts +0 -5
  123. package/dist/scanners/engine/engine.d.ts.map +1 -1
  124. package/dist/scanners/engine/engine.js +2 -30
  125. package/dist/scanners/engine/engine.js.map +1 -1
  126. package/dist/scanners/external/scaffold/delivery.py +12 -79
  127. package/dist/scanners/external/scaffold/scaffold.py +9 -63
  128. package/dist/scanners/external-scanner-receiver.d.ts +0 -11
  129. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  130. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  131. package/dist/scanners/runtime-module.d.ts.map +1 -1
  132. package/dist/scanners/runtime-module.js +0 -5
  133. package/dist/scanners/runtime-module.js.map +1 -1
  134. package/dist/senpi/client.d.ts.map +1 -1
  135. package/dist/senpi/client.js +4 -12
  136. package/dist/senpi/client.js.map +1 -1
  137. package/dist/senpi/types.d.ts +0 -5
  138. package/dist/senpi/types.d.ts.map +1 -1
  139. package/dist/skills-manager/manifest.d.ts.map +1 -1
  140. package/dist/skills-manager/manifest.js +3 -12
  141. package/dist/skills-manager/manifest.js.map +1 -1
  142. package/dist/skills-manager/skills-manifest.json +3 -3
  143. package/dist/strategy/strategy-state.d.ts +26 -0
  144. package/dist/strategy/strategy-state.d.ts.map +1 -1
  145. package/dist/strategy/strategy-state.js +48 -20
  146. package/dist/strategy/strategy-state.js.map +1 -1
  147. package/dist/telemetry/event-builders.d.ts +6 -74
  148. package/dist/telemetry/event-builders.d.ts.map +1 -1
  149. package/dist/telemetry/event-builders.js +11 -108
  150. package/dist/telemetry/event-builders.js.map +1 -1
  151. package/dist/telemetry/event-store.d.ts +0 -6
  152. package/dist/telemetry/event-store.d.ts.map +1 -1
  153. package/dist/telemetry/event-store.js +6 -6
  154. package/dist/telemetry/event-store.js.map +1 -1
  155. package/dist/telemetry/metrics.d.ts +2 -14
  156. package/dist/telemetry/metrics.d.ts.map +1 -1
  157. package/dist/telemetry/metrics.js +4 -19
  158. package/dist/telemetry/metrics.js.map +1 -1
  159. package/dist/telemetry/tracing.d.ts +0 -9
  160. package/dist/telemetry/tracing.d.ts.map +1 -1
  161. package/dist/telemetry/tracing.js +0 -36
  162. package/dist/telemetry/tracing.js.map +1 -1
  163. package/dist/types/dsl/index.d.ts +0 -11
  164. package/dist/types/dsl/index.d.ts.map +1 -1
  165. package/dist/types/runtime.d.ts +9 -0
  166. package/dist/types/runtime.d.ts.map +1 -1
  167. package/dist/types/scanner.d.ts +0 -9
  168. package/dist/types/scanner.d.ts.map +1 -1
  169. package/dist/types/strategy.d.ts +6 -0
  170. package/dist/types/strategy.d.ts.map +1 -1
  171. package/dist/utils/event-catalog.d.ts +0 -81
  172. package/dist/utils/event-catalog.d.ts.map +1 -1
  173. package/dist/utils/event-catalog.js +0 -1
  174. package/dist/utils/event-catalog.js.map +1 -1
  175. package/dist/utils/keyed-mutex.d.ts +1 -3
  176. package/dist/utils/keyed-mutex.d.ts.map +1 -1
  177. package/dist/utils/keyed-mutex.js +1 -3
  178. package/dist/utils/keyed-mutex.js.map +1 -1
  179. package/openclaw.plugin.json +1 -1
  180. package/package.json +1 -1
  181. package/dist/actions/skipped-signal-results.d.ts +0 -16
  182. package/dist/actions/skipped-signal-results.d.ts.map +0 -1
  183. package/dist/actions/skipped-signal-results.js +0 -47
  184. package/dist/actions/skipped-signal-results.js.map +0 -1
  185. package/dist/scanners/external/scaffold/ulid.py +0 -58
  186. package/dist/telemetry/live-telemetry.d.ts +0 -158
  187. package/dist/telemetry/live-telemetry.d.ts.map +0 -1
  188. package/dist/telemetry/live-telemetry.js +0 -581
  189. package/dist/telemetry/live-telemetry.js.map +0 -1
  190. package/dist/telemetry/scanner-stats.d.ts +0 -136
  191. package/dist/telemetry/scanner-stats.d.ts.map +0 -1
  192. package/dist/telemetry/scanner-stats.js +0 -304
  193. package/dist/telemetry/scanner-stats.js.map +0 -1
  194. package/dist/telemetry/strategy-topology.d.ts +0 -99
  195. package/dist/telemetry/strategy-topology.d.ts.map +0 -1
  196. package/dist/telemetry/strategy-topology.js +0 -93
  197. package/dist/telemetry/strategy-topology.js.map +0 -1
  198. package/dist/telemetry/telemetry-publisher.d.ts +0 -63
  199. package/dist/telemetry/telemetry-publisher.d.ts.map +0 -1
  200. package/dist/telemetry/telemetry-publisher.js +0 -229
  201. package/dist/telemetry/telemetry-publisher.js.map +0 -1
  202. package/dist/telemetry/timeline-buffer.d.ts +0 -95
  203. package/dist/telemetry/timeline-buffer.d.ts.map +0 -1
  204. package/dist/telemetry/timeline-buffer.js +0 -237
  205. package/dist/telemetry/timeline-buffer.js.map +0 -1
  206. package/dist/utils/ulid.d.ts +0 -9
  207. package/dist/utils/ulid.d.ts.map +0 -1
  208. package/dist/utils/ulid.js +0 -43
  209. package/dist/utils/ulid.js.map +0 -1
@@ -12,15 +12,30 @@ import { createLogger } from '../../utils/logger.js';
12
12
  import { positionRegistryKey } from '../../runtime/position-id-registry.js';
13
13
  import { resolveOpenOrderType, resolveFeeOptimizedLimitOptions } from '../../utils/resolve-order-type.js';
14
14
  import { emitActionResultEvents, emitOrderPlaced, emitOrderFilled, emitOrderFailed, buildActionEventContext } from '../action-events.js';
15
- import { skippedSignalResults } from '../skipped-signal-results.js';
16
15
  import { withSpan } from '../../telemetry/tracing.js';
17
16
  import { SpanStatusCode } from '@opentelemetry/api';
18
17
  import { classifyToolError } from '../../senpi/tool-errors.js';
18
+ import { hasOpenPositionIn } from '../../strategy/strategy-state.js';
19
+ import { createPendingOpensLedger, pendingOpenKey } from '../../runtime/pending-opens-ledger.js';
20
+ import { recordSignalOutcome } from '../../telemetry/metrics.js';
19
21
  const logger = createLogger("[actions][open-position]");
20
22
  /** Hyperliquid minimum notional value (margin × leverage) required to open a position. */
21
23
  const MIN_NOTIONAL_VALUE = 10;
22
24
  /** Minimum leverage floor — positions cannot go below 1x. */
23
25
  const MIN_LEVERAGE = 1;
26
+ /**
27
+ * How long the view fetched at the top of `handle` is trusted before the signal loop
28
+ * refetches it.
29
+ *
30
+ * The gap between the two is a decision, and an LLM decision takes seconds — long enough
31
+ * for a fill, a liquidation or a manual close to move the book underneath the numbers the
32
+ * entry is about to be sized against. Five seconds is the order of the snapshot's own age,
33
+ * so anything older is worth a second read; the rule-decision path resolves well inside it
34
+ * and never pays for one.
35
+ */
36
+ const CLEARINGHOUSE_VIEW_MAX_AGE_MS = 5_000;
37
+ /** Minimum gap between repeats of the full-book log while the book STAYS full. */
38
+ const NO_SLOTS_LOG_INTERVAL_MS = 30 * 60 * 1000;
24
39
  // ─── Open Position Action ───────────────────────────────────────────────────
25
40
  export class OpenPositionAction {
26
41
  config;
@@ -28,6 +43,21 @@ export class OpenPositionAction {
28
43
  name;
29
44
  actionType = 'OPEN_POSITION';
30
45
  decisionEngine;
46
+ /**
47
+ * Ledger of last resort for a context that carries none (standalone scripts, older call
48
+ * sites). Private to this action, so it still counts this action's own in-flight opens.
49
+ */
50
+ ownPendingOpens = createPendingOpensLedger();
51
+ /**
52
+ * Edge-trigger state for the full-book log: `full` is the level, the rest bound the repeat
53
+ * rate while it stays full. Instance state on purpose — a full book is a property of this
54
+ * strategy, and the action is one per recipe.
55
+ */
56
+ noSlotsLog = {
57
+ full: false,
58
+ lastLoggedAt: 0,
59
+ suppressed: 0,
60
+ };
31
61
  constructor(config, context) {
32
62
  this.config = config;
33
63
  this.context = context;
@@ -94,53 +124,61 @@ export class OpenPositionAction {
94
124
  // re-submitting on every signal.
95
125
  if (this.context.backendPauseRegistry?.isPaused(address)) {
96
126
  logger.warn('Skipping signals: strategy is paused or closed on the backend', { wallet: address });
97
- const pausedResults = skippedSignalResults(scanResult.signals, 'strategy_backend_paused', Date.now());
98
- const pausedResult = {
127
+ return {
99
128
  success: true,
100
129
  action_type: this.actionType,
101
130
  summary: 'Strategy is paused or closed on the backend — skipping signals until it is resumed',
102
131
  skipped: true,
103
132
  skipReason: 'strategy_backend_paused',
104
- data: {
105
- results: pausedResults,
106
- submittedCount: 0,
107
- failedCount: 0,
108
- skippedCount: pausedResults.length,
109
- },
110
133
  };
111
- // No decision ran yet, so the pass carries no `_decisionMeta` and yields no `decision.made`.
112
- emitActionResultEvents(this.eventCtx(), event, address, pausedResult, { scanResult });
113
- return pausedResult;
114
134
  }
115
- // Early exit: no slots.
135
+ // The exchange view for THIS pass. Read here, once, and threaded through the rest of it.
136
+ //
137
+ // This context's StrategyState is not the instance the DSL monitor refreshes each tick —
138
+ // they are two objects built from the same config — so its cached view was only ever
139
+ // written by an earlier pass through this action, if at all. Reading the cache therefore
140
+ // froze the slot gate, the dedupe check and the margin budget at whatever the first
141
+ // signal after boot happened to see: a full snapshot skipped every later signal forever,
142
+ // an empty one let the slot cap and the dedupe never bind at all.
143
+ const view = await strategyState.fetchClearinghouseState({ retryUnreadable: true });
144
+ if (view === undefined) {
145
+ return this.skipClearinghouseUnknown(event, address, [], undefined, undefined, { scanResult });
146
+ }
147
+ const snapshot = { view, fetchedAt: Date.now() };
148
+ // Early exit: no slots. Counted as snapshot positions PLUS opens this runtime has already
149
+ // submitted that the snapshot is too old to show (see PendingOpensLedger).
150
+ //
116
151
  // NOTE re "zero slots skips cap alerts": we intentionally do NOT evaluate risk
117
152
  // halts/cap self-pause here. It would add a per-signal getPnlHistoryWithSnapshot
118
153
  // MCP call on the full-book path (costly on a full + high-signal-rate strategy),
119
154
  // and that halt data is fetched nowhere else to reuse (only risk-guard.checkGate
120
- // fetches it; the DSL monitor fetches clearinghouse = different data). Self-pause
155
+ // fetches it — the clearinghouse read above is different data). Self-pause
121
156
  // alerts still fire via the normal open path once a slot frees and a signal
122
157
  // arrives (delayed, not lost). A durable fix — a periodic lifecycle driven off
123
158
  // the monitor tick / bus events (also catches a fully-silent producer) — is
124
159
  // deliberately out of scope here.
125
- if (!strategyState.getAvailableSlots?.()) {
126
- const noSlotResults = skippedSignalResults(scanResult.signals, 'no_slots', Date.now());
127
- const noSlotResult = {
160
+ const ledger = this.pendingOpens();
161
+ ledger.reconcile(view.positions);
162
+ const pending = ledger.unseen(view.positions);
163
+ const slots = strategyState.config.slots;
164
+ const openCount = view.positions.length + pending.length;
165
+ if (openCount >= slots) {
166
+ 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 {
128
172
  success: true,
129
173
  action_type: this.actionType,
130
- summary: 'No slots available',
174
+ summary: `No slots available (${openCount}/${slots} open)`,
131
175
  skipped: true,
132
176
  skipReason: 'no_slots',
133
- data: {
134
- results: noSlotResults,
135
- submittedCount: 0,
136
- failedCount: 0,
137
- skippedCount: noSlotResults.length,
138
- },
177
+ data: { openCount, slots, pendingCount: pending.length },
139
178
  };
140
- // No decision ran yet, so the pass carries no `_decisionMeta` and yields no `decision.made`.
141
- emitActionResultEvents(this.eventCtx(), event, address, noSlotResult, { scanResult });
142
- return noSlotResult;
143
179
  }
180
+ // A pass with room re-arms the edge, so the next time the book fills it is logged again.
181
+ this.noSlotsLog.full = false;
144
182
  logger.info('building context');
145
183
  // Build context
146
184
  const providerRequest = {
@@ -152,43 +190,17 @@ export class OpenPositionAction {
152
190
  ? (address, scannerId) => this.context.scannerModule.getScannerContext(address, scannerId)
153
191
  : undefined,
154
192
  };
155
- // A context provider or the prompt template can throw before any decision exists. Both are
156
- // preparation for the same decision, so they fail as one unit: the scan signals are recorded as
157
- // failures and the pass returns, instead of the throw escaping to the caller unrecorded.
158
- let contextData;
159
- let interpolatedPrompt;
160
- try {
161
- contextData = this.config.context?.length
162
- ? await this.context.contextBuilder.build(this.config.context, providerRequest)
163
- : {};
164
- logger.info('context data', contextData);
165
- // Interpolate prompt template with resolved context + action params
166
- interpolatedPrompt = this.config.decision_prompt
167
- ? interpolatePrompt(this.config.decision_prompt, {
168
- ...contextData,
169
- ...(this.config.params ?? {}),
170
- })
171
- : undefined;
172
- }
173
- catch (error) {
174
- const message = error instanceof Error ? error.message : String(error);
175
- logger.error('context build failed', error, { wallet: address });
176
- const contextErrorResults = skippedSignalResults(scanResult.signals, 'context_error', Date.now(), { success: false, error: message });
177
- const contextErrorResult = {
178
- success: false,
179
- action_type: this.actionType,
180
- summary: `Context build failed: ${message}`,
181
- error: message,
182
- data: {
183
- results: contextErrorResults,
184
- submittedCount: 0,
185
- failedCount: contextErrorResults.length,
186
- skippedCount: 0,
187
- },
188
- };
189
- emitActionResultEvents(this.eventCtx(), event, address, contextErrorResult, { scanResult });
190
- return contextErrorResult;
191
- }
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;
192
204
  // Get decision
193
205
  let decision;
194
206
  try {
@@ -204,20 +216,12 @@ export class OpenPositionAction {
204
216
  const message = error instanceof Error ? error.message : String(error);
205
217
  await emitDecisionErrorNotification(this.context, message);
206
218
  const eventCtx = this.eventCtx();
207
- // The decision produced nothing usable, so the scan signals are the only attributable inputs.
208
- // The rows are failures, not skips: `success: false` plus the error message, counted under
209
- // `failedCount` so the pass does not read as benign on the span or the stored record.
210
- const errorResults = skippedSignalResults(scanResult.signals, 'decision_error', Date.now(), { success: false, error: message });
211
219
  const errorResult = {
212
220
  success: false,
213
221
  action_type: this.actionType,
214
222
  summary: `Decision failed: ${message}`,
215
223
  error: message,
216
224
  data: {
217
- results: errorResults,
218
- submittedCount: 0,
219
- failedCount: errorResults.length,
220
- skippedCount: 0,
221
225
  _decisionMeta: {
222
226
  durationMs: 0,
223
227
  tokensUsed: null,
@@ -241,7 +245,6 @@ export class OpenPositionAction {
241
245
  // Check decision
242
246
  if (!decision.execute) {
243
247
  logger.info('decision no execute', decision);
244
- const noExecuteResults = skippedSignalResults(scanResult.signals, 'decision_no_execute', Date.now());
245
248
  const rejectedResult = {
246
249
  success: true,
247
250
  action_type: this.actionType,
@@ -249,10 +252,6 @@ export class OpenPositionAction {
249
252
  skipped: true,
250
253
  skipReason: 'decision_no_execute',
251
254
  data: {
252
- results: noExecuteResults,
253
- submittedCount: 0,
254
- failedCount: 0,
255
- skippedCount: noExecuteResults.length,
256
255
  confidence: decision.confidence,
257
256
  reasoning: decision.reasoning,
258
257
  _decisionMeta: decisionMetaFromDecision(this.config, decision),
@@ -265,7 +264,6 @@ export class OpenPositionAction {
265
264
  // Check confidence threshold
266
265
  if (isBelowConfidence) {
267
266
  logger.info('confidence below threshold', { confidence: decision.confidence, threshold: minConfidence });
268
- const belowConfResults = skippedSignalResults(scanResult.signals, 'below_confidence', Date.now());
269
267
  const belowConfResult = {
270
268
  success: true,
271
269
  action_type: this.actionType,
@@ -273,10 +271,6 @@ export class OpenPositionAction {
273
271
  skipped: true,
274
272
  skipReason: 'below_confidence',
275
273
  data: {
276
- results: belowConfResults,
277
- submittedCount: 0,
278
- failedCount: 0,
279
- skippedCount: belowConfResults.length,
280
274
  confidence: decision.confidence,
281
275
  threshold: minConfidence,
282
276
  _decisionMeta: decisionMetaFromDecision(this.config, decision),
@@ -290,27 +284,92 @@ export class OpenPositionAction {
290
284
  const signals = this.normalizeSignals(decision.payload);
291
285
  const meta = decisionMetaFromDecision(this.config, decision);
292
286
  if (!signals.length) {
293
- // `success: false` on each row so the outcome classifies as rejected, matching the batch
294
- // result, and the rows are counted as failures rather than skips.
295
- const invalidPayloadResults = skippedSignalResults(scanResult.signals, 'invalid_payload', Date.now(), { success: false });
296
287
  const invalidPayloadResult = {
297
288
  success: false,
298
289
  action_type: this.actionType,
299
290
  summary: 'Invalid decision payload: no actionable signals',
300
291
  error: 'invalid_payload',
301
- data: {
302
- results: invalidPayloadResults,
303
- submittedCount: 0,
304
- failedCount: invalidPayloadResults.length,
305
- skippedCount: 0,
306
- _decisionMeta: meta,
307
- _decisionPrompt: interpolatedPrompt ?? null,
308
- },
292
+ data: { _decisionMeta: meta, _decisionPrompt: interpolatedPrompt ?? null },
309
293
  };
310
294
  emitActionResultEvents(eventCtx, event, address, invalidPayloadResult, trackingExtras);
311
295
  return invalidPayloadResult;
312
296
  }
313
- return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt, trackingExtras);
297
+ return this.handleSignals(signals, event, strategyState, snapshot, decision.reasoning, meta, interpolatedPrompt, trackingExtras);
298
+ }
299
+ /** The pending-opens ledger this action counts against — the context's when it has one. */
300
+ pendingOpens() {
301
+ return this.context.pendingOpensLedger ?? this.ownPendingOpens;
302
+ }
303
+ /**
304
+ * Edge-triggered full-book log: once on the transition into full, then at most once per
305
+ * {@link NO_SLOTS_LOG_INTERVAL_MS} with the count it suppressed in between.
306
+ *
307
+ * The early `no_slots` skip used to return silently, so a strategy that had stopped
308
+ * entering looked identical in the logs to one with no signals — the single hardest state
309
+ * to diagnose from a box. Logging every signal instead would bury the log, which is how it
310
+ * came to be silent in the first place.
311
+ */
312
+ logBookFull(address, openCount, slots, pendingCoins, positions) {
313
+ const now = Date.now();
314
+ const attributes = {
315
+ wallet: address,
316
+ open: openCount,
317
+ slots,
318
+ pending: pendingCoins.length,
319
+ coins: [...positions.map((p) => p.coin), ...pendingCoins],
320
+ };
321
+ if (!this.noSlotsLog.full) {
322
+ this.noSlotsLog = { full: true, lastLoggedAt: now, suppressed: 0 };
323
+ logger.info('skipping: no_slots', attributes);
324
+ return;
325
+ }
326
+ this.noSlotsLog.suppressed++;
327
+ if (now - this.noSlotsLog.lastLoggedAt >= NO_SLOTS_LOG_INTERVAL_MS) {
328
+ logger.info('skipping: no_slots', { ...attributes, suppressed: this.noSlotsLog.suppressed });
329
+ this.noSlotsLog.lastLoggedAt = now;
330
+ this.noSlotsLog.suppressed = 0;
331
+ }
332
+ }
333
+ /**
334
+ * Never open against an unknown exchange view. If the view is undefined after retries we
335
+ * cannot tell what is already open or how much free margin exists — skip every signal
336
+ * rather than risk a duplicate or an over-margined open.
337
+ *
338
+ * 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.
341
+ */
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
+ }));
351
+ logger.error('Skipping open: clearinghouse view unknown after retries', {
352
+ wallet: address,
353
+ skippedCount: skippedResults.length,
354
+ assets: skippedResults.map((r) => r.asset),
355
+ });
356
+ const skipResult = {
357
+ success: true,
358
+ action_type: this.actionType,
359
+ summary: 'Clearinghouse view unknown — skipped open',
360
+ skipped: true,
361
+ skipReason: 'clearinghouse_unknown',
362
+ data: {
363
+ results: skippedResults,
364
+ submittedCount: 0,
365
+ failedCount: 0,
366
+ skippedCount: skippedResults.length,
367
+ ...(meta ? { _decisionMeta: meta } : {}),
368
+ _decisionPrompt: decisionPrompt ?? null,
369
+ },
370
+ };
371
+ emitActionResultEvents(this.eventCtx(), event, address, skipResult, trackingExtras);
372
+ return skipResult;
314
373
  }
315
374
  normalizeSignals(payload) {
316
375
  if (Array.isArray(payload.signals))
@@ -325,7 +384,7 @@ export class OpenPositionAction {
325
384
  }
326
385
  return [];
327
386
  }
328
- async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt, trackingExtras) {
387
+ async handleSignals(signals, event, strategyState, snapshot, reasoning, meta, decisionPrompt, trackingExtras) {
329
388
  const address = event.address;
330
389
  const orderType = resolveOpenOrderType(this.config.params?.order_type);
331
390
  const feeOptimizedLimitOptions = orderType === 'FEE_OPTIMIZED_LIMIT'
@@ -352,70 +411,39 @@ export class OpenPositionAction {
352
411
  return undefined;
353
412
  })(),
354
413
  (async () => {
355
- // Resolve the exchange view once: it sizes margin_pct AND feeds the
356
- // free-margin pre-flight below. A cached defined view is trusted;
357
- // otherwise fetch with retry-on-unreadable so a transient unreadable
358
- // response is retried rather than treated as an unknown book.
359
- // A throw here is the same situation as an unreadable response — an unknown
360
- // book — so it resolves undefined and falls into the skip below rather than
361
- // escaping the action with no per-signal outcome recorded.
362
- try {
363
- return strategyState.getClearinghouseState()
364
- ?? await strategyState.fetchClearinghouseState({ retryUnreadable: true });
365
- }
366
- catch (error) {
367
- logger.warn('Clearinghouse fetch threw; treating the exchange view as unknown', error, { wallet: address });
368
- return undefined;
369
- }
414
+ // The view handed down from the slot gate, re-read only if the decision took long
415
+ // enough for it to go stale. No cached-getter fallback: the only view this action
416
+ // trusts is one it read itself, on this pass.
417
+ if (Date.now() - snapshot.fetchedAt <= CLEARINGHOUSE_VIEW_MAX_AGE_MS)
418
+ return snapshot.view;
419
+ return strategyState.fetchClearinghouseState({ retryUnreadable: true });
370
420
  })(),
371
421
  ]);
372
422
  riskSnapshot = riskSnapResult;
373
- // Never open against an unknown exchange view. If the view is still
374
- // undefined after retries, we cannot tell what is already open or how much
375
- // free margin exists — skip every signal rather than risk a duplicate or
376
- // over-margined open.
377
423
  if (clearinghouseView === undefined) {
378
- const skippedResults = sorted.map((signal) => ({
379
- asset: signal.asset,
380
- direction: signal.direction,
381
- dex: typeof signal.dex === 'string' && signal.dex.trim() !== '' ? signal.dex.trim() : 'main',
382
- score: signal.score,
383
- success: true,
384
- reason: 'clearinghouse_unknown',
385
- }));
386
- logger.error('Skipping open: clearinghouse view unknown after retries', {
387
- wallet: address,
388
- skippedCount: skippedResults.length,
389
- assets: skippedResults.map((r) => r.asset),
390
- });
391
- const skipResult = {
392
- success: true,
393
- action_type: this.actionType,
394
- summary: 'Clearinghouse view unknown — skipped open',
395
- skipped: true,
396
- skipReason: 'clearinghouse_unknown',
397
- data: {
398
- results: skippedResults,
399
- submittedCount: 0,
400
- failedCount: 0,
401
- skippedCount: skippedResults.length,
402
- ...(meta ? { _decisionMeta: meta } : {}),
403
- // The snapshot resolved alongside the clearinghouse view, so it is in hand here. An
404
- // absent snapshot tells telemetry the gates never ran, which on this path is false.
405
- ...(riskSnapshot ? { _riskSnapshot: riskSnapshot } : {}),
406
- _decisionPrompt: decisionPrompt ?? null,
407
- },
408
- };
409
- emitActionResultEvents(this.eventCtx(), event, address, skipResult, trackingExtras);
410
- return skipResult;
424
+ return this.skipClearinghouseUnknown(event, address, sorted, meta, decisionPrompt, trackingExtras);
411
425
  }
412
- const withdrawable = clearinghouseView.withdrawable;
413
- let remainingSlots = strategyState.getAvailableSlots?.() ?? 0;
414
- // Free-margin pre-flight budget. Undefined when withdrawable is unknown
415
- // (no clearinghouse read) — in that case we do NOT block, preserving prior behavior.
426
+ // ONE snapshot decides everything below: slots, dedupe and margin. Mixing a second read
427
+ // in mid-batch is how the counts stopped agreeing with each other in the first place.
428
+ const view = clearinghouseView;
429
+ const withdrawable = view.withdrawable;
430
+ const ledger = this.pendingOpens();
431
+ ledger.reconcile(view.positions);
432
+ const pendingEntries = ledger.unseen(view.positions);
433
+ // Keys of opens already in flight: a dedupe hit the snapshot cannot produce yet.
434
+ const pendingKeys = new Set(pendingEntries.map((entry) => entry.key));
435
+ const pendingMargin = pendingEntries.reduce((sum, entry) => sum + entry.marginAmount, 0);
436
+ let remainingSlots = Math.max(0, strategyState.config.slots - (view.positions.length + pendingEntries.length));
437
+ // Free-margin pre-flight budget, and the basis `margin_pct` sizes off. Undefined when
438
+ // withdrawable is unknown — in that case we do NOT block, preserving prior behavior.
439
+ // Margin already committed by in-flight opens is subtracted: the snapshot still counts it
440
+ // as free, so sizing off the raw figure would size two positions out of one pot. Erring
441
+ // small is the safe direction — an undersized entry is a worse fill, an oversized one is
442
+ // a rejection or a liquidation-adjacent position.
443
+ const availableWithdrawable = withdrawable != null ? withdrawable - pendingMargin : undefined;
416
444
  // Decremented per submitted signal so later signals in the same batch are
417
445
  // evaluated against the margin already consumed by earlier ones.
418
- let remainingMargin = withdrawable;
446
+ let remainingMargin = availableWithdrawable;
419
447
  const results = [];
420
448
  let submittedCount = 0;
421
449
  let failedCount = 0;
@@ -451,7 +479,9 @@ export class OpenPositionAction {
451
479
  outcome("failed", 'invalid_direction');
452
480
  return;
453
481
  }
454
- if (strategyState.hasOpenPosition(asset, dexForLookup)) {
482
+ // Dedupe against THIS snapshot plus the in-flight opens it is too old to show.
483
+ const pendingKey = pendingOpenKey(asset, dexForLookup);
484
+ if (hasOpenPositionIn(view.positions, asset, dexForLookup) || pendingKeys.has(pendingKey)) {
455
485
  results.push({ asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists', startedAt: signalStartedAt, endedAt: Date.now() });
456
486
  outcome("skipped", 'position_already_exists');
457
487
  return;
@@ -486,7 +516,9 @@ export class OpenPositionAction {
486
516
  const { amount: marginAmount, source: marginSource } = resolveMarginWithSource({
487
517
  signalMarginPct,
488
518
  marginPct: strategyState.config.marginPct,
489
- withdrawable,
519
+ // The pending-adjusted figure, not the raw snapshot one: a percent of a balance that
520
+ // an in-flight open has already claimed sizes the same dollars twice.
521
+ withdrawable: availableWithdrawable,
490
522
  marginPerSlot: strategyState.config.marginPerSlot,
491
523
  budget: strategyState.config.budget,
492
524
  slots: strategyState.config.slots,
@@ -551,11 +583,23 @@ export class OpenPositionAction {
551
583
  direction,
552
584
  source: marginSource,
553
585
  withdrawable,
554
- accountValue: clearinghouseView.accountValue,
586
+ availableWithdrawable,
587
+ accountValue: view.accountValue,
555
588
  marginAmount,
556
589
  leverage,
557
590
  notional: notionalValue,
558
591
  });
592
+ // Reserved BEFORE the await: between the submit and its outcome the next signal can
593
+ // already be evaluating, and the exchange snapshot will not show this for seconds.
594
+ ledger.reserve(asset, submittedDex, marginAmount);
595
+ pendingKeys.add(pendingKey);
596
+ // Charge the batch counters here, not on the success path: the reservation just made
597
+ // stands for every outcome below except an outright rejection (resting, filled, and
598
+ // even a throw all keep the slot/margin held). Refund only where the reservation is
599
+ // actually released.
600
+ remainingSlots--;
601
+ if (remainingMargin != null)
602
+ remainingMargin -= marginAmount;
559
603
  const result = await withSpan("mcp.createPosition", { "senpi.signal.asset": asset, "senpi.signal.direction": direction }, async (mcpSpan) => {
560
604
  const r = await this.context.senpi.createPosition({
561
605
  strategyWalletAddress: address,
@@ -579,10 +623,6 @@ export class OpenPositionAction {
579
623
  const detail = r.error || orderErrors || 'unknown error';
580
624
  mcpSpan.addEvent("order.failed");
581
625
  mcpSpan.setStatus({ code: SpanStatusCode.ERROR, message: detail });
582
- // Cross-system correlation handle: the server's trace id for this
583
- // rejection, when the envelope carried one.
584
- if (r.errorTraceId)
585
- mcpSpan.setAttribute("senpi.error.trace_id", r.errorTraceId);
586
626
  }
587
627
  else {
588
628
  const o = r.orders[0];
@@ -608,19 +648,32 @@ export class OpenPositionAction {
608
648
  .map((o) => `${o.coin}: ${o.error}`)
609
649
  .join('; ');
610
650
  const detail = result.error || orderErrors || 'unknown error';
651
+ // A resting maker order is NOT a rejection: the order is live on the book and will
652
+ // consume this slot and this margin the moment it fills. The reservation therefore
653
+ // stands (on the longer resting TTL) while the result entry still reports the open
654
+ // as unfilled. The code arrives either as the top-level envelope code or, when the
655
+ // venue reported it per-order, only inside the joined order errors — hence both.
656
+ const restingOrder = result.errorCode === 'CREATE_ORDER_RESTING' || /ORDER_RESTING/i.test(detail);
657
+ if (restingOrder) {
658
+ ledger.settle(pendingKey, 'resting', result.orders[0]?.orderId ?? undefined);
659
+ }
660
+ else {
661
+ // A rejected submit holds nothing — free the slot and the margin immediately.
662
+ ledger.release(pendingKey);
663
+ pendingKeys.delete(pendingKey);
664
+ remainingSlots++;
665
+ if (remainingMargin != null)
666
+ remainingMargin += marginAmount;
667
+ }
611
668
  // Backend says the strategy is PAUSED/CLOSED: arm the pause registry so
612
669
  // subsequent signals skip instead of re-submitting (2026-07 storm class),
613
670
  // and tell the user once. Classify with the structured envelope fields
614
671
  // (code/retryable) when the client parsed them, message text otherwise.
615
- // When the envelope carried no code but exactly the orders did (the
616
- // per-order {code, description} shape), classify with the first
617
- // structured order code rather than falling back to message text.
618
- const effectiveCode = result.errorCode ?? result.orders.find((o) => o.errorCode)?.errorCode ?? null;
619
672
  const classification = classifyToolError({
620
673
  success: false,
621
674
  error: {
622
675
  message: detail,
623
- ...(effectiveCode != null ? { code: effectiveCode } : {}),
676
+ ...(result.errorCode != null ? { code: result.errorCode } : {}),
624
677
  ...(result.errorRetryable != null ? { retryable: result.errorRetryable } : {}),
625
678
  },
626
679
  });
@@ -639,7 +692,6 @@ export class OpenPositionAction {
639
692
  asset, direction, dex: submittedDex, score: signalScore,
640
693
  leverage, marginAmount, marginSource, marginPct, orderType,
641
694
  success: false, reason: 'position_open_failed', error: detail,
642
- errorCode: effectiveCode, errorTraceId: result.errorTraceId ?? null,
643
695
  startedAt: signalStartedAt, endedAt: Date.now(),
644
696
  });
645
697
  // Task 8a: the venue rejected the open submit.
@@ -648,8 +700,6 @@ export class OpenPositionAction {
648
700
  direction,
649
701
  reason: 'position_open_failed',
650
702
  error: detail,
651
- errorCode: effectiveCode ?? undefined,
652
- errorTraceId: result.errorTraceId ?? undefined,
653
703
  reduceOnly: false,
654
704
  });
655
705
  failedCount++;
@@ -659,6 +709,8 @@ export class OpenPositionAction {
659
709
  // A successful submit proves the strategy is active again — re-arm immediately.
660
710
  this.context.backendPauseRegistry?.clear(address);
661
711
  const submittedOrder = result.orders[0];
712
+ // Accepted: hold the slot and the margin until a snapshot carries the position.
713
+ ledger.settle(pendingKey, 'filled', submittedOrder?.orderId ?? undefined);
662
714
  // Mint the position.id at intent — the moment the open order is submitted,
663
715
  // before the position is confirmed open — so the id rides the action.open
664
716
  // emit and the open trace correlates by position.id directly (no ~60s
@@ -730,9 +782,6 @@ export class OpenPositionAction {
730
782
  });
731
783
  }
732
784
  submittedCount++;
733
- remainingSlots--;
734
- if (remainingMargin != null)
735
- remainingMargin -= marginAmount;
736
785
  outcome("submitted", 'submitted');
737
786
  }
738
787
  catch (error) {
@@ -743,6 +792,10 @@ export class OpenPositionAction {
743
792
  const timedOut = error instanceof SenpiTimeoutError;
744
793
  const reason = timedOut ? 'timeout' : 'exception';
745
794
  logger.error(timedOut ? 'open timed out — order may be live' : 'open failed', error, { asset });
795
+ // The submit may have landed server-side; nothing here proves it did not. The
796
+ // reservation stands on the ambiguous TTL so the next signal counts it rather than
797
+ // opening a second position on the same margin.
798
+ ledger.settle(pendingKey, 'ambiguous');
746
799
  results.push({
747
800
  asset, direction, dex: submittedDex, score: signalScore,
748
801
  leverage, marginAmount, marginSource, marginPct, orderType,