@reefclaw/connect 0.1.36 → 0.1.38
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/assets/bridge/gateway/gateway-ws-client.d.ts +2 -0
- package/assets/bridge/gateway/gateway-ws-client.js +6 -0
- package/assets/bridge/heartbeat-runs-state.d.ts +16 -0
- package/assets/bridge/heartbeat-runs-state.js +58 -0
- package/assets/bridge/heartbeat-runs.d.ts +99 -0
- package/assets/bridge/heartbeat-runs.js +300 -0
- package/assets/bridge/heartbeat-transcript.d.ts +209 -0
- package/assets/bridge/heartbeat-transcript.js +688 -0
- package/assets/bridge/index.js +31 -0
- package/assets/bridge/model-health.d.ts +37 -0
- package/assets/bridge/model-health.js +97 -0
- package/assets/bridge/provider.d.ts +5 -1
- package/assets/bridge/providers/gateway.d.ts +25 -1
- package/assets/bridge/providers/gateway.js +167 -2
- package/assets/bridge/providers/mock.js +1 -0
- package/assets/bridge/shock-wake.d.ts +80 -0
- package/assets/bridge/shock-wake.js +291 -0
- package/assets/bridge/types.d.ts +41 -0
- package/assets/plugin/config/agent-config-client.d.ts +3 -1
- package/assets/plugin/config/agent-config-client.js +4 -0
- package/assets/plugin/config/gate-store.d.ts +3 -0
- package/assets/plugin/config/gate-store.js +11 -2
- package/assets/plugin/config/loss-streak-config.d.ts +2 -0
- package/assets/plugin/config/loss-streak-config.js +33 -0
- package/assets/plugin/config/plugin-config-io.d.ts +19 -0
- package/assets/plugin/config/reentry-cooldown-config.d.ts +7 -0
- package/assets/plugin/config/reentry-cooldown-config.js +59 -0
- package/assets/plugin/index.js +5 -0
- package/assets/plugin/ingest/position-auto-capture.js +35 -2
- package/assets/plugin/openclaw.plugin.json +1 -1
- package/assets/plugin/portfolio/directional-scoreboard.d.ts +17 -0
- package/assets/plugin/portfolio/directional-scoreboard.js +71 -0
- package/assets/plugin/portfolio/reentry-tracker.d.ts +38 -1
- package/assets/plugin/portfolio/reentry-tracker.js +49 -0
- package/assets/plugin/signals/change-of-character.d.ts +38 -0
- package/assets/plugin/signals/change-of-character.js +93 -0
- package/assets/plugin/signals/types.js +1 -1
- package/assets/plugin/simulator/types.d.ts +11 -0
- package/assets/plugin/strategy/evaluator.d.ts +4 -0
- package/assets/plugin/tools/create-order.js +72 -2
- package/assets/plugin/tools/reentry-cooldown.d.ts +33 -0
- package/assets/plugin/tools/reentry-cooldown.js +74 -0
- package/assets/plugin/tools/scan-pairs.d.ts +7 -0
- package/assets/plugin/tools/scan-pairs.js +47 -0
- package/assets/shared/signals/change-of-character.d.ts +38 -0
- package/assets/shared/signals/change-of-character.js +86 -0
- package/assets/skill/SKILL.md +2 -2
- package/dist/cli.js +11 -2
- package/dist/plugin.js +70 -28
- package/package.json +1 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Shock-wake poller — WS3 of docs/MARKET_ADAPTIVITY_PLAN.md.
|
|
2
|
+
//
|
|
3
|
+
// The agent thinks on a 15–30 min heartbeat; an announcement lands mid-cycle
|
|
4
|
+
// and isn't reconsidered until the next one — the adaptivity investigation's
|
|
5
|
+
// core latency gap. This poller watches intel's /api/shocks (the WS2
|
|
6
|
+
// change-of-character flags, computed each fact pass) and, when the market
|
|
7
|
+
// genuinely changes character, delivers ONE out-of-band agent turn through
|
|
8
|
+
// the same gateway path operator chat uses (provider.handleChat — the
|
|
9
|
+
// precedent is the trade-mission presenter).
|
|
10
|
+
//
|
|
11
|
+
// Guardrails (every one deliberately boring):
|
|
12
|
+
// - Central gate `agent_config.gates.shockWake`: off (default) → shadow
|
|
13
|
+
// (logs WOULD WAKE, delivers nothing) → on. Polled from the webapp
|
|
14
|
+
// every ~5 min; absent/garbage/outage → off. No local file fallback —
|
|
15
|
+
// this gate is central-only.
|
|
16
|
+
// - Wake-worthy = a market LEADER (BTC/ETH) flagged 'shock', OR a broad
|
|
17
|
+
// correlated move: ≥5 distinct coins shocked (env RC_SHOCK_WAKE_MIN_BROAD),
|
|
18
|
+
// or ≥3 when a MAJOR (BTC/ETH/BNB/SOL/XRP) is among them. Disagreement-only
|
|
19
|
+
// flags never wake — they are slow-moving and already ride scan_pairs
|
|
20
|
+
// indication. (Broad bar raised from a flat ≥3 on 2026-09-06: the first
|
|
21
|
+
// live night delivered 7 wakes on rotating 3-coin mid-cap alt clusters —
|
|
22
|
+
// routine alt volatility, not announcements. Replayed against the new
|
|
23
|
+
// rule, those 7 become 1.)
|
|
24
|
+
// - Stale shocks (fact older than 10 min) are ignored.
|
|
25
|
+
// - Global cooldown 60 min between wakes + daily cap 8 (UTC), armed in
|
|
26
|
+
// shadow too so the shadow soak counts exactly what 'on' would deliver.
|
|
27
|
+
// A FAILED delivery does not arm the cooldown (retry next tick).
|
|
28
|
+
// (Cooldown default raised 30→60 min 2026-09-05: the shadow soak showed a
|
|
29
|
+
// single sustained 09-03 event re-firing 4 would-wakes in 95 min — one
|
|
30
|
+
// re-ping per hour during a long move is the intended cadence.)
|
|
31
|
+
// - The wake is INDICATION: it mandates re-evaluation, never an action.
|
|
32
|
+
// - DELIVERY CONFIRMATION (2026-09-06): the gateway acks the RPC even when
|
|
33
|
+
// the main-session lane silently swallows the turn (the 2026-07-22
|
|
34
|
+
// OpenClaw class — 7 of the first night's 9 wakes died this way). The
|
|
35
|
+
// bridge wires provider `chatMessageStart` into noteAssistantActivity();
|
|
36
|
+
// if no agent turn STARTS within RC_SHOCK_WAKE_REPLY_TIMEOUT_MS (10 min)
|
|
37
|
+
// of a delivered wake, a WARN names the swallow suspicion (dashboard chat
|
|
38
|
+
// is likely dead too) and a counter increments. Observability only — no
|
|
39
|
+
// auto-retry into a wedged lane.
|
|
40
|
+
//
|
|
41
|
+
// Env overrides: RC_SHOCK_WAKE_POLL_MS (60s), RC_SHOCK_WAKE_GATE_POLL_MS
|
|
42
|
+
// (5 min), RC_SHOCK_WAKE_COOLDOWN_MS (30 min), RC_SHOCK_WAKE_DAILY_CAP (8).
|
|
43
|
+
import { logger, formatError } from './logger.js';
|
|
44
|
+
const TAG = 'shock-wake';
|
|
45
|
+
/** Leaders whose lone shock is wake-worthy, as base coins (venue-stripped). */
|
|
46
|
+
const LEADER_COINS = new Set(['BTC', 'ETH']);
|
|
47
|
+
/** Majors (the platform's liquidation-pulse majors group): their participation
|
|
48
|
+
* lowers the broad-shock coin bar from 5 to 3 — a SOL-led cluster is market
|
|
49
|
+
* information; three rotating meme-alts are Tuesday. */
|
|
50
|
+
const MAJOR_COINS = new Set(['BTC', 'ETH', 'BNB', 'SOL', 'XRP']);
|
|
51
|
+
const STALE_SHOCK_MS = 10 * 60_000;
|
|
52
|
+
function envInt(name, fallback) {
|
|
53
|
+
const v = Number(process.env[name]);
|
|
54
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
55
|
+
}
|
|
56
|
+
/** 'BTCUSDT' / 'HL_BTC' / 'BTC/USDC' → 'BTC'. Best-effort market vocabulary
|
|
57
|
+
* for the wake message — never used to address an order. */
|
|
58
|
+
export function coinOf(intelSymbol) {
|
|
59
|
+
return intelSymbol
|
|
60
|
+
.replace(/^HL_/, '')
|
|
61
|
+
.replace(/\/.*$/, '')
|
|
62
|
+
.replace(/(USDT|USDC|BUSD)$/i, '')
|
|
63
|
+
.toUpperCase();
|
|
64
|
+
}
|
|
65
|
+
/** Parse gates.shockWake off the raw /api/internal/config payload. Central-only
|
|
66
|
+
* gate — absent/garbage → 'off'. */
|
|
67
|
+
export function parseShockWakeGate(raw) {
|
|
68
|
+
if (!raw || typeof raw !== 'object')
|
|
69
|
+
return 'off';
|
|
70
|
+
const gates = raw.gates;
|
|
71
|
+
if (!gates || typeof gates !== 'object')
|
|
72
|
+
return 'off';
|
|
73
|
+
const v = gates.shockWake;
|
|
74
|
+
return v === 'shadow' || v === 'on' ? v : 'off';
|
|
75
|
+
}
|
|
76
|
+
/** Pure wake-worthiness rule. `nowMs` gates staleness. Broad rule (2026-09-06):
|
|
77
|
+
* ≥`minBroadCount` distinct coins shocked, OR ≥3 when a major participates. */
|
|
78
|
+
export function assessWakeWorthiness(shocks, nowMs, minBroadCount = 5) {
|
|
79
|
+
const fresh = shocks.filter((s) => {
|
|
80
|
+
const t = Date.parse(s.time);
|
|
81
|
+
return Number.isFinite(t) && nowMs - t <= STALE_SHOCK_MS;
|
|
82
|
+
});
|
|
83
|
+
const shocked = fresh.filter((s) => s.flags.includes('shock'));
|
|
84
|
+
// One coin can appear per-venue (BTCUSDT + HL_BTC) — count coins, not rows.
|
|
85
|
+
const coins = new Set(shocked.map((s) => coinOf(s.symbol)));
|
|
86
|
+
const leader = shocked.find((s) => LEADER_COINS.has(coinOf(s.symbol)));
|
|
87
|
+
if (leader)
|
|
88
|
+
return { wake: true, reason: 'leader_shock', shocked };
|
|
89
|
+
const hasMajor = [...coins].some((c) => MAJOR_COINS.has(c));
|
|
90
|
+
if (coins.size >= minBroadCount || (hasMajor && coins.size >= 3)) {
|
|
91
|
+
return { wake: true, reason: 'broad_shock', shocked };
|
|
92
|
+
}
|
|
93
|
+
return { wake: false, shocked };
|
|
94
|
+
}
|
|
95
|
+
/** The out-of-band agent turn. Clearly machine-labeled (never impersonates the
|
|
96
|
+
* operator), mandates re-evaluation, orders nothing. */
|
|
97
|
+
export function buildWakeMessage(a) {
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const lines = [];
|
|
100
|
+
for (const s of a.shocked) {
|
|
101
|
+
const coin = coinOf(s.symbol);
|
|
102
|
+
if (seen.has(coin))
|
|
103
|
+
continue;
|
|
104
|
+
seen.add(coin);
|
|
105
|
+
const sign = (v) => (v >= 0 ? '+' : '');
|
|
106
|
+
lines.push(`- ${coin}: 30m ${Math.max(s.shock30mAtr, s.range30mAtr).toFixed(1)}×ATR, ` +
|
|
107
|
+
`1h ${sign(s.return1hPct)}${s.return1hPct}%, 4h ${sign(s.return4hPct)}${s.return4hPct}% ` +
|
|
108
|
+
`(regime label: ${s.regime})`);
|
|
109
|
+
if (lines.length >= 6)
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
return [
|
|
113
|
+
'[AUTOMATED MARKET-SHIFT ALERT — from the ReefClaw system, NOT the operator]',
|
|
114
|
+
'',
|
|
115
|
+
a.reason === 'leader_shock'
|
|
116
|
+
? 'A market leader just moved sharply — the market may have changed character:'
|
|
117
|
+
: 'Multiple symbols moved sharply together — the market may have changed character:',
|
|
118
|
+
...lines,
|
|
119
|
+
'',
|
|
120
|
+
'Do this NOW, ahead of your scheduled heartbeat:',
|
|
121
|
+
'1. Re-derive your directional bias from FRESH data (scan_pairs, get_regime) — do NOT lean on theses formed before this move; regime labels may LAG it (see market_shift_caution in scan results).',
|
|
122
|
+
'2. For EVERY open position: check the pinned invalidation FIRST. If price is through it, act on your own plan.',
|
|
123
|
+
'3. Hold off on new entries until the re-derived bias and the tape agree.',
|
|
124
|
+
'',
|
|
125
|
+
'This alert is indication only — it does not order you to close anything. Reply with a brief assessment so the operator sees your read.',
|
|
126
|
+
].join('\n');
|
|
127
|
+
}
|
|
128
|
+
export class ShockWakePoller {
|
|
129
|
+
opts;
|
|
130
|
+
mode = 'off';
|
|
131
|
+
pollTimer = null;
|
|
132
|
+
gateTimer = null;
|
|
133
|
+
lastWakeAtMs = 0;
|
|
134
|
+
wakesToday = 0;
|
|
135
|
+
wakeDayUtc = '';
|
|
136
|
+
polling = false;
|
|
137
|
+
pollMs = envInt('RC_SHOCK_WAKE_POLL_MS', 60_000);
|
|
138
|
+
gatePollMs = envInt('RC_SHOCK_WAKE_GATE_POLL_MS', 5 * 60_000);
|
|
139
|
+
cooldownMs = envInt('RC_SHOCK_WAKE_COOLDOWN_MS', 60 * 60_000);
|
|
140
|
+
dailyCap = envInt('RC_SHOCK_WAKE_DAILY_CAP', 8);
|
|
141
|
+
minBroad = envInt('RC_SHOCK_WAKE_MIN_BROAD', 5);
|
|
142
|
+
replyTimeoutMs = envInt('RC_SHOCK_WAKE_REPLY_TIMEOUT_MS', 10 * 60_000);
|
|
143
|
+
// Delivery confirmation (see header): a wake is only proven delivered when
|
|
144
|
+
// an agent turn STARTS after it. lastAssistantAtMs is fed by the bridge's
|
|
145
|
+
// chatMessageStart wiring; awaitingSince/deadline track one in-flight wake
|
|
146
|
+
// (the 60-min cooldown guarantees no overlap with the 10-min timeout).
|
|
147
|
+
lastAssistantAtMs = 0;
|
|
148
|
+
awaitingSinceMs = null;
|
|
149
|
+
awaitingDeadlineMs = 0;
|
|
150
|
+
suspectedSwallows = 0;
|
|
151
|
+
constructor(opts) {
|
|
152
|
+
this.opts = opts;
|
|
153
|
+
}
|
|
154
|
+
start() {
|
|
155
|
+
if (this.pollTimer)
|
|
156
|
+
return;
|
|
157
|
+
const refresh = () => { void this.refreshGate(); };
|
|
158
|
+
refresh();
|
|
159
|
+
this.gateTimer = setInterval(refresh, this.gatePollMs);
|
|
160
|
+
this.gateTimer.unref?.();
|
|
161
|
+
this.pollTimer = setInterval(() => { void this.tick(); }, this.pollMs);
|
|
162
|
+
this.pollTimer.unref?.();
|
|
163
|
+
logger.info(TAG, `started (poll ${this.pollMs / 1000}s, cooldown ${this.cooldownMs / 60000}m, cap ${this.dailyCap}/day, gate polled ${this.gatePollMs / 60000}m)`);
|
|
164
|
+
}
|
|
165
|
+
stop() {
|
|
166
|
+
if (this.pollTimer)
|
|
167
|
+
clearInterval(this.pollTimer);
|
|
168
|
+
if (this.gateTimer)
|
|
169
|
+
clearInterval(this.gateTimer);
|
|
170
|
+
this.pollTimer = null;
|
|
171
|
+
this.gateTimer = null;
|
|
172
|
+
}
|
|
173
|
+
getMode() {
|
|
174
|
+
return this.mode;
|
|
175
|
+
}
|
|
176
|
+
/** Fed by the bridge from provider `chatMessageStart` — any agent turn
|
|
177
|
+
* beginning counts (the wake's reply, or an operator chat's; either proves
|
|
178
|
+
* the lane is alive, which is what the swallow check needs). */
|
|
179
|
+
noteAssistantActivity(atMs) {
|
|
180
|
+
const t = atMs ?? this.opts.now?.() ?? Date.now();
|
|
181
|
+
this.lastAssistantAtMs = t;
|
|
182
|
+
if (this.awaitingSinceMs !== null && t >= this.awaitingSinceMs) {
|
|
183
|
+
logger.info(TAG, `wake reply confirmed — agent turn started ${Math.round((t - this.awaitingSinceMs) / 1000)}s after delivery`);
|
|
184
|
+
this.awaitingSinceMs = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Suspected-swallow count this process (test/telemetry seam). */
|
|
188
|
+
getSuspectedSwallows() {
|
|
189
|
+
return this.suspectedSwallows;
|
|
190
|
+
}
|
|
191
|
+
checkPendingReply(nowMs) {
|
|
192
|
+
if (this.awaitingSinceMs === null || nowMs <= this.awaitingDeadlineMs)
|
|
193
|
+
return;
|
|
194
|
+
this.suspectedSwallows++;
|
|
195
|
+
logger.warn(TAG, `wake delivered at ${new Date(this.awaitingSinceMs).toISOString()} but NO agent turn started ` +
|
|
196
|
+
`within ${Math.round(this.replyTimeoutMs / 60_000)}m — the main-session lane may be silently ` +
|
|
197
|
+
`swallowing turns (known OpenClaw class, 2026-07-22; dashboard chat is likely affected too). ` +
|
|
198
|
+
`Suspected swallows this process: ${this.suspectedSwallows}`);
|
|
199
|
+
this.awaitingSinceMs = null;
|
|
200
|
+
}
|
|
201
|
+
async refreshGate() {
|
|
202
|
+
const base = (this.opts.webappUrl ?? process.env.REEFCLAW_API_URL ?? 'https://www.reefclaw.com').replace(/\/$/, '');
|
|
203
|
+
try {
|
|
204
|
+
const doFetch = this.opts.fetchImpl ?? fetch;
|
|
205
|
+
const res = await doFetch(`${base}/api/internal/config`, {
|
|
206
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok)
|
|
209
|
+
return; // keep last-known mode on outage (fail toward inert)
|
|
210
|
+
const next = parseShockWakeGate(await res.json());
|
|
211
|
+
if (next !== this.mode) {
|
|
212
|
+
logger.info(TAG, `gate: shockWake ${this.mode} → ${next}`);
|
|
213
|
+
this.mode = next;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
logger.debug(TAG, `gate poll failed (keeping ${this.mode}): ${formatError(err)}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** One poll cycle. Exposed for tests. */
|
|
221
|
+
async tick() {
|
|
222
|
+
const tickNowMs = this.opts.now?.() ?? Date.now();
|
|
223
|
+
// Runs even when the gate is off — a wake delivered just before an
|
|
224
|
+
// operator rollback still deserves its confirmation verdict.
|
|
225
|
+
this.checkPendingReply(tickNowMs);
|
|
226
|
+
if (this.mode === 'off' || this.polling)
|
|
227
|
+
return;
|
|
228
|
+
this.polling = true;
|
|
229
|
+
try {
|
|
230
|
+
const nowMs = tickNowMs;
|
|
231
|
+
const doFetch = this.opts.fetchImpl ?? fetch;
|
|
232
|
+
const base = this.opts.intelligenceUrl.replace(/\/$/, '');
|
|
233
|
+
const res = await doFetch(`${base}/api/shocks`, {
|
|
234
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
235
|
+
});
|
|
236
|
+
if (!res.ok)
|
|
237
|
+
return;
|
|
238
|
+
const body = (await res.json());
|
|
239
|
+
if (!Array.isArray(body?.shocks))
|
|
240
|
+
return;
|
|
241
|
+
const a = assessWakeWorthiness(body.shocks, nowMs, this.minBroad);
|
|
242
|
+
if (!a.wake)
|
|
243
|
+
return;
|
|
244
|
+
// Rate limits — checked only for wake-worthy ticks so quiet markets
|
|
245
|
+
// never touch the counters.
|
|
246
|
+
const day = new Date(nowMs).toISOString().slice(0, 10);
|
|
247
|
+
if (day !== this.wakeDayUtc) {
|
|
248
|
+
this.wakeDayUtc = day;
|
|
249
|
+
this.wakesToday = 0;
|
|
250
|
+
}
|
|
251
|
+
if (nowMs - this.lastWakeAtMs < this.cooldownMs)
|
|
252
|
+
return;
|
|
253
|
+
if (this.wakesToday >= this.dailyCap) {
|
|
254
|
+
logger.warn(TAG, `daily cap ${this.dailyCap} reached — suppressing further wakes today`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const coins = [...new Set(a.shocked.map((s) => coinOf(s.symbol)))].join(', ');
|
|
258
|
+
if (this.mode === 'shadow') {
|
|
259
|
+
this.lastWakeAtMs = nowMs;
|
|
260
|
+
this.wakesToday++;
|
|
261
|
+
logger.warn(TAG, `WOULD WAKE (shadow): ${a.reason} — ${coins} (${this.wakesToday}/${this.dailyCap} today)`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const message = buildWakeMessage(a);
|
|
265
|
+
const out = await this.opts.provider.handleChat(message);
|
|
266
|
+
if (out.received) {
|
|
267
|
+
this.lastWakeAtMs = nowMs;
|
|
268
|
+
this.wakesToday++;
|
|
269
|
+
// Arm the delivery-confirmation window (see checkPendingReply).
|
|
270
|
+
this.awaitingSinceMs = nowMs;
|
|
271
|
+
this.awaitingDeadlineMs = nowMs + this.replyTimeoutMs;
|
|
272
|
+
logger.warn(TAG, `WAKE delivered: ${a.reason} — ${coins} (${this.wakesToday}/${this.dailyCap} today)`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
// Delivery failed (gateway not connected etc.) — no cooldown, retry
|
|
276
|
+
// on the next tick while the shock is still fresh.
|
|
277
|
+
logger.warn(TAG, `wake delivery FAILED (${out.error ?? 'unknown'}) — will retry next tick`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
logger.debug(TAG, `tick failed: ${formatError(err)}`);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
this.polling = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Test seam. */
|
|
288
|
+
__setModeForTest(mode) {
|
|
289
|
+
this.mode = mode;
|
|
290
|
+
}
|
|
291
|
+
}
|
package/assets/bridge/types.d.ts
CHANGED
|
@@ -133,10 +133,51 @@ export interface AgentStateData {
|
|
|
133
133
|
/** Last run outcome, e.g. "ok" | "error". */
|
|
134
134
|
lastRunStatus?: string;
|
|
135
135
|
};
|
|
136
|
+
/** Compact summary of the last recorded heartbeat run (flight recorder,
|
|
137
|
+
* skill/src/heartbeat-runs.ts). Absent until the first beat is
|
|
138
|
+
* recorded after start — byte-identical to pre-feature until then. */
|
|
139
|
+
lastHeartbeat?: LastHeartbeatSummary;
|
|
140
|
+
/** Is the LLM behind the agent answering? Derived skill-side from the
|
|
141
|
+
* cron job's FailoverReason + the last recorded beat
|
|
142
|
+
* (skill/src/model-health.ts). Absent until first derived. */
|
|
143
|
+
modelHealth?: ModelHealthSummary;
|
|
136
144
|
};
|
|
137
145
|
};
|
|
138
146
|
timestamp: string;
|
|
139
147
|
}
|
|
148
|
+
/** Dashboard-facing summary of one heartbeat run (the full record lives in
|
|
149
|
+
* the webapp's heartbeat_runs table). Token counts are RAW COUNTS — never
|
|
150
|
+
* translated to a currency here or downstream. */
|
|
151
|
+
export interface LastHeartbeatSummary {
|
|
152
|
+
runId: string;
|
|
153
|
+
startedAtMs: number;
|
|
154
|
+
endedAtMs: number | null;
|
|
155
|
+
durationMs: number | null;
|
|
156
|
+
status: 'ok' | 'error' | 'incomplete' | 'unknown';
|
|
157
|
+
toolCallCount: number;
|
|
158
|
+
toolErrorCount: number;
|
|
159
|
+
tokensTotal: number | null;
|
|
160
|
+
tokensInput: number | null;
|
|
161
|
+
tokensOutput: number | null;
|
|
162
|
+
model: string | null;
|
|
163
|
+
}
|
|
164
|
+
export interface ModelHealthSummary {
|
|
165
|
+
level: 'ok' | 'degraded' | 'error';
|
|
166
|
+
reason: string | null;
|
|
167
|
+
detail: string | null;
|
|
168
|
+
atMs: number;
|
|
169
|
+
model: string | null;
|
|
170
|
+
}
|
|
171
|
+
/** An agent turn's lifecycle as the gateway reports it, WITH the session key
|
|
172
|
+
* (an isolated cron beat runs under `agent:main:cron:<jobId>:run:<sid>`),
|
|
173
|
+
* so consumers can tell a heartbeat turn from a chat turn. */
|
|
174
|
+
export interface AgentRunLifecycleData {
|
|
175
|
+
runId: string;
|
|
176
|
+
phase: 'start' | 'end' | 'error';
|
|
177
|
+
sessionKey?: string;
|
|
178
|
+
sessionId?: string;
|
|
179
|
+
at: number;
|
|
180
|
+
}
|
|
140
181
|
export interface RiskLimits {
|
|
141
182
|
position: {
|
|
142
183
|
maxPositionSize: number;
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
* `positionReviewMode` (slice 3, the Position Decision Journal
|
|
7
7
|
* heartbeat-mandate + superset gate, file key `positionReview.mode`) →
|
|
8
8
|
* `approvalMode` (slice 4, per-trade operator approval, file key
|
|
9
|
-
* `approval.mode`)
|
|
9
|
+
* `approval.mode`) → `reentryCooldown` (create_order re-entry cooldown, file
|
|
10
|
+
* key `reentryCooldown.mode`). All but approvalMode ride the same four-stage
|
|
10
11
|
* `off → shadow → observe → enforce` ladder; approvalMode has its own. */
|
|
11
12
|
export interface AgentGates {
|
|
12
13
|
exitGate?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
13
14
|
positionReviewMode?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
14
15
|
approvalMode?: 'off' | 'per_trade';
|
|
16
|
+
reentryCooldown?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
15
17
|
}
|
|
16
18
|
/** Server-resolved entitlement verdict (webapp lib/entitlements.ts, computed
|
|
17
19
|
* from the users row and delivered on the config channel). The plugin NEVER
|
|
@@ -125,6 +125,10 @@ function validateGates(raw) {
|
|
|
125
125
|
if (typeof approvalMode === 'string' && APPROVAL_MODE_VALUES.has(approvalMode)) {
|
|
126
126
|
gates.approvalMode = approvalMode;
|
|
127
127
|
}
|
|
128
|
+
const reentryCooldown = obj.reentryCooldown;
|
|
129
|
+
if (typeof reentryCooldown === 'string' && MODE_LADDER_VALUES.has(reentryCooldown)) {
|
|
130
|
+
gates.reentryCooldown = reentryCooldown;
|
|
131
|
+
}
|
|
128
132
|
return gates;
|
|
129
133
|
}
|
|
130
134
|
/** Version-monotonic acceptance (basic rollback/replay protection): a fetched
|
|
@@ -18,6 +18,9 @@ declare class GateStore {
|
|
|
18
18
|
* is the pre-existing autonomous behaviour, so neither value can leave a
|
|
19
19
|
* position unprotected. */
|
|
20
20
|
getApprovalMode(): AgentGates['approvalMode'] | null;
|
|
21
|
+
/** The central reentryCooldown mode, or null when central has no value (or
|
|
22
|
+
* the kill-switch is on) — null tells the reader to fall back to the file. */
|
|
23
|
+
getReentryCooldown(): AgentGates['reentryCooldown'] | null;
|
|
21
24
|
/** Test-only. */
|
|
22
25
|
__reset(): void;
|
|
23
26
|
}
|
|
@@ -31,12 +31,14 @@ class GateStore {
|
|
|
31
31
|
const next = gates ?? {};
|
|
32
32
|
const changed = next.exitGate !== this.gates.exitGate ||
|
|
33
33
|
next.positionReviewMode !== this.gates.positionReviewMode ||
|
|
34
|
-
next.approvalMode !== this.gates.approvalMode
|
|
34
|
+
next.approvalMode !== this.gates.approvalMode ||
|
|
35
|
+
next.reentryCooldown !== this.gates.reentryCooldown;
|
|
35
36
|
this.gates = { ...next };
|
|
36
37
|
if (changed) {
|
|
37
38
|
logger.info(TAG, `applied central gates: exitGate=${next.exitGate ?? UNSET} ` +
|
|
38
39
|
`positionReviewMode=${next.positionReviewMode ?? UNSET} ` +
|
|
39
|
-
`approvalMode=${next.approvalMode ?? UNSET}`
|
|
40
|
+
`approvalMode=${next.approvalMode ?? UNSET} ` +
|
|
41
|
+
`reentryCooldown=${next.reentryCooldown ?? UNSET}`);
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
/** The central exitGate mode, or null when central has no value (or the
|
|
@@ -66,6 +68,13 @@ class GateStore {
|
|
|
66
68
|
return null;
|
|
67
69
|
return this.gates.approvalMode ?? null;
|
|
68
70
|
}
|
|
71
|
+
/** The central reentryCooldown mode, or null when central has no value (or
|
|
72
|
+
* the kill-switch is on) — null tells the reader to fall back to the file. */
|
|
73
|
+
getReentryCooldown() {
|
|
74
|
+
if (!centralGatesEnabled())
|
|
75
|
+
return null;
|
|
76
|
+
return this.gates.reentryCooldown ?? null;
|
|
77
|
+
}
|
|
69
78
|
/** Test-only. */
|
|
70
79
|
__reset() {
|
|
71
80
|
this.gates = {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// WS1 (docs/MARKET_ADAPTIVITY_PLAN.md §3) — mode resolver for the LIVE
|
|
2
|
+
// loss-streak sizing brake feed.
|
|
3
|
+
//
|
|
4
|
+
// preTradeRiskCheck has always had a graduated loss-streak brake (0.5× /
|
|
5
|
+
// 0.25× position size at the operator-tunable lossStreakHalfSize /
|
|
6
|
+
// lossStreakQuarterSize thresholds — never a hard block), but live fed it a
|
|
7
|
+
// hardcoded consecutiveLosses=0 ("would come from intelligence DB — use 0
|
|
8
|
+
// for now"). The real feed now comes from the ReentryTracker exit records
|
|
9
|
+
// (wasLoss + book tag, persisted).
|
|
10
|
+
//
|
|
11
|
+
// Env RC_LOSS_STREAK_SIZING:
|
|
12
|
+
// 'enforce' (DEFAULT since 2026-09-05) — feed the real streak to the risk
|
|
13
|
+
// check; the graduated size reduction applies on
|
|
14
|
+
// live exactly as it always has on paper. Promoted
|
|
15
|
+
// from 'log' after the pre-registered soak: 3 days
|
|
16
|
+
// of clean streak logs (1→3 tracked + reset
|
|
17
|
+
// correctly) and an 18/18 loss-sign agreement audit
|
|
18
|
+
// between the agent's r_multiple_at_close and DB
|
|
19
|
+
// realized_r.
|
|
20
|
+
// 'log' — compute + log on entries; sizing UNAFFECTED
|
|
21
|
+
// (riskCheck still sees 0). The rollout soak mode.
|
|
22
|
+
// 'off' — no compute, no log; byte-identical to the
|
|
23
|
+
// pre-WS1 path (kill-switch).
|
|
24
|
+
//
|
|
25
|
+
// Deliberately env-based (not the central gate channel): it is a
|
|
26
|
+
// live/paper-parity bug fix on a risk-reduction mechanism, not a new policy
|
|
27
|
+
// ladder — and its only enforce-direction effect is SMALLER size.
|
|
28
|
+
export function resolveLossStreakSizingMode() {
|
|
29
|
+
const raw = (process.env.RC_LOSS_STREAK_SIZING ?? '').toLowerCase();
|
|
30
|
+
if (raw === 'off' || raw === 'log')
|
|
31
|
+
return raw;
|
|
32
|
+
return 'enforce';
|
|
33
|
+
}
|
|
@@ -197,6 +197,25 @@ export interface PluginConfigFile {
|
|
|
197
197
|
stopWatcher?: {
|
|
198
198
|
intervalMs?: number;
|
|
199
199
|
};
|
|
200
|
+
/** Re-entry cooldown gate — blocks (mode-laddered) a NEW create_order entry
|
|
201
|
+
* on a symbol whose last close within `minutes` was a LOSS. See
|
|
202
|
+
* plugin/src/tools/reentry-cooldown.ts for the evidence + semantics.
|
|
203
|
+
*
|
|
204
|
+
* mode='off' (default) — gate never runs; behaviour identical to today.
|
|
205
|
+
* mode='shadow' — gate runs; verdict logged + tagged into
|
|
206
|
+
* position_entries.metadata.reentry_cooldown;
|
|
207
|
+
* the order always fires.
|
|
208
|
+
* mode='observe' — as shadow, but a triggered verdict logs at WARN.
|
|
209
|
+
* mode='enforce' — triggered verdict hard-rejects create_order.
|
|
210
|
+
*
|
|
211
|
+
* `mode` here is the LOCAL fallback — once the central gate
|
|
212
|
+
* (agent_config.gates.reentryCooldown) is set, central rules (kill-switch
|
|
213
|
+
* RC_CENTRAL_GATES=off). `minutes` is local-only (default 60, clamped
|
|
214
|
+
* 5–1440). */
|
|
215
|
+
reentryCooldown?: {
|
|
216
|
+
mode?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
217
|
+
minutes?: number;
|
|
218
|
+
};
|
|
200
219
|
[extra: string]: unknown;
|
|
201
220
|
}
|
|
202
221
|
export declare function defaultConfigPath(): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type PluginConfigFile } from './plugin-config-io.js';
|
|
2
|
+
import type { ReentryCooldownMode } from '../tools/reentry-cooldown.js';
|
|
3
|
+
export declare const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
|
|
4
|
+
export declare function getReentryCooldownMode(config?: PluginConfigFile): ReentryCooldownMode;
|
|
5
|
+
export declare function loadReentryCooldownMode(): ReentryCooldownMode;
|
|
6
|
+
export declare function getReentryCooldownMinutes(config?: PluginConfigFile): number;
|
|
7
|
+
export declare function loadReentryCooldownMinutes(): number;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Feature-flag readers for the re-entry cooldown gate (create_order).
|
|
2
|
+
//
|
|
3
|
+
// Mode default 'off' so the gate ships dead-code; the cooldown window default
|
|
4
|
+
// (60 min) matches the 2026-09-02 measurement window that motivated the gate.
|
|
5
|
+
// Mode resolution follows the exitGate pattern (config-service slice 2):
|
|
6
|
+
//
|
|
7
|
+
// central (agent_config.gates.reentryCooldown via gate-store)
|
|
8
|
+
// → plugin-config.json reentryCooldown.mode
|
|
9
|
+
// → 'off'
|
|
10
|
+
//
|
|
11
|
+
// create_order reads the mode PER CALL, so a dashboard/API flip via
|
|
12
|
+
// scripts/enable-reentry-cooldown.py hot-applies within one config poll —
|
|
13
|
+
// no restart. Kill-switch RC_CENTRAL_GATES=off hands control back to the
|
|
14
|
+
// local file. The minutes knob is LOCAL-only (mechanism tunable, not a
|
|
15
|
+
// ladder) — central carries only the mode.
|
|
16
|
+
import { readPluginConfig } from './plugin-config-io.js';
|
|
17
|
+
import { gateStore } from './gate-store.js';
|
|
18
|
+
const VALID_MODES = new Set([
|
|
19
|
+
'off',
|
|
20
|
+
'shadow',
|
|
21
|
+
'observe',
|
|
22
|
+
'enforce',
|
|
23
|
+
]);
|
|
24
|
+
export const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
|
|
25
|
+
const MIN_COOLDOWN_MINUTES = 5;
|
|
26
|
+
const MAX_COOLDOWN_MINUTES = 1440;
|
|
27
|
+
export function getReentryCooldownMode(config) {
|
|
28
|
+
const raw = config?.reentryCooldown?.mode;
|
|
29
|
+
if (typeof raw === 'string' && VALID_MODES.has(raw)) {
|
|
30
|
+
return raw;
|
|
31
|
+
}
|
|
32
|
+
return 'off';
|
|
33
|
+
}
|
|
34
|
+
export function loadReentryCooldownMode() {
|
|
35
|
+
const central = gateStore.getReentryCooldown();
|
|
36
|
+
if (central)
|
|
37
|
+
return central;
|
|
38
|
+
try {
|
|
39
|
+
return getReentryCooldownMode(readPluginConfig());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return 'off';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function getReentryCooldownMinutes(config) {
|
|
46
|
+
const raw = config?.reentryCooldown?.minutes;
|
|
47
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
48
|
+
return Math.max(MIN_COOLDOWN_MINUTES, Math.min(MAX_COOLDOWN_MINUTES, raw));
|
|
49
|
+
}
|
|
50
|
+
return DEFAULT_REENTRY_COOLDOWN_MINUTES;
|
|
51
|
+
}
|
|
52
|
+
export function loadReentryCooldownMinutes() {
|
|
53
|
+
try {
|
|
54
|
+
return getReentryCooldownMinutes(readPluginConfig());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return DEFAULT_REENTRY_COOLDOWN_MINUTES;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/assets/plugin/index.js
CHANGED
|
@@ -2637,6 +2637,11 @@ const paperTradingPlugin = {
|
|
|
2637
2637
|
decisionsClient: positionDecisionsClient,
|
|
2638
2638
|
userId: positionDecisionsUserId,
|
|
2639
2639
|
reentryTracker,
|
|
2640
|
+
// WS2 directional scoreboard (docs/MARKET_ADAPTIVITY_PLAN.md §3):
|
|
2641
|
+
// tracked positions from the state store (no exchange round-trip)
|
|
2642
|
+
// + book resolved per call so a paper↔live flip follows.
|
|
2643
|
+
openPositions: () => positionStateStore.getAll().map((e) => ({ side: e.side })),
|
|
2644
|
+
book: () => (runtime.adapter.isLive ? 'live' : 'paper'),
|
|
2640
2645
|
})),
|
|
2641
2646
|
},
|
|
2642
2647
|
{
|
|
@@ -306,12 +306,27 @@ export async function onClosePositionFilled(ctx, inputs, order) {
|
|
|
306
306
|
};
|
|
307
307
|
ctx.decisionsClient.postClose(ctx.userId, close);
|
|
308
308
|
// Re-entry indication (issue #204) — record the exit so scan_pairs can flag
|
|
309
|
-
// same-bar re-entries on this (symbol, setup)
|
|
309
|
+
// same-bar re-entries on this (symbol, setup), and so the reentryCooldown
|
|
310
|
+
// gate can see recent losses. Live agent-closes have no engine trade record
|
|
311
|
+
// here (exchange-exact PnL arrives later on the WS fill, racing this path),
|
|
312
|
+
// so the loss sign falls back to the agent's own r_multiple_at_close — the
|
|
313
|
+
// same validator-checked field the exit gate trusts. lossSource lets the
|
|
314
|
+
// shadow soak audit that sign against the DB before enforce.
|
|
315
|
+
const rRaw = inputs.closeAssessment?.['r_multiple_at_close'];
|
|
316
|
+
const rAtClose = typeof rRaw === 'number' && Number.isFinite(rRaw) ? rRaw : undefined;
|
|
310
317
|
ctx.reentryTracker?.recordExit({
|
|
311
318
|
symbol: inputs.symbol,
|
|
312
319
|
setupType: stateEntry.setupType ?? paperTrade?.setupType,
|
|
313
320
|
side: stateEntry.side,
|
|
314
|
-
wasLoss: paperTrade
|
|
321
|
+
wasLoss: paperTrade
|
|
322
|
+
? paperTrade.netRealizedPnl < 0
|
|
323
|
+
: rAtClose != null
|
|
324
|
+
? rAtClose < 0
|
|
325
|
+
: undefined,
|
|
326
|
+
lossSource: paperTrade ? 'paper_engine' : rAtClose != null ? 'assessment_r' : undefined,
|
|
327
|
+
mode: ctx.resolveMode?.(),
|
|
328
|
+
realizedR: rAtClose,
|
|
329
|
+
realizedPnl: paperTrade?.netRealizedPnl,
|
|
315
330
|
closedAtMs: closeAtMs,
|
|
316
331
|
});
|
|
317
332
|
// Drop local state — symbol can re-enter as a new position.
|
|
@@ -388,6 +403,7 @@ export async function onAutoFlattenClose(ctx, inputs, lookup = {
|
|
|
388
403
|
symbol: inputs.symbol,
|
|
389
404
|
setupType: flattenState?.setupType,
|
|
390
405
|
side: flattenState?.side ?? 'long',
|
|
406
|
+
mode: ctx.resolveMode?.(),
|
|
391
407
|
closedAtMs: inputs.observedAtMs ?? Date.now(),
|
|
392
408
|
});
|
|
393
409
|
ctx.stateStore.remove(inputs.symbol);
|
|
@@ -421,6 +437,9 @@ export async function onStopWatcherClose(ctx, inputs) {
|
|
|
421
437
|
setupType: stateEntry?.setupType ?? paperTrade?.setupType,
|
|
422
438
|
side: stateEntry?.side ?? 'long',
|
|
423
439
|
wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
|
|
440
|
+
lossSource: paperTrade ? 'paper_engine' : undefined,
|
|
441
|
+
mode: ctx.resolveMode?.(),
|
|
442
|
+
realizedPnl: paperTrade?.netRealizedPnl,
|
|
424
443
|
closedAtMs: closeAtMs,
|
|
425
444
|
});
|
|
426
445
|
const dropState = () => { ctx.stateStore?.remove(inputs.symbol); };
|
|
@@ -680,6 +699,9 @@ async function handleReduceOnlyExit(ctx, fill) {
|
|
|
680
699
|
setupType: stateEntry.setupType,
|
|
681
700
|
side: stateEntry.side,
|
|
682
701
|
wasLoss: realizedPnl < 0,
|
|
702
|
+
lossSource: 'ws_fill',
|
|
703
|
+
mode: ctx.resolveMode?.(),
|
|
704
|
+
realizedPnl,
|
|
683
705
|
closedAtMs: fill.exchangeTimeMs ?? Date.now(),
|
|
684
706
|
});
|
|
685
707
|
ctx.stateStore.remove(fill.symbol);
|
|
@@ -745,5 +767,16 @@ export function buildEntryPlanMetadata(md) {
|
|
|
745
767
|
j.note = rr.note;
|
|
746
768
|
out.realization_rule = j;
|
|
747
769
|
}
|
|
770
|
+
// Cooldown-gate measurement tag (snake_case per the canonical JSONB key
|
|
771
|
+
// rule) — present only when the gate triggered and the entry fired anyway.
|
|
772
|
+
const rc = md.reentryCooldown;
|
|
773
|
+
if (rc) {
|
|
774
|
+
out.reentry_cooldown = {
|
|
775
|
+
mode: rc.mode,
|
|
776
|
+
minutes_since_loss: rc.minutesSinceLoss,
|
|
777
|
+
cooldown_minutes: rc.cooldownMinutes,
|
|
778
|
+
would_block: rc.wouldBlock,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
748
781
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
749
782
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.25",
|
|
5
5
|
"description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard — the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|