@senpi/runtime 3.0.0-dev.runtime-phase-2.20260623210537 → 3.0.1-dev.openclaw-upgrade-mai.20260625140818

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 (215) hide show
  1. package/README.md +10 -9
  2. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  3. package/dist/actions/action-runtime-module.js +1 -1
  4. package/dist/actions/action-runtime-module.js.map +1 -1
  5. package/dist/actions/decision-engine.d.ts.map +1 -1
  6. package/dist/actions/decision-engine.js +3 -0
  7. package/dist/actions/decision-engine.js.map +1 -1
  8. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  9. package/dist/actions/open-position/open-position.action.js +54 -14
  10. package/dist/actions/open-position/open-position.action.js.map +1 -1
  11. package/dist/actions/types.d.ts +10 -2
  12. package/dist/actions/types.d.ts.map +1 -1
  13. package/dist/actions/types.js +8 -0
  14. package/dist/actions/types.js.map +1 -1
  15. package/dist/cli/guide-commands.d.ts.map +1 -1
  16. package/dist/cli/guide-commands.js +80 -74
  17. package/dist/cli/guide-commands.js.map +1 -1
  18. package/dist/cli/senpi-commands.d.ts.map +1 -1
  19. package/dist/cli/senpi-commands.js +0 -108
  20. package/dist/cli/senpi-commands.js.map +1 -1
  21. package/dist/constants/dsl/index.d.ts +2 -0
  22. package/dist/constants/dsl/index.d.ts.map +1 -1
  23. package/dist/constants/dsl/index.js +2 -0
  24. package/dist/constants/dsl/index.js.map +1 -1
  25. package/dist/dsl/config/parse-tiers.d.ts.map +1 -1
  26. package/dist/dsl/config/parse-tiers.js +6 -2
  27. package/dist/dsl/config/parse-tiers.js.map +1 -1
  28. package/dist/dsl/engine-client/config-mapping.d.ts +7 -0
  29. package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
  30. package/dist/dsl/engine-client/config-mapping.js +16 -2
  31. package/dist/dsl/engine-client/config-mapping.js.map +1 -1
  32. package/dist/dsl/events/handlers.d.ts +18 -2
  33. package/dist/dsl/events/handlers.d.ts.map +1 -1
  34. package/dist/dsl/events/handlers.js +302 -213
  35. package/dist/dsl/events/handlers.js.map +1 -1
  36. package/dist/dsl/monitor/create-monitor.d.ts +15 -0
  37. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  38. package/dist/dsl/monitor/create-monitor.js +46 -9
  39. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  40. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  41. package/dist/dsl/monitor/process-one-position.js +138 -77
  42. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  43. package/dist/dsl/plugin/index.d.ts +16 -1
  44. package/dist/dsl/plugin/index.d.ts.map +1 -1
  45. package/dist/dsl/plugin/index.js +35 -4
  46. package/dist/dsl/plugin/index.js.map +1 -1
  47. package/dist/dsl/plugin/options.d.ts +18 -0
  48. package/dist/dsl/plugin/options.d.ts.map +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/runtime/create-scanner-module.d.ts +4 -3
  53. package/dist/runtime/create-scanner-module.d.ts.map +1 -1
  54. package/dist/runtime/create-scanner-module.js +14 -5
  55. package/dist/runtime/create-scanner-module.js.map +1 -1
  56. package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
  57. package/dist/runtime/create-scanner-providers-for-run.js +12 -1
  58. package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
  59. package/dist/runtime/load-runtime-config.js +1 -1
  60. package/dist/runtime/load-runtime-config.js.map +1 -1
  61. package/dist/runtime/map-strategies-registration.js +7 -8
  62. package/dist/runtime/map-strategies-registration.js.map +1 -1
  63. package/dist/runtime/map-strategies.d.ts.map +1 -1
  64. package/dist/runtime/map-strategies.js +12 -3
  65. package/dist/runtime/map-strategies.js.map +1 -1
  66. package/dist/runtime/reconcile-decide.d.ts +73 -0
  67. package/dist/runtime/reconcile-decide.d.ts.map +1 -0
  68. package/dist/runtime/reconcile-decide.js +186 -0
  69. package/dist/runtime/reconcile-decide.js.map +1 -0
  70. package/dist/runtime/reconcile-run.d.ts +62 -0
  71. package/dist/runtime/reconcile-run.d.ts.map +1 -0
  72. package/dist/runtime/reconcile-run.js +173 -0
  73. package/dist/runtime/reconcile-run.js.map +1 -0
  74. package/dist/runtime/reconcile-state.d.ts +44 -0
  75. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  76. package/dist/runtime/reconcile-state.js +80 -44
  77. package/dist/runtime/reconcile-state.js.map +1 -1
  78. package/dist/runtime/risk-guard.d.ts.map +1 -1
  79. package/dist/runtime/risk-guard.js +17 -12
  80. package/dist/runtime/risk-guard.js.map +1 -1
  81. package/dist/runtime/runtime-schema.d.ts +2912 -1052
  82. package/dist/runtime/runtime-schema.d.ts.map +1 -1
  83. package/dist/runtime/runtime-schema.js +136 -42
  84. package/dist/runtime/runtime-schema.js.map +1 -1
  85. package/dist/runtime/runtime.d.ts +26 -0
  86. package/dist/runtime/runtime.d.ts.map +1 -1
  87. package/dist/runtime/runtime.js +254 -8
  88. package/dist/runtime/runtime.js.map +1 -1
  89. package/dist/runtime/scanner-registry.d.ts +1 -1
  90. package/dist/runtime/scanner-registry.d.ts.map +1 -1
  91. package/dist/runtime/scanner-registry.js +3 -12
  92. package/dist/runtime/scanner-registry.js.map +1 -1
  93. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  94. package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
  95. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  96. package/dist/runtime/supervisor/orphan-sweep.js +1 -1
  97. package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
  98. package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
  99. package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
  100. package/dist/runtime/supervisor/supervisor-utils.js +1 -1
  101. package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
  102. package/dist/runtime-api/intake/acceptance-record.d.ts +10 -0
  103. package/dist/runtime-api/intake/acceptance-record.d.ts.map +1 -1
  104. package/dist/runtime-api/intake/acceptance-record.js +28 -0
  105. package/dist/runtime-api/intake/acceptance-record.js.map +1 -1
  106. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  107. package/dist/runtime-api/intake/intake-mount.js +28 -36
  108. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  109. package/dist/runtime-api/intake/process-signal-post.d.ts +20 -1
  110. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  111. package/dist/runtime-api/intake/process-signal-post.js +57 -6
  112. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  113. package/dist/runtime-api/intake/scanner-launcher.d.ts +18 -4
  114. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  115. package/dist/runtime-api/intake/scanner-launcher.js +24 -3
  116. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  117. package/dist/runtime-api/intake/types.d.ts +16 -0
  118. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  119. package/dist/runtime-api/routes/signals.schema.d.ts +4 -0
  120. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  121. package/dist/runtime-api/routes/signals.schema.js +12 -0
  122. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  123. package/dist/scanners/external/scaffold/__main__.py +65 -24
  124. package/dist/scanners/external/scaffold/ctx.py +78 -0
  125. package/dist/scanners/external/scaffold/delivery.py +56 -22
  126. package/dist/scanners/external/scaffold/entrypoint_loader.py +42 -48
  127. package/dist/scanners/external/scaffold/journal.py +13 -2
  128. package/dist/scanners/external/scaffold/requirements.txt +11 -0
  129. package/dist/scanners/external/scaffold/scaffold.py +159 -128
  130. package/dist/scanners/external/scaffold/senpi_mcp.py +250 -0
  131. package/dist/scanners/external/scaffold/signal_envelope.py +177 -34
  132. package/dist/scanners/external/scaffold/state_manager.py +175 -0
  133. package/dist/scanners/external-scanner-receiver.d.ts +6 -0
  134. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  135. package/dist/scanners/external-scanner-receiver.js +14 -1
  136. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  137. package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
  138. package/dist/scripts/run-dsl-standalone-live.js +5 -3
  139. package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
  140. package/dist/senpi/client.d.ts +37 -4
  141. package/dist/senpi/client.d.ts.map +1 -1
  142. package/dist/senpi/client.js +101 -24
  143. package/dist/senpi/client.js.map +1 -1
  144. package/dist/state/state-manager.d.ts +17 -0
  145. package/dist/state/state-manager.d.ts.map +1 -1
  146. package/dist/state/state-manager.js +50 -4
  147. package/dist/state/state-manager.js.map +1 -1
  148. package/dist/strategy/strategy-state.d.ts +9 -1
  149. package/dist/strategy/strategy-state.d.ts.map +1 -1
  150. package/dist/strategy/strategy-state.js +62 -9
  151. package/dist/strategy/strategy-state.js.map +1 -1
  152. package/dist/tracking/constants.d.ts.map +1 -1
  153. package/dist/tracking/constants.js +2 -0
  154. package/dist/tracking/constants.js.map +1 -1
  155. package/dist/types/dsl/index.d.ts +1 -1
  156. package/dist/types/dsl/index.d.ts.map +1 -1
  157. package/dist/types/scanner.d.ts +7 -0
  158. package/dist/types/scanner.d.ts.map +1 -1
  159. package/dist/types/strategy.d.ts +3 -1
  160. package/dist/types/strategy.d.ts.map +1 -1
  161. package/dist/utils/keyed-mutex.d.ts +37 -0
  162. package/dist/utils/keyed-mutex.d.ts.map +1 -0
  163. package/dist/utils/keyed-mutex.js +77 -0
  164. package/dist/utils/keyed-mutex.js.map +1 -0
  165. package/examples/strategies/README.md +17 -12
  166. package/examples/strategies/iguana/README.md +135 -0
  167. package/examples/strategies/iguana/recipe.yaml +137 -0
  168. package/examples/strategies/iguana/scanners/scan.py +243 -0
  169. package/examples/strategies/iguana/scanners/scoring.py +152 -0
  170. package/examples/strategies/raptor/README.md +156 -0
  171. package/examples/strategies/raptor/recipe.yaml +154 -0
  172. package/examples/strategies/raptor/scanners/scan.py +349 -0
  173. package/examples/strategies/raptor/scanners/scoring.py +452 -0
  174. package/examples/strategies/spider/README.md +89 -0
  175. package/examples/strategies/spider/scalp/recipe.yaml +129 -0
  176. package/examples/strategies/spider/scalp/scanners/scan.py +219 -0
  177. package/examples/strategies/spider/scalp/scanners/scoring.py +179 -0
  178. package/examples/strategies/spider/swing/recipe.yaml +137 -0
  179. package/examples/strategies/spider/swing/scanners/scan.py +334 -0
  180. package/examples/strategies/spider/swing/scanners/scoring.py +184 -0
  181. package/examples/strategies/turbine/README.md +166 -0
  182. package/examples/strategies/turbine/recipe-runners.yaml +123 -0
  183. package/examples/strategies/turbine/recipe-volume.yaml +129 -0
  184. package/examples/strategies/turbine/scanners/scan.py +283 -0
  185. package/examples/strategies/turbine/scanners/scoring.py +190 -0
  186. package/openclaw.plugin.json +1 -1
  187. package/package.json +1 -1
  188. package/dist/scanners/external/liquidation-watchdog/README.md +0 -107
  189. package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +0 -48
  190. package/dist/scanners/external/liquidation-watchdog/producer.mjs +0 -336
  191. package/dist/scanners/external/momentum/README.md +0 -148
  192. package/dist/scanners/external/momentum/momentum.yaml +0 -116
  193. package/dist/scanners/external/momentum/producer.mjs +0 -251
  194. package/dist/scanners/external/shared/ingest.mjs +0 -112
  195. package/examples/README.md +0 -89
  196. package/examples/scanners-consumer-quickstart.mjs +0 -134
  197. package/examples/scanners-emerging-movers-v4.mjs +0 -287
  198. package/examples/scanners-liquidation-watchdog.mjs +0 -341
  199. package/examples/scanners-live-mcp.mjs +0 -208
  200. package/examples/scanners-multi-strategy.mjs +0 -165
  201. package/examples/scanners-position-tracker-e2e.mjs +0 -225
  202. package/examples/scanners-scheduled-events.mjs +0 -96
  203. package/examples/scanners-senpi-provider-client.mjs +0 -182
  204. package/examples/scanners-single-strategy.mjs +0 -198
  205. package/examples/strategies/dsl-showcase.yaml +0 -68
  206. package/examples/strategies/dsl.yaml +0 -51
  207. package/examples/strategies/fox-minimal-loosened.yaml +0 -240
  208. package/examples/strategies/fox-minimal.yaml +0 -242
  209. package/examples/strategies/minimal.yaml +0 -46
  210. package/examples/strategies/momentum/how-it-works.md +0 -48
  211. package/examples/strategies/momentum/momentum-guarded.yaml +0 -161
  212. package/examples/strategies/risk-showcase.yaml +0 -93
  213. package/examples/strategies/viper.yaml +0 -67
  214. package/examples/strategies/wolf.yaml +0 -72
  215. package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +0 -123
