@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
@@ -0,0 +1,123 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { CcxtOrder, CcxtPosition } from '../../types.js';
3
+ import type { CloseReason } from '../../simulator/types.js';
4
+ import type { BracketId, BracketRequest, BracketState } from '../../live/bracket-types.js';
5
+ import type { BracketLedger } from '../../live/bracket-ledger.js';
6
+ import type { HlOrderUpdateEvent } from './hl-user-stream.js';
7
+ /** Narrow execution surface the coordinator needs — the HyperliquidLiveAdapter
8
+ * implements it; tests mock it without a network. */
9
+ export interface HlBracketExecutor {
10
+ attachBrackets(args: {
11
+ bracketId: BracketId;
12
+ symbol: string;
13
+ positionSide: 'long' | 'short';
14
+ positionSize: number;
15
+ stopPrice?: number;
16
+ targetPrice?: number;
17
+ }): Promise<{
18
+ orders: CcxtOrder[];
19
+ slCid?: string;
20
+ tpCid?: string;
21
+ }>;
22
+ resizeBrackets(args: {
23
+ bracketId: BracketId;
24
+ symbol: string;
25
+ positionSide: 'long' | 'short';
26
+ positionSize: number;
27
+ }): Promise<{
28
+ resized: boolean;
29
+ slCid?: string;
30
+ tpCid?: string;
31
+ }>;
32
+ /** Cancel one leg by cloid. Idempotent — "already gone" must not throw. */
33
+ cancelBracketLeg(cloid: string, symbol: string): Promise<void>;
34
+ /** Coverage audit — THROWS when order state is unknown (null ≠ empty). */
35
+ auditBracketCoverage(symbol: string, positionSize: number): Promise<{
36
+ covered: boolean;
37
+ shortfall: {
38
+ stop: number;
39
+ target: number;
40
+ };
41
+ missing: ('stop' | 'target')[];
42
+ }>;
43
+ getPositionsOrNull(symbol?: string): Promise<CcxtPosition[] | null>;
44
+ closePosition(symbol: string, closeReason?: CloseReason): Promise<CcxtOrder>;
45
+ }
46
+ export interface HlAttachResult {
47
+ ok: boolean;
48
+ latencyMs: number;
49
+ slCid?: string;
50
+ tpCid?: string;
51
+ error?: string;
52
+ attempts: number;
53
+ }
54
+ export interface HlCoordinatorOpts {
55
+ maxAttempts?: number;
56
+ retryBackoffMs?: (attempt: number) => number;
57
+ now?: () => number;
58
+ sleep?: (ms: number) => Promise<void>;
59
+ }
60
+ export declare function isTerminalBracketState(state: BracketState): boolean;
61
+ export declare class HlBracketCoordinator extends EventEmitter {
62
+ private readonly executor;
63
+ private readonly ledger;
64
+ private readonly maxAttempts;
65
+ private readonly backoff;
66
+ private readonly now;
67
+ private readonly sleep;
68
+ /** Per-symbol in-flight guard so a WS fill + the createOrder return path
69
+ * can't both run attachOnFill concurrently (double-submit). */
70
+ private readonly inFlight;
71
+ /** Cloids WE deliberately cancelled (resize/modify supersede, retry cleanup,
72
+ * cancelBrackets). Their WS 'canceled' events are OUR OWN and must never
73
+ * trip the stripped-protection canary — the T-8 testnet run proved the WS
74
+ * event can arrive BEFORE the ledger's fresh-cid update lands, so a
75
+ * generation check alone loses the race. Entries pruned after 5 min. */
76
+ private readonly ownCancels;
77
+ constructor(executor: HlBracketExecutor, ledger: BracketLedger, opts?: HlCoordinatorOpts);
78
+ getLedger(): BracketLedger;
79
+ /** Record a leg cancel WE initiated (see `ownCancels`). The adapter calls
80
+ * this for every deliberate leg cancel, including the resize path's direct
81
+ * api cancels, BEFORE the cancel request goes out. */
82
+ noteOwnLegCancel(cloid: string): void;
83
+ /** Same anti-clobber contract as BracketManager.registerEntry: a duplicate
84
+ * fill signal against a non-terminal row is a warned NO-OP, never a fresh
85
+ * bracketId that orphans the live legs' ledger identity. */
86
+ registerEntry(req: BracketRequest, bracketId: BracketId, entryCid: string): void;
87
+ /** Attach both legs (ONE batched signed action) with retries. Idempotent on
88
+ * a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
89
+ * the ADAPTER escalates to auto-flatten (it owns closePosition). */
90
+ attachOnFill(symbol: string, filledSize: number): Promise<HlAttachResult>;
91
+ /** ★ T-2: bring the legs to the CURRENT position size (scale-in / partial
92
+ * fill growth). Submit-first-then-cancel inside the adapter. Updates the
93
+ * ledger's qty + fresh cids on success. */
94
+ resizeToPosition(symbol: string, newPositionSize: number): Promise<{
95
+ resized: boolean;
96
+ }>;
97
+ /** Move the stop. HL ordering: submit the NEW leg first, then cancel the old
98
+ * one — over-protection for a moment beats a naked window (see header). */
99
+ modifyStop(symbol: string, newStopPrice: number): Promise<void>;
100
+ modifyTarget(symbol: string, newTargetPrice: number): Promise<void>;
101
+ private modifyLeg;
102
+ /** Cancel both legs, mark cancelled. Risk-reducing; best-effort per leg. */
103
+ cancelBrackets(symbol: string, reason: string): Promise<void>;
104
+ /**
105
+ * The HL analog of the Binance ALGO_UPDATE handler — `orderUpdates` is
106
+ * authoritative for bracket-leg lifecycle. Returns the transition applied
107
+ * (for the adapter to emit drift/close-bypass signals on triggers).
108
+ */
109
+ handleOrderUpdate(update: HlOrderUpdateEvent): 'triggered_sl' | 'triggered_tp' | 'forced_close' | null;
110
+ /**
111
+ * ★ T-5 mandate: REST truth-check. Runs after every WS (re)connect AND on the
112
+ * periodic sweep. TRUSTED reads only — a null positions fetch skips the pass
113
+ * (never act on unknown). Self-heals: pending rows whose entry filled while
114
+ * we were blind get their legs attached; undersized legs get resized; flat
115
+ * positions get their rows closed out (T-1 already cancelled the legs).
116
+ * Returns symbols whose rows were closed out (close-bypass signals for the
117
+ * adapter's drift pipeline).
118
+ */
119
+ resyncAgainstExchange(reasonTag: string): Promise<{
120
+ closedSymbols: string[];
121
+ }>;
122
+ private emitEvent;
123
+ }
@@ -0,0 +1,533 @@
1
+ // HlBracketCoordinator — orchestrates the lifecycle of Hyperliquid bracket legs
2
+ // (issue #209: the wiring that Phase 3 shipped the machinery for).
3
+ //
4
+ // A SIBLING of live/bracket-manager.ts, not a reuse of it: BracketManager's leg
5
+ // layer is Binance-shaped (sequential STOP_MARKET/TAKE_PROFIT_MARKET submits,
6
+ // closePosition:true whole-position semantics, `bkt…` cids), while HL brackets
7
+ // are fixed-size reduce-only trigger legs submitted as ONE batched signed action
8
+ // (nonce mutex — see hl-brackets.ts). The ORCHESTRATION is deliberately the
9
+ // same shape: registerEntry → attach-on-fill with retries → auto-flatten
10
+ // escalation on failure, persisted in the SAME venue-agnostic BracketLedger
11
+ // (its own file — `new BracketLedger('<pluginId>-hl')` — so a venue switch can
12
+ // never cross-contaminate rows), emitting the SAME BracketEvent shapes.
13
+ //
14
+ // THE MEASURED FACTS THIS FILE TURNS ON (testnet 2026-07-12, do NOT re-derive):
15
+ // ★ T-1: reduce-only trigger legs auto-cancel on flat and behave OCO — no
16
+ // sibling-canceller needed; a defensive cancel is harmless.
17
+ // ★ T-2: legs are FIXED SIZE and do NOT follow a scale-in — every position
18
+ // size increase MUST resize the legs or the added contracts are NAKED.
19
+ // ★ T-5: the WS replays NOTHING on reconnect — `onResyncNeeded` drives a REST
20
+ // truth-check here (`resyncAgainstExchange`), and a periodic sweep runs the
21
+ // same check even while connected (a dropped frame is invisible).
22
+ //
23
+ // Modify semantics differ from Binance ON PURPOSE: Binance modifyStop cancels
24
+ // the old leg then submits the new one (with rollback). On HL we SUBMIT FIRST,
25
+ // then cancel the stale leg — two live reduce-only stops for a few hundred ms
26
+ // are over-protection (reduce-only cannot flip a position, and T-1 cleans up
27
+ // stragglers on flat), whereas cancel-first opens a naked window. Same ordering
28
+ // rule as planResize.
29
+ import { EventEmitter } from 'node:events';
30
+ import { logger, formatError } from '../../logger.js';
31
+ import { parseHlBracketCloid } from './hl-cloid.js';
32
+ const TAG = 'hl-bracket-coordinator';
33
+ const DEFAULT_MAX_ATTEMPTS = 3;
34
+ const DEFAULT_BACKOFF = (attempt) => (attempt === 1 ? 1_000 : 2_000);
35
+ const TERMINAL_STATES = new Set([
36
+ 'triggered_sl',
37
+ 'triggered_tp',
38
+ 'cancelled',
39
+ 'failed',
40
+ ]);
41
+ export function isTerminalBracketState(state) {
42
+ return TERMINAL_STATES.has(state);
43
+ }
44
+ /** Order-update statuses that mean the leg EXECUTED (position closed by it). */
45
+ const TRIGGERED_STATUSES = new Set(['filled', 'triggered']);
46
+ /** A batch item HL rejected. ccxt types status as open|closed|canceled, but a
47
+ * per-item rejection surfaces as an `error` entry in the raw batch response
48
+ * (`info.error`) — check both shapes rather than trust the narrow type. */
49
+ function isRejectedOrder(o) {
50
+ if (!o)
51
+ return true;
52
+ if (String(o.status ?? '') === 'rejected')
53
+ return true;
54
+ const info = o.info;
55
+ return Boolean(info && info.error);
56
+ }
57
+ /** Expected T-1 cleanup — the sibling of a fired leg self-cancels. */
58
+ const SIBLING_CLEANUP_STATUSES = new Set(['siblingFilledCanceled']);
59
+ /** The position was force-closed out from under the bracket. */
60
+ const FORCED_CLOSE_STATUSES = new Set(['liquidatedCanceled', 'marginCanceled', 'delistedCanceled']);
61
+ export class HlBracketCoordinator extends EventEmitter {
62
+ executor;
63
+ ledger;
64
+ maxAttempts;
65
+ backoff;
66
+ now;
67
+ sleep;
68
+ /** Per-symbol in-flight guard so a WS fill + the createOrder return path
69
+ * can't both run attachOnFill concurrently (double-submit). */
70
+ inFlight = new Set();
71
+ /** Cloids WE deliberately cancelled (resize/modify supersede, retry cleanup,
72
+ * cancelBrackets). Their WS 'canceled' events are OUR OWN and must never
73
+ * trip the stripped-protection canary — the T-8 testnet run proved the WS
74
+ * event can arrive BEFORE the ledger's fresh-cid update lands, so a
75
+ * generation check alone loses the race. Entries pruned after 5 min. */
76
+ ownCancels = new Map();
77
+ constructor(executor, ledger, opts = {}) {
78
+ super();
79
+ this.executor = executor;
80
+ this.ledger = ledger;
81
+ this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
82
+ this.backoff = opts.retryBackoffMs ?? DEFAULT_BACKOFF;
83
+ this.now = opts.now ?? Date.now;
84
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
85
+ }
86
+ getLedger() {
87
+ return this.ledger;
88
+ }
89
+ /** Record a leg cancel WE initiated (see `ownCancels`). The adapter calls
90
+ * this for every deliberate leg cancel, including the resize path's direct
91
+ * api cancels, BEFORE the cancel request goes out. */
92
+ noteOwnLegCancel(cloid) {
93
+ const now = this.now();
94
+ for (const [k, ts] of this.ownCancels) {
95
+ if (now - ts > 300_000)
96
+ this.ownCancels.delete(k);
97
+ }
98
+ this.ownCancels.set(cloid, now);
99
+ }
100
+ /** Same anti-clobber contract as BracketManager.registerEntry: a duplicate
101
+ * fill signal against a non-terminal row is a warned NO-OP, never a fresh
102
+ * bracketId that orphans the live legs' ledger identity. */
103
+ registerEntry(req, bracketId, entryCid) {
104
+ if (req.stopPrice === undefined && req.targetPrice === undefined) {
105
+ throw new Error('registerEntry: at least one of stopPrice or targetPrice is required');
106
+ }
107
+ const existing = this.ledger.getBySymbol(req.symbol);
108
+ if (existing && !isTerminalBracketState(existing.state)) {
109
+ logger.warn(TAG, `registerEntry(${req.symbol}): non-terminal row exists (state=${existing.state}, ` +
110
+ `bracketId=${existing.bracketId}) — duplicate fill signal, NOT clobbering ` +
111
+ `(anti-clobber contract; scale-ins go through resizeToPosition)`);
112
+ return;
113
+ }
114
+ if (existing)
115
+ this.ledger.remove(req.symbol);
116
+ this.ledger.upsert({
117
+ bracketId,
118
+ symbol: req.symbol,
119
+ entrySide: req.side,
120
+ entryCid,
121
+ stopPrice: req.stopPrice,
122
+ targetPrice: req.targetPrice,
123
+ state: 'pending_entry',
124
+ });
125
+ this.emitEvent({
126
+ type: 'bracket.entry_submitted',
127
+ bracketId,
128
+ symbol: req.symbol,
129
+ ts: new Date(this.now()).toISOString(),
130
+ });
131
+ }
132
+ /** Attach both legs (ONE batched signed action) with retries. Idempotent on
133
+ * a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
134
+ * the ADAPTER escalates to auto-flatten (it owns closePosition). */
135
+ async attachOnFill(symbol, filledSize) {
136
+ const entry = this.ledger.getBySymbol(symbol);
137
+ if (!entry)
138
+ throw new Error(`attachOnFill: no ledger entry for ${symbol}`);
139
+ if (entry.state !== 'pending_entry') {
140
+ return {
141
+ ok: entry.state === 'active' || entry.state === 'partial',
142
+ latencyMs: entry.attachLatencyMs ?? 0,
143
+ slCid: entry.slCid,
144
+ tpCid: entry.tpCid,
145
+ attempts: 0,
146
+ };
147
+ }
148
+ if (this.inFlight.has(entry.symbol)) {
149
+ logger.warn(TAG, `attachOnFill(${symbol}): attach already in flight — skipping duplicate`);
150
+ return { ok: false, latencyMs: 0, attempts: 0, error: 'attach_in_flight' };
151
+ }
152
+ this.inFlight.add(entry.symbol);
153
+ try {
154
+ const startTs = this.now();
155
+ this.ledger.markState(symbol, 'attaching', {
156
+ attachStartedAt: new Date(startTs).toISOString(),
157
+ });
158
+ this.emitEvent({
159
+ type: 'bracket.entry_filled',
160
+ bracketId: entry.bracketId,
161
+ symbol: entry.symbol,
162
+ ts: new Date(startTs).toISOString(),
163
+ });
164
+ const positionSide = entry.entrySide === 'buy' ? 'long' : 'short';
165
+ let lastError;
166
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
167
+ let slCid;
168
+ let tpCid;
169
+ try {
170
+ const res = await this.executor.attachBrackets({
171
+ bracketId: entry.bracketId,
172
+ symbol: entry.symbol,
173
+ positionSide,
174
+ positionSize: filledSize,
175
+ stopPrice: entry.stopPrice,
176
+ targetPrice: entry.targetPrice,
177
+ });
178
+ slCid = res.slCid;
179
+ tpCid = res.tpCid;
180
+ const wanted = (entry.stopPrice !== undefined ? 1 : 0) + (entry.targetPrice !== undefined ? 1 : 0);
181
+ const rejected = res.orders.filter((o) => isRejectedOrder(o));
182
+ if (res.orders.length < wanted || rejected.length > 0) {
183
+ throw new Error(`batch returned ${res.orders.length}/${wanted} legs` +
184
+ (rejected.length ? ` (${rejected.length} rejected)` : ''));
185
+ }
186
+ const latencyMs = this.now() - startTs;
187
+ const targetState = slCid && tpCid ? 'active' : 'partial';
188
+ this.ledger.markState(symbol, targetState, {
189
+ attachCompletedAt: new Date(this.now()).toISOString(),
190
+ attachLatencyMs: latencyMs,
191
+ slCid,
192
+ tpCid,
193
+ // Freeze the size the legs were sized to — modify/resize MUST reuse
194
+ // it (the Binance 1-contract under-protect lesson carries over).
195
+ qty: filledSize,
196
+ });
197
+ this.emitEvent({
198
+ type: 'bracket.attached',
199
+ bracketId: entry.bracketId,
200
+ symbol: entry.symbol,
201
+ latencyMs,
202
+ ts: new Date(this.now()).toISOString(),
203
+ });
204
+ return { ok: true, latencyMs, slCid, tpCid, attempts: attempt };
205
+ }
206
+ catch (err) {
207
+ lastError = formatError(err);
208
+ this.emitEvent({
209
+ type: 'bracket.attach_failed',
210
+ bracketId: entry.bracketId,
211
+ symbol: entry.symbol,
212
+ error: lastError,
213
+ retryCount: attempt,
214
+ ts: new Date(this.now()).toISOString(),
215
+ });
216
+ // A failed/partial batch may have left a leg on the book. Cancel
217
+ // best-effort before retrying with FRESH cloids (a cloid is
218
+ // single-use) so the exchange never runs ahead of the ledger.
219
+ // T-1 makes a missed cancel harmless (auto-cleanup on flat).
220
+ for (const cloid of [slCid, tpCid]) {
221
+ if (!cloid)
222
+ continue;
223
+ try {
224
+ await this.executor.cancelBracketLeg(cloid, entry.symbol);
225
+ }
226
+ catch {
227
+ /* idempotent best-effort */
228
+ }
229
+ }
230
+ if (attempt < this.maxAttempts)
231
+ await this.sleep(this.backoff(attempt));
232
+ }
233
+ }
234
+ const latencyMs = this.now() - startTs;
235
+ this.ledger.markState(symbol, 'failed', {
236
+ closeReason: 'attach_failed',
237
+ lastError,
238
+ });
239
+ return { ok: false, latencyMs, attempts: this.maxAttempts, error: lastError };
240
+ }
241
+ finally {
242
+ this.inFlight.delete(entry.symbol);
243
+ }
244
+ }
245
+ /** ★ T-2: bring the legs to the CURRENT position size (scale-in / partial
246
+ * fill growth). Submit-first-then-cancel inside the adapter. Updates the
247
+ * ledger's qty + fresh cids on success. */
248
+ async resizeToPosition(symbol, newPositionSize) {
249
+ const entry = this.ledger.getBySymbol(symbol);
250
+ if (!entry)
251
+ throw new Error(`resizeToPosition: no ledger entry for ${symbol}`);
252
+ if (entry.state !== 'active' && entry.state !== 'partial') {
253
+ throw new Error(`resizeToPosition: cannot resize in state ${entry.state}`);
254
+ }
255
+ if (!Number.isFinite(newPositionSize) || newPositionSize <= 0) {
256
+ throw new Error(`resizeToPosition: invalid size ${newPositionSize}`);
257
+ }
258
+ const positionSide = entry.entrySide === 'buy' ? 'long' : 'short';
259
+ const res = await this.executor.resizeBrackets({
260
+ bracketId: entry.bracketId,
261
+ symbol: entry.symbol,
262
+ positionSide,
263
+ positionSize: newPositionSize,
264
+ });
265
+ if (res.resized) {
266
+ this.ledger.markState(symbol, entry.state, {
267
+ qty: newPositionSize,
268
+ ...(res.slCid ? { slCid: res.slCid } : {}),
269
+ ...(res.tpCid ? { tpCid: res.tpCid } : {}),
270
+ });
271
+ logger.info(TAG, `resized ${entry.symbol} legs to ${newPositionSize} (T-2: HL legs do not auto-resize)`);
272
+ }
273
+ return { resized: res.resized };
274
+ }
275
+ /** Move the stop. HL ordering: submit the NEW leg first, then cancel the old
276
+ * one — over-protection for a moment beats a naked window (see header). */
277
+ async modifyStop(symbol, newStopPrice) {
278
+ await this.modifyLeg(symbol, 'stop', newStopPrice);
279
+ }
280
+ async modifyTarget(symbol, newTargetPrice) {
281
+ await this.modifyLeg(symbol, 'target', newTargetPrice);
282
+ }
283
+ async modifyLeg(symbol, role, newPrice) {
284
+ if (!Number.isFinite(newPrice) || newPrice <= 0) {
285
+ throw new Error(`modify ${role}: price must be positive finite, got ${newPrice}`);
286
+ }
287
+ const entry = this.ledger.getBySymbol(symbol);
288
+ if (!entry)
289
+ throw new Error(`modify ${role}: no ledger entry for ${symbol}`);
290
+ if (entry.state !== 'active' && entry.state !== 'partial') {
291
+ throw new Error(`modify ${role}: cannot modify in state ${entry.state}`);
292
+ }
293
+ const qty = entry.qty;
294
+ if (qty === undefined || !Number.isFinite(qty) || qty <= 0) {
295
+ throw new Error(`modify ${role}: ledger has no valid bracket qty for ${symbol} (qty=${qty}); ` +
296
+ `refusing to rebuild the leg at a placeholder size — re-attach the bracket first`);
297
+ }
298
+ const oldCid = role === 'stop' ? entry.slCid : entry.tpCid;
299
+ const positionSide = entry.entrySide === 'buy' ? 'long' : 'short';
300
+ // SUBMIT the replacement first. On failure the old leg is still live —
301
+ // the position stays protected and we just report the error.
302
+ const res = await this.executor.attachBrackets({
303
+ bracketId: entry.bracketId,
304
+ symbol: entry.symbol,
305
+ positionSide,
306
+ positionSize: qty,
307
+ ...(role === 'stop' ? { stopPrice: newPrice } : { targetPrice: newPrice }),
308
+ });
309
+ const newCid = role === 'stop' ? res.slCid : res.tpCid;
310
+ const submitted = res.orders.filter((o) => !isRejectedOrder(o));
311
+ if (!newCid || submitted.length === 0) {
312
+ throw new Error(`modify ${role}: replacement leg submission failed — old leg left in place (still protected)`);
313
+ }
314
+ // Then cancel the stale leg. A failed cancel = brief over-protection (two
315
+ // reduce-only legs), which T-1 cleans up on flat — warn, don't throw.
316
+ if (oldCid) {
317
+ try {
318
+ await this.executor.cancelBracketLeg(oldCid, entry.symbol);
319
+ }
320
+ catch (err) {
321
+ logger.warn(TAG, `modify ${role}(${symbol}): stale leg ${oldCid} cancel failed (over-protection, ` +
322
+ `not risk — T-1 cleans up on flat): ${formatError(err)}`);
323
+ }
324
+ }
325
+ const oldPrice = role === 'stop' ? entry.stopPrice : entry.targetPrice;
326
+ this.ledger.upsert({
327
+ ...entry,
328
+ ...(role === 'stop'
329
+ ? { stopPrice: newPrice, slCid: newCid }
330
+ : { targetPrice: newPrice, tpCid: newCid }),
331
+ });
332
+ this.emitEvent({
333
+ type: 'bracket.modified',
334
+ bracketId: entry.bracketId,
335
+ symbol: entry.symbol,
336
+ field: role,
337
+ oldValue: oldPrice ?? NaN,
338
+ newValue: newPrice,
339
+ ts: new Date(this.now()).toISOString(),
340
+ });
341
+ }
342
+ /** Cancel both legs, mark cancelled. Risk-reducing; best-effort per leg. */
343
+ async cancelBrackets(symbol, reason) {
344
+ const entry = this.ledger.getBySymbol(symbol);
345
+ if (!entry)
346
+ return;
347
+ for (const cloid of [entry.slCid, entry.tpCid]) {
348
+ if (!cloid)
349
+ continue;
350
+ try {
351
+ await this.executor.cancelBracketLeg(cloid, entry.symbol);
352
+ }
353
+ catch (err) {
354
+ logger.warn(TAG, `cancelBrackets ${entry.symbol} leg ${cloid}: ${formatError(err)}`);
355
+ }
356
+ }
357
+ this.ledger.markState(symbol, 'cancelled', { closeReason: 'cancelled_manual' });
358
+ this.emitEvent({
359
+ type: 'bracket.cancelled',
360
+ bracketId: entry.bracketId,
361
+ symbol: entry.symbol,
362
+ reason,
363
+ ts: new Date(this.now()).toISOString(),
364
+ });
365
+ }
366
+ /**
367
+ * The HL analog of the Binance ALGO_UPDATE handler — `orderUpdates` is
368
+ * authoritative for bracket-leg lifecycle. Returns the transition applied
369
+ * (for the adapter to emit drift/close-bypass signals on triggers).
370
+ */
371
+ handleOrderUpdate(update) {
372
+ const cloid = update.order?.cloid;
373
+ if (!cloid)
374
+ return null;
375
+ const parsed = parseHlBracketCloid(cloid);
376
+ if (!parsed || parsed.role === 'entry')
377
+ return null;
378
+ const status = update.status;
379
+ const coin = update.order.coin;
380
+ const entry = this.ledger.getByBracketId(parsed.bracketId);
381
+ if (!entry)
382
+ return null;
383
+ if (TRIGGERED_STATUSES.has(status)) {
384
+ if (isTerminalBracketState(entry.state))
385
+ return null; // duplicate signal
386
+ const next = parsed.role === 'stop' ? 'triggered_sl' : 'triggered_tp';
387
+ this.ledger.markState(entry.symbol, next, {
388
+ closeReason: parsed.role === 'stop' ? 'triggered_sl' : 'triggered_tp',
389
+ });
390
+ this.emitEvent({
391
+ type: 'bracket.triggered',
392
+ bracketId: entry.bracketId,
393
+ symbol: entry.symbol,
394
+ side: parsed.role === 'stop' ? 'sl' : 'tp',
395
+ ts: new Date(this.now()).toISOString(),
396
+ });
397
+ logger.info(TAG, `${entry.symbol} ${parsed.role} leg fired (coin=${coin}, status=${status})`);
398
+ return next;
399
+ }
400
+ if (SIBLING_CLEANUP_STATUSES.has(status)) {
401
+ // Expected T-1 OCO cleanup after the sibling fired — benign.
402
+ logger.info(TAG, `${entry.symbol} ${parsed.role} leg sibling-cancelled (T-1 cleanup)`);
403
+ return null;
404
+ }
405
+ if (FORCED_CLOSE_STATUSES.has(status)) {
406
+ if (!isTerminalBracketState(entry.state)) {
407
+ this.ledger.markState(entry.symbol, 'cancelled', {
408
+ closeReason: 'cancelled_auto',
409
+ lastError: `leg ${status}`,
410
+ });
411
+ }
412
+ logger.warn(TAG, `${entry.symbol} ${parsed.role} leg ${status} — position force-closed by the exchange`);
413
+ return 'forced_close';
414
+ }
415
+ if (status === 'canceled' && (entry.state === 'active' || entry.state === 'partial')) {
416
+ // CANCELED-while-active without a trigger/sibling reason = something
417
+ // stripped protection. Same WARN canary as the Binance ALGO_UPDATE rule.
418
+ // ONLY for cancels we did NOT initiate: resize/modify supersede legs
419
+ // with fresh cloids and then cancel the stale ones, and the WS 'canceled'
420
+ // event can arrive BEFORE the ledger's fresh-cid update (race observed
421
+ // live on the T-8 testnet run) — so the check is the explicit own-cancel
422
+ // registry, backstopped by the generation check.
423
+ const isCurrentLeg = cloid === entry.slCid || cloid === entry.tpCid;
424
+ if (isCurrentLeg && !this.ownCancels.has(cloid)) {
425
+ logger.warn(TAG, `CANARY: ${entry.symbol} ${parsed.role} leg CANCELED while bracket ${entry.state} — ` +
426
+ `protection may have been stripped out-of-band; coverage audit will re-attach`);
427
+ }
428
+ }
429
+ return null;
430
+ }
431
+ /**
432
+ * ★ T-5 mandate: REST truth-check. Runs after every WS (re)connect AND on the
433
+ * periodic sweep. TRUSTED reads only — a null positions fetch skips the pass
434
+ * (never act on unknown). Self-heals: pending rows whose entry filled while
435
+ * we were blind get their legs attached; undersized legs get resized; flat
436
+ * positions get their rows closed out (T-1 already cancelled the legs).
437
+ * Returns symbols whose rows were closed out (close-bypass signals for the
438
+ * adapter's drift pipeline).
439
+ */
440
+ async resyncAgainstExchange(reasonTag) {
441
+ const closedSymbols = [];
442
+ const rows = this.ledger.getAll().filter((r) => !isTerminalBracketState(r.state));
443
+ if (rows.length === 0)
444
+ return { closedSymbols };
445
+ const positions = await this.executor.getPositionsOrNull();
446
+ if (positions === null) {
447
+ logger.warn(TAG, `resync(${reasonTag}): positions fetch FAILED — state UNKNOWN, skipping this pass ` +
448
+ `(null ≠ empty; acting would risk closing rows for live positions)`);
449
+ return { closedSymbols };
450
+ }
451
+ const sizeBySymbol = new Map();
452
+ for (const p of positions) {
453
+ const contracts = Math.abs(Number(p.contracts ?? 0));
454
+ if (contracts > 0 && p.symbol)
455
+ sizeBySymbol.set(baseOf(p.symbol), contracts);
456
+ }
457
+ for (const row of rows) {
458
+ const liveSize = sizeBySymbol.get(baseOf(row.symbol)) ?? 0;
459
+ if (liveSize === 0) {
460
+ // Exchange is flat. T-1 already auto-cancelled any legs. If the row
461
+ // was active, the trigger event was missed (T-5) — close the row out
462
+ // and let the adapter surface a close-bypass drift.
463
+ if (row.state === 'active' || row.state === 'partial') {
464
+ logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} flat on exchange while bracket ${row.state} — ` +
465
+ `missed trigger/close during a WS gap; closing the row (legs auto-cancel on flat, T-1)`);
466
+ this.ledger.markState(row.symbol, 'cancelled', {
467
+ closeReason: 'cancelled_auto',
468
+ lastError: `resync_position_flat:${reasonTag}`,
469
+ });
470
+ closedSymbols.push(row.symbol);
471
+ }
472
+ else {
473
+ // pending_entry/attaching with no position: entry never filled (or
474
+ // filled and closed inside the gap — the journal reconcile owns
475
+ // that). Remove the stale row so the next entry starts clean.
476
+ logger.info(TAG, `resync(${reasonTag}): ${row.symbol} row ${row.state} with no position — clearing stale row`);
477
+ this.ledger.markState(row.symbol, 'cancelled', {
478
+ closeReason: 'cancelled_auto',
479
+ lastError: `resync_no_position:${reasonTag}`,
480
+ });
481
+ }
482
+ continue;
483
+ }
484
+ // Position is live.
485
+ if (row.state === 'pending_entry') {
486
+ // Entry filled while we were blind — attach now, sized to the position.
487
+ logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} position live (${liveSize}) with bracket still ` +
488
+ `pending_entry — the fill event was missed; attaching legs now`);
489
+ const res = await this.attachOnFill(row.symbol, liveSize).catch((err) => {
490
+ logger.error(TAG, `resync attach failed for ${row.symbol}: ${formatError(err)}`);
491
+ return null;
492
+ });
493
+ if (res && !res.ok) {
494
+ this.emitEvent({
495
+ type: 'bracket.attach_failed',
496
+ bracketId: row.bracketId,
497
+ symbol: row.symbol,
498
+ error: res.error ?? 'resync attach failed',
499
+ retryCount: res.attempts,
500
+ ts: new Date(this.now()).toISOString(),
501
+ });
502
+ }
503
+ continue;
504
+ }
505
+ if (row.state === 'active' || row.state === 'partial') {
506
+ // ★ T-2 invariant: do the live legs cover the live size?
507
+ try {
508
+ const audit = await this.executor.auditBracketCoverage(row.symbol, liveSize);
509
+ if (!audit.covered) {
510
+ logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} legs do NOT cover position ` +
511
+ `(size=${liveSize}, shortfall=${JSON.stringify(audit.shortfall)}, ` +
512
+ `missing=${audit.missing.join(',') || 'none'}) — resizing (naked size is a ` +
513
+ `safety-floor breach)`);
514
+ await this.resizeToPosition(row.symbol, liveSize);
515
+ }
516
+ }
517
+ catch (err) {
518
+ // getOpenOrders threw (unknown state) — honest skip, next sweep retries.
519
+ logger.warn(TAG, `resync(${reasonTag}): ${row.symbol} coverage unverifiable (${formatError(err)}) — ` +
520
+ `not acting on unknown state; next sweep retries`);
521
+ }
522
+ }
523
+ }
524
+ return { closedSymbols };
525
+ }
526
+ emitEvent(event) {
527
+ this.emit('event', event);
528
+ }
529
+ }
530
+ /** Compare symbols by base asset (`BTC/USDC` vs `BTC/USDC:USDC`). */
531
+ function baseOf(symbol) {
532
+ return symbol.split(':')[0];
533
+ }