@reefclaw/openclaw-plugin 0.1.23 → 0.1.25

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 (95) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.d.ts +3 -1
  3. package/bridge/connector.js +51 -4
  4. package/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/bridge/gateway/poller.d.ts +5 -0
  6. package/bridge/gateway/poller.js +9 -0
  7. package/bridge/index.js +21 -0
  8. package/bridge/provider.d.ts +15 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +26 -1
  14. package/bridge/providers/gateway.js +159 -8
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/shock-wake.d.ts +80 -0
  17. package/bridge/shock-wake.js +291 -0
  18. package/bridge/types.d.ts +5 -1
  19. package/bridge/types.js +5 -0
  20. package/bridge/utils/instance-id.d.ts +3 -0
  21. package/bridge/utils/instance-id.js +48 -0
  22. package/ccxt/binance-private.js +2 -1
  23. package/ccxt/binance-public.js +6 -1
  24. package/config/agent-config-client.d.ts +7 -2
  25. package/config/agent-config-client.js +17 -0
  26. package/config/agent-config-poller.js +5 -1
  27. package/config/brackets-config.d.ts +2 -1
  28. package/config/brackets-config.js +25 -3
  29. package/config/gate-store.d.ts +12 -0
  30. package/config/gate-store.js +26 -2
  31. package/config/loss-streak-config.d.ts +2 -0
  32. package/config/loss-streak-config.js +33 -0
  33. package/config/plugin-config-io.d.ts +19 -0
  34. package/config/plugin-config-io.js +24 -2
  35. package/config/reentry-cooldown-config.d.ts +7 -0
  36. package/config/reentry-cooldown-config.js +59 -0
  37. package/http/keepalive-fetch.d.ts +5 -0
  38. package/http/keepalive-fetch.js +50 -0
  39. package/index.js +77 -8
  40. package/ingest/position-auto-capture.js +49 -4
  41. package/ingest/position-decisions-client.d.ts +6 -0
  42. package/ingest/position-decisions-client.js +27 -9
  43. package/ingest/readiness-reporter.d.ts +23 -2
  44. package/ingest/readiness-reporter.js +56 -1
  45. package/live/approval-lifecycle.d.ts +10 -0
  46. package/live/approval-lifecycle.js +16 -2
  47. package/live/microstructure-assembler.js +11 -2
  48. package/live/proposal-decision-listener.d.ts +21 -0
  49. package/live/proposal-decision-listener.js +39 -0
  50. package/live/proposal-manager.d.ts +12 -0
  51. package/live/proposal-manager.js +47 -0
  52. package/live/stop-watcher.d.ts +16 -1
  53. package/live/stop-watcher.js +48 -8
  54. package/onboarding/runtime.js +4 -0
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +38 -38
  57. package/persistence/state-manager.d.ts +7 -0
  58. package/persistence/state-manager.js +28 -1
  59. package/portfolio/directional-scoreboard.d.ts +17 -0
  60. package/portfolio/directional-scoreboard.js +71 -0
  61. package/portfolio/reentry-tracker.d.ts +38 -1
  62. package/portfolio/reentry-tracker.js +49 -0
  63. package/signals/change-of-character.d.ts +38 -0
  64. package/signals/change-of-character.js +93 -0
  65. package/simulator/exchange-simulator.d.ts +27 -1
  66. package/simulator/exchange-simulator.js +98 -38
  67. package/simulator/types.d.ts +11 -0
  68. package/skills/reefclaw/SKILL.md +2 -2
  69. package/strategy/evaluator.d.ts +4 -0
  70. package/tools/audit-bracket-protection.js +11 -7
  71. package/tools/close-position.js +10 -1
  72. package/tools/create-order.js +121 -9
  73. package/tools/get-funding-context.js +6 -1
  74. package/tools/get-liquidation-levels.js +5 -1
  75. package/tools/get-liquidation-pulse.js +7 -1
  76. package/tools/get-market-intel.js +2 -1
  77. package/tools/get-relevant-learnings.js +20 -1
  78. package/tools/get-resting-liquidity.js +6 -1
  79. package/tools/get-wave9-status.js +17 -0
  80. package/tools/hl-provision-agent-wallet.js +29 -11
  81. package/tools/intel-api.d.ts +9 -0
  82. package/tools/intel-api.js +32 -1
  83. package/tools/record-position-reviews.js +2 -2
  84. package/tools/reentry-cooldown.d.ts +33 -0
  85. package/tools/reentry-cooldown.js +74 -0
  86. package/tools/scan-pairs.d.ts +7 -0
  87. package/tools/scan-pairs.js +67 -11
  88. package/tools/set-exchange-credentials.js +19 -0
  89. package/tools/set-trading-mode.d.ts +6 -0
  90. package/tools/set-trading-mode.js +48 -1
  91. package/types.d.ts +7 -0
  92. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  93. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  94. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  95. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -12,5 +12,10 @@
12
12
  // FUNDING_OVERLAY flag is off or the symbol has < 100 30d samples.
