@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
@@ -4,10 +4,15 @@ import type { CcxtOrder, CcxtBalance, CcxtPosition, TradingMode } from '../../ty
4
4
  import type { PositionMetadata, CloseReason } from '../../simulator/types.js';
5
5
  import { type HlCredentials } from './hl-private.js';
6
6
  import type { BracketId } from '../../live/bracket-types.js';
7
+ import { BracketLedger } from '../../live/bracket-ledger.js';
8
+ import { HlBracketCoordinator } from './hl-bracket-coordinator.js';
7
9
  export interface HlLiveAdapterOptions {
8
10
  credentials: HlCredentials;
9
11
  mode: TradingMode;
10
12
  marketSlippagePct?: number;
13
+ /** Test seams. Production omits both. */
14
+ bracketLedger?: BracketLedger;
15
+ disableUserStream?: boolean;
11
16
  }
12
17
  export declare class HyperliquidLiveAdapter extends EventEmitter implements IExchangeAdapter {
13
18
  private readonly opts;
@@ -15,16 +20,42 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
15
20
  private readonly publicApi;
16
21
  private readonly infoCache;
17
22
  private readonly slippagePct;
23
+ /** ★ On HL live, brackets are ALWAYS enforced — there is no watcher fallback
24
+ * (the stop-watcher has no live metadata) and no `brackets.mode=off` arm:
25
+ * the exchange-side legs ARE the safety floor. Lazily constructed so that
26
+ * merely constructing the adapter (registry tests) writes no ledger file. */
27
+ private _coordinator;
28
+ private userStream;
29
+ private truthCheckTimer;
30
+ private truthCheckRunning;
18
31
  private _readiness;
19
32
  private openOrdersUnavailableUntil;
20
33
  constructor(opts: HlLiveAdapterOptions);
34
+ /** The HL bracket orchestrator — the venue-aware tools (attach_brackets /
35
+ * modify_stop / modify_target / audit) drive brackets through this. */
36
+ getHlBracketCoordinator(): HlBracketCoordinator;
21
37
  /** Boot: load markets + asset rules. Readiness gates `create_order` ONLY —
22
38
  * emergency paths never consult it. */
23
39
  initialize(): Promise<void>;
40
+ /** Stop background machinery (tests / graceful shutdown). The exchange-side
41
+ * legs keep protecting the position regardless — that is the point. */
42
+ stop(): void;
24
43
  get readiness(): AdapterReadiness;
25
44
  get mode(): TradingMode;
26
45
  get isLive(): boolean;
27
- createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, _metadata?: PositionMetadata, options?: OrderOptions): Promise<CcxtOrder>;
46
+ createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, options?: OrderOptions): Promise<CcxtOrder>;
47
+ /**
48
+ * Post-submit bracket wiring, mirroring the Binance LiveAdapter sequence:
49
+ * validate direction against the ACTUAL fill (issue #196 — never the limit
50
+ * price on a filled order), flatten a filled entry whose bracket is invalid
51
+ * (a naked entry must not survive), detect scale-ins (T-2: HL legs are fixed
52
+ * size — the ADDED contracts are naked until resized), then register +
53
+ * attach. Attach itself is async (non-blocking, mirrors Binance) — retries +
54
+ * auto-flatten handle failures.
55
+ */
56
+ private wireBracketsAfterSubmit;
57
+ private attachOnFillAsync;
58
+ private resizeAfterScaleInAsync;
28
59
  /** Emergency-safe: never pre-gated, never readiness-gated. */
29
60
  cancelOrder(orderId: string, symbol?: string): Promise<CcxtOrder>;
