@reefclaw/openclaw-plugin 0.1.12 → 0.1.14

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 (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
@@ -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
+ }
@@ -49,16 +49,33 @@ export declare class HyperliquidPrivateApi {
49
49
  getExchange(): any;
50
50
  loadMarkets(): Promise<boolean>;
51
51
  /** Positions for the MASTER account. `null` = fetch failed (state unknown);
52
- * `[]` = the exchange confirmed flat. */
52
+ * `[]` = the exchange confirmed flat.
53
+ *
54
+ * ★ Rows are normalized through `normalizeHlPosition` — ccxt leaves
55
+ * `markPrice`/`contractSize`/`timestamp`/`datetime` UNDEFINED on this venue
56
+ * (clearinghouseState carries no markPx) while `CcxtPosition` declares them
57
+ * required, which crashed `get_risk_summary` and degraded every other
58
+ * mark-reading consumer. See hl-position.ts for the doc-exact derivation.
59
+ * A row that cannot be priced makes the WHOLE snapshot null (unknown) — never
60
+ * a partial list, never a fabricated zero. */
53
61
  fetchPositions(symbol?: string): Promise<CcxtPosition[] | null>;
54
62
  /** Open orders INCLUDING trigger/TPSL legs. CCXT's HL `fetchOpenOrders`
55
63
  * defaults to `frontendOpenOrders`, which is the only endpoint that returns
56
- * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints. */
64
+ * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints.
65
+ *
66
+ * ★ Rows are normalized through `normalizeHlOrder` (ccxt leaves cost/fee/
67
+ * average/timeInForce undefined and leaks 'take profit market' past the
68
+ * type union — see hl-order.ts); `info` + `clientOrderId` survive verbatim
69
+ * (protective classification + bracket reconciliation read them). */
57
70
  fetchOpenOrders(symbol?: string): Promise<CcxtOrder[] | null>;
58
71
  fetchBalance(): Promise<CcxtBalance | null>;
59
72
  /** Per-order status — the liveness resolver's REST tier (Tier 2 of the
60
73
  * 3-tier rule). Weight 2. `null` = lookup FAILED (unknown), which callers
61
- * must treat as "do not act", NOT as "gone". */
74
+ * must treat as "do not act", NOT as "gone".
75
+ *
76
+ * Normalized like fetchOpenOrders. Unknown statuses collapse to 'open'
77
+ * (= not confirmed terminal — the conservative direction for both the
78
+ * liveness resolver and the entry poller); raw status stays in `info`. */
62
79
  fetchOrder(orderId: string, symbol?: string): Promise<CcxtOrder | null>;
63
80
  /** Own fills. WS is the authoritative ingress (plan + the audit-trail rule);
64
81
  * this is the gap-fill/truth-check path. NOTE: only the 10,000 most recent
@@ -29,6 +29,8 @@ import { logger } from '../../logger.js';
29
29
  import { toCcxtSymbol } from '../symbols.js';
30
30
  import { assertNotLimited, noteError, noteSuccess, exchangeIpWeight, updateAddressBudget, } from './hl-rate-gate.js';
31
31
  import { isValidHlCloid } from './hl-cloid.js';
32
+ import { normalizeHlPosition } from './hl-position.js';
33
+ import { normalizeHlOrder } from './hl-order.js';
32
34
  // ccxt via CJS require — OpenClaw's ESM loader yields the wrong module shape
33
35
  // (same rationale as binance-private.ts / hl-public.ts).
34
36
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -124,7 +126,15 @@ export class HyperliquidPrivateApi {
124
126
  }
125
127
  // ---- Reads (null on failure — NEVER []) ----
126
128
  /** Positions for the MASTER account. `null` = fetch failed (state unknown);
127
- * `[]` = the exchange confirmed flat. */
129
+ * `[]` = the exchange confirmed flat.
130
+ *
131
+ * ★ Rows are normalized through `normalizeHlPosition` — ccxt leaves
132
+ * `markPrice`/`contractSize`/`timestamp`/`datetime` UNDEFINED on this venue
133
+ * (clearinghouseState carries no markPx) while `CcxtPosition` declares them
134
+ * required, which crashed `get_risk_summary` and degraded every other
135
+ * mark-reading consumer. See hl-position.ts for the doc-exact derivation.
136
+ * A row that cannot be priced makes the WHOLE snapshot null (unknown) — never
137
+ * a partial list, never a fabricated zero. */
128
138
  async fetchPositions(symbol) {
129
139
  try {
130
140
  assertNotLimited('clearinghouseState');
@@ -133,7 +143,19 @@ export class HyperliquidPrivateApi {
133
143
  noteSuccess('clearinghouseState');
134
144
  if (!Array.isArray(raw))
135
145
  return null;
136
- return raw.filter((p) => Math.abs(Number(p.contracts ?? 0)) > 0);
146
+ const out = [];
147
+ for (const p of raw) {
148
+ if (Math.abs(Number(p?.contracts ?? 0)) <= 0)
149
+ continue; // flat row — information, not failure
150
+ const normalized = normalizeHlPosition(p);
151
+ if (!normalized) {
152
+ logger.error(TAG, `fetchPositions: unparseable position row for ${String(p?.symbol ?? 'unknown symbol')} ` +
153
+ '— returning null (snapshot UNTRUSTED; a dropped row would read as "flat")');
154
+ return null;
155
+ }
156
+ out.push(normalized);
157
+ }
158
+ return out;
137
159
  }
138
160
  catch (err) {
139
161
  noteError(err, 'fetchPositions');
@@ -143,14 +165,19 @@ export class HyperliquidPrivateApi {
143
165
  }
144
166
  /** Open orders INCLUDING trigger/TPSL legs. CCXT's HL `fetchOpenOrders`
145
167
  * defaults to `frontendOpenOrders`, which is the only endpoint that returns
146
- * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints. */
168
+ * trigger orders (plan §3.6) — the analog of Binance's merged algo endpoints.
169
+ *
170
+ * ★ Rows are normalized through `normalizeHlOrder` (ccxt leaves cost/fee/
171
+ * average/timeInForce undefined and leaks 'take profit market' past the
172
+ * type union — see hl-order.ts); `info` + `clientOrderId` survive verbatim
173
+ * (protective classification + bracket reconciliation read them). */
147
174
  async fetchOpenOrders(symbol) {
148
175
  try {
149
176
  assertNotLimited('frontendOpenOrders');
150
177
  const s = symbol ? toCcxtSymbol('hyperliquid', symbol) : undefined;
151
178
  const raw = await this.exchange.fetchOpenOrders(s);
152
179
  noteSuccess('frontendOpenOrders');
153
- return Array.isArray(raw) ? raw : null;
180
+ return Array.isArray(raw) ? raw.map((o) => normalizeHlOrder(o)) : null;
154
181
  }
155
182
  catch (err) {
156
183
  noteError(err, 'fetchOpenOrders');
@@ -173,14 +200,18 @@ export class HyperliquidPrivateApi {
173
200
  }
174
201
  /** Per-order status — the liveness resolver's REST tier (Tier 2 of the
175
202
  * 3-tier rule). Weight 2. `null` = lookup FAILED (unknown), which callers
176
- * must treat as "do not act", NOT as "gone". */
203
+ * must treat as "do not act", NOT as "gone".
204
+ *
205
+ * Normalized like fetchOpenOrders. Unknown statuses collapse to 'open'
206
+ * (= not confirmed terminal — the conservative direction for both the
207
+ * liveness resolver and the entry poller); raw status stays in `info`. */
177
208
  async fetchOrder(orderId, symbol) {
178
209
  try {
179
210
  assertNotLimited('orderStatus');
180
211
  const s = symbol ? toCcxtSymbol('hyperliquid', symbol) : undefined;
181
212
  const raw = await this.exchange.fetchOrder(orderId, s);
182
213
  noteSuccess('orderStatus');
183
- return raw ?? null;
214
+ return raw ? normalizeHlOrder(raw) : null;
184
215
  }
185
216
  catch (err) {
186
217
  noteError(err, 'fetchOrder');
@@ -1,6 +1,7 @@
1
1
  import type { CcxtTicker, CcxtOHLCV } from '../../types.js';
2
2
  import type { OrderBookDepth } from '../../simulator/types.js';
3
3
  import type { PublicMarketDataApi } from '../../ccxt/public-market-data-api.js';
4
+ import { type VenueReachabilityResult } from '@reefclaw/shared';
4
5
  export interface HyperliquidPublicApiOptions {
5
6
  testnet?: boolean;
6
7
  /** Test seam — injected ccxt exchange instance. */
@@ -60,11 +61,17 @@ export declare class HyperliquidPublicApi implements PublicMarketDataApi {
60
61
  * (verified live 2026-07-11: `{"specialStatuses":null,"time":…}`) doubles
61
62
  * as the clock-drift source. Geo classification is best-effort — HL's
62
63
  * API-level geo behavior is UNVERIFIED (plan §3.9); a 403/451 maps to
63
- * geo_blocked, anything else non-2xx/network maps to unreachable. */
64
- probeReachability(): Promise<{
65
- outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
66
- driftMs: number | null;
67
- }>;
64
+ * geo_blocked, anything else non-2xx/network maps to unreachable.
65
+ *
66
+ * ★ Self-stall detection (issue #265): the failure path is wall-clock timed.
67
+ * A throw is only evidence about the VENUE if our own abort timer fired
68
+ * roughly when it was set for. On a starved host the loop stops running —
69
+ * observed live on the HL rig: this 10s timer landed 86s late while the
70
+ * agent was trading on HL perfectly well — and blaming the network then
71
+ * produces a confident, wrong "check DNS/firewall/region" banner. Past
72
+ * STALL_FACTOR× the budget we report `stalled`, which the reporter renders
73
+ * as `unknown`: we genuinely did not learn whether HL is reachable. */
74
+ probeReachability(): Promise<VenueReachabilityResult>;
68
75
  /** `meta` — the asset universe (szDecimals, maxLeverage, positional assetIndex).
69
76
  * Keyless info read, weight 20. Feeds HyperliquidInfoCache. Returns null on any
70
77
  * failure (never a partial universe — a half-loaded rules table would silently
@@ -32,6 +32,7 @@
32
32
  // on this venue — logged clearly, null returned (never silently translated).
33
33
  import { createRequire } from 'node:module';
34
34
  import { logger } from '../../logger.js';
35
+ import { REACHABILITY_STALL_FACTOR } from '@reefclaw/shared';
35
36
  import { toCcxtSymbol, toHyperliquidCoin } from '../symbols.js';
36
37
  const TAG = 'hl-public';
37
38
  // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
@@ -55,6 +56,10 @@ const DEFAULT_TICKER_TTL_MS = 4_000;
55
56
  * snapshot's ccxt call re-runs the whole fetchMarkets pipeline (weight ~60,
56
57
  * ~13s on a slow host) and nothing price-critical reads these fields. */
57
58
  const FULL_SNAPSHOT_REFRESH_MS = 300_000;
59
+ /** Reachability-probe request budget. Also the yardstick for self-stall
60
+ * detection — see probeReachability. */
61
+ const PROBE_TIMEOUT_MS = 10_000;
62
+ const STALL_FACTOR = REACHABILITY_STALL_FACTOR;
58
63
  function resolveTickerTtlMs() {
59
64
  const raw = Number(process.env.RC_HL_TICKER_TTL_MS);
60
65
  if (!Number.isFinite(raw) || raw < 500)
@@ -343,10 +348,20 @@ export class HyperliquidPublicApi {
343
348
  * (verified live 2026-07-11: `{"specialStatuses":null,"time":…}`) doubles
344
349
  * as the clock-drift source. Geo classification is best-effort — HL's
345
350
  * API-level geo behavior is UNVERIFIED (plan §3.9); a 403/451 maps to
346
- * geo_blocked, anything else non-2xx/network maps to unreachable. */
351
+ * geo_blocked, anything else non-2xx/network maps to unreachable.
352
+ *
353
+ * ★ Self-stall detection (issue #265): the failure path is wall-clock timed.
354
+ * A throw is only evidence about the VENUE if our own abort timer fired
355
+ * roughly when it was set for. On a starved host the loop stops running —
356
+ * observed live on the HL rig: this 10s timer landed 86s late while the
357
+ * agent was trading on HL perfectly well — and blaming the network then
358
+ * produces a confident, wrong "check DNS/firewall/region" banner. Past
359
+ * STALL_FACTOR× the budget we report `stalled`, which the reporter renders
360
+ * as `unknown`: we genuinely did not learn whether HL is reachable. */
347
361
  async probeReachability() {
348
362
  const ac = new AbortController();
349
- const tid = setTimeout(() => ac.abort(), 10_000);
363
+ const tid = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
364
+ const startedAt = Date.now();
350
365
  try {
351
366
  const res = await this.fetchImpl(`${this.baseUrl()}/info`, {
352
367
  method: 'POST',
@@ -368,7 +383,13 @@ export class HyperliquidPublicApi {
368
383
  return { outcome: 'reachable', driftMs };
369
384
  }
370
385
  catch (err) {
371
- logger.warn(TAG, `probeReachability failed: ${err instanceof Error ? err.message : String(err)}`);
386
+ const elapsedMs = Date.now() - startedAt;
387
+ const msg = err instanceof Error ? err.message : String(err);
388
+ if (elapsedMs > PROBE_TIMEOUT_MS * STALL_FACTOR) {
389
+ logger.warn(TAG, `probeReachability inconclusive: this process was starved — a ${PROBE_TIMEOUT_MS}ms probe took ${Math.round(elapsedMs / 1000)}s (${msg}). Reporting reachability as unknown, NOT as a Hyperliquid failure.`);
390
+ return { outcome: 'stalled', driftMs: null, stallMs: elapsedMs };
391
+ }
392
+ logger.warn(TAG, `probeReachability failed: ${msg}`);
372
393
  return { outcome: 'unreachable', driftMs: null };
373
394
  }
374
395
  finally {
@@ -41,7 +41,19 @@ export interface HlOrderUpdateEvent {
41
41
  statusTimestamp: number;
42
42
  }
43
43
  export interface HlUserStreamCallbacks {
44
- onFill: (fill: HlFillEvent) => void;
44
+ /** ★ `meta.isSnapshot` marks a fill from the subscription SNAPSHOT — the batch
45
+ * of recent history HL sends on every (re)subscribe — not a live execution.
46
+ *
47
+ * Do NOT confuse this with the T-5 gap rule below: HL not replaying the fills
48
+ * you MISSED while disconnected is true and unrelated. It still ships a
49
+ * snapshot of recent history on subscribe, and consumers must tell them apart.
50
+ * Idempotent consumers (the audit-trail ingest, keyed on exchange trade id)
51
+ * should take snapshot fills; state MUTATORS (bracket resize/attach) must not
52
+ * — replaying an old fill's `startPosition + sz` resizes live protective legs
53
+ * to a stale historical size (audit 2026-07-27 F44). */
54
+ onFill: (fill: HlFillEvent, meta: {
55
+ isSnapshot: boolean;
56
+ }) => void;
45
57
  onOrderUpdate: (update: HlOrderUpdateEvent) => void;
46
58
  /** userEvents: liquidation / funding / non-user-cancel — the close-bypass feed. */
47
59
  onUserEvent: (event: Record<string, unknown>) => void;
@@ -137,9 +137,12 @@ export class HyperliquidUserStream {
137
137
  switch (channel) {
138
138
  case 'userFills': {
139
139
  const payload = data;
140
+ // `isSnapshot` was typed here from the start but never read — so every
141
+ // (re)connect replayed recent history as if it were live (F44).
142
+ const isSnapshot = payload?.isSnapshot === true;
140
143
  for (const fill of payload?.fills ?? []) {
141
144
  this.lastEventAt = Math.max(this.lastEventAt, fill.time ?? Date.now());
142
- this.opts.callbacks.onFill(fill);
145
+ this.opts.callbacks.onFill(fill, { isSnapshot });
143
146
  }
144
147
  break;
145
148
  }