13
13
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
14
14
  export async function getFundingContextTool(args, deps) {
15
- return fetchIntelApi(`/api/funding/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps);
15
+ // Server-side percentile context already has a 15-min TTL — 60s here just
16
+ // collapses same-heartbeat repeats.
17
+ return fetchIntelApi(`/api/funding/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps, {
18
+ cacheTtlMs: 60_000,
19
+ timeoutMs: 10_000,
20
+ });
16
21
  }
@@ -3,5 +3,9 @@
3
3
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
4
4
  export async function getLiquidationLevelsTool(args, deps) {
5
5
  const hours = args.hours ?? 24;
6
- return fetchIntelApi(`/api/liquidation-levels/${enc(resolveIntelSymbol(deps, args.symbol))}?hours=${hours}`, deps);
6
+ // Historical clusters move slowly — 30s TTL collapses per-position repeats.
7
+ return fetchIntelApi(`/api/liquidation-levels/${enc(resolveIntelSymbol(deps, args.symbol))}?hours=${hours}`, deps, {
8
+ cacheTtlMs: 30_000,
9
+ timeoutMs: 10_000,
10
+ });
7
11
  }
@@ -13,7 +13,13 @@ export async function getLiquidationPulseTool(args, deps) {
13
13
  params.set('symbol', resolveIntelSymbol(deps, args.symbol));
14
14
  const windowSeconds = clamp(args.window_seconds ?? 60, 5, 300);
15
15
  params.set('window_seconds', String(windowSeconds));
16
- return fetchIntelApi(`/api/liquidation-pulse?${params.toString()}`, deps);
16
+ // 10s TTL: short enough that an active_cascade classification is never
17
+ // stale-read into the override decision (cascades persist 60-180s), long
18
+ // enough to collapse the per-position repeats within one heartbeat.
19
+ return fetchIntelApi(`/api/liquidation-pulse?${params.toString()}`, deps, {
20
+ cacheTtlMs: 10_000,
21
+ timeoutMs: 10_000,
22
+ });
17
23
  }