30
61
  /**
@@ -57,7 +88,9 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
57
88
  getOpenOrders(symbol?: string): Promise<CcxtOrder[]>;
58
89
  fetchOrder(orderId: string, symbol?: string): Promise<CcxtOrder | null>;
59
90
  getLastPrice(symbol: string): Promise<number | null>;
60
- /** Attach protective legs to a filled position — ONE signed action for both. */
91
+ /** Attach protective legs to a filled position — ONE signed action for both.
92
+ * Returns the cloids it generated so the coordinator can persist them (a
93
+ * cloid is single-use; the ledger must always match the live legs). */
61
94
  attachBrackets(args: {
62
95
  bracketId: BracketId;
63
96
  symbol: string;
@@ -65,7 +98,11 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
65
98
  positionSize: number;
66
99
  stopPrice?: number;
67
100
  targetPrice?: number;
68
- }): Promise<CcxtOrder[]>;
101
+ }): Promise<{
102
+ orders: CcxtOrder[];
103
+ slCid?: string;
104
+ tpCid?: string;
105
+ }>;
69
106
  /**
70
107
  * ★ T-2 REQUIREMENT: resize protective legs after a scale-in.
71
108
  *
@@ -80,9 +117,30 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
80
117
  positionSize: number;
81
118
  }): Promise<{
82
119
  resized: boolean;
120
+ slCid?: string;
121
+ tpCid?: string;
83
122
  }>;
123
+ /** Cancel one bracket leg by cloid. Idempotent: a leg that is already gone
124
+ * (triggered / T-1 auto-cancelled / sibling-cancelled) is SUCCESS, not an
125
+ * error — the goal state ("leg not on the book") is reached. */
126
+ cancelBracketLeg(cloid: string, symbol: string): Promise<void>;
84
127
  /** Coverage audit — `covered:false` means part of the position is NAKED. */
85
128
  auditBracketCoverage(symbol: string, positionSize: number): Promise<import("./hl-brackets.js").CoverageResult>;
129
+ /** Cancel every OUR-cloid bracket leg on a symbol (recovery-path orphan
130
+ * cleanup before a fresh attach). THROWS when order state is unknown —
131
+ * the caller logs and still attaches (protection beats hygiene). */
132
+ cancelSymbolBracketLegs(symbol: string): Promise<number>;
133
+ /** Entry fills drive attach (resting limits) / resize (partial-fill growth).
134
+ * `startPosition` is the position BEFORE this fill — the WS-authoritative
135
+ * way to know the after-fill total without an extra REST read. */
136
+ private onUserFill;
137
+ /** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
138
+ * analog). A trigger = the exchange closed the position — surface the same
139
+ * `drift_detected` close shape the Binance reconciler emits so the journal
140
+ * close-bypass cleanup fires at once, not ≤5 min late. */
141
+ private onUserOrderUpdate;
142
+ /** T-5 REST truth-check — serialized so a slow pass can't stack. */
143
+ private runTruthCheck;
86
144
  /** Our protective legs currently on the exchange. Throws on unknown (never []). */
87
145
  private readLiveLegs;
88
146
  }
@@ -32,7 +32,21 @@ import { HyperliquidPublicApi } from './hl-public.js';
32
32
  import { planBracket, planResize, buildBracketOrders, bracketCoversPosition, } from './hl-brackets.js';
33
33
  import { deriveHlNav, toCcxtBalance } from './hl-balance.js';
34
34
  import { buildHlOrderCloid, parseHlBracketCloid } from './hl-cloid.js';
35
+ import { BracketLedger } from '../../live/bracket-ledger.js';
36
+ import { generateBracketId } from '../../live/bracket-id.js';
37
+ import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
38
+ import { HlBracketCoordinator, isTerminalBracketState } from './hl-bracket-coordinator.js';
39
+ import { HyperliquidUserStream } from './hl-user-stream.js';
40
+ import { formatError } from '../../logger.js';
35
41
  const TAG = 'hl-live-adapter';
42
+ /** Venue-distinct ledger storage — a venue switch on the same box must never
43
+ * read the Binance ledger's rows as HL brackets (or vice versa). */
44
+ const HL_LEDGER_PLUGIN_ID = 'reefclaw-paper-trading-hl';
45
+ /** Periodic REST truth-check cadence. T-5: HL's WS replays nothing, and a
46
+ * dropped frame while connected is invisible — the sweep is the backstop
47
+ * that catches missed fills/triggers and T-2 under-sized legs. ~4 IP weight
48
+ * per pass against a 1200/min budget. */
49
+ const TRUTH_CHECK_INTERVAL_MS = 60_000;
36
50
  /** Default IOC slippage bound for an emulated market order. 0.5% — NOT ccxt's
37
51
  * 5% default, which would be a silent execution-quality disaster. */
