@reefclaw/openclaw-plugin 0.1.11 → 0.1.13

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.
@@ -41,6 +41,10 @@ export interface RiskSummaryResult {
41
41
  readyForUpgrade: boolean;
42
42
  upgradeBlockers: string[];
43
43
  };
44
+ /** Present only when an input field was missing/non-finite and a substitute
45
+ * was used. Honest degradation: the numbers above still render, and the
46
+ * agent is TOLD which ones are not straight from the exchange. */
47
+ dataWarnings?: string[];
44
48
  timestamp: string;
45
49
  }
46
50
  export declare function getRiskSummaryTool(_args: Record<string, never>, deps: {
@@ -2,6 +2,26 @@
2
2
  // Returns exposure, drawdown, heat score, position count — everything
3
3
  // the agent needs for risk-aware decision making.
4
4
  import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
5
+ /** Finite number or undefined — never NaN, never a silent 0. */
6
+ function fin(v) {
7
+ if (v === undefined || v === null || v === '')
8
+ return undefined;
9
+ const n = typeof v === 'number' ? v : Number(v);
10
+ return Number.isFinite(n) ? n : undefined;
11
+ }
12
+ /** Round for display without ever throwing.
13
+ *
14
+ * ★ This tool is read-only observability — it must DEGRADE, never crash. It used
15
+ * to call `.toFixed()` straight on adapter fields, and on Hyperliquid live
16
+ * (where ccxt leaves `markPrice` undefined) that took the whole tool down with
17
+ * `Cannot read properties of undefined (reading 'toFixed')` — the agent lost its
18
+ * entire risk read because one display field was absent. The venue-side root
19
+ * cause is fixed in `venues/hyperliquid/hl-position.ts`; this is the backstop
20
+ * for every other adapter/field. */
21
+ function round(v, dp) {
22
+ const n = fin(v);
23
+ return n === undefined ? 0 : +n.toFixed(dp);
24
+ }
5
25
  export async function getRiskSummaryTool(_args, deps) {
6
26
  // In live modes, use real exchange data instead of simulator
7
27
  let balance;
@@ -27,23 +47,41 @@ export async function getRiskSummaryTool(_args, deps) {
27
47
  let longExposure = 0;
28
48
  let shortExposure = 0;
29
49
  let totalUnrealizedPnl = 0;
50
+ const dataWarnings = [];
30
51
  const positionDetails = positions.map((pos) => {
31
- const notional = Math.abs(pos.notional);
52
+ const entryPrice = fin(pos.entryPrice);
53
+ const rawMark = fin(pos.markPrice);
54
+ // Same convention as pre-trade-check / create-order: an absent mark falls
55
+ // back to entry (a $0 mark would read as a total loss), and we say so.
56
+ const markPrice = rawMark ?? entryPrice;
57
+ if (rawMark === undefined) {
58
+ dataWarnings.push(`${pos.symbol}: mark price unavailable from the exchange — entry price substituted (uPnL/exposure may be stale)`);
59
+ }
60
+ if (entryPrice === undefined) {
61
+ dataWarnings.push(`${pos.symbol}: entry price unavailable from the exchange`);
62
+ }
63
+ const quantity = fin(pos.contracts) ?? 0;
64
+ const rawNotional = fin(pos.notional);
65
+ const notional = rawNotional !== undefined ? Math.abs(rawNotional) : Math.abs(quantity * (markPrice ?? 0));
66
+ const unrealizedPnl = fin(pos.unrealizedPnl);
67
+ if (unrealizedPnl === undefined) {
68
+ dataWarnings.push(`${pos.symbol}: unrealized PnL unavailable from the exchange — counted as 0`);
69
+ }
32
70
  grossExposure += notional;
33
71
  if (pos.side === 'long')
34
72
  longExposure += notional;
35
73
  else
36
74
  shortExposure += notional;
37
- totalUnrealizedPnl += pos.unrealizedPnl;
75
+ totalUnrealizedPnl += unrealizedPnl ?? 0;
38
76
  return {
39
77
  symbol: pos.symbol,
40
78
  side: pos.side,
41
- quantity: pos.contracts,
42
- entryPrice: +pos.entryPrice.toFixed(2),
43
- markPrice: +pos.markPrice.toFixed(2),
44
- notional: +notional.toFixed(2),
45
- unrealizedPnl: +pos.unrealizedPnl.toFixed(2),
46
- portfolioPercent: totalEquity > 0 ? +((notional / totalEquity) * 100).toFixed(1) : 0,
79
+ quantity,
80
+ entryPrice: round(entryPrice, 2),
81
+ markPrice: round(markPrice, 2),
82
+ notional: round(notional, 2),
83
+ unrealizedPnl: round(unrealizedPnl, 2),
84
+ portfolioPercent: totalEquity > 0 ? round((notional / totalEquity) * 100, 1) : 0,
47
85
  };
48
86
  });
49
87
  const netExposure = longExposure - shortExposure;
@@ -76,10 +114,10 @@ export async function getRiskSummaryTool(_args, deps) {
76
114
  const executionStats = execStats && execStats.totalTrades > 0
77
115
  ? {
78
116
  totalTrades: execStats.totalTrades,
79
- avgSlippageBps: +execStats.avgSlippageBps.toFixed(2),
80
- worstSlippageBps: +execStats.worstSlippageBps.toFixed(2),
81
- totalFeesPaid: +execStats.totalFeesPaid.toFixed(2),
82
- avgLatencyMs: +execStats.avgLatencyMs.toFixed(0),
117
+ avgSlippageBps: round(execStats.avgSlippageBps, 2),
118
+ worstSlippageBps: round(execStats.worstSlippageBps, 2),
119
+ totalFeesPaid: round(execStats.totalFeesPaid, 2),
120
+ avgLatencyMs: round(execStats.avgLatencyMs, 0),
83
121
  }
84
122
  : undefined;
85
123
  // Shadow metrics (Phase 9b)
@@ -89,8 +127,8 @@ export async function getRiskSummaryTool(_args, deps) {
89
127
  const upgrade = deps.shadowTracker.isReadyForUpgrade();
90
128
  shadowMetrics = {
91
129
  totalShadowTrades: sm.totalShadowTrades,
92
- avgDeltaBps: +sm.avgDeltaBps.toFixed(2),
93
- worstDeltaBps: +sm.worstDeltaBps.toFixed(2),
130
+ avgDeltaBps: round(sm.avgDeltaBps, 2),
131
+ worstDeltaBps: round(sm.worstDeltaBps, 2),
94
132
  tradingDays: sm.tradingDays,
95
133
  readyForUpgrade: upgrade.ready,
96
134
  upgradeBlockers: upgrade.reasons,
@@ -98,21 +136,22 @@ export async function getRiskSummaryTool(_args, deps) {
98
136
  }
99
137
  return {
100
138
  tradingMode: deps.tradingMode ?? 'PAPER',
101
- totalEquity: +totalEquity.toFixed(2),
102
- availableBalance: +availableBalance.toFixed(2),
103
- lockedBalance: +lockedBalance.toFixed(2),
104
- grossExposure: +grossExposure.toFixed(2),
105
- grossExposurePercent: +grossExposurePercent.toFixed(1),
106
- netExposure: +netExposure.toFixed(2),
107
- netExposurePercent: +netExposurePercent.toFixed(1),
139
+ totalEquity: round(totalEquity, 2),
140
+ availableBalance: round(availableBalance, 2),
141
+ lockedBalance: round(lockedBalance, 2),
142
+ grossExposure: round(grossExposure, 2),
143
+ grossExposurePercent: round(grossExposurePercent, 1),
144
+ netExposure: round(netExposure, 2),
145
+ netExposurePercent: round(netExposurePercent, 1),
108
146
  positionCount: positions.length,
109
- unrealizedPnl: +totalUnrealizedPnl.toFixed(2),
110
- unrealizedPnlPercent: +unrealizedPnlPercent.toFixed(2),
147
+ unrealizedPnl: round(totalUnrealizedPnl, 2),
148
+ unrealizedPnlPercent: round(unrealizedPnlPercent, 2),
111
149
  positions: positionDetails,
112
150
  heatScore,
113
151
  heatLevel,
114
152
  executionStats,
115
153
  shadowMetrics,
154
+ ...(dataWarnings.length > 0 ? { dataWarnings } : {}),
116
155
  timestamp: new Date().toISOString(),
117
156
  };
118
157
  }
@@ -58,6 +58,10 @@ export interface HlCoordinatorOpts {
58
58
  sleep?: (ms: number) => Promise<void>;
59
59
  }
60
60
  export declare function isTerminalBracketState(state: BracketState): boolean;
61
+ /** A batch item HL rejected. ccxt types status as open|closed|canceled, but a
62
+ * per-item rejection surfaces as an `error` entry in the raw batch response
63
+ * (`info.error`) — check both shapes rather than trust the narrow type. */
64
+ export declare function isRejectedOrder(o: CcxtOrder | undefined | null): boolean;
61
65
  export declare class HlBracketCoordinator extends EventEmitter {
62
66
  private readonly executor;
63
67
  private readonly ledger;
@@ -46,10 +46,10 @@ const TRIGGERED_STATUSES = new Set(['filled', 'triggered']);
46
46
  /** A batch item HL rejected. ccxt types status as open|closed|canceled, but a
47
47
  * per-item rejection surfaces as an `error` entry in the raw batch response
48
48
  * (`info.error`) — check both shapes rather than trust the narrow type. */
49
- function isRejectedOrder(o) {
49
+ export function isRejectedOrder(o) {
50
50
  if (!o)
51
51
  return true;
52
- if (String(o.status ?? '') === 'rejected')
52
+ if (String(o.status ?? '').toLowerCase() === 'rejected')
53
53
  return true;
54
54
  const info = o.info;
55
55
  return Boolean(info && info.error);
@@ -76,9 +76,21 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
76
76
  * ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
77
77
  * against an unknown book could open a NEW position in the opposite direction);
78
78
  * a CONFIRMED-flat account is a benign no-op.
79
+ *
80
+ * ★ Success requires CONFIRMED flat: brackets/ledger are only cleaned up after
81
+ * a post-close position read shows zero — a rejected/partial IOC throws with
82
+ * protection left in place.
79
83
  */
80
84
  closePosition(symbol: string, _closeReason?: CloseReason): Promise<CcxtOrder>;
81
85
  getBalance(): Promise<CcxtBalance>;
86
+ /** The live SL/TP trigger prices for `symbol`, or undefined when no
87
+ * non-terminal ledger row exists. Mirrors LiveAdapter's Binance-side
88
+ * `lookupBracket` so the dashboard's protective-level surfaces read the same
89
+ * shape on both venues. Reads `_coordinator` directly rather than through
90
+ * the lazy getter — merely displaying positions must not construct the
91
+ * coordinator (which writes a ledger file); with no coordinator there are
92
+ * no brackets to report anyway. */
93
+ private lookupBracket;
82
94
  /** Display contract (`?? []`) — the 20+ KPI/display callers. */
83
95
  getPositions(symbol?: string): Promise<CcxtPosition[]>;
84
96
  /** ★ Decision contract — null means UNKNOWN, and destructive paths must not act. */
@@ -36,7 +36,7 @@ import { buildHlOrderCloid, parseHlBracketCloid } from './hl-cloid.js';
36
36
  import { BracketLedger } from '../../live/bracket-ledger.js';
37
37
  import { generateBracketId } from '../../live/bracket-id.js';
38
38
  import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
39
- import { HlBracketCoordinator, isTerminalBracketState } from './hl-bracket-coordinator.js';
39
+ import { HlBracketCoordinator, isRejectedOrder, isTerminalBracketState, } from './hl-bracket-coordinator.js';
40
40
  import { HyperliquidUserStream } from './hl-user-stream.js';
41
41
  import { formatError } from '../../logger.js';
42
42
  const TAG = 'hl-live-adapter';
@@ -120,6 +120,11 @@ export class HyperliquidLiveAdapter extends EventEmitter {
120
120
  // Prime the ADDRESS action budget (the starvation guard, §5.6).
121
121
  await this.api.refreshAddressBudget();
122
122
  // ---- Bracket wiring (issue #209) ----
123
+ // Build the coordinator here, not on first bracket action: getPositions
124
+ // reads its ledger to surface live SL/TP, and until the first truth-check
125
+ // fired (60s) a fresh boot would otherwise show every open position as
126
+ // unprotected on the dashboard. Construction is idempotent + cheap.
127
+ this.getHlBracketCoordinator();
123
128
  // Fast path: the user stream (fills drive attach for resting limits;
124
129
  // orderUpdates is the authoritative leg-lifecycle signal — the HL analog
125
130
  // of Binance's ALGO_UPDATE). Truth path: T-5 proved the WS replays
@@ -415,6 +420,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
415
420
  * ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
416
421
  * against an unknown book could open a NEW position in the opposite direction);
417
422
  * a CONFIRMED-flat account is a benign no-op.
423
+ *
424
+ * ★ Success requires CONFIRMED flat: brackets/ledger are only cleaned up after
425
+ * a post-close position read shows zero — a rejected/partial IOC throws with
426
+ * protection left in place.
418
427
  */
419
428
  async closePosition(symbol, _closeReason) {
420
429
  const positions = await this.api.fetchPositions(symbol);
@@ -451,10 +460,34 @@ export class HyperliquidLiveAdapter extends EventEmitter {
451
460
  });
452
461
  if (!order)
453
462
  throw new Error(`closePosition(${symbol}) returned no order`);
454
- // Ledger hygiene: mark the bracket row terminal. T-1 auto-cancels the
455
- // exchange legs when the position goes flat, so this is bookkeeping (plus
456
- // a harmless defensive cancel), never load-bearing. Fire-and-forget — a
457
- // close must never fail on ledger cleanup.
463
+ if (isRejectedOrder(order)) {
464
+ throw new Error(`closePosition(${symbol}): close order REJECTED by the exchange — position still open, ` +
465
+ 'protection left in place. Retry the close.');
466
+ }
467
+ // ★ An IOC acknowledgement is NOT a fill: the slippage-bounded close can
468
+ // partially fill (or fill nothing) in a fast market. Confirm flat from
469
+ // exchange truth BEFORE touching protection — cancelling the brackets on a
470
+ // live residual would strip its only stop (no watcher fallback on HL live)
471
+ // AND terminalize the ledger row, hiding the residual from the truth-check
472
+ // sweep forever (audit 2026-07-26 F3).
473
+ const after = await this.api.fetchPositions(symbol);
474
+ if (after === null) {
475
+ logger.warn(TAG, `closePosition(${symbol}): post-close position read FAILED — flat UNCONFIRMED; leaving ` +
476
+ 'protection + ledger row in place (T-1 auto-cancels legs on flat; the truth-check ' +
477
+ 'sweep reconciles the row either way)');
478
+ return order;
479
+ }
480
+ const residualPos = after.find((p) => p.symbol?.startsWith(symbol.split(':')[0]));
481
+ const residual = Math.abs(Number(residualPos?.contracts ?? 0));
482
+ if (residual > 0) {
483
+ throw new Error(`closePosition(${symbol}): IOC close did NOT fully fill — residual ${residual} still open, ` +
484
+ 'protection left in place (the truth-check sweep resizes the legs to the residual). ' +
485
+ 'Retry the close.');
486
+ }
487
+ // Confirmed flat. Ledger hygiene: mark the bracket row terminal. T-1
488
+ // auto-cancels the exchange legs when the position goes flat, so this is
489
+ // bookkeeping (plus a harmless defensive cancel), never load-bearing.
490
+ // Fire-and-forget — a close must never fail on ledger cleanup.
458
491
  const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
459
492
  if (row && !isTerminalBracketState(row.state)) {
460
493
  void this.getHlBracketCoordinator()
@@ -497,9 +530,29 @@ export class HyperliquidLiveAdapter extends EventEmitter {
497
530
  enriched.equity = round4(nav.equity);
498
531
  return enriched;
499
532
  }
533
+ /** The live SL/TP trigger prices for `symbol`, or undefined when no
534
+ * non-terminal ledger row exists. Mirrors LiveAdapter's Binance-side
535
+ * `lookupBracket` so the dashboard's protective-level surfaces read the same
536
+ * shape on both venues. Reads `_coordinator` directly rather than through
537
+ * the lazy getter — merely displaying positions must not construct the
538
+ * coordinator (which writes a ledger file); with no coordinator there are
539
+ * no brackets to report anyway. */
540
+ lookupBracket(symbol) {
541
+ const row = this._coordinator?.getLedger().getBySymbol(symbol);
542
+ if (!row)
543
+ return undefined;
544
+ if (row.state !== 'active' && row.state !== 'partial' && row.state !== 'attaching') {
545
+ return undefined;
546
+ }
547
+ return { slPrice: row.stopPrice, tpPrice: row.targetPrice, state: row.state };
548
+ }
500
549
  /** Display contract (`?? []`) — the 20+ KPI/display callers. */
501
550
  async getPositions(symbol) {
502
- return (await this.api.fetchPositions(symbol)) ?? [];
551
+ const positions = (await this.api.fetchPositions(symbol)) ?? [];
552
+ // HL live keeps no per-symbol metadata map, so the bracket ledger is the
553
+ // ONLY protective-level source here — without this the dashboard drew
554
+ // neither a stop nor a target for an HL live position.
555
+ return positions.map((p) => ({ ...p, bracket: this.lookupBracket(p.symbol) }));
503
556
  }
504
557
  /** ★ Decision contract — null means UNKNOWN, and destructive paths must not act. */
505
558
  async getPositionsOrNull(symbol) {
@@ -583,6 +636,16 @@ export class HyperliquidLiveAdapter extends EventEmitter {
583
636
  // added size stays naked until the caller retries (truth-check sweep).
584
637
  throw new Error(`resizeBrackets(${args.symbol}): submission returned nothing — old legs left in place`);
585
638
  }
639
+ const rejected = submitted.filter((order) => isRejectedOrder(order));
640
+ if (submitted.length !== plan.submit.length || rejected.length > 0) {
641
+ // A batch-level acknowledgement does not mean every leg was accepted:
642
+ // Hyperliquid reports individual failures in each order's raw
643
+ // `info.error`. Keep every known-good old leg live and leave the ledger
644
+ // untouched so the truth-check sweep can retry with fresh cloids.
645
+ throw new Error(`resizeBrackets(${args.symbol}): batch returned ${submitted.length}/${plan.submit.length} legs` +
646
+ (rejected.length > 0 ? ` (${rejected.length} rejected)` : '') +
647
+ ' — old legs left in place');
648
+ }
586
649
  for (const cloid of plan.cancelCloids) {
587
650
  // Register BEFORE the request: the WS 'canceled' event can beat the
588
651
  // ledger update and must not read as stripped protection (canary).
@@ -0,0 +1,35 @@
1
+ import type { CcxtOrder } from '../../types.js';
2
+ /**
3
+ * Collapse HL/ccxt order-type strings into the declared `'market' | 'limit'`
4
+ * union. HL order types are all execution-styles of one of the two: anything
5
+ * containing "market" ('Market', 'Stop Market', 'Take Profit Market') executes
6
+ * as a market when it goes; everything else — including a missing type, which
7
+ * only occurs on RESTING rows (a market order never rests) — is a limit.
8
+ * The raw subtype stays readable at `info.orderType`.
9
+ */
10
+ export declare function normalizeHlOrderType(raw: unknown): 'market' | 'limit';
11
+ /**
12
+ * Collapse order statuses into `'open' | 'closed' | 'canceled'`.
13
+ *
14
+ * ccxt's parseOrderStatus already maps filled→closed, triggered→open,
15
+ * marginCanceled/liquidatedCanceled→canceled — but lets 'rejected' (and any
16
+ * future HL status) through verbatim. Directional rule for the residue:
17
+ * - 'rejected' and any '*canceled' variant → 'canceled' (positively terminal,
18
+ * nothing was or will be executed);
19
+ * - 'filled' → 'closed' (in case a raw status bypasses ccxt's map);
20
+ * - anything UNKNOWN → 'open' + WARN. 'open' is the conservative direction on
21
+ * both consumer classes: destructive bracket decisions treat the leg as
22
+ * still live (never "gone" on a guess — the null≠empty doctrine), and the
23
+ * entry-order poller just keeps polling into its own timeout.
24
+ */
25
+ export declare function normalizeHlOrderStatus(raw: unknown): 'open' | 'closed' | 'canceled';
26
+ /**
27
+ * Normalize one ccxt HL order into a contract-compliant `CcxtOrder`.
28
+ *
29
+ * Unlike positions (where a fabricated number is a risk-limit hazard and an
30
+ * unpriceable row nulls the snapshot), an order row is never dropped: the
31
+ * fields we default (fee 0, cost from average×filled) are display/accounting
32
+ * conveniences, while the fields decisions hang on (id, cloid, status, info)
33
+ * are passed through or conservatively mapped.
34
+ */
35
+ export declare function normalizeHlOrder(raw: CcxtOrder, nowMs?: number): CcxtOrder;
@@ -0,0 +1,123 @@
1
+ // Hyperliquid order normalization — brings ccxt's HL order rows up to the
2
+ // `CcxtOrder` contract, the sibling of hl-position.ts (see its header for the
3
+ // class of bug: ccxt emits fields our types declare required as undefined, and
4
+ // TypeScript never checks runtime shapes).
5
+ //
6
+ // What ccxt@4.5.x's hyperliquid `parseOrder` actually emits (verified offline
7
+ // against the real parser, 2026-07-26, surfaced by hl-venue-conformance.test.ts):
8
+ // - `cost: undefined`, `fee: undefined` (HL carries fees on FILLS only, never
9
+ // on order objects) — our contract requires both.
10
+ // - `type: 'take profit market'` / `'take profit limit'` pass straight through
11
+ // `parseOrderType` (only 'stop limit'/'stop market' are mapped) — outside
12
+ // our `'market' | 'limit'` union. A resting limit with no `orderType` field
13
+ // yields `type: undefined`.
14
+ // - `status: 'rejected'` survives `parseOrderStatus` — outside our
15
+ // `'open' | 'closed' | 'canceled'` union.
16
+ // - `average: undefined`, `timeInForce: undefined` on trigger legs.
17
+ //
18
+ // ★ SCOPE: READ paths only (`fetchOpenOrders`, `fetchOrder`). Submit-path
19
+ // responses (`submitOrder`/`submitOrders`) are deliberately NOT normalized —
20
+ // `HlBracketCoordinator.isRejectedOrder` keys on the raw `status: 'rejected'`
21
+ // sentinel there, and blurring it into 'canceled' would break per-item batch
22
+ // rejection detection.
23
+ //
24
+ // ★ WHAT MUST SURVIVE VERBATIM: `info` (skill-side protective classification
25
+ // reads `info.orderType` — 'Take Profit Market' etc — via isProtectiveOrder/
26
+ // classifyProtectiveRole) and `clientOrderId` (bracket reconciliation parses
27
+ // the cloid). The conformance suite pins both.
28
+ import { logger } from '../../logger.js';
29
+ const TAG = 'hl-order';
30
+ /** HL's settlement asset — the only fee currency on this venue. */
31
+ const HL_FEE_CURRENCY = 'USDC';
32
+ /** Finite number or undefined — never NaN, never a silent 0. */
33
+ function fin(v) {
34
+ if (v === undefined || v === null || v === '')
35
+ return undefined;
36
+ const n = typeof v === 'number' ? v : Number(v);
37
+ return Number.isFinite(n) ? n : undefined;
38
+ }
39
+ /**
40
+ * Collapse HL/ccxt order-type strings into the declared `'market' | 'limit'`
41
+ * union. HL order types are all execution-styles of one of the two: anything
42
+ * containing "market" ('Market', 'Stop Market', 'Take Profit Market') executes
43
+ * as a market when it goes; everything else — including a missing type, which
44
+ * only occurs on RESTING rows (a market order never rests) — is a limit.
45
+ * The raw subtype stays readable at `info.orderType`.
46
+ */
47
+ export function normalizeHlOrderType(raw) {
48
+ const t = String(raw ?? '').toLowerCase();
49
+ return t.includes('market') ? 'market' : 'limit';
50
+ }
51
+ /**
52
+ * Collapse order statuses into `'open' | 'closed' | 'canceled'`.
53
+ *
54
+ * ccxt's parseOrderStatus already maps filled→closed, triggered→open,
55
+ * marginCanceled/liquidatedCanceled→canceled — but lets 'rejected' (and any
56
+ * future HL status) through verbatim. Directional rule for the residue:
57
+ * - 'rejected' and any '*canceled' variant → 'canceled' (positively terminal,
58
+ * nothing was or will be executed);
59
+ * - 'filled' → 'closed' (in case a raw status bypasses ccxt's map);
60
+ * - anything UNKNOWN → 'open' + WARN. 'open' is the conservative direction on
61
+ * both consumer classes: destructive bracket decisions treat the leg as
62
+ * still live (never "gone" on a guess — the null≠empty doctrine), and the
63
+ * entry-order poller just keeps polling into its own timeout.
64
+ */
65
+ export function normalizeHlOrderStatus(raw) {
66
+ const s = String(raw ?? '').trim();
67
+ if (s === 'open' || s === 'closed' || s === 'canceled')
68
+ return s;
69
+ const lower = s.toLowerCase();
70
+ if (lower === 'rejected' || lower.endsWith('canceled') || lower.endsWith('cancelled')) {
71
+ return 'canceled';
72
+ }
73
+ if (lower === 'filled')
74
+ return 'closed';
75
+ logger.warn(TAG, `unknown HL order status '${s}' — treating as 'open' (not confirmed terminal)`);
76
+ return 'open';
77
+ }
78
+ /**
79
+ * Normalize one ccxt HL order into a contract-compliant `CcxtOrder`.
80
+ *
81
+ * Unlike positions (where a fabricated number is a risk-limit hazard and an
82
+ * unpriceable row nulls the snapshot), an order row is never dropped: the
83
+ * fields we default (fee 0, cost from average×filled) are display/accounting
84
+ * conveniences, while the fields decisions hang on (id, cloid, status, info)
85
+ * are passed through or conservatively mapped.
86
+ */
87
+ export function normalizeHlOrder(raw, nowMs = Date.now()) {
88
+ const r = raw;
89
+ const amount = fin(r.amount) ?? 0;
90
+ const filledRaw = fin(r.filled);
91
+ const remainingRaw = fin(r.remaining);
92
+ const filled = filledRaw ?? (remainingRaw !== undefined ? Math.max(0, amount - remainingRaw) : 0);
93
+ const remaining = remainingRaw ?? Math.max(0, amount - filled);
94
+ const average = fin(r.average) ?? null;
95
+ const price = fin(r.price) ?? null;
96
+ const cost = fin(r.cost) ?? (average !== null ? average * filled : 0);
97
+ const fee = r.fee && typeof r.fee === 'object' && Number.isFinite(r.fee.cost)
98
+ ? r.fee
99
+ : { cost: 0, currency: HL_FEE_CURRENCY };
100
+ const timestamp = fin(r.timestamp) ?? nowMs;
101
+ return {
102
+ ...raw,
103
+ id: String(r.id ?? ''),
104
+ type: normalizeHlOrderType(
105
+ // Prefer the raw venue subtype — ccxt's unified type may already be
106
+ // collapsed ('stop market'→'market') or missing; info.orderType is the
107
+ // authoritative HL string when present.
108
+ r.info?.orderType ?? r.type),
109
+ status: normalizeHlOrderStatus(r.status),
110
+ amount,
111
+ filled,
112
+ remaining,
113
+ average,
114
+ price,
115
+ cost,
116
+ fee,
117
+ timeInForce: typeof r.timeInForce === 'string' && r.timeInForce.length > 0 ? r.timeInForce : 'GTC',
118
+ timestamp,
119
+ datetime: typeof r.datetime === 'string' && r.datetime.length > 0
120
+ ? r.datetime
121
+ : new Date(timestamp).toISOString(),
122
+ };
123
+ }
@@ -0,0 +1,36 @@
1
+ import type { CcxtPosition } from '../../types.js';
2
+ /** The raw `assetPositions[].position` payload ccxt passes through at
3
+ * `position.info.position` (fields per the info-endpoint docs, §3.6 of the
4
+ * integration plan). */
5
+ export interface HlRawPositionInfo {
6
+ coin?: string;
7
+ /** Signed size — negative for a short. */
8
+ szi?: string | number;
9
+ entryPx?: string | number;
10
+ unrealizedPnl?: string | number;
11
+ /** |szi| × mark. */
12
+ positionValue?: string | number;
13
+ }
14
+ /**
15
+ * Mark price for an HL position, derived from the position payload alone.
16
+ *
17
+ * Primary route inverts the documented uPnl formula; secondary route divides
18
+ * `positionValue` by |szi|. Returns undefined rather than a guess when neither
19
+ * route yields a positive price.
20
+ */
21
+ export declare function deriveHlMarkPrice(args: {
22
+ entryPrice?: number;
23
+ unrealizedPnl?: number;
24
+ /** Signed size (negative = short). */
25
+ signedSize?: number;
26
+ positionValue?: number;
27
+ }): number | undefined;
28
+ /**
29
+ * Normalize one ccxt HL position into a contract-compliant `CcxtPosition`.
30
+ *
31
+ * Returns **null** when the row cannot be priced or sized — the caller must then
32
+ * treat the whole snapshot as UNKNOWN (`null`), not drop the row: a silently
33
+ * missing position is exactly the "empty ≠ unknown" collapse that drove the
34
+ * naked-bracket incidents on the Binance side.
35
+ */
36
+ export declare function normalizeHlPosition(raw: CcxtPosition | null | undefined, nowMs?: number): CcxtPosition | null;
@@ -0,0 +1,127 @@
1
+ // Hyperliquid position normalization — brings ccxt's HL position rows up to the
2
+ // `CcxtPosition` contract the rest of the plugin treats as REQUIRED numbers.
3
+ //
4
+ // ★ WHY THIS FILE EXISTS (the bug it fixes, 2026-07-26): ccxt@4.5.x's hyperliquid
5
+ // `parsePosition` sets `markPrice: undefined` (hyperliquid.js:3603), plus
6
+ // `contractSize`/`timestamp`/`datetime: undefined` — and `safePosition()` fills
7
+ // none of them. `CcxtPosition` declares all four as non-optional numbers, so
8
+ // every consumer that trusts the type broke on an HL LIVE position:
9
+ // - `get_risk_summary` threw `Cannot read properties of undefined (reading
10
+ // 'toFixed')` on `+pos.markPrice.toFixed(2)` (the reported symptom),
11
+ // - `get_wave9_status` / `live-account-capture` reject the position as
12
+ // non-finite, `stop-watcher` / `modify_stop` / `modify_target` read a
13
+ // `undefined` reference price, and `pre-trade-check` / `create-order`
14
+ // silently fall back to entryPrice (stale exposure + stop-distance math).
15
+ // Fixing it at this boundary fixes all of them at once — which is why the
16
+ // normalization lives here (the single choke point every HL position read
17
+ // passes through) and not in the individual tools.
18
+ //
19
+ // ★ THE MARK DERIVATION IS DOC-EXACT AND COSTS NO NETWORK CALL.
20
+ // `clearinghouseState` carries NO per-position mark price — markPx lives only in
21
+ // `metaAndAssetCtxs` (weight 20), so ccxt has nothing to parse and a naive fix
22
+ // would put a heavy call on a per-tick read path (the rule from
23
+ // memory/project_hl_venue_scoped_scan_jul13: never put a heavy HL info call on a
24
+ // poll path). Instead we invert Hyperliquid's own documented formula:
25
+ //
26
+ // "Unrealized pnl is defined as `side * (mark_price - entry_price) *
27
+ // position_size`" — hyperliquid.gitbook.io → Trading → Entry price and pnl
28
+ // (verified 2026-07-26)
29
+ //
30
+ // With `szi` the SIGNED size (side × size) that inverts exactly to
31
+ // mark = entryPx + unrealizedPnl / szi
32
+ // and the docs' own example confirms both this and the secondary route:
33
+ // szi 0.0335, entryPx 2986.3, uPnl −0.0134 → mark 2985.9
34
+ // positionValue 100.02765 / 0.0335 → mark 2985.9 ✓ (|szi| × mark)
35
+ //
36
+ // ★ NEVER FABRICATE. A row we cannot price returns null and the CALLER turns the
37
+ // whole snapshot into null ("unknown"), never a partial list and never a $0
38
+ // mark — a phantom zero mark reads as a total loss and would trip risk limits.
39
+ /** Finite number or undefined — never NaN, never a silent 0. */
40
+ function fin(v) {
41
+ if (v === undefined || v === null || v === '')
42
+ return undefined;
43
+ const n = typeof v === 'number' ? v : Number(v);
44
+ return Number.isFinite(n) ? n : undefined;
45
+ }
46
+ /**
47
+ * Mark price for an HL position, derived from the position payload alone.
48
+ *
49
+ * Primary route inverts the documented uPnl formula; secondary route divides
50
+ * `positionValue` by |szi|. Returns undefined rather than a guess when neither
51
+ * route yields a positive price.
52
+ */
53
+ export function deriveHlMarkPrice(args) {
54
+ const { entryPrice, unrealizedPnl, signedSize, positionValue } = args;
55
+ // Primary: mark = entry + uPnl / szi (exact inverse of the documented
56
+ // uPnl = side * (mark - entry) * size). Also correct when uPnl is 0 — a
57
+ // freshly-filled position marks at its entry.
58
+ if (entryPrice !== undefined &&
59
+ entryPrice > 0 &&
60
+ unrealizedPnl !== undefined &&
61
+ signedSize !== undefined &&
62
+ signedSize !== 0) {
63
+ const mark = entryPrice + unrealizedPnl / signedSize;
64
+ if (Number.isFinite(mark) && mark > 0)
65
+ return mark;
66
+ }
67
+ // Secondary: positionValue = |szi| × mark.
68
+ if (positionValue !== undefined &&
69
+ positionValue > 0 &&
70
+ signedSize !== undefined &&
71
+ signedSize !== 0) {
72
+ const mark = positionValue / Math.abs(signedSize);
73
+ if (Number.isFinite(mark) && mark > 0)
74
+ return mark;
75
+ }
76
+ return undefined;
77
+ }
78
+ /**
79
+ * Normalize one ccxt HL position into a contract-compliant `CcxtPosition`.
80
+ *
81
+ * Returns **null** when the row cannot be priced or sized — the caller must then
82
+ * treat the whole snapshot as UNKNOWN (`null`), not drop the row: a silently
83
+ * missing position is exactly the "empty ≠ unknown" collapse that drove the
84
+ * naked-bracket incidents on the Binance side.
85
+ */
86
+ export function normalizeHlPosition(raw, nowMs = Date.now()) {
87
+ if (!raw || typeof raw !== 'object')
88
+ return null;
89
+ const info = raw.info?.position;
90
+ const signedFromInfo = fin(info?.szi);
91
+ const contracts = fin(raw.contracts) ?? (signedFromInfo === undefined ? undefined : Math.abs(signedFromInfo));
92
+ if (contracts === undefined || contracts <= 0)
93
+ return null;
94
+ const side = raw.side === 'short' || (raw.side === undefined && signedFromInfo !== undefined && signedFromInfo < 0)
95
+ ? 'short'
96
+ : 'long';
97
+ const signedSize = signedFromInfo ?? (side === 'short' ? -contracts : contracts);
98
+ const entryPrice = fin(raw.entryPrice) ?? fin(info?.entryPx);
99
+ if (entryPrice === undefined || entryPrice <= 0)
100
+ return null;
101
+ // HL always sends unrealizedPnl for an open position; 0 is the neutral value
102
+ // for the impossible branch (it makes the primary mark route degrade to
103
+ // "marks at entry" rather than inventing a price).
104
+ const unrealizedPnl = fin(raw.unrealizedPnl) ?? fin(info?.unrealizedPnl) ?? 0;
105
+ const positionValue = fin(raw.notional) ?? fin(info?.positionValue);
106
+ const markPrice = fin(raw.markPrice) ??
107
+ deriveHlMarkPrice({ entryPrice, unrealizedPnl, signedSize, positionValue });
108
+ if (markPrice === undefined || markPrice <= 0)
109
+ return null;
110
+ const notional = positionValue ?? Math.abs(contracts * markPrice);
111
+ const timestamp = fin(raw.timestamp) ?? nowMs;
112
+ return {
113
+ ...raw,
114
+ side,
115
+ contracts,
116
+ contractSize: fin(raw.contractSize) ?? 1, // HL perps: 1 coin per contract
117
+ entryPrice,
118
+ markPrice,
119
+ notional,
120
+ unrealizedPnl,
121
+ percentage: fin(raw.percentage) ?? 0,
122
+ timestamp,
123
+ datetime: typeof raw.datetime === 'string' && raw.datetime.length > 0
124
+ ? raw.datetime
125
+ : new Date(timestamp).toISOString(),
126
+ };
127
+ }