@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.
- package/dist/actions/action-events.d.ts +7 -21
- package/dist/actions/action-events.d.ts.map +1 -1
- package/dist/actions/action-events.js +25 -56
- package/dist/actions/action-events.js.map +1 -1
- package/dist/actions/action-runtime-module.d.ts.map +1 -1
- package/dist/actions/action-runtime-module.js +1 -18
- package/dist/actions/action-runtime-module.js.map +1 -1
- package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
- package/dist/actions/close-position/close-position.action.js +17 -89
- package/dist/actions/close-position/close-position.action.js.map +1 -1
- package/dist/actions/event-derivation.d.ts +2 -40
- package/dist/actions/event-derivation.d.ts.map +1 -1
- package/dist/actions/event-derivation.js +9 -189
- package/dist/actions/event-derivation.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts +33 -0
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +223 -170
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/actions/position-tracker/position-tracker.action.d.ts +1 -2
- package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
- package/dist/actions/position-tracker/position-tracker.action.js +15 -50
- package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
- package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
- package/dist/dsl/engine-client/event-mapping.js +0 -15
- package/dist/dsl/engine-client/event-mapping.js.map +1 -1
- package/dist/dsl/events/build-closed-event.d.ts +1 -16
- package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
- package/dist/dsl/events/build-closed-event.js +0 -5
- package/dist/dsl/events/build-closed-event.js.map +1 -1
- package/dist/dsl/events/handlers.d.ts +0 -7
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +7 -49
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
- package/dist/dsl/monitor/create-monitor.js +0 -7
- package/dist/dsl/monitor/create-monitor.js.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.d.ts +0 -2
- package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.js +1 -2
- package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts +0 -6
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +21 -55
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/health/types.d.ts +0 -6
- package/dist/health/types.d.ts.map +1 -1
- package/dist/health/types.js.map +1 -1
- package/dist/index.d.ts +4 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -245
- package/dist/index.js.map +1 -1
- package/dist/runtime/__tests__/helpers/runtime-gateway-harness.d.ts +6 -0
- package/dist/runtime/__tests__/helpers/runtime-gateway-harness.d.ts.map +1 -1
- package/dist/runtime/__tests__/helpers/runtime-gateway-harness.js +25 -0
- package/dist/runtime/__tests__/helpers/runtime-gateway-harness.js.map +1 -1
- package/dist/runtime/create-runtime-context.d.ts.map +1 -1
- package/dist/runtime/create-runtime-context.js +5 -0
- package/dist/runtime/create-runtime-context.js.map +1 -1
- package/dist/runtime/domain-events.d.ts.map +1 -1
- package/dist/runtime/domain-events.js +0 -7
- package/dist/runtime/domain-events.js.map +1 -1
- package/dist/runtime/lifecycle-events.d.ts +1 -9
- package/dist/runtime/lifecycle-events.d.ts.map +1 -1
- package/dist/runtime/lifecycle-events.js +2 -11
- package/dist/runtime/lifecycle-events.js.map +1 -1
- package/dist/runtime/pending-opens-ledger.d.ts +93 -0
- package/dist/runtime/pending-opens-ledger.d.ts.map +1 -0
- package/dist/runtime/pending-opens-ledger.js +118 -0
- package/dist/runtime/pending-opens-ledger.js.map +1 -0
- package/dist/runtime/reconcile-run.d.ts +0 -2
- package/dist/runtime/reconcile-run.d.ts.map +1 -1
- package/dist/runtime/reconcile-run.js +1 -2
- package/dist/runtime/reconcile-run.js.map +1 -1
- package/dist/runtime/reconcile-state.d.ts +0 -8
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +1 -3
- package/dist/runtime/reconcile-state.js.map +1 -1
- package/dist/runtime/runtime.d.ts +5 -5
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +20 -44
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/strategy-registry.d.ts +1 -0
- package/dist/runtime/strategy-registry.d.ts.map +1 -1
- package/dist/runtime/strategy-registry.js +19 -0
- package/dist/runtime/strategy-registry.js.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.d.ts +0 -8
- package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js +0 -1
- package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
- package/dist/runtime/update-apply-deps.d.ts +6 -0
- package/dist/runtime/update-apply-deps.d.ts.map +1 -1
- package/dist/runtime/update-apply-deps.js +8 -19
- package/dist/runtime/update-apply-deps.js.map +1 -1
- package/dist/runtime-api/intake/intake-events.d.ts +3 -3
- package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-events.js +3 -21
- package/dist/runtime-api/intake/intake-events.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts +2 -0
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +10 -57
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts +0 -11
- package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.js +8 -39
- package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
- package/dist/runtime-api/intake/scaffold-error-router.d.ts +43 -0
- package/dist/runtime-api/intake/scaffold-error-router.d.ts.map +1 -0
- package/dist/runtime-api/intake/scaffold-error-router.js +63 -0
- package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -0
- package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.js +0 -12
- package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
- package/dist/runtime-api/intake/types.d.ts +1 -22
- package/dist/runtime-api/intake/types.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.js +1 -7
- package/dist/runtime-api/routes/signals.js.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +0 -1
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +0 -6
- package/dist/runtime-api/routes/signals.schema.js.map +1 -1
- package/dist/scanners/engine/engine.d.ts +0 -5
- package/dist/scanners/engine/engine.d.ts.map +1 -1
- package/dist/scanners/engine/engine.js +2 -30
- package/dist/scanners/engine/engine.js.map +1 -1
- package/dist/scanners/external/scaffold/delivery.py +12 -79
- package/dist/scanners/external/scaffold/scaffold.py +9 -63
- package/dist/scanners/external-scanner-receiver.d.ts +0 -11
- package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
- package/dist/scanners/external-scanner-receiver.js.map +1 -1
- package/dist/scanners/runtime-module.d.ts.map +1 -1
- package/dist/scanners/runtime-module.js +0 -5
- package/dist/scanners/runtime-module.js.map +1 -1
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +4 -12
- package/dist/senpi/client.js.map +1 -1
- package/dist/senpi/types.d.ts +0 -5
- package/dist/senpi/types.d.ts.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +3 -12
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +3 -3
- package/dist/strategy/strategy-state.d.ts +26 -0
- package/dist/strategy/strategy-state.d.ts.map +1 -1
- package/dist/strategy/strategy-state.js +48 -20
- package/dist/strategy/strategy-state.js.map +1 -1
- package/dist/telemetry/event-builders.d.ts +6 -74
- package/dist/telemetry/event-builders.d.ts.map +1 -1
- package/dist/telemetry/event-builders.js +11 -108
- package/dist/telemetry/event-builders.js.map +1 -1
- package/dist/telemetry/event-store.d.ts +0 -6
- package/dist/telemetry/event-store.d.ts.map +1 -1
- package/dist/telemetry/event-store.js +6 -6
- package/dist/telemetry/event-store.js.map +1 -1
- package/dist/telemetry/metrics.d.ts +2 -14
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +4 -19
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/telemetry/tracing.d.ts +0 -9
- package/dist/telemetry/tracing.d.ts.map +1 -1
- package/dist/telemetry/tracing.js +0 -36
- package/dist/telemetry/tracing.js.map +1 -1
- package/dist/types/dsl/index.d.ts +0 -11
- package/dist/types/dsl/index.d.ts.map +1 -1
- package/dist/types/runtime.d.ts +9 -0
- package/dist/types/runtime.d.ts.map +1 -1
- package/dist/types/scanner.d.ts +0 -9
- package/dist/types/scanner.d.ts.map +1 -1
- package/dist/types/strategy.d.ts +6 -0
- package/dist/types/strategy.d.ts.map +1 -1
- package/dist/utils/event-catalog.d.ts +0 -81
- package/dist/utils/event-catalog.d.ts.map +1 -1
- package/dist/utils/event-catalog.js +0 -1
- package/dist/utils/event-catalog.js.map +1 -1
- package/dist/utils/keyed-mutex.d.ts +1 -3
- package/dist/utils/keyed-mutex.d.ts.map +1 -1
- package/dist/utils/keyed-mutex.js +1 -3
- package/dist/utils/keyed-mutex.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/actions/skipped-signal-results.d.ts +0 -16
- package/dist/actions/skipped-signal-results.d.ts.map +0 -1
- package/dist/actions/skipped-signal-results.js +0 -47
- package/dist/actions/skipped-signal-results.js.map +0 -1
- package/dist/scanners/external/scaffold/ulid.py +0 -58
- package/dist/telemetry/live-telemetry.d.ts +0 -158
- package/dist/telemetry/live-telemetry.d.ts.map +0 -1
- package/dist/telemetry/live-telemetry.js +0 -581
- package/dist/telemetry/live-telemetry.js.map +0 -1
- package/dist/telemetry/scanner-stats.d.ts +0 -136
- package/dist/telemetry/scanner-stats.d.ts.map +0 -1
- package/dist/telemetry/scanner-stats.js +0 -304
- package/dist/telemetry/scanner-stats.js.map +0 -1
- package/dist/telemetry/strategy-topology.d.ts +0 -99
- package/dist/telemetry/strategy-topology.d.ts.map +0 -1
- package/dist/telemetry/strategy-topology.js +0 -93
- package/dist/telemetry/strategy-topology.js.map +0 -1
- package/dist/telemetry/telemetry-publisher.d.ts +0 -63
- package/dist/telemetry/telemetry-publisher.d.ts.map +0 -1
- package/dist/telemetry/telemetry-publisher.js +0 -229
- package/dist/telemetry/telemetry-publisher.js.map +0 -1
- package/dist/telemetry/timeline-buffer.d.ts +0 -95
- package/dist/telemetry/timeline-buffer.d.ts.map +0 -1
- package/dist/telemetry/timeline-buffer.js +0 -237
- package/dist/telemetry/timeline-buffer.js.map +0 -1
- package/dist/utils/ulid.d.ts +0 -9
- package/dist/utils/ulid.d.ts.map +0 -1
- package/dist/utils/ulid.js +0 -43
- 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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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:
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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,
|