38
52
  const DEFAULT_MARKET_SLIPPAGE = 0.005;
@@ -45,6 +59,14 @@ export class HyperliquidLiveAdapter extends EventEmitter {
45
59
  publicApi;
46
60
  infoCache;
47
61
  slippagePct;
62
+ /** ★ On HL live, brackets are ALWAYS enforced — there is no watcher fallback
63
+ * (the stop-watcher has no live metadata) and no `brackets.mode=off` arm:
64
+ * the exchange-side legs ARE the safety floor. Lazily constructed so that
65
+ * merely constructing the adapter (registry tests) writes no ledger file. */
66
+ _coordinator = null;
67
+ userStream = null;
68
+ truthCheckTimer = null;
69
+ truthCheckRunning = false;
48
70
  _readiness = 'INIT_PENDING';
49
71
  openOrdersUnavailableUntil = 0;
50
72
  constructor(opts) {
@@ -58,6 +80,21 @@ export class HyperliquidLiveAdapter extends EventEmitter {
58
80
  return this.publicApi.fetchMeta();
59
81
  });
60
82
  }
83
+ /** The HL bracket orchestrator — the venue-aware tools (attach_brackets /
84
+ * modify_stop / modify_target / audit) drive brackets through this. */
85
+ getHlBracketCoordinator() {
86
+ if (!this._coordinator) {
87
+ this._coordinator = new HlBracketCoordinator({
88
+ attachBrackets: (args) => this.attachBrackets(args),
89
+ resizeBrackets: (args) => this.resizeBrackets(args),
90
+ cancelBracketLeg: (cloid, symbol) => this.cancelBracketLeg(cloid, symbol),
91
+ auditBracketCoverage: (symbol, size) => this.auditBracketCoverage(symbol, size),
92
+ getPositionsOrNull: (symbol) => this.getPositionsOrNull(symbol),
93
+ closePosition: (symbol, reason) => this.closePosition(symbol, reason),
94
+ }, this.opts.bracketLedger ?? new BracketLedger(HL_LEDGER_PLUGIN_ID));
95
+ }
96
+ return this._coordinator;
97
+ }
61
98
  // ---- Lifecycle ----
62
99
  /** Boot: load markets + asset rules. Readiness gates `create_order` ONLY —
63
100
  * emergency paths never consult it. */
@@ -76,6 +113,42 @@ export class HyperliquidLiveAdapter extends EventEmitter {
76
113
  }
77
114
  // Prime the ADDRESS action budget (the starvation guard, §5.6).
78
115
  await this.api.refreshAddressBudget();
116
+ // ---- Bracket wiring (issue #209) ----
117
+ // Fast path: the user stream (fills drive attach for resting limits;
118
+ // orderUpdates is the authoritative leg-lifecycle signal — the HL analog
119
+ // of Binance's ALGO_UPDATE). Truth path: T-5 proved the WS replays
120
+ // NOTHING, so every (re)connect AND a periodic sweep run the REST
121
+ // truth-check in resyncAgainstExchange.
122
+ if (!this.opts.disableUserStream) {
123
+ this.userStream = new HyperliquidUserStream({
124
+ walletAddress: this.opts.credentials.walletAddress,
125
+ testnet: this.opts.credentials.testnet,
126
+ callbacks: {
127
+ onFill: (fill) => this.onUserFill(fill),
128
+ onOrderUpdate: (update) => this.onUserOrderUpdate(update),
129
+ onUserEvent: () => {
130
+ /* liquidation/funding — liquidation fills also arrive via onFill */
131
+ },
132
+ onResyncNeeded: (window) => {
133
+ void this.runTruthCheck(`ws_resync_blind_${Math.round(window.wasDisconnectedMs / 1000)}s`);
134
+ },
135
+ },
136
+ });
137
+ this.userStream.start();
138
+ }
139
+ this.truthCheckTimer = setInterval(() => {
140
+ void this.runTruthCheck('periodic');
141
+ }, TRUTH_CHECK_INTERVAL_MS);
142
+ this.truthCheckTimer.unref?.();
143
+ }
144
+ /** Stop background machinery (tests / graceful shutdown). The exchange-side
145
+ * legs keep protecting the position regardless — that is the point. */
146
+ stop() {
147
+ this.userStream?.stop();
148
+ this.userStream = null;
149
+ if (this.truthCheckTimer)
150
+ clearInterval(this.truthCheckTimer);
151
+ this.truthCheckTimer = null;
79
152
  }
