@reefclaw/openclaw-plugin 0.1.5 → 0.1.7
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 +6 -1
- package/bridge/gateway/event-parser.js +19 -2
- package/bridge/gateway/poller.d.ts +1 -0
- package/bridge/gateway/poller.js +14 -2
- package/bridge/providers/gateway.d.ts +22 -2
- package/bridge/providers/gateway.js +67 -9
- package/ccxt/binance-private.d.ts +21 -0
- package/ccxt/binance-private.js +132 -22
- package/ccxt/public-market-data-api.d.ts +14 -0
- package/ccxt/public-market-data-api.js +15 -1
- package/config/plugin-config-io.d.ts +13 -0
- package/config/plugin-config-io.js +15 -0
- package/config/tool-gate.js +3 -0
- package/exchange-adapter.d.ts +16 -0
- package/index.js +658 -83
- package/ingest/position-auto-capture.d.ts +68 -0
- package/ingest/position-auto-capture.js +321 -23
- package/ingest/position-decisions-client.d.ts +7 -2
- package/ingest/position-decisions-client.js +13 -3
- package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
- package/ingest/reconcile-db-vs-exchange.js +66 -10
- package/lifecycle/trading-operation-lock.d.ts +17 -0
- package/lifecycle/trading-operation-lock.js +14 -0
- package/live/bracket-id.d.ts +2 -3
- package/live/bracket-id.js +22 -9
- package/live/fill-price.d.ts +13 -0
- package/live/fill-price.js +37 -0
- package/live/live-adapter.d.ts +57 -2
- package/live/live-adapter.js +290 -49
- package/live/local-signal-service.js +11 -6
- package/live/local-strategy-evaluator.js +4 -0
- package/live/position-state-store.d.ts +4 -0
- package/live/proposal-decision-listener.d.ts +6 -0
- package/live/proposal-decision-listener.js +4 -0
- package/live/stop-watcher.d.ts +35 -2
- package/live/stop-watcher.js +63 -3
- package/onboarding/runtime.d.ts +19 -0
- package/onboarding/runtime.js +34 -3
- package/openclaw.plugin.json +1 -0
- package/package.json +2 -2
- package/portfolio/reentry-tracker.d.ts +36 -0
- package/portfolio/reentry-tracker.js +127 -0
- package/portfolio/wave9-admission.d.ts +67 -0
- package/portfolio/wave9-admission.js +262 -0
- package/portfolio/wave9-policy.d.ts +36 -0
- package/portfolio/wave9-policy.js +183 -0
- package/signals/conditions/registry.js +61 -2
- package/signals/strategy-adapter.js +17 -7
- package/simulator/exchange-simulator.d.ts +12 -0
- package/simulator/exchange-simulator.js +78 -3
- package/simulator/fill-engine.js +5 -1
- package/simulator/types.d.ts +10 -1
- package/skills/reefclaw/SKILL.md +2 -0
- package/strategy/evaluator.d.ts +3 -0
- package/strategy/evaluator.js +5 -0
- package/tools/assessment-validation.d.ts +23 -0
- package/tools/assessment-validation.js +58 -0
- package/tools/attach-brackets.d.ts +7 -2
- package/tools/attach-brackets.js +201 -0
- package/tools/audit-bracket-protection.js +157 -1
- package/tools/bracket-control.d.ts +12 -0
- package/tools/bracket-control.js +35 -0
- package/tools/cancel-all-orders.d.ts +2 -0
- package/tools/cancel-all-orders.js +4 -1
- package/tools/cancel-order.d.ts +4 -0
- package/tools/cancel-order.js +49 -3
- package/tools/close-position.d.ts +23 -0
- package/tools/close-position.js +286 -13
- package/tools/create-order.d.ts +28 -0
- package/tools/create-order.js +1390 -190
- package/tools/get-analytics.js +2 -2
- package/tools/get-basis.js +2 -2
- package/tools/get-cascade-risk.js +2 -2
- package/tools/get-crypto-metrics.js +14 -4
- package/tools/get-cvd.js +2 -2
- package/tools/get-divergences.js +2 -2
- package/tools/get-funding-context.js +2 -2
- package/tools/get-liquidation-levels.js +2 -2
- package/tools/get-liquidation-pulse.js +2 -2
- package/tools/get-pattern-scan.js +2 -2
- package/tools/get-regime.js +2 -2
- package/tools/get-resting-liquidity.js +2 -2
- package/tools/get-risk-scenario.js +2 -2
- package/tools/get-session-review.js +2 -2
- package/tools/get-setup-detail.js +21 -2
- package/tools/get-signals.js +2 -2
- package/tools/get-sizing.js +2 -2
- package/tools/get-trade-feedback.js +2 -2
- package/tools/get-trade-flow.js +2 -2
- package/tools/get-volume-profile.js +2 -2
- package/tools/get-wave9-status.d.ts +127 -0
- package/tools/get-wave9-status.js +796 -0
- package/tools/intel-api.d.ts +20 -0
- package/tools/intel-api.js +67 -0
- package/tools/intel-cache.d.ts +1 -1
- package/tools/intel-cache.js +20 -5
- package/tools/list-strategies.d.ts +11 -1
- package/tools/list-strategies.js +17 -0
- package/tools/modify-stop.d.ts +4 -0
- package/tools/modify-stop.js +63 -24
- package/tools/modify-target.d.ts +4 -0
- package/tools/modify-target.js +62 -23
- package/tools/scan-pairs.d.ts +4 -0
- package/tools/scan-pairs.js +22 -8
- package/tools/toggle-strategy.js +7 -0
- package/types.d.ts +5 -0
- package/venues/hyperliquid/hl-balance.d.ts +116 -0
- package/venues/hyperliquid/hl-balance.js +145 -0
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
- package/venues/hyperliquid/hl-brackets.d.ts +102 -0
- package/venues/hyperliquid/hl-brackets.js +172 -0
- package/venues/hyperliquid/hl-cloid.d.ts +22 -0
- package/venues/hyperliquid/hl-cloid.js +82 -0
- package/venues/hyperliquid/hl-info-cache.d.ts +46 -0
- package/venues/hyperliquid/hl-info-cache.js +125 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +146 -0
- package/venues/hyperliquid/hl-live-adapter.js +728 -0
- package/venues/hyperliquid/hl-precision.d.ts +61 -0
- package/venues/hyperliquid/hl-precision.js +176 -0
- package/venues/hyperliquid/hl-private.d.ts +88 -0
- package/venues/hyperliquid/hl-private.js +357 -0
- package/venues/hyperliquid/hl-public.d.ts +31 -4
- package/venues/hyperliquid/hl-public.js +163 -12
- package/venues/hyperliquid/hl-rate-gate.d.ts +57 -0
- package/venues/hyperliquid/hl-rate-gate.js +220 -0
- package/venues/hyperliquid/hl-user-stream.d.ts +90 -0
- package/venues/hyperliquid/hl-user-stream.js +220 -0
- package/venues/registry.d.ts +23 -9
- package/venues/registry.js +12 -13
- package/venues/symbols.d.ts +43 -0
- package/venues/symbols.js +107 -0
- package/wave9/live-account-capture.d.ts +67 -0
- package/wave9/live-account-capture.js +435 -0
- package/wave9/live-autonomous-protection.d.ts +39 -0
- package/wave9/live-autonomous-protection.js +112 -0
- package/wave9/live-durable-reconciliation-scheduler.d.ts +33 -0
- package/wave9/live-durable-reconciliation-scheduler.js +115 -0
- package/wave9/live-execution-ledger.d.ts +107 -0
- package/wave9/live-execution-ledger.js +498 -0
- package/wave9/live-position-confirmation.d.ts +18 -0
- package/wave9/live-position-confirmation.js +111 -0
- package/wave9/live-residual-protection.d.ts +18 -0
- package/wave9/live-residual-protection.js +250 -0
- package/wave9/live-startup-reconciliation.d.ts +38 -0
- package/wave9/live-startup-reconciliation.js +454 -0
- package/wave9/live-symbol-ownership.d.ts +20 -0
- package/wave9/live-symbol-ownership.js +132 -0
- package/wave9/paper-admission-guard.d.ts +199 -0
- package/wave9/paper-admission-guard.js +650 -0
- package/wave9/usdm-evidence-provider.d.ts +42 -0
- package/wave9/usdm-evidence-provider.js +133 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
// HyperliquidLiveAdapter — IExchangeAdapter over Hyperliquid (plan §5.1).
|
|
2
|
+
//
|
|
3
|
+
// A SIBLING of Binance's LiveAdapter, not a refactor of it: ~1800 lines of
|
|
4
|
+
// incident-hardened live-money logic stay untouched, and the invariants are held
|
|
5
|
+
// by the SHARED CONTRACT SUITE that runs against both adapters (plan §9.1).
|
|
6
|
+
//
|
|
7
|
+
// THE INHERITED CONTRACTS (each one is an incident, not a preference):
|
|
8
|
+
// ★ null ≠ empty. `getPositionsOrNull()` returns null when the fetch FAILED;
|
|
9
|
+
// `getOpenOrders()` THROWS rather than return `[]` on a failed fetch (a
|
|
10
|
+
// phantom-empty order list once drove ~17 cycles of re-attach against real
|
|
11
|
+
// protective orders — the 2026-05-14 ATOM naked-bracket loop).
|
|
12
|
+
// ★ `closePosition()` distinguishes null (unknown) from [] (confirmed flat).
|
|
13
|
+
// ★ Emergency paths (cancel/close) are NEVER gated by our own pacer or by
|
|
14
|
+
// readiness — an exit must always be attemptable.
|
|
15
|
+
// ★ A sticky cooldown after a failed order-list fetch, so a tight agent retry
|
|
16
|
+
// loop cannot pin the rate budget and blind the whole agent.
|
|
17
|
+
//
|
|
18
|
+
// THE HL-SPECIFIC RULES (all measured on testnet 2026-07-12):
|
|
19
|
+
// ★ No native market order → IOC limit at a slippage-bounded price, and ccxt
|
|
20
|
+
// REQUIRES that reference price (it throws without one).
|
|
21
|
+
// ★ Reduce-only trigger legs auto-cancel on flat and behave OCO (T-1) → no
|
|
22
|
+
// sibling-canceller needed.
|
|
23
|
+
// ★ Legs are FIXED SIZE and do NOT follow a scale-in (T-2) → `resizeBrackets()`
|
|
24
|
+
// exists and the auditor treats an under-sized leg as NAKED size.
|
|
25
|
+
// ★ Positions/balance are queried on the MASTER address; the agent wallet only
|
|
26
|
+
// signs (it holds nothing).
|
|
27
|
+
import { EventEmitter } from 'node:events';
|
|
28
|
+
import { logger } from '../../logger.js';
|
|
29
|
+
import { HyperliquidPrivateApi } from './hl-private.js';
|
|
30
|
+
import { HyperliquidInfoCache } from './hl-info-cache.js';
|
|
31
|
+
import { HyperliquidPublicApi } from './hl-public.js';
|
|
32
|
+
import { planBracket, planResize, buildBracketOrders, bracketCoversPosition, } from './hl-brackets.js';
|
|
33
|
+
import { deriveHlNav, toCcxtBalance } from './hl-balance.js';
|
|
34
|
+
import { buildHlOrderCloid, parseHlBracketCloid } from './hl-cloid.js';
|
|
35
|
+
import { BracketLedger } from '../../live/bracket-ledger.js';
|
|
36
|
+
import { generateBracketId } from '../../live/bracket-id.js';
|
|
37
|
+
import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
|
|
38
|
+
import { HlBracketCoordinator, isTerminalBracketState } from './hl-bracket-coordinator.js';
|
|
39
|
+
import { HyperliquidUserStream } from './hl-user-stream.js';
|
|
40
|
+
import { formatError } from '../../logger.js';
|
|
41
|
+
const TAG = 'hl-live-adapter';
|
|
42
|
+
/** Venue-distinct ledger storage — a venue switch on the same box must never
|
|
43
|
+
* read the Binance ledger's rows as HL brackets (or vice versa). */
|
|
44
|
+
const HL_LEDGER_PLUGIN_ID = 'reefclaw-paper-trading-hl';
|
|
45
|
+
/** Periodic REST truth-check cadence. T-5: HL's WS replays nothing, and a
|
|
46
|
+
* dropped frame while connected is invisible — the sweep is the backstop
|
|
47
|
+
* that catches missed fills/triggers and T-2 under-sized legs. ~4 IP weight
|
|
48
|
+
* per pass against a 1200/min budget. */
|
|
49
|
+
const TRUTH_CHECK_INTERVAL_MS = 60_000;
|
|
50
|
+
/** Default IOC slippage bound for an emulated market order. 0.5% — NOT ccxt's
|
|
51
|
+
* 5% default, which would be a silent execution-quality disaster. */
|
|
52
|
+
const DEFAULT_MARKET_SLIPPAGE = 0.005;
|
|
53
|
+
/** Sticky cooldown after a failed open-orders fetch (the Binance lesson: the
|
|
54
|
+
* SKILL.md audit→attach loop re-calls every ~3s and would pin the budget). */
|
|
55
|
+
const OPEN_ORDERS_COOLDOWN_MS = 45_000;
|
|
56
|
+
export class HyperliquidLiveAdapter extends EventEmitter {
|
|
57
|
+
opts;
|
|
58
|
+
api;
|
|
59
|
+
publicApi;
|
|
60
|
+
infoCache;
|
|
61
|
+
slippagePct;
|
|
62
|
+
/** ★ On HL live, brackets are ALWAYS enforced — there is no watcher fallback
|
|
63
|
+
* (the stop-watcher has no live metadata) and no `brackets.mode=off` arm:
|
|
64
|
+
* the exchange-side legs ARE the safety floor. Lazily constructed so that
|
|
65
|
+
* merely constructing the adapter (registry tests) writes no ledger file. */
|
|
66
|
+
_coordinator = null;
|
|
67
|
+
userStream = null;
|
|
68
|
+
truthCheckTimer = null;
|
|
69
|
+
truthCheckRunning = false;
|
|
70
|
+
_readiness = 'INIT_PENDING';
|
|
71
|
+
openOrdersUnavailableUntil = 0;
|
|
72
|
+
constructor(opts) {
|
|
73
|
+
super();
|
|
74
|
+
this.opts = opts;
|
|
75
|
+
this.api = new HyperliquidPrivateApi(opts.credentials);
|
|
76
|
+
this.publicApi = new HyperliquidPublicApi({ testnet: opts.credentials.testnet });
|
|
77
|
+
this.slippagePct = clampSlippage(opts.marketSlippagePct ?? DEFAULT_MARKET_SLIPPAGE);
|
|
78
|
+
this.infoCache = new HyperliquidInfoCache(async () => {
|
|
79
|
+
// `meta` is a keyless info read — the public client owns it.
|
|
80
|
+
return this.publicApi.fetchMeta();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** The HL bracket orchestrator — the venue-aware tools (attach_brackets /
|
|
84
|
+
* modify_stop / modify_target / audit) drive brackets through this. */
|
|
85
|
+
getHlBracketCoordinator() {
|
|
86
|
+
if (!this._coordinator) {
|
|
87
|
+
this._coordinator = new HlBracketCoordinator({
|
|
88
|
+
attachBrackets: (args) => this.attachBrackets(args),
|
|
89
|
+
resizeBrackets: (args) => this.resizeBrackets(args),
|
|
90
|
+
cancelBracketLeg: (cloid, symbol) => this.cancelBracketLeg(cloid, symbol),
|
|
91
|
+
auditBracketCoverage: (symbol, size) => this.auditBracketCoverage(symbol, size),
|
|
92
|
+
getPositionsOrNull: (symbol) => this.getPositionsOrNull(symbol),
|
|
93
|
+
closePosition: (symbol, reason) => this.closePosition(symbol, reason),
|
|
94
|
+
}, this.opts.bracketLedger ?? new BracketLedger(HL_LEDGER_PLUGIN_ID));
|
|
95
|
+
}
|
|
96
|
+
return this._coordinator;
|
|
97
|
+
}
|
|
98
|
+
// ---- Lifecycle ----
|
|
99
|
+
/** Boot: load markets + asset rules. Readiness gates `create_order` ONLY —
|
|
100
|
+
* emergency paths never consult it. */
|
|
101
|
+
async initialize() {
|
|
102
|
+
const markets = await this.api.loadMarkets();
|
|
103
|
+
const rules = await this.infoCache.load();
|
|
104
|
+
if (markets && rules && this.infoCache.size > 0) {
|
|
105
|
+
this._readiness = 'READY';
|
|
106
|
+
logger.info(TAG, `HL live adapter READY (${this.infoCache.size} assets, mode=${this.opts.mode})`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// DEGRADED, not BLOCKED: reads/exits still work; entries are refused
|
|
110
|
+
// upstream by the readiness gate.
|
|
111
|
+
this._readiness = 'DEGRADED';
|
|
112
|
+
logger.error(TAG, 'HL live adapter DEGRADED — markets/asset rules unavailable; entries blocked, exits still work');
|
|
113
|
+
}
|
|
114
|
+
// Prime the ADDRESS action budget (the starvation guard, §5.6).
|
|
115
|
+
await this.api.refreshAddressBudget();
|
|
116
|
+
// ---- Bracket wiring (issue #209) ----
|
|
117
|
+
// Fast path: the user stream (fills drive attach for resting limits;
|
|
118
|
+
// orderUpdates is the authoritative leg-lifecycle signal — the HL analog
|
|
119
|
+
// of Binance's ALGO_UPDATE). Truth path: T-5 proved the WS replays
|
|
120
|
+
// NOTHING, so every (re)connect AND a periodic sweep run the REST
|
|
121
|
+
// truth-check in resyncAgainstExchange.
|
|
122
|
+
if (!this.opts.disableUserStream) {
|
|
123
|
+
this.userStream = new HyperliquidUserStream({
|
|
124
|
+
walletAddress: this.opts.credentials.walletAddress,
|
|
125
|
+
testnet: this.opts.credentials.testnet,
|
|
126
|
+
callbacks: {
|
|
127
|
+
onFill: (fill) => this.onUserFill(fill),
|
|
128
|
+
onOrderUpdate: (update) => this.onUserOrderUpdate(update),
|
|
129
|
+
onUserEvent: () => {
|
|
130
|
+
/* liquidation/funding — liquidation fills also arrive via onFill */
|
|
131
|
+
},
|
|
132
|
+
onResyncNeeded: (window) => {
|
|
133
|
+
void this.runTruthCheck(`ws_resync_blind_${Math.round(window.wasDisconnectedMs / 1000)}s`);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
this.userStream.start();
|
|
138
|
+
}
|
|
139
|
+
this.truthCheckTimer = setInterval(() => {
|
|
140
|
+
void this.runTruthCheck('periodic');
|
|
141
|
+
}, TRUTH_CHECK_INTERVAL_MS);
|
|
142
|
+
this.truthCheckTimer.unref?.();
|
|
143
|
+
}
|
|
144
|
+
/** Stop background machinery (tests / graceful shutdown). The exchange-side
|
|
145
|
+
* legs keep protecting the position regardless — that is the point. */
|
|
146
|
+
stop() {
|
|
147
|
+
this.userStream?.stop();
|
|
148
|
+
this.userStream = null;
|
|
149
|
+
if (this.truthCheckTimer)
|
|
150
|
+
clearInterval(this.truthCheckTimer);
|
|
151
|
+
this.truthCheckTimer = null;
|
|
152
|
+
}
|
|
153
|
+
get readiness() {
|
|
154
|
+
return this._readiness;
|
|
155
|
+
}
|
|
156
|
+
get mode() {
|
|
157
|
+
return this.opts.mode;
|
|
158
|
+
}
|
|
159
|
+
get isLive() {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
// ---- Core trading ----
|
|
163
|
+
async createOrder(symbol, side, type, amount, price, metadata, options) {
|
|
164
|
+
// A market order NEEDS a reference price on HL (no native market order; ccxt
|
|
165
|
+
// derives the slippage cap from it and THROWS without one — verified live).
|
|
166
|
+
let referencePrice = price;
|
|
167
|
+
if (type === 'market' || referencePrice === undefined) {
|
|
168
|
+
const mark = await this.publicApi.fetchMarkPrice(symbol);
|
|
169
|
+
if (mark === null || !(mark > 0)) {
|
|
170
|
+
throw new Error(`Cannot price a ${type} order on ${symbol}: mark price unavailable. ` +
|
|
171
|
+
'Hyperliquid has no native market order — an IOC bound needs a reference price.');
|
|
172
|
+
}
|
|
173
|
+
referencePrice = mark;
|
|
174
|
+
}
|
|
175
|
+
const submitPrice = type === 'market'
|
|
176
|
+
? this.infoCache.marketPrice({
|
|
177
|
+
symbol,
|
|
178
|
+
side,
|
|
179
|
+
referencePrice,
|
|
180
|
+
slippagePct: this.slippagePct,
|
|
181
|
+
})
|
|
182
|
+
: this.infoCache.roundPrice(symbol, referencePrice, side === 'buy' ? 'down' : 'up');
|
|
183
|
+
const check = this.infoCache.validate({
|
|
184
|
+
symbol,
|
|
185
|
+
amount,
|
|
186
|
+
side,
|
|
187
|
+
price: type === 'limit' ? submitPrice : undefined,
|
|
188
|
+
referencePrice,
|
|
189
|
+
});
|
|
190
|
+
if (!check.valid)
|
|
191
|
+
throw new Error(check.error ?? 'Hyperliquid order validation failed');
|
|
192
|
+
const cloid = options?.clientOrderId ?? buildHlOrderCloid();
|
|
193
|
+
const order = await this.api.submitOrder({
|
|
194
|
+
symbol,
|
|
195
|
+
side,
|
|
196
|
+
type,
|
|
197
|
+
amount: check.roundedAmount,
|
|
198
|
+
price: submitPrice,
|
|
199
|
+
reduceOnly: options?.reduceOnly,
|
|
200
|
+
cloid,
|
|
201
|
+
});
|
|
202
|
+
if (!order)
|
|
203
|
+
throw new Error(`Hyperliquid order submission returned no order for ${symbol}`);
|
|
204
|
+
// ---- Bracket wiring (issue #209): protection attaches HERE, at the fill ----
|
|
205
|
+
// Closes / reduce-only orders never carry brackets.
|
|
206
|
+
const hasBracketMeta = metadata !== undefined &&
|
|
207
|
+
(metadata.stopPrice !== undefined || metadata.targetPrice !== undefined);
|
|
208
|
+
if (!options?.reduceOnly && hasBracketMeta) {
|
|
209
|
+
await this.wireBracketsAfterSubmit(symbol, side, order, metadata, cloid);
|
|
210
|
+
}
|
|
211
|
+
return order;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Post-submit bracket wiring, mirroring the Binance LiveAdapter sequence:
|
|
215
|
+
* validate direction against the ACTUAL fill (issue #196 — never the limit
|
|
216
|
+
* price on a filled order), flatten a filled entry whose bracket is invalid
|
|
217
|
+
* (a naked entry must not survive), detect scale-ins (T-2: HL legs are fixed
|
|
218
|
+
* size — the ADDED contracts are naked until resized), then register +
|
|
219
|
+
* attach. Attach itself is async (non-blocking, mirrors Binance) — retries +
|
|
220
|
+
* auto-flatten handle failures.
|
|
221
|
+
*/
|
|
222
|
+
async wireBracketsAfterSubmit(symbol, side, order, metadata, entryCloid) {
|
|
223
|
+
const filled = Number(order.filled ?? 0);
|
|
224
|
+
const isFilled = filled > 0;
|
|
225
|
+
// Direction check vs the actual fill (filled) or the limit (resting).
|
|
226
|
+
let dirRef = null;
|
|
227
|
+
if (isFilled) {
|
|
228
|
+
const avg = Number(order.average ?? 0);
|
|
229
|
+
const cost = Number(order.cost ?? 0);
|
|
230
|
+
dirRef = avg > 0 ? avg : cost > 0 && filled > 0 ? cost / filled : null;
|
|
231
|
+
if (dirRef === null) {
|
|
232
|
+
logger.warn(TAG, `Post-fill bracket direction check skipped for ${symbol}: fill price unresolvable — ` +
|
|
233
|
+
'the exchange-side trigger validation is the arbiter (issue #196 rule)');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const lim = Number(order.price ?? 0);
|
|
238
|
+
dirRef = lim > 0 ? lim : null;
|
|
239
|
+
}
|
|
240
|
+
let dirMsg = null;
|
|
241
|
+
if (dirRef !== null && dirRef > 0) {
|
|
242
|
+
if (metadata.stopPrice !== undefined) {
|
|
243
|
+
dirMsg = validateStopDirection(side, dirRef, metadata.stopPrice);
|
|
244
|
+
}
|
|
245
|
+
if (!dirMsg && metadata.targetPrice !== undefined) {
|
|
246
|
+
dirMsg = validateTargetDirection(side, dirRef, metadata.targetPrice);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (dirMsg) {
|
|
250
|
+
if (isFilled) {
|
|
251
|
+
logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}, fill=${dirRef}) — ` +
|
|
252
|
+
'flattening the just-filled position to avoid a naked entry');
|
|
253
|
+
let flattened = false;
|
|
254
|
+
for (let a = 1; a <= 4 && !flattened; a++) {
|
|
255
|
+
try {
|
|
256
|
+
await this.closePosition(symbol, 'bracket_attach_failed');
|
|
257
|
+
flattened = true;
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${msg(err)}`);
|
|
261
|
+
if (a < 4)
|
|
262
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!flattened) {
|
|
266
|
+
logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, ` +
|
|
267
|
+
'immediate operator intervention required');
|
|
268
|
+
this.emit('emergency_progress', {
|
|
269
|
+
action: 'flatten',
|
|
270
|
+
status: 'failed',
|
|
271
|
+
symbol,
|
|
272
|
+
message: `Naked ${symbol} after invalid bracket — manual close required`,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the ` +
|
|
278
|
+
'resting entry so it cannot fill unprotected');
|
|
279
|
+
try {
|
|
280
|
+
await this.api.cancelOrderByCloid(entryCloid, symbol);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
logger.error(TAG, `Cancel of resting entry after invalid bracket failed: ${msg(err)}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
|
|
287
|
+
}
|
|
288
|
+
// ★ Scale-in (T-2): a non-terminal active/partial row means legs are live
|
|
289
|
+
// at the OLD size — the added contracts are NAKED until resized. Resolve
|
|
290
|
+
// the new total from exchange truth and resize; on an unknown read the
|
|
291
|
+
// 60s truth-check sweep is the backstop (and we say so loudly).
|
|
292
|
+
const ledger = this.getHlBracketCoordinator().getLedger();
|
|
293
|
+
const existing = ledger.getBySymbol(symbol);
|
|
294
|
+
if (existing && !isTerminalBracketState(existing.state) && existing.state !== 'pending_entry') {
|
|
295
|
+
if (isFilled) {
|
|
296
|
+
void this.resizeAfterScaleInAsync(symbol);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const bracketId = generateBracketId();
|
|
301
|
+
this.getHlBracketCoordinator().registerEntry({ symbol, side, stopPrice: metadata.stopPrice, targetPrice: metadata.targetPrice }, bracketId, entryCloid);
|
|
302
|
+
if (isFilled) {
|
|
303
|
+
// Do NOT await — attach can take seconds; retries + auto-flatten own
|
|
304
|
+
// the failure path (same contract as Binance attachBracketsAsync).
|
|
305
|
+
void this.attachOnFillAsync(symbol, filled);
|
|
306
|
+
}
|
|
307
|
+
// Resting limit: the user-stream fill event (or the truth-check sweep,
|
|
308
|
+
// if the fill lands in a WS gap — T-5) drives the attach.
|
|
309
|
+
}
|
|
310
|
+
async attachOnFillAsync(symbol, filledSize) {
|
|
311
|
+
try {
|
|
312
|
+
const result = await this.getHlBracketCoordinator().attachOnFill(symbol, filledSize);
|
|
313
|
+
if (!result.ok && result.error !== 'attach_in_flight') {
|
|
314
|
+
logger.error(TAG, `Bracket attach FAILED for ${symbol} after ${result.attempts} attempts: ${result.error}. ` +
|
|
315
|
+
'Auto-flattening position (a naked HL live position has NO fallback watcher).');
|
|
316
|
+
try {
|
|
317
|
+
await this.closePosition(symbol, 'bracket_attach_failed');
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
logger.error(TAG, `Auto-flatten after bracket failure ALSO failed: ${msg(err)}`);
|
|
321
|
+
this.emit('emergency_progress', {
|
|
322
|
+
action: 'flatten',
|
|
323
|
+
status: 'failed',
|
|
324
|
+
symbol,
|
|
325
|
+
message: `Naked ${symbol} after failed bracket attach — manual close required`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
logger.error(TAG, `attachOnFillAsync unexpected error for ${symbol}: ${formatError(err)}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async resizeAfterScaleInAsync(symbol) {
|
|
335
|
+
try {
|
|
336
|
+
// Exchange truth for the new total — never arithmetic on local state.
|
|
337
|
+
let total = null;
|
|
338
|
+
for (let a = 1; a <= 3 && total === null; a++) {
|
|
339
|
+
const positions = await this.getPositionsOrNull(symbol);
|
|
340
|
+
if (positions !== null) {
|
|
341
|
+
const pos = positions.find((p) => p.symbol?.startsWith(symbol.split(':')[0]));
|
|
342
|
+
total = Math.abs(Number(pos?.contracts ?? 0));
|
|
343
|
+
}
|
|
344
|
+
else if (a < 3) {
|
|
345
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (total === null) {
|
|
349
|
+
logger.error(TAG, `scale-in resize for ${symbol}: position size UNKNOWN after 3 reads — the added size ` +
|
|
350
|
+
'may be NAKED until the 60s truth-check sweep resizes the legs (T-2)');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (total > 0) {
|
|
354
|
+
await this.getHlBracketCoordinator().resizeToPosition(symbol, total);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
logger.error(TAG, `scale-in resize for ${symbol} failed: ${formatError(err)} — truth-check sweep will retry`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Emergency-safe: never pre-gated, never readiness-gated. */
|
|
362
|
+
async cancelOrder(orderId, symbol) {
|
|
363
|
+
if (!symbol)
|
|
364
|
+
throw new Error('Hyperliquid cancelOrder requires a symbol');
|
|
365
|
+
const res = await this.api.cancelOrder(orderId, symbol);
|
|
366
|
+
if (!res)
|
|
367
|
+
throw new Error(`Cancel returned no result for ${orderId}`);
|
|
368
|
+
return res;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Cancel WORKING orders — protective legs are PRESERVED.
|
|
372
|
+
*
|
|
373
|
+
* This is the plugin-side half of the kill-preserve rule (the skill has its own,
|
|
374
|
+
* broader layer). A Kill must never strip the stop off a live position.
|
|
375
|
+
*/
|
|
376
|
+
async cancelAllOrders(symbol) {
|
|
377
|
+
const open = await this.api.fetchOpenOrders(symbol);
|
|
378
|
+
if (open === null) {
|
|
379
|
+
// Unknown ≠ nothing. Refuse to "cancel all" against an unknown book.
|
|
380
|
+
throw new Error('cancelAllOrders: could not read open orders (fetch failed) — refusing to act on an unknown order book');
|
|
381
|
+
}
|
|
382
|
+
const cancelled = [];
|
|
383
|
+
for (const o of open) {
|
|
384
|
+
const cloid = (o.clientOrderId ?? '');
|
|
385
|
+
// Preserve OUR bracket legs…
|
|
386
|
+
if (cloid && parseHlBracketCloid(cloid))
|
|
387
|
+
continue;
|
|
388
|
+
// …and any other protective order (externally placed stops included).
|
|
389
|
+
const raw = o.info ?? {};
|
|
390
|
+
if (raw.reduceOnly === true ||
|
|
391
|
+
raw.isTrigger === true ||
|
|
392
|
+
raw.isPositionTpsl === true) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const res = await this.api.cancelOrder(String(o.id), o.symbol ?? symbol ?? '');
|
|
397
|
+
if (res)
|
|
398
|
+
cancelled.push(res);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
logger.warn(TAG, `cancelAllOrders: cancel ${o.id} failed: ${msg(err)}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return cancelled;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Close a position at market (reduce-only IOC).
|
|
408
|
+
*
|
|
409
|
+
* ★ null ≠ empty: a FAILED position fetch throws (state unknown — closing
|
|
410
|
+
* against an unknown book could open a NEW position in the opposite direction);
|
|
411
|
+
* a CONFIRMED-flat account is a benign no-op.
|
|
412
|
+
*/
|
|
413
|
+
async closePosition(symbol, _closeReason) {
|
|
414
|
+
const positions = await this.api.fetchPositions(symbol);
|
|
415
|
+
if (positions === null) {
|
|
416
|
+
throw new Error(`closePosition(${symbol}): position state UNKNOWN (fetch failed) — refusing to submit a ` +
|
|
417
|
+
'close that could open an opposite position. Retry once the exchange responds.');
|
|
418
|
+
}
|
|
419
|
+
const pos = positions.find((p) => p.symbol?.startsWith(symbol.split(':')[0]));
|
|
420
|
+
if (!pos || Math.abs(Number(pos.contracts ?? 0)) === 0) {
|
|
421
|
+
throw new Error(`closePosition(${symbol}): no open position (exchange confirmed flat)`);
|
|
422
|
+
}
|
|
423
|
+
const size = Math.abs(Number(pos.contracts));
|
|
424
|
+
const side = pos.side === 'long' ? 'sell' : 'buy';
|
|
425
|
+
const mark = await this.publicApi.fetchMarkPrice(symbol);
|
|
426
|
+
if (mark === null || !(mark > 0)) {
|
|
427
|
+
throw new Error(`closePosition(${symbol}): mark price unavailable — cannot bound the IOC close`);
|
|
428
|
+
}
|
|
429
|
+
const price = this.infoCache.marketPrice({
|
|
430
|
+
symbol,
|
|
431
|
+
side,
|
|
432
|
+
referencePrice: mark,
|
|
433
|
+
// A CLOSE is not the place to be precious about slippage — use a wider
|
|
434
|
+
// bound than an entry so an exit does not silently fail to fill.
|
|
435
|
+
slippagePct: Math.min(this.slippagePct * 2, 0.02),
|
|
436
|
+
});
|
|
437
|
+
const order = await this.api.submitOrder({
|
|
438
|
+
symbol,
|
|
439
|
+
side,
|
|
440
|
+
type: 'market',
|
|
441
|
+
amount: this.infoCache.roundSize(symbol, size),
|
|
442
|
+
price,
|
|
443
|
+
reduceOnly: true,
|
|
444
|
+
cloid: buildHlOrderCloid(),
|
|
445
|
+
});
|
|
446
|
+
if (!order)
|
|
447
|
+
throw new Error(`closePosition(${symbol}) returned no order`);
|
|
448
|
+
// Ledger hygiene: mark the bracket row terminal. T-1 auto-cancels the
|
|
449
|
+
// exchange legs when the position goes flat, so this is bookkeeping (plus
|
|
450
|
+
// a harmless defensive cancel), never load-bearing. Fire-and-forget — a
|
|
451
|
+
// close must never fail on ledger cleanup.
|
|
452
|
+
const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
|
|
453
|
+
if (row && !isTerminalBracketState(row.state)) {
|
|
454
|
+
void this.getHlBracketCoordinator()
|
|
455
|
+
.cancelBrackets(symbol, `position_closed:${_closeReason ?? 'unspecified'}`)
|
|
456
|
+
.catch((err) => logger.warn(TAG, `bracket cleanup after close(${symbol}): ${msg(err)}`));
|
|
457
|
+
}
|
|
458
|
+
return order;
|
|
459
|
+
}
|
|
460
|
+
// ---- State queries ----
|
|
461
|
+
async getBalance() {
|
|
462
|
+
const state = await this.api.rawInfo({
|
|
463
|
+
type: 'clearinghouseState',
|
|
464
|
+
user: this.opts.credentials.walletAddress,
|
|
465
|
+
}).catch(() => null);
|
|
466
|
+
const nav = deriveHlNav(state);
|
|
467
|
+
if (!nav) {
|
|
468
|
+
throw new Error('getBalance: clearinghouseState unreadable — balance UNKNOWN (never reported as $0)');
|
|
469
|
+
}
|
|
470
|
+
return toCcxtBalance(nav);
|
|
471
|
+
}
|
|
472
|
+
/** Display contract (`?? []`) — the 20+ KPI/display callers. */
|
|
473
|
+
async getPositions(symbol) {
|
|
474
|
+
return (await this.api.fetchPositions(symbol)) ?? [];
|
|
475
|
+
}
|
|
476
|
+
/** ★ Decision contract — null means UNKNOWN, and destructive paths must not act. */
|
|
477
|
+
async getPositionsOrNull(symbol) {
|
|
478
|
+
return this.api.fetchPositions(symbol);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* ★ THROWS on a failed fetch — never returns [].
|
|
482
|
+
*
|
|
483
|
+
* An empty list is positive information ("the exchange has no orders"); a failed
|
|
484
|
+
* fetch is not. Collapsing the two is what drove the ATOM naked-bracket loop on
|
|
485
|
+
* Binance. A sticky cooldown then prevents a retry loop from pinning the budget.
|
|
486
|
+
*/
|
|
487
|
+
async getOpenOrders(symbol) {
|
|
488
|
+
const now = Date.now();
|
|
489
|
+
if (now < this.openOrdersUnavailableUntil) {
|
|
490
|
+
const waitS = Math.ceil((this.openOrdersUnavailableUntil - now) / 1000);
|
|
491
|
+
throw new Error(`getOpenOrders: Hyperliquid unavailable — sticky cooldown active (~${waitS}s left, no call made). ` +
|
|
492
|
+
'Caller must NOT proceed as if the exchange has zero orders.');
|
|
493
|
+
}
|
|
494
|
+
const orders = await this.api.fetchOpenOrders(symbol);
|
|
495
|
+
if (orders === null) {
|
|
496
|
+
this.openOrdersUnavailableUntil = Date.now() + OPEN_ORDERS_COOLDOWN_MS;
|
|
497
|
+
throw new Error('getOpenOrders: fetch FAILED (rate limit / transient) — order state UNKNOWN. ' +
|
|
498
|
+
'This is NOT "zero orders": a caller that treats it as empty would re-attach or cancel ' +
|
|
499
|
+
'against real protective orders.');
|
|
500
|
+
}
|
|
501
|
+
this.openOrdersUnavailableUntil = 0;
|
|
502
|
+
return orders;
|
|
503
|
+
}
|
|
504
|
+
async fetchOrder(orderId, symbol) {
|
|
505
|
+
return this.api.fetchOrder(orderId, symbol);
|
|
506
|
+
}
|
|
507
|
+
async getLastPrice(symbol) {
|
|
508
|
+
return this.publicApi.fetchMarkPrice(symbol);
|
|
509
|
+
}
|
|
510
|
+
// ---- Brackets (the safety floor) ----
|
|
511
|
+
/** Attach protective legs to a filled position — ONE signed action for both.
|
|
512
|
+
* Returns the cloids it generated so the coordinator can persist them (a
|
|
513
|
+
* cloid is single-use; the ledger must always match the live legs). */
|
|
514
|
+
async attachBrackets(args) {
|
|
515
|
+
const plan = planBracket({
|
|
516
|
+
...args,
|
|
517
|
+
positionSize: this.infoCache.roundSize(args.symbol, args.positionSize),
|
|
518
|
+
// Round each trigger to the PROTECTIVE side: a long's stop rounds DOWN
|
|
519
|
+
// (never tighter than the agent asked), its target rounds UP.
|
|
520
|
+
stopPrice: args.stopPrice === undefined
|
|
521
|
+
? undefined
|
|
522
|
+
: this.infoCache.roundPrice(args.symbol, args.stopPrice, args.positionSide === 'long' ? 'down' : 'up'),
|
|
523
|
+
targetPrice: args.targetPrice === undefined
|
|
524
|
+
? undefined
|
|
525
|
+
: this.infoCache.roundPrice(args.symbol, args.targetPrice, args.positionSide === 'long' ? 'up' : 'down'),
|
|
526
|
+
});
|
|
527
|
+
const orders = await this.api.submitOrders(buildBracketOrders(plan));
|
|
528
|
+
if (!orders)
|
|
529
|
+
throw new Error(`attachBrackets(${args.symbol}): submission returned nothing`);
|
|
530
|
+
return {
|
|
531
|
+
orders,
|
|
532
|
+
slCid: plan.legs.find((l) => l.role === 'stop')?.cloid,
|
|
533
|
+
tpCid: plan.legs.find((l) => l.role === 'target')?.cloid,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* ★ T-2 REQUIREMENT: resize protective legs after a scale-in.
|
|
538
|
+
*
|
|
539
|
+
* HL legs are FIXED SIZE and do not follow the position (measured). Without this
|
|
540
|
+
* the added contracts are NAKED. Submit-then-cancel: a moment of over-protection
|
|
541
|
+
* (harmless — reduce-only cannot flip a position) beats a naked window.
|
|
542
|
+
*/
|
|
543
|
+
async resizeBrackets(args) {
|
|
544
|
+
const live = await this.readLiveLegs(args.symbol);
|
|
545
|
+
const plan = planResize({
|
|
546
|
+
...args,
|
|
547
|
+
positionSize: this.infoCache.roundSize(args.symbol, args.positionSize),
|
|
548
|
+
liveLegs: live,
|
|
549
|
+
});
|
|
550
|
+
if (plan.noop)
|
|
551
|
+
return { resized: false };
|
|
552
|
+
const submitted = await this.api.submitOrders(plan.submit); // protect FIRST
|
|
553
|
+
if (!submitted) {
|
|
554
|
+
// Old legs untouched — the position keeps its ORIGINAL protection; the
|
|
555
|
+
// added size stays naked until the caller retries (truth-check sweep).
|
|
556
|
+
throw new Error(`resizeBrackets(${args.symbol}): submission returned nothing — old legs left in place`);
|
|
557
|
+
}
|
|
558
|
+
for (const cloid of plan.cancelCloids) {
|
|
559
|
+
// Register BEFORE the request: the WS 'canceled' event can beat the
|
|
560
|
+
// ledger update and must not read as stripped protection (canary).
|
|
561
|
+
this.getHlBracketCoordinator().noteOwnLegCancel(cloid);
|
|
562
|
+
try {
|
|
563
|
+
await this.api.cancelOrderByCloid(cloid, args.symbol);
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
// A stale leg that refuses to cancel is over-protection, not risk.
|
|
567
|
+
logger.warn(TAG, `resizeBrackets: stale leg ${cloid} cancel failed: ${msg(err)}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
logger.info(TAG, `resized brackets on ${args.symbol} to ${args.positionSize} (T-2: HL legs do not auto-resize)`);
|
|
571
|
+
let slCid;
|
|
572
|
+
let tpCid;
|
|
573
|
+
for (const o of plan.submit) {
|
|
574
|
+
const parsed = typeof o.cloid === 'string' ? parseHlBracketCloid(o.cloid) : null;
|
|
575
|
+
if (parsed?.role === 'stop')
|
|
576
|
+
slCid = o.cloid;
|
|
577
|
+
if (parsed?.role === 'target')
|
|
578
|
+
tpCid = o.cloid;
|
|
579
|
+
}
|
|
580
|
+
return { resized: true, slCid, tpCid };
|
|
581
|
+
}
|
|
582
|
+
/** Cancel one bracket leg by cloid. Idempotent: a leg that is already gone
|
|
583
|
+
* (triggered / T-1 auto-cancelled / sibling-cancelled) is SUCCESS, not an
|
|
584
|
+
* error — the goal state ("leg not on the book") is reached. */
|
|
585
|
+
async cancelBracketLeg(cloid, symbol) {
|
|
586
|
+
this.getHlBracketCoordinator().noteOwnLegCancel(cloid);
|
|
587
|
+
try {
|
|
588
|
+
await this.api.cancelOrderByCloid(cloid, symbol);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
const m = msg(err).toLowerCase();
|
|
592
|
+
if (m.includes('never placed') || m.includes('already') || m.includes('filled') || m.includes('not found')) {
|
|
593
|
+
return; // already gone — idempotent success
|
|
594
|
+
}
|
|
595
|
+
throw err;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/** Coverage audit — `covered:false` means part of the position is NAKED. */
|
|
599
|
+
async auditBracketCoverage(symbol, positionSize) {
|
|
600
|
+
const live = await this.readLiveLegs(symbol);
|
|
601
|
+
return bracketCoversPosition({ positionSize, liveLegs: live });
|
|
602
|
+
}
|
|
603
|
+
/** Cancel every OUR-cloid bracket leg on a symbol (recovery-path orphan
|
|
604
|
+
* cleanup before a fresh attach). THROWS when order state is unknown —
|
|
605
|
+
* the caller logs and still attaches (protection beats hygiene). */
|
|
606
|
+
async cancelSymbolBracketLegs(symbol) {
|
|
607
|
+
const legs = await this.readLiveLegs(symbol); // throws on failed fetch
|
|
608
|
+
let cancelled = 0;
|
|
609
|
+
for (const leg of legs) {
|
|
610
|
+
try {
|
|
611
|
+
await this.cancelBracketLeg(leg.cloid, symbol);
|
|
612
|
+
cancelled++;
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
logger.warn(TAG, `cancelSymbolBracketLegs(${symbol}): ${leg.cloid} failed: ${msg(err)}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return cancelled;
|
|
619
|
+
}
|
|
620
|
+
// ---- User-stream handlers (issue #209 wiring) ----
|
|
621
|
+
/** Entry fills drive attach (resting limits) / resize (partial-fill growth).
|
|
622
|
+
* `startPosition` is the position BEFORE this fill — the WS-authoritative
|
|
623
|
+
* way to know the after-fill total without an extra REST read. */
|
|
624
|
+
onUserFill(fill) {
|
|
625
|
+
try {
|
|
626
|
+
const ledger = this.getHlBracketCoordinator().getLedger();
|
|
627
|
+
const rows = ledger.getAll().filter((r) => !isTerminalBracketState(r.state));
|
|
628
|
+
const row = rows.find((r) => r.entryCid && fill.cloid && r.entryCid === fill.cloid);
|
|
629
|
+
if (!row)
|
|
630
|
+
return;
|
|
631
|
+
const sz = Math.abs(Number(fill.sz ?? 0));
|
|
632
|
+
const before = Math.abs(Number(fill.startPosition ?? 0));
|
|
633
|
+
const isOpen = (fill.dir ?? '').toLowerCase().startsWith('open');
|
|
634
|
+
if (!isOpen || sz <= 0)
|
|
635
|
+
return;
|
|
636
|
+
const afterTotal = before + sz;
|
|
637
|
+
if (row.state === 'pending_entry') {
|
|
638
|
+
void this.attachOnFillAsync(row.symbol, afterTotal);
|
|
639
|
+
}
|
|
640
|
+
else if (row.state === 'active' || row.state === 'partial') {
|
|
641
|
+
// Further fills of the same resting entry — grow the legs (T-2).
|
|
642
|
+
void this.getHlBracketCoordinator()
|
|
643
|
+
.resizeToPosition(row.symbol, afterTotal)
|
|
644
|
+
.catch((err) => logger.warn(TAG, `partial-fill resize ${row.symbol}: ${msg(err)} — sweep will retry`));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
logger.error(TAG, `onUserFill handler error: ${formatError(err)}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
|
|
652
|
+
* analog). A trigger = the exchange closed the position — surface the same
|
|
653
|
+
* `drift_detected` close shape the Binance reconciler emits so the journal
|
|
654
|
+
* close-bypass cleanup fires at once, not ≤5 min late. */
|
|
655
|
+
onUserOrderUpdate(update) {
|
|
656
|
+
try {
|
|
657
|
+
const transition = this.getHlBracketCoordinator().handleOrderUpdate(update);
|
|
658
|
+
if (transition === 'triggered_sl' || transition === 'triggered_tp' || transition === 'forced_close') {
|
|
659
|
+
const row = this.getHlBracketCoordinator().getLedger().getAll().find((r) => update.order.cloid && (r.slCid === update.order.cloid || r.tpCid === update.order.cloid));
|
|
660
|
+
const symbol = row?.symbol;
|
|
661
|
+
if (symbol) {
|
|
662
|
+
this.emit('drift_detected', {
|
|
663
|
+
timestamp: new Date().toISOString(),
|
|
664
|
+
drifts: [
|
|
665
|
+
{
|
|
666
|
+
type: 'closed',
|
|
667
|
+
symbol,
|
|
668
|
+
localContracts: row?.qty ?? 0,
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
logger.error(TAG, `onUserOrderUpdate handler error: ${formatError(err)}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/** T-5 REST truth-check — serialized so a slow pass can't stack. */
|
|
680
|
+
async runTruthCheck(reasonTag) {
|
|
681
|
+
if (this.truthCheckRunning)
|
|
682
|
+
return;
|
|
683
|
+
this.truthCheckRunning = true;
|
|
684
|
+
try {
|
|
685
|
+
const { closedSymbols } = await this.getHlBracketCoordinator().resyncAgainstExchange(reasonTag);
|
|
686
|
+
for (const symbol of closedSymbols) {
|
|
687
|
+
const row = this.getHlBracketCoordinator().getLedger().getBySymbol(symbol);
|
|
688
|
+
this.emit('drift_detected', {
|
|
689
|
+
timestamp: new Date().toISOString(),
|
|
690
|
+
drifts: [{ type: 'closed', symbol, localContracts: row?.qty ?? 0 }],
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
logger.error(TAG, `truth-check(${reasonTag}) failed: ${formatError(err)}`);
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
this.truthCheckRunning = false;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/** Our protective legs currently on the exchange. Throws on unknown (never []). */
|
|
702
|
+
async readLiveLegs(symbol) {
|
|
703
|
+
const orders = await this.getOpenOrders(symbol); // throws on failed fetch
|
|
704
|
+
const legs = [];
|
|
705
|
+
for (const o of orders) {
|
|
706
|
+
const cloid = (o.clientOrderId ?? '');
|
|
707
|
+
const parsed = cloid ? parseHlBracketCloid(cloid) : null;
|
|
708
|
+
if (!parsed || parsed.role === 'entry')
|
|
709
|
+
continue;
|
|
710
|
+
const raw = o.info ?? {};
|
|
711
|
+
legs.push({
|
|
712
|
+
role: parsed.role,
|
|
713
|
+
cloid,
|
|
714
|
+
size: Number(o.remaining ?? o.amount ?? raw.sz ?? 0),
|
|
715
|
+
triggerPrice: Number(raw.triggerPx ?? o.price ?? 0),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return legs;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function clampSlippage(pct) {
|
|
722
|
+
if (!Number.isFinite(pct) || pct <= 0)
|
|
723
|
+
return DEFAULT_MARKET_SLIPPAGE;
|
|
724
|
+
return Math.min(pct, 0.02); // hard ceiling 2%
|
|
725
|
+
}
|
|
726
|
+
function msg(err) {
|
|
727
|
+
return err instanceof Error ? err.message : String(err);
|
|
728
|
+
}
|