@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
@@ -43,17 +43,10 @@ const TAG = 'reconcile-db-vs-exchange';
43
43
  function canonical(symbol) {
44
44
  return symbol.split(':')[0];
45
45
  }
46
- /**
47
- * Close webapp `positions` rows that are status='open' but absent from the
48
- * (trusted) exchange snapshot. Returns the number of synthetic closes posted.
49
- *
50
- * @param exchangeSymbols symbols from a TRUSTED snapshot (getPositionsOrNull()
51
- * !== null). Pass only when the fetch genuinely succeeded.
52
- */
53
- export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Date.now()) {
46
+ export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Date.now(), opts = {}) {
54
47
  if (!ctx.decisionsClient || !ctx.userId)
55
48
  return 0;
56
- const resp = await ctx.decisionsClient.getOpenPositions(ctx.userId);
49
+ const resp = await ctx.decisionsClient.getOpenPositions(ctx.userId, ctx.resolveMode?.(), ctx.resolveExchange?.());
57
50
  if (!resp) {
58
51
  // null = fetch failed (network / terminal). NEVER treat as "no open rows"
59
52
  // (null≠empty) — skip; the next boot / periodic sweep retries.
@@ -77,12 +70,17 @@ export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Da
77
70
  logger.warn(TAG, `${o.symbol}: bad size/price from DB (size=${o.remainingSize}, entry=${o.entryPrice}) — skipping close`);
78
71
  continue;
79
72
  }
73
+ const lastExitInfo = opts.describeLastExit?.(o.symbol);
74
+ if (lastExitInfo) {
75
+ logger.warn(TAG, `${o.symbol}: capture-miss attribution — ${lastExitInfo}`);
76
+ }
80
77
  const close = {
81
78
  positionId: o.id,
82
79
  closeAt: nowMs,
83
80
  closeReason: 'reconciler_observed_flat',
84
81
  closeAssessment: {
85
- source: 'db_exchange_sweep_boot',
82
+ source: opts.source ?? 'db_exchange_sweep_boot',
83
+ ...(lastExitInfo ? { last_engine_trade: lastExitInfo } : {}),
86
84
  synthetic: true,
87
85
  note: 'Position open in DB but absent from the exchange snapshot. It was ' +
88
86
  'closed on-exchange via a path that bypassed close_position (bracket ' +
@@ -112,3 +110,61 @@ export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Da
112
110
  }
113
111
  return posted;
114
112
  }
113
+ // ---- Periodic sweep (issues #199/#203) ----
114
+ //
115
+ // The boot-only sweep left phantoms alive for DAYS between restarts (paper:
116
+ // the LTC phantom of issue #199 lived 26.5h; live: 38 of 85 closes in 14 days
117
+ // were harvested only at the daily restart, issue #203). This interval runs
118
+ // the same DB-authoritative pass continuously with the same trusted-snapshot
119
+ // discipline: getPositionsOrNull() null → skip, never treat as flat.
120
+ export const DEFAULT_DB_RECONCILE_INTERVAL_MS = 300_000;
121
+ const MIN_DB_RECONCILE_INTERVAL_MS = 60_000;
122
+ /** Resolve the sweep interval from RC_DB_RECONCILE_INTERVAL_MS.
123
+ * 'off' or '0' disables (returns null); values are clamped to ≥60s. */
124
+ export function resolveDbReconcileIntervalMs(raw = process.env.RC_DB_RECONCILE_INTERVAL_MS) {
125
+ if (raw === 'off' || raw === '0')
126
+ return null;
127
+ const n = Number(raw);
128
+ if (!Number.isFinite(n) || n <= 0)
129
+ return DEFAULT_DB_RECONCILE_INTERVAL_MS;
130
+ return Math.max(MIN_DB_RECONCILE_INTERVAL_MS, n);
131
+ }
132
+ export function startPeriodicDbReconcile(deps, intervalMs = resolveDbReconcileIntervalMs()) {
133
+ if (intervalMs === null) {
134
+ logger.info(TAG, 'periodic sweep disabled (RC_DB_RECONCILE_INTERVAL_MS=off)');
135
+ return null;
136
+ }
137
+ let running = false;
138
+ const runOnce = async () => {
139
+ if (running)
140
+ return 0; // concurrency guard — a slow sweep never overlaps
141
+ running = true;
142
+ try {
143
+ const adapter = deps.resolveAdapter();
144
+ if (!adapter)
145
+ return 0;
146
+ const positions = await adapter.getPositionsOrNull();
147
+ if (positions === null) {
148
+ // Untrusted snapshot — NEVER read as flat (null≠empty).
149
+ logger.warn(TAG, 'periodic sweep skipped — positions fetch untrusted (null)');
150
+ return 0;
151
+ }
152
+ return await reconcileDbOpenVsExchange(deps, positions.map((p) => p.symbol), Date.now(), { source: 'db_exchange_sweep_periodic', describeLastExit: deps.describeLastExit });
153
+ }
154
+ catch (err) {
155
+ logger.warn(TAG, `periodic sweep failed: ${err instanceof Error ? err.message : String(err)}`);
156
+ return 0;
157
+ }
158
+ finally {
159
+ running = false;
160
+ }
161
+ };
162
+ const timer = setInterval(() => { void runOnce(); }, intervalMs);
163
+ // Never keep the process alive just for the sweep.
164
+ timer.unref?.();
165
+ logger.info(TAG, `periodic DB-vs-exchange sweep started (interval ${intervalMs}ms)`);
166
+ return {
167
+ stop: () => clearInterval(timer),
168
+ runOnce,
169
+ };
170
+ }
@@ -0,0 +1,13 @@
1
+ /** Minimal order shape needed to resolve a fill price. */
2
+ export interface FillPriceOrder {
3
+ average: number | null;
4
+ filled: number;
5
+ cost: number;
6
+ /** Present only to make explicit that we deliberately DO NOT read it. */
7
+ price?: number | null;
8
+ }
9
+ /**
10
+ * The average price a filled order actually executed at, or null if it can't
11
+ * be determined from the response. Never returns the limit price.
12
+ */
13
+ export declare function fillPriceFromOrder(order: FillPriceOrder): number | null;
@@ -0,0 +1,37 @@
1
+ // Resolve the ACTUAL average fill price of a (partially) filled order.
2
+ //
3
+ // Issue #196 (prod LTC/USDT 2026-07-14, TAO/USDT 2026-07-15): for marketable
4
+ // LIMIT orders, Binance's synchronous USD-M order response
5
+ // (newOrderRespType=RESULT) can carry avgPrice=0 even though executedQty>0 —
6
+ // per the Binance doc, RESULT only guarantees the final fill for MARKET orders
7
+ // and LIMIT orders with a special timeInForce, NOT a plain GTC LIMIT. Code that
8
+ // then used `order.price` (the LIMIT price) as the entry proxy validated bracket
9
+ // geometry / journaled the entry against a price the order never filled at —
10
+ // several percent off for a marketable limit that crossed. That produced:
11
+ // - false "bracket direction invalid" auto-flattens (LTC: SELL limit 43 filled
12
+ // 44.49, target 43.21 compared vs 43 → "must be BELOW 43" → needless scratch), and
13
+ // - wrong journal entry prices (TAO: BUY limit 202 filled ~196.23 → journaled 202).
14
+ //
15
+ // The LIMIT price is NEVER a valid substitute for the fill. This resolver
16
+ // returns the true average fill from the response alone (no network), else null;
17
+ // callers must re-query or fall back to a mark price, never to `order.price`.
18
+ /**
19
+ * The average price a filled order actually executed at, or null if it can't
20
+ * be determined from the response. Never returns the limit price.
21
+ */
22
+ export function fillPriceFromOrder(order) {
23
+ // 1. The exchange-reported average fill price, when present and positive.
24
+ if (typeof order.average === 'number' && Number.isFinite(order.average) && order.average > 0) {
25
+ return order.average;
26
+ }
27
+ // 2. VWAP from cost/filled. For USD-M futures ccxt maps cost = cumQuote =
28
+ // Σ(price×qty), so cost/filled = Σ(price×qty)/Σqty = the exact avgPrice.
29
+ // Covers the case where avgPrice=0 in the response but cumQuote settled.
30
+ if (typeof order.cost === 'number' && Number.isFinite(order.cost) && order.cost > 0 &&
31
+ typeof order.filled === 'number' && Number.isFinite(order.filled) && order.filled > 0) {
32
+ return order.cost / order.filled;
33
+ }
34
+ // 3. Unknown. The caller MUST NOT fall back to order.price (the limit) — that
35
+ // is exactly the marketable-limit bug this helper exists to prevent.
36
+ return null;
37
+ }
@@ -17,7 +17,7 @@ import { BracketManager } from './bracket-manager.js';
17
17
  import { type BracketMode } from '../config/brackets-config.js';
18
18
  import { type UserDataStreamMode, type UserDataStreamTunables } from '../config/user-data-stream-config.js';
19
19
  import { UserDataStreamController } from './user-data-stream-controller.js';
20
- import type { AutoCaptureContext } from '../ingest/position-auto-capture.js';
20
+ import { type AutoCaptureContext } from '../ingest/position-auto-capture.js';
21
21
  import type { TradeStoreClient } from '../ingest/trade-store-client.js';
22
22
  /** Optional audit-trail wiring (TRADE_AUDIT_TRAIL_PLAN Phase 1). Caller
23
23
  * passes this only when userDataStream.dbWrite='on' AND the WEBAPP_INGEST_TOKEN
@@ -79,6 +79,7 @@ export declare class LiveAdapter extends EventEmitter implements IExchangeAdapte
79
79
  private lastIncomeRefreshMs;
80
80
  private incomeAnchorUtcDay;
81
81
  private liveMetadata;
82
+ private autoCapture?;
82
83
  private _readiness;
83
84
  get readiness(): AdapterReadiness;
84
85
  /** Session-start NAV, captured once at initialization. Used for drawdown calculation. */
@@ -143,6 +144,27 @@ export declare class LiveAdapter extends EventEmitter implements IExchangeAdapte
143
144
  * the subsequent flatten.
144
145
  */
145
146
  private attachBracketsAsync;
147
+ /**
148
+ * Patch `order.average` with the authoritative average fill price when a
149
+ * filled order came back without one (marketable-limit RESULT quirk, #196).
150
+ * Re-queries by clientOrderId (GET /fapi/v1/order, weight 1). Best-effort:
151
+ * on any failure or an unresolvable re-query the order is left as-is —
152
+ * downstream then falls back to a mark price / skips capture, NEVER to the
153
+ * limit price. Mutates `order` in place.
154
+ */
155
+ private enrichFilledAvgPrice;
156
+ /**
157
+ * Journal the close produced by an internal auto-flatten
158
+ * (`bracket_attach_failed`), so the position row can't orphan as
159
+ * status='open' (issue #196). These flattens go straight to
160
+ * `this.closePosition`, bypassing the close_position tool, so
161
+ * `onClosePositionFilled` never runs for them. Fire-and-forget + fail-open —
162
+ * never blocks or throws into the flatten path. The entry journal may not be
163
+ * ready at flatten time (the entry's WS fill can land ~1-2s later), so
164
+ * `onAutoFlattenClose` polls briefly for the webappPositionId; the boot DB
165
+ * reconcile sweep is the backstop if the entry never journals.
166
+ */
167
+ private captureAutoFlattenClose;
146
168
  /** Install the Wave 9-only autonomous recovery bridge. With no handler,
147
169
  * every generic bracket lifecycle remains byte-for-byte behaviorally
148
170
  * unchanged. Runtime reapplies this setter after adapter reconnects. */
@@ -259,6 +281,16 @@ export declare class LiveAdapter extends EventEmitter implements IExchangeAdapte
259
281
  * drift shape (re-emitted upstream as `bracket_drift`) plus a loud log so
260
282
  * the operator knows re-protection is required. */
261
283
  private reattachBracketsAfterFailedClose;
284
+ /** Null-honest balance read: null = the fetch FAILED (429 / weight-paced /
285
+ * banned) — the caller MUST treat it as UNKNOWN, never as a zero balance.
286
+ * Same null≠empty contract as getPositionsOrNull. Decision paths (sizing,
287
+ * pre-trade risk) must use this: the legacy getBalance() collapse below
288
+ * reads as walletTotal=0 → ~−100% drawdown → RED zone. */
289
+ getBalanceOrNull(): Promise<CcxtBalance | null>;
290
+ /** Legacy display-path read. Collapses a FAILED fetch to an empty balance
291
+ * object — acceptable for read-only surfaces (fetch_balance tool,
292
+ * risk-summary display), a phantom-zero hazard for anything that decides.
293
+ * Decision paths use getBalanceOrNull(). */
262
294
  getBalance(): Promise<CcxtBalance>;
263
295
  /** Re-anchor the realized-today seed + sessionStartNav from /fapi/v1/income.
264
296
  * Throttled to {@link INCOME_REFRESH_MIN_INTERVAL_MS}; UTC date rollover
@@ -22,9 +22,11 @@ import { BracketReconciler } from './bracket-reconciler.js';
22
22
  import { LiveBracketApi } from './live-bracket-api.js';
23
23
  import { generateBracketId, buildBracketCid, parseBracketCid } from './bracket-id.js';
24
24
  import { validateStopDirection, validateTargetDirection } from './bracket-params.js';
25
+ import { fillPriceFromOrder } from './fill-price.js';
25
26
  import { bracketsEnabled } from '../config/brackets-config.js';
26
27
  import { userDataStreamEnabled, userDataStreamAuthoritative, DEFAULT_TUNABLES as USER_DATA_STREAM_DEFAULT_TUNABLES, } from '../config/user-data-stream-config.js';
27
28
  import { UserDataStreamController } from './user-data-stream-controller.js';
29
+ import { onAutoFlattenClose } from '../ingest/position-auto-capture.js';
28
30
  import { assertNotShuttingDown, registerOp } from '../lifecycle/shutdown-coordinator.js';
29
31
  import { updateMfe } from '../mfe.js';
30
32
  import { computeInvalidationHit } from '../pinned-plan.js';
@@ -171,6 +173,11 @@ export class LiveAdapter extends EventEmitter {
171
173
  // getPositions tick, dropped on closePosition or when a symbol vanishes from
172
174
  // the open-positions snapshot.
173
175
  liveMetadata = new Map();
176
+ // Position-decision auto-capture wiring (optional). Retained so the internal
177
+ // bracket_attach_failed auto-flatten can journal its close (issue #196): that
178
+ // flatten bypasses the close_position tool, so onClosePositionFilled never
179
+ // runs and the row would orphan as status='open'.
180
+ autoCapture;
174
181
  // Readiness state — mutable, starts INIT_PENDING
175
182
  _readiness = 'INIT_PENDING';
176
183
  get readiness() {
@@ -194,6 +201,11 @@ export class LiveAdapter extends EventEmitter {
194
201
  this.mode = mode;
195
202
  this.bracketMode = bracketMode;
196
203
  this.userDataStreamMode = userDataStreamMode;
204
+ // Retained for the auto-flatten journal close capture (issue #196): the
205
+ // adapter-internal `closePosition(…, 'bracket_attach_failed')` calls bypass
206
+ // the close_position tool, so onClosePositionFilled never runs for them —
207
+ // the row orphaned as status='open' until this capture existed.
208
+ this.autoCapture = autoCapture;
197
209
  this.api = new BinancePrivateApi(config);
198
210
  if (mode === 'MICRO_LIVE') {
199
211
  // sizeCapMultiplier is 1.0 — the agent already sizes for the real wallet.
@@ -742,6 +754,19 @@ export class LiveAdapter extends EventEmitter {
742
754
  }
743
755
  // ---- Post-submission bookkeeping ----
744
756
  this.intentJournal.updateIntent(clientOrderId, { status: 'confirmed', exchangeOrderId: order.id });
757
+ // ---- Fill-price enrichment (issue #196) ----
758
+ // For a marketable LIMIT order Binance's synchronous RESULT response can
759
+ // come back avgPrice=0 even though it filled (RESULT only guarantees the
760
+ // fill for MARKET / special-timeInForce LIMIT orders). Every downstream
761
+ // consumer — bracket direction validation, slippage, journal entry capture
762
+ // — MUST key off the real fill, never the limit price. Re-query by
763
+ // clientOrderId (weight 1) to patch order.average BEFORE any of them read
764
+ // it. Market orders report avgPrice reliably, so this fires only on the
765
+ // filled-limit edge; best-effort — a failed re-query leaves order.average
766
+ // as-is (consumers then use a mark price / skip, never the limit).
767
+ if (order.status === 'closed' && order.filled > 0 && fillPriceFromOrder(order) === null) {
768
+ await this.enrichFilledAvgPrice(order, clientOrderId, symbol);
769
+ }
745
770
  // Track slippage for market orders that filled immediately
746
771
  if (order.status === 'closed' && order.average != null && expectedPrice > 0) {
747
772
  this.slippageTracker.recordFill({
@@ -781,60 +806,81 @@ export class LiveAdapter extends EventEmitter {
781
806
  // leg, register the bracket and (for market orders) attach immediately.
782
807
  // Limit orders defer attach to the poller's 'filled' event handler.
783
808
  if (this.bracketManager && this.bracketLedger && metadata && (metadata.stopPrice !== undefined || metadata.targetPrice !== undefined)) {
784
- // Defense-in-depth: the pre-submission check above already rejected sign
785
- // errors against the ref price, but re-check against the actual fill
786
- // (order.average) in case the fill landed across the level. Crucially, a
787
- // failure HERE is post-submission: bare-throwing would abandon a live
788
- // position (filled market order) or a resting entry that fills naked
789
- // (open limit). So clean up BEFORE throwing.
790
- const refPrice = order.average ?? order.price ?? price ?? 0;
791
- if (refPrice > 0) {
792
- let dirMsg = null;
809
+ // Defense-in-depth direction re-check. The pre-submission check above
810
+ // already rejected sign errors against the ref price; this re-checks once
811
+ // the order's outcome is known. Reference price by order state:
812
+ // - FILLED (status=closed, filled>0): the ACTUAL average fill — NEVER
813
+ // the limit price (issue #196). A marketable limit can fill percent-
814
+ // scale away from its limit, so comparing the bracket to the limit
815
+ // gives a FALSE invalid (needless auto-flatten LTC 2026-07-14) or
816
+ // lets a genuinely wrong stop through. If the fill price is
817
+ // unresolvable, SKIP the check — the bracket attach (Binance -2021 on
818
+ // a wrong-side stop) is the arbiter; validating against the limit
819
+ // would resurrect the bug.
820
+ // - still-OPEN resting limit: no fill yet, so the limit price IS the
821
+ // correct reference (the order fills at/through its limit).
822
+ // A failure HERE is post-submission — clean up (flatten the filled
823
+ // position / cancel the resting order) BEFORE throwing.
824
+ let dirRef = null;
825
+ if (order.status === 'closed' && order.filled > 0) {
826
+ dirRef = fillPriceFromOrder(order);
827
+ if (dirRef === null) {
828
+ logger.warn(TAG, `Post-fill bracket direction check skipped for ${symbol}: actual fill price unresolvable (avg=${order.average ?? 'null'} cost=${order.cost} filled=${order.filled}); NOT validating against the limit price — the bracket attach (Binance -2021) is the arbiter`);
829
+ }
830
+ }
831
+ else if (order.status === 'open') {
832
+ const lim = order.price ?? price ?? 0;
833
+ dirRef = lim > 0 ? lim : null;
834
+ }
835
+ let dirMsg = null;
836
+ if (dirRef !== null && dirRef > 0) {
793
837
  if (metadata.stopPrice !== undefined) {
794
- dirMsg = validateStopDirection(side, refPrice, metadata.stopPrice);
838
+ dirMsg = validateStopDirection(side, dirRef, metadata.stopPrice);
795
839
  }
796
840
  if (!dirMsg && metadata.targetPrice !== undefined) {
797
- dirMsg = validateTargetDirection(side, refPrice, metadata.targetPrice);
841
+ dirMsg = validateTargetDirection(side, dirRef, metadata.targetPrice);
798
842
  }
799
- if (dirMsg) {
800
- if (order.status === 'closed' && order.filled > 0) {
801
- logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}) flattening the just-filled position to avoid a naked entry`);
802
- // We have HARD evidence the entry filled (status=closed, filled>0).
803
- // closePosition throws "No open position" when its attempt-1
804
- // fetchPositions returns a *confirmed-empty* snapshot which, in
805
- // the moments right after a fill, is almost always Binance
806
- // position-state lagging the order response, NOT a real flat.
807
- // Accepting that single failure would fail open and leave the
808
- // position naked (the exact high-risk branch this cleanup exists
809
- // for). Retry across the lag window instead of trusting one snapshot.
810
- let flattened = false;
811
- for (let a = 1; a <= 4 && !flattened; a++) {
812
- try {
813
- await this.closePosition(symbol, 'bracket_attach_failed');
814
- flattened = true;
815
- }
816
- catch (err) {
817
- logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${formatError(err)}`);
818
- if (a < 4)
819
- await sleep(1500);
820
- }
821
- }
822
- if (!flattened) {
823
- logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, immediate operator intervention required`);
824
- this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: `Naked ${symbol} after invalid bracket — manual close required` });
825
- }
826
- }
827
- else if (order.status === 'open') {
828
- logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the resting entry order so it can't fill unprotected`);
843
+ }
844
+ if (dirMsg) {
845
+ if (order.status === 'closed' && order.filled > 0) {
846
+ logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}, fill=${dirRef}) — flattening the just-filled position to avoid a naked entry`);
847
+ // We have HARD evidence the entry filled (status=closed, filled>0).
848
+ // closePosition throws "No open position" when its attempt-1
849
+ // fetchPositions returns a *confirmed-empty* snapshot which, in
850
+ // the moments right after a fill, is almost always Binance
851
+ // position-state lagging the order response, NOT a real flat.
852
+ // Accepting that single failure would fail open and leave the
853
+ // position naked (the exact high-risk branch this cleanup exists
854
+ // for). Retry across the lag window instead of trusting one snapshot.
855
+ // closePosition journals the auto-flatten close itself (keyed on the
856
+ // 'bracket_attach_failed' reason) so the row can't orphan (#196).
857
+ let flattened = false;
858
+ for (let a = 1; a <= 4 && !flattened; a++) {
829
859
  try {
830
- await this.api.cancelOrderByClientId(clientOrderId, symbol);
860
+ await this.closePosition(symbol, 'bracket_attach_failed');
861
+ flattened = true;
831
862
  }
832
863
  catch (err) {
833
- logger.error(TAG, `Cancel of resting entry after invalid bracket failed for ${symbol}: ${formatError(err)}`);
864
+ logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${formatError(err)}`);
865
+ if (a < 4)
866
+ await sleep(1500);
834
867
  }
835
868
  }
836
- throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
869
+ if (!flattened) {
870
+ logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, immediate operator intervention required`);
871
+ this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: `Naked ${symbol} after invalid bracket — manual close required` });
872
+ }
837
873
  }
874
+ else if (order.status === 'open') {
875
+ logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the resting entry order so it can't fill unprotected`);
876
+ try {
877
+ await this.api.cancelOrderByClientId(clientOrderId, symbol);
878
+ }
879
+ catch (err) {
880
+ logger.error(TAG, `Cancel of resting entry after invalid bracket failed for ${symbol}: ${formatError(err)}`);
881
+ }
882
+ }
883
+ throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
838
884
  }
839
885
  const bracketId = generateBracketId();
840
886
  this.bracketManager.registerEntry({ symbol, side, stopPrice: metadata.stopPrice, targetPrice: metadata.targetPrice }, bracketId, clientOrderId);
@@ -866,7 +912,8 @@ export class LiveAdapter extends EventEmitter {
866
912
  const result = await this.bracketManager.attachBrackets(symbol, entryAmount);
867
913
  if (!result.ok) {
868
914
  logger.error(TAG, `Bracket attach FAILED for ${symbol} after ${result.attempts} attempts: ${result.error}. Auto-flattening position.`);
869
- // Best-effort emergency close. closePosition has its own retry logic.
915
+ // Best-effort emergency close. closePosition has its own retry logic +
916
+ // journals the auto-flatten close itself (keyed on the reason, #196).
870
917
  try {
871
918
  await this.closePosition(symbol, 'bracket_attach_failed');
872
919
  }
@@ -880,6 +927,64 @@ export class LiveAdapter extends EventEmitter {
880
927
  logger.error(TAG, `attachBracketsAsync unexpected error for ${symbol}: ${formatError(err)}`);
881
928
  }
882
929
  }
930
+ /**
931
+ * Patch `order.average` with the authoritative average fill price when a
932
+ * filled order came back without one (marketable-limit RESULT quirk, #196).
933
+ * Re-queries by clientOrderId (GET /fapi/v1/order, weight 1). Best-effort:
934
+ * on any failure or an unresolvable re-query the order is left as-is —
935
+ * downstream then falls back to a mark price / skips capture, NEVER to the
936
+ * limit price. Mutates `order` in place.
937
+ */
938
+ async enrichFilledAvgPrice(order, clientOrderId, symbol) {
939
+ const rawAvg = order.average;
940
+ try {
941
+ const requeried = await this.api.fetchOrderByClientId(clientOrderId, symbol);
942
+ const resolved = requeried ? fillPriceFromOrder(requeried) : null;
943
+ if (resolved !== null && resolved > 0) {
944
+ order.average = resolved;
945
+ // Carry cumQuote across too, so cost-based consumers (VWAP, slippage)
946
+ // see the settled notional rather than 0.
947
+ if ((order.cost === 0 || order.cost == null) && requeried && requeried.cost > 0) {
948
+ order.cost = requeried.cost;
949
+ }
950
+ logger.info(TAG, `Fill-price enriched for ${symbol}: avg ${rawAvg ?? 'null'} → ${resolved} (re-query by cid; limit price ${order.price ?? 'n/a'})`);
951
+ return;
952
+ }
953
+ logger.warn(TAG, `Fill-price enrichment for ${symbol} could not resolve an average (re-query avg=${requeried?.average ?? 'null'} cost=${requeried?.cost ?? 'null'} filled=${requeried?.filled ?? 'null'}); downstream uses mark price / skips capture, NOT the limit price ${order.price ?? 'n/a'}`);
954
+ }
955
+ catch (err) {
956
+ logger.warn(TAG, `Fill-price enrichment re-query for ${symbol} threw: ${formatError(err)}; leaving average as-is`);
957
+ }
958
+ }
959
+ /**
960
+ * Journal the close produced by an internal auto-flatten
961
+ * (`bracket_attach_failed`), so the position row can't orphan as
962
+ * status='open' (issue #196). These flattens go straight to
963
+ * `this.closePosition`, bypassing the close_position tool, so
964
+ * `onClosePositionFilled` never runs for them. Fire-and-forget + fail-open —
965
+ * never blocks or throws into the flatten path. The entry journal may not be
966
+ * ready at flatten time (the entry's WS fill can land ~1-2s later), so
967
+ * `onAutoFlattenClose` polls briefly for the webappPositionId; the boot DB
968
+ * reconcile sweep is the backstop if the entry never journals.
969
+ */
970
+ captureAutoFlattenClose(symbol, closeOrder) {
971
+ if (!this.autoCapture)
972
+ return;
973
+ const fillPrice = fillPriceFromOrder(closeOrder);
974
+ // A synthetic "already flat" return (filled=0, avg=null) means another path
975
+ // (a bracket fill / external close) owns the real close — don't mislabel it
976
+ // as bracket_attach_failed and don't invent a price.
977
+ if (fillPrice === null || !(closeOrder.filled > 0))
978
+ return;
979
+ void onAutoFlattenClose(this.autoCapture, {
980
+ symbol,
981
+ fillPrice,
982
+ fillSize: Math.abs(closeOrder.filled),
983
+ ...(typeof closeOrder.id === 'string' && closeOrder.id.length > 0
984
+ ? { exchangeTradeId: closeOrder.id }
985
+ : {}),
986
+ }).catch((err) => logger.warn(TAG, `auto-flatten close capture for ${symbol} threw: ${formatError(err)}`));
987
+ }
883
988
  /** Install the Wave 9-only autonomous recovery bridge. With no handler,
884
989
  * every generic bracket lifecycle remains byte-for-byte behaviorally
885
990
  * unchanged. Runtime reapplies this setter after adapter reconnects. */
@@ -1379,6 +1484,14 @@ export class LiveAdapter extends EventEmitter {
1379
1484
  const order = await this.api.createOrder(symbol, closeSide, 'market', position.contracts, undefined, ccxtParams);
1380
1485
  logger.info(TAG, `FLATTEN: ${symbol} close order ${order.id} status=${order.status}`);
1381
1486
  this.emit('emergency_progress', { action: 'flatten', status: 'completed', symbol });
1487
+ // Auto-flatten (bracket_attach_failed) bypasses the close_position tool,
1488
+ // so onClosePositionFilled never journals its close and the position row
1489
+ // orphans as status='open' (issue #196). Journal it here from the real
1490
+ // close fill. Other reasons (operator_command, agent close_position) are
1491
+ // journaled by their own paths — don't double-capture.
1492
+ if (closeReason === 'bracket_attach_failed') {
1493
+ this.captureAutoFlattenClose(symbol, order);
1494
+ }
1382
1495
  return order;
1383
1496
  }
1384
1497
  catch (err) {
@@ -1456,12 +1569,17 @@ export class LiveAdapter extends EventEmitter {
1456
1569
  emitUnprotected(`threw: ${formatError(err)}`);
1457
1570
  }
1458
1571
  }
1459
- async getBalance() {
1572
+ /** Null-honest balance read: null = the fetch FAILED (429 / weight-paced /
1573
+ * banned) — the caller MUST treat it as UNKNOWN, never as a zero balance.
1574
+ * Same null≠empty contract as getPositionsOrNull. Decision paths (sizing,
1575
+ * pre-trade risk) must use this: the legacy getBalance() collapse below
1576
+ * reads as walletTotal=0 → ~−100% drawdown → RED zone. */
1577
+ async getBalanceOrNull() {
1460
1578
  const balance = await this.api.fetchBalance();
1461
1579
  this.syncRateLimits();
1462
1580
  this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchBalance ?? 5);
1463
1581
  if (!balance) {
1464
- return { free: {}, used: {}, total: {} };
1582
+ return null;
1465
1583
  }
1466
1584
  // Binance USDⓈ-M quirk: `availableBalance` is only populated for the primary
1467
1585
  // margin asset (USDT). Secondary collaterals like USDC come back with
@@ -1499,6 +1617,17 @@ export class LiveAdapter extends EventEmitter {
1499
1617
  const cachedPositions = this.reconciler.getLastExchangePositions();
1500
1618
  return this.enricher.enrich(balance, cachedPositions);
1501
1619
  }
1620
+ /** Legacy display-path read. Collapses a FAILED fetch to an empty balance
1621
+ * object — acceptable for read-only surfaces (fetch_balance tool,
1622
+ * risk-summary display), a phantom-zero hazard for anything that decides.
1623
+ * Decision paths use getBalanceOrNull(). */
1624
+ async getBalance() {
1625
+ const balance = await this.getBalanceOrNull();
1626
+ if (!balance) {
1627
+ return { free: {}, used: {}, total: {} };
1628
+ }
1629
+ return balance;
1630
+ }
1502
1631
  /** Re-anchor the realized-today seed + sessionStartNav from /fapi/v1/income.
1503
1632
  * Throttled to {@link INCOME_REFRESH_MIN_INTERVAL_MS}; UTC date rollover
1504
1633
  * forces an immediate refresh. Best-effort — failures are logged and
@@ -10,6 +10,10 @@ export interface PositionStateEntry {
10
10
  webappPositionId?: string;
11
11
  /** exchangeTradeId of the opening fill (audit trail). */
12
12
  openedFromExchangeTradeId?: string;
13
+ /** setup_type from the entry metadata (e.g. 'pullback_trend_short_4h').
14
+ * Carried so close paths can record the exit into the re-entry tracker
15
+ * keyed by (symbol, setup) — issue #204. Optional; older entries lack it. */
16
+ setupType?: string;
13
17
  /** Last record_position_reviews call timestamp (epoch ms). Drives stale-gate. */
14
18
  lastReviewAt?: number;
15
19
  lastVerdict?: 'hold' | 'add_on' | 'close_recommended';
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
- import type { CcxtPosition } from '../types.js';
3
+ import type { CcxtOrder, CcxtPosition } from '../types.js';
4
4
  import type { TradingOperationLock } from '../lifecycle/trading-operation-lock.js';
5
5
  export declare const DEFAULT_INTERVAL_MS = 10000;
6
6
  export interface StopTriggeredEvent {
@@ -10,6 +10,13 @@ export interface StopTriggeredEvent {
10
10
  markPrice: number;
11
11
  quantity: number;
12
12
  }
13
+ /** Emitted with 'stop_closed' — carries the executed close order so listeners
14
+ * (the journal auto-capture wiring in index.ts) can record the close with the
15
+ * real fill. Without this, watcher closes bypassed the Position Decision
16
+ * Journal entirely and left phantom-open journal positions (issue #199). */
17
+ export interface StopClosedEvent extends StopTriggeredEvent {
18
+ order?: CcxtOrder;
19
+ }
13
20
  export type Wave9StopCloseOutcome = {
14
21
  status: 'flat';
15
22
  detail: string;
@@ -138,6 +138,9 @@ export class PositionWatcher extends EventEmitter {
138
138
  quantity: position.contracts,
139
139
  };
140
140
  this.emit('stop_triggered', event);
141
+ // Captured close order for the 'stop_closed' event — set inside close()
142
+ // (which may run under the symbol lock) and read after it resolves.
143
+ let closeOrder;
141
144
  const close = async () => {
142
145
  let trustedPosition = position;
143
146
  // Re-read after acquiring the shared trading boundary. A Wave 9
@@ -169,7 +172,7 @@ export class PositionWatcher extends EventEmitter {
169
172
  throw new Error(`Wave 9 ownership resolution failed before stop close submission: ${ownershipError}; ` +
170
173
  'IMMEDIATE MANUAL INTERVENTION REQUIRED');
171
174
  }
172
- await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher');
175
+ closeOrder = await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher');
173
176
  if (!candidateId || !this.wave9CloseLifecycle)
174
177
  return 'closed';
175
178
  const outcome = await this.wave9CloseLifecycle.settleAfterClose(candidateId, trustedPosition.symbol);
@@ -190,7 +193,7 @@ export class PositionWatcher extends EventEmitter {
190
193
  if (result !== 'closed')
191
194
  return;
192
195
  logger.info(TAG, `Auto-closed ${position.symbol} (reason=stop_watcher)`);
193
- this.emit('stop_closed', event);
196
+ this.emit('stop_closed', { ...event, order: closeOrder });
194
197
  }
195
198
  catch (err) {
196
199
  const msg = formatError(err);
@@ -47,6 +47,11 @@ export declare class PluginRuntime {
47
47
  private readonly _marketFeed;
48
48
  private readonly operationLock?;
49
49
  private wave9LiveLifecycleHooks?;
50
+ /** Observer applied to EVERY stop-watcher this runtime creates (reconnects
51
+ * included). index.ts uses it to attach the journal auto-capture listener
52
+ * for watcher closes (issue #199) — without it, a live<->paper reconnect
53
+ * would silently shed the capture wiring. */
54
+ private readonly onWatcherCreated?;
50
55
  /** Reconnect is serialized — a second caller waits for the first to finish
51
56
  * so we never tear down an adapter that's mid-rebuild. */
52
57
  private reconnectInFlight;
@@ -57,6 +62,7 @@ export declare class PluginRuntime {
57
62
  stopWatcher?: PositionWatcher | null;
58
63
  marketFeed?: PaperMarketFeed | null;
59
64
  operationLock?: TradingOperationLock;
65
+ onWatcherCreated?: (watcher: PositionWatcher) => void;
60
66
  });
61
67
  get adapter(): IExchangeAdapter;
62
68
  get mode(): TradingMode;