80
153
  get readiness() {
81
154
  return this._readiness;
@@ -87,7 +160,7 @@ export class HyperliquidLiveAdapter extends EventEmitter {
87
160
  return true;
88
161
  }
89
162
  // ---- Core trading ----
90
- async createOrder(symbol, side, type, amount, price, _metadata, options) {
163
+ async createOrder(symbol, side, type, amount, price, metadata, options) {
91
164
  // A market order NEEDS a reference price on HL (no native market order; ccxt
92
165
  // derives the slippage cap from it and THROWS without one — verified live).
93
166
  let referencePrice = price;
@@ -128,8 +201,163 @@ export class HyperliquidLiveAdapter extends EventEmitter {
128
201
  });
129
202
  if (!order)
130
203
  throw new Error(`Hyperliquid order submission returned no order for ${symbol}`);
204
+ // ---- Bracket wiring (issue #209): protection attaches HERE, at the fill ----
205
+ // Closes / reduce-only orders never carry brackets.
206
+ const hasBracketMeta = metadata !== undefined &&
207
+ (metadata.stopPrice !== undefined || metadata.targetPrice !== undefined);
208
+ if (!options?.reduceOnly && hasBracketMeta) {
209
+ await this.wireBracketsAfterSubmit(symbol, side, order, metadata, cloid);
210
+ }
131
211
  return order;
132
212
  }
213
+ /**
214
+ * Post-submit bracket wiring, mirroring the Binance LiveAdapter sequence:
215
+ * validate direction against the ACTUAL fill (issue #196 — never the limit
216
+ * price on a filled order), flatten a filled entry whose bracket is invalid
217
+ * (a naked entry must not survive), detect scale-ins (T-2: HL legs are fixed
218
+ * size — the ADDED contracts are naked until resized), then register +
219
+ * attach. Attach itself is async (non-blocking, mirrors Binance) — retries +
220
+ * auto-flatten handle failures.
221
+ */
222
+ async wireBracketsAfterSubmit(symbol, side, order, metadata, entryCloid) {
223
+ const filled = Number(order.filled ?? 0);
224
+ const isFilled = filled > 0;
225
+ // Direction check vs the actual fill (filled) or the limit (resting).
226
+ let dirRef = null;
227
+ if (isFilled) {
228
+ const avg = Number(order.average ?? 0);
229
+ const cost = Number(order.cost ?? 0);
230
+ dirRef = avg > 0 ? avg : cost > 0 && filled > 0 ? cost / filled : null;
231
+ if (dirRef === null) {
232
+ logger.warn(TAG, `Post-fill bracket direction check skipped for ${symbol}: fill price unresolvable — ` +
233
+ 'the exchange-side trigger validation is the arbiter (issue #196 rule)');
234
+ }
235
+ }
236
+ else {
237
+ const lim = Number(order.price ?? 0);
238
+ dirRef = lim > 0 ? lim : null;
239
+ }
240
+ let dirMsg = null;
241
+ if (dirRef !== null && dirRef > 0) {
242
+ if (metadata.stopPrice !== undefined) {
243
+ dirMsg = validateStopDirection(side, dirRef, metadata.stopPrice);
244
+ }
245
+ if (!dirMsg && metadata.targetPrice !== undefined) {
246
+ dirMsg = validateTargetDirection(side, dirRef, metadata.targetPrice);
247
+ }
248
+ }
249
+ if (dirMsg) {
250
+ if (isFilled) {
251
+ logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}, fill=${dirRef}) — ` +
252
+ 'flattening the just-filled position to avoid a naked entry');
253
+ let flattened = false;
254
+ for (let a = 1; a <= 4 && !flattened; a++) {
255
+ try {
256
+ await this.closePosition(symbol, 'bracket_attach_failed');
257
+ flattened = true;
258
+ }
259
+ catch (err) {
260
+ logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${msg(err)}`);
261
+ if (a < 4)
262
+ await new Promise((r) => setTimeout(r, 1500));
263
+ }
264
+ }
265
+ if (!flattened) {
266
+ logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, ` +
267
+ 'immediate operator intervention required');
268
+ this.emit('emergency_progress', {
269
+ action: 'flatten',
270
+ status: 'failed',
271
+ symbol,
272
+ message: `Naked ${symbol} after invalid bracket — manual close required`,
273
+ });
274
+ }
275
+ }
276
+ else {
277
+ logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the ` +
278
+ 'resting entry so it cannot fill unprotected');
279
+ try {
280
+ await this.api.cancelOrderByCloid(entryCloid, symbol);
281
+ }
282
+ catch (err) {
283
+ logger.error(TAG, `Cancel of resting entry after invalid bracket failed: ${msg(err)}`);
284
+ }
285
+ }
286
+ throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
287
+ }
288
+ // ★ Scale-in (T-2): a non-terminal active/partial row means legs are live
289
+ // at the OLD size — the added contracts are NAKED until resized. Resolve
290
+ // the new total from exchange truth and resize; on an unknown read the
291
+ // 60s truth-check sweep is the backstop (and we say so loudly).
292
+ const ledger = this.getHlBracketCoordinator().getLedger();
293
+ const existing = ledger.getBySymbol(symbol);
294
+ if (existing && !isTerminalBracketState(existing.state) && existing.state !== 'pending_entry') {
295
+ if (isFilled) {
296
+ void this.resizeAfterScaleInAsync(symbol);
297
+ }
298
+ return;
299
+ }
300
+ const bracketId = generateBracketId();
301
+ this.getHlBracketCoordinator().registerEntry({ symbol, side, stopPrice: metadata.stopPrice, targetPrice: metadata.targetPrice }, bracketId, entryCloid);
302
+ if (isFilled) {
303
+ // Do NOT await — attach can take seconds; retries + auto-flatten own
304
+ // the failure path (same contract as Binance attachBracketsAsync).
305
+ void this.attachOnFillAsync(symbol, filled);
306
+ }
307
+ // Resting limit: the user-stream fill event (or the truth-check sweep,
308
+ // if the fill lands in a WS gap — T-5) drives the attach.
309
+ }
310
+ async attachOnFillAsync(symbol, filledSize) {
311
+ try {
312
+ const result = await this.getHlBracketCoordinator().attachOnFill(symbol, filledSize);
313
+ if (!result.ok && result.error !== 'attach_in_flight') {
314
+ logger.error(TAG, `Bracket attach FAILED for ${symbol} after ${result.attempts} attempts: ${result.error}. ` +
315
+ 'Auto-flattening position (a naked HL live position has NO fallback watcher).');
316
+ try {
317
+ await this.closePosition(symbol, 'bracket_attach_failed');
318
+ }
319
+ catch (err) {
320
+ logger.error(TAG, `Auto-flatten after bracket failure ALSO failed: ${msg(err)}`);
321
+ this.emit('emergency_progress', {
322
+ action: 'flatten',
323
+ status: 'failed',
324
+ symbol,
325
+ message: `Naked ${symbol} after failed bracket attach — manual close required`,
326
+ });
327
+ }
328
+ }
329
+ }
330
+ catch (err) {
331
+ logger.error(TAG, `attachOnFillAsync unexpected error for ${symbol}: ${formatError(err)}`);
332
+ }
333
+ }
334
+ async resizeAfterScaleInAsync(symbol) {
335
+ try {
336
+ // Exchange truth for the new total — never arithmetic on local state.
337
+ let total = null;
338
+ for (let a = 1; a <= 3 && total === null; a++) {
339
+ const positions = await this.getPositionsOrNull(symbol);
340
+ if (positions !== null) {
341
+ const pos = positions.find((p) => p.symbol?.startsWith(symbol.split(':')[0]));
342
+ total = Math.abs(Number(pos?.contracts ?? 0));
343
+ }
344
+ else if (a < 3) {
345
+ await new Promise((r) => setTimeout(r, 1500));
346
+ }
347
+ }
348
+ if (total === null) {
349
+ logger.error(TAG, `scale-in resize for ${symbol}: position size UNKNOWN after 3 reads — the added size ` +
350
+ 'may be NAKED until the 60s truth-check sweep resizes the legs (T-2)');
351
+ return;
352
+ }
353
+ if (total > 0) {
354
+ await this.getHlBracketCoordinator().resizeToPosition(symbol, total);
355
+ }
356
+ }
357
+ catch (err) {
358
+ logger.error(TAG, `scale-in resize for ${symbol} failed: ${formatError(err)} — truth-check sweep will retry`);
359
+ }
360
+ }
133
361
  /** Emergency-safe: never pre-gated, never readiness-gated. */
