@reefclaw/openclaw-plugin 0.1.23 → 0.1.25

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 (95) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.d.ts +3 -1
  3. package/bridge/connector.js +51 -4
  4. package/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/bridge/gateway/poller.d.ts +5 -0
  6. package/bridge/gateway/poller.js +9 -0
  7. package/bridge/index.js +21 -0
  8. package/bridge/provider.d.ts +15 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +26 -1
  14. package/bridge/providers/gateway.js +159 -8
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/shock-wake.d.ts +80 -0
  17. package/bridge/shock-wake.js +291 -0
  18. package/bridge/types.d.ts +5 -1
  19. package/bridge/types.js +5 -0
  20. package/bridge/utils/instance-id.d.ts +3 -0
  21. package/bridge/utils/instance-id.js +48 -0
  22. package/ccxt/binance-private.js +2 -1
  23. package/ccxt/binance-public.js +6 -1
  24. package/config/agent-config-client.d.ts +7 -2
  25. package/config/agent-config-client.js +17 -0
  26. package/config/agent-config-poller.js +5 -1
  27. package/config/brackets-config.d.ts +2 -1
  28. package/config/brackets-config.js +25 -3
  29. package/config/gate-store.d.ts +12 -0
  30. package/config/gate-store.js +26 -2
  31. package/config/loss-streak-config.d.ts +2 -0
  32. package/config/loss-streak-config.js +33 -0
  33. package/config/plugin-config-io.d.ts +19 -0
  34. package/config/plugin-config-io.js +24 -2
  35. package/config/reentry-cooldown-config.d.ts +7 -0
  36. package/config/reentry-cooldown-config.js +59 -0
  37. package/http/keepalive-fetch.d.ts +5 -0
  38. package/http/keepalive-fetch.js +50 -0
  39. package/index.js +77 -8
  40. package/ingest/position-auto-capture.js +49 -4
  41. package/ingest/position-decisions-client.d.ts +6 -0
  42. package/ingest/position-decisions-client.js +27 -9
  43. package/ingest/readiness-reporter.d.ts +23 -2
  44. package/ingest/readiness-reporter.js +56 -1
  45. package/live/approval-lifecycle.d.ts +10 -0
  46. package/live/approval-lifecycle.js +16 -2
  47. package/live/microstructure-assembler.js +11 -2
  48. package/live/proposal-decision-listener.d.ts +21 -0
  49. package/live/proposal-decision-listener.js +39 -0
  50. package/live/proposal-manager.d.ts +12 -0
  51. package/live/proposal-manager.js +47 -0
  52. package/live/stop-watcher.d.ts +16 -1
  53. package/live/stop-watcher.js +48 -8
  54. package/onboarding/runtime.js +4 -0
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +38 -38
  57. package/persistence/state-manager.d.ts +7 -0
  58. package/persistence/state-manager.js +28 -1
  59. package/portfolio/directional-scoreboard.d.ts +17 -0
  60. package/portfolio/directional-scoreboard.js +71 -0
  61. package/portfolio/reentry-tracker.d.ts +38 -1
  62. package/portfolio/reentry-tracker.js +49 -0
  63. package/signals/change-of-character.d.ts +38 -0
  64. package/signals/change-of-character.js +93 -0
  65. package/simulator/exchange-simulator.d.ts +27 -1
  66. package/simulator/exchange-simulator.js +98 -38
  67. package/simulator/types.d.ts +11 -0
  68. package/skills/reefclaw/SKILL.md +2 -2
  69. package/strategy/evaluator.d.ts +4 -0
  70. package/tools/audit-bracket-protection.js +11 -7
  71. package/tools/close-position.js +10 -1
  72. package/tools/create-order.js +121 -9
  73. package/tools/get-funding-context.js +6 -1
  74. package/tools/get-liquidation-levels.js +5 -1
  75. package/tools/get-liquidation-pulse.js +7 -1
  76. package/tools/get-market-intel.js +2 -1
  77. package/tools/get-relevant-learnings.js +20 -1
  78. package/tools/get-resting-liquidity.js +6 -1
  79. package/tools/get-wave9-status.js +17 -0
  80. package/tools/hl-provision-agent-wallet.js +29 -11
  81. package/tools/intel-api.d.ts +9 -0
  82. package/tools/intel-api.js +32 -1
  83. package/tools/record-position-reviews.js +2 -2
  84. package/tools/reentry-cooldown.d.ts +33 -0
  85. package/tools/reentry-cooldown.js +74 -0
  86. package/tools/scan-pairs.d.ts +7 -0
  87. package/tools/scan-pairs.js +67 -11
  88. package/tools/set-exchange-credentials.js +19 -0
  89. package/tools/set-trading-mode.d.ts +6 -0
  90. package/tools/set-trading-mode.js +48 -1
  91. package/types.d.ts +7 -0
  92. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  93. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  94. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  95. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -12,6 +12,7 @@
