@reefclaw/openclaw-plugin 0.1.6 → 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/public-market-data-api.d.ts +14 -0
- package/ccxt/public-market-data-api.js +15 -1
- package/config/plugin-config-io.d.ts +7 -0
- package/config/plugin-config-io.js +15 -0
- package/index.js +107 -29
- 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/live/fill-price.d.ts +13 -0
- package/live/fill-price.js +37 -0
- package/live/live-adapter.d.ts +33 -1
- package/live/live-adapter.js +176 -47
- package/live/position-state-store.d.ts +4 -0
- package/live/stop-watcher.d.ts +8 -1
- package/live/stop-watcher.js +5 -2
- package/onboarding/runtime.d.ts +6 -0
- package/onboarding/runtime.js +13 -2
- package/package.json +2 -2
- package/portfolio/reentry-tracker.d.ts +36 -0
- package/portfolio/reentry-tracker.js +127 -0
- package/signals/conditions/registry.js +11 -2
- package/signals/strategy-adapter.js +17 -7
- package/simulator/exchange-simulator.d.ts +12 -0
- package/simulator/exchange-simulator.js +73 -3
- package/simulator/types.d.ts +4 -0
- package/skills/reefclaw/SKILL.md +2 -0
- package/tools/assessment-validation.d.ts +21 -0
- package/tools/assessment-validation.js +58 -0
- package/tools/attach-brackets.js +165 -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/create-order.js +30 -2
- package/tools/get-setup-detail.js +12 -1
- package/tools/modify-stop.js +5 -5
- package/tools/modify-target.js +5 -5
- package/tools/scan-pairs.d.ts +4 -0
- package/tools/scan-pairs.js +4 -1
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
- package/venues/hyperliquid/hl-live-adapter.js +380 -5
- package/venues/hyperliquid/hl-public.js +8 -1
package/tools/attach-brackets.js
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
import { generateBracketId, buildBracketCid } from '../live/bracket-id.js';
|
|
23
23
|
import { validateStopDirection, validateTargetDirection } from '../live/bracket-params.js';
|
|
24
24
|
import { formatError, logger } from '../logger.js';
|
|
25
|
+
import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
|
|
26
|
+
import { isTerminalBracketState } from '../venues/hyperliquid/hl-bracket-coordinator.js';
|
|
27
|
+
import { buildHlBracketCloid } from '../venues/hyperliquid/hl-cloid.js';
|
|
25
28
|
const TAG = 'attach-brackets';
|
|
26
29
|
export async function attachBracketsTool(args, deps) {
|
|
27
30
|
// ---- 1. Argument validation ----
|
|
@@ -77,6 +80,12 @@ async function attachBracketsLive(args, deps) {
|
|
|
77
80
|
};
|
|
78
81
|
}
|
|
79
82
|
}
|
|
83
|
+
// Venue dispatch (issue #209): the HL arm drives the HlBracketCoordinator —
|
|
84
|
+
// the Binance-shaped path below would silently no-op ("not enabled") on HL,
|
|
85
|
+
// which is exactly the recovery gap the boot guard existed for.
|
|
86
|
+
if (deps.adapter instanceof HyperliquidLiveAdapter) {
|
|
87
|
+
return attachBracketsHl(args, deps.adapter);
|
|
88
|
+
}
|
|
80
89
|
const live = deps.adapter;
|
|
81
90
|
const mgr = live.getBracketManager?.();
|
|
82
91
|
const ledger = live.getBracketLedger?.();
|
|
@@ -267,6 +276,162 @@ async function attachBracketsLive(args, deps) {
|
|
|
267
276
|
attempts: attachResult.attempts,
|
|
268
277
|
};
|
|
269
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Hyperliquid recovery attach (issue #209). Same safety contract as the
|
|
281
|
+
* Binance path — null ≠ empty, positive-evidence-only destructive decisions,
|
|
282
|
+
* idempotent no-op on a live matching bracket — expressed against the
|
|
283
|
+
* HlBracketCoordinator. Classification is the d59e51b floor: a stored cid
|
|
284
|
+
* positively present in a successful getOpenOrders is 'live'; a NON-EMPTY set
|
|
285
|
+
* lacking every stored cid is 'stale'; an EMPTY set or a thrown fetch is
|
|
286
|
+
* 'unknown' (empty is never positive disconfirmation).
|
|
287
|
+
*/
|
|
288
|
+
async function attachBracketsHl(args, adapter) {
|
|
289
|
+
const coordinator = adapter.getHlBracketCoordinator();
|
|
290
|
+
const ledger = coordinator.getLedger();
|
|
291
|
+
// ---- Position must exist (null ≠ empty) ----
|
|
292
|
+
const positions = await adapter.getPositionsOrNull(args.symbol);
|
|
293
|
+
if (positions === null) {
|
|
294
|
+
return {
|
|
295
|
+
error: `Exchange data unavailable for ${args.symbol} (Hyperliquid fetch failed / rate-gated) — ` +
|
|
296
|
+
`position state UNKNOWN. NOT treating this as "no position"; bracket attach skipped ` +
|
|
297
|
+
`this cycle. Retry on next heartbeat.`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const base = args.symbol.split(':')[0];
|
|
301
|
+
const position = positions.find(p => p.symbol?.startsWith(base)) ?? positions[0];
|
|
302
|
+
const contracts = Math.abs(Number(position?.contracts ?? 0));
|
|
303
|
+
if (!position || contracts <= 0) {
|
|
304
|
+
return { error: `No open position for ${args.symbol}.` };
|
|
305
|
+
}
|
|
306
|
+
const entrySide = position.side === 'long' ? 'buy' : 'sell';
|
|
307
|
+
// ---- Direction sanity vs entry price ----
|
|
308
|
+
const refPrice = position.entryPrice > 0 ? position.entryPrice : await adapter.getLastPrice(position.symbol);
|
|
309
|
+
if (refPrice != null && refPrice > 0) {
|
|
310
|
+
if (args.stop_price !== undefined) {
|
|
311
|
+
const msg = validateStopDirection(entrySide, refPrice, args.stop_price);
|
|
312
|
+
if (msg)
|
|
313
|
+
return { error: msg };
|
|
314
|
+
}
|
|
315
|
+
if (args.target_price !== undefined) {
|
|
316
|
+
const msg = validateTargetDirection(entrySide, refPrice, args.target_price);
|
|
317
|
+
if (msg)
|
|
318
|
+
return { error: msg };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// ---- Existing ledger row: classify against the exchange ----
|
|
322
|
+
const existing = ledger.getBySymbol(position.symbol);
|
|
323
|
+
let clearedStaleLedgerRow = false;
|
|
324
|
+
if (existing && !isTerminalBracketState(existing.state)) {
|
|
325
|
+
let cls = 'unknown';
|
|
326
|
+
const hasCids = Boolean(existing.slCid || existing.tpCid);
|
|
327
|
+
if (hasCids) {
|
|
328
|
+
try {
|
|
329
|
+
const open = await adapter.getOpenOrders(position.symbol);
|
|
330
|
+
const liveCids = new Set(open.map(o => o.clientOrderId).filter(Boolean));
|
|
331
|
+
if ((existing.slCid && liveCids.has(existing.slCid)) || (existing.tpCid && liveCids.has(existing.tpCid))) {
|
|
332
|
+
cls = 'live';
|
|
333
|
+
}
|
|
334
|
+
else if (open.length > 0) {
|
|
335
|
+
cls = 'stale'; // non-empty set positively lacking our cids
|
|
336
|
+
} // empty set → 'unknown' (d59e51b: empty is never disconfirmation)
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
cls = 'unknown';
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (cls === 'unknown') {
|
|
343
|
+
return {
|
|
344
|
+
error: `Cannot confirm whether ${position.symbol} brackets are live on Hyperliquid ` +
|
|
345
|
+
`(open-orders unavailable, the set came back empty, or the row is in-flight with no ` +
|
|
346
|
+
`cids yet). NOT assuming protected and NOT reattaching this cycle. Retry next ` +
|
|
347
|
+
`heartbeat; if this persists treat the position as potentially NAKED and verify on ` +
|
|
348
|
+
`the exchange. Do NOT record a hold/protected review on the basis of this call.`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (cls === 'live') {
|
|
352
|
+
if (pricesMatch(args.stop_price, existing.stopPrice)
|
|
353
|
+
&& pricesMatch(args.target_price, existing.targetPrice)) {
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
symbol: position.symbol,
|
|
357
|
+
bracket_id: existing.bracketId,
|
|
358
|
+
entry_side: entrySide,
|
|
359
|
+
stop_price: existing.stopPrice,
|
|
360
|
+
target_price: existing.targetPrice,
|
|
361
|
+
sl_cid: existing.slCid,
|
|
362
|
+
tp_cid: existing.tpCid,
|
|
363
|
+
attach_latency_ms: 0,
|
|
364
|
+
cancelled_stale_bracket_orders: 0,
|
|
365
|
+
cleared_stale_ledger_row: false,
|
|
366
|
+
attempts: 0,
|
|
367
|
+
idempotent_no_op: true,
|
|
368
|
+
note: `Bracket already ${existing.state} on exchange with matching prices — no-op.`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
error: `Bracket already ${existing.state} for ${position.symbol} at ` +
|
|
373
|
+
`stop=${existing.stopPrice ?? 'none'} target=${existing.targetPrice ?? 'none'}. ` +
|
|
374
|
+
`Use modify_stop / modify_target to change levels, or close_position to exit.`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
logger.warn(TAG, `HL ledger row for ${position.symbol} is stale (state=${existing.state}; stored cloids not ` +
|
|
378
|
+
`on exchange, non-empty order set) — forcing terminal to re-attach`);
|
|
379
|
+
ledger.markState(position.symbol, 'cancelled', {
|
|
380
|
+
closeReason: 'cancelled_auto',
|
|
381
|
+
lastError: 'stale_ledger_detected_by_attach_brackets',
|
|
382
|
+
});
|
|
383
|
+
clearedStaleLedgerRow = true;
|
|
384
|
+
}
|
|
385
|
+
// ---- Orphan cleanup (best-effort; protection beats hygiene) ----
|
|
386
|
+
let cancelledOrphans = 0;
|
|
387
|
+
try {
|
|
388
|
+
cancelledOrphans = await adapter.cancelSymbolBracketLegs(position.symbol);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
logger.warn(TAG, `HL orphan cleanup failed for ${position.symbol}: ${formatError(err)}`);
|
|
392
|
+
}
|
|
393
|
+
// ---- Register + attach ----
|
|
394
|
+
const bracketId = generateBracketId();
|
|
395
|
+
try {
|
|
396
|
+
coordinator.registerEntry({
|
|
397
|
+
symbol: position.symbol,
|
|
398
|
+
side: entrySide,
|
|
399
|
+
stopPrice: args.stop_price,
|
|
400
|
+
targetPrice: args.target_price,
|
|
401
|
+
}, bracketId, buildHlBracketCloid(bracketId, 'entry'));
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
return { error: `registerEntry failed: ${formatError(err)}` };
|
|
405
|
+
}
|
|
406
|
+
let attachResult;
|
|
407
|
+
try {
|
|
408
|
+
attachResult = await coordinator.attachOnFill(position.symbol, contracts);
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return { error: `attach failed: ${formatError(err)}` };
|
|
412
|
+
}
|
|
413
|
+
if (!attachResult.ok) {
|
|
414
|
+
return {
|
|
415
|
+
error: `Bracket attach failed after ${attachResult.attempts} attempt(s): ` +
|
|
416
|
+
`${attachResult.error ?? 'unknown error'}. Position remains open. Try again with ` +
|
|
417
|
+
`adjusted levels, or close_position to exit.`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
symbol: position.symbol,
|
|
423
|
+
bracket_id: bracketId,
|
|
424
|
+
entry_side: entrySide,
|
|
425
|
+
stop_price: args.stop_price,
|
|
426
|
+
target_price: args.target_price,
|
|
427
|
+
sl_cid: attachResult.slCid,
|
|
428
|
+
tp_cid: attachResult.tpCid,
|
|
429
|
+
attach_latency_ms: attachResult.latencyMs,
|
|
430
|
+
cancelled_stale_bracket_orders: cancelledOrphans,
|
|
431
|
+
cleared_stale_ledger_row: clearedStaleLedgerRow,
|
|
432
|
+
attempts: attachResult.attempts,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
270
435
|
// Non-terminal bracket states — a row in any of these means brackets are
|
|
271
436
|
// either in-flight or fully live; attach_brackets refuses to clobber them
|
|
272
437
|
// UNLESS the ledger cids don't match any live exchange order (stale row).
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
import { parseBracketCid } from '../live/bracket-id.js';
|
|
18
18
|
import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
|
|
19
19
|
import { formatError, logger } from '../logger.js';
|
|
20
|
+
import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
|
|
21
|
+
import { isTerminalBracketState } from '../venues/hyperliquid/hl-bracket-coordinator.js';
|
|
22
|
+
import { parseHlBracketCloid } from '../venues/hyperliquid/hl-cloid.js';
|
|
23
|
+
import { isProtectiveHlOrder } from '../venues/hyperliquid/hl-brackets.js';
|
|
20
24
|
const TAG = 'audit-bracket-protection';
|
|
21
25
|
// Per-symbol verdict history for flicker detection. When audit verdicts
|
|
22
26
|
// transition protected→unprotected→protected repeatedly inside a short
|
|
@@ -73,7 +77,12 @@ export async function auditBracketProtectionTool(_args, deps) {
|
|
|
73
77
|
positions: entries,
|
|
74
78
|
};
|
|
75
79
|
}
|
|
76
|
-
// Live mode.
|
|
80
|
+
// Live mode — venue dispatch (issue #209). The Binance-shaped path below
|
|
81
|
+
// reads algo-order fields HL doesn't have; on HL it produced permanent
|
|
82
|
+
// false "unprotected" verdicts (the spiral seed).
|
|
83
|
+
if (deps.adapter instanceof HyperliquidLiveAdapter) {
|
|
84
|
+
return auditHlLive(deps.adapter);
|
|
85
|
+
}
|
|
77
86
|
const live = deps.adapter;
|
|
78
87
|
const ledger = live.getBracketLedger?.() ?? null;
|
|
79
88
|
const bracketsEnabled = ledger !== null;
|
|
@@ -525,3 +534,150 @@ function extractTriggerPrice(o) {
|
|
|
525
534
|
}
|
|
526
535
|
return undefined;
|
|
527
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Hyperliquid live audit (issue #209). Same contracts as the Binance path:
|
|
539
|
+
* null ≠ empty ({error} on an unavailable read — the agent skips the cycle and
|
|
540
|
+
* MUST NOT act), and an EMPTY open-orders set against a non-terminal ledger row
|
|
541
|
+
* WITH cids is unverifiable, never "unprotected" (HL has no zero-weight per-leg
|
|
542
|
+
* resolver, so the honest floor is the whole-audit {error}). External trigger
|
|
543
|
+
* legs count as protection (`manual_order_protecting`) via the HL order shape
|
|
544
|
+
* (isTrigger/reduceOnly/orderType — T-7 fields), mirroring the Binance rule
|
|
545
|
+
* that a manually-placed stop must never be reported as naked.
|
|
546
|
+
*/
|
|
547
|
+
async function auditHlLive(adapter) {
|
|
548
|
+
const ledger = adapter.getHlBracketCoordinator().getLedger();
|
|
549
|
+
const positionsOrNull = await adapter.getPositionsOrNull();
|
|
550
|
+
if (positionsOrNull === null) {
|
|
551
|
+
return {
|
|
552
|
+
error: 'Exchange data unavailable (Hyperliquid fetch failed / rate-gated). Skipping audit ' +
|
|
553
|
+
'this cycle — bracket state on the exchange is unchanged. Retry on next heartbeat.',
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const activePositions = positionsOrNull.filter(p => Math.abs(Number(p.contracts ?? 0)) > 0);
|
|
557
|
+
if (activePositions.length === 0) {
|
|
558
|
+
return {
|
|
559
|
+
ok: true,
|
|
560
|
+
mode: 'live',
|
|
561
|
+
brackets_enabled: true,
|
|
562
|
+
total_positions: 0,
|
|
563
|
+
protected_count: 0,
|
|
564
|
+
unprotected_count: 0,
|
|
565
|
+
positions: [],
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const entries = [];
|
|
569
|
+
for (const p of activePositions) {
|
|
570
|
+
let symbolOrders;
|
|
571
|
+
try {
|
|
572
|
+
symbolOrders = await adapter.getOpenOrders(p.symbol);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
const m = formatError(err);
|
|
576
|
+
logger.warn(TAG, `HL audit skipped — open orders unavailable for ${p.symbol}: ${m}`);
|
|
577
|
+
return {
|
|
578
|
+
error: `Exchange data unavailable for ${p.symbol} (Hyperliquid open-orders fetch failed). ` +
|
|
579
|
+
`Skipping audit this cycle — do NOT run attach_brackets or ` +
|
|
580
|
+
`close_position(reason='bracket_integrity') on the basis of this call. Underlying: ${m}`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const ledgerRow = ledger.getBySymbol(p.symbol) ?? null;
|
|
584
|
+
const rowNonTerminalWithCids = !!ledgerRow && !isTerminalBracketState(ledgerRow.state) && Boolean(ledgerRow.slCid || ledgerRow.tpCid);
|
|
585
|
+
if (symbolOrders.length === 0 && rowNonTerminalWithCids) {
|
|
586
|
+
// Untrusted-empty contradiction (the SOL/INJ lesson, HL edition): the
|
|
587
|
+
// ledger says legs are live but the successful fetch shows nothing.
|
|
588
|
+
// Empty is NOT positive disconfirmation — refuse honestly.
|
|
589
|
+
return {
|
|
590
|
+
error: `Cannot confirm bracket protection for ${p.symbol} — the exchange returned an empty ` +
|
|
591
|
+
`open-orders set while the ledger row is ${ledgerRow.state} with stored cloids. ` +
|
|
592
|
+
`Empty ≠ "brackets gone". Skipping audit this cycle; do NOT run attach_brackets or ` +
|
|
593
|
+
`close_position(reason='bracket_integrity'). Retry next heartbeat.`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
let hasBracketStop = false;
|
|
597
|
+
let hasBracketTarget = false;
|
|
598
|
+
let hasManualStop = false;
|
|
599
|
+
let hasManualTarget = false;
|
|
600
|
+
let stopPrice;
|
|
601
|
+
let targetPrice;
|
|
602
|
+
for (const o of symbolOrders) {
|
|
603
|
+
const parsed = o.clientOrderId ? parseHlBracketCloid(o.clientOrderId) : null;
|
|
604
|
+
const triggerPrice = extractTriggerPrice(o);
|
|
605
|
+
const info = o.info ?? {};
|
|
606
|
+
if (parsed?.role === 'stop') {
|
|
607
|
+
hasBracketStop = true;
|
|
608
|
+
stopPrice = triggerPrice ?? stopPrice;
|
|
609
|
+
}
|
|
610
|
+
else if (parsed?.role === 'target') {
|
|
611
|
+
hasBracketTarget = true;
|
|
612
|
+
targetPrice = triggerPrice ?? targetPrice;
|
|
613
|
+
}
|
|
614
|
+
else if (isProtectiveHlOrder(info)) {
|
|
615
|
+
const t = String(info.orderType ?? o.type ?? '').toLowerCase();
|
|
616
|
+
if (t.includes('take profit') || t.includes('tp') || t.includes('take_profit')) {
|
|
617
|
+
hasManualTarget = true;
|
|
618
|
+
targetPrice = triggerPrice ?? targetPrice;
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
hasManualStop = true;
|
|
622
|
+
stopPrice = triggerPrice ?? stopPrice;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const hasStop = hasBracketStop || hasManualStop;
|
|
627
|
+
const hasTarget = hasBracketTarget || hasManualTarget;
|
|
628
|
+
if (ledgerRow?.stopPrice !== undefined)
|
|
629
|
+
stopPrice = ledgerRow.stopPrice;
|
|
630
|
+
if (ledgerRow?.targetPrice !== undefined)
|
|
631
|
+
targetPrice = ledgerRow.targetPrice;
|
|
632
|
+
let reason;
|
|
633
|
+
if (hasStop || hasTarget) {
|
|
634
|
+
reason = hasBracketStop || hasBracketTarget ? 'bracket_active' : 'manual_order_protecting';
|
|
635
|
+
}
|
|
636
|
+
else if (ledgerRow &&
|
|
637
|
+
(ledgerRow.state === 'active' || ledgerRow.state === 'partial' || ledgerRow.state === 'attaching')) {
|
|
638
|
+
reason = 'stale_ledger_row';
|
|
639
|
+
}
|
|
640
|
+
else if (ledgerRow && isTerminalBracketState(ledgerRow.state)) {
|
|
641
|
+
reason = 'ledger_terminal_no_order';
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
reason = 'no_protective_order';
|
|
645
|
+
}
|
|
646
|
+
// ★ T-2 under-coverage: a stop can exist AND still leave scaled-in size
|
|
647
|
+
// naked (fixed-size legs). Surface it in logs; the adapter's 60s
|
|
648
|
+
// truth-check sweep auto-resizes.
|
|
649
|
+
const contracts = Math.abs(Number(p.contracts ?? 0));
|
|
650
|
+
if (hasBracketStop && ledgerRow?.qty !== undefined && ledgerRow.qty + 1e-8 < contracts) {
|
|
651
|
+
logger.warn(TAG, `HL audit: ${p.symbol} stop leg sized ${ledgerRow.qty} vs position ${contracts} — ` +
|
|
652
|
+
'under-covered (T-2); the truth-check sweep will resize');
|
|
653
|
+
}
|
|
654
|
+
entries.push({
|
|
655
|
+
symbol: p.symbol,
|
|
656
|
+
side: p.side,
|
|
657
|
+
contracts: p.contracts,
|
|
658
|
+
entry_price: p.entryPrice,
|
|
659
|
+
mark_price: p.markPrice,
|
|
660
|
+
has_stop: hasStop,
|
|
661
|
+
has_target: hasTarget,
|
|
662
|
+
stop_price: stopPrice,
|
|
663
|
+
target_price: targetPrice,
|
|
664
|
+
ledger_state: ledgerRow?.state ?? 'no_ledger_row',
|
|
665
|
+
reason,
|
|
666
|
+
recommended_action: hasStop ? 'none' : 'attach_brackets',
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
const nowTs = Date.now();
|
|
670
|
+
for (const e of entries) {
|
|
671
|
+
recordVerdictAndCheckFlicker(normalizeBracketSymbol(e.symbol), e.has_stop, nowTs);
|
|
672
|
+
}
|
|
673
|
+
const unprotected = entries.filter(e => !e.has_stop).length;
|
|
674
|
+
return {
|
|
675
|
+
ok: true,
|
|
676
|
+
mode: 'live',
|
|
677
|
+
brackets_enabled: true,
|
|
678
|
+
total_positions: entries.length,
|
|
679
|
+
protected_count: entries.length - unprotected,
|
|
680
|
+
unprotected_count: unprotected,
|
|
681
|
+
positions: entries,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IExchangeAdapter } from '../exchange-adapter.js';
|
|
2
|
+
import type { BracketLedger } from '../live/bracket-ledger.js';
|
|
3
|
+
export interface BracketControl {
|
|
4
|
+
venue: 'binance' | 'hyperliquid';
|
|
5
|
+
ledger: BracketLedger;
|
|
6
|
+
modifyStop(symbol: string, newStopPrice: number): Promise<void>;
|
|
7
|
+
modifyTarget(symbol: string, newTargetPrice: number): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/** Resolve the venue's bracket-mutation surface, or null when brackets are
|
|
10
|
+
* not enabled (Binance with brackets.mode=off; never null on HL live —
|
|
11
|
+
* exchange-side legs are HL live's ONLY protection, so they are always on). */
|
|
12
|
+
export declare function resolveBracketControl(adapter: IExchangeAdapter): BracketControl | null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Venue dispatch for the bracket-mutating tools (issue #209).
|
|
2
|
+
//
|
|
3
|
+
// modify_stop / modify_target need one seam: "give me the thing that can move
|
|
4
|
+
// a leg, plus the ledger that knows the current geometry". On Binance that is
|
|
5
|
+
// BracketManager (+ LiveAdapter's ledger); on Hyperliquid it is the
|
|
6
|
+
// HlBracketCoordinator (which owns its own venue-distinct ledger). The tools
|
|
7
|
+
// must never know which venue they are on beyond this resolver — the
|
|
8
|
+
// hard-cast-to-LiveAdapter pattern is exactly what left the HL arm returning
|
|
9
|
+
// "Bracket orders are not enabled" on every call (the #209 recovery gap).
|
|
10
|
+
import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
|
|
11
|
+
/** Resolve the venue's bracket-mutation surface, or null when brackets are
|
|
12
|
+
* not enabled (Binance with brackets.mode=off; never null on HL live —
|
|
13
|
+
* exchange-side legs are HL live's ONLY protection, so they are always on). */
|
|
14
|
+
export function resolveBracketControl(adapter) {
|
|
15
|
+
if (adapter instanceof HyperliquidLiveAdapter) {
|
|
16
|
+
const coordinator = adapter.getHlBracketCoordinator();
|
|
17
|
+
return {
|
|
18
|
+
venue: 'hyperliquid',
|
|
19
|
+
ledger: coordinator.getLedger(),
|
|
20
|
+
modifyStop: (symbol, px) => coordinator.modifyStop(symbol, px),
|
|
21
|
+
modifyTarget: (symbol, px) => coordinator.modifyTarget(symbol, px),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const live = adapter;
|
|
25
|
+
const mgr = live.getBracketManager?.();
|
|
26
|
+
const ledger = live.getBracketLedger?.();
|
|
27
|
+
if (!mgr || !ledger)
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
venue: 'binance',
|
|
31
|
+
ledger,
|
|
32
|
+
modifyStop: (symbol, px) => mgr.modifyStop(symbol, px),
|
|
33
|
+
modifyTarget: (symbol, px) => mgr.modifyTarget(symbol, px),
|
|
34
|
+
};
|
|
35
|
+
}
|
package/tools/create-order.js
CHANGED
|
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { formatError } from '../logger.js';
|
|
5
5
|
import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
|
|
6
6
|
import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
|
|
7
|
-
import { validateCreateOrder, sanitizeRealizationRule } from './assessment-validation.js';
|
|
7
|
+
import { validateCreateOrder, sanitizeRealizationRule, validateProtectiveGeometry } from './assessment-validation.js';
|
|
8
8
|
import { preTradeRiskCheck, getDefaultPreTradeLimits, computeConsecutiveLosses } from '../risk/pre-trade-check.js';
|
|
9
9
|
import { bracketsEnabled, loadBracketMode, loadBracketRequirements } from '../config/brackets-config.js';
|
|
10
10
|
import { onCreateOrderFilled } from '../ingest/position-auto-capture.js';
|
|
@@ -664,8 +664,14 @@ function buildPortfolioSnapshotFromSimulator(simulator) {
|
|
|
664
664
|
}
|
|
665
665
|
/** Build a PortfolioSnapshot from adapter (live mode). */
|
|
666
666
|
async function buildPortfolioSnapshotFromAdapter(adapter) {
|
|
667
|
+
// Null-honest balance read where the adapter offers one (LiveAdapter) —
|
|
668
|
+
// getBalance() collapses a FAILED fetch to a phantom-zero object, which
|
|
669
|
+
// reads as walletTotal=0 against the real sessionStartNav → ~−100%
|
|
670
|
+
// drawdown → RED zone rejects the entry for the wrong reason (and sizing
|
|
671
|
+
// runs against walletAvailable=0).
|
|
672
|
+
const balanceRead = adapter.getBalanceOrNull?.() ?? adapter.getBalance();
|
|
667
673
|
const [balance, positions] = await Promise.all([
|
|
668
|
-
|
|
674
|
+
balanceRead,
|
|
669
675
|
adapter.getPositionsOrNull(),
|
|
670
676
|
]);
|
|
671
677
|
// null = positions fetch FAILED (429 / weight-paced / transient). If we
|
|
@@ -678,6 +684,11 @@ async function buildPortfolioSnapshotFromAdapter(adapter) {
|
|
|
678
684
|
throw new Error('Cannot build portfolio snapshot — position fetch failed (exchange data ' +
|
|
679
685
|
'unavailable, likely Binance 429 or transient). Order NOT placed; retry next heartbeat.');
|
|
680
686
|
}
|
|
687
|
+
// Same contract for the balance leg: null = fetch failed, NOT a zero wallet.
|
|
688
|
+
if (balance === null) {
|
|
689
|
+
throw new Error('Cannot build portfolio snapshot — balance fetch failed (exchange data ' +
|
|
690
|
+
'unavailable, likely Binance 429 or transient). Order NOT placed; retry next heartbeat.');
|
|
691
|
+
}
|
|
681
692
|
const walletTotal = getQuoteWalletBalance(balance);
|
|
682
693
|
// Use the session-start NAV captured once at initialization, not the current balance.
|
|
683
694
|
// If not available (shouldn't happen after init), fall back to current balance.
|
|
@@ -925,6 +936,23 @@ export async function createOrderTool(args, deps) {
|
|
|
925
936
|
return rejectWave9Divergence(`stopPrice is on the wrong side of entry ${orderPrice} for a ${side} order.`, 'actual_market_stop_geometry_diverged');
|
|
926
937
|
}
|
|
927
938
|
}
|
|
939
|
+
// ---- Generic-path protective geometry gate (issue #200) ----
|
|
940
|
+
// The wrong-side check above ran ONLY for wave9 orders; a generic entry with
|
|
941
|
+
// its stop/invalidation on the profit side passed validation and was closed
|
|
942
|
+
// by the stop-watcher seconds later (7 sub-5-minute kills on the 2026-07 HL
|
|
943
|
+
// soak — one spawned the −$117.86 journal fabrication of issue #199).
|
|
944
|
+
if (!wave9Claimed) {
|
|
945
|
+
const geometryError = validateProtectiveGeometry({
|
|
946
|
+
side: side,
|
|
947
|
+
refPrice: orderPrice,
|
|
948
|
+
stopPrice: args.stopPrice,
|
|
949
|
+
invalidationPrice: args.invalidation_price,
|
|
950
|
+
targetPrice: args.target_price,
|
|
951
|
+
});
|
|
952
|
+
if (geometryError) {
|
|
953
|
+
return { error: `create_order rejected (protective geometry): ${geometryError}` };
|
|
954
|
+
}
|
|
955
|
+
}
|
|
928
956
|
const proposed = {
|
|
929
957
|
symbol: args.symbol,
|
|
930
958
|
side: side,
|
|
@@ -307,5 +307,16 @@ function computeStop(strategy, atr, entryPrice, direction) {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
function roundPrice(p) {
|
|
310
|
-
|
|
310
|
+
// 2 decimals for $1+ prices; below that, 6 significant digits — a flat
|
|
311
|
+
// 2-decimal round collapses entry/stop/targets onto the same value for the
|
|
312
|
+
// sub-cent class (1000PEPE/1000SHIB/…, ATR below the $0.01 granularity),
|
|
313
|
+
// tripping the zero-risk guard and refusing every setup on those symbols.
|
|
314
|
+
if (!Number.isFinite(p) || p === 0)
|
|
315
|
+
return p;
|
|
316
|
+
const abs = Math.abs(p);
|
|
317
|
+
if (abs >= 1)
|
|
318
|
+
return Math.round(p * 100) / 100;
|
|
319
|
+
const decimals = 5 - Math.floor(Math.log10(abs));
|
|
320
|
+
const f = Math.pow(10, decimals);
|
|
321
|
+
return Math.round(p * f) / f;
|
|
311
322
|
}
|
package/tools/modify-stop.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Paper mode: moves the position's metadata.stopPrice in place (the paper
|
|
6
6
|
// stop-watcher reads it), so BE/trail works without a close+reopen round-trip.
|
|
7
7
|
// Live mode without brackets enabled: error asking operator to enable.
|
|
8
|
+
import { resolveBracketControl } from './bracket-control.js';
|
|
8
9
|
import { validateStopDirection } from '../live/bracket-params.js';
|
|
9
10
|
import { formatError } from '../logger.js';
|
|
10
11
|
import { isWave9ManagedPosition } from '../wave9/paper-admission-guard.js';
|
|
@@ -43,12 +44,11 @@ export async function modifyStopTool(args, deps) {
|
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
if (!mgr || !ledger) {
|
|
47
|
+
const control = resolveBracketControl(deps.adapter);
|
|
48
|
+
if (!control) {
|
|
49
49
|
return { error: 'Bracket orders are not enabled. Set brackets.mode in plugin-config.json to "observe" or "enforce".' };
|
|
50
50
|
}
|
|
51
|
-
const entry = ledger.getBySymbol(args.symbol);
|
|
51
|
+
const entry = control.ledger.getBySymbol(args.symbol);
|
|
52
52
|
if (!entry) {
|
|
53
53
|
return { error: `No active bracket for ${args.symbol}.` };
|
|
54
54
|
}
|
|
@@ -62,7 +62,7 @@ export async function modifyStopTool(args, deps) {
|
|
|
62
62
|
return { error: msg };
|
|
63
63
|
}
|
|
64
64
|
try {
|
|
65
|
-
await
|
|
65
|
+
await control.modifyStop(args.symbol, args.new_stop_price);
|
|
66
66
|
return {
|
|
67
67
|
ok: true,
|
|
68
68
|
symbol: args.symbol,
|
package/tools/modify-target.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Tool: modify_target — move the exchange-side take-profit on an open live
|
|
2
2
|
// position. Mirror of modify_stop; see that file for rationale.
|
|
3
|
+
import { resolveBracketControl } from './bracket-control.js';
|
|
3
4
|
import { validateTargetDirection } from '../live/bracket-params.js';
|
|
4
5
|
import { formatError } from '../logger.js';
|
|
5
6
|
import { isWave9ManagedPosition } from '../wave9/paper-admission-guard.js';
|
|
@@ -38,12 +39,11 @@ export async function modifyTargetTool(args, deps) {
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
if (!mgr || !ledger) {
|
|
42
|
+
const control = resolveBracketControl(deps.adapter);
|
|
43
|
+
if (!control) {
|
|
44
44
|
return { error: 'Bracket orders are not enabled. Set brackets.mode in plugin-config.json to "observe" or "enforce".' };
|
|
45
45
|
}
|
|
46
|
-
const entry = ledger.getBySymbol(args.symbol);
|
|
46
|
+
const entry = control.ledger.getBySymbol(args.symbol);
|
|
47
47
|
if (!entry) {
|
|
48
48
|
return { error: `No active bracket for ${args.symbol}.` };
|
|
49
49
|
}
|
|
@@ -56,7 +56,7 @@ export async function modifyTargetTool(args, deps) {
|
|
|
56
56
|
return { error: msg };
|
|
57
57
|
}
|
|
58
58
|
try {
|
|
59
|
-
await
|
|
59
|
+
await control.modifyTarget(args.symbol, args.new_target_price);
|
|
60
60
|
return {
|
|
61
61
|
ok: true,
|
|
62
62
|
symbol: args.symbol,
|
package/tools/scan-pairs.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IntelApiDeps } from './intel-api.js';
|
|
2
|
+
import type { ReentryTracker } from '../portfolio/reentry-tracker.js';
|
|
2
3
|
import type { PositionDecisionsClient } from '../ingest/position-decisions-client.js';
|
|
3
4
|
export interface ScanPairsArgs {
|
|
4
5
|
/** Minimum confluence score (number of conditions met) to include in rankings. */
|
|
@@ -9,6 +10,9 @@ export interface ScanPairsArgs {
|
|
|
9
10
|
export interface ScanPairsDecisionsDeps {
|
|
10
11
|
decisionsClient?: PositionDecisionsClient;
|
|
11
12
|
userId?: string;
|
|
13
|
+
/** Re-entry tracker (issue #204) — flags setups already traded within the
|
|
14
|
+
* current signal bar. Indication only; nothing is filtered out. */
|
|
15
|
+
reentryTracker?: ReentryTracker;
|
|
12
16
|
}
|
|
13
17
|
export declare function scanPairsTool(args: ScanPairsArgs, deps: IntelApiDeps, decisionsDeps?: ScanPairsDecisionsDeps): Promise<Record<string, unknown> | {
|
|
14
18
|
error: string;
|
package/tools/scan-pairs.js
CHANGED
|
@@ -182,15 +182,18 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
182
182
|
const vetoed = [];
|
|
183
183
|
for (const r of results) {
|
|
184
184
|
const matches = entryLearnings.filter(l => learningMatches(l, r.strategy, r.regime));
|
|
185
|
+
const agentSymbol = presentIntelSymbol(deps, r.symbol);
|
|
186
|
+
const reentryCaution = decisionsDeps?.reentryTracker?.cautionFor(agentSymbol, r.strategy);
|
|
185
187
|
const out = {
|
|
186
188
|
// Agent-facing form: on hyperliquid the agent must see the symbol it
|
|
187
189
|
// can hand straight to create_order ('BTC/USDC'), never 'HL_BTC'.
|
|
188
|
-
symbol:
|
|
190
|
+
symbol: agentSymbol,
|
|
189
191
|
score: r.score,
|
|
190
192
|
regime: r.regime,
|
|
191
193
|
strategy: r.strategy,
|
|
192
194
|
conditions: `${r.conditionsMet}/${r.conditionsTotal} met: ${r.conditions.filter(c => c.met).map(c => c.name).join(', ')}`,
|
|
193
195
|
summary: r.summary,
|
|
196
|
+
...(reentryCaution ? { reentry_caution: reentryCaution } : {}),
|
|
194
197
|
};
|
|
195
198
|
if (matches.length === 0) {
|
|
196
199
|
rankings.push(out);
|