@reefclaw/openclaw-plugin 0.1.6 → 0.1.7

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 (52) hide show
  1. package/bridge/gateway/event-parser.d.ts +6 -1
  2. package/bridge/gateway/event-parser.js +19 -2
  3. package/bridge/gateway/poller.d.ts +1 -0
  4. package/bridge/gateway/poller.js +14 -2
  5. package/bridge/providers/gateway.d.ts +22 -2
  6. package/bridge/providers/gateway.js +67 -9
  7. package/ccxt/public-market-data-api.d.ts +14 -0
  8. package/ccxt/public-market-data-api.js +15 -1
  9. package/config/plugin-config-io.d.ts +7 -0
  10. package/config/plugin-config-io.js +15 -0
  11. package/index.js +107 -29
  12. package/ingest/position-auto-capture.d.ts +68 -0
  13. package/ingest/position-auto-capture.js +321 -23
  14. package/ingest/position-decisions-client.d.ts +7 -2
  15. package/ingest/position-decisions-client.js +13 -3
  16. package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
  17. package/ingest/reconcile-db-vs-exchange.js +66 -10
  18. package/live/fill-price.d.ts +13 -0
  19. package/live/fill-price.js +37 -0
  20. package/live/live-adapter.d.ts +33 -1
  21. package/live/live-adapter.js +176 -47
  22. package/live/position-state-store.d.ts +4 -0
  23. package/live/stop-watcher.d.ts +8 -1
  24. package/live/stop-watcher.js +5 -2
  25. package/onboarding/runtime.d.ts +6 -0
  26. package/onboarding/runtime.js +13 -2
  27. package/package.json +2 -2
  28. package/portfolio/reentry-tracker.d.ts +36 -0
  29. package/portfolio/reentry-tracker.js +127 -0
  30. package/signals/conditions/registry.js +11 -2
  31. package/signals/strategy-adapter.js +17 -7
  32. package/simulator/exchange-simulator.d.ts +12 -0
  33. package/simulator/exchange-simulator.js +73 -3
  34. package/simulator/types.d.ts +4 -0
  35. package/skills/reefclaw/SKILL.md +2 -0
  36. package/tools/assessment-validation.d.ts +21 -0
  37. package/tools/assessment-validation.js +58 -0
  38. package/tools/attach-brackets.js +165 -0
  39. package/tools/audit-bracket-protection.js +157 -1
  40. package/tools/bracket-control.d.ts +12 -0
  41. package/tools/bracket-control.js +35 -0
  42. package/tools/create-order.js +30 -2
  43. package/tools/get-setup-detail.js +12 -1
  44. package/tools/modify-stop.js +5 -5
  45. package/tools/modify-target.js +5 -5
  46. package/tools/scan-pairs.d.ts +4 -0
  47. package/tools/scan-pairs.js +4 -1
  48. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  49. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  50. package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
  51. package/venues/hyperliquid/hl-live-adapter.js +380 -5
  52. package/venues/hyperliquid/hl-public.js +8 -1
@@ -1,8 +1,10 @@
1
1
  import type { PositionDecisionsClient } from './position-decisions-client.js';
2
2
  import type { PositionStateStore } from '../live/position-state-store.js';
3
3
  import type { CcxtOrder } from '../types.js';
4
+ import type { IExchangeAdapter } from '../exchange-adapter.js';
4
5
  import type { PositionMetadata } from '../simulator/types.js';
5
6
  import type { PendingEntryStore } from './pending-entry-metadata.js';
