@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.
Files changed (52) hide show
  1. package/bridge/gateway/event-parser.d.ts +6 -1
  2. package/bridge/gateway/event-parser.js +19 -2
  3. package/bridge/gateway/poller.d.ts +1 -0
  4. package/bridge/gateway/poller.js +14 -2
  5. package/bridge/providers/gateway.d.ts +22 -2
  6. package/bridge/providers/gateway.js +67 -9
  7. package/ccxt/public-market-data-api.d.ts +14 -0
  8. package/ccxt/public-market-data-api.js +15 -1
  9. package/config/plugin-config-io.d.ts +7 -0
  10. package/config/plugin-config-io.js +15 -0
  11. package/index.js +107 -29
  12. package/ingest/position-auto-capture.d.ts +68 -0
  13. package/ingest/position-auto-capture.js +321 -23
  14. package/ingest/position-decisions-client.d.ts +7 -2
  15. package/ingest/position-decisions-client.js +13 -3
  16. package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
  17. package/ingest/reconcile-db-vs-exchange.js +66 -10
  18. package/live/fill-price.d.ts +13 -0
  19. package/live/fill-price.js +37 -0
  20. package/live/live-adapter.d.ts +33 -1
  21. package/live/live-adapter.js +176 -47
  22. package/live/position-state-store.d.ts +4 -0
  23. package/live/stop-watcher.d.ts +8 -1
  24. package/live/stop-watcher.js +5 -2
  25. package/onboarding/runtime.d.ts +6 -0
  26. package/onboarding/runtime.js +13 -2
  27. package/package.json +2 -2
  28. package/portfolio/reentry-tracker.d.ts +36 -0
  29. package/portfolio/reentry-tracker.js +127 -0
  30. package/signals/conditions/registry.js +11 -2
  31. package/signals/strategy-adapter.js +17 -7
  32. package/simulator/exchange-simulator.d.ts +12 -0
  33. package/simulator/exchange-simulator.js +73 -3
  34. package/simulator/types.d.ts +4 -0
  35. package/skills/reefclaw/SKILL.md +2 -0
  36. package/tools/assessment-validation.d.ts +21 -0
  37. package/tools/assessment-validation.js +58 -0
  38. package/tools/attach-brackets.js +165 -0
  39. package/tools/audit-bracket-protection.js +157 -1
  40. package/tools/bracket-control.d.ts +12 -0
  41. package/tools/bracket-control.js +35 -0
  42. package/tools/create-order.js +30 -2
  43. package/tools/get-setup-detail.js +12 -1
  44. package/tools/modify-stop.js +5 -5
  45. package/tools/modify-target.js +5 -5
  46. package/tools/scan-pairs.d.ts +4 -0
  47. package/tools/scan-pairs.js +4 -1
  48. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  49. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  50. package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
  51. package/venues/hyperliquid/hl-live-adapter.js +380 -5
  52. package/venues/hyperliquid/hl-public.js +8 -1
@@ -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
+ }
@@ -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
- adapter.getBalance(),
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
- return Math.round(p * 100) / 100;
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
  }
@@ -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 mgr = deps.adapter.getBracketManager?.();
47
- const ledger = deps.adapter.getBracketLedger?.();
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 mgr.modifyStop(args.symbol, args.new_stop_price);
65
+ await control.modifyStop(args.symbol, args.new_stop_price);
66
66
  return {
67
67
  ok: true,
68
68
  symbol: args.symbol,
@@ -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 mgr = deps.adapter.getBracketManager?.();
42
- const ledger = deps.adapter.getBracketLedger?.();
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 mgr.modifyTarget(args.symbol, args.new_target_price);
59
+ await control.modifyTarget(args.symbol, args.new_target_price);
60
60
  return {
61
61
  ok: true,
62
62
  symbol: args.symbol,
@@ -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;
@@ -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: presentIntelSymbol(deps, r.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);