@reefclaw/connect 0.1.19 → 0.1.21
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/assets/bridge/gateway/event-parser.d.ts +19 -0
- package/assets/bridge/gateway/event-parser.js +52 -0
- package/assets/bridge/gateway/gateway-config.d.ts +16 -5
- package/assets/bridge/gateway/gateway-config.js +68 -12
- package/assets/bridge/gateway/heartbeat-cron.d.ts +1 -0
- package/assets/bridge/gateway/heartbeat-cron.js +22 -1
- package/assets/bridge/gateway/poller.js +18 -8
- package/assets/bridge/providers/emergency-commands.d.ts +9 -1
- package/assets/bridge/providers/emergency-commands.js +38 -1
- package/assets/bridge/providers/gateway.d.ts +72 -1
- package/assets/bridge/providers/gateway.js +241 -29
- package/assets/bridge/providers/onboarding-commands.d.ts +8 -0
- package/assets/bridge/providers/onboarding-commands.js +4 -4
- package/assets/bridge/types.d.ts +30 -0
- package/assets/bridge/utils/identity-name.d.ts +24 -0
- package/assets/bridge/utils/identity-name.js +54 -0
- package/assets/plugin/ccxt/binance-public.d.ts +21 -7
- package/assets/plugin/ccxt/binance-public.js +70 -6
- package/assets/plugin/config/operator-provenance.d.ts +6 -0
- package/assets/plugin/config/operator-provenance.js +50 -0
- package/assets/plugin/config/plugin-config-io.d.ts +15 -1
- package/assets/plugin/config/plugin-config-io.js +24 -0
- package/assets/plugin/index.js +216 -173
- package/assets/plugin/ingest/event-loop-monitor.d.ts +11 -0
- package/assets/plugin/ingest/event-loop-monitor.js +113 -0
- package/assets/plugin/ingest/position-auto-capture.d.ts +5 -0
- package/assets/plugin/ingest/position-auto-capture.js +14 -5
- package/assets/plugin/ingest/readiness-reporter.d.ts +17 -6
- package/assets/plugin/ingest/readiness-reporter.js +88 -9
- package/assets/plugin/ingest/skill-version-reader.d.ts +16 -0
- package/assets/plugin/ingest/skill-version-reader.js +64 -0
- package/assets/plugin/live/approval-lifecycle.d.ts +30 -0
- package/assets/plugin/live/approval-lifecycle.js +80 -0
- package/assets/plugin/live/bracket-types.d.ts +9 -0
- package/assets/plugin/live/live-adapter.d.ts +0 -1
- package/assets/plugin/live/proposal-decision-listener.d.ts +20 -0
- package/assets/plugin/live/proposal-decision-listener.js +211 -48
- package/assets/plugin/onboarding/runtime.d.ts +34 -1
- package/assets/plugin/onboarding/runtime.js +56 -5
- package/assets/plugin/openclaw.plugin.json +1 -1
- package/assets/plugin/simulator/exchange-simulator.d.ts +45 -2
- package/assets/plugin/simulator/exchange-simulator.js +96 -4
- package/assets/plugin/simulator/types.d.ts +17 -0
- package/assets/plugin/tools/attach-brackets.js +50 -1
- package/assets/plugin/tools/create-order.d.ts +11 -0
- package/assets/plugin/tools/create-order.js +23 -2
- package/assets/plugin/tools/get-risk-summary.d.ts +4 -0
- package/assets/plugin/tools/get-risk-summary.js +62 -23
- package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
- package/assets/plugin/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
- package/assets/plugin/venues/hyperliquid/hl-brackets.d.ts +10 -0
- package/assets/plugin/venues/hyperliquid/hl-brackets.js +45 -13
- package/assets/plugin/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
- package/assets/plugin/venues/hyperliquid/hl-fill-ingest.js +69 -0
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.js +155 -12
- package/assets/plugin/venues/hyperliquid/hl-order.d.ts +35 -0
- package/assets/plugin/venues/hyperliquid/hl-order.js +123 -0
- package/assets/plugin/venues/hyperliquid/hl-position.d.ts +36 -0
- package/assets/plugin/venues/hyperliquid/hl-position.js +127 -0
- package/assets/plugin/venues/hyperliquid/hl-private.d.ts +20 -3
- package/assets/plugin/venues/hyperliquid/hl-private.js +37 -6
- package/assets/plugin/venues/hyperliquid/hl-public.d.ts +12 -5
- package/assets/plugin/venues/hyperliquid/hl-public.js +24 -3
- package/assets/plugin/venues/hyperliquid/hl-user-stream.d.ts +13 -1
- package/assets/plugin/venues/hyperliquid/hl-user-stream.js +4 -1
- package/assets/shared/index.d.ts +3 -3
- package/assets/shared/index.js +2 -2
- package/assets/shared/readiness.d.ts +38 -2
- package/assets/shared/readiness.js +40 -8
- package/dist/cli.js +31 -10
- package/dist/gateway-tuning.js +112 -0
- package/package.json +1 -1
- package/assets/shared/signals/indicators-extended.d.ts +0 -52
- package/assets/shared/signals/indicators-extended.js +0 -284
- package/assets/shared/signals/indicators.d.ts +0 -15
- package/assets/shared/signals/indicators.js +0 -107
|
@@ -84,6 +84,11 @@ export function bracketCoversPosition(args) {
|
|
|
84
84
|
const legs = args.liveLegs.filter((l) => l.role === role);
|
|
85
85
|
if (legs.length === 0) {
|
|
86
86
|
missing.push(role);
|
|
87
|
+
// A role with no leg at all leaves the WHOLE position uncovered on that
|
|
88
|
+
// side — report the honest naked size. Leaving this at 0 was audit
|
|
89
|
+
// 2026-07-26 F4: the resize planner keyed on shortfall, saw zero, and
|
|
90
|
+
// no-oped forever while a stop-less position stayed naked.
|
|
91
|
+
shortfall[role] = args.positionSize;
|
|
87
92
|
continue;
|
|
88
93
|
}
|
|
89
94
|
const coveredSize = legs.reduce((sum, l) => sum + l.size, 0);
|
|
@@ -91,12 +96,15 @@ export function bracketCoversPosition(args) {
|
|
|
91
96
|
if (gap > COVERAGE_EPSILON)
|
|
92
97
|
shortfall[role] = gap;
|
|
93
98
|
}
|
|
94
|
-
const covered =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
const covered =
|
|
100
|
+
// A missing STOP is never "covered" — that is the naked-position case
|
|
101
|
+
// (its shortfall is the full position size, so this arm also fails).
|
|
102
|
+
!missing.includes('stop') &&
|
|
103
|
+
shortfall.stop <= COVERAGE_EPSILON &&
|
|
104
|
+
// A missing TARGET is a policy choice (requireTakeProfit can be off): it
|
|
105
|
+
// is reported via `missing` + `shortfall` but does not by itself fail
|
|
106
|
+
// coverage. A PRESENT-but-undersized target still does.
|
|
107
|
+
(missing.includes('target') || shortfall.target <= COVERAGE_EPSILON);
|
|
100
108
|
return { covered, shortfall, missing };
|
|
101
109
|
}
|
|
102
110
|
/** ★ Resize protective legs after a scale-in (or any position-size change).
|
|
@@ -120,20 +128,44 @@ export function planResize(args) {
|
|
|
120
128
|
positionSize: args.positionSize,
|
|
121
129
|
liveLegs: args.liveLegs,
|
|
122
130
|
});
|
|
123
|
-
|
|
131
|
+
const healable = ['stop', 'target'].filter((role) => {
|
|
132
|
+
const price = args.registeredPrices?.[role];
|
|
133
|
+
return (coverage.missing.includes(role) &&
|
|
134
|
+
typeof price === 'number' && Number.isFinite(price) && price > 0);
|
|
135
|
+
});
|
|
136
|
+
// Nothing to do when every present leg already covers the position exactly
|
|
137
|
+
// and no vanished-but-registered leg needs rebuilding. (A missing role's
|
|
138
|
+
// shortfall is the full position size, so present-leg sizing is checked
|
|
139
|
+
// against the PRESENT legs only.)
|
|
140
|
+
const presentShortfall = ['stop', 'target'].some((role) => !coverage.missing.includes(role) && coverage.shortfall[role] > COVERAGE_EPSILON);
|
|
124
141
|
const oversized = args.liveLegs.some((l) => l.size - args.positionSize > COVERAGE_EPSILON);
|
|
125
|
-
if (
|
|
126
|
-
coverage.shortfall.target <= COVERAGE_EPSILON &&
|
|
127
|
-
!oversized) {
|
|
142
|
+
if (!presentShortfall && !oversized && healable.length === 0) {
|
|
128
143
|
return { cancelCloids: [], submit: [], noop: true };
|
|
129
144
|
}
|
|
130
|
-
// Rebuild every PRESENT role at the correct size
|
|
145
|
+
// Rebuild every PRESENT role at the correct size (preserving its trigger
|
|
146
|
+
// price), plus every HEALABLE missing role at its registered price.
|
|
131
147
|
const legs = [];
|
|
132
148
|
const cancelCloids = [];
|
|
133
149
|
for (const role of ['stop', 'target']) {
|
|
134
150
|
const existing = args.liveLegs.filter((l) => l.role === role);
|
|
135
|
-
if (existing.length === 0)
|
|
136
|
-
|
|
151
|
+
if (existing.length === 0) {
|
|
152
|
+
if (!healable.includes(role))
|
|
153
|
+
continue; // no leg, no registered price — coverage alarms own it
|
|
154
|
+
legs.push({
|
|
155
|
+
role,
|
|
156
|
+
triggerPrice: args.registeredPrices[role],
|
|
157
|
+
size: args.positionSize,
|
|
158
|
+
cloid: buildHlBracketCloid(args.bracketId, role),
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// A present role that already covers exactly (and isn't oversized) is left
|
|
163
|
+
// untouched — a heal of the OTHER role must not churn a healthy leg.
|
|
164
|
+
const coveredSize = existing.reduce((sum, l) => sum + l.size, 0);
|
|
165
|
+
const roleHealthy = args.positionSize - coveredSize <= COVERAGE_EPSILON &&
|
|
166
|
+
!existing.some((l) => l.size - args.positionSize > COVERAGE_EPSILON);
|
|
167
|
+
if (roleHealthy)
|
|
168
|
+
continue;
|
|
137
169
|
// Trigger price is preserved from the live leg (the agent's pinned plan) —
|
|
138
170
|
// resizing must NEVER silently move a stop.
|
|
139
171
|
const triggerPrice = existing[0].triggerPrice;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FillEvent } from '@reefclaw/shared';
|
|
2
|
+
import type { HlFillEvent } from './hl-user-stream.js';
|
|
3
|
+
/** Inverse of shared `toHyperliquidCoin`: every HL perp is USDC-quoted, so the
|
|
4
|
+
* canonical symbol is `<coin>/USDC` with the coin's case preserved (kPEPE). */
|
|
5
|
+
export declare function hlCoinToCanonical(coin: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Map one HL fill to the shared FillEvent contract.
|
|
8
|
+
*
|
|
9
|
+
* Returns null (with a warn) when the identity fields the audit trail's
|
|
10
|
+
* idempotency key needs are absent or garbled — a row we cannot key must be
|
|
11
|
+
* dropped, never fabricated. `exchange` MUST come from
|
|
12
|
+
* `fillExchangeId('hyperliquid')` (FILL_EXCHANGE_ID strings are frozen once
|
|
13
|
+
* rows exist) — this mapper trusts the wiring, it does not mint literals.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hlFillToFillEvent(fill: Partial<HlFillEvent>, wiring: {
|
|
16
|
+
userId: string;
|
|
17
|
+
exchange: string;
|
|
18
|
+
}, source: 'ws' | 'rest_reconcile'): FillEvent | null;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Per-fill audit-trail ingest for the Hyperliquid venue (audit 2026-07-26 F26).
|
|
2
|
+
//
|
|
3
|
+
// Binance fills reach the `trades` table via ws-ingest (ORDER_TRADE_UPDATE →
|
|
4
|
+
// FillEvent → TradeStoreClient). HL's user stream had no equivalent: fills
|
|
5
|
+
// drove bracket wiring and were then DROPPED — a live book and P&L with no
|
|
6
|
+
// independent per-trade record, nothing to reconcile against when a number
|
|
7
|
+
// looks wrong, and fills during a WS gap lost forever. This module is the HL
|
|
8
|
+
// analog: a pure mapper from the WS fill shape (also satisfied by REST
|
|
9
|
+
// `userFillsByTime` rows) to the shared FillEvent contract. The adapter calls
|
|
10
|
+
// it from the WS hot path and from the reconnect gap backfill; idempotency is
|
|
11
|
+
// the server-side (exchange, exchange_trade_id) upsert, so double-delivery
|
|
12
|
+
// between the two paths is harmless by design.
|
|
13
|
+
import { logger } from '../../logger.js';
|
|
14
|
+
const TAG = 'hl-fill-ingest';
|
|
15
|
+
/** Inverse of shared `toHyperliquidCoin`: every HL perp is USDC-quoted, so the
|
|
16
|
+
* canonical symbol is `<coin>/USDC` with the coin's case preserved (kPEPE). */
|
|
17
|
+
export function hlCoinToCanonical(coin) {
|
|
18
|
+
return `${coin}/USDC`;
|
|
19
|
+
}
|
|
20
|
+
const num = (v) => {
|
|
21
|
+
const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : Number.NaN;
|
|
22
|
+
return Number.isFinite(n) ? n : undefined;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Map one HL fill to the shared FillEvent contract.
|
|
26
|
+
*
|
|
27
|
+
* Returns null (with a warn) when the identity fields the audit trail's
|
|
28
|
+
* idempotency key needs are absent or garbled — a row we cannot key must be
|
|
29
|
+
* dropped, never fabricated. `exchange` MUST come from
|
|
30
|
+
* `fillExchangeId('hyperliquid')` (FILL_EXCHANGE_ID strings are frozen once
|
|
31
|
+
* rows exist) — this mapper trusts the wiring, it does not mint literals.
|
|
32
|
+
*/
|
|
33
|
+
export function hlFillToFillEvent(fill, wiring, source) {
|
|
34
|
+
const price = num(fill.px);
|
|
35
|
+
const quantity = num(fill.sz);
|
|
36
|
+
const time = num(fill.time);
|
|
37
|
+
if (typeof fill.tid !== 'number' || !Number.isFinite(fill.tid) ||
|
|
38
|
+
typeof fill.oid !== 'number' || !Number.isFinite(fill.oid) ||
|
|
39
|
+
typeof fill.coin !== 'string' || fill.coin.length === 0 ||
|
|
40
|
+
price === undefined || quantity === undefined || quantity <= 0 ||
|
|
41
|
+
time === undefined ||
|
|
42
|
+
(fill.side !== 'A' && fill.side !== 'B')) {
|
|
43
|
+
logger.warn(TAG, `Dropping unmappable HL fill (source=${source}): ${JSON.stringify(fill).slice(0, 200)}`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
exchange: wiring.exchange,
|
|
48
|
+
exchangeTradeId: String(fill.tid),
|
|
49
|
+
exchangeOrderId: String(fill.oid),
|
|
50
|
+
clientOrderId: typeof fill.cloid === 'string' && fill.cloid.length > 0 ? fill.cloid : undefined,
|
|
51
|
+
source,
|
|
52
|
+
userId: wiring.userId,
|
|
53
|
+
symbol: hlCoinToCanonical(fill.coin),
|
|
54
|
+
side: fill.side === 'B' ? 'BUY' : 'SELL',
|
|
55
|
+
quantity,
|
|
56
|
+
price,
|
|
57
|
+
// HL `crossed` is the taker flag; maker is its inverse. Absent → unknown.
|
|
58
|
+
maker: typeof fill.crossed === 'boolean' ? !fill.crossed : undefined,
|
|
59
|
+
// `fee` is inclusive of builderFee, and we hard-disable the builder fee
|
|
60
|
+
// (options.builderFee:false, pinned by hl-private.test.ts) so this is the
|
|
61
|
+
// whole commission either way. HL fees settle in USDC unless feeToken says
|
|
62
|
+
// otherwise.
|
|
63
|
+
commission: num(fill.fee),
|
|
64
|
+
commissionAsset: typeof fill.feeToken === 'string' ? fill.feeToken : 'USDC',
|
|
65
|
+
realizedPnl: num(fill.closedPnl),
|
|
66
|
+
exchangeTime: time,
|
|
67
|
+
rawPayload: fill,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -6,10 +6,23 @@ import { type HlCredentials } from './hl-private.js';
|
|
|
6
6
|
import type { BracketId } from '../../live/bracket-types.js';
|
|
7
7
|
import { BracketLedger } from '../../live/bracket-ledger.js';
|
|
8
8
|
import { HlBracketCoordinator } from './hl-bracket-coordinator.js';
|
|
9
|
+
import type { TradeIngestWiring } from '../../live/live-adapter.js';
|
|
9
10
|
export interface HlLiveAdapterOptions {
|
|
10
11
|
credentials: HlCredentials;
|
|
11
12
|
mode: TradingMode;
|
|
12
13
|
marketSlippagePct?: number;
|
|
14
|
+
/** MICRO_LIVE per-order notional cap in quote-USD (USDC here). Defaults to
|
|
15
|
+
* $50 in MICRO_LIVE — the same default Binance's LiveAdapter has enforced
|
|
16
|
+
* since the mode existed. HL shipped Phase 3 without any cap, making
|
|
17
|
+
* "micro" a label (audit 2026-07-26 F8). Ignored in LIVE. */
|
|
18
|
+
microLive?: {
|
|
19
|
+
maxPositionUSDT?: number;
|
|
20
|
+
};
|
|
21
|
+
/** Audit-trail wiring (audit 2026-07-26 F26): when present, every user-stream
|
|
22
|
+
* fill is POSTed to /api/internal/trades and reconnect gaps are backfilled
|
|
23
|
+
* via userFillsByTime. Same object boot passes the Binance adapter —
|
|
24
|
+
* `exchange` MUST be fillExchangeId('hyperliquid'). */
|
|
25
|
+
tradeIngest?: TradeIngestWiring;
|
|
13
26
|
/** Test seams. Production omits both. */
|
|
14
27
|
bracketLedger?: BracketLedger;
|
|
15
28
|
disableUserStream?: boolean;
|
|
@@ -30,6 +43,13 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
30
43
|
private truthCheckRunning;
|
|
31
44
|
private _readiness;
|
|
32
45
|
private openOrdersUnavailableUntil;
|
|
46
|
+
/** MICRO_LIVE per-order notional cap (USD). null = LIVE, uncapped. */
|
|
47
|
+
private readonly maxPositionUsd;
|
|
48
|
+
/** Resolved audit-trail wiring (F26). undefined = ingest not configured. */
|
|
49
|
+
private readonly fillIngest?;
|
|
50
|
+
/** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
|
|
51
|
+
* low-water mark the reconnect gap backfill widens from. */
|
|
52
|
+
private lastFillIngestMs;
|
|
33
53
|
/** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
|
|
34
54
|
* income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
|
|
35
55
|
* each balance fetch. Without this the skill self-computes a bogus anchor and
|
|
@@ -76,6 +96,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
76
96
|
* ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
|
|
77
97
|
* against an unknown book could open a NEW position in the opposite direction);
|
|
78
98
|
* a CONFIRMED-flat account is a benign no-op.
|
|
99
|
+
*
|
|
100
|
+
* ★ Success requires CONFIRMED flat: brackets/ledger are only cleaned up after
|
|
101
|
+
* a post-close position read shows zero — a rejected/partial IOC throws with
|
|
102
|
+
* protection left in place.
|
|
79
103
|
*/
|
|
80
104
|
closePosition(symbol: string, _closeReason?: CloseReason): Promise<CcxtOrder>;
|
|
81
105
|
getBalance(): Promise<CcxtBalance>;
|
|
@@ -128,6 +152,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
128
152
|
symbol: string;
|
|
129
153
|
positionSide: 'long' | 'short';
|
|
130
154
|
positionSize: number;
|
|
155
|
+
registeredPrices?: {
|
|
156
|
+
stop?: number;
|
|
157
|
+
target?: number;
|
|
158
|
+
};
|
|
131
159
|
}): Promise<{
|
|
132
160
|
resized: boolean;
|
|
133
161
|
slCid?: string;
|
|
@@ -143,6 +171,14 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
143
171
|
* cleanup before a fresh attach). THROWS when order state is unknown —
|
|
144
172
|
* the caller logs and still attaches (protection beats hygiene). */
|
|
145
173
|
cancelSymbolBracketLegs(symbol: string): Promise<number>;
|
|
174
|
+
/** Audit-trail ingest (F26): fire-and-forget POST of one fill. The client
|
|
175
|
+
* never blocks the WS hot path; the server-side (exchange, trade id) upsert
|
|
176
|
+
* makes WS/backfill double-delivery a no-op. */
|
|
177
|
+
private ingestFill;
|
|
178
|
+
/** Reconnect gap backfill (F26): the WS replays NOTHING (T-5), so fills that
|
|
179
|
+
* landed while the socket was down exist ONLY via REST. Over-fetch with a
|
|
180
|
+
* 60s overlap is harmless (idempotent upsert); under-fetch loses audit rows. */
|
|
181
|
+
private backfillFillGap;
|
|
146
182
|
/** Entry fills drive attach (resting limits) / resize (partial-fill growth).
|
|
147
183
|
* `startPosition` is the position BEFORE this fill — the WS-authoritative
|
|
148
184
|
* way to know the after-fill total without an extra REST read. */
|
|
@@ -36,8 +36,9 @@ 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
|
+
import { hlFillToFillEvent } from './hl-fill-ingest.js';
|
|
41
42
|
import { formatError } from '../../logger.js';
|
|
42
43
|
const TAG = 'hl-live-adapter';
|
|
43
44
|
/** Venue-distinct ledger storage — a venue switch on the same box must never
|
|
@@ -70,6 +71,13 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
70
71
|
truthCheckRunning = false;
|
|
71
72
|
_readiness = 'INIT_PENDING';
|
|
72
73
|
openOrdersUnavailableUntil = 0;
|
|
74
|
+
/** MICRO_LIVE per-order notional cap (USD). null = LIVE, uncapped. */
|
|
75
|
+
maxPositionUsd;
|
|
76
|
+
/** Resolved audit-trail wiring (F26). undefined = ingest not configured. */
|
|
77
|
+
fillIngest;
|
|
78
|
+
/** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
|
|
79
|
+
* low-water mark the reconnect gap backfill widens from. */
|
|
80
|
+
lastFillIngestMs = 0;
|
|
73
81
|
/** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
|
|
74
82
|
* income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
|
|
75
83
|
* each balance fetch. Without this the skill self-computes a bogus anchor and
|
|
@@ -81,6 +89,23 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
81
89
|
this.api = new HyperliquidPrivateApi(opts.credentials);
|
|
82
90
|
this.publicApi = new HyperliquidPublicApi({ testnet: opts.credentials.testnet });
|
|
83
91
|
this.slippagePct = clampSlippage(opts.marketSlippagePct ?? DEFAULT_MARKET_SLIPPAGE);
|
|
92
|
+
this.maxPositionUsd =
|
|
93
|
+
opts.mode === 'MICRO_LIVE' ? opts.microLive?.maxPositionUSDT ?? 50 : null;
|
|
94
|
+
if (opts.tradeIngest) {
|
|
95
|
+
if (opts.tradeIngest.exchange) {
|
|
96
|
+
this.fillIngest = {
|
|
97
|
+
client: opts.tradeIngest.client,
|
|
98
|
+
userId: opts.tradeIngest.userId,
|
|
99
|
+
exchange: opts.tradeIngest.exchange,
|
|
100
|
+
};
|
|
101
|
+
logger.info(TAG, 'Per-fill audit-trail ingest wired (WS + reconnect gap backfill)');
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// FILL_EXCHANGE_ID strings are frozen once rows exist — refusing beats
|
|
105
|
+
// minting rows under a guessed exchange id.
|
|
106
|
+
logger.warn(TAG, 'tradeIngest provided WITHOUT an exchange id — fill ingest disabled');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
84
109
|
this.infoCache = new HyperliquidInfoCache(async () => {
|
|
85
110
|
// `meta` is a keyless info read — the public client owns it.
|
|
86
111
|
return this.publicApi.fetchMeta();
|
|
@@ -109,7 +134,8 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
109
134
|
const rules = await this.infoCache.load();
|
|
110
135
|
if (markets && rules && this.infoCache.size > 0) {
|
|
111
136
|
this._readiness = 'READY';
|
|
112
|
-
logger.info(TAG, `HL live adapter READY (${this.infoCache.size} assets, mode=${this.opts.mode}
|
|
137
|
+
logger.info(TAG, `HL live adapter READY (${this.infoCache.size} assets, mode=${this.opts.mode}` +
|
|
138
|
+
`${this.maxPositionUsd != null ? `, micro cap $${this.maxPositionUsd}/order` : ''})`);
|
|
113
139
|
}
|
|
114
140
|
else {
|
|
115
141
|
// DEGRADED, not BLOCKED: reads/exits still work; entries are refused
|
|
@@ -135,13 +161,17 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
135
161
|
walletAddress: this.opts.credentials.walletAddress,
|
|
136
162
|
testnet: this.opts.credentials.testnet,
|
|
137
163
|
callbacks: {
|
|
138
|
-
onFill: (fill) => this.onUserFill(fill),
|
|
164
|
+
onFill: (fill, meta) => this.onUserFill(fill, meta),
|
|
139
165
|
onOrderUpdate: (update) => this.onUserOrderUpdate(update),
|
|
140
166
|
onUserEvent: () => {
|
|
141
167
|
/* liquidation/funding — liquidation fills also arrive via onFill */
|
|
142
168
|
},
|
|
143
169
|
onResyncNeeded: (window) => {
|
|
144
170
|
void this.runTruthCheck(`ws_resync_blind_${Math.round(window.wasDisconnectedMs / 1000)}s`);
|
|
171
|
+
// Fills that landed inside the blind window exist only via REST
|
|
172
|
+
// (T-5: the WS replays nothing) — recover them for the audit
|
|
173
|
+
// trail (F26). Bracket state is healed by the truth-check above.
|
|
174
|
+
void this.backfillFillGap(window.sinceMs).catch((err) => logger.warn(TAG, `Fill gap backfill threw: ${msg(err)}`));
|
|
145
175
|
},
|
|
146
176
|
},
|
|
147
177
|
});
|
|
@@ -183,6 +213,21 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
183
213
|
}
|
|
184
214
|
referencePrice = mark;
|
|
185
215
|
}
|
|
216
|
+
// ---- Micro-live notional cap (audit 2026-07-26 F8) ----
|
|
217
|
+
// Same semantics as Binance's LiveAdapter: clamp NEW exposure to the cap;
|
|
218
|
+
// never touch risk-reducing orders (blocking a close is worse than an
|
|
219
|
+
// uncapped close, and reduce-only cannot increase the position). A
|
|
220
|
+
// reference price is always in hand by this point — market orders fetched
|
|
221
|
+
// the mark above, limit orders carry their own price.
|
|
222
|
+
let effectiveAmount = amount;
|
|
223
|
+
if (this.maxPositionUsd != null && !options?.reduceOnly) {
|
|
224
|
+
const maxAmount = this.maxPositionUsd / referencePrice;
|
|
225
|
+
if (effectiveAmount > maxAmount) {
|
|
226
|
+
logger.info(TAG, `Micro-live cap: reducing ${symbol} amount ${effectiveAmount} → ${maxAmount} ` +
|
|
227
|
+
`(max $${this.maxPositionUsd} notional at ref ${referencePrice})`);
|
|
228
|
+
effectiveAmount = maxAmount;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
186
231
|
const submitPrice = type === 'market'
|
|
187
232
|
? this.infoCache.marketPrice({
|
|
188
233
|
symbol,
|
|
@@ -193,7 +238,7 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
193
238
|
: this.infoCache.roundPrice(symbol, referencePrice, side === 'buy' ? 'down' : 'up');
|
|
194
239
|
const check = this.infoCache.validate({
|
|
195
240
|
symbol,
|
|
196
|
-
amount,
|
|
241
|
+
amount: effectiveAmount,
|
|
197
242
|
side,
|
|
198
243
|
price: type === 'limit' ? submitPrice : undefined,
|
|
199
244
|
referencePrice,
|
|
@@ -303,6 +348,13 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
303
348
|
const ledger = this.getHlBracketCoordinator().getLedger();
|
|
304
349
|
const existing = ledger.getBySymbol(symbol);
|
|
305
350
|
if (existing && !isTerminalBracketState(existing.state) && existing.state !== 'pending_entry') {
|
|
351
|
+
// ★ F5: record THIS order's cloid against the live row FIRST. A scale-in
|
|
352
|
+
// submitted as a resting limit fills later, and `onUserFill` matches
|
|
353
|
+
// fills to rows by cid — without this its fill matched nothing, so no
|
|
354
|
+
// resize ever ran and the added contracts stayed NAKED (T-2: legs are
|
|
355
|
+
// fixed size) until the 60s truth-check sweep. Registered before the
|
|
356
|
+
// resize below so a fast fill can never race ahead of the bookkeeping.
|
|
357
|
+
this.getHlBracketCoordinator().registerAdditionalEntryCid(symbol, entryCloid);
|
|
306
358
|
if (isFilled) {
|
|
307
359
|
void this.resizeAfterScaleInAsync(symbol);
|
|
308
360
|
}
|
|
@@ -420,6 +472,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
420
472
|
* ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
|
|
421
473
|
* against an unknown book could open a NEW position in the opposite direction);
|
|
422
474
|
* a CONFIRMED-flat account is a benign no-op.
|
|
475
|
+
*
|
|
476
|
+
* ★ Success requires CONFIRMED flat: brackets/ledger are only cleaned up after
|
|
477
|
+
* a post-close position read shows zero — a rejected/partial IOC throws with
|
|
478
|
+
* protection left in place.
|
|
423
479
|
*/
|
|
424
480
|
async closePosition(symbol, _closeReason) {
|
|
425
481
|
const positions = await this.api.fetchPositions(symbol);
|
|
@@ -456,10 +512,34 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
456
512
|
});
|
|
457
513
|
if (!order)
|
|
458
514
|
throw new Error(`closePosition(${symbol}) returned no order`);
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
515
|
+
if (isRejectedOrder(order)) {
|
|
516
|
+
throw new Error(`closePosition(${symbol}): close order REJECTED by the exchange — position still open, ` +
|
|
517
|
+
'protection left in place. Retry the close.');
|
|
518
|
+
}
|
|
519
|
+
// ★ An IOC acknowledgement is NOT a fill: the slippage-bounded close can
|
|
520
|
+
// partially fill (or fill nothing) in a fast market. Confirm flat from
|
|
521
|
+
// exchange truth BEFORE touching protection — cancelling the brackets on a
|
|
522
|
+
// live residual would strip its only stop (no watcher fallback on HL live)
|
|
523
|
+
// AND terminalize the ledger row, hiding the residual from the truth-check
|
|
524
|
+
// sweep forever (audit 2026-07-26 F3).
|
|
525
|
+
const after = await this.api.fetchPositions(symbol);
|
|
526
|
+
if (after === null) {
|
|
527
|
+
logger.warn(TAG, `closePosition(${symbol}): post-close position read FAILED — flat UNCONFIRMED; leaving ` +
|
|
528
|
+
'protection + ledger row in place (T-1 auto-cancels legs on flat; the truth-check ' +
|
|
529
|
+
'sweep reconciles the row either way)');
|
|
530
|
+
return order;
|
|
531
|
+
}
|
|
532
|
+
const residualPos = after.find((p) => p.symbol?.startsWith(symbol.split(':')[0]));
|
|
533
|
+
const residual = Math.abs(Number(residualPos?.contracts ?? 0));
|
|
534
|
+
if (residual > 0) {
|
|
535
|
+
throw new Error(`closePosition(${symbol}): IOC close did NOT fully fill — residual ${residual} still open, ` +
|
|
536
|
+
'protection left in place (the truth-check sweep resizes the legs to the residual). ' +
|
|
537
|
+
'Retry the close.');
|
|
538
|
+
}
|
|
539
|
+
// Confirmed flat. Ledger hygiene: mark the bracket row terminal. T-1
|
|
540
|
+
// auto-cancels the exchange legs when the position goes flat, so this is
|
|
541
|
+
// bookkeeping (plus a harmless defensive cancel), never load-bearing.
|
|
542
|
+
// Fire-and-forget — a close must never fail on ledger cleanup.
|
|
463
543
|
const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
|
|
464
544
|
if (row && !isTerminalBracketState(row.state)) {
|
|
465
545
|
void this.getHlBracketCoordinator()
|
|
@@ -608,6 +688,16 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
608
688
|
// added size stays naked until the caller retries (truth-check sweep).
|
|
609
689
|
throw new Error(`resizeBrackets(${args.symbol}): submission returned nothing — old legs left in place`);
|
|
610
690
|
}
|
|
691
|
+
const rejected = submitted.filter((order) => isRejectedOrder(order));
|
|
692
|
+
if (submitted.length !== plan.submit.length || rejected.length > 0) {
|
|
693
|
+
// A batch-level acknowledgement does not mean every leg was accepted:
|
|
694
|
+
// Hyperliquid reports individual failures in each order's raw
|
|
695
|
+
// `info.error`. Keep every known-good old leg live and leave the ledger
|
|
696
|
+
// untouched so the truth-check sweep can retry with fresh cloids.
|
|
697
|
+
throw new Error(`resizeBrackets(${args.symbol}): batch returned ${submitted.length}/${plan.submit.length} legs` +
|
|
698
|
+
(rejected.length > 0 ? ` (${rejected.length} rejected)` : '') +
|
|
699
|
+
' — old legs left in place');
|
|
700
|
+
}
|
|
611
701
|
for (const cloid of plan.cancelCloids) {
|
|
612
702
|
// Register BEFORE the request: the WS 'canceled' event can beat the
|
|
613
703
|
// ledger update and must not read as stripped protection (canary).
|
|
@@ -671,14 +761,67 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
671
761
|
return cancelled;
|
|
672
762
|
}
|
|
673
763
|
// ---- User-stream handlers (issue #209 wiring) ----
|
|
764
|
+
/** Audit-trail ingest (F26): fire-and-forget POST of one fill. The client
|
|
765
|
+
* never blocks the WS hot path; the server-side (exchange, trade id) upsert
|
|
766
|
+
* makes WS/backfill double-delivery a no-op. */
|
|
767
|
+
ingestFill(fill, source) {
|
|
768
|
+
if (!this.fillIngest)
|
|
769
|
+
return false;
|
|
770
|
+
const event = hlFillToFillEvent(fill, this.fillIngest, source);
|
|
771
|
+
if (!event)
|
|
772
|
+
return false;
|
|
773
|
+
this.fillIngest.client.post(this.fillIngest.userId, event);
|
|
774
|
+
if (event.exchangeTime > this.lastFillIngestMs)
|
|
775
|
+
this.lastFillIngestMs = event.exchangeTime;
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
/** Reconnect gap backfill (F26): the WS replays NOTHING (T-5), so fills that
|
|
779
|
+
* landed while the socket was down exist ONLY via REST. Over-fetch with a
|
|
780
|
+
* 60s overlap is harmless (idempotent upsert); under-fetch loses audit rows. */
|
|
781
|
+
async backfillFillGap(sinceMs) {
|
|
782
|
+
if (!this.fillIngest)
|
|
783
|
+
return;
|
|
784
|
+
const floor = Date.now() - 24 * 3600_000; // never sweep more than a day
|
|
785
|
+
const from = Math.max(floor, Math.min(sinceMs, this.lastFillIngestMs > 0 ? this.lastFillIngestMs : Number.POSITIVE_INFINITY) - 60_000);
|
|
786
|
+
const fills = await this.api.fetchFillsSince(from);
|
|
787
|
+
if (fills === null) {
|
|
788
|
+
// null ≠ empty: the fetch FAILED — the gap stays open, the next resync
|
|
789
|
+
// (or the periodic truth-check path) retries. Never treat as "no fills".
|
|
790
|
+
logger.warn(TAG, `Fill gap backfill fetch FAILED (since=${new Date(from).toISOString()}) — retried on next resync`);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
let posted = 0;
|
|
794
|
+
for (const f of fills) {
|
|
795
|
+
if (this.ingestFill(f, 'rest_reconcile'))
|
|
796
|
+
posted++;
|
|
797
|
+
}
|
|
798
|
+
if (fills.length > 0) {
|
|
799
|
+
logger.info(TAG, `Fill gap backfill: posted ${posted}/${fills.length} fill(s) since ${new Date(from).toISOString()}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
674
802
|
/** Entry fills drive attach (resting limits) / resize (partial-fill growth).
|
|
675
803
|
* `startPosition` is the position BEFORE this fill — the WS-authoritative
|
|
676
804
|
* way to know the after-fill total without an extra REST read. */
|
|
677
|
-
onUserFill(fill) {
|
|
805
|
+
onUserFill(fill, meta) {
|
|
806
|
+
// Audit trail first — bracket bookkeeping below must not gate the record.
|
|
807
|
+
// Snapshot fills ARE ingested on purpose: the ledger is idempotent on
|
|
808
|
+
// (exchange, exchange_trade_id), so replayed history upserts harmlessly and
|
|
809
|
+
// backfills rows we'd otherwise miss (F26).
|
|
810
|
+
this.ingestFill(fill, 'ws');
|
|
811
|
+
// ★ F44: but bracket bookkeeping is a STATE MUTATION, not an idempotent
|
|
812
|
+
// write. Every (re)subscribe ships a snapshot of recent fills, and acting on
|
|
813
|
+
// one recomputes the position as that old fill's `startPosition + sz` — a
|
|
814
|
+
// stale total — then resizes the LIVE protective legs down to it. Observed
|
|
815
|
+
// on the rig 2026-07-27: ETH legs cut 0.027 → 0.0231 thirteen seconds after
|
|
816
|
+
// a user-stream connect, leaving 0.0039 unprotected until the 60s sweep
|
|
817
|
+
// healed it, in a repeating flap. Exchange truth is the resync's job.
|
|
818
|
+
if (meta?.isSnapshot)
|
|
819
|
+
return;
|
|
678
820
|
try {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
821
|
+
// Matches the primary entry cid OR any additional cid recorded for a
|
|
822
|
+
// scale-in / second resting entry (F5) — matching on `entryCid` alone
|
|
823
|
+
// silently dropped those fills.
|
|
824
|
+
const row = this.getHlBracketCoordinator().findRowByEntryCid(fill.cloid);
|
|
682
825
|
if (!row)
|
|
683
826
|
return;
|
|
684
827
|
const sz = Math.abs(Number(fill.sz ?? 0));
|
|
@@ -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;
|