12
12
  // by design: the check lives once, at registration, for all operator tools.
13
13
  import { readPluginConfig } from '../config/plugin-config-io.js';
14
14
  import { validateModeTransition, modeRequiresCredentials } from '../onboarding/mode-ladder.js';
15
+ import { checkHlAgentApproval, } from '../venues/hyperliquid/hl-agent-wallet.js';
15
16
  import { parseVenue } from '../venues/registry.js';
16
17
  import { logger } from '../logger.js';
17
18
  import { recordModeTransition } from '../audit/mode-transition-audit.js';
@@ -133,6 +134,48 @@ export async function setTradingModeTool(args, deps) {
133
134
  reason: 'acknowledgment_required',
134
135
  };
135
136
  }
137
+ // 4.5. HL agent-approval preflight (E2E audit 2026-08-11 #7). An agent key
138
+ // that is not in the master account's extraAgents list — or whose approval
139
+ // expired (they last ≤180 days) — signs orders Hyperliquid rejects one by
140
+ // one: the flip would "succeed", the dashboard would look healthy, and
141
+ // every order would bounce. Refuse ONLY on a definitive negative from the
142
+ // exchange; derivation/network failures fail OPEN with a warning (infra
143
+ // must never block a deliberate operator action — same rule as the
144
+ // credential-entry guards).
145
+ let preflightWarnings = [];
146
+ if (venue === 'hyperliquid' && hlCredentials) {
147
+ let verdict;
148
+ try {
149
+ verdict = await (deps.hlApprovalCheck ?? checkHlAgentApproval)(hlCredentials);
150
+ }
151
+ catch (err) {
152
+ verdict = {
153
+ approved: null,
154
+ validUntil: null,
155
+ warnings: [
156
+ `Approval preflight errored (${err instanceof Error ? err.message : String(err)}) — approval not verified.`,
157
+ ],
158
+ };
159
+ }
160
+ if (verdict.approved === false) {
161
+ recordModeTransition({ previousMode, targetMode: target, acknowledged, ok: false, reason: 'hl_agent_not_approved' });
162
+ const why = verdict.warnings.length > 0 ? ` ${verdict.warnings.join(' ')}` : '';
163
+ return {
164
+ ok: false,
165
+ message: `Hyperliquid reports this agent wallet is NOT approved to trade for ${hlCredentials.walletAddress}.` +
166
+ `${why} Every live order would be rejected. Approve it first: Dashboard → Settings → ` +
167
+ `Exchange connection → Hyperliquid (the guided setup signs one approveAgent transaction), ` +
168
+ `then retry ${target}.`,
169
+ previousMode,
170
+ mode: previousMode,
171
+ readiness: deps.runtime.adapter.readiness,
172
+ reason: 'hl_agent_not_approved',
173
+ };
174
+ }
175
+ preflightWarnings = verdict.warnings;
176
+ for (const w of preflightWarnings)
177
+ logger.warn(TAG, `HL preflight: ${w}`);
178
+ }
136
179
  // 5. Persist the new mode.
137
180
  try {
138
181
  const { updatePluginConfig } = await import('../config/plugin-config-io.js');
@@ -162,7 +205,11 @@ export async function setTradingModeTool(args, deps) {
162
205
  });
