@reefclaw/openclaw-plugin 0.1.12 → 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.
- package/bridge/gateway/event-parser.d.ts +19 -0
- package/bridge/gateway/event-parser.js +52 -0
- package/bridge/gateway/heartbeat-cron.d.ts +1 -0
- package/bridge/gateway/heartbeat-cron.js +22 -1
- package/bridge/providers/gateway.d.ts +49 -0
- package/bridge/providers/gateway.js +162 -12
- package/bridge/types.d.ts +30 -0
- package/bridge/utils/identity-name.d.ts +24 -0
- package/bridge/utils/identity-name.js +54 -0
- package/ccxt/binance-public.d.ts +4 -2
- package/ccxt/binance-public.js +39 -3
- package/live/proposal-decision-listener.d.ts +20 -0
- package/live/proposal-decision-listener.js +211 -48
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/tools/create-order.d.ts +11 -0
- package/tools/create-order.js +23 -2
- package/tools/get-risk-summary.d.ts +4 -0
- package/tools/get-risk-summary.js +62 -23
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +4 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +2 -2
- package/venues/hyperliquid/hl-live-adapter.d.ts +4 -0
- package/venues/hyperliquid/hl-live-adapter.js +43 -5
- package/venues/hyperliquid/hl-order.d.ts +35 -0
- package/venues/hyperliquid/hl-order.js +123 -0
- package/venues/hyperliquid/hl-position.d.ts +36 -0
- package/venues/hyperliquid/hl-position.js +127 -0
- package/venues/hyperliquid/hl-private.d.ts +20 -3
- package/venues/hyperliquid/hl-private.js +37 -6
|
@@ -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
|
|
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 +=
|
|
75
|
+
totalUnrealizedPnl += unrealizedPnl ?? 0;
|
|
38
76
|
return {
|
|
39
77
|
symbol: pos.symbol,
|
|
40
78
|
side: pos.side,
|
|
41
|
-
quantity
|
|
42
|
-
entryPrice:
|
|
43
|
-
markPrice:
|
|
44
|
-
notional:
|
|
45
|
-
unrealizedPnl:
|
|
46
|
-
portfolioPercent: totalEquity > 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:
|
|
80
|
-
worstSlippageBps:
|
|
81
|
-
totalFeesPaid:
|
|
82
|
-
avgLatencyMs:
|
|
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:
|
|
93
|
-
worstDeltaBps:
|
|
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:
|
|
102
|
-
availableBalance:
|
|
103
|
-
lockedBalance:
|
|
104
|
-
grossExposure:
|
|
105
|
-
grossExposurePercent:
|
|
106
|
-
netExposure:
|
|
107
|
-
netExposurePercent:
|
|
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:
|
|
110
|
-
unrealizedPnlPercent:
|
|
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,6 +76,10 @@ 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>;
|
|
@@ -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';
|
|
@@ -420,6 +420,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
420
420
|
* ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
|
|
421
421
|
* against an unknown book could open a NEW position in the opposite direction);
|
|
422
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.
|
|
423
427
|
*/
|
|
424
428
|
async closePosition(symbol, _closeReason) {
|
|
425
429
|
const positions = await this.api.fetchPositions(symbol);
|
|
@@ -456,10 +460,34 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
456
460
|
});
|
|
457
461
|
if (!order)
|
|
458
462
|
throw new Error(`closePosition(${symbol}) returned no order`);
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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.
|
|
463
491
|
const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
|
|
464
492
|
if (row && !isTerminalBracketState(row.state)) {
|
|
465
493
|
void this.getHlBracketCoordinator()
|
|
@@ -608,6 +636,16 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
608
636
|
// added size stays naked until the caller retries (truth-check sweep).
|
|
609
637
|
throw new Error(`resizeBrackets(${args.symbol}): submission returned nothing — old legs left in place`);
|
|
610
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
|
+
}
|
|
611
649
|
for (const cloid of plan.cancelCloids) {
|
|
612
650
|
// Register BEFORE the request: the WS 'canceled' event can beat the
|
|
613
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
|
+
}
|
|
@@ -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
|