@senpi/runtime 1.1.0-dev.runtime-phase-2.20260609123811 → 1.1.0-dev.runtime-phase-2.20260609132603
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/close-position/close-position.action.d.ts +16 -0
- package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
- package/dist/actions/close-position/close-position.action.js +299 -38
- package/dist/actions/close-position/close-position.action.js.map +1 -1
- package/dist/actions/decision-engine.d.ts +2 -0
- package/dist/actions/decision-engine.d.ts.map +1 -1
- package/dist/actions/decision-engine.js.map +1 -1
- package/dist/actions/llm-decision/factory.d.ts +3 -0
- package/dist/actions/llm-decision/factory.d.ts.map +1 -1
- package/dist/actions/llm-decision/factory.js +1 -0
- package/dist/actions/llm-decision/factory.js.map +1 -1
- package/dist/actions/llm-decision/openclaw-llm-decision.d.ts +13 -0
- package/dist/actions/llm-decision/openclaw-llm-decision.d.ts.map +1 -1
- package/dist/actions/llm-decision/openclaw-llm-decision.js +195 -68
- package/dist/actions/llm-decision/openclaw-llm-decision.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts +12 -0
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +346 -123
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/actions/otel-helpers.d.ts +65 -0
- package/dist/actions/otel-helpers.d.ts.map +1 -0
- package/dist/actions/otel-helpers.js +122 -0
- package/dist/actions/otel-helpers.js.map +1 -0
- package/dist/actions/position-tracker/position-tracker.action.d.ts +6 -0
- package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
- package/dist/actions/position-tracker/position-tracker.action.js +48 -0
- package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
- package/dist/otel/context.d.ts +24 -0
- package/dist/otel/context.d.ts.map +1 -0
- package/dist/otel/context.js +29 -0
- package/dist/otel/context.js.map +1 -0
- package/dist/otel/emitter.d.ts +73 -0
- package/dist/otel/emitter.d.ts.map +1 -0
- package/dist/otel/emitter.js +248 -0
- package/dist/otel/emitter.js.map +1 -0
- package/dist/otel/encode.d.ts +48 -0
- package/dist/otel/encode.d.ts.map +1 -0
- package/dist/otel/encode.js +100 -0
- package/dist/otel/encode.js.map +1 -0
- package/dist/otel/index.d.ts +13 -0
- package/dist/otel/index.d.ts.map +1 -0
- package/dist/otel/index.js +10 -0
- package/dist/otel/index.js.map +1 -0
- package/dist/otel/transport.d.ts +57 -0
- package/dist/otel/transport.d.ts.map +1 -0
- package/dist/otel/transport.js +208 -0
- package/dist/otel/transport.js.map +1 -0
- package/dist/otel/types.d.ts +62 -0
- package/dist/otel/types.d.ts.map +1 -0
- package/dist/otel/types.js +2 -0
- package/dist/otel/types.js.map +1 -0
- package/dist/runtime/create-actions.d.ts.map +1 -1
- package/dist/runtime/create-actions.js +2 -0
- package/dist/runtime/create-actions.js.map +1 -1
- package/dist/runtime/create-runtime-context.d.ts +2 -0
- package/dist/runtime/create-runtime-context.d.ts.map +1 -1
- package/dist/runtime/create-runtime-context.js +1 -0
- package/dist/runtime/create-runtime-context.js.map +1 -1
- package/dist/runtime/create-scanner-module.d.ts +1 -1
- package/dist/runtime/create-scanner-module.d.ts.map +1 -1
- package/dist/runtime/create-scanner-module.js +2 -1
- package/dist/runtime/create-scanner-module.js.map +1 -1
- package/dist/runtime/runtime-schema.d.ts +24 -0
- package/dist/runtime/runtime-schema.d.ts.map +1 -1
- package/dist/runtime/runtime-schema.js +1 -0
- package/dist/runtime/runtime-schema.js.map +1 -1
- package/dist/runtime/runtime.d.ts +5 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +127 -4
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/scanners/engine/data-providers.d.ts +2 -0
- package/dist/scanners/engine/data-providers.d.ts.map +1 -1
- package/dist/scanners/engine/data-providers.js.map +1 -1
- package/dist/scanners/engine/engine.d.ts.map +1 -1
- package/dist/scanners/engine/engine.js +68 -9
- package/dist/scanners/engine/engine.js.map +1 -1
- package/dist/scanners/runtime-module.d.ts +2 -0
- package/dist/scanners/runtime-module.d.ts.map +1 -1
- package/dist/scanners/runtime-module.js +1 -0
- package/dist/scanners/runtime-module.js.map +1 -1
- package/dist/senpi/client.d.ts +2 -2
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +120 -20
- package/dist/senpi/client.js.map +1 -1
- package/dist/senpi/types.d.ts +68 -1
- package/dist/senpi/types.d.ts.map +1 -1
- package/dist/strategy/strategy-state.d.ts +1 -1
- package/dist/strategy/strategy-state.d.ts.map +1 -1
- package/dist/tracking/spans/plugin-span-tracker.d.ts.map +1 -1
- package/dist/tracking/spans/plugin-span-tracker.js +4 -24
- package/dist/tracking/spans/plugin-span-tracker.js.map +1 -1
- package/dist/tracking/spans/redact-pii.d.ts +10 -0
- package/dist/tracking/spans/redact-pii.d.ts.map +1 -1
- package/dist/tracking/spans/redact-pii.js +80 -2
- package/dist/tracking/spans/redact-pii.js.map +1 -1
- package/dist/types/runtime.d.ts +3 -0
- package/dist/types/runtime.d.ts.map +1 -1
- package/dist/types/strategy.d.ts +2 -2
- package/dist/types/strategy.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -8,11 +8,57 @@ import { resolveMargin } from './resolve-margin.js';
|
|
|
8
8
|
import { createLogger } from '../../utils/logger.js';
|
|
9
9
|
import { resolveOpenOrderType, resolveFeeOptimizedLimitOptions } from '../../utils/resolve-order-type.js';
|
|
10
10
|
import { emitTrackingDecision, emitTrackingActionResult } from '../tracking-helpers.js';
|
|
11
|
+
import { buildSignalAttrs, decisionAttrs, decisionErrorAttrs, emitBatchRejected, setPositionId, withSignalSpanScope, } from '../otel-helpers.js';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
11
13
|
const logger = createLogger("[actions][open-position]");
|
|
12
14
|
/** Hyperliquid minimum notional value (margin × leverage) required to open a position. */
|
|
13
15
|
const MIN_NOTIONAL_VALUE = 10;
|
|
14
16
|
/** Minimum leverage floor — positions cannot go below 1x. */
|
|
15
17
|
const MIN_LEVERAGE = 1;
|
|
18
|
+
// ─── Pure attr-shape helpers ─────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Flatten a senpi `createPosition` response into the queryable subset of
|
|
21
|
+
* `action.open` span attrs. Extracted (per D5) so the senpi-response shape
|
|
22
|
+
* evolves in one place rather than scattered across the action body.
|
|
23
|
+
*/
|
|
24
|
+
function flattenOpenResultAttrs(result) {
|
|
25
|
+
const submitted = result.orders[0];
|
|
26
|
+
return {
|
|
27
|
+
"action.success": result.success,
|
|
28
|
+
"action.outcome": result.success ? "submitted" : "failed",
|
|
29
|
+
"action.order_id": submitted?.orderId,
|
|
30
|
+
"action.entry_price": submitted?.entryPrice,
|
|
31
|
+
"action.size": submitted?.size,
|
|
32
|
+
"action.filled": submitted?.filled,
|
|
33
|
+
"action.failure_reason": submitted?.error,
|
|
34
|
+
"action.fill_type": submitted?.executionAsMaker === true
|
|
35
|
+
? "maker"
|
|
36
|
+
: submitted?.executionAsMaker === false
|
|
37
|
+
? "taker"
|
|
38
|
+
: "unfilled",
|
|
39
|
+
"action.metadata.trace_id": result.metadata?.traceId,
|
|
40
|
+
"action.metadata.execution_id": result.metadata?.executionId,
|
|
41
|
+
...(submitted?.mainOrder ? {
|
|
42
|
+
"action.main_order.status": submitted.mainOrder.status,
|
|
43
|
+
"action.main_order.avg_price": submitted.mainOrder.avgPrice,
|
|
44
|
+
"action.main_order.requested_size": submitted.mainOrder.requestedSize,
|
|
45
|
+
} : {}),
|
|
46
|
+
...(submitted?.stopLoss ? {
|
|
47
|
+
"action.stop_loss.enabled": submitted.stopLoss.enabled,
|
|
48
|
+
"action.stop_loss.order_id": submitted.stopLoss.orderId,
|
|
49
|
+
"action.stop_loss.status": submitted.stopLoss.status,
|
|
50
|
+
"action.stop_loss.trigger_price": submitted.stopLoss.triggerPrice,
|
|
51
|
+
"action.stop_loss.error": submitted.stopLoss.error,
|
|
52
|
+
} : {}),
|
|
53
|
+
...(submitted?.takeProfit ? {
|
|
54
|
+
"action.take_profit.enabled": submitted.takeProfit.enabled,
|
|
55
|
+
"action.take_profit.order_id": submitted.takeProfit.orderId,
|
|
56
|
+
"action.take_profit.status": submitted.takeProfit.status,
|
|
57
|
+
"action.take_profit.trigger_price": submitted.takeProfit.triggerPrice,
|
|
58
|
+
"action.take_profit.error": submitted.takeProfit.error,
|
|
59
|
+
} : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
16
62
|
// ─── Open Position Action ───────────────────────────────────────────────────
|
|
17
63
|
export class OpenPositionAction {
|
|
18
64
|
config;
|
|
@@ -79,16 +125,7 @@ export class OpenPositionAction {
|
|
|
79
125
|
error: 'strategy_address_mismatch',
|
|
80
126
|
};
|
|
81
127
|
}
|
|
82
|
-
// Early exit: no slots
|
|
83
|
-
// NOTE re "zero slots skips cap alerts": we intentionally do NOT evaluate risk
|
|
84
|
-
// halts/cap self-pause here. It would add a per-signal getPnlHistoryWithSnapshot
|
|
85
|
-
// MCP call on the full-book path (costly on a full + high-signal-rate strategy),
|
|
86
|
-
// and that halt data is fetched nowhere else to reuse (only risk-guard.checkGate
|
|
87
|
-
// fetches it; the DSL monitor fetches clearinghouse = different data). Self-pause
|
|
88
|
-
// alerts still fire via the normal open path once a slot frees and a signal
|
|
89
|
-
// arrives (delayed, not lost). The durable fix — a periodic lifecycle driven off
|
|
90
|
-
// the monitor tick / bus events (also catches a fully-silent producer) — is
|
|
91
|
-
// roadmap §5 (the merge), deliberately not blended into this §4 short-term PR.
|
|
128
|
+
// Early exit: no slots
|
|
92
129
|
if (!strategyState.getAvailableSlots?.()) {
|
|
93
130
|
return {
|
|
94
131
|
success: true,
|
|
@@ -120,15 +157,19 @@ export class OpenPositionAction {
|
|
|
120
157
|
...(this.config.params ?? {}),
|
|
121
158
|
})
|
|
122
159
|
: undefined;
|
|
160
|
+
const fullContext = { ...contextData, scanResult };
|
|
161
|
+
const contextKeys = Object.keys(fullContext);
|
|
162
|
+
const minConfidence = this.config.min_confidence ?? 1;
|
|
123
163
|
// Get decision
|
|
124
164
|
let decision;
|
|
125
165
|
try {
|
|
126
166
|
decision = await this.decisionEngine.decide(this.config.decision_mode, {
|
|
127
167
|
prompt: interpolatedPrompt,
|
|
128
|
-
context:
|
|
168
|
+
context: fullContext,
|
|
129
169
|
model: this.config.decision_model,
|
|
130
170
|
actionType: this.actionType,
|
|
131
171
|
event,
|
|
172
|
+
minConfidence,
|
|
132
173
|
});
|
|
133
174
|
}
|
|
134
175
|
catch (error) {
|
|
@@ -160,13 +201,29 @@ export class OpenPositionAction {
|
|
|
160
201
|
},
|
|
161
202
|
};
|
|
162
203
|
emitTrackingActionResult(trackingCtx, event, address, errorResult);
|
|
204
|
+
const errAttrs = decisionErrorAttrs({
|
|
205
|
+
mode: this.config.decision_mode,
|
|
206
|
+
message,
|
|
207
|
+
minConfidence,
|
|
208
|
+
actionType: this.actionType,
|
|
209
|
+
model: this.config.decision_model,
|
|
210
|
+
contextKeys,
|
|
211
|
+
});
|
|
212
|
+
emitBatchRejected(this.context.otel, scanResult, "decision_error", "OPEN", errAttrs, error);
|
|
163
213
|
return errorResult;
|
|
164
214
|
}
|
|
165
215
|
logger.info('decision', decision);
|
|
166
216
|
const trackingCtx = this.trackingCtx();
|
|
167
|
-
const minConfidence = this.config.min_confidence ?? 1;
|
|
168
217
|
const isBelowConfidence = decision.execute && decision.confidence < minConfidence;
|
|
169
218
|
emitTrackingDecision(trackingCtx, event, address, scanResult, decision, minConfidence, interpolatedPrompt, contextData, isBelowConfidence ? 'below_confidence' : undefined);
|
|
219
|
+
const decAttrs = decisionAttrs({
|
|
220
|
+
mode: this.config.decision_mode,
|
|
221
|
+
decision,
|
|
222
|
+
minConfidence,
|
|
223
|
+
actionType: this.actionType,
|
|
224
|
+
model: this.config.decision_model,
|
|
225
|
+
contextKeys,
|
|
226
|
+
});
|
|
170
227
|
// Check decision
|
|
171
228
|
if (!decision.execute) {
|
|
172
229
|
logger.info('decision no execute', decision);
|
|
@@ -184,6 +241,7 @@ export class OpenPositionAction {
|
|
|
184
241
|
},
|
|
185
242
|
};
|
|
186
243
|
emitTrackingActionResult(trackingCtx, event, address, rejectedResult);
|
|
244
|
+
emitBatchRejected(this.context.otel, scanResult, "rejected_by_decision", "OPEN", decAttrs);
|
|
187
245
|
return rejectedResult;
|
|
188
246
|
}
|
|
189
247
|
// Check confidence threshold
|
|
@@ -203,12 +261,17 @@ export class OpenPositionAction {
|
|
|
203
261
|
},
|
|
204
262
|
};
|
|
205
263
|
emitTrackingActionResult(trackingCtx, event, address, belowConfResult);
|
|
264
|
+
emitBatchRejected(this.context.otel, scanResult, "rejected_by_confidence", "OPEN", {
|
|
265
|
+
...decAttrs,
|
|
266
|
+
"decision.skipped_reason": "below_confidence",
|
|
267
|
+
});
|
|
206
268
|
return belowConfResult;
|
|
207
269
|
}
|
|
208
270
|
// ── Unified signals path ──
|
|
209
271
|
const signals = this.normalizeSignals(decision.payload);
|
|
210
272
|
const meta = decisionMetaFromDecision(this.config, decision);
|
|
211
273
|
if (!signals.length) {
|
|
274
|
+
emitBatchRejected(this.context.otel, scanResult, "invalid_payload", "OPEN", decAttrs);
|
|
212
275
|
return {
|
|
213
276
|
success: false,
|
|
214
277
|
action_type: this.actionType,
|
|
@@ -217,7 +280,7 @@ export class OpenPositionAction {
|
|
|
217
280
|
data: { _decisionMeta: meta, _decisionPrompt: interpolatedPrompt ?? null },
|
|
218
281
|
};
|
|
219
282
|
}
|
|
220
|
-
return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt);
|
|
283
|
+
return this.handleSignals(signals, event, strategyState, decision.reasoning, meta, interpolatedPrompt, decAttrs);
|
|
221
284
|
}
|
|
222
285
|
normalizeSignals(payload) {
|
|
223
286
|
if (Array.isArray(payload.signals))
|
|
@@ -232,8 +295,10 @@ export class OpenPositionAction {
|
|
|
232
295
|
}
|
|
233
296
|
return [];
|
|
234
297
|
}
|
|
235
|
-
async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt) {
|
|
298
|
+
async handleSignals(signals, event, strategyState, reasoning, meta, decisionPrompt, decAttrs) {
|
|
236
299
|
const address = event.address;
|
|
300
|
+
const eventScanResult = event.data?.scanResult;
|
|
301
|
+
const scannerId = eventScanResult?.scannerId;
|
|
237
302
|
const orderType = resolveOpenOrderType(this.config.params?.order_type);
|
|
238
303
|
const feeOptimizedLimitOptions = orderType === 'FEE_OPTIMIZED_LIMIT'
|
|
239
304
|
? resolveFeeOptimizedLimitOptions(this.config.params?.fee_optimized_limit_options)
|
|
@@ -271,38 +336,137 @@ export class OpenPositionAction {
|
|
|
271
336
|
withdrawable = withdrawableResult;
|
|
272
337
|
let remainingSlots = strategyState.getAvailableSlots?.() ?? 0;
|
|
273
338
|
// Free-margin pre-flight budget (P1). Undefined when withdrawable is unknown
|
|
274
|
-
// (no clearinghouse read) — in that case we do NOT block, preserving prior
|
|
275
|
-
// Decremented per submitted signal so later signals in the same
|
|
276
|
-
// evaluated against the margin already consumed by earlier ones.
|
|
339
|
+
// (no clearinghouse read) — in that case we do NOT block, preserving prior
|
|
340
|
+
// behavior. Decremented per submitted signal so later signals in the same
|
|
341
|
+
// batch are evaluated against the margin already consumed by earlier ones.
|
|
277
342
|
let remainingMargin = withdrawable;
|
|
278
343
|
const results = [];
|
|
279
344
|
let submittedCount = 0;
|
|
280
345
|
let failedCount = 0;
|
|
281
346
|
for (const signal of sorted) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
347
|
+
try {
|
|
348
|
+
const outcome = await this.processOpenSignal({
|
|
349
|
+
signal,
|
|
350
|
+
event,
|
|
351
|
+
scannerId,
|
|
352
|
+
strategyState,
|
|
353
|
+
orderType,
|
|
354
|
+
feeOptimizedLimitOptions,
|
|
355
|
+
reasoning,
|
|
356
|
+
decAttrs,
|
|
357
|
+
withdrawable,
|
|
358
|
+
remainingMargin,
|
|
359
|
+
remainingSlots,
|
|
360
|
+
});
|
|
361
|
+
results.push(outcome.result);
|
|
362
|
+
if (outcome.kind === 'submitted') {
|
|
363
|
+
submittedCount++;
|
|
364
|
+
remainingSlots--;
|
|
365
|
+
// Decrement the free-margin budget only on a real submit, so later
|
|
366
|
+
// signals are evaluated against margin already consumed (P1).
|
|
367
|
+
if (remainingMargin != null && typeof outcome.marginConsumed === 'number') {
|
|
368
|
+
remainingMargin -= outcome.marginConsumed;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else if (outcome.kind === 'failed') {
|
|
372
|
+
failedCount++;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// withSignalSpanScope re-throws after stamping `exception` outcome /
|
|
377
|
+
// recording the exception event on the span. The per-signal failed
|
|
378
|
+
// result has already been pushed inside processOpenSignal; the
|
|
379
|
+
// failure must not interrupt the loop over remaining signals.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const skippedCount = results.length - submittedCount - failedCount;
|
|
383
|
+
const actionResult = {
|
|
384
|
+
success: submittedCount > 0 || failedCount === 0,
|
|
385
|
+
action_type: this.actionType,
|
|
386
|
+
summary: `Open submit: ${submittedCount} submitted, ${failedCount} failed, ${skippedCount} skipped`,
|
|
387
|
+
data: {
|
|
388
|
+
results,
|
|
389
|
+
submittedCount,
|
|
390
|
+
failedCount,
|
|
391
|
+
skippedCount,
|
|
392
|
+
...(meta ? { _decisionMeta: meta } : {}),
|
|
393
|
+
...(riskSnapshot ? { _riskSnapshot: riskSnapshot } : {}),
|
|
394
|
+
_decisionPrompt: decisionPrompt ?? null,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
emitTrackingActionResult(this.trackingCtx(), event, address, actionResult);
|
|
398
|
+
return actionResult;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Process one signal end-to-end: gates → leverage/margin resolution →
|
|
402
|
+
* senpi.createPosition → hook fire. Opens a per-signal `signal.evaluated`
|
|
403
|
+
* funnel span (via `withSignalSpanScope`) and stamps the per-signal `position.id`
|
|
404
|
+
* on mint. Returns an outcome the caller applies to counters + results.
|
|
405
|
+
*
|
|
406
|
+
* The exception path inside the senpi.createPosition try/catch sets a
|
|
407
|
+
* 'failed' outcome and lets `withSignalSpanScope` complete normally — the
|
|
408
|
+
* outer for-loop's catch in `handleSignals` only fires on truly unexpected
|
|
409
|
+
* throws.
|
|
410
|
+
*/
|
|
411
|
+
async processOpenSignal(opts) {
|
|
412
|
+
const { signal, event, scannerId, strategyState, orderType, feeOptimizedLimitOptions, reasoning, decAttrs, withdrawable, remainingMargin, remainingSlots } = opts;
|
|
413
|
+
const address = event.address;
|
|
414
|
+
const asset = signal.asset;
|
|
415
|
+
const direction = signal.direction;
|
|
416
|
+
const signalScore = signal.score;
|
|
417
|
+
const signalDex = signal.dex;
|
|
418
|
+
const submittedDex = typeof signalDex === "string" && signalDex.trim() !== "" ? signalDex.trim() : "main";
|
|
419
|
+
const dexForLookup = submittedDex === "main" ? undefined : submittedDex;
|
|
420
|
+
const signalAttrs = {
|
|
421
|
+
...buildSignalAttrs(scannerId, {
|
|
422
|
+
asset,
|
|
423
|
+
direction: direction ?? null,
|
|
424
|
+
score: signalScore,
|
|
425
|
+
signalType: signal.signalType,
|
|
426
|
+
factors: signal.factors,
|
|
427
|
+
meta: signal.meta,
|
|
428
|
+
}, "OPEN"),
|
|
429
|
+
...(decAttrs ?? {}),
|
|
430
|
+
};
|
|
431
|
+
// The per-signal ALS frame (opened by withSignalSpanScope) carries
|
|
432
|
+
// `currentSpan` so descendant spans/logs inherit parent automatically;
|
|
433
|
+
// `positionId` is set on mint and never leaks to the next iteration
|
|
434
|
+
// (H1/H2/B06). The closure mutates `outcome` so we can return it after
|
|
435
|
+
// the scope completes.
|
|
436
|
+
let outcome = {
|
|
437
|
+
kind: 'failed',
|
|
438
|
+
result: { asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'unknown' },
|
|
439
|
+
};
|
|
440
|
+
await withSignalSpanScope(this.context.otel, signalAttrs, async (signalSpan) => {
|
|
441
|
+
const endSignal = (label, status) => {
|
|
442
|
+
signalSpan.setAttr("signal.outcome", label);
|
|
443
|
+
signalSpan.end(status ? { statusCode: status } : undefined);
|
|
444
|
+
};
|
|
288
445
|
if (remainingSlots <= 0) {
|
|
289
|
-
|
|
290
|
-
|
|
446
|
+
outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'no_slots' } };
|
|
447
|
+
endSignal("no_slots");
|
|
448
|
+
return;
|
|
291
449
|
}
|
|
292
450
|
if (!direction || (direction !== 'LONG' && direction !== 'SHORT')) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
451
|
+
outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, success: false, reason: 'invalid_direction' } };
|
|
452
|
+
endSignal("invalid_direction", "Error");
|
|
453
|
+
return;
|
|
296
454
|
}
|
|
297
455
|
if (strategyState.hasOpenPosition(asset, dexForLookup)) {
|
|
298
|
-
|
|
299
|
-
|
|
456
|
+
outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: 'position_already_exists' } };
|
|
457
|
+
endSignal("position_already_exists");
|
|
458
|
+
return;
|
|
300
459
|
}
|
|
301
|
-
// Risk guard per-open (real-time via MCP, with per-asset cooldown)
|
|
460
|
+
// Risk guard per-open (real-time via MCP, with per-asset cooldown).
|
|
461
|
+
// C11: outcome + reason stamped on signal.evaluated directly; the
|
|
462
|
+
// separate risk_gate.evaluated log was redundant.
|
|
302
463
|
const gate = await this.context.riskGuard.checkGate(address, { candidateAsset: asset });
|
|
464
|
+
signalSpan.setAttr("gate.outcome", gate.gate);
|
|
465
|
+
signalSpan.setAttr("gate.reason", gate.reason);
|
|
303
466
|
if (gate.gate !== 'OPEN') {
|
|
304
|
-
|
|
305
|
-
|
|
467
|
+
outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, success: true, reason: `risk_gate_${gate.gate}` } };
|
|
468
|
+
endSignal(`rejected_by_gate_${gate.gate.toLowerCase()}`);
|
|
469
|
+
return;
|
|
306
470
|
}
|
|
307
471
|
// Leverage: LLM override > config-based resolution
|
|
308
472
|
const llmLeverage = typeof signal.leverage === 'number' ? signal.leverage : undefined;
|
|
@@ -338,122 +502,181 @@ export class OpenPositionAction {
|
|
|
338
502
|
slots: strategyState.config.slots,
|
|
339
503
|
withdrawable,
|
|
340
504
|
});
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
505
|
+
outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, leverage, success: false, reason: 'no_margin_configured' } };
|
|
506
|
+
endSignal("no_margin_configured", "Error");
|
|
507
|
+
return;
|
|
344
508
|
}
|
|
345
509
|
const notionalValue = marginAmount * leverage;
|
|
346
510
|
if (notionalValue < MIN_NOTIONAL_VALUE) {
|
|
347
511
|
logger.warn(`Skipping ${asset}: notional value $${notionalValue.toFixed(2)} ` +
|
|
348
512
|
`(margin $${marginAmount.toFixed(2)} × ${leverage}x) is below minimum $${MIN_NOTIONAL_VALUE}`);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
513
|
+
outcome = { kind: 'failed', result: { asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, notionalValue, success: false, reason: 'below_min_notional' } };
|
|
514
|
+
endSignal("below_min_notional", "Error");
|
|
515
|
+
return;
|
|
352
516
|
}
|
|
353
|
-
// Free-margin pre-flight (P1): if we know free margin and it can't cover
|
|
354
|
-
// position's margin, skip rather than fire a create the engine will
|
|
355
|
-
// (CREATE_INSUFFICIENT_FUNDS).
|
|
517
|
+
// Free-margin pre-flight (P1): if we know free margin and it can't cover
|
|
518
|
+
// this position's margin, skip rather than fire a create the engine will
|
|
519
|
+
// reject (CREATE_INSUFFICIENT_FUNDS). The check is skipped when free
|
|
520
|
+
// margin is unknown (withdrawable null), preserving prior behavior.
|
|
356
521
|
if (remainingMargin != null && remainingMargin < marginAmount) {
|
|
357
522
|
logger.warn(`Skipping ${asset}: insufficient free margin ` +
|
|
358
523
|
`($${remainingMargin.toFixed(2)} available < $${marginAmount.toFixed(2)} required)`);
|
|
359
|
-
|
|
360
|
-
|
|
524
|
+
outcome = { kind: 'skipped', result: { asset, direction, dex: submittedDex, score: signalScore, leverage, marginAmount, requiredMarginAmount: marginAmount, availableMarginAmount: remainingMargin, success: true, reason: 'insufficient_margin' } };
|
|
525
|
+
endSignal("insufficient_margin");
|
|
526
|
+
return;
|
|
361
527
|
}
|
|
362
528
|
const signalReason = signal.reason ?? signal.signalType ?? reasoning;
|
|
529
|
+
// Mint position.id at intent — before order submission, after confidence + risk gates.
|
|
530
|
+
// The OTEL ALS frame opened by withSignalSpanScope is per-signal, so the id is stamped
|
|
531
|
+
// on signal.evaluated, action.open, and downstream logs without leaking across signals.
|
|
532
|
+
const positionId = randomUUID();
|
|
533
|
+
setPositionId(positionId);
|
|
534
|
+
signalSpan.setAttr("position.id", positionId);
|
|
535
|
+
const actionSpan = this.context.otel.startSpan("action.open", {
|
|
536
|
+
parent: signalSpan,
|
|
537
|
+
attrs: {
|
|
538
|
+
"action.intent": "OPEN",
|
|
539
|
+
"action.asset": asset,
|
|
540
|
+
"action.direction": direction,
|
|
541
|
+
"action.order_type": orderType,
|
|
542
|
+
"action.dex": submittedDex,
|
|
543
|
+
"action.leverage": leverage,
|
|
544
|
+
"action.margin": marginAmount,
|
|
545
|
+
"action.risk_gate": gate.gate,
|
|
546
|
+
...(typeof withdrawable === "number" ? { "action.withdrawable_balance": withdrawable } : {}),
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
const createRequest = {
|
|
550
|
+
strategyWalletAddress: address,
|
|
551
|
+
orders: [
|
|
552
|
+
{
|
|
553
|
+
coin: asset,
|
|
554
|
+
direction,
|
|
555
|
+
leverage,
|
|
556
|
+
marginAmount,
|
|
557
|
+
orderType,
|
|
558
|
+
...(feeOptimizedLimitOptions ? { feeOptimizedLimitOptions } : {}),
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
reason: signalReason,
|
|
562
|
+
};
|
|
563
|
+
let submittedOrder;
|
|
564
|
+
let resultSuccess = false;
|
|
565
|
+
let signalEnded = false;
|
|
363
566
|
try {
|
|
364
567
|
logger.info('opening position', { wallet: address, asset, direction });
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
568
|
+
// D1: the standalone `action.order_execution` span is gone — ack
|
|
569
|
+
// latency + senpi metadata fold onto action.open directly.
|
|
570
|
+
const submitStart = Date.now();
|
|
571
|
+
let result;
|
|
572
|
+
try {
|
|
573
|
+
result = await this.context.senpi.createPosition(createRequest);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
actionSpan.setAttr("action.ack_latency_ms", Date.now() - submitStart);
|
|
577
|
+
actionSpan.recordException(err);
|
|
578
|
+
const e = err;
|
|
579
|
+
actionSpan.setAttr("exception.type", e?.name ?? "Error");
|
|
580
|
+
actionSpan.setAttr("action.outcome", "exception");
|
|
581
|
+
actionSpan.end({ statusCode: "Error" });
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
actionSpan.setAttr("action.ack_latency_ms", Date.now() - submitStart);
|
|
585
|
+
// Source-of-truth blob for retrospection / fields not yet extracted.
|
|
586
|
+
// Span attrs carry the queryable subset; the log body carries the
|
|
587
|
+
// full response. Independent retention/sampling per the design.
|
|
588
|
+
this.context.otel.emitLog({
|
|
589
|
+
eventName: "senpi.order_response",
|
|
590
|
+
parent: actionSpan,
|
|
591
|
+
attrs: { "senpi.operation": "create_position" },
|
|
592
|
+
body: { request: createRequest, response: result },
|
|
378
593
|
});
|
|
594
|
+
actionSpan.setAttrs(flattenOpenResultAttrs(result));
|
|
595
|
+
actionSpan.end({ statusCode: result.success ? "Ok" : "Error" });
|
|
596
|
+
resultSuccess = result.success;
|
|
597
|
+
submittedOrder = result.orders[0];
|
|
379
598
|
if (!result.success) {
|
|
380
599
|
const orderErrors = result.orders
|
|
381
600
|
.filter((o) => o.error)
|
|
382
601
|
.map((o) => `${o.coin}: ${o.error}`)
|
|
383
602
|
.join('; ');
|
|
384
603
|
const detail = result.error || orderErrors || 'unknown error';
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
604
|
+
outcome = { kind: 'failed', result: {
|
|
605
|
+
asset, direction, dex: submittedDex, score: signalScore,
|
|
606
|
+
leverage, marginAmount, orderType,
|
|
607
|
+
success: false, reason: 'position_open_failed', error: detail,
|
|
608
|
+
} };
|
|
609
|
+
endSignal("position_open_failed", "Error");
|
|
610
|
+
signalEnded = true;
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
outcome = { kind: 'submitted', marginConsumed: marginAmount, result: {
|
|
614
|
+
asset, direction, dex: submittedDex, score: signalScore,
|
|
615
|
+
leverage, marginAmount, orderType,
|
|
616
|
+
orderId: submittedOrder?.orderId ?? null,
|
|
617
|
+
entryPrice: submittedOrder?.entryPrice ?? null,
|
|
618
|
+
size: submittedOrder?.size ?? null,
|
|
619
|
+
filled: submittedOrder?.filled ?? null,
|
|
620
|
+
success: true, reason: 'submitted',
|
|
621
|
+
} };
|
|
622
|
+
endSignal("accepted", "Ok");
|
|
623
|
+
signalEnded = true;
|
|
392
624
|
}
|
|
393
|
-
const submittedOrder = result.orders[0];
|
|
394
|
-
await this.context.hookSystem.fire({
|
|
395
|
-
type: HOOK_EVENT.ON_OPEN_ORDER_SUBMITTED,
|
|
396
|
-
runtimeName: event.runtimeName,
|
|
397
|
-
address,
|
|
398
|
-
data: {
|
|
399
|
-
asset,
|
|
400
|
-
direction,
|
|
401
|
-
leverage,
|
|
402
|
-
marginAmount,
|
|
403
|
-
orderType,
|
|
404
|
-
wallet: address,
|
|
405
|
-
dex: submittedDex,
|
|
406
|
-
reasoning: signalReason,
|
|
407
|
-
orderId: submittedOrder?.orderId,
|
|
408
|
-
entryPrice: submittedOrder?.entryPrice ?? null,
|
|
409
|
-
size: submittedOrder?.size ?? null,
|
|
410
|
-
filled: submittedOrder?.filled ?? null,
|
|
411
|
-
__correlationId: event.data.__correlationId,
|
|
412
|
-
},
|
|
413
|
-
timestamp: new Date().toISOString(),
|
|
414
|
-
});
|
|
415
|
-
results.push({
|
|
416
|
-
asset, direction, dex: submittedDex, score: signalScore,
|
|
417
|
-
leverage, marginAmount, orderType,
|
|
418
|
-
orderId: submittedOrder?.orderId ?? null,
|
|
419
|
-
entryPrice: submittedOrder?.entryPrice ?? null,
|
|
420
|
-
size: submittedOrder?.size ?? null,
|
|
421
|
-
filled: submittedOrder?.filled ?? null,
|
|
422
|
-
success: true, reason: 'submitted',
|
|
423
|
-
});
|
|
424
|
-
submittedCount++;
|
|
425
|
-
remainingSlots--;
|
|
426
|
-
if (remainingMargin != null)
|
|
427
|
-
remainingMargin -= marginAmount;
|
|
428
625
|
}
|
|
429
626
|
catch (error) {
|
|
430
627
|
const message = error instanceof Error ? error.message : String(error);
|
|
431
628
|
logger.error('open failed', { asset, err: message });
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
629
|
+
if (!signalEnded) {
|
|
630
|
+
signalSpan.recordException(error);
|
|
631
|
+
const e = error;
|
|
632
|
+
signalSpan.setAttr("exception.type", e?.name ?? "Error");
|
|
633
|
+
endSignal("exception", "Error");
|
|
634
|
+
signalEnded = true;
|
|
635
|
+
}
|
|
636
|
+
outcome = { kind: 'failed', result: {
|
|
637
|
+
asset, direction, dex: submittedDex, score: signalScore,
|
|
638
|
+
leverage, marginAmount, orderType,
|
|
639
|
+
success: false, reason: 'exception', error: message,
|
|
640
|
+
} };
|
|
641
|
+
return;
|
|
438
642
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
643
|
+
// B12 — hook fire happens AFTER the signal verdict is final and
|
|
644
|
+
// OUTSIDE the senpi.createPosition try/catch. A hook-fire failure
|
|
645
|
+
// must not flip the signal's outcome to "exception" when the order
|
|
646
|
+
// in fact succeeded. The hook failure is logged on the action span
|
|
647
|
+
// but never propagates.
|
|
648
|
+
if (resultSuccess) {
|
|
649
|
+
try {
|
|
650
|
+
await this.context.hookSystem.fire({
|
|
651
|
+
type: HOOK_EVENT.ON_OPEN_ORDER_SUBMITTED,
|
|
652
|
+
runtimeName: event.runtimeName,
|
|
653
|
+
address,
|
|
654
|
+
data: {
|
|
655
|
+
asset,
|
|
656
|
+
direction,
|
|
657
|
+
leverage,
|
|
658
|
+
marginAmount,
|
|
659
|
+
orderType,
|
|
660
|
+
wallet: address,
|
|
661
|
+
dex: submittedDex,
|
|
662
|
+
reasoning: signalReason,
|
|
663
|
+
orderId: submittedOrder?.orderId,
|
|
664
|
+
entryPrice: submittedOrder?.entryPrice ?? null,
|
|
665
|
+
size: submittedOrder?.size ?? null,
|
|
666
|
+
filled: submittedOrder?.filled ?? null,
|
|
667
|
+
__correlationId: event.data.__correlationId,
|
|
668
|
+
},
|
|
669
|
+
timestamp: new Date().toISOString(),
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
catch (hookErr) {
|
|
673
|
+
logger.warn('hook fire ON_OPEN_ORDER_SUBMITTED failed', {
|
|
674
|
+
asset, err: hookErr instanceof Error ? hookErr.message : String(hookErr),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
return outcome;
|
|
457
680
|
}
|
|
458
681
|
}
|
|
459
682
|
// ─── Factory & Manifest ─────────────────────────────────────────────────────
|