@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.
- package/bridge/gateway/event-parser.d.ts +42 -0
- package/bridge/gateway/event-parser.js +88 -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 +167 -13
- package/bridge/types.d.ts +34 -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 +12 -0
- package/venues/hyperliquid/hl-live-adapter.js +69 -6
- 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
|
@@ -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
|
|
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,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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
+
}
|