18
24
  function clamp(n, lo, hi) {
19
25
  if (!Number.isFinite(n))
@@ -12,6 +12,7 @@
12
12
  // unchanged. This bounds the threat — it can't make the agent place a naked
13
13
  // order (the create_order gate still applies) — and makes injected directives
14
14
  // far less likely to be followed.
15
+ import { keepAliveFetch } from '../http/keepalive-fetch.js';
15
16
  /** Categories whose payloads contain attacker-influenceable free text. */
16
17
  const UNTRUSTED_TEXT_CATEGORIES = new Set(['news', 'social', 'calendar']);
17
18
  const MAX_TEXT_LEN = 2000;
@@ -88,7 +89,7 @@ export async function getMarketIntelTool(args, deps) {
88
89
  url.searchParams.set('symbols', symbols.join(','));
89
90
  }
90
91
  try {
91
- const res = await fetch(url.toString(), {
92
+ const res = await keepAliveFetch(url.toString(), {
92
93
  headers: { Authorization: `Bearer ${connectionToken}` },
93
94
  signal: AbortSignal.timeout(10_000),
94
95
  });
@@ -15,6 +15,13 @@
15
15
  import { logger, formatError } from '../logger.js';
16
16
  const TAG = 'get-relevant-learnings';
17
17
  const VALID_APPLIES_AT = new Set(['entry', 'heartbeat', 'close']);
18
+ // Curated learnings change on operator-curation timescales, but the agent is
19
+ // instructed to read them at several points per heartbeat — and a looping
20
+ // model can spam identical calls (observed live 2026-08-04: ~230 identical
21
+ // calls in one beat drove a 272k-token context overflow). A short TTL keyed
22
+ // on the exact query bounds both.
23
+ const CACHE_TTL_MS = 30_000;
24
+ const cache = new Map();
18
25
  export async function getRelevantLearningsTool(args, deps) {
19
26
  if (!deps.decisionsClient || !deps.userId) {
20
27
  // Ingest not wired — return empty, callable. Same shape as
@@ -32,6 +39,11 @@ export async function getRelevantLearningsTool(args, deps) {
32
39
  error: "applies_at is required and must be one of: 'entry', 'heartbeat', 'close'.",
33
40
  };
34
41
  }
42
+ const cacheKey = `${deps.userId}:${args.applies_at}:${args.setup_type ?? ''}:${args.regime ?? ''}:${args.verdict ?? ''}`;
43
+ const hit = cache.get(cacheKey);
44
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
45
+ return structuredClone(hit.result);
46
+ }
35
47
  let response;
36
48
  try {
37
49
  response = await deps.decisionsClient.getRelevantLearnings(deps.userId, {
@@ -56,10 +68,17 @@ export async function getRelevantLearningsTool(args, deps) {
56
68
  note: 'Webapp /api/internal/learnings returned no result; proceeding without curated learnings.',
57
69
  };
58
70
  }
59
- return {
71
+ const result = {
60
72
  ok: true,
61
73
  learnings: response.learnings,
62
74
  total_candidates: response.totalCandidates,
63
75
  returned_count: response.returnedCount,
64
76
  };
77
+ cache.set(cacheKey, { at: Date.now(), result });
78
+ if (cache.size > 64) {
79
+ const oldest = cache.keys().next().value;
80
+ if (oldest !== undefined)
81
+ cache.delete(oldest);
82
+ }
83
+ return structuredClone(result);
65
84
  }
@@ -7,5 +7,10 @@
7
7
  // `microstructure.bandedLiquidity` flag is off (= columns are NULL).
8
8
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
9
9
  export async function getRestingLiquidityTool(args, deps) {
10
- return fetchIntelApi(`/api/resting-liquidity/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps);
10
+ // Cached: the microstructure assembler hits the same endpoint on the review
11
+ // path each heartbeat; advisory read, so a short TTL + tight timeout.
12
+ return fetchIntelApi(`/api/resting-liquidity/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps, {
13
+ cacheTtlMs: 15_000,
14
+ timeoutMs: 10_000,
15
+ });
11
16
  }
@@ -695,6 +695,23 @@ export async function getWave9StatusTool(deps) {
695
695
  const reversalDue = position
696
696
  ? (position.side === 'long' ? daily.exitLong : daily.exitShort)
697
697
  : false;
698
+ // Quiet-row compaction: no candidate and no held position — the evidence
699
+ // numerics (returns / ATR / reference close) are ~740B per symbol the
700
+ // agent never acts on; × 8 symbols × every heartbeat this was ~68% of
701
+ // the whole status payload. Rows with entries or a position keep the
702
+ // full shape unchanged. Deliberately NOT gated on daily.exitLong/
703
+ // exitShort: those flags are set for most symbols on any
704
+ // negative-momentum day, and with no held position there is nothing to
705
+ // exit — the first shipped version kept them and saved nothing
706
+ // (verified live 2026-08-04: 72 currentReturn mentions per beat).
707
+ if (entries.length === 0 && !position) {
708
+ return {
709
+ symbol,
710
+ capDecision: { status: 'not_candidate', reason: 'no_completed_daily_zero_cross' },
711
+ entries: [],
712
+ reversal: { due: false, timing: 'not_due' },
713
+ };
714
+ }
698
715
  return {
699
716
  symbol,
700
717
  currentReturn: daily.currentReturn,
@@ -96,22 +96,40 @@ export async function hlProvisionAgentWalletTool(args, deps) {
96
96
  const derived = await deriveAddressFromPrivateKey(existingKey);
97
97
  if (derived.ok) {
98
98
  const masterMatches = existingMaster != null && existingMaster.toLowerCase() === walletAddress.toLowerCase();
99
- // ★ Honour a CHANGED network on the resume path. The same keypair is
100
- // valid on both Hyperliquid networks, so switching mainnet<->testnet
101
- // must not require regenerating the wallet — but the stored flag has to
102
- // follow, or the box boots against the network the operator did NOT
103
- // pick. Before this, unticking "Use Hyperliquid testnet" after
104
- // provisioning silently kept testnet:true, and the approval was signed
105
- // for Testnet while the operator believed they were on mainnet
106
- // (observed live 2026-08-03).
107
- if (masterMatches && existingTestnet !== testnet) {
99
+ // ★ Re-ACTIVATE the venue on resume (E2E audit 2026-08-11 #6) and honour
100
+ // a CHANGED network in the same write.
101
+ //
102
+ // Venue: the resume path used to write NOTHING unless the network flag
103
+ // moved so a box whose active venue was still binance (HL provisioned
104
+ // earlier and switched back, or a live-Binance box that just clicked
105
+ // through the confirm-switch dialog above) kept `venue:'binance'` on
106
+ // disk. hl_agent_wallet_status only reports configured when
107
+ // `exchange.venue==='hyperliquid'`, so the guided flow dead-ended
108
+ // polling configured:false forever, and the confirm dialog "switched"
109
+ // nothing. By this point the venue-switch confirm gate has already run,
110
+ // so the activation write is authorized. buildVenueExchangeConfig with
111
+ // empty fields activates the venue while keeping BOTH venues'
112
+ // credentials (the merge contract).
113
+ //
114
+ // Network: the same keypair is valid on both Hyperliquid networks, so
115
+ // mainnet<->testnet must not require regenerating — but the stored flag
116
+ // has to follow, or the box boots against the network the operator did
117
+ // NOT pick (observed live 2026-08-03: approval signed for Testnet while
118
+ // the operator believed mainnet).
119
+ const needsVenueActivation = fromVenue !== 'hyperliquid';
120
+ if (masterMatches && (needsVenueActivation || existingTestnet !== testnet)) {
108
121
  try {
109
122
  updatePluginConfig({ exchange: buildVenueExchangeConfig(existingExchange, 'hyperliquid', {}, testnet) }, deps.configPath);
123
+ if (needsVenueActivation) {
124
+ logger.info(TAG, 'venue re-activated: hyperliquid (existing agent wallet resumed)');
125
+ }
126
+ if (existingTestnet !== testnet) {
127
+ logger.info(TAG, `network switched to ${testnet ? 'TESTNET' : 'MAINNET'} (same agent wallet)`);
128
+ }
110
129
  existingTestnet = testnet;
111
- logger.info(TAG, `network switched to ${testnet ? 'TESTNET' : 'MAINNET'} (same agent wallet)`);
112
130
  }
113
131
  catch (err) {
114
- logger.warn(TAG, `could not persist network change: ${err instanceof Error ? err.message : String(err)}`);
132
+ logger.warn(TAG, `could not persist venue/network change: ${err instanceof Error ? err.message : String(err)}`);
115
133
  }
116
134
  }
117
135
  return {
@@ -26,7 +26,16 @@ export interface FetchOptions {
26
26
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
27
27
  body?: unknown;
28
28
  timeoutMs?: number;
29
+ /** Opt-in TTL cache for GET reads. The same intel endpoints get hit several
30
+ * times per heartbeat by different callers (agent tool + microstructure
31
+ * assembler + repeated agent calls) — a short shared TTL collapses those
32
+ * into one round-trip. Never set on polling reads that must observe fresh
33
+ * server state (e.g. backtest status). */
34
+ cacheTtlMs?: number;
29
35
  }
36
+ /** Test support: drop every cached GET response (the cache is module-level,
37
+ * so suites that stub fetch with per-test responses must clear it). */
38
+ export declare function clearIntelGetCache(): void;
30
39
  /** Encode a value for safe use in a URL path segment */
31
40
  export declare const enc: typeof encodeURIComponent;
32
41
  export declare function fetchIntelApi(path: string, deps: IntelApiDeps, options?: FetchOptions): Promise<Record<string, unknown> | {
@@ -1,5 +1,6 @@
1
1
  // Shared helper for Intelligence API calls.
2
2
  // All intelligence tools use Bearer token auth against intel.reefclaw.com.
3
+ import { keepAliveFetch } from '../http/keepalive-fetch.js';
3
4
  import { HL_INTEL_PREFIX, fromIntelSymbol, toIntelSymbol } from '../venues/symbols.js';
4
5
  // ─── Venue-aware intel-symbol mapping (plan §5.3: the agent keeps using
5
6
  // canonical symbols like BTC/USDC; these helpers translate at the intel
@@ -67,6 +68,13 @@ export function intelSymbolOnVenue(deps, intelSymbol) {
67
68
  const isHl = intelSymbol.startsWith(HL_INTEL_PREFIX);
68
69
  return (deps.venue ?? 'binance') === 'hyperliquid' ? isHl : !isHl;
69
70
  }
71
+ const getCache = new Map();
72
+ const GET_CACHE_MAX_ENTRIES = 256;
73
+ /** Test support: drop every cached GET response (the cache is module-level,
74
+ * so suites that stub fetch with per-test responses must clear it). */
75
+ export function clearIntelGetCache() {
76
+ getCache.clear();
77
+ }
70
78
  /** Encode a value for safe use in a URL path segment */
71
79
  export const enc = encodeURIComponent;
72
80
  export async function fetchIntelApi(path, deps, options) {
@@ -78,6 +86,29 @@ export async function fetchIntelApi(path, deps, options) {
78
86
  return { error: 'No intelligence URL configured. Set intelligenceUrl in openclaw.json plugin config.' };
79
87
  }
80
88
  const url = `${intelligenceUrl}${path}`;
89
+ const ttl = options?.cacheTtlMs ?? 0;
90
+ if (ttl > 0 && (options?.method ?? 'GET') === 'GET') {
91
+ const key = `${connectionToken}:${url}`;
92
+ const hit = getCache.get(key);
93
+ if (hit && Date.now() - hit.at < ttl) {
94
+ // structuredClone: callers must never alias each other's response object.
95
+ return hit.value.then((v) => structuredClone(v));
96
+ }
97
+ const value = fetchIntelApiUncached(url, connectionToken, options);
98
+ getCache.set(key, { at: Date.now(), value });
99
+ // Failures don't stick for the TTL — next caller retries fresh.
100
+ void value.then((v) => { if (v && typeof v === 'object' && 'error' in v)
101
+ getCache.delete(key); }, () => getCache.delete(key));
102
+ if (getCache.size > GET_CACHE_MAX_ENTRIES) {
103
+ const oldest = getCache.keys().next().value;
104
+ if (oldest !== undefined)
105
+ getCache.delete(oldest);
106
+ }
107
+ return value.then((v) => structuredClone(v));
108
+ }
109
+ return fetchIntelApiUncached(url, connectionToken, options);
110
+ }
111
+ async function fetchIntelApiUncached(url, connectionToken, options) {
81
112
  const headers = { Authorization: `Bearer ${connectionToken}` };
82
113
  const fetchInit = {
83
114
  method: options?.method ?? 'GET',
@@ -89,7 +120,7 @@ export async function fetchIntelApi(path, deps, options) {
89
120
  fetchInit.body = JSON.stringify(options.body);
90
121
  }
91
122
  try {
92
- const res = await fetch(url, fetchInit);
123
+ const res = await keepAliveFetch(url, fetchInit);
93
124
  if (res.status === 401) {
94
125
  return { error: 'Invalid or expired ReefClaw connection token.' };
95
126
  }
@@ -94,7 +94,6 @@ export async function recordPositionReviewsTool(args, deps) {
94
94
  // not load-bearing (each row carries its own reviewAt).
95
95
  let filed = 0;
96
96
  if (deps.decisionsClient && deps.userId) {
97
- const promises = [];
98
97
  for (const r of reviews) {
99
98
  const stateEntry = deps.stateStore?.get(r.symbol);
100
99
  const positionId = stateEntry?.webappPositionId;
@@ -137,7 +136,8 @@ export async function recordPositionReviewsTool(args, deps) {
137
136
  deps.stateStore.recordReview(r.symbol, r.verdict, undefined, r.thesis_status);
138
137
  }
139
138
  }
140
- await Promise.allSettled(promises);
139
+ // POSTs above are fire-and-forget by design — durability is the client's
140
+ // in-flight set + drain(), not an await here.
141
141
  }
142
142
  else {
143
143
  logger.info(TAG, `record_position_reviews validated ${reviews.length} reviews; no decisionsClient configured (off-mode).`);
@@ -0,0 +1,33 @@
1
+ import type { ReentryExitRecord, ReentryTracker } from '../portfolio/reentry-tracker.js';
2
+ export type ReentryCooldownMode = 'off' | 'shadow' | 'observe' | 'enforce';
3
+ export interface ReentryCooldownEval {
4
+ /** Mode the gate ran under (never 'off' — off is not evaluated). */
5
+ mode: Exclude<ReentryCooldownMode, 'off'>;
6
+ /** A lossy same-symbol close exists within the cooldown window. */
7
+ triggered: boolean;
8
+ /** triggered && mode === 'enforce' — caller must reject the order. */
9
+ blocked: boolean;
10
+ code: 'no_recent_loss' | 'cooldown_active' | 'scale_in_exempt' | 'operator_approved_exempt' | 'tracker_unavailable';
11
+ cooldownMinutes: number;
12
+ /** Minutes since the arming loss (only when triggered). */
13
+ minutesSinceLoss?: number;
14
+ /** The arming loss (only when triggered). */
15
+ lastLoss?: Pick<ReentryExitRecord, 'side' | 'setupType' | 'closedAtMs' | 'lossSource'>;
16
+ }
17
+ export interface ReentryCooldownInputs {
18
+ symbol: string;
19
+ mode: Exclude<ReentryCooldownMode, 'off'>;
20
+ tracker: Pick<ReentryTracker, 'lastLossyExit'> | undefined;
21
+ /** Current trading book — filters tracker records after a paper↔live flip. */
22
+ book: 'paper' | 'live';
23
+ cooldownMinutes: number;
24
+ /** Same-symbol position already open → this order is a scale-in, exempt. */
25
+ hasOpenPosition: boolean;
26
+ /** ProposalDecisionListener fire of an operator-APPROVED proposal, exempt. */
27
+ isListenerFire: boolean;
28
+ nowMs?: number;
29
+ }
30
+ export declare function evaluateReentryCooldown(inputs: ReentryCooldownInputs): ReentryCooldownEval;
31
+ /** Agent-facing rejection for enforce mode. Names the gate, the remaining
32
+ * wait, and the honest path forward — no bypass hint by design. */
33
+ export declare function buildReentryCooldownRejection(ev: ReentryCooldownEval, symbol: string): string;
@@ -0,0 +1,74 @@
1
+ // Re-entry cooldown gate — blocks (mode-laddered) a NEW entry on a symbol
2
+ // whose last close within the cooldown window was a LOSS.
3
+ //
4
+ // Evidence (2026-09-02, full live journal): entries opened within 60 minutes
5
+ // of a same-symbol losing close ran mean −0.077R (n=58) vs +0.090R for all
6
+ // other entries (n=1151) at identical ~41% win rates, plus the extra fee load
7
+ // — the LINK/DOGE churn signature of the 2026-06 investigation recurring on
8
+ // the HL live book. Suggestive, not proven, at n=58 — which is exactly what
9
+ // the shadow rung is for: this ships `off` by default, is flipped shadow-first
10
+ // via the central gate channel (`agent_config.gates.reentryCooldown`), and
11
+ // tags would-block entries into position_entries.metadata.reentry_cooldown so
12
+ // the forward counterfactual is measurable in the journal before any enforce
13
+ // decision.
14
+ //
15
+ // Scope guards (all fail OPEN — this gate can only ever suppress a NEW entry,
16
+ // never an exit, close, stop, or emergency action):
17
+ // - generic entries only (wave9 has its own frozen admission policy);
18
+ // - scale-ins exempt (position already open — entry already happened);
19
+ // - operator-approved proposal fires exempt (human already said yes);
20
+ // - tracker unavailable → pass.
21
+ //
22
+ // Mode semantics mirror the exit gate (docs/CLAUDE/exit-gate.md):
23
+ // off — not evaluated; byte-identical to the pre-gate path.
24
+ // shadow — evaluated + logged + journal-tagged; never affects the order.
25
+ // observe — as shadow, but a triggered eval logs at WARN (operator-visible).
26
+ // enforce — a triggered eval hard-rejects create_order with a recovery hint.
27
+ export function evaluateReentryCooldown(inputs) {
28
+ const base = {
29
+ mode: inputs.mode,
30
+ triggered: false,
31
+ blocked: false,
32
+ cooldownMinutes: inputs.cooldownMinutes,
33
+ };
34
+ if (inputs.isListenerFire)
35
+ return { ...base, code: 'operator_approved_exempt' };
36
+ if (inputs.hasOpenPosition)
37
+ return { ...base, code: 'scale_in_exempt' };
38
+ if (!inputs.tracker)
39
+ return { ...base, code: 'tracker_unavailable' };
40
+ const nowMs = inputs.nowMs ?? Date.now();
41
+ const windowMs = inputs.cooldownMinutes * 60_000;
42
+ const loss = inputs.tracker.lastLossyExit(inputs.symbol, windowMs, {
43
+ mode: inputs.book,
44
+ nowMs,
45
+ });
46
+ if (!loss)
47
+ return { ...base, code: 'no_recent_loss' };
48
+ const minutesSinceLoss = Math.max(0, Math.round((nowMs - loss.closedAtMs) / 60_000));
49
+ return {
50
+ ...base,
51
+ triggered: true,
52
+ blocked: inputs.mode === 'enforce',
53
+ code: 'cooldown_active',
54
+ minutesSinceLoss,
55
+ lastLoss: {
56
+ side: loss.side,
57
+ setupType: loss.setupType,
58
+ closedAtMs: loss.closedAtMs,
59
+ lossSource: loss.lossSource,
60
+ },
61
+ };
62
+ }
63
+ /** Agent-facing rejection for enforce mode. Names the gate, the remaining
64
+ * wait, and the honest path forward — no bypass hint by design. */
65
+ export function buildReentryCooldownRejection(ev, symbol) {
66
+ const remaining = Math.max(1, ev.cooldownMinutes - (ev.minutesSinceLoss ?? 0));
67
+ return (`create_order rejected (reentry cooldown): ${symbol} closed at a LOSS ` +
68
+ `${ev.minutesSinceLoss}m ago and the operator-configured cooldown is ` +
69
+ `${ev.cooldownMinutes}m — ~${remaining}m remaining. This is a mechanical ` +
70
+ `gate against re-entry churn (measured −0.17R/trade edge gap on re-entries ` +
71
+ `within the window). Do not retry this symbol until the cooldown lapses; ` +
72
+ `spend the time re-scoring the setup — if it is still valid then, enter then. ` +
73
+ `Other symbols are unaffected.`);
74
+ }
@@ -13,6 +13,13 @@ export interface ScanPairsDecisionsDeps {
13
13
  /** Re-entry tracker (issue #204) — flags setups already traded within the
14
14
  * current signal bar. Indication only; nothing is filtered out. */
15
15
  reentryTracker?: ReentryTracker;
16
+ /** WS2 directional scoreboard inputs (docs/MARKET_ADAPTIVITY_PLAN.md §3) —
17
+ * tracked open positions (state store; no exchange round-trip) + the
18
+ * current book. Indication only, like everything else in this block. */
19
+ openPositions?: () => Array<{
20
+ side: 'long' | 'short';
21
+ }>;
22
+ book?: () => 'paper' | 'live';
16
23
  }
17
24
  export declare function scanPairsTool(args: ScanPairsArgs, deps: IntelApiDeps, decisionsDeps?: ScanPairsDecisionsDeps): Promise<Record<string, unknown> | {
18
25
  error: string;
@@ -17,8 +17,31 @@
17
17
  // heartbeat the agent was rejecting post-scorecard pre-fix.
18
18
  import { intelSymbolOnVenue, presentIntelSymbol, resolveIntelSymbol } from './intel-api.js';
19
19
  import { scanAllPairs } from '../strategy/evaluator.js';
20
+ import { buildDirectionalScoreboard } from '../portfolio/directional-scoreboard.js';
20
21
  import { getAllFactsCached, getStrategiesCached, __testing__ as cacheTesting } from './intel-cache.js';
21
22
  import { normalizeSetupFamily, resolveTriggerFamilies } from '../learning/setup-family.js';
23
+ /** WS2 kill-switch — RC_CHANGE_OF_CHARACTER=off suppresses the market-shift
24
+ * cautions + directional scoreboard without a redeploy. Default on. */
25
+ function changeOfCharacterEnabled() {
26
+ return (process.env.RC_CHANGE_OF_CHARACTER ?? '').toLowerCase() !== 'off';
27
+ }
28
+ /** Tape line for the scoreboard from the market leader's fact (BTC on this
29
+ * venue; falls back to the first fact so alt-only books still get a tape). */
30
+ function leaderTapeLine(deps, facts) {
31
+ const leader = facts.find((f) => presentIntelSymbol(deps, f.symbol).toUpperCase().startsWith('BTC/')) ??
32
+ facts[0];
33
+ const coc = leader?.changeOfCharacter;
34
+ if (!coc)
35
+ return {};
36
+ const sym = presentIntelSymbol(deps, leader.symbol).split('/')[0];
37
+ const sign = (v) => (v >= 0 ? '+' : '');
38
+ return {
39
+ line: `${sym} 4h ${sign(coc.return4hPct)}${coc.return4hPct}% ` +
40
+ `(${sign(coc.tape4hAtr)}${coc.tape4hAtr}×ATR)` +
41
+ (coc.flags.length > 0 ? ` [${coc.flags.join(', ')}]` : ''),
42
+ caution: coc.flags.length > 0 ? coc.summary : undefined,
43
+ };
44
+ }
22
45
  const LEARNINGS_TTL_MS = 60_000;
23
46
  const entryLearningsCache = new Map();
24
47
  /** Family-keying gate (2026-06-22). Off by default → byte-identical exact-match
@@ -129,8 +152,20 @@ function learningMatches(learning, setupType, regime) {
129
152
  }
130
153
  export async function scanPairsTool(args, deps, decisionsDeps) {
131
154
  const minScore = args.min_score ?? 4;
132
- // 1. Fetch all symbol facts (cached, shared with get_setup_detail)
133
- const factsRes = await getAllFactsCached(deps);
155
+ // Facts, strategies, and entry learnings are three independent reads (two
156
+ // intel, one webapp) — start them all now and await in the original order,
157
+ // so error precedence (facts → strategies → learnings-fail-quiet) and every
158
+ // early-return body stay byte-identical while the round-trips overlap.
159
+ const factsPromise = getAllFactsCached(deps);
160
+ const stratPromise = getStrategiesCached(deps);
161
+ const learningsPromise = decisionsDeps?.decisionsClient && decisionsDeps.userId
162
+ ? getEntryLearningsCached(decisionsDeps.decisionsClient, decisionsDeps.userId)
163
+ : Promise.resolve([]);
164
+ // An early return below must not leave a floating rejection behind.
165
+ stratPromise.catch(() => { });
166
+ learningsPromise.catch(() => { });
167
+ // 1. All symbol facts (cached, shared with get_setup_detail)
168
+ const factsRes = await factsPromise;
134
169
  if ('error' in factsRes)
135
170
  return factsRes;
136
171
  // Venue scope: only rank symbols this box can actually trade. Without this
@@ -154,8 +189,8 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
154
189
  : 'No symbol facts available. Intelligence service may still be computing initial data.',
155
190
  };
156
191
  }
157
- // 2. Fetch user's active strategies (cached, shared with get_setup_detail)
158
- const stratRes = await getStrategiesCached(deps);
192
+ // 2. User's active strategies (cached, shared with get_setup_detail)
193
+ const stratRes = await stratPromise;
159
194
  if ('error' in stratRes)
160
195
  return stratRes;
161
196
  const strategies = stratRes;
@@ -171,19 +206,20 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
171
206
  }
172
207
  // 3. Evaluate all strategies against all facts
173
208
  const results = scanAllPairs(strategies, facts, minScore);
174
- // 4. Fetch user's confirmed entry-time learnings (optional path — fail
175
- // quietly when the decisions client isn't wired, e.g. in dev or in
176
- // tests that exercise the pre-learning behaviour).
177
- let entryLearnings = [];
178
- if (decisionsDeps?.decisionsClient && decisionsDeps.userId) {
179
- entryLearnings = await getEntryLearningsCached(decisionsDeps.decisionsClient, decisionsDeps.userId);
180
- }
209
+ // 4. User's confirmed entry-time learnings (optional path — resolves []
210
+ // when the decisions client isn't wired, e.g. in dev or in tests that
211
+ // exercise the pre-learning behaviour).
212
+ const entryLearnings = await learningsPromise;
181
213
  const rankings = [];
182
214
  const vetoed = [];
215
+ // WS2 change-of-character: per-symbol lookup + market-leader tape line.
216
+ const cocOn = changeOfCharacterEnabled();
217
+ const factBySymbol = new Map(facts.map(f => [f.symbol, f]));
183
218
  for (const r of results) {
184
219
  const matches = entryLearnings.filter(l => learningMatches(l, r.strategy, r.regime));
185
220
  const agentSymbol = presentIntelSymbol(deps, r.symbol);
186
221
  const reentryCaution = decisionsDeps?.reentryTracker?.cautionFor(agentSymbol, r.strategy);
222
+ const coc = cocOn ? factBySymbol.get(r.symbol)?.changeOfCharacter : undefined;
187
223
  const out = {
188
224
  // Agent-facing form: on hyperliquid the agent must see the symbol it
189
225
  // can hand straight to create_order ('BTC/USDC'), never 'HL_BTC'.
@@ -194,6 +230,7 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
194
230
  conditions: `${r.conditionsMet}/${r.conditionsTotal} met: ${r.conditions.filter(c => c.met).map(c => c.name).join(', ')}`,
195
231
  summary: r.summary,
196
232
  ...(reentryCaution ? { reentry_caution: reentryCaution } : {}),
233
+ ...(coc && coc.flags.length > 0 && coc.summary ? { market_shift_caution: coc.summary } : {}),
197
234
  };
198
235
  if (matches.length === 0) {
199
236
  rankings.push(out);
@@ -214,10 +251,29 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
214
251
  const noSetup = facts
215
252
  .filter(f => !setupSymbols.has(f.symbol))
216
253
  .map(f => presentIntelSymbol(deps, f.symbol));
254
+ // WS2 top-level indication: leader tape + shift caution + the directional
255
+ // scoreboard (book tilt vs recent per-direction outcomes vs tape). All
256
+ // indication-only; RC_CHANGE_OF_CHARACTER=off suppresses without redeploy.
257
+ let marketCaution;
258
+ let scoreboard;
259
+ if (cocOn) {
260
+ const tape = leaderTapeLine(deps, facts);
261
+ marketCaution = tape.caution;
262
+ if (decisionsDeps?.reentryTracker) {
263
+ scoreboard = buildDirectionalScoreboard({
264
+ openPositions: decisionsDeps.openPositions?.() ?? [],
265
+ exitRecords: decisionsDeps.reentryTracker.getRecords(),
266
+ book: decisionsDeps.book?.() ?? 'paper',
267
+ tapeLine: tape.line,
268
+ });
269
+ }
270
+ }
217
271
  return {
218
272
  timestamp: new Date().toISOString(),
219
273
  pairs_scanned: facts.length,
220
274
  setups_found: results.length,
275
+ ...(marketCaution ? { market_caution: marketCaution } : {}),
276
+ ...(scoreboard ? { directional_scoreboard: scoreboard } : {}),
221
277
  rankings,
222
278
  vetoed_setups: vetoed.length > 0 ? vetoed : undefined,
223
279
  no_setup: noSetup.length > 5
@@ -34,12 +34,31 @@ import { parseVenue } from '../venues/registry.js';
34
34
  import { unsealCredentials, SealedEnvelopeError } from '../security/sealed-credentials.js';
35
35
  import { logger } from '../logger.js';
36
36
  const TAG = 'set-exchange-credentials';
37
+ /** Non-secret CONTROL flags honored from the PLAINTEXT SIBLINGS of a sealed
38
+ * envelope (E2E audit 2026-08-11 #5). The envelope's anti-smuggle property —
39
+ * "plaintext siblings are ignored" — exists so a mixed payload can't override
40
+ * sealed CREDENTIAL values. But the dashboard sends `confirm_venue_switch`
41
+ * as a sibling NEXT TO `sealed` (it is not a secret and the sealer encrypts
42
+ * only the per-venue credential payload), so dropping every sibling made the
43
+ * confirm retry look unconfirmed forever: any live venue-switcher on a box
44
+ * with a transport key hit an infinite confirm loop. The flags below are
45
+ * safe to honor from plaintext because they cannot change WHAT gets stored
46
+ * (the sealed payload alone decides that) — they only acknowledge a warn
47
+ * step for the request the operator themselves sealed. A value INSIDE the
48
+ * envelope still wins (sealed-beats-plaintext is preserved). */
49
+ const SEALED_SIBLING_CONTROL_FLAGS = ['confirm_venue_switch'];
37
50
  /** Open a sealed envelope into plain args, or return a renderable error. */
38
51
  export function resolveSealedArgs(args, configDir) {
39
52
  if (args?.sealed == null)
40
53
  return { args, sealed: false };
41
54
  try {
42
55
  const plain = unsealCredentials(args.sealed, configDir);
56
+ for (const flag of SEALED_SIBLING_CONTROL_FLAGS) {
57
+ const sibling = args[flag];
58
+ if (plain[flag] === undefined && sibling !== undefined) {
59
+ plain[flag] = sibling;
60
+ }
61
+ }
43
62
  return { args: plain, sealed: true };
44
63
  }
45
64
  catch (err) {
@@ -1,6 +1,8 @@
1
1
  import type { PluginRuntime } from '../onboarding/runtime.js';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
3
  import type { TradingMode } from '../types.js';
4
+ import type { HlCredentials } from '../venues/hyperliquid/hl-private.js';
5
+ import { type HlAgentApprovalVerdict } from '../venues/hyperliquid/hl-agent-wallet.js';
4
6
  import { type VenueId } from '../venues/registry.js';
5
7
  export interface SetTradingModeArgs {
6
8
  mode: TradingMode;
@@ -27,6 +29,10 @@ export interface SetTradingModeDeps {
27
29
  * wired to the boot venue and a mixed-venue runtime is not a valid state.
28
30
  * Absent (legacy tests) → guard skipped. */
29
31
  bootVenue?: VenueId;
32
+ /** Injectable approval check (tests). DEFAULTS TO THE REAL ONE — unlike
33
+ * bootVenue this gate is on unless explicitly stubbed, so production
34
+ * wiring can never forget it. */
35
+ hlApprovalCheck?: (creds: HlCredentials) => Promise<HlAgentApprovalVerdict>;
30
36
  /** Override the config file path — tests use this. */
31
37
  configPath?: string;
32
38
  }