163
206
  return {
164
207
  ok: true,
165
- message: `Trading mode changed from ${previousMode} to ${target}`,
208
+ // Preflight warnings (near-expiry approval, zero balance, unverifiable
209
+ // approval) ride the success message — the operator just took a real-money
210
+ // action and these are the things to fix before the first order.
211
+ message: `Trading mode changed from ${previousMode} to ${target}` +
212
+ (preflightWarnings.length > 0 ? `. Note: ${preflightWarnings.join(' ')}` : ''),
166
213
  previousMode,
167
214
  mode: deps.runtime.mode,
168
215
  readiness: deps.runtime.adapter.readiness,
package/types.d.ts CHANGED
@@ -63,6 +63,13 @@ export interface CcxtPosition {
63
63
  contractSize: number;
64
64
  entryPrice: number;
65
65
  markPrice: number;
66
+ /** Paper only: true when NO quote was available for this symbol, so
67
+ * `markPrice` is a fabricated fallback (entryPrice) rather than a real mark.
68
+ * Consumers that make protective decisions MUST NOT read `markPrice` as
69
+ * truth when this is set — a fabricated "flat since entry" mark reads as a
70
+ * healthy position and is how a breached stop stayed invisible for 35h.
71
+ * Undefined on live (real marks come from the exchange). */
72
+ markPriceStale?: boolean;
66
73
  notional: number;
67
74
  unrealizedPnl: number;
68
75
  percentage: number;
@@ -27,3 +27,29 @@ export declare function __resetKeccakCacheForTests(): void;
27
27
  export declare function deriveAddressFromPrivateKey(privateKey: string): Promise<DeriveAddressResult>;
28
28
  /** Case-insensitive address equality (addresses may arrive checksummed). */
29
29
  export declare function sameAddress(a: string, b: string): boolean;
30
+ /** One-call approval verdict for the STORED credentials — derivation + the
31
+ * unsigned preflight, packaged for the live-flip gate in set_trading_mode
32
+ * (E2E audit 2026-08-11 #7: an unapproved/expired agent key reached LIVE
33
+ * with a healthy-looking dashboard and every order rejected).
34
+ *
35
+ * Lives HERE, not in hl-preflight.ts, because it takes the PRIVATE KEY —
36
+ * hl-preflight's module boundary is addresses-only by design.
37
+ *
38
+ * Verdict semantics (the caller refuses ONLY on `approved === false`):
39
+ * false = the exchange answered definitively — the agent address is not in
40
+ * extraAgents, or its approval has expired. Real money would be
41
+ * un-tradeable; block the flip.
42
+ * null = could not verify (derivation unavailable, HL unreachable,
43
+ * extraAgents unreadable). Fail OPEN — infra must never block a
44
+ * deliberate operator action; the warning travels instead.
45
+ * true = approved (warnings may still carry a near-expiry nudge). */
46
+ export interface HlAgentApprovalVerdict {
47
+ approved: boolean | null;
48
+ validUntil: number | null;
49
+ warnings: string[];
50
+ }
51
+ export declare function checkHlAgentApproval(creds: {
52
+ walletAddress: string;
53
+ agentPrivateKey: string;
54
+ testnet?: boolean;
55
+ }, fetchImpl?: typeof fetch): Promise<HlAgentApprovalVerdict>;
@@ -116,3 +116,35 @@ export async function deriveAddressFromPrivateKey(privateKey) {
116
116
  export function sameAddress(a, b) {
117
117
  return a.trim().toLowerCase() === b.trim().toLowerCase();
118
118
  }
119
+ export async function checkHlAgentApproval(creds, fetchImpl) {
120
+ const derived = await deriveAddressFromPrivateKey(creds.agentPrivateKey);
121
+ if (!derived.ok) {
122
+ return {
123
+ approved: null,
124
+ validUntil: null,
125
+ warnings: [
126
+ derived.reason === 'invalid_key'
127
+ ? 'The stored agent key looks malformed — approval could not be verified.'
128
+ : 'Could not derive the agent wallet address on this host — approval not verified.',
129
+ ],
130
+ };
131
+ }
132
+ const { hlPreflight } = await import('./hl-preflight.js');
133
+ const pre = await hlPreflight({
134
+ walletAddress: creds.walletAddress,
135
+ agentAddress: derived.address,
136
+ testnet: creds.testnet === true,
137
+ fetchImpl,
138
+ });
139
+ if (!pre.reachable) {
140
+ return {
141
+ approved: null,
142
+ validUntil: null,
143
+ warnings: [
144
+ `Could not reach Hyperliquid to verify the agent approval (${pre.unreachableError ?? 'unknown error'}).`,
145
+ ...pre.warnings,
146
+ ],
147
+ };
148
+ }
149
+ return { approved: pre.agentApproved, validUntil: pre.agentValidUntil, warnings: pre.warnings };
150
+ }
@@ -6,6 +6,7 @@ import { type HlCredentials } from './hl-private.js';
6
6
  import type { BracketId } from '../../live/bracket-types.js';
7
7
  import { BracketLedger } from '../../live/bracket-ledger.js';
8
8
  import { HlBracketCoordinator } from './hl-bracket-coordinator.js';
9
+ import { type AutoCaptureContext } from '../../ingest/position-auto-capture.js';
9
10
  import type { TradeIngestWiring } from '../../live/live-adapter.js';
10
11
  export interface HlLiveAdapterOptions {
11
12
  credentials: HlCredentials;
@@ -23,6 +24,16 @@ export interface HlLiveAdapterOptions {
23
24
  * via userFillsByTime. Same object boot passes the Binance adapter —
24
25
  * `exchange` MUST be fillExchangeId('hyperliquid'). */
25
26
  tradeIngest?: TradeIngestWiring;
27
+ /** Position-decision journal wiring. When present, a close-direction user-
28
+ * stream fill (dir "Close …" or a liquidation fill) is routed through
29
+ * `onWsFillObserved` so an exchange-native bracket SL/TP fill journals an
30
+ * EXACT close (reason `bracket_fill`) instead of leaking as status='open'
31
+ * until the reconciler heals it as `reconciler_observed_flat` — the HL
32
+ * analog of the wiring Binance's ws-ingest has carried since PR #205.
33
+ * Entry/scale-in fills are deliberately NOT routed (the sync create_order
34
+ * path captures them; the WS dedup key is unverified on HL — see
35
+ * onUserFill). */
36
+ autoCapture?: AutoCaptureContext;
26
37
  /** Test seams. Production omits both. */
27
38
  bracketLedger?: BracketLedger;
28
39
  disableUserStream?: boolean;
@@ -54,6 +65,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
54
65
  /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
55
66
  * low-water mark the reconnect gap backfill widens from. */
56
67
  private lastFillIngestMs;
68
+ /** oid → cloid backfill for fills that omit `cloid` (the journal close path
69
+ * recognizes bracket legs by client id). Populated from `orderUpdates`,
70
+ * which always carries both. Bounded, insertion-order eviction. */
71
+ private readonly oidToCloid;
57
72
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
58
73
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
59
74
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -187,10 +202,20 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
187
202
  * `startPosition` is the position BEFORE this fill — the WS-authoritative
188
203
  * way to know the after-fill total without an extra REST read. */
189
204
  private onUserFill;
205
+ /** Close-direction fill → position-decision journal (close-bypass fix, HL
206
+ * arm). Fire-and-forget: a journal POST blip must never touch the WS hot
207
+ * path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
208
+ * flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
209
+ * dirs ("Long > Short") are NOT closes of a tracked side we understand —
210
+ * they stay with the reconciler backstop. */
211
+ private captureCloseFill;
190
212
  /** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
191
- * analog). A trigger = the exchange closed the position — surface the same
213
+ * analog). A trigger = the exchange closed the position — surface the
192
214
  * `drift_detected` close shape the Binance reconciler emits so the journal
193
- * close-bypass cleanup fires at once, not ≤5 min late. */
215
+ * close-bypass cleanup fires fast but AFTER a short grace, so the close
216
+ * FILL (the exact-close journal path, `captureCloseFill`) wins the
217
+ * undocumented orderUpdates/userFills frame ordering. The cleanup is
218
+ * idempotent: state already dropped by the fill path ⇒ no-op. */
194
219
  private onUserOrderUpdate;
195
220
  /** T-5 REST truth-check — serialized so a slow pass can't stack. */
196
221
  private runTruthCheck;
@@ -38,7 +38,8 @@ import { generateBracketId } from '../../live/bracket-id.js';
38
38
  import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
39
39
  import { HlBracketCoordinator, isRejectedOrder, isTerminalBracketState, } from './hl-bracket-coordinator.js';
40
40
  import { HyperliquidUserStream } from './hl-user-stream.js';
41
- import { hlFillToFillEvent } from './hl-fill-ingest.js';
41
+ import { hlFillToFillEvent, hlCoinToCanonical } from './hl-fill-ingest.js';
42
+ import { onWsFillObserved } from '../../ingest/position-auto-capture.js';
42
43
  import { formatError } from '../../logger.js';
43
44
  const TAG = 'hl-live-adapter';
44
45
  /** Venue-distinct ledger storage — a venue switch on the same box must never
@@ -55,6 +56,21 @@ const DEFAULT_MARKET_SLIPPAGE = 0.005;
55
56
  /** Sticky cooldown after a failed open-orders fetch (the Binance lesson: the
56
57
  * SKILL.md audit→attach loop re-calls every ~3s and would pin the budget). */
57
58
  const OPEN_ORDERS_COOLDOWN_MS = 45_000;
59
+ /** Grace before a bracket-trigger `drift_detected` emit. A trigger's close FILL
60
+ * arrives on `userFills` and journals the exact close (real price, real PnL,
61
+ * reason `bracket_fill`); the drift path's cleanup can only post a generic
62
+ * `reconciler_observed_flat`. HL's WS frame ordering between `orderUpdates`
63
+ * and `userFills` is undocumented, so without this grace the generic close
64
+ * routinely won the race and 50% of HL live closes carried no close reason
65
+ * (wisekid, 30d to 2026-08-17: 150/299). The cleanup is idempotent — when the
66
+ * fill already journaled + dropped state, the delayed drift is a no-op; when
67
+ * the fill never arrives (T-5 gap), the drift still heals, 10s late instead
68
+ * of instant (previously ≤5 min via the periodic sweep). */
69
+ const TRIGGER_DRIFT_GRACE_MS = 10_000;
70
+ /** Bound on the oid→cloid map (fills MAY omit `cloid` — facts ledger §3.4 —
71
+ * while `orderUpdates` always carries both, so the map backfills the fill's
72
+ * client id for bracket recognition). Insertion-ordered eviction. */
73
+ const OID_CLOID_MAP_MAX = 512;
58
74
  export class HyperliquidLiveAdapter extends EventEmitter {
59
75
  opts;
60
76
  api;
@@ -82,6 +98,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
82
98
  /** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
83
99
  * low-water mark the reconnect gap backfill widens from. */
84
100
  lastFillIngestMs = 0;
101
+ /** oid → cloid backfill for fills that omit `cloid` (the journal close path
102
+ * recognizes bracket legs by client id). Populated from `orderUpdates`,
103
+ * which always carries both. Bounded, insertion-order eviction. */
104
+ oidToCloid = new Map();
85
105
  /** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
86
106
  * income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
87
107
  * each balance fetch. Without this the skill self-computes a bogus anchor and
@@ -821,6 +841,19 @@ export class HyperliquidLiveAdapter extends EventEmitter {
821
841
  // healed it, in a repeating flap. Exchange truth is the resync's job.
822
842
  if (meta?.isSnapshot)
823
843
  return;
844
+ // Journal close capture: a close-direction fill (bracket SL/TP trigger,
845
+ // liquidation, external reduce) bypasses close_position, and before this
846
+ // wiring the journal only learned about it from the reconciler — 50% of
847
+ // wisekid's 30d closes were `reconciler_observed_flat` heals with the
848
+ // close reason lost. Route it through the SAME generic path Binance's
849
+ // ws-ingest uses; `handleReduceOnlyExit` posts the exact close when the
850
+ // position goes flat and defers to close_position/backstop otherwise.
851
+ // ONLY close-direction fills are routed: entry/scale-in fills stay with
852
+ // the synchronous create_order capture, because the WS dedup key
853
+ // (`openedFromExchangeTradeId === exchangeOrderId`) is unverified against
854
+ // ccxt's HL order.id shape and a dedup miss would re-mint the 38-duplicate-
855
+ // pairs class (issue #199 twin bug).
856
+ this.captureCloseFill(fill);
824
857
  try {
825
858
  // Matches the primary entry cid OR any additional cid recorded for a
826
859
  // scale-in / second resting entry (F5) — matching on `entryCid` alone
@@ -848,27 +881,82 @@ export class HyperliquidLiveAdapter extends EventEmitter {
848
881
  logger.error(TAG, `onUserFill handler error: ${formatError(err)}`);
849
882
  }
850
883
  }
884
+ /** Close-direction fill → position-decision journal (close-bypass fix, HL
885
+ * arm). Fire-and-forget: a journal POST blip must never touch the WS hot
886
+ * path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
887
+ * flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
888
+ * dirs ("Long > Short") are NOT closes of a tracked side we understand —
889
+ * they stay with the reconciler backstop. */
890
+ captureCloseFill(fill) {
891
+ const capture = this.opts.autoCapture;
892
+ if (!capture)
893
+ return;
894
+ const dir = (fill.dir ?? '').toLowerCase();
895
+ const isClose = dir.startsWith('close') || fill.liquidation !== undefined;
896
+ if (!isClose)
897
+ return;
898
+ const price = Number(fill.px);
899
+ const size = Number(fill.sz);
900
+ if (!Number.isFinite(price) || price <= 0 || !Number.isFinite(size) || size <= 0)
901
+ return;
902
+ const realizedPnl = Number(fill.closedPnl);
903
+ const cloid = typeof fill.cloid === 'string' && fill.cloid.length > 0
904
+ ? fill.cloid
905
+ : this.oidToCloid.get(fill.oid);
906
+ onWsFillObserved(capture, {
907
+ symbol: hlCoinToCanonical(fill.coin),
908
+ side: fill.side === 'B' ? 'buy' : 'sell',
909
+ exchangeOrderId: String(fill.oid),
910
+ exchangeTradeId: String(fill.tid),
911
+ fillPrice: price,
912
+ fillSize: size,
913
+ reduceOnly: true,
914
+ realizedPnl: Number.isFinite(realizedPnl) ? realizedPnl : undefined,
915
+ clientOrderId: cloid,
916
+ exchangeTimeMs: Number.isFinite(fill.time) ? fill.time : undefined,
917
+ }).catch((err) => {
918
+ logger.warn(TAG, `journal close capture failed for ${fill.coin} oid=${fill.oid}: ${msg(err)}`);
919
+ });
920
+ }
851
921
  /** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
852
- * analog). A trigger = the exchange closed the position — surface the same
922
+ * analog). A trigger = the exchange closed the position — surface the
853
923
  * `drift_detected` close shape the Binance reconciler emits so the journal
854
- * close-bypass cleanup fires at once, not ≤5 min late. */
924
+ * close-bypass cleanup fires fast but AFTER a short grace, so the close
925
+ * FILL (the exact-close journal path, `captureCloseFill`) wins the
926
+ * undocumented orderUpdates/userFills frame ordering. The cleanup is
927
+ * idempotent: state already dropped by the fill path ⇒ no-op. */
855
928
  onUserOrderUpdate(update) {
856
929
  try {
930
+ // oid→cloid backfill for fills that omit their client id (see
931
+ // captureCloseFill). orderUpdates always carries both.
932
+ const { oid, cloid } = update.order;
933
+ if (typeof oid === 'number' && typeof cloid === 'string' && cloid.length > 0) {
934
+ this.oidToCloid.set(oid, cloid);
935
+ if (this.oidToCloid.size > OID_CLOID_MAP_MAX) {
936
+ const oldest = this.oidToCloid.keys().next().value;
937
+ if (oldest !== undefined)
938
+ this.oidToCloid.delete(oldest);
939
+ }
940
+ }
857
941
  const transition = this.getHlBracketCoordinator().handleOrderUpdate(update);
858
942
  if (transition === 'triggered_sl' || transition === 'triggered_tp' || transition === 'forced_close') {
859
943
  const row = this.getHlBracketCoordinator().getLedger().getAll().find((r) => update.order.cloid && (r.slCid === update.order.cloid || r.tpCid === update.order.cloid));
860
944
  const symbol = row?.symbol;
861
945
  if (symbol) {
862
- this.emit('drift_detected', {
863
- timestamp: new Date().toISOString(),
864
- drifts: [
865
- {
866
- type: 'closed',
867
- symbol,
868
- localContracts: row?.qty ?? 0,
869
- },
870
- ],
871
- });
946
+ const qty = row?.qty ?? 0;
947
+ const timer = setTimeout(() => {
948
+ this.emit('drift_detected', {
949
+ timestamp: new Date().toISOString(),
950
+ drifts: [
951
+ {
952
+ type: 'closed',
953
+ symbol,
954
+ localContracts: qty,
955
+ },
956
+ ],
957
+ });
958
+ }, TRIGGER_DRIFT_GRACE_MS);
959
+ timer.unref?.();
872
960
  }
873
961
  }
874
962
  }