134
362
  async cancelOrder(orderId, symbol) {
135
363
  if (!symbol)
@@ -217,6 +445,16 @@ export class HyperliquidLiveAdapter extends EventEmitter {
217
445
  });
218
446
  if (!order)
219
447
  throw new Error(`closePosition(${symbol}) returned no order`);
448
+ // Ledger hygiene: mark the bracket row terminal. T-1 auto-cancels the
449
+ // exchange legs when the position goes flat, so this is bookkeeping (plus
450
+ // a harmless defensive cancel), never load-bearing. Fire-and-forget — a
451
+ // close must never fail on ledger cleanup.
452
+ const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
453
+ if (row && !isTerminalBracketState(row.state)) {
454
+ void this.getHlBracketCoordinator()
455
+ .cancelBrackets(symbol, `position_closed:${_closeReason ?? 'unspecified'}`)
456
+ .catch((err) => logger.warn(TAG, `bracket cleanup after close(${symbol}): ${msg(err)}`));
457
+ }
220
458
  return order;
221
459
  }
222
460
  // ---- State queries ----
@@ -270,7 +508,9 @@ export class HyperliquidLiveAdapter extends EventEmitter {
270
508
  return this.publicApi.fetchMarkPrice(symbol);
271
509
  }
272
510
  // ---- Brackets (the safety floor) ----
