@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.
- package/bridge/bridge.js +72 -5
- package/bridge/connector.d.ts +3 -1
- package/bridge/connector.js +51 -4
- package/bridge/gateway/heartbeat-cron.js +31 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/index.js +21 -0
- package/bridge/provider.d.ts +15 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +26 -1
- package/bridge/providers/gateway.js +159 -8
- package/bridge/providers/mock.js +1 -0
- package/bridge/shock-wake.d.ts +80 -0
- package/bridge/shock-wake.js +291 -0
- package/bridge/types.d.ts +5 -1
- package/bridge/types.js +5 -0
- package/bridge/utils/instance-id.d.ts +3 -0
- package/bridge/utils/instance-id.js +48 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +7 -2
- package/config/agent-config-client.js +17 -0
- package/config/agent-config-poller.js +5 -1
- package/config/brackets-config.d.ts +2 -1
- package/config/brackets-config.js +25 -3
- package/config/gate-store.d.ts +12 -0
- package/config/gate-store.js +26 -2
- package/config/loss-streak-config.d.ts +2 -0
- package/config/loss-streak-config.js +33 -0
- package/config/plugin-config-io.d.ts +19 -0
- package/config/plugin-config-io.js +24 -2
- package/config/reentry-cooldown-config.d.ts +7 -0
- package/config/reentry-cooldown-config.js +59 -0
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +77 -8
- package/ingest/position-auto-capture.js +49 -4
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/ingest/readiness-reporter.d.ts +23 -2
- package/ingest/readiness-reporter.js +56 -1
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/onboarding/runtime.js +4 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/portfolio/directional-scoreboard.d.ts +17 -0
- package/portfolio/directional-scoreboard.js +71 -0
- package/portfolio/reentry-tracker.d.ts +38 -1
- package/portfolio/reentry-tracker.js +49 -0
- package/signals/change-of-character.d.ts +38 -0
- package/signals/change-of-character.js +93 -0
- package/simulator/exchange-simulator.d.ts +27 -1
- package/simulator/exchange-simulator.js +98 -38
- package/simulator/types.d.ts +11 -0
- package/skills/reefclaw/SKILL.md +2 -2
- package/strategy/evaluator.d.ts +4 -0
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/close-position.js +10 -1
- package/tools/create-order.js +121 -9
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/hl-provision-agent-wallet.js +29 -11
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/reentry-cooldown.d.ts +33 -0
- package/tools/reentry-cooldown.js +74 -0
- package/tools/scan-pairs.d.ts +7 -0
- package/tools/scan-pairs.js +67 -11
- package/tools/set-exchange-credentials.js +19 -0
- package/tools/set-trading-mode.d.ts +6 -0
- package/tools/set-trading-mode.js +48 -1
- package/types.d.ts +7 -0
- package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
- package/venues/hyperliquid/hl-agent-wallet.js +32 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ★
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
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 {
|
package/tools/intel-api.d.ts
CHANGED
|
@@ -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> | {
|
package/tools/intel-api.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/tools/scan-pairs.d.ts
CHANGED
|
@@ -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;
|
package/tools/scan-pairs.js
CHANGED
|
@@ -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
|
-
//
|
|
133
|
-
|
|
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.
|
|
158
|
-
const stratRes = await
|
|
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.
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
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
|
}
|