7
+ import type { ReentryTracker } from '../portfolio/reentry-tracker.js';
6
8
  export interface AutoCaptureContext {
7
9
  decisionsClient?: PositionDecisionsClient;
8
10
  stateStore?: PositionStateStore;
@@ -26,7 +28,29 @@ export interface AutoCaptureContext {
26
28
  * `resolveMode` this is a plain value, not a resolver. Tags journal rows
27
29
  * (positions.exchange, migration 0058). */
28
30
  venue?: 'binance' | 'hyperliquid';
31
+ /** Resolve the ACTIVE adapter at capture time (follows a runtime reconnect,
32
+ * same deferred-closure pattern as resolveMode). Used by the stale-state
33
+ * defense in onCreateOrderFilled: when the state-store claims an open
34
+ * position but the exchange position is exactly the just-filled quantity,
35
+ * the prior exposure was closed outside the journal (watcher/manual) and
36
+ * the mapping is STALE — stitching onto it fabricates P&L (issue #199). */
37
+ resolveAdapter?: () => IExchangeAdapter | undefined;
38
+ /** Re-entry tracker (issue #204) — every close path records the exit so
39
+ * scan_pairs can flag setups already traded within the current signal bar. */
40
+ reentryTracker?: ReentryTracker;
29
41
  }
42
+ /** Engine-exact paper fill economics attached by the simulator to close-order
43
+ * results (`order.info.paperTrade`, issue #201). All numbers are quote-ccy. */
44
+ export interface PaperTradeInfo {
45
+ grossRealizedPnl: number;
46
+ netRealizedPnl: number;
47
+ openFee: number;
48
+ closeFee: number;
49
+ setupType?: string;
50
+ }
51
+ /** Extract the simulator's engine-exact trade economics from a close order,
52
+ * with strict numeric guards (absent on live orders → undefined). */
53
+ export declare function extractPaperTrade(order: CcxtOrder | undefined): PaperTradeInfo | undefined;
30
54
  export interface CreateOrderInputs {
31
55
  symbol: string;
32
56
  side: 'buy' | 'sell';
@@ -56,6 +80,50 @@ export interface ClosePositionInputs {
56
80
  * assessment from close_position.ts. Looks up the cached position UUID, posts
57
81
  * the close decision row, and drops the local state entry. */
58
82
  export declare function onClosePositionFilled(ctx: AutoCaptureContext, inputs: ClosePositionInputs, order: CcxtOrder): Promise<void>;
83
+ export interface AutoFlattenCloseInputs {
84
+ symbol: string;
85
+ /** Actual average fill price of the flatten close (resolved, never a limit). */
86
+ fillPrice: number;
87
+ fillSize: number;
88
+ exchangeTradeId?: string;
89
+ observedAtMs?: number;
90
+ }
91
+ /** Journal the close produced by an adapter-internal auto-flatten
92
+ * (`bracket_attach_failed`). Those flattens call `adapter.closePosition`
93
+ * directly, bypassing the close_position tool, so `onClosePositionFilled`
94
+ * never runs and the position row orphaned as status='open' (issue #196:
95
+ * LTC 2026-07-14, TAO 2026-07-15 — both showed 1 exchange position vs 2 in the
96
+ * journal). Polls briefly for the entry journal's webappPositionId (the
97
+ * entry's WS fill can arrive just after the flatten), then posts an idempotent
98
+ * close row (UNIQUE on position_id) and drops the state-store entry. Fail-open;
99
+ * the boot DB reconcile sweep is the backstop if the entry never journals. */
100
+ export declare function onAutoFlattenClose(ctx: AutoCaptureContext, inputs: AutoFlattenCloseInputs, lookup?: {
101
+ attempts: number;
102
+ intervalMs: number;
103
+ }): Promise<void>;
104
+ export interface StopWatcherCloseInputs {
105
+ symbol: string;
106
+ stopPrice: number;
107
+ markPrice: number;
108
+ /** The executed close order from adapter.closePosition (may be absent if the
109
+ * close resolved through a path that didn't surface it). */
110
+ order?: CcxtOrder;
111
+ }
112
+ /** Journal a stop-watcher auto-close (issue #199).
113
+ *
114
+ * The watcher calls `adapter.closePosition(symbol, 'stop_watcher')` directly —
115
+ * it never goes through the close_position tool, and paper has no WS fill
116
+ * stream, so before this hook every watcher close left the journal position
117
+ * (and the state-store mapping) alive: the next trade on the symbol was then
118
+ * silently dropped or stitched onto the stale position, fabricating P&L
119
+ * (measured 14/69 corrupted closes on the 2026-07 HL soak; worst single
120
+ * fabrication −$117.86 vs a real −$10.63).
121
+ *
122
+ * Contract: the engine exposure is DEFINITIVELY gone when this runs (the
123
+ * watcher's close resolved), so the state-store entry is always dropped —
124
+ * even when we can't post a close row (the periodic DB sweep then heals the
125
+ * orphaned DB row instead of a later close being mis-stitched). Fail-open. */
126
+ export declare function onStopWatcherClose(ctx: AutoCaptureContext, inputs: StopWatcherCloseInputs): Promise<void>;
59
127
  /** WS-driven fill observed. Resolves the PR1 deferral that limit-order fills
60
128
  * + scale-ins were silently dropped. Branches on three cases:
61
129
  *
@@ -20,11 +20,57 @@
20
20
  // §5.1 for the longer-term design).
21
21
  import { logger } from '../logger.js';
22
22
  import { isBracketCid } from '../live/bracket-id.js';
23
+ import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
24
+ import { fillPriceFromOrder } from '../live/fill-price.js';
23
25
  const TAG = 'position-auto-capture';
24
26
  /** Flatness tolerance for remaining-contracts tracking. Reduce-only fills sum
25
27
  * exactly to the position size on Binance, so any residual below this is noise
26
28
  * / float rounding and means the position is flat. */
27
29
  const FLAT_EPSILON = 1e-6;
30
+ /** Extract the simulator's engine-exact trade economics from a close order,
31
+ * with strict numeric guards (absent on live orders → undefined). */
32
+ export function extractPaperTrade(order) {
33
+ const raw = order?.info?.['paperTrade'];
34
+ if (!raw || typeof raw !== 'object')
35
+ return undefined;
36
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
37
+ const gross = num(raw['grossRealizedPnl']);
38
+ const net = num(raw['netRealizedPnl']);
39
+ if (gross === undefined || net === undefined)
40
+ return undefined;
41
+ return {
42
+ grossRealizedPnl: gross,
43
+ netRealizedPnl: net,
44
+ openFee: num(raw['openFee']) ?? 0,
45
+ closeFee: num(raw['closeFee']) ?? 0,
46
+ setupType: typeof raw['setupType'] === 'string' ? raw['setupType'] : undefined,
47
+ };
48
+ }
49
+ /** Stale-state probe: does the exchange position for `symbol` consist entirely
50
+ * of the just-filled quantity? 'stale' → the tracked prior exposure no longer
51
+ * exists (closed outside the journal); 'active' → genuine scale-in;
52
+ * 'unknown' → cannot tell (null fetch etc.) — callers keep legacy behaviour. */
53
+ async function probeStaleStateEntry(ctx, symbol, filledQty) {
54
+ const adapter = ctx.resolveAdapter?.();
55
+ if (!adapter)
56
+ return 'unknown';
57
+ let positions;
58
+ try {
59
+ positions = await adapter.getPositionsOrNull(symbol);
60
+ }
61
+ catch {
62
+ return 'unknown';
63
+ }
64
+ if (positions === null)
65
+ return 'unknown';
66
+ const key = normalizeBracketSymbol(symbol);
67
+ const pos = positions.find((p) => normalizeBracketSymbol(p.symbol) === key);
68
+ const contracts = Math.abs(Number(pos?.contracts));
69
+ if (!pos || !Number.isFinite(contracts) || contracts <= 0)
70
+ return 'unknown';
71
+ const tolerance = Math.max(1e-9, contracts * 0.001);
72
+ return Math.abs(contracts - filledQty) <= tolerance ? 'stale' : 'active';
73
+ }
28
74
  /** Called after a successful adapter.createOrder().
29
75
  *
30
76
  * For PR 1, we treat any non-zero `filled` quantity as an entry event and
@@ -44,13 +90,16 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
44
90
  // Limit order not yet filled — nothing to capture yet.
45
91
  return;
46
92
  }
47
- const fillPrice = typeof order.average === 'number' && Number.isFinite(order.average) && order.average > 0
48
- ? order.average
49
- : typeof order.price === 'number' && Number.isFinite(order.price) && order.price > 0
50
- ? order.price
51
- : null;
93
+ // Resolve the ACTUAL average fill never order.price (the limit). A
94
+ // marketable limit can fill percent-scale away from its limit, and the
95
+ // synchronous RESULT response sometimes omits avgPrice on a filled limit
96
+ // (issue #196: TAO journaled 202 vs a real 196.23 fill). The adapter
97
+ // re-queries to enrich order.average before we get here; if it's still
98
+ // unresolvable we SKIP rather than journal the wrong (limit) price — the
99
+ // WS-driven onWsFillObserved path re-captures the entry with the real fill.
100
+ const fillPrice = fillPriceFromOrder(order);
52
101
  if (fillPrice === null) {
53
- logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: missing fill price; skipping capture`);
102
+ logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: fill price unresolvable (avg=${order.average ?? 'null'} cost=${order.cost} filled=${order.filled}); skipping capture (WS fill path will re-capture with the real price)`);
54
103
  return;
55
104
  }
56
105
  // Need v2.10.0 metadata to build a meaningful entry row. In paper mode the
@@ -62,13 +111,57 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
62
111
  }
63
112
  const positionSide = inputs.side === 'buy' ? 'long' : 'short';
64
113
  const openedAtMs = Date.now();
65
- // Skip if state-store already knows about this position (scale-in / partial
66
- // fill of an existing entry). PR 1 deferral: don't record additional entries.
67
- // The follow-up that hooks WS-ingest will detect scale-ins explicitly.
114
+ // State-store already knows this symbol. Three cases (issue #199):
115
+ // STALE — the exchange position is exactly this fill, so the tracked
116
+ // prior exposure was closed outside the journal (stop-watcher /
117
+ // manual). Drop the stale mapping and journal a NEW position;
118
+ // stitching onto it fabricates P&L against the old entry.
119
+ // SCALE-IN (paper) — genuine add. Paper has no WS fill stream, so the old
120
+ // "deferred to v2" early-return silently dropped every paper
121
+ // scale-in; journal an is_scale_in entry row here instead.
122
+ // SCALE-IN (live) — keep deferring to onWsFillObserved (it captures
123
+ // scale-ins with WS-exact data; capturing here would double-post).
68
124
  const existing = ctx.stateStore.get(inputs.symbol);
69
125
  if (existing) {
70
- logger.info(TAG, `onCreateOrderFilled ${inputs.symbol}: existing position in state-store (openedAt=${existing.openedAt}); treating as scale-in (auto-capture deferred to v2)`);
71
- return;
126
+ const probe = await probeStaleStateEntry(ctx, inputs.symbol, filledQty);
127
+ if (probe === 'stale') {
128
+ logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: state-store entry (openedAt=${existing.openedAt}) is STALE — ` +
129
+ `exchange position equals this fill (${filledQty}); prior exposure closed outside the journal. ` +
130
+ `Dropping stale mapping and journaling a NEW position (issue #199).`);
131
+ ctx.stateStore.remove(inputs.symbol);
132
+ // fall through to the new-position path below
133
+ }
134
+ else if (ctx.resolveMode?.() !== 'paper') {
135
+ logger.info(TAG, `onCreateOrderFilled ${inputs.symbol}: existing position in state-store (openedAt=${existing.openedAt}); scale-in captured by the WS fill path`);
136
+ return;
137
+ }
138
+ else if (existing.side !== positionSide) {
139
+ // Opposite-side paper fill on a tracked position = partial/soft flatten
140
+ // that bypassed close_position. Don't guess — the close paths + periodic
141
+ // sweep own this.
142
+ logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: opposite-side fill on tracked ${existing.side} position — skipping (use close_position)`);
143
+ return;
144
+ }
145
+ else {
146
+ if (!existing.webappPositionId) {
147
+ logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: paper scale-in but no webappPositionId yet — skipping entry row`);
148
+ return;
149
+ }
150
+ const entry = buildEntryPayload({
151
+ positionId: existing.webappPositionId,
152
+ isScaleIn: true,
153
+ ts: openedAtMs,
154
+ fillPrice,
155
+ fillSize: filledQty,
156
+ exchangeTradeId: typeof order.id === 'string' ? order.id : '',
157
+ metadata: md,
158
+ });
159
+ ctx.decisionsClient.postEntry(ctx.userId, entry);
160
+ ctx.stateStore.addOpenContracts(inputs.symbol, filledQty);
161
+ logger.info(TAG, `scale-in captured (paper) ${inputs.symbol} ${positionSide} ${filledQty} @ ${fillPrice} ` +
162
+ `(positionId=${existing.webappPositionId.slice(0, 8)}…)`);
163
+ return;
164
+ }
72
165
  }
73
166
  // Local state-store first — ensures restart-survival even if the webapp POST
74
167
  // fails on the first attempt.
@@ -77,6 +170,7 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
77
170
  openedAt: openedAtMs,
78
171
  side: positionSide,
79
172
  openedFromExchangeTradeId: typeof order.id === 'string' ? order.id : undefined,
173
+ setupType: md.setupType,
80
174
  });
81
175
  const upsert = {
82
176
  symbol: inputs.symbol,
@@ -134,46 +228,242 @@ export async function onClosePositionFilled(ctx, inputs, order) {
134
228
  const filledQty = typeof order.filled === 'number' && Number.isFinite(order.filled)
135
229
  ? order.filled
136
230
  : 0;
137
- const fillPrice = typeof order.average === 'number' && Number.isFinite(order.average) && order.average > 0
138
- ? order.average
139
- : typeof order.price === 'number' && Number.isFinite(order.price) && order.price > 0
140
- ? order.price
141
- : null;
231
+ // Actual average fill — never order.price (the limit); see fillPriceFromOrder (#196).
232
+ const fillPrice = fillPriceFromOrder(order);
142
233
  if (filledQty <= 0 || fillPrice === null) {
143
234
  logger.warn(TAG, `onClosePositionFilled ${inputs.symbol}: missing fill quantity or price; skipping capture`);
144
235
  return;
145
236
  }
146
237
  const closeAtMs = Date.now();
147
- // Realized PnL + R-multiple require entry-side state we don't have here for
148
- // PR 1. The skill-side computation already produces these for the webapp;
149
- // this auto-capture just records what the close tool input told us. Default
150
- // numeric fields to 0 — webapp accepts and the operator will see the close
151
- // card with zeros until the v2 ws-ingest hook fills them properly.
152
238
  const regimeConfNormalized = typeof inputs.regimeConfidence === 'number'
153
239
  ? Math.max(0, Math.min(1, inputs.regimeConfidence / 100))
154
240
  : 0.5;
241
+ // Engine-exact economics on paper (issue #201): the simulator attaches the
242
+ // trade's net P&L + both fee legs to the close order. Posting a non-zero
243
+ // realizedPnl means the webapp keeps it verbatim (its recompute only fires
244
+ // on the legacy all-zero path) — the journal stops being gross-of-fees.
245
+ const paperTrade = extractPaperTrade(order);
246
+ // Size-mismatch defense (issue #199): if the close fill is materially larger
247
+ // or smaller than the contracts tracked for THIS journal position, the close
248
+ // likely belongs to different exposure (state-store staleness). Flag it so
249
+ // the webapp close route refuses to fabricate price-based P&L/R from it.
250
+ const tracked = stateEntry.remainingContracts;
251
+ const sizeMismatch = typeof tracked === 'number' && Number.isFinite(tracked) && tracked > 0 &&
252
+ Math.abs(tracked - filledQty) / Math.max(tracked, filledQty) > 0.05
253
+ ? { tracked_contracts: tracked, close_fill_size: filledQty }
254
+ : undefined;
255
+ if (sizeMismatch) {
256
+ logger.warn(TAG, `onClosePositionFilled ${inputs.symbol}: close fill ${filledQty} vs tracked ${tracked} contracts — ` +
257
+ `flagging size_mismatch (webapp will not derive price-based metrics from this close)`);
258
+ }
259
+ const closeAssessment = {
260
+ ...inputs.closeAssessment,
261
+ ...(paperTrade
262
+ ? {
263
+ pnl_source: 'paper_engine_net',
264
+ pnl_gross: paperTrade.grossRealizedPnl,
265
+ fees: { open: paperTrade.openFee, close: paperTrade.closeFee },
266
+ }
267
+ : {}),
268
+ ...(sizeMismatch ? { size_mismatch: sizeMismatch } : {}),
269
+ };
155
270
  const close = {
156
271
  positionId: stateEntry.webappPositionId,
157
272
  closeAt: closeAtMs,
158
273
  closeReason: inputs.closeReason,
159
- closeAssessment: inputs.closeAssessment,
274
+ closeAssessment,
160
275
  scorecardVerdict: inputs.scorecardVerdict ?? 'NO_GO',
161
276
  confluenceScore: typeof inputs.confluenceScore === 'number' ? inputs.confluenceScore : 0,
162
277
  regime: inputs.regime ?? 'unknown',
163
278
  regimeConfidence: regimeConfNormalized,
164
279
  fillPrice,
165
280
  fillSize: filledQty,
166
- realizedPnl: 0, // populated by v2 ws-ingest hook
281
+ realizedPnl: paperTrade?.netRealizedPnl ?? 0, // live: filled by ws-ingest / server
167
282
  realizedR: 0,
168
283
  mfeRAtClose: 0,
169
284
  giveBackPctAtClose: 0,
170
285
  exchangeTradeId: typeof order.id === 'string' ? order.id : undefined,
171
286
  };
172
287
  ctx.decisionsClient.postClose(ctx.userId, close);
288
+ // Re-entry indication (issue #204) — record the exit so scan_pairs can flag
289
+ // same-bar re-entries on this (symbol, setup).
290
+ ctx.reentryTracker?.recordExit({
291
+ symbol: inputs.symbol,
292
+ setupType: stateEntry.setupType ?? paperTrade?.setupType,
293
+ side: stateEntry.side,
294
+ wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
295
+ closedAtMs: closeAtMs,
296
+ });
173
297
  // Drop local state — symbol can re-enter as a new position.
174
298
  ctx.stateStore.remove(inputs.symbol);
175
299
  logger.info(TAG, `close captured ${inputs.symbol} reason=${inputs.closeReason} (positionId=${stateEntry.webappPositionId.slice(0, 8)}…)`);
176
300
  }
301
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
302
+ /** How long onAutoFlattenClose polls for the entry journal's webappPositionId
303
+ * before giving up (the entry's WS fill can land a beat after the flatten).
304
+ * 6 × 750ms ≈ 4.5s upper bound; off the trading hot path (event-handler). */
305
+ const AUTO_FLATTEN_LOOKUP_ATTEMPTS = 6;
306
+ const AUTO_FLATTEN_LOOKUP_INTERVAL_MS = 750;
307
+ /** Journal the close produced by an adapter-internal auto-flatten
308
+ * (`bracket_attach_failed`). Those flattens call `adapter.closePosition`
309
+ * directly, bypassing the close_position tool, so `onClosePositionFilled`
310
+ * never runs and the position row orphaned as status='open' (issue #196:
311
+ * LTC 2026-07-14, TAO 2026-07-15 — both showed 1 exchange position vs 2 in the
312
+ * journal). Polls briefly for the entry journal's webappPositionId (the
313
+ * entry's WS fill can arrive just after the flatten), then posts an idempotent
314
+ * close row (UNIQUE on position_id) and drops the state-store entry. Fail-open;
315
+ * the boot DB reconcile sweep is the backstop if the entry never journals. */
316
+ export async function onAutoFlattenClose(ctx, inputs, lookup = {
317
+ attempts: AUTO_FLATTEN_LOOKUP_ATTEMPTS,
318
+ intervalMs: AUTO_FLATTEN_LOOKUP_INTERVAL_MS,
319
+ }) {
320
+ if (!ctx.decisionsClient || !ctx.userId || !ctx.stateStore)
321
+ return;
322
+ if (!(inputs.fillPrice > 0) || !(inputs.fillSize > 0))
323
+ return;
324
+ // The entry may not be journaled yet: the auto-flatten fires the moment the
325
+ // bracket attach exhausts its retries, which can beat the entry's WS-fill
326
+ // capture. Poll a few times for the webappPositionId before giving up.
327
+ let webappPositionId;
328
+ for (let attempt = 0; attempt < lookup.attempts; attempt++) {
329
+ webappPositionId = ctx.stateStore.get(inputs.symbol)?.webappPositionId;
330
+ if (webappPositionId)
331
+ break;
332
+ if (attempt < lookup.attempts - 1)
333
+ await sleep(lookup.intervalMs);
334
+ }
335
+ if (!webappPositionId) {
336
+ logger.warn(TAG, `onAutoFlattenClose ${inputs.symbol}: no webappPositionId after ${lookup.attempts} attempts — ` +
337
+ `DB row may stay status='open' until the boot reconcile sweep (entry likely not journaled)`);
338
+ return;
339
+ }
340
+ const close = {
341
+ positionId: webappPositionId,
342
+ closeAt: inputs.observedAtMs ?? Date.now(),
343
+ closeReason: 'bracket_attach_failed',
344
+ closeAssessment: {
345
+ note: 'Position auto-flattened because exchange-native bracket protection could not be ' +
346
+ 'attached (bracket_attach_failed). Journaled from the flatten fill — no close_position ' +
347
+ 'call (auto-flatten bypass, issue #196).',
348
+ observedFrom: 'auto_flatten',
349
+ },
350
+ scorecardVerdict: 'NO_GO',
351
+ confluenceScore: 0,
352
+ regime: 'unknown',
353
+ regimeConfidence: 0.5,
354
+ fillPrice: inputs.fillPrice,
355
+ fillSize: Math.abs(inputs.fillSize),
356
+ // Real close (we have the flatten fill) — not synthetic. Zeros are filled
357
+ // in per-metric by the webapp close route (price-based R from the pinned
358
+ // invalidation_price); a scratch resolves to ≈0R, honestly.
359
+ realizedPnl: 0,
360
+ realizedR: 0,
361
+ mfeRAtClose: 0,
362
+ giveBackPctAtClose: 0,
363
+ ...(inputs.exchangeTradeId ? { exchangeTradeId: inputs.exchangeTradeId } : {}),
364
+ };
365
+ ctx.decisionsClient.postClose(ctx.userId, close);
366
+ const flattenState = ctx.stateStore.get(inputs.symbol);
367
+ ctx.reentryTracker?.recordExit({
368
+ symbol: inputs.symbol,
369
+ setupType: flattenState?.setupType,
370
+ side: flattenState?.side ?? 'long',
371
+ closedAtMs: inputs.observedAtMs ?? Date.now(),
372
+ });
373
+ ctx.stateStore.remove(inputs.symbol);
374
+ logger.info(TAG, `auto-flatten close captured ${inputs.symbol} @ ${inputs.fillPrice} ` +
375
+ `(positionId=${webappPositionId.slice(0, 8)}…)`);
376
+ }
377
+ /** Journal a stop-watcher auto-close (issue #199).
378
+ *
379
+ * The watcher calls `adapter.closePosition(symbol, 'stop_watcher')` directly —
380
+ * it never goes through the close_position tool, and paper has no WS fill
381
+ * stream, so before this hook every watcher close left the journal position
382
+ * (and the state-store mapping) alive: the next trade on the symbol was then
383
+ * silently dropped or stitched onto the stale position, fabricating P&L
384
+ * (measured 14/69 corrupted closes on the 2026-07 HL soak; worst single
385
+ * fabrication −$117.86 vs a real −$10.63).
386
+ *
387
+ * Contract: the engine exposure is DEFINITIVELY gone when this runs (the
388
+ * watcher's close resolved), so the state-store entry is always dropped —
389
+ * even when we can't post a close row (the periodic DB sweep then heals the
390
+ * orphaned DB row instead of a later close being mis-stitched). Fail-open. */
391
+ export async function onStopWatcherClose(ctx, inputs) {
392
+ if (!ctx.stateStore)
393
+ return;
394
+ const stateEntry = ctx.stateStore.get(inputs.symbol);
395
+ const paperTrade = extractPaperTrade(inputs.order);
396
+ const closeAtMs = Date.now();
397
+ // Record the exit for re-entry indication regardless of journal linkage —
398
+ // the engine trade happened even if the journal never knew the position.
399
+ ctx.reentryTracker?.recordExit({
400
+ symbol: inputs.symbol,
401
+ setupType: stateEntry?.setupType ?? paperTrade?.setupType,
402
+ side: stateEntry?.side ?? 'long',
403
+ wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
404
+ closedAtMs: closeAtMs,
405
+ });
406
+ const dropState = () => { ctx.stateStore?.remove(inputs.symbol); };
407
+ if (!ctx.decisionsClient || !ctx.userId) {
408
+ dropState();
409
+ return;
410
+ }
411
+ if (!stateEntry?.webappPositionId) {
412
+ logger.warn(TAG, `onStopWatcherClose ${inputs.symbol}: no cached webappPositionId — dropping state; ` +
413
+ `periodic DB sweep will close any orphaned journal row`);
414
+ dropState();
415
+ return;
416
+ }
417
+ const order = inputs.order;
418
+ const fillPrice = (order ? fillPriceFromOrder(order) : null)
419
+ ?? (Number.isFinite(inputs.markPrice) && inputs.markPrice > 0 ? inputs.markPrice : null);
420
+ const filledQty = typeof order?.filled === 'number' && Number.isFinite(order.filled) && order.filled > 0
421
+ ? order.filled
422
+ : (typeof stateEntry.remainingContracts === 'number' && stateEntry.remainingContracts > 0
423
+ ? stateEntry.remainingContracts
424
+ : null);
425
+ if (fillPrice === null || filledQty === null) {
426
+ logger.warn(TAG, `onStopWatcherClose ${inputs.symbol}: fill data unresolvable — dropping state; ` +
427
+ `periodic DB sweep will close the journal row`);
428
+ dropState();
429
+ return;
430
+ }
431
+ const close = {
432
+ positionId: stateEntry.webappPositionId,
433
+ closeAt: closeAtMs,
434
+ closeReason: 'stop_watcher',
435
+ closeAssessment: {
436
+ note: 'Position auto-closed by the stop-watcher: mark crossed the pinned stopPrice. ' +
437
+ 'Journaled from the watcher close fill — no close_position call (issue #199).',
438
+ observedFrom: 'stop_watcher',
439
+ stop_price: inputs.stopPrice,
440
+ mark_price: inputs.markPrice,
441
+ ...(paperTrade
442
+ ? {
443
+ pnl_source: 'paper_engine_net',
444
+ pnl_gross: paperTrade.grossRealizedPnl,
445
+ fees: { open: paperTrade.openFee, close: paperTrade.closeFee },
446
+ }
447
+ : {}),
448
+ },
449
+ scorecardVerdict: 'NO_GO',
450
+ confluenceScore: 0,
451
+ regime: 'unknown',
452
+ regimeConfidence: 0.5,
453
+ fillPrice,
454
+ fillSize: Math.abs(filledQty),
455
+ realizedPnl: paperTrade?.netRealizedPnl ?? 0,
456
+ realizedR: 0,
457
+ mfeRAtClose: 0,
458
+ giveBackPctAtClose: 0,
459
+ ...(typeof order?.id === 'string' && order.id.length > 0 ? { exchangeTradeId: order.id } : {}),
460
+ };
461
+ ctx.decisionsClient.postClose(ctx.userId, close);
462
+ dropState();
463
+ logger.info(TAG, `stop-watcher close captured ${inputs.symbol} @ ${fillPrice} ` +
464
+ `(positionId=${stateEntry.webappPositionId.slice(0, 8)}…, ` +
465
+ `pnl=${paperTrade ? paperTrade.netRealizedPnl.toFixed(4) : 'server-side'})`);
466
+ }
177
467
  /** Look up the metadata create_order stashed for this fill. Primary key is
178
468
  * the exchange orderId (post-REST-ack `promote()`); the clientOrderId
179
469
  * fallback covers the routine market-order race where the WS fill arrives
@@ -246,6 +536,7 @@ export async function onWsFillObserved(ctx, fill) {
246
536
  openedAt: ts,
247
537
  side: fillSide,
248
538
  openedFromExchangeTradeId: fill.exchangeOrderId,
539
+ setupType: pending?.metadata?.setupType,
249
540
  });
250
541
  const upsert = {
251
542
  symbol: fill.symbol,
@@ -347,6 +638,13 @@ async function handleReduceOnlyExit(ctx, fill) {
347
638
  exchangeTradeId: fill.exchangeTradeId,
348
639
  };
349
640
  ctx.decisionsClient.postClose(ctx.userId, close);
641
+ ctx.reentryTracker?.recordExit({
642
+ symbol: fill.symbol,
643
+ setupType: stateEntry.setupType,
644
+ side: stateEntry.side,
645
+ wasLoss: realizedPnl < 0,
646
+ closedAtMs: fill.exchangeTimeMs ?? Date.now(),
647
+ });
350
648
  ctx.stateStore.remove(fill.symbol);
351
649
  logger.info(TAG, `close captured (ws bracket fill) ${fill.symbol} pnl=${realizedPnl.toFixed(4)} ` +
352
650
  `(positionId=${stateEntry.webappPositionId.slice(0, 8)}…)`);
@@ -290,8 +290,13 @@ export declare class PositionDecisionsClient {
290
290
  /** DB-vs-exchange reconcile read: this tenant's open positions. Awaited.
291
291
  * Returns null on terminal/retry-exhausted failure — the caller MUST treat
292
292
  * null as UNKNOWN (never as "no open rows"), else a failed fetch would
293
- * false-close the whole book. */
294
- getOpenPositions(userId: string): Promise<OpenPositionsResponse | null>;
293
+ * false-close the whole book. Pass `mode` to scope the read to the ACTIVE
294
+ * book (paper strict / live incl. legacy NULL rows) and `exchange` to scope
295
+ * it to the ACTIVE venue (hyperliquid strict / binance incl. legacy NULL
296
+ * rows) — the sweep diffs against one adapter's snapshot, so an unscoped
297
+ * read misreads the inactive book's/venue's rows as orphans (issue #209
298
+ * item 5: the same corruption class on the venue axis). */
299
+ getOpenPositions(userId: string, mode?: 'paper' | 'live', exchange?: 'binance' | 'hyperliquid'): Promise<OpenPositionsResponse | null>;
295
300
  /** Read endpoint for the Phase 1 self-reflection feature. Awaited.
296
301
  * Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
297
302
  getRecentReviews(userId: string, positionIds: string[], reviewLimit?: number): Promise<RecentReviewsResponse | null>;
@@ -53,9 +53,19 @@ export class PositionDecisionsClient {
53
53
  /** DB-vs-exchange reconcile read: this tenant's open positions. Awaited.
54
54
  * Returns null on terminal/retry-exhausted failure — the caller MUST treat
55
55
  * null as UNKNOWN (never as "no open rows"), else a failed fetch would
56
- * false-close the whole book. */
57
- async getOpenPositions(userId) {
58
- return this.runGetReturning(userId, '/api/internal/positions?status=open');
56
+ * false-close the whole book. Pass `mode` to scope the read to the ACTIVE
57
+ * book (paper strict / live incl. legacy NULL rows) and `exchange` to scope
58
+ * it to the ACTIVE venue (hyperliquid strict / binance incl. legacy NULL
59
+ * rows) — the sweep diffs against one adapter's snapshot, so an unscoped
60
+ * read misreads the inactive book's/venue's rows as orphans (issue #209
61
+ * item 5: the same corruption class on the venue axis). */
62
+ async getOpenPositions(userId, mode, exchange) {
63
+ const qs = new URLSearchParams({ status: 'open' });
64
+ if (mode)
65
+ qs.set('mode', mode);
66
+ if (exchange)
67
+ qs.set('exchange', exchange);
68
+ return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`);
59
69
  }
60
70
  /** Read endpoint for the Phase 1 self-reflection feature. Awaited.
61
71
  * Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
@@ -2,6 +2,16 @@ import type { PositionDecisionsClient } from './position-decisions-client.js';
2
2
  export interface DbVsExchangeContext {
3
3
  decisionsClient?: PositionDecisionsClient;
4
4
  userId?: string;
5
+ /** Active trading book. Scopes the DB read so open rows from the INACTIVE
6
+ * book — which can never appear in this adapter's snapshot — are not
7
+ * misread as orphans and synthetically closed (cross-book corruption after
8
+ * a set_trading_mode switch with positions open). Absent → unscoped
9
+ * (legacy callers). */
10
+ resolveMode?: () => 'paper' | 'live';
11
+ /** Active venue (issue #209 item 5) — the same orphan-misread class on the
12
+ * exchange axis: a tenant running two venues has open rows the OTHER
13
+ * venue's snapshot can never contain. Absent → unscoped (legacy). */
14
+ resolveExchange?: () => 'binance' | 'hyperliquid';
5
15
  }
6
16
  /**
7
17
  * Close webapp `positions` rows that are status='open' but absent from the
@@ -10,4 +20,32 @@ export interface DbVsExchangeContext {
10
20
  * @param exchangeSymbols symbols from a TRUSTED snapshot (getPositionsOrNull()
11
21
  * !== null). Pass only when the fetch genuinely succeeded.
12
22
  */
13
- export declare function reconcileDbOpenVsExchange(ctx: DbVsExchangeContext, exchangeSymbols: Iterable<string>, nowMs?: number): Promise<number>;
23
+ export interface DbReconcileOptions {
24
+ /** Provenance tag written into closeAssessment.source (default boot sweep). */
25
+ source?: string;
26
+ /** Attribution hook (issue #203): given an orphaned symbol, return a short
27
+ * human-readable description of what the execution engine last knew about
28
+ * it (e.g. the simulator's last trade). Logged with the synthetic close so
29
+ * chronic capture misses can be classified instead of perpetually healed. */
30
+ describeLastExit?: (symbol: string) => string | undefined;
31
+ }
32
+ export declare function reconcileDbOpenVsExchange(ctx: DbVsExchangeContext, exchangeSymbols: Iterable<string>, nowMs?: number, opts?: DbReconcileOptions): Promise<number>;
33
+ export declare const DEFAULT_DB_RECONCILE_INTERVAL_MS = 300000;
34
+ export interface PeriodicDbReconcileDeps extends DbVsExchangeContext {
35
+ /** Resolve the ACTIVE adapter each tick (follows runtime reconnects). */
36
+ resolveAdapter: () => {
37
+ getPositionsOrNull(symbol?: string): Promise<Array<{
38
+ symbol: string;
39
+ }> | null>;
40
+ } | undefined;
41
+ describeLastExit?: (symbol: string) => string | undefined;
42
+ }
43
+ export interface PeriodicDbReconcileHandle {
44
+ stop(): void;
45
+ /** One sweep cycle — exposed for tests (no setInterval). */
46
+ runOnce(): Promise<number>;
47
+ }
48
+ /** Resolve the sweep interval from RC_DB_RECONCILE_INTERVAL_MS.
49
+ * 'off' or '0' disables (returns null); values are clamped to ≥60s. */
50
+ export declare function resolveDbReconcileIntervalMs(raw?: string | undefined): number | null;
51
+ export declare function startPeriodicDbReconcile(deps: PeriodicDbReconcileDeps, intervalMs?: number | null): PeriodicDbReconcileHandle | null;