@reefclaw/openclaw-plugin 0.1.24 → 0.1.26
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/connector.d.ts +3 -1
- package/bridge/connector.js +37 -2
- package/bridge/gateway/gateway-ws-client.d.ts +2 -0
- package/bridge/gateway/gateway-ws-client.js +6 -0
- package/bridge/gateway/heartbeat-cron.js +2 -1
- package/bridge/heartbeat-runs-state.d.ts +16 -0
- package/bridge/heartbeat-runs-state.js +58 -0
- package/bridge/heartbeat-runs.d.ts +99 -0
- package/bridge/heartbeat-runs.js +300 -0
- package/bridge/heartbeat-transcript.d.ts +209 -0
- package/bridge/heartbeat-transcript.js +688 -0
- package/bridge/index.js +35 -0
- package/bridge/model-health.d.ts +37 -0
- package/bridge/model-health.js +97 -0
- package/bridge/provider.d.ts +5 -1
- package/bridge/providers/gateway.d.ts +25 -1
- package/bridge/providers/gateway.js +167 -2
- 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 +45 -0
- package/bridge/utils/instance-id.d.ts +3 -0
- package/bridge/utils/instance-id.js +48 -0
- package/config/agent-config-client.d.ts +3 -1
- package/config/agent-config-client.js +4 -0
- package/config/brackets-config.d.ts +2 -1
- package/config/brackets-config.js +25 -3
- package/config/gate-store.d.ts +3 -0
- package/config/gate-store.js +11 -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/reentry-cooldown-config.d.ts +7 -0
- package/config/reentry-cooldown-config.js +59 -0
- package/index.js +29 -2
- package/ingest/position-auto-capture.js +49 -4
- package/ingest/readiness-reporter.d.ts +23 -2
- package/ingest/readiness-reporter.js +56 -1
- package/onboarding/runtime.js +4 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- 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/signals/types.js +1 -1
- package/simulator/exchange-simulator.d.ts +5 -1
- package/simulator/exchange-simulator.js +24 -6
- package/simulator/types.d.ts +11 -0
- package/skills/reefclaw/SKILL.md +2 -2
- package/strategy/evaluator.d.ts +4 -0
- package/tools/close-position.js +10 -1
- package/tools/create-order.js +72 -2
- package/tools/hl-provision-agent-wallet.js +29 -11
- 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 +47 -0
- 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/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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ⚠️ GENERATED FILE — DO NOT EDIT.
|
|
2
|
+
// Canonical source of truth: shared/src/signals/change-of-character.ts
|
|
3
|
+
// Regenerate: node scripts/sync-shared-code.mjs (enforced by shared-code-sync.test.ts)
|
|
4
|
+
//
|
|
5
|
+
// This copy exists because this package builds with tsc and deploys as a
|
|
6
|
+
// self-contained tree that strips workspace deps, so it cannot import
|
|
7
|
+
// @reefclaw/shared runtime code across the deploy boundary.
|
|
8
|
+
// Change-of-character detection — WS2 of docs/MARKET_ADAPTIVITY_PLAN.md.
|
|
9
|
+
//
|
|
10
|
+
// The 2026-09 adaptivity investigation found the agent keeps trading a stale
|
|
11
|
+
// directional view after event-driven regime flips because (a) the regime
|
|
12
|
+
// label is computed from realized bars and lags a shock by hours, and (b) no
|
|
13
|
+
// input ever shows it the contradiction. This module computes the two fast
|
|
14
|
+
// counter-evidence facts from data the fact-computer already has in hand —
|
|
15
|
+
// NO new DB queries (the intel hot-query chunk-exclusion rule is not in play):
|
|
16
|
+
//
|
|
17
|
+
// - SHOCK: the last ~30 minutes moved a multiple of the hourly ATR. An
|
|
18
|
+
// hourly ATR is roughly "a typical hour's range", so 2×ATR inside 30m is
|
|
19
|
+
// ~4× the typical rate — announcement territory.
|
|
20
|
+
// - REGIME/TAPE DISAGREEMENT: the regime label says TREND_UP (or DOWN) but
|
|
21
|
+
// the realized 4h tape moved ≥2×ATR the other way — the label is lagging.
|
|
22
|
+
//
|
|
23
|
+
// Pure function, deterministic, fail-open: insufficient bars or a degenerate
|
|
24
|
+
// ATR returns undefined and every consumer treats that as "no signal".
|
|
25
|
+
// Thresholds are exported so the soak can be re-cut without archaeology.
|
|
26
|
+
// Canonical copy lives in shared/src/signals/ and is synced to
|
|
27
|
+
// intelligence/ + plugin/ via scripts/sync-shared-code.mjs (drift-tested).
|
|
28
|
+
/** 30m move ≥ this ×ATR(1h) ⇒ 'shock'. */
|
|
29
|
+
export const SHOCK_ATR_MULT_30M = 2.0;
|
|
30
|
+
/** 4h tape opposing a directional regime label by ≥ this ×ATR(1h) ⇒ 'regime_tape_disagreement'. */
|
|
31
|
+
export const DISAGREEMENT_ATR_MULT_4H = 2.0;
|
|
32
|
+
const fin = (v) => Number.isFinite(v);
|
|
33
|
+
const round2 = (v) => Math.round(v * 100) / 100;
|
|
34
|
+
export function computeChangeOfCharacter(inputs) {
|
|
35
|
+
const { ohlcv5m, ohlcv1h, atr14, currentPrice, regime } = inputs;
|
|
36
|
+
if (!fin(atr14) || atr14 <= 0 || !fin(currentPrice) || currentPrice <= 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (!Array.isArray(ohlcv5m) || ohlcv5m.length < 7)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (!Array.isArray(ohlcv1h) || ohlcv1h.length < 5)
|
|
41
|
+
return undefined;
|
|
42
|
+
// ~30m window = last 6 closed 5m bars; the reference close sits just before it.
|
|
43
|
+
const last6 = ohlcv5m.slice(-6);
|
|
44
|
+
const ref30m = ohlcv5m[ohlcv5m.length - 7].close;
|
|
45
|
+
const ref1h = ohlcv1h[ohlcv1h.length - 2].close;
|
|
46
|
+
const ref4h = ohlcv1h[ohlcv1h.length - 5].close;
|
|
47
|
+
if (!fin(ref30m) || ref30m <= 0 || !fin(ref1h) || ref1h <= 0 || !fin(ref4h) || ref4h <= 0) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const shock30mAtr = Math.abs(currentPrice - ref30m) / atr14;
|
|
51
|
+
const hi30 = Math.max(...last6.map((b) => b.high));
|
|
52
|
+
const lo30 = Math.min(...last6.map((b) => b.low));
|
|
53
|
+
const range30mAtr = fin(hi30) && fin(lo30) && hi30 >= lo30 ? (hi30 - lo30) / atr14 : 0;
|
|
54
|
+
const return1hPct = (currentPrice / ref1h - 1) * 100;
|
|
55
|
+
const return4hPct = (currentPrice / ref4h - 1) * 100;
|
|
56
|
+
const tape4hAtr = (currentPrice - ref4h) / atr14;
|
|
57
|
+
const atrPct = (atr14 / currentPrice) * 100;
|
|
58
|
+
const flags = [];
|
|
59
|
+
if (Math.max(shock30mAtr, range30mAtr) >= SHOCK_ATR_MULT_30M)
|
|
60
|
+
flags.push('shock');
|
|
61
|
+
const labelDir = regime === 'TREND_UP' ? 1 : regime === 'TREND_DOWN' ? -1 : 0;
|
|
62
|
+
const opposing = labelDir !== 0 && Math.sign(tape4hAtr) === -labelDir;
|
|
63
|
+
if (opposing && Math.abs(tape4hAtr) >= DISAGREEMENT_ATR_MULT_4H) {
|
|
64
|
+
flags.push('regime_tape_disagreement');
|
|
65
|
+
}
|
|
66
|
+
const out = {
|
|
67
|
+
shock30mAtr: round2(shock30mAtr),
|
|
68
|
+
range30mAtr: round2(range30mAtr),
|
|
69
|
+
return1hPct: round2(return1hPct),
|
|
70
|
+
return4hPct: round2(return4hPct),
|
|
71
|
+
tape4hAtr: round2(tape4hAtr),
|
|
72
|
+
atrPct: round2(atrPct),
|
|
73
|
+
flags,
|
|
74
|
+
};
|
|
75
|
+
if (flags.length > 0)
|
|
76
|
+
out.summary = buildSummary(out, regime);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function buildSummary(c, regime) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (c.flags.includes('shock')) {
|
|
82
|
+
parts.push(`SHOCK: last 30m moved ${round2(Math.max(c.shock30mAtr, c.range30mAtr))}×ATR ` +
|
|
83
|
+
`(${c.return1hPct >= 0 ? '+' : ''}${c.return1hPct}% on the hour)`);
|
|
84
|
+
}
|
|
85
|
+
if (c.flags.includes('regime_tape_disagreement')) {
|
|
86
|
+
parts.push(`regime label '${regime}' contradicts the 4h tape ` +
|
|
87
|
+
`(${c.tape4hAtr >= 0 ? '+' : ''}${c.tape4hAtr}×ATR / ` +
|
|
88
|
+
`${c.return4hPct >= 0 ? '+' : ''}${c.return4hPct}%) — the label LAGS realized price`);
|
|
89
|
+
}
|
|
90
|
+
return (`MARKET SHIFT — ${parts.join('; ')}. Re-derive direction from fresh price data ` +
|
|
91
|
+
`before entering; on open positions, check the pinned invalidation FIRST and do not ` +
|
|
92
|
+
`lean on prior theses formed before this move.`);
|
|
93
|
+
}
|
package/signals/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Re-export shim for the plugin-side strategy evaluator (facts-out).
|
|
2
2
|
//
|
|
3
|
-
// The generated
|
|
3
|
+
// The generated evaluator-core copies (registry, strategy-adapter, direction/entry/
|
|
4
4
|
// stop-rules) import their types from `./types.js`. The canonical definitions
|
|
5
5
|
// live in @reefclaw/shared (type-only → erased at runtime), so this shim makes
|
|
6
6
|
// those relative imports resolve inside the plugin tree without pulling any
|
|
@@ -76,7 +76,11 @@ export declare class ExchangeSimulator extends EventEmitter {
|
|
|
76
76
|
* market branch prices off THIS instead of the last tick, and skips the
|
|
77
77
|
* stale-quote guard — the caller supplied the price, so quote age is
|
|
78
78
|
* irrelevant, and a protective exit must never be blocked (issue #202). */
|
|
79
|
-
referencePrice?: number
|
|
79
|
+
referencePrice?: number,
|
|
80
|
+
/** Set ONLY by closePosition for mechanical/operator-initiated exits —
|
|
81
|
+
* exempts them from the startup lockout. Never reachable from the
|
|
82
|
+
* agent-facing create_order path. */
|
|
83
|
+
protectiveExit?: boolean): CcxtOrder;
|
|
80
84
|
cancelOrder(orderId: string): CcxtOrder;
|
|
81
85
|
cancelAllOrders(symbol?: string): CcxtOrder[];
|
|
82
86
|
/**
|
|
@@ -290,17 +290,29 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
290
290
|
* market branch prices off THIS instead of the last tick, and skips the
|
|
291
291
|
* stale-quote guard — the caller supplied the price, so quote age is
|
|
292
292
|
* irrelevant, and a protective exit must never be blocked (issue #202). */
|
|
293
|
-
referencePrice
|
|
293
|
+
referencePrice,
|
|
294
|
+
/** Set ONLY by closePosition for mechanical/operator-initiated exits —
|
|
295
|
+
* exempts them from the startup lockout. Never reachable from the
|
|
296
|
+
* agent-facing create_order path. */
|
|
297
|
+
protectiveExit) {
|
|
294
298
|
// ---- Startup trade lockout ----
|
|
295
299
|
// Block trades during the first 15s after gateway restart IF there were
|
|
296
300
|
// existing positions at startup. This prevents stale agent sessions from
|
|
297
301
|
// selling positions before the session is cleared and the agent re-reads SKILL.md.
|
|
298
302
|
// Only activates when positions exist (nothing to protect if starting empty).
|
|
303
|
+
// Protective exits pass through: a stop breached 3s after a restart must
|
|
304
|
+
// close NOW — blocking the watcher here left positions unprotected for
|
|
305
|
+
// the whole window (open item since 2026-07-27).
|
|
299
306
|
const elapsed = Date.now() - this.startupTime;
|
|
300
307
|
if (this.hadPositionsAtStartup && elapsed < ExchangeSimulator.STARTUP_LOCKOUT_MS) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
308
|
+
if (protectiveExit) {
|
|
309
|
+
logger.info(TAG, `Startup lockout bypassed for protective exit: ${side} ${amount} ${symbol}`);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const remaining = Math.ceil((ExchangeSimulator.STARTUP_LOCKOUT_MS - elapsed) / 1000);
|
|
313
|
+
logger.warn(TAG, `STARTUP LOCKOUT: Blocked ${side} ${amount} ${symbol} — ${remaining}s remaining. This prevents stale session trades during restart.`);
|
|
314
|
+
throw new Error(`Trade blocked: startup lockout (${remaining}s remaining). The gateway just restarted — wait for the agent to re-read its instructions and check positions before trading.`);
|
|
315
|
+
}
|
|
304
316
|
}
|
|
305
317
|
if (amount <= 0) {
|
|
306
318
|
throw new Error('Order amount must be positive');
|
|
@@ -410,9 +422,15 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
410
422
|
if (closeReason) {
|
|
411
423
|
position.metadata = { ...(position.metadata ?? {}), closeReason };
|
|
412
424
|
}
|
|
413
|
-
// Create opposing market order to close the position
|
|
425
|
+
// Create opposing market order to close the position.
|
|
426
|
+
// Mechanical / operator-initiated exits (stop_watcher, exchange_target,
|
|
427
|
+
// emergency, operator, bracket_attach_failed, …) must never wait out the
|
|
428
|
+
// startup lockout — a stop breached seconds after a restart has to close
|
|
429
|
+
// immediately. Only discretionary closes ('agent' or reason-less) keep
|
|
430
|
+
// the stale-session guard.
|
|
431
|
+
const protectiveExit = closeReason !== undefined && closeReason !== 'agent';
|
|
414
432
|
const closeSide = position.side === 'long' ? 'sell' : 'buy';
|
|
415
|
-
return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice);
|
|
433
|
+
return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice, protectiveExit);
|
|
416
434
|
}
|
|
417
435
|
/** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
|
|
418
436
|
* targetPrice) in place and persist, WITHOUT the close+reopen round-trip
|
package/simulator/types.d.ts
CHANGED
|
@@ -150,6 +150,17 @@ export interface PositionMetadata {
|
|
|
150
150
|
/** The agent's stated profit-realization plan, pinned at entry (indication,
|
|
151
151
|
* not an enforced mechanic). */
|
|
152
152
|
realizationRule?: RealizationRule;
|
|
153
|
+
/** Re-entry cooldown gate evaluation, present ONLY when the gate TRIGGERED on this
|
|
154
|
+
* entry (a same-symbol loss within the window) and the order fired anyway
|
|
155
|
+
* (shadow/observe). Journaled to position_entries.metadata.reentry_cooldown
|
|
156
|
+
* so the shadow soak can measure the would-block cohort's forward outcomes
|
|
157
|
+
* against untagged entries (tools/reentry-cooldown.ts). */
|
|
158
|
+
reentryCooldown?: {
|
|
159
|
+
mode: 'shadow' | 'observe' | 'enforce';
|
|
160
|
+
minutesSinceLoss: number;
|
|
161
|
+
cooldownMinutes: number;
|
|
162
|
+
wouldBlock: boolean;
|
|
163
|
+
};
|
|
153
164
|
/** Entry price as it was at the moment of the FIRST fill, frozen. MFE-in-R is
|
|
154
165
|
* denominated against this (and originalStopPrice), never against the
|
|
155
166
|
* position's running averaged entryPrice — otherwise a scale-in would
|
package/skills/reefclaw/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: reefclaw
|
|
3
|
-
version: 0.0.
|
|
3
|
+
version: 0.0.10
|
|
4
4
|
description: ReefClaw trading control room — bootstrap (connects your agent; the full trading instructions arrive after first connect as a signed, signature-verified update)
|
|
5
5
|
author: ReefClaw
|
|
6
6
|
homepage: https://reefclaw.com
|
|
@@ -108,7 +108,7 @@ That is the whole install. It places the plugin, sets up the connector, restarts
|
|
|
108
108
|
|
|
109
109
|
**Do not attempt to perform this install yourself, and do not work around it if it is unavailable to you.** Specifically: do not edit OpenClaw's configuration to enable chat install commands, do not add anyone to an owner or elevated-exec allowlist, do not enable elevated mode, and do not restart the gateway to widen your own permissions. If you cannot run the command, the correct outcome is to hand the user the one line above and let them run it deliberately. A trading integration is exactly the kind of software whose installation a human should perform knowingly.
|
|
110
110
|
|
|
111
|
-
**If the user installs via ClawHub instead**
|
|
111
|
+
**If the user installs via ClawHub instead** — no terminal needed on OpenClaw 2026.8.1 or newer: either OpenClaw's Control UI (Plugins → Discover → search "reefclaw" → Install → accept the listed capabilities), or the chat command `/plugins install clawhub:@reefclaw/openclaw-plugin` followed, after reading the capability list it replies with, by the same command plus `--accept-capabilities` (the chat command needs `commands.plugins: true` in openclaw.json, which only the user sets). ClawHub labels the plugin "Security scan: suspicious". That is expected and honest — ReefClaw discloses live-trading capability, exchange-credential storage, and remote instruction updates, which ClawHub flags for review (its linked security page has the details; VirusTotal reports the artifact clean). OpenClaw 2026.8.1+ shows that audit and continues once the capabilities are accepted; OpenClaw 2026.7.x still blocks the chat command on that label — there the user runs the `npx` line above, or `openclaw plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk` in a terminal.
|
|
112
112
|
|
|
113
113
|
Whatever the install path, **save the connection settings anyway** — the connector picks them up automatically the moment the plugin is running.
|
|
114
114
|
|
package/strategy/evaluator.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ export interface SymbolFact {
|
|
|
5
5
|
conditions: Record<string, boolean>;
|
|
6
6
|
regime: string;
|
|
7
7
|
regime_confidence: number;
|
|
8
|
+
/** Change-of-character block computed by the intel fact-computer (WS2,
|
|
9
|
+
* docs/MARKET_ADAPTIVITY_PLAN.md §3). Absent on older intel builds or when
|
|
10
|
+
* inputs were insufficient — consumers treat absence as "no signal". */
|
|
11
|
+
changeOfCharacter?: import('../signals/change-of-character.js').ChangeOfCharacter;
|
|
8
12
|
}
|
|
9
13
|
export interface ConditionConfig {
|
|
10
14
|
type: string;
|
package/tools/close-position.js
CHANGED
|
@@ -317,9 +317,18 @@ export async function closePositionTool(args, deps) {
|
|
|
317
317
|
'Use get_wave9_status for a reversal authorization or operator_command for an emergency close.');
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
|
+
// Thread the validated reason to the adapter as its CloseReason:
|
|
321
|
+
// 'operator_command' maps to 'operator' so an operator-driven close is
|
|
322
|
+
// never caught by the paper startup lockout (whose protective-exit
|
|
323
|
+
// carve-out keys on the reason — before this, the tool dropped the
|
|
324
|
+
// reason entirely and EVERY generic close arrived as discretionary).
|
|
325
|
+
// All other reasons are agent-discretionary by design and stay subject
|
|
326
|
+
// to the lockout; the rich reason still reaches the journal via
|
|
327
|
+
// close_reason, this only fixes the adapter-level class + metadata.
|
|
328
|
+
const adapterCloseReason = args.reason === 'operator_command' ? 'operator' : 'agent';
|
|
320
329
|
return wave9ExitClaimed
|
|
321
330
|
? deps.adapter.closePosition(args.symbol, 'wave9_signal_reversal')
|
|
322
|
-
: deps.adapter.closePosition(args.symbol);
|
|
331
|
+
: deps.adapter.closePosition(args.symbol, adapterCloseReason);
|
|
323
332
|
};
|
|
324
333
|
const closeWave9 = async () => {
|
|
325
334
|
if (!wave9Lease)
|
package/tools/create-order.js
CHANGED
|
@@ -7,6 +7,10 @@ import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
|
|
|
7
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
|
+
import { loadReentryCooldownMinutes, loadReentryCooldownMode, } from '../config/reentry-cooldown-config.js';
|
|
11
|
+
import { resolveLossStreakSizingMode } from '../config/loss-streak-config.js';
|
|
12
|
+
import { buildReentryCooldownRejection, evaluateReentryCooldown, } from './reentry-cooldown.js';
|
|
13
|
+
import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
|
|
10
14
|
import { onCreateOrderFilled } from '../ingest/position-auto-capture.js';
|
|
11
15
|
import { wave9ClientOrderId, } from '../wave9/live-execution-ledger.js';
|
|
12
16
|
import { inspectWave9LiveSymbolOwnership, } from '../wave9/live-symbol-ownership.js';
|
|
@@ -1012,7 +1016,24 @@ export async function createOrderTool(args, deps) {
|
|
|
1012
1016
|
}
|
|
1013
1017
|
else {
|
|
1014
1018
|
portfolio = await (livePortfolioPromise ?? buildPortfolioSnapshotFromAdapter(deps.adapter));
|
|
1015
|
-
//
|
|
1019
|
+
// WS1 (docs/MARKET_ADAPTIVITY_PLAN.md §3): the graduated loss-streak
|
|
1020
|
+
// sizing brake was fed a hardcoded 0 on live since the beginning. Real
|
|
1021
|
+
// feed = trailing lossy closes on the live book from the ReentryTracker
|
|
1022
|
+
// records. RC_LOSS_STREAK_SIZING = off | log (sizing unaffected) |
|
|
1023
|
+
// enforce (DEFAULT since 2026-09-05 — live/paper parity restored after
|
|
1024
|
+
// the log soak).
|
|
1025
|
+
const streakMode = resolveLossStreakSizingMode();
|
|
1026
|
+
if (streakMode !== 'off' && deps.autoCapture?.reentryTracker) {
|
|
1027
|
+
const liveStreak = deps.autoCapture.reentryTracker.consecutiveLosses('live');
|
|
1028
|
+
if (liveStreak > 0) {
|
|
1029
|
+
logger.info('loss-streak', `live consecutive losses=${liveStreak} (mode=${streakMode}` +
|
|
1030
|
+
(streakMode === 'log'
|
|
1031
|
+
? ' — sizing UNAFFECTED; RC_LOSS_STREAK_SIZING=enforce applies the graduated reduction)'
|
|
1032
|
+
: ')'));
|
|
1033
|
+
}
|
|
1034
|
+
if (streakMode === 'enforce')
|
|
1035
|
+
consecutiveLosses = liveStreak;
|
|
1036
|
+
}
|
|
1016
1037
|
}
|
|
1017
1038
|
// Bracket enforcement only applies in live mode when the feature is enabled.
|
|
1018
1039
|
// Paper mode uses the stop-watcher and doesn't care about these flags.
|
|
@@ -1048,6 +1069,43 @@ export async function createOrderTool(args, deps) {
|
|
|
1048
1069
|
const reasons = riskCheck.violations.map(v => v.message).join('; ');
|
|
1049
1070
|
return rejectWave9Divergence(`Order REJECTED by risk gate [${riskCheck.drawdownZone} zone]: ${reasons}`, `generic_pretrade_divergence:${riskCheck.violations.map((v) => v.rule).join(',')}`);
|
|
1050
1071
|
}
|
|
1072
|
+
// ---- Re-entry cooldown gate (mode-laddered; ships 'off') ----
|
|
1073
|
+
// Generic NEW entries only: wave9 has its own frozen admission policy,
|
|
1074
|
+
// scale-ins and operator-approved proposal fires are exempt inside the
|
|
1075
|
+
// evaluator, and this branch can only ever suppress a new entry — exits,
|
|
1076
|
+
// closes, stops, and emergency paths never route through it. Mode resolves
|
|
1077
|
+
// PER CALL (central → file → off) so a gate flip hot-applies, same as the
|
|
1078
|
+
// exit gate. See tools/reentry-cooldown.ts for the measurement behind it.
|
|
1079
|
+
let reentryCooldownEval;
|
|
1080
|
+
if (!wave9Claimed) {
|
|
1081
|
+
const rcMode = loadReentryCooldownMode();
|
|
1082
|
+
if (rcMode !== 'off') {
|
|
1083
|
+
const symbolKey = normalizeBracketSymbol(args.symbol);
|
|
1084
|
+
reentryCooldownEval = evaluateReentryCooldown({
|
|
1085
|
+
symbol: args.symbol,
|
|
1086
|
+
mode: rcMode,
|
|
1087
|
+
tracker: deps.autoCapture?.reentryTracker,
|
|
1088
|
+
book: deps.adapter.isLive ? 'live' : 'paper',
|
|
1089
|
+
cooldownMinutes: loadReentryCooldownMinutes(),
|
|
1090
|
+
hasOpenPosition: portfolio.positions.some((p) => normalizeBracketSymbol(p.symbol) === symbolKey && Math.abs(p.quantity) > 0),
|
|
1091
|
+
isListenerFire: deps.entryClientOrderId != null,
|
|
1092
|
+
});
|
|
1093
|
+
if (reentryCooldownEval.triggered) {
|
|
1094
|
+
const detail = `${args.symbol} ${side}: lossy close ${reentryCooldownEval.minutesSinceLoss}m ago ` +
|
|
1095
|
+
`(window=${reentryCooldownEval.cooldownMinutes}m, mode=${rcMode}, ` +
|
|
1096
|
+
`loss_source=${reentryCooldownEval.lastLoss?.lossSource ?? 'unknown'})`;
|
|
1097
|
+
if (rcMode === 'shadow') {
|
|
1098
|
+
logger.info('reentry-cooldown', `WOULD BLOCK (shadow) ${detail}`);
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
logger.warn('reentry-cooldown', `${rcMode === 'enforce' ? 'BLOCKED' : 'WOULD BLOCK (observe)'} ${detail}`);
|
|
1102
|
+
}
|
|
1103
|
+
if (reentryCooldownEval.blocked) {
|
|
1104
|
+
return { error: buildReentryCooldownRejection(reentryCooldownEval, args.symbol) };
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1051
1109
|
// Build position metadata from optional args (only if any metadata provided)
|
|
1052
1110
|
// Sanitize numeric metadata — reject non-finite values, clamp ranges
|
|
1053
1111
|
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
@@ -1055,9 +1113,20 @@ export async function createOrderTool(args, deps) {
|
|
|
1055
1113
|
const n = num(v);
|
|
1056
1114
|
return n != null ? Math.max(min, Math.min(max, n)) : undefined;
|
|
1057
1115
|
};
|
|
1116
|
+
// Journal-tag a TRIGGERED cooldown evaluation (shadow/observe fire-anyway cohort —
|
|
1117
|
+
// the measurement rows the promote-to-enforce decision reads).
|
|
1118
|
+
const reentryCooldownTag = reentryCooldownEval?.triggered
|
|
1119
|
+
? {
|
|
1120
|
+
mode: reentryCooldownEval.mode,
|
|
1121
|
+
minutesSinceLoss: reentryCooldownEval.minutesSinceLoss ?? 0,
|
|
1122
|
+
cooldownMinutes: reentryCooldownEval.cooldownMinutes,
|
|
1123
|
+
wouldBlock: true,
|
|
1124
|
+
}
|
|
1125
|
+
: undefined;
|
|
1058
1126
|
const hasMetadata = args.mission_id || args.setup_type || args.thesis || args.target_price != null
|
|
1059
1127
|
|| args.regime || args.scorecard_verdict || args.confluence_score != null
|
|
1060
|
-
|| args.invalidation_price != null || args.realization_rule != null
|
|
1128
|
+
|| args.invalidation_price != null || args.realization_rule != null
|
|
1129
|
+
|| reentryCooldownTag != null;
|
|
1061
1130
|
const metadata = hasMetadata ? {
|
|
1062
1131
|
missionId: args.mission_id,
|
|
1063
1132
|
setupType: args.setup_type,
|
|
@@ -1076,6 +1145,7 @@ export async function createOrderTool(args, deps) {
|
|
|
1076
1145
|
confluenceScore: clamp(args.confluence_score, 0, 10),
|
|
1077
1146
|
invalidationPrice: num(args.invalidation_price),
|
|
1078
1147
|
realizationRule: sanitizeRealizationRule(args.realization_rule),
|
|
1148
|
+
reentryCooldown: reentryCooldownTag,
|
|
1079
1149
|
} : undefined;
|
|
1080
1150
|
// ---- Approval-mode branches ----
|
|
1081
1151
|
// Both shadow and per_trade need full v2.10.0 metadata so the proposal is
|
|
@@ -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 {
|
|
@@ -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 evaluation logs at WARN (operator-visible).
|
|
26
|
+
// enforce — a triggered evaluation 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
|
|
@@ -189,10 +212,14 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
189
212
|
const entryLearnings = await learningsPromise;
|
|
190
213
|
const rankings = [];
|
|
191
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]));
|
|
192
218
|
for (const r of results) {
|
|
193
219
|
const matches = entryLearnings.filter(l => learningMatches(l, r.strategy, r.regime));
|
|
194
220
|
const agentSymbol = presentIntelSymbol(deps, r.symbol);
|
|
195
221
|
const reentryCaution = decisionsDeps?.reentryTracker?.cautionFor(agentSymbol, r.strategy);
|
|
222
|
+
const coc = cocOn ? factBySymbol.get(r.symbol)?.changeOfCharacter : undefined;
|
|
196
223
|
const out = {
|
|
197
224
|
// Agent-facing form: on hyperliquid the agent must see the symbol it
|
|
198
225
|
// can hand straight to create_order ('BTC/USDC'), never 'HL_BTC'.
|
|
@@ -203,6 +230,7 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
203
230
|
conditions: `${r.conditionsMet}/${r.conditionsTotal} met: ${r.conditions.filter(c => c.met).map(c => c.name).join(', ')}`,
|
|
204
231
|
summary: r.summary,
|
|
205
232
|
...(reentryCaution ? { reentry_caution: reentryCaution } : {}),
|
|
233
|
+
...(coc && coc.flags.length > 0 && coc.summary ? { market_shift_caution: coc.summary } : {}),
|
|
206
234
|
};
|
|
207
235
|
if (matches.length === 0) {
|
|
208
236
|
rankings.push(out);
|
|
@@ -223,10 +251,29 @@ export async function scanPairsTool(args, deps, decisionsDeps) {
|
|
|
223
251
|
const noSetup = facts
|
|
224
252
|
.filter(f => !setupSymbols.has(f.symbol))
|
|
225
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
|
+
}
|
|
226
271
|
return {
|
|
227
272
|
timestamp: new Date().toISOString(),
|
|
228
273
|
pairs_scanned: facts.length,
|
|
229
274
|
setups_found: results.length,
|
|
275
|
+
...(marketCaution ? { market_caution: marketCaution } : {}),
|
|
276
|
+
...(scoreboard ? { directional_scoreboard: scoreboard } : {}),
|
|
230
277
|
rankings,
|
|
231
278
|
vetoed_setups: vetoed.length > 0 ? vetoed : undefined,
|
|
232
279
|
no_setup: noSetup.length > 5
|