@@ -51,7 +51,7 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
51
51
  normalizeDslCoinDex(event.coin, event.dex ?? "").coin &&
52
52
  s.direction === newDirection);
53
53
  }
54
- async function handlePositionOpened(payload, triggerReason) {
54
+ async function handlePositionOpened(payload, triggerReason, opts) {
55
55
  let event = null;
56
56
  if (isHookEvent(payload)) {
57
57
  event = hookToPositionOpenedEvent(payload);
@@ -72,105 +72,125 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
72
72
  return;
73
73
  event = normalizePositionOpenedEvent(event);
74
74
  const dexKey = event.dex ?? "";
75
- const existing = await stateManager.getDslState(event.address, event.coin, dexKey);
76
- if (existing?.active) {
77
- logger.warn("[dsl] position.opened ignored: DSL state already active for this asset/dex", {
78
- address: event.address,
79
- asset: event.coin,
80
- dex: dexKey,
81
- });
82
- return;
83
- }
84
- if (await isDuplicatePositionEvent(event)) {
85
- logger.warn("[dsl] duplicate position event ignored (same entry, size, coin, direction)", {
75
+ const posKey = stateKey(event.address, event.coin, dexKey);
76
+ // Serialize concurrent opens on the same (address,coin,dex) key.
77
+ // NON-REENTRANT: callers must NOT hold posKey when calling this function.
78
+ await stateManager.withKeyLock(posKey, async () => {
79
+ // Re-read inside the lock so a racing adopt that already wrote is visible.
80
+ const existing = await stateManager.getDslState(event.address, event.coin, dexKey);
81
+ if (existing?.active) {
82
+ logger.warn("[dsl] position.opened ignored: DSL state already active for this asset/dex", {
83
+ address: event.address,
84
+ asset: event.coin,
85
+ dex: dexKey,
86
+ });
87
+ return;
88
+ }
89
+ if (await isDuplicatePositionEvent(event)) {
90
+ logger.warn("[dsl] duplicate position event ignored (same entry, size, coin, direction)", {
91
+ address: event.address,
92
+ asset: event.coin,
93
+ });
94
+ return;
95
+ }
96
+ const presetName = event.preset ?? config.preset;
97
+ const preset = config.presets[presetName];
98
+ if (!preset) {
99
+ logger.warn("preset not found, skipping DSL", { presetName, asset: event.coin });
100
+ return;
101
+ }
102
+ const entryPrice = parseFloat(String(event.entryPx));
103
+ const direction = parseFloat(String(event.szi)) > 0 ? "LONG" : "SHORT";
104
+ const leverage = event.leverage ?? 1;
105
+ const phase1Floor = phase1FloorFromPreset(entryPrice, direction, preset.maxLossPct, leverage);
106
+ const resolvedTrigger = triggerReason ?? "position_opened";
107
+ const state = buildInitialState(event, preset, phase1Floor, resolvedTrigger);
108
+ const floor = state.floorPrice;
109
+ logger.info("[dsl] position.opened handling", {
86
110
  address: event.address,
87
111
  asset: event.coin,
88
- });
89
- return;
90
- }
91
- const presetName = event.preset ?? config.preset;
92
- const preset = config.presets[presetName];
93
- if (!preset) {
94
- logger.warn("preset not found, skipping DSL", { presetName, asset: event.coin });
95
- return;
96
- }
97
- const entryPrice = parseFloat(String(event.entryPx));
98
- const direction = parseFloat(String(event.szi)) > 0 ? "LONG" : "SHORT";
99
- const leverage = event.leverage ?? 1;
100
- const phase1Floor = phase1FloorFromPreset(entryPrice, direction, preset.maxLossPct, leverage);
101
- const resolvedTrigger = triggerReason ?? "position_opened";
102
- const state = buildInitialState(event, preset, phase1Floor, resolvedTrigger);
103
- const floor = state.floorPrice;
104
- logger.info("[dsl] position.opened handling", {
105
- address: event.address,
106
- asset: event.coin,
107
- dex: event.dex ?? "",
108
- preset: presetName,
109
- floor,
110
- wallet: event.wallet,
111
- });
112
- const strategy = strategyStates.get(event.address);
113
- if (!strategy) {
114
- logger.warn("[dsl] no strategy found for address, skipping initial SL", { address: event.address });
115
- state.slOrderId = null;
116
- state.lastSlSyncError = "no strategy state for address";
117
- state.lastError = state.lastSlSyncError;
118
- state.lastErrorAt = now();
119
- await stateManager.setDslState(event.address, event.coin, event.dex ?? "", state);
120
- bus.emit(DSL_EVENTS.CREATED, buildDslCreatedPayload(event, presetName, preset, state, now()));
121
- return;
122
- }
123
- const posKey = stateKey(event.address, event.coin, event.dex ?? "");
124
- slPlacementInProgress.add(posKey);
125
- try {
126
- await stateManager.setDslState(event.address, event.coin, event.dex ?? "", state);
127
- logger.info("[dsl] initial state written, calling editPosition for initial SL", {
128
- asset: event.coin,
129
- wallet: event.wallet,
130
- dex: event.dex || undefined,
112
+ dex: event.dex ?? "",
113
+ preset: presetName,
131
114
  floor,
115
+ wallet: event.wallet,
132
116
  });
117
+ const strategy = strategyStates.get(event.address);
118
+ if (!strategy) {
119
+ logger.warn("[dsl] no strategy found for address, skipping initial SL", { address: event.address });
120
+ state.slOrderId = null;
121
+ state.lastSlSyncError = "no strategy state for address";
122
+ state.lastError = state.lastSlSyncError;
123
+ state.lastErrorAt = now();
124
+ await stateManager.setDslState(event.address, event.coin, event.dex ?? "", state);
125
+ bus.emit(DSL_EVENTS.CREATED, buildDslCreatedPayload(event, presetName, preset, state, now()));
126
+ return;
127
+ }
128
+ const shouldPlaceSl = opts?.placeSl !== false;
129
+ if (!shouldPlaceSl) {
130
+ // Adoption with deferred SL: monitor places it on the next tick via shouldSyncExchangeSl.
131
+ state.slOrderId = null;
132
+ state.lastSlSyncError = "adopt: SL deferred to monitor";
133
+ logger.info("[dsl] initial state written, SL deferred to monitor (adopt)", {
134
+ asset: event.coin,
135
+ wallet: event.wallet,
136
+ dex: event.dex || undefined,
137
+ floor,
138
+ });
139
+ await stateManager.setDslState(event.address, event.coin, event.dex ?? "", state);
140
+ bus.emit(DSL_EVENTS.CREATED, buildDslCreatedPayload(event, presetName, preset, state, now()));
141
+ return;
142
+ }
143
+ slPlacementInProgress.add(posKey);
133
144
  try {
134
- const res = await withRetry(() => strategy.editPosition({
135
- coin: event.coin,
145
+ await stateManager.setDslState(event.address, event.coin, event.dex ?? "", state);
146
+ logger.info("[dsl] initial state written, calling editPosition for initial SL", {
147
+ asset: event.coin,
148
+ wallet: event.wallet,
136
149
  dex: event.dex || undefined,
137
- stopLoss: { triggerPx: floor },
138
- }), {
139
- retries: DSL_EXTERNAL_RETRIES,
140
- delayMs: DSL_EXTERNAL_RETRY_DELAY_MS,
141
- label: "editPosition (initial SL)",
142
- onRetry: (attempt, remaining, err) => logger.warn("[dsl] editPosition retry", { attempt, remaining, asset: event.coin, err }),
150
+ floor,
143
151
  });
144
- state.slOrderId = res.slOrderId ?? null;
145
- state.lastSyncedFloorPrice = floor;
146
- state.slOrderIdUpdatedAt = now();
147
- state.lastSlSyncError = null;
148
- logger.info("[dsl] exchange SL created (position.opened)", {
152
+ try {
153
+ const res = await withRetry(() => strategy.editPosition({
154
+ coin: event.coin,
155
+ dex: event.dex || undefined,
156
+ stopLoss: { triggerPx: floor },
157
+ }), {
158
+ retries: DSL_EXTERNAL_RETRIES,
159
+ delayMs: DSL_EXTERNAL_RETRY_DELAY_MS,
160
+ label: "editPosition (initial SL)",
161
+ onRetry: (attempt, remaining, err) => logger.warn("[dsl] editPosition retry", { attempt, remaining, asset: event.coin, err }),
162
+ });
163
+ state.slOrderId = res.slOrderId ?? null;
164
+ state.lastSyncedFloorPrice = floor;
165
+ state.slOrderIdUpdatedAt = now();
166
+ state.lastSlSyncError = null;
167
+ logger.info("[dsl] exchange SL created (position.opened)", {
168
+ asset: event.coin,
169
+ address: event.address,
170
+ slOrderId: state.slOrderId,
171
+ floorPrice: floor,
172
+ entryPrice: state.entryPrice,
173
+ });
174
+ }
175
+ catch (err) {
176
+ state.slOrderId = null;
177
+ state.lastSlSyncError = String(err);
178
+ state.lastError = String(err);
179
+ state.lastErrorAt = now();
180
+ logger.warn("initial SL place failed", { asset: event.coin, err });
181
+ }
182
+ logger.info("[dsl] writing state after initial SL", {
149
183
  asset: event.coin,
150
- address: event.address,
151
184
  slOrderId: state.slOrderId,
152
- floorPrice: floor,
153
- entryPrice: state.entryPrice,
185
+ key: posKey,
154
186
  });
187
+ await stateManager.setDslState(event.address, event.coin, event.dex ?? "", state);
188
+ bus.emit(DSL_EVENTS.CREATED, buildDslCreatedPayload(event, presetName, preset, state, now()));
155
189
  }
156
- catch (err) {
157
- state.slOrderId = null;
158
- state.lastSlSyncError = String(err);
159
- state.lastError = String(err);
160
- state.lastErrorAt = now();
161
- logger.warn("initial SL place failed", { asset: event.coin, err });
190
+ finally {
191
+ slPlacementInProgress.delete(posKey);
162
192
  }
163
- logger.info("[dsl] writing state after initial SL", {
164
- asset: event.coin,
165
- slOrderId: state.slOrderId,
166
- key: posKey,
167
- });
168
- await stateManager.setDslState(event.address, event.coin, event.dex ?? "", state);
169
- bus.emit(DSL_EVENTS.CREATED, buildDslCreatedPayload(event, presetName, preset, state, now()));
170
- }
171
- finally {
172
- slPlacementInProgress.delete(posKey);
173
- }
193
+ });
174
194
  }
175
195
  async function handlePositionClosed(payload) {
176
196
  let event = null;
@@ -188,62 +208,65 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
188
208
  asset: event.asset,
189
209
  dex: event.dex ?? "",
190
210
  });
191
- const state = await stateManager.getDslStateByAsset(event.address, event.asset, event.dex);
192
- if (!state?.active)
193
- return;
194
- logger.info("[dsl] closing active state", { asset: state.asset, slOrderId: state.slOrderId });
195
- let reason = CLOSE_REASON.MANUAL_CLOSE;
196
- if (state.slOrderId != null) {
197
- try {
198
- const strategy = strategyStates.get(state.address);
199
- const status = strategy
200
- ? await withRetry(() => strategy.getOrderStatus(state.slOrderId), {
201
- retries: DSL_EXTERNAL_RETRIES,
202
- delayMs: DSL_EXTERNAL_RETRY_DELAY_MS,
203
- label: "getOrderStatus (position.closed)",
204
- onRetry: (attempt, remaining, err) => logger.warn("[dsl] getOrderStatus retry", { attempt, remaining, asset: event.asset, err }),
205
- })
206
- : null;
207
- if (status?.filled)
208
- reason = CLOSE_REASON.EXCHANGE_SL_HIT;
211
+ const closedKey = stateKey(event.address, event.asset, event.dex ?? "");
212
+ await stateManager.withKeyLock(closedKey, async () => {
213
+ const state = await stateManager.getDslStateByAsset(event.address, event.asset, event.dex);
214
+ if (!state?.active)
215
+ return;
216
+ logger.info("[dsl] closing active state", { asset: state.asset, slOrderId: state.slOrderId });
217
+ let reason = CLOSE_REASON.MANUAL_CLOSE;
218
+ if (state.slOrderId != null) {
219
+ try {
220
+ const strategy = strategyStates.get(state.address);
221
+ const status = strategy
222
+ ? await withRetry(() => strategy.getOrderStatus(state.slOrderId), {
223
+ retries: DSL_EXTERNAL_RETRIES,
224
+ delayMs: DSL_EXTERNAL_RETRY_DELAY_MS,
225
+ label: "getOrderStatus (position.closed)",
226
+ onRetry: (attempt, remaining, err) => logger.warn("[dsl] getOrderStatus retry", { attempt, remaining, asset: event.asset, err }),
227
+ })
228
+ : null;
229
+ if (status?.filled)
230
+ reason = CLOSE_REASON.EXCHANGE_SL_HIT;
231
+ }
232
+ catch (e) {
233
+ logger.warn("getOrderStatus failed, assuming manual_close", { asset: event.asset, err: e });
234
+ }
209
235
  }
210
- catch (e) {
211
- logger.warn("getOrderStatus failed, assuming manual_close", { asset: event.asset, err: e });
236
+ const rawCloseReason = fromHook ? payload.data?.closeReason : undefined;
237
+ const dataCloseReason = typeof rawCloseReason === "string" ? rawCloseReason : null;
238
+ const archiveCloseReason = reason === CLOSE_REASON.MANUAL_CLOSE && dataCloseReason ? dataCloseReason : reason;
239
+ logger.info("[dsl] archiving position (position.closed)", {
240
+ address: event.address,
241
+ asset: state.asset,
242
+ closeReason: archiveCloseReason,
243
+ slOrderId: state.slOrderId,
244
+ fromHook: fromHook,
245
+ });
246
+ const closedState = { ...state, active: false, closedAt: now(), closeReason: archiveCloseReason };
247
+ await stateManager.archiveAndDeleteDslState(state.address, state.asset, state.dex, closedState);
248
+ if (state.handoffStatus === "ok" && state.backendDslPositionId != null) {
249
+ fireAndForgetBackendDelete(state, "position.closed");
212
250
  }
213
- }
214
- const rawCloseReason = fromHook ? payload.data?.closeReason : undefined;
215
- const dataCloseReason = typeof rawCloseReason === "string" ? rawCloseReason : null;
216
- const archiveCloseReason = reason === CLOSE_REASON.MANUAL_CLOSE && dataCloseReason ? dataCloseReason : reason;
217
- logger.info("[dsl] archiving position (position.closed)", {
218
- address: event.address,
219
- asset: state.asset,
220
- closeReason: archiveCloseReason,
221
- slOrderId: state.slOrderId,
222
- fromHook: fromHook,
223
- });
224
- const closedState = { ...state, active: false, closedAt: now(), closeReason: archiveCloseReason };
225
- await stateManager.archiveAndDeleteDslState(state.address, state.asset, state.dex, closedState);
226
- if (state.handoffStatus === "ok" && state.backendDslPositionId != null) {
227
- fireAndForgetBackendDelete(state, "position.closed");
228
- }
229
- const closedTs = now();
230
- bus.emit(EVENTS.POSITION_CLOSED, {
231
- address: state.address,
232
- asset: state.asset,
233
- wallet: state.wallet,
234
- reason,
235
- closeReason: archiveCloseReason,
236
- timestamp: closedTs,
237
- // Provenance marker (data only, no consumer branches on it): this emit re-broadcasts a close
238
- // after archiving DSL state; the recording subscriber skips it to avoid double-counting a
239
- // close already recorded from the original (enveloped or flat) emit.
240
- source: "dsl_archive",
251
+ const closedTs = now();
252
+ bus.emit(EVENTS.POSITION_CLOSED, {
253
+ address: state.address,
254
+ asset: state.asset,
255
+ wallet: state.wallet,
256
+ reason,
257
+ closeReason: archiveCloseReason,
258
+ timestamp: closedTs,
259
+ // Provenance marker (data only, no consumer branches on it): this emit re-broadcasts a close
260
+ // after archiving DSL state; the recording subscriber skips it to avoid double-counting a
261
+ // close already recorded from the original (enveloped or flat) emit.
262
+ source: "dsl_archive",
263
+ });
264
+ bus.emit(DSL_EVENTS.CLOSED, buildDslClosedEventFromState(closedState, {
265
+ reason: archiveCloseReason,
266
+ closeReason: archiveCloseReason,
267
+ timestamp: closedTs,
268
+ }));
241
269
  });
242
- bus.emit(DSL_EVENTS.CLOSED, buildDslClosedEventFromState(closedState, {
243
- reason: archiveCloseReason,
244
- closeReason: archiveCloseReason,
245
- timestamp: closedTs,
246
- }));
247
270
  }
248
271
  async function handlePositionFlipped(payload) {
249
272
  let event = null;
@@ -260,26 +283,36 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
260
283
  archiveAsset: event.asset,
261
284
  newCoin: event.coin,
262
285
  });
286
+ // Archive the OLD state under its own key — lock released before calling handlePositionOpened
287
+ // (which locks the NEW key itself; keys differ after a flip).
263
288
  const oldState = await stateManager.getDslStateByAsset(event.address, event.asset, event.dex);
264
289
  if (oldState?.active) {
265
- logger.info("[dsl] archiving position (position.flipped)", {
266
- address: event.address,
267
- asset: oldState.asset,
268
- dex: oldState.dex ?? "",
269
- closeReason: CLOSE_REASON.FLIPPED,
270
- slOrderId: oldState.slOrderId,
290
+ const oldKey = stateKey(event.address, oldState.asset, oldState.dex ?? "");
291
+ await stateManager.withKeyLock(oldKey, async () => {
292
+ // Re-read to confirm still active (concurrent close could have already archived it).
293
+ const currentOld = await stateManager.getDslStateByAsset(event.address, event.asset, event.dex);
294
+ if (!currentOld?.active)
295
+ return;
296
+ logger.info("[dsl] archiving position (position.flipped)", {
297
+ address: event.address,
298
+ asset: currentOld.asset,
299
+ dex: currentOld.dex ?? "",
300
+ closeReason: CLOSE_REASON.FLIPPED,
301
+ slOrderId: currentOld.slOrderId,
302
+ });
303
+ const closedState = { ...currentOld, active: false, closedAt: now(), closeReason: CLOSE_REASON.FLIPPED };
304
+ // Must use canonical keys from loaded state — not event.asset (e.g. xyz:BASE vs BASE + dex xyz
305
+ // produce different stateKey paths; wrong asset would archive nothing and leave the live file active).
306
+ await stateManager.archiveAndDeleteDslState(event.address, currentOld.asset, currentOld.dex ?? "", closedState);
307
+ if (currentOld.handoffStatus === "ok" && currentOld.backendDslPositionId != null) {
308
+ fireAndForgetBackendDelete(currentOld, "position.flipped");
309
+ }
271
310
  });
272
- const closedState = { ...oldState, active: false, closedAt: now(), closeReason: CLOSE_REASON.FLIPPED };
273
- // Must use canonical keys from loaded state — not event.asset (e.g. xyz:BASE vs BASE + dex xyz
274
- // produce different stateKey paths; wrong asset would archive nothing and leave the live file active).
275
- await stateManager.archiveAndDeleteDslState(event.address, oldState.asset, oldState.dex ?? "", closedState);
276
- if (oldState.handoffStatus === "ok" && oldState.backendDslPositionId != null) {
277
- fireAndForgetBackendDelete(oldState, "position.flipped");
278
- }
279
311
  }
280
312
  else {
281
313
  logger.warn("[dsl] position.flipped: no active DSL state matched for archive (check asset/dex vs state file). If a live file still exists for this market, the next position.opened may be ignored as duplicate.", { address: event.address, asset: event.asset, dex: event.dex ?? "" });
282
314
  }
315
+ // Old-key lock is released above. handlePositionOpened locks the NEW key itself.
283
316
  // New row uses `coin` (and dex) from the flip payload — not `asset`, which identifies the pre-flip state for archival.
284
317
  const openedPayload = {
285
318
  address: event.address,
@@ -305,8 +338,13 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
305
338
  if (!raw)
306
339
  return;
307
340
  const event = normalizePositionOpenedEvent(raw);
308
- const oldState = await stateManager.getDslStateByAsset(event.address, event.coin, event.dex);
309
- if (oldState?.active) {
341
+ // Archive the OLD state under its key — lock released BEFORE calling handlePositionOpened
342
+ // (which re-locks the same key; holding it across the nested call would deadlock).
343
+ const archiveKey = stateKey(event.address, event.coin, event.dex ?? "");
344
+ await stateManager.withKeyLock(archiveKey, async () => {
345
+ const oldState = await stateManager.getDslStateByAsset(event.address, event.coin, event.dex);
346
+ if (!oldState?.active)
347
+ return;
310
348
  const closedState = {
311
349
  ...oldState,
312
350
  active: false,
@@ -319,7 +357,8 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
319
357
  if (oldState.handoffStatus === "ok" && oldState.backendDslPositionId != null) {
320
358
  fireAndForgetBackendDelete(oldState, "position.increased");
321
359
  }
322
- }
360
+ });
361
+ // archiveKey lock is released; handlePositionOpened re-acquires it safely.
323
362
  await handlePositionOpened(event, "position_increased");
324
363
  }
325
364
  async function handlePositionDecreased(payload) {
@@ -333,8 +372,13 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
333
372
  if (!raw)
334
373
  return;
335
374
  const event = normalizePositionOpenedEvent(raw);
336
- const oldState = await stateManager.getDslStateByAsset(event.address, event.coin, event.dex);
337
- if (oldState?.active) {
375
+ // Archive the OLD state under its key — lock released BEFORE calling handlePositionOpened
376
+ // (which re-locks the same key; holding it across the nested call would deadlock).
377
+ const archiveKey = stateKey(event.address, event.coin, event.dex ?? "");
378
+ await stateManager.withKeyLock(archiveKey, async () => {
379
+ const oldState = await stateManager.getDslStateByAsset(event.address, event.coin, event.dex);
380
+ if (!oldState?.active)
381
+ return;
338
382
  const closedState = {
339
383
  ...oldState,
340
384
  active: false,
@@ -347,7 +391,8 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
347
391
  if (oldState.handoffStatus === "ok" && oldState.backendDslPositionId != null) {
348
392
  fireAndForgetBackendDelete(oldState, "position.decreased");
349
393
  }
350
- }
394
+ });
395
+ // archiveKey lock is released; handlePositionOpened re-acquires it safely.
351
396
  await handlePositionOpened(event, "position_decreased");
352
397
  }
353
398
  async function deleteDslsForStrategy(address) {
@@ -355,62 +400,70 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
355
400
  const ts = now();
356
401
  let abandoned = 0;
357
402
  for (const key of keys) {
358
- const state = await stateManager.getOrLoad(key);
359
- if (!state?.active)
403
+ // Peek outside the lock to skip inactive entries cheaply.
404
+ const peeked = await stateManager.getOrLoad(key);
405
+ if (!peeked?.active)
360
406
  continue;
361
- abandoned += 1;
362
- const strategy = strategyStates.get(state.address);
363
- const cleanupPromises = [];
364
- if (strategy) {
365
- cleanupPromises.push(withRetry(() => strategy.editPosition({ coin: state.asset, dex: state.dex || undefined }), {
366
- retries: DSL_EXTERNAL_RETRIES,
367
- delayMs: DSL_EXTERNAL_RETRY_DELAY_MS,
368
- label: "editPosition (cancel SL, dsl delete)",
369
- onRetry: (attempt, remaining, err) => logger.warn("[dsl] editPosition cancel SL retry", { attempt, remaining, err }),
370
- }).then(() => undefined).catch((err) => {
371
- logger.warn("[dsl] cancel SL failed during deleteDslsForStrategy; continuing archive", {
372
- address,
407
+ // Lock the per-position key for the archive RMW.
408
+ const perPosKey = stateKey(peeked.address, peeked.asset, peeked.dex ?? "");
409
+ await stateManager.withKeyLock(perPosKey, async () => {
410
+ const state = await stateManager.getOrLoad(key);
411
+ if (!state?.active)
412
+ return;
413
+ abandoned += 1;
414
+ const strategy = strategyStates.get(state.address);
415
+ const cleanupPromises = [];
416
+ if (strategy) {
417
+ cleanupPromises.push(withRetry(() => strategy.editPosition({ coin: state.asset, dex: state.dex || undefined }), {
418
+ retries: DSL_EXTERNAL_RETRIES,
419
+ delayMs: DSL_EXTERNAL_RETRY_DELAY_MS,
420
+ label: "editPosition (cancel SL, dsl delete)",
421
+ onRetry: (attempt, remaining, err) => logger.warn("[dsl] editPosition cancel SL retry", { attempt, remaining, err }),
422
+ }).then(() => undefined).catch((err) => {
423
+ logger.warn("[dsl] cancel SL failed during deleteDslsForStrategy; continuing archive", {
424
+ address,
425
+ asset: state.asset,
426
+ err,
427
+ });
428
+ }));
429
+ }
430
+ else {
431
+ logger.warn("[dsl] no strategy found for address, skipping SL cancel", { address });
432
+ }
433
+ if (state.handoffStatus === "ok" && state.backendDslPositionId != null && strategy) {
434
+ cleanupPromises.push(strategy.deleteRatchetStop({
373
435
  asset: state.asset,
374
- err,
375
- });
376
- }));
377
- }
378
- else {
379
- logger.warn("[dsl] no strategy found for address, skipping SL cancel", { address });
380
- }
381
- if (state.handoffStatus === "ok" && state.backendDslPositionId != null && strategy) {
382
- cleanupPromises.push(strategy.deleteRatchetStop({
436
+ dex: state.dex || undefined,
437
+ }).then(() => {
438
+ logger.info("[dsl] deleted backend dsl position (deleteDslsForStrategy)", {
439
+ address,
440
+ asset: state.asset,
441
+ });
442
+ }).catch((err) => {
443
+ logger.warn("[dsl] failed to delete backend dsl position (deleteDslsForStrategy)", {
444
+ address,
445
+ asset: state.asset,
446
+ err,
447
+ });
448
+ }));
449
+ }
450
+ await Promise.all(cleanupPromises);
451
+ const closedState = {
452
+ ...state,
453
+ active: false,
454
+ closedAt: ts,
455
+ closeReason: CLOSE_REASON.DSL_DELETED,
456
+ };
457
+ await stateManager.archiveAndDeleteDslState(state.address, state.asset, state.dex, closedState);
458
+ bus.emit(DSL_EVENTS.DELETED, {
459
+ address: state.address,
383
460
  asset: state.asset,
384
- dex: state.dex || undefined,
385
- }).then(() => {
386
- logger.info("[dsl] deleted backend dsl position (deleteDslsForStrategy)", {
387
- address,
388
- asset: state.asset,
389
- });
390
- }).catch((err) => {
391
- logger.warn("[dsl] failed to delete backend dsl position (deleteDslsForStrategy)", {
392
- address,
393
- asset: state.asset,
394
- err,
395
- });
396
- }));
397
- }
398
- await Promise.all(cleanupPromises);
399
- const closedState = {
400
- ...state,
401
- active: false,
402
- closedAt: ts,
403
- closeReason: CLOSE_REASON.DSL_DELETED,
404
- };
405
- await stateManager.archiveAndDeleteDslState(state.address, state.asset, state.dex, closedState);
406
- bus.emit(DSL_EVENTS.DELETED, {
407
- address: state.address,
408
- asset: state.asset,
409
- dex: state.dex ?? "",
410
- triggerReason: state.triggerReason,
411
- __positionId: state.positionId ?? state.correlationId,
412
- __correlationId: state.correlationId,
413
- timestamp: ts,
461
+ dex: state.dex ?? "",
462
+ triggerReason: state.triggerReason,
463
+ __positionId: state.positionId ?? state.correlationId,
464
+ __correlationId: state.correlationId,
465
+ timestamp: ts,
466
+ });
414
467
  });
415
468
  }
416
469
  return abandoned;
@@ -418,6 +471,40 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
418
471
  function isSlPlacementInProgress(key) {
419
472
  return slPlacementInProgress.has(key);
420
473
  }
474
+ /**
475
+ * Adopt an orphaned exchange position into DSL.
476
+ * Calls handlePositionOpened directly — NO bus emit of on_position_opened / position.opened.
477
+ * triggerReason is always "adopted" so formatDslCreated returns null (no user "Opened" message).
478
+ */
479
+ async function adoptPosition(payload, opts) {
480
+ await handlePositionOpened(payload, "adopted", opts);
481
+ }
482
+ /**
483
+ * Resize: archive any existing ACTIVE DSL state for the (coin, dex), then call adoptPosition.
484
+ * Archive uses CLOSE_REASON.RECONCILE_RESIZE. Lock is released before the nested adopt
485
+ * (same pattern as handlePositionIncreased) to avoid deadlock.
486
+ */
487
+ async function resizePosition(payload, opts) {
488
+ const dexKey = payload.dex ?? "";
489
+ const archiveKey = stateKey(payload.address, payload.coin, dexKey);
490
+ await stateManager.withKeyLock(archiveKey, async () => {
491
+ const oldState = await stateManager.getDslStateByAsset(payload.address, payload.coin, dexKey);
492
+ if (!oldState?.active)
493
+ return;
494
+ const closedState = {
495
+ ...oldState,
496
+ active: false,
497
+ closedAt: now(),
498
+ closeReason: CLOSE_REASON.RECONCILE_RESIZE,
499
+ };
500
+ await stateManager.archiveAndDeleteDslState(payload.address, oldState.asset, oldState.dex, closedState);
501
+ if (oldState.handoffStatus === "ok" && oldState.backendDslPositionId != null) {
502
+ fireAndForgetBackendDelete(oldState, "reconcile_resize");
503
+ }
504
+ });
505
+ // archiveKey lock released above; adoptPosition (via handlePositionOpened) re-acquires it safely.
506
+ await adoptPosition(payload, opts);
507
+ }
421
508
  function fireAndForgetBackendDelete(state, trigger) {
422
509
  const resolveAndDelete = async () => {
423
510
  const strategy = strategyStates.get(state.address);
@@ -465,6 +552,8 @@ export function createEventHandlers(config, stateManager, strategyStates, bus, _
465
552
  handlePositionDecreased,
466
553
  deleteDslsForStrategy,
467
554
  isSlPlacementInProgress,
555
+ adoptPosition,
556
+ resizePosition,
468
557
  };
469
558
  }
470
559
  //# sourceMappingURL=handlers.js.map