273
- /** Attach protective legs to a filled position — ONE signed action for both. */
511
+ /** Attach protective legs to a filled position — ONE signed action for both.
512
+ * Returns the cloids it generated so the coordinator can persist them (a
513
+ * cloid is single-use; the ledger must always match the live legs). */
274
514
  async attachBrackets(args) {
275
515
  const plan = planBracket({
276
516
  ...args,
@@ -287,7 +527,11 @@ export class HyperliquidLiveAdapter extends EventEmitter {
287
527
  const orders = await this.api.submitOrders(buildBracketOrders(plan));
288
528
  if (!orders)
289
529
  throw new Error(`attachBrackets(${args.symbol}): submission returned nothing`);
290
- return orders;
530
+ return {
531
+ orders,
532
+ slCid: plan.legs.find((l) => l.role === 'stop')?.cloid,
533
+ tpCid: plan.legs.find((l) => l.role === 'target')?.cloid,
534
+ };
291
535
  }
292
536
  /**
293
537
  * ★ T-2 REQUIREMENT: resize protective legs after a scale-in.
@@ -305,8 +549,16 @@ export class HyperliquidLiveAdapter extends EventEmitter {
305
549
  });
306
550
  if (plan.noop)
307
551
  return { resized: false };
308
- await this.api.submitOrders(plan.submit); // protect FIRST
552
+ const submitted = await this.api.submitOrders(plan.submit); // protect FIRST
553
+ if (!submitted) {
554
+ // Old legs untouched — the position keeps its ORIGINAL protection; the
555
+ // added size stays naked until the caller retries (truth-check sweep).
556
+ throw new Error(`resizeBrackets(${args.symbol}): submission returned nothing — old legs left in place`);
557
+ }
309
558
  for (const cloid of plan.cancelCloids) {
559
+ // Register BEFORE the request: the WS 'canceled' event can beat the
560
+ // ledger update and must not read as stripped protection (canary).
561
+ this.getHlBracketCoordinator().noteOwnLegCancel(cloid);
310
562
  try {
311
563
  await this.api.cancelOrderByCloid(cloid, args.symbol);
312
564
  }
@@ -316,13 +568,136 @@ export class HyperliquidLiveAdapter extends EventEmitter {
316
568
  }
317
569
  }
318
570
  logger.info(TAG, `resized brackets on ${args.symbol} to ${args.positionSize} (T-2: HL legs do not auto-resize)`);
319
- return { resized: true };
571
+ let slCid;
572
+ let tpCid;
573
+ for (const o of plan.submit) {
574
+ const parsed = typeof o.cloid === 'string' ? parseHlBracketCloid(o.cloid) : null;
575
+ if (parsed?.role === 'stop')
576
+ slCid = o.cloid;
577
+ if (parsed?.role === 'target')
578
+ tpCid = o.cloid;
579
+ }
580
+ return { resized: true, slCid, tpCid };
581
+ }
582
+ /** Cancel one bracket leg by cloid. Idempotent: a leg that is already gone
583
+ * (triggered / T-1 auto-cancelled / sibling-cancelled) is SUCCESS, not an
584
+ * error — the goal state ("leg not on the book") is reached. */
585
+ async cancelBracketLeg(cloid, symbol) {
586
+ this.getHlBracketCoordinator().noteOwnLegCancel(cloid);
587
+ try {
588
+ await this.api.cancelOrderByCloid(cloid, symbol);
589
+ }
590
+ catch (err) {
591
+ const m = msg(err).toLowerCase();
592
+ if (m.includes('never placed') || m.includes('already') || m.includes('filled') || m.includes('not found')) {
593
+ return; // already gone — idempotent success
594
+ }
595
+ throw err;
596
+ }
320
597
  }
321
598
  /** Coverage audit — `covered:false` means part of the position is NAKED. */
322
599
  async auditBracketCoverage(symbol, positionSize) {
323
600
  const live = await this.readLiveLegs(symbol);
324
601
  return bracketCoversPosition({ positionSize, liveLegs: live });
325
602
  }
603
+ /** Cancel every OUR-cloid bracket leg on a symbol (recovery-path orphan
604
+ * cleanup before a fresh attach). THROWS when order state is unknown —
605
+ * the caller logs and still attaches (protection beats hygiene). */
606
+ async cancelSymbolBracketLegs(symbol) {
607
+ const legs = await this.readLiveLegs(symbol); // throws on failed fetch
608
+ let cancelled = 0;
609
+ for (const leg of legs) {
610
+ try {
611
+ await this.cancelBracketLeg(leg.cloid, symbol);
612
+ cancelled++;
613
+ }
614
+ catch (err) {
615
+ logger.warn(TAG, `cancelSymbolBracketLegs(${symbol}): ${leg.cloid} failed: ${msg(err)}`);
616
+ }
617
+ }
618
+ return cancelled;
619
+ }
620
+ // ---- User-stream handlers (issue #209 wiring) ----
621
+ /** Entry fills drive attach (resting limits) / resize (partial-fill growth).
622
+ * `startPosition` is the position BEFORE this fill — the WS-authoritative
623
+ * way to know the after-fill total without an extra REST read. */
624
+ onUserFill(fill) {
625
+ try {
626
+ const ledger = this.getHlBracketCoordinator().getLedger();
627
+ const rows = ledger.getAll().filter((r) => !isTerminalBracketState(r.state));
628
+ const row = rows.find((r) => r.entryCid && fill.cloid && r.entryCid === fill.cloid);
629
+ if (!row)
630
+ return;
631
+ const sz = Math.abs(Number(fill.sz ?? 0));
632
+ const before = Math.abs(Number(fill.startPosition ?? 0));
633
+ const isOpen = (fill.dir ?? '').toLowerCase().startsWith('open');
634
+ if (!isOpen || sz <= 0)
635
+ return;
636
+ const afterTotal = before + sz;
637
+ if (row.state === 'pending_entry') {
638
+ void this.attachOnFillAsync(row.symbol, afterTotal);
639
+ }
640
+ else if (row.state === 'active' || row.state === 'partial') {
641
+ // Further fills of the same resting entry — grow the legs (T-2).
642
+ void this.getHlBracketCoordinator()
643
+ .resizeToPosition(row.symbol, afterTotal)
644
+ .catch((err) => logger.warn(TAG, `partial-fill resize ${row.symbol}: ${msg(err)} — sweep will retry`));
645
+ }
646
+ }
647
+ catch (err) {
648
+ logger.error(TAG, `onUserFill handler error: ${formatError(err)}`);
649
+ }
650
+ }
651
+ /** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
652
+ * analog). A trigger = the exchange closed the position — surface the same
653
+ * `drift_detected` close shape the Binance reconciler emits so the journal
654
+ * close-bypass cleanup fires at once, not ≤5 min late. */
655
+ onUserOrderUpdate(update) {
656
+ try {
657
+ const transition = this.getHlBracketCoordinator().handleOrderUpdate(update);
658
+ if (transition === 'triggered_sl' || transition === 'triggered_tp' || transition === 'forced_close') {
659
+ const row = this.getHlBracketCoordinator().getLedger().getAll().find((r) => update.order.cloid && (r.slCid === update.order.cloid || r.tpCid === update.order.cloid));
660
+ const symbol = row?.symbol;
661
+ if (symbol) {
662
+ this.emit('drift_detected', {
663
+ timestamp: new Date().toISOString(),
664
+ drifts: [
665
+ {
666
+ type: 'closed',
667
+ symbol,
668
+ localContracts: row?.qty ?? 0,
669
+ },
670
+ ],
671
+ });
672
+ }
673
+ }
674
+ }
675
+ catch (err) {
676
+ logger.error(TAG, `onUserOrderUpdate handler error: ${formatError(err)}`);
677
+ }
678
+ }
679
+ /** T-5 REST truth-check — serialized so a slow pass can't stack. */
680
+ async runTruthCheck(reasonTag) {
681
+ if (this.truthCheckRunning)
682
+ return;
683
+ this.truthCheckRunning = true;
684
+ try {
685
+ const { closedSymbols } = await this.getHlBracketCoordinator().resyncAgainstExchange(reasonTag);
686
+ for (const symbol of closedSymbols) {
687
+ const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
688
+ this.emit('drift_detected', {
689
+ timestamp: new Date().toISOString(),
690
+ drifts: [{ type: 'closed', symbol, localContracts: row?.qty ?? 0 }],
691
+ });
692
+ }
693
+ }
694
+ catch (err) {
695
+ logger.error(TAG, `truth-check(${reasonTag}) failed: ${formatError(err)}`);
696
+ }
697
+ finally {
698
+ this.truthCheckRunning = false;
699
+ }
700
+ }
326
701
  /** Our protective legs currently on the exchange. Throws on unknown (never []). */
327
702
  async readLiveLegs(symbol) {
328
703
  const orders = await this.getOpenOrders(symbol); // throws on failed fetch
@@ -241,7 +241,14 @@ export class HyperliquidPublicApi {
241
241
  logger.warn(TAG, `fetchTicker(${symbol}) — no usable price on ticker`);
242
242
  return null;
243
243
  }
244
- const timestamp = Number.isFinite(raw.timestamp) ? Number(raw.timestamp) : Date.now();
244
+ // Timestamp honesty (issue #202): this fallback serves the background-
245
+ // refreshed FULL SNAPSHOT price, which can be minutes old. When the raw
246
+ // ticker carries no timestamp, stamp the snapshot's fetch time — never
247
+ // Date.now(), which would disguise a stale price as fresh and defeat the
248
+ // paper engine's stale-quote fill guard.
249
+ const timestamp = Number.isFinite(raw.timestamp)
250
+ ? Number(raw.timestamp)
251
+ : this.tickersCache?.at ?? Date.now();
245
252
  return {
246
253
  // Key on the symbol the CALLER used so per-symbol maps line up.
247
254
  symbol,