@reefclaw/openclaw-plugin 0.1.6 → 0.1.8
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/gateway/event-parser.d.ts +6 -1
- package/bridge/gateway/event-parser.js +19 -2
- package/bridge/gateway/poller.d.ts +1 -0
- package/bridge/gateway/poller.js +14 -2
- package/bridge/providers/gateway.d.ts +22 -2
- package/bridge/providers/gateway.js +67 -9
- package/ccxt/public-market-data-api.d.ts +14 -0
- package/ccxt/public-market-data-api.js +15 -1
- package/config/plugin-config-io.d.ts +7 -0
- package/config/plugin-config-io.js +15 -0
- package/index.js +116 -31
- package/ingest/position-auto-capture.d.ts +68 -0
- package/ingest/position-auto-capture.js +321 -23
- package/ingest/position-decisions-client.d.ts +7 -2
- package/ingest/position-decisions-client.js +13 -3
- package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
- package/ingest/reconcile-db-vs-exchange.js +66 -10
- package/live/fill-price.d.ts +13 -0
- package/live/fill-price.js +37 -0
- package/live/live-adapter.d.ts +33 -1
- package/live/live-adapter.js +176 -47
- package/live/position-state-store.d.ts +4 -0
- package/live/stop-watcher.d.ts +8 -1
- package/live/stop-watcher.js +5 -2
- package/onboarding/runtime.d.ts +18 -0
- package/onboarding/runtime.js +45 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/portfolio/reentry-tracker.d.ts +36 -0
- package/portfolio/reentry-tracker.js +127 -0
- package/scripts/assemble.mjs +18 -2
- package/signals/conditions/registry.js +11 -2
- package/signals/strategy-adapter.js +17 -7
- package/simulator/exchange-simulator.d.ts +12 -0
- package/simulator/exchange-simulator.js +73 -3
- package/simulator/types.d.ts +4 -0
- package/skills/reefclaw/SKILL.md +2 -0
- package/tools/assessment-validation.d.ts +21 -0
- package/tools/assessment-validation.js +58 -0
- package/tools/attach-brackets.js +165 -0
- package/tools/audit-bracket-protection.js +157 -1
- package/tools/bracket-control.d.ts +12 -0
- package/tools/bracket-control.js +35 -0
- package/tools/create-order.d.ts +7 -0
- package/tools/create-order.js +42 -3
- package/tools/get-setup-detail.js +12 -1
- package/tools/modify-stop.js +5 -5
- package/tools/modify-target.js +5 -5
- package/tools/scan-pairs.d.ts +4 -0
- package/tools/scan-pairs.js +4 -1
- package/tools/set-trading-mode.js +23 -6
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
- package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
- package/venues/hyperliquid/hl-live-adapter.js +380 -5
- package/venues/hyperliquid/hl-public.js +8 -1
package/onboarding/runtime.js
CHANGED
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
// instant — no gateway restart required.
|
|
14
14
|
import { PaperAdapter } from '../paper-adapter.js';
|
|
15
15
|
import { LiveAdapter } from '../live/live-adapter.js';
|
|
16
|
+
import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
|
|
17
|
+
import { createLiveAdapter } from '../venues/registry.js';
|
|
16
18
|
import { PositionWatcher } from '../live/stop-watcher.js';
|
|
17
19
|
import { loadBracketMode } from '../config/brackets-config.js';
|
|
20
|
+
import { loadStopWatcherIntervalMs, readPluginConfig } from '../config/plugin-config-io.js';
|
|
18
21
|
import { logger, formatError } from '../logger.js';
|
|
19
22
|
const TAG = 'plugin-runtime';
|
|
20
23
|
/** Pure-ish factory: builds an adapter for the requested mode.
|
|
@@ -28,6 +31,23 @@ export function buildAdapter(input) {
|
|
|
28
31
|
// ShadowTracker wraps a BinancePrivateApi for real-balance comparison.
|
|
29
32
|
return new PaperAdapter(simulator);
|
|
30
33
|
}
|
|
34
|
+
// Hyperliquid live (issue #217): mirror the boot path's construction —
|
|
35
|
+
// per-venue credential shape, the SAME factory (createLiveAdapter), and
|
|
36
|
+
// the same fall-back-to-paper defense when credentials are absent.
|
|
37
|
+
if (input.venue === 'hyperliquid') {
|
|
38
|
+
if (!input.hlCredentials) {
|
|
39
|
+
logger.warn(TAG, `${mode} on hyperliquid requested without walletAddress+agentPrivateKey — falling back to PAPER`);
|
|
40
|
+
return new PaperAdapter(simulator);
|
|
41
|
+
}
|
|
42
|
+
return createLiveAdapter({
|
|
43
|
+
venue: 'hyperliquid',
|
|
44
|
+
args: {
|
|
45
|
+
credentials: input.hlCredentials,
|
|
46
|
+
mode: mode,
|
|
47
|
+
marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
31
51
|
if (!exchange) {
|
|
32
52
|
logger.warn(TAG, `${mode} requested without credentials — falling back to PAPER`);
|
|
33
53
|
return new PaperAdapter(simulator);
|
|
@@ -57,6 +77,11 @@ export class PluginRuntime {
|
|
|
57
77
|
_marketFeed;
|
|
58
78
|
operationLock;
|
|
59
79
|
wave9LiveLifecycleHooks;
|
|
80
|
+
/** Observer applied to EVERY stop-watcher this runtime creates (reconnects
|
|
81
|
+
* included). index.ts uses it to attach the journal auto-capture listener
|
|
82
|
+
* for watcher closes (issue #199) — without it, a live<->paper reconnect
|
|
83
|
+
* would silently shed the capture wiring. */
|
|
84
|
+
onWatcherCreated;
|
|
60
85
|
/** Reconnect is serialized — a second caller waits for the first to finish
|
|
61
86
|
* so we never tear down an adapter that's mid-rebuild. */
|
|
62
87
|
reconnectInFlight = null;
|
|
@@ -67,6 +92,7 @@ export class PluginRuntime {
|
|
|
67
92
|
this._stopWatcher = initial.stopWatcher ?? null;
|
|
68
93
|
this._marketFeed = initial.marketFeed ?? null;
|
|
69
94
|
this.operationLock = initial.operationLock;
|
|
95
|
+
this.onWatcherCreated = initial.onWatcherCreated;
|
|
70
96
|
}
|
|
71
97
|
get adapter() { return this._adapter; }
|
|
72
98
|
get mode() { return this._mode; }
|
|
@@ -124,12 +150,24 @@ export class PluginRuntime {
|
|
|
124
150
|
logger.warn(TAG, `old adapter shutdown failed: ${formatError(err)}`);
|
|
125
151
|
}
|
|
126
152
|
}
|
|
153
|
+
else if (old instanceof HyperliquidLiveAdapter) {
|
|
154
|
+
// Stops the HL user stream + the 60s REST truth-check timer — without
|
|
155
|
+
// this, a flip AWAY from HL live leaks both (issue #217).
|
|
156
|
+
try {
|
|
157
|
+
old.stop();
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
logger.warn(TAG, `old HL adapter stop failed: ${formatError(err)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
127
163
|
// 3. Build the new adapter.
|
|
128
164
|
const fresh = buildAdapter({
|
|
129
165
|
mode: next.mode,
|
|
130
166
|
exchange: next.exchange,
|
|
131
167
|
microLive: next.microLive,
|
|
132
168
|
simulator: this.simulator,
|
|
169
|
+
venue: next.venue,
|
|
170
|
+
hlCredentials: next.hlCredentials,
|
|
133
171
|
});
|
|
134
172
|
// Install autonomous protection callbacks before initialization can emit
|
|
135
173
|
// user-data or bracket-reconciler events.
|
|
@@ -138,7 +176,7 @@ export class PluginRuntime {
|
|
|
138
176
|
}
|
|
139
177
|
// 4. Fire async init for live adapters (non-blocking — readiness flips
|
|
140
178
|
// INIT_PENDING → READY/DEGRADED/BLOCKED on its own).
|
|
141
|
-
if (fresh instanceof LiveAdapter) {
|
|
179
|
+
if (fresh instanceof LiveAdapter || fresh instanceof HyperliquidLiveAdapter) {
|
|
142
180
|
fresh.initialize().catch(err => {
|
|
143
181
|
logger.error(TAG, `New adapter init failed: ${formatError(err)}`);
|
|
144
182
|
});
|
|
@@ -152,9 +190,13 @@ export class PluginRuntime {
|
|
|
152
190
|
this._adapter = fresh;
|
|
153
191
|
this._mode = next.mode;
|
|
154
192
|
deps.adapterDeps.adapter = fresh;
|
|
155
|
-
// 6. Start a new stop-watcher bound to the new adapter.
|
|
156
|
-
|
|
193
|
+
// 6. Start a new stop-watcher bound to the new adapter. Re-read the
|
|
194
|
+
// operator's cadence override — building with `undefined` here
|
|
195
|
+
// silently reverted plugin-config `stopWatcher.intervalMs` to the
|
|
196
|
+
// default on every reconnect.
|
|
197
|
+
const watcher = new PositionWatcher(fresh, loadStopWatcherIntervalMs(), this.operationLock);
|
|
157
198
|
this.wave9LiveLifecycleHooks?.configurePositionWatcher?.(watcher, fresh);
|
|
199
|
+
this.onWatcherCreated?.(watcher);
|
|
158
200
|
watcher.start();
|
|
159
201
|
this._stopWatcher = watcher;
|
|
160
202
|
// 7. Paper market feed follows the mode: run it when the new adapter is
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Paper Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.8",
|
|
5
5
|
"description": "Paper trading plugin with real Binance market data and simulated execution. No API keys required.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "ReefClaw trading plugin for OpenClaw \u2014 paper trading with real Binance market data, plus the ReefClaw dashboard connector (supervised by OpenClaw, no service manager needed). Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@reefclaw/shared": "0.1.
|
|
25
|
+
"@reefclaw/shared": "0.1.2",
|
|
26
26
|
"ccxt": "4.5.37",
|
|
27
27
|
"json5": "2.2.3",
|
|
28
28
|
"ws": "8.19.0"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface ReentryExitRecord {
|
|
2
|
+
/** Canonical symbol (no settle suffix). */
|
|
3
|
+
symbol: string;
|
|
4
|
+
/** setup_type / strategy name from the entry metadata, when known. */
|
|
5
|
+
setupType?: string;
|
|
6
|
+
side: 'long' | 'short';
|
|
7
|
+
/** Whether the closed trade realized a loss (drives the stronger caution). */
|
|
8
|
+
wasLoss?: boolean;
|
|
9
|
+
closedAtMs: number;
|
|
10
|
+
}
|
|
11
|
+
/** Signal-bar duration for a strategy/setup name. Name-suffix inference:
|
|
12
|
+
* `..._4h` → 240min, `..._1d` / daily → 1440min, `..._Nh` → N×60. Unknown
|
|
13
|
+
* shapes default to 60min so short-TF strategies are never over-warned. */
|
|
14
|
+
export declare function strategyBarMinutes(name: string | undefined): number;
|
|
15
|
+
export declare class ReentryTracker {
|
|
16
|
+
private records;
|
|
17
|
+
private readonly filePath;
|
|
18
|
+
private readonly dir;
|
|
19
|
+
constructor(pluginId?: string, opts?: {
|
|
20
|
+
basePath?: string;
|
|
21
|
+
});
|
|
22
|
+
/** Record a position exit. Never throws (best-effort persistence). */
|
|
23
|
+
recordExit(record: Omit<ReentryExitRecord, 'symbol'> & {
|
|
24
|
+
symbol: string;
|
|
25
|
+
}): void;
|
|
26
|
+
/** Most recent exit for (symbol[, setup]). A setup-specific record wins over
|
|
27
|
+
* a symbol-only match so multi-strategy books get precise cautions. */
|
|
28
|
+
lastExit(symbol: string, setupType?: string): ReentryExitRecord | undefined;
|
|
29
|
+
/** Structured caution when (symbol, strategy) was already traded within the
|
|
30
|
+
* current signal bar. Undefined = no caution. Pure indication (issue #204):
|
|
31
|
+
* the agent decides; nothing here blocks an order. */
|
|
32
|
+
cautionFor(symbol: string, strategy: string | undefined, nowMs?: number): string | undefined;
|
|
33
|
+
/** Test seam. */
|
|
34
|
+
getRecords(): readonly ReentryExitRecord[];
|
|
35
|
+
private persist;
|
|
36
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Re-entry tracker — records recent position exits per (symbol, setup) so the
|
|
2
|
+
// entry funnel (scan_pairs / get_signals) can flag setups the agent already
|
|
3
|
+
// traded within the current signal bar (issue #204).
|
|
4
|
+
//
|
|
5
|
+
// WHY: the validated backtests for the 4h/1d templates have one-trade-per-
|
|
6
|
+
// signal semantics — a signal bar produces at most one trade. Live, the 4h
|
|
7
|
+
// condition stays true for hours, so after every exit the next heartbeat
|
|
8
|
+
// re-entered the same setup: measured 19× the backtest cadence with 79-minute
|
|
9
|
+
// median holds, including 22 same-direction re-entries within one bar of a
|
|
10
|
+
// LOSING close (2026-07-14 → 07-20 HL soak). Per the tools-not-mandates
|
|
11
|
+
// doctrine this ships as STRUCTURED INDICATION — the annotation tells the
|
|
12
|
+
// agent the setup was already traded this bar; it never blocks the order.
|
|
13
|
+
//
|
|
14
|
+
// Persistence: small JSON in the plugins base dir (same pattern as
|
|
15
|
+
// position-state-store) so restarts don't blind the indication. Best-effort —
|
|
16
|
+
// a persistence failure degrades to in-memory-only, never throws into the
|
|
17
|
+
// trading path.
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { logger, formatError } from '../logger.js';
|
|
21
|
+
import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
|
|
22
|
+
import { resolvePluginsBaseDir } from '../util/plugin-paths.js';
|
|
23
|
+
const TAG = 'reentry-tracker';
|
|
24
|
+
const DEFAULT_PLUGIN_ID = 'reefclaw-paper-trading';
|
|
25
|
+
const STATE_FILENAME = 'reentry-log.json';
|
|
26
|
+
const MAX_RECORDS = 300;
|
|
27
|
+
/** Signal-bar duration for a strategy/setup name. Name-suffix inference:
|
|
28
|
+
* `..._4h` → 240min, `..._1d` / daily → 1440min, `..._Nh` → N×60. Unknown
|
|
29
|
+
* shapes default to 60min so short-TF strategies are never over-warned. */
|
|
30
|
+
export function strategyBarMinutes(name) {
|
|
31
|
+
if (!name)
|
|
32
|
+
return 60;
|
|
33
|
+
const lower = name.toLowerCase();
|
|
34
|
+
const hourMatch = lower.match(/_(\d{1,2})h\b|_(\d{1,2})h_|_(\d{1,2})h$/);
|
|
35
|
+
if (hourMatch) {
|
|
36
|
+
const h = Number(hourMatch[1] ?? hourMatch[2] ?? hourMatch[3]);
|
|
37
|
+
if (Number.isFinite(h) && h > 0)
|
|
38
|
+
return h * 60;
|
|
39
|
+
}
|
|
40
|
+
if (/_1d\b|_1d_|_1d$|daily/.test(lower))
|
|
41
|
+
return 1440;
|
|
42
|
+
return 60;
|
|
43
|
+
}
|
|
44
|
+
export class ReentryTracker {
|
|
45
|
+
records = [];
|
|
46
|
+
filePath;
|
|
47
|
+
dir;
|
|
48
|
+
constructor(pluginId, opts) {
|
|
49
|
+
const base = resolvePluginsBaseDir(opts?.basePath);
|
|
50
|
+
this.dir = join(base, pluginId ?? DEFAULT_PLUGIN_ID);
|
|
51
|
+
this.filePath = join(this.dir, STATE_FILENAME);
|
|
52
|
+
try {
|
|
53
|
+
if (!existsSync(this.dir))
|
|
54
|
+
mkdirSync(this.dir, { recursive: true });
|
|
55
|
+
if (existsSync(this.filePath)) {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
57
|
+
if (Array.isArray(parsed?.records)) {
|
|
58
|
+
this.records = parsed.records.filter((r) => typeof r?.symbol === 'string' && Number.isFinite(r?.closedAtMs));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
logger.warn(TAG, `load failed (${formatError(err)}) — starting empty`);
|
|
64
|
+
this.records = [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Record a position exit. Never throws (best-effort persistence). */
|
|
68
|
+
recordExit(record) {
|
|
69
|
+
try {
|
|
70
|
+
this.records.push({ ...record, symbol: normalizeBracketSymbol(record.symbol) });
|
|
71
|
+
if (this.records.length > MAX_RECORDS) {
|
|
72
|
+
this.records = this.records.slice(-MAX_RECORDS);
|
|
73
|
+
}
|
|
74
|
+
this.persist();
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
logger.warn(TAG, `recordExit failed: ${formatError(err)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Most recent exit for (symbol[, setup]). A setup-specific record wins over
|
|
81
|
+
* a symbol-only match so multi-strategy books get precise cautions. */
|
|
82
|
+
lastExit(symbol, setupType) {
|
|
83
|
+
const key = normalizeBracketSymbol(symbol);
|
|
84
|
+
let bySetup;
|
|
85
|
+
let bySymbol;
|
|
86
|
+
for (let i = this.records.length - 1; i >= 0; i--) {
|
|
87
|
+
const r = this.records[i];
|
|
88
|
+
if (r.symbol !== key)
|
|
89
|
+
continue;
|
|
90
|
+
if (!bySymbol)
|
|
91
|
+
bySymbol = r;
|
|
92
|
+
if (setupType && r.setupType === setupType) {
|
|
93
|
+
bySetup = r;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (!setupType)
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
return bySetup ?? bySymbol;
|
|
100
|
+
}
|
|
101
|
+
/** Structured caution when (symbol, strategy) was already traded within the
|
|
102
|
+
* current signal bar. Undefined = no caution. Pure indication (issue #204):
|
|
103
|
+
* the agent decides; nothing here blocks an order. */
|
|
104
|
+
cautionFor(symbol, strategy, nowMs = Date.now()) {
|
|
105
|
+
const last = this.lastExit(symbol, strategy);
|
|
106
|
+
if (!last)
|
|
107
|
+
return undefined;
|
|
108
|
+
const barMin = strategyBarMinutes(strategy ?? last.setupType);
|
|
109
|
+
const agoMin = Math.round((nowMs - last.closedAtMs) / 60_000);
|
|
110
|
+
if (agoMin < 0 || agoMin > barMin)
|
|
111
|
+
return undefined;
|
|
112
|
+
const lossNote = last.wasLoss ? ' at a LOSS' : '';
|
|
113
|
+
return (`already traded this signal bar: exited a ${last.side} on this setup ${agoMin}m ago${lossNote} ` +
|
|
114
|
+
`(bar=${barMin}m). The validated backtest takes ONE trade per signal bar — re-enter only if ` +
|
|
115
|
+
`you can name what NEW information arrived since that exit.`);
|
|
116
|
+
}
|
|
117
|
+
/** Test seam. */
|
|
118
|
+
getRecords() {
|
|
119
|
+
return this.records;
|
|
120
|
+
}
|
|
121
|
+
persist() {
|
|
122
|
+
const payload = { schemaVersion: 1, records: this.records };
|
|
123
|
+
const tmp = `${this.filePath}.tmp`;
|
|
124
|
+
writeFileSync(tmp, JSON.stringify(payload), 'utf-8');
|
|
125
|
+
renameSync(tmp, this.filePath);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/scripts/assemble.mjs
CHANGED
|
@@ -66,16 +66,32 @@ if (!existsSync(pluginDist)) {
|
|
|
66
66
|
console.log('[assemble] plugin/dist -> package root');
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// 2. Manifest, with the `skills` declaration injected (npm package only)
|
|
69
|
+
// 2. Manifest, with the `skills` declaration injected (npm package only) and the
|
|
70
|
+
// release version stamped from THIS package's package.json.
|
|
71
|
+
//
|
|
72
|
+
// package.json#version is the authoritative release version — that is what
|
|
73
|
+
// ClawHub's `package-manifest-version-drift` rule compares against, and what
|
|
74
|
+
// OpenClaw's own `openclaw plugins build` writes into the manifest. The repo
|
|
75
|
+
// manifest (plugin/openclaw.plugin.json) carries a workspace-local version
|
|
76
|
+
// nobody bumps at release time, so copying it verbatim published
|
|
77
|
+
// manifest:0.1.0 against package:0.1.7 and tripped the validator. Deriving it
|
|
78
|
+
// here makes that drift structurally impossible instead of a bump to remember.
|
|
70
79
|
const manifestSrc = join(repoRoot, 'plugin', 'openclaw.plugin.json');
|
|
80
|
+
const pkgVersion = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version;
|
|
71
81
|
if (!existsSync(manifestSrc)) {
|
|
72
82
|
console.error('[assemble] missing plugin/openclaw.plugin.json.');
|
|
73
83
|
missing += 1;
|
|
84
|
+
} else if (typeof pkgVersion !== 'string' || pkgVersion.length === 0) {
|
|
85
|
+
console.error('[assemble] package.json has no version — cannot stamp the manifest.');
|
|
86
|
+
missing += 1;
|
|
74
87
|
} else {
|
|
75
88
|
const manifest = JSON.parse(readFileSync(manifestSrc, 'utf-8'));
|
|
89
|
+
manifest.version = pkgVersion;
|
|
76
90
|
manifest.skills = ['./skills'];
|
|
77
91
|
writeFileSync(join(pkgRoot, 'openclaw.plugin.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
78
|
-
console.log(
|
|
92
|
+
console.log(
|
|
93
|
+
`[assemble] manifest v${pkgVersion} (+skills decl), ${manifest.contracts?.tools?.length ?? 0} contract tools`,
|
|
94
|
+
);
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
// 3. The connector, bundled — this is what flips the supervisor's auto-start.
|
|
@@ -160,11 +160,20 @@ register('price_sweep', (ctx, params, _dir, condCtx) => {
|
|
|
160
160
|
sweepLevel = nearestHigh;
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
|
-
// Store in shared context for entry/stop rules and direction
|
|
163
|
+
// Store in shared context for entry/stop rules and direction.
|
|
164
|
+
// sweptDirection is written non-destructively, like the funding_extreme /
|
|
165
|
+
// cvd_divergence setters: only when a sweep actually resolved a side. An
|
|
166
|
+
// unconditional write (including null on a no-sweep bar) clobbered a
|
|
167
|
+
// direction set by an earlier condition in the same evaluation, so a
|
|
168
|
+
// from_sweep/from_funding strategy listing price_sweep AFTER another
|
|
169
|
+
// setter could never fire.
|
|
164
170
|
condCtx.sweepLevel = sweepLevel;
|
|
165
171
|
condCtx.recentLow = recentLow;
|
|
166
172
|
condCtx.recentHigh = recentHigh;
|
|
167
|
-
|
|
173
|
+
if (sweptLow)
|
|
174
|
+
condCtx.sweptDirection = 'LONG';
|
|
175
|
+
else if (sweptHigh)
|
|
176
|
+
condCtx.sweptDirection = 'SHORT';
|
|
168
177
|
const met = sweptLow || sweptHigh;
|
|
169
178
|
return {
|
|
170
179
|
met,
|
|
@@ -170,15 +170,25 @@ export function adaptStrategy(config, gateNamespace) {
|
|
|
170
170
|
const { conditions: pass1, condCtx } = evaluateConditions(config.conditions, ectx, null);
|
|
171
171
|
// Determine direction
|
|
172
172
|
const direction = resolveDirection(config.directionRule, ectx, condCtx);
|
|
173
|
-
// Pass 2:
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
// Pass 2: once the direction is known, re-evaluate the FULL condition
|
|
174
|
+
// array with it. Many registry conditions branch on `direction`
|
|
175
|
+
// (orderbook_imbalance, funding_contrarian, macd_crossover,
|
|
176
|
+
// bollinger_breakout, vwap_position, stoch_rsi_extreme, ichimoku_cloud,
|
|
177
|
+
// supertrend_direction, …) and their pass-1 result was computed against
|
|
178
|
+
// direction=null — typically the permissive either-side branch. A
|
|
179
|
+
// hand-curated allowlist here (the pre-fix 5-type set) silently drifted
|
|
180
|
+
// out of sync with the registry, so non-listed direction-dependent
|
|
181
|
+
// conditions kept their permissive pass-1 result forever (e.g. a
|
|
182
|
+
// fixed_long strategy's macd_crossover accepted a BEARISH crossover).
|
|
183
|
+
// Pass 2 always evaluated the whole array anyway — use it wholesale.
|
|
184
|
+
// Direction-insensitive conditions are pure functions of (ctx, params)
|
|
185
|
+
// and return identical results in both passes; entry/stop computation
|
|
186
|
+
// below deliberately keeps pass 1's condCtx (setter values don't depend
|
|
187
|
+
// on direction).
|
|
177
188
|
let finalConditions = pass1;
|
|
178
|
-
if (direction
|
|
189
|
+
if (direction) {
|
|
179
190
|
const { conditions: pass2 } = evaluateConditions(config.conditions, ectx, direction);
|
|
180
|
-
|
|
181
|
-
finalConditions = pass1.map((c, i) => directionSensitive.has(config.conditions[i].type) ? pass2[i] : c);
|
|
191
|
+
finalConditions = pass2;
|
|
182
192
|
}
|
|
183
193
|
const allMet = finalConditions.every(c => c.met);
|
|
184
194
|
let trade;
|
|
@@ -79,6 +79,18 @@ export declare class ExchangeSimulator extends EventEmitter {
|
|
|
79
79
|
* reload never regresses the agent's give-back signal. */
|
|
80
80
|
replaceState(newState: SimulatorState): void;
|
|
81
81
|
getState(): SimulatorState;
|
|
82
|
+
/** Default max quote age a NEW-exposure fill may price against (issue #202).
|
|
83
|
+
* Generous vs the 5s paper feed cadence; env RC_PAPER_MAX_QUOTE_AGE_MS
|
|
84
|
+
* overrides. */
|
|
85
|
+
static readonly DEFAULT_MAX_QUOTE_AGE_MS = 45000;
|
|
86
|
+
private maxQuoteAgeMs;
|
|
87
|
+
/** Quote age from the ticker's own timestamp. A missing/invalid timestamp
|
|
88
|
+
* reads as age 0 (fail-open — the guard cannot fire on it). */
|
|
89
|
+
private quoteAgeMs;
|
|
90
|
+
/** Reject fills that would OPEN or GROW exposure on a stale quote. Risk-
|
|
91
|
+
* reducing fills (closes/partials against an existing position) are always
|
|
92
|
+
* allowed — blocking a close on a broken feed compounds the risk. */
|
|
93
|
+
private assertQuoteFresh;
|
|
82
94
|
private shouldFillLimit;
|
|
83
95
|
private executeMarketFill;
|
|
84
96
|
private executeLimitFill;
|
|
@@ -286,11 +286,18 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
286
286
|
if (!ticker) {
|
|
287
287
|
throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
|
|
288
288
|
}
|
|
289
|
+
// Stale-quote guard (issue #202): a market fill priced off an aged quote
|
|
290
|
+
// books phantom P&L the moment a fresh price arrives (measured up to
|
|
291
|
+
// ~0.6% off on the HL paper book — a real −$22.99 in 4 seconds). Reject
|
|
292
|
+
// instead; the agent refreshes via fetch_ticker and retries.
|
|
293
|
+
this.assertQuoteFresh(symbol, ticker);
|
|
289
294
|
return this.executeMarketFill(order, ticker.last, metadata);
|
|
290
295
|
}
|
|
291
|
-
// Limit order — check if it crosses the current price
|
|
296
|
+
// Limit order — check if it crosses the current price. A stale quote must
|
|
297
|
+
// not price an immediate cross-fill (same hazard as market fills); the
|
|
298
|
+
// order RESTS instead and fills on the next fresh tick via updateTicker.
|
|
292
299
|
const ticker = this.lastTicker.get(symbol);
|
|
293
|
-
if (ticker && this.shouldFillLimit(order, ticker.last)) {
|
|
300
|
+
if (ticker && this.quoteAgeMs(ticker) <= this.maxQuoteAgeMs() && this.shouldFillLimit(order, ticker.last)) {
|
|
294
301
|
return this.executeLimitFill(order, ticker.last, metadata);
|
|
295
302
|
}
|
|
296
303
|
// Limit order doesn't cross — add to open orders. Pin the entry metadata
|
|
@@ -548,6 +555,45 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
548
555
|
};
|
|
549
556
|
}
|
|
550
557
|
// ---- Private helpers ----
|
|
558
|
+
/** Default max quote age a NEW-exposure fill may price against (issue #202).
|
|
559
|
+
* Generous vs the 5s paper feed cadence; env RC_PAPER_MAX_QUOTE_AGE_MS
|
|
560
|
+
* overrides. */
|
|
561
|
+
static DEFAULT_MAX_QUOTE_AGE_MS = 45_000;
|
|
562
|
+
maxQuoteAgeMs() {
|
|
563
|
+
const raw = Number(process.env.RC_PAPER_MAX_QUOTE_AGE_MS);
|
|
564
|
+
return Number.isFinite(raw) && raw > 0 ? raw : ExchangeSimulator.DEFAULT_MAX_QUOTE_AGE_MS;
|
|
565
|
+
}
|
|
566
|
+
/** Quote age from the ticker's own timestamp. A missing/invalid timestamp
|
|
567
|
+
* reads as age 0 (fail-open — the guard cannot fire on it). */
|
|
568
|
+
quoteAgeMs(ticker) {
|
|
569
|
+
const ts = ticker.timestamp;
|
|
570
|
+
if (!Number.isFinite(ts) || ts <= 0)
|
|
571
|
+
return 0;
|
|
572
|
+
return Math.max(0, Date.now() - ts);
|
|
573
|
+
}
|
|
574
|
+
/** Reject fills that would OPEN or GROW exposure on a stale quote. Risk-
|
|
575
|
+
* reducing fills (closes/partials against an existing position) are always
|
|
576
|
+
* allowed — blocking a close on a broken feed compounds the risk. */
|
|
577
|
+
assertQuoteFresh(symbol, ticker) {
|
|
578
|
+
const age = this.quoteAgeMs(ticker);
|
|
579
|
+
const max = this.maxQuoteAgeMs();
|
|
580
|
+
if (age <= max)
|
|
581
|
+
return;
|
|
582
|
+
const pos = this.state.positions.find(p => p.symbol === symbol);
|
|
583
|
+
// The order side reaching here is the one being filled — derive reduce vs
|
|
584
|
+
// grow from the position side at the call site instead? The market path
|
|
585
|
+
// calls this before fill with the order side unavailable; use position
|
|
586
|
+
// presence: any existing position keeps closes flowing, and a stale-quote
|
|
587
|
+
// scale-in on an open position is bounded by the feed refreshing open
|
|
588
|
+
// symbols every 5s (only NEW symbols go minutes without a tick).
|
|
589
|
+
if (pos) {
|
|
590
|
+
logger.warn(TAG, `Stale quote for ${symbol} (${Math.round(age / 1000)}s old) — allowing fill because an open ` +
|
|
591
|
+
`position exists (risk-reducing paths are never blocked)`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
throw new Error(`Order rejected: market data for ${symbol} is stale (${Math.round(age / 1000)}s old, ` +
|
|
595
|
+
`max ${Math.round(max / 1000)}s). Refresh the price (fetch_ticker) and retry.`);
|
|
596
|
+
}
|
|
551
597
|
shouldFillLimit(order, currentPrice) {
|
|
552
598
|
if (order.price === null)
|
|
553
599
|
return false;
|
|
@@ -567,6 +613,12 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
567
613
|
metadata,
|
|
568
614
|
};
|
|
569
615
|
const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
|
|
616
|
+
// Observability for issue #202: stamp the quote's age onto the fill's
|
|
617
|
+
// execution-quality record so staleness is visible in trade history.
|
|
618
|
+
const tickerAtFill = this.lastTicker.get(order.symbol);
|
|
619
|
+
if (result.executionQuality && tickerAtFill) {
|
|
620
|
+
result.executionQuality.quoteAgeMs = this.quoteAgeMs(tickerAtFill);
|
|
621
|
+
}
|
|
570
622
|
const ccxtOrder = this.applyFillResult(result);
|
|
571
623
|
const eq = result.executionQuality;
|
|
572
624
|
if (eq) {
|
|
@@ -613,7 +665,25 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
613
665
|
fillPrice: result.order.average ?? 0,
|
|
614
666
|
fee: result.order.fee.cost,
|
|
615
667
|
});
|
|
616
|
-
|
|
668
|
+
const ccxtOrder = this.toCcxtOrder(result.order);
|
|
669
|
+
// Reducing fills carry a Trade record — attach its engine-exact economics
|
|
670
|
+
// (net-of-fee P&L + both fee legs) so the journal close capture records
|
|
671
|
+
// NET, matching the live book's convention (issue #201). The wallet/NAV
|
|
672
|
+
// was already net; only the journal was blind to fees.
|
|
673
|
+
if (result.trade) {
|
|
674
|
+
const t = result.trade;
|
|
675
|
+
ccxtOrder.info = {
|
|
676
|
+
...(ccxtOrder.info ?? {}),
|
|
677
|
+
paperTrade: {
|
|
678
|
+
grossRealizedPnl: t.realizedPnl,
|
|
679
|
+
netRealizedPnl: t.realizedPnl - (t.fee + (t.openFee ?? 0)),
|
|
680
|
+
openFee: t.openFee ?? 0,
|
|
681
|
+
closeFee: t.fee,
|
|
682
|
+
...(typeof t.metadata?.setupType === 'string' ? { setupType: t.metadata.setupType } : {}),
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
return ccxtOrder;
|
|
617
687
|
}
|
|
618
688
|
updatePosition(symbol, newPosition) {
|
|
619
689
|
const idx = this.state.positions.findIndex(p => p.symbol === symbol);
|
package/simulator/types.d.ts
CHANGED
|
@@ -38,6 +38,10 @@ export interface ExecutionQuality {
|
|
|
38
38
|
feePaid: number;
|
|
39
39
|
bookLevelsConsumed: number;
|
|
40
40
|
bookDepthAvailable: boolean;
|
|
41
|
+
/** Age of the quote the fill priced against (fill time − ticker.timestamp).
|
|
42
|
+
* Surfaces feed staleness (issue #202); absent on records from before the
|
|
43
|
+
* field existed or when the ticker carried no usable timestamp. */
|
|
44
|
+
quoteAgeMs?: number;
|
|
41
45
|
}
|
|
42
46
|
export interface SimulatorState {
|
|
43
47
|
version: string;
|
package/skills/reefclaw/SKILL.md
CHANGED
|
@@ -80,6 +80,8 @@ OpenClaw can install the ReefClaw plugin through its own owner chat command —
|
|
|
80
80
|
2. Tell the user to type `/restart` in this chat.
|
|
81
81
|
3. Then the user types: `/plugins install clawhub:@reefclaw/openclaw-plugin` — the install restarts OpenClaw automatically; the bundled connector starts by itself and the dashboard flips to Connected.
|
|
82
82
|
|
|
83
|
+
If that install is **cancelled asking for a ClawHub risk acknowledgement** (OpenClaw 2026.7+ does this; older versions never ask and reject the flag), rerun the identical command with the acknowledgement appended: `/plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk`. If it is still refused, use the `npx @reefclaw/connect` fallback — it does not go through ClawHub.
|
|
84
|
+
|
|
83
85
|
Only fall back to `npx @reefclaw/connect` (below) when the chat install is unavailable.
|
|
84
86
|
|
|
85
87
|
## If you cannot run the installer (elevated exec disabled)
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import type { RealizationRule } from '../simulator/types.js';
|
|
2
|
+
/** Minimum pre-entry stop distance in basis points (the SKILL v2.10.0
|
|
3
|
+
* discipline floor's fixed component; the ATR component is enforced by the
|
|
4
|
+
* live bracket path where ATR is authoritative). */
|
|
5
|
+
export declare const MIN_STOP_DISTANCE_BPS = 30;
|
|
6
|
+
export interface ProtectiveGeometryArgs {
|
|
7
|
+
side: 'buy' | 'sell';
|
|
8
|
+
/** Reference entry price: the limit price, or last trade for market orders. */
|
|
9
|
+
refPrice: number;
|
|
10
|
+
stopPrice?: number;
|
|
11
|
+
invalidationPrice?: number;
|
|
12
|
+
targetPrice?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Validate stop / invalidation / target geometry against the entry side.
|
|
15
|
+
*
|
|
16
|
+
* Before this existed the wrong-side check ran ONLY on the wave9 path
|
|
17
|
+
* (create-order.ts) — a generic short entered with its stop BELOW entry
|
|
18
|
+
* passed validation and was closed by the stop-watcher seconds later
|
|
19
|
+
* (7 sub-5-minute kills on the 2026-07 HL soak, issue #200). Returns an
|
|
20
|
+
* error string, or null when the geometry is sound. Fields left undefined
|
|
21
|
+
* are not judged (stop-required policy stays with the caller). */
|
|
22
|
+
export declare function validateProtectiveGeometry(args: ProtectiveGeometryArgs): string | null;
|
|
2
23
|
/** The five legitimate reasons to manually close an otherwise-bracketed
|
|
3
24
|
* position (see SKILL.md v2.10.0 Position Management Discipline). Any other
|
|
4
25
|
* value is rejected. */
|
|
@@ -13,6 +13,64 @@
|
|
|
13
13
|
// live mode.
|
|
14
14
|
// Tested in: assessment-validation.test.ts.
|
|
15
15
|
import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
|
|
16
|
+
// ---- protective geometry (create_order, issue #200) ----
|
|
17
|
+
/** Minimum pre-entry stop distance in basis points (the SKILL v2.10.0
|
|
18
|
+
* discipline floor's fixed component; the ATR component is enforced by the
|
|
19
|
+
* live bracket path where ATR is authoritative). */
|
|
20
|
+
export const MIN_STOP_DISTANCE_BPS = 30;
|
|
21
|
+
/** Validate stop / invalidation / target geometry against the entry side.
|
|
22
|
+
*
|
|
23
|
+
* Before this existed the wrong-side check ran ONLY on the wave9 path
|
|
24
|
+
* (create-order.ts) — a generic short entered with its stop BELOW entry
|
|
25
|
+
* passed validation and was closed by the stop-watcher seconds later
|
|
26
|
+
* (7 sub-5-minute kills on the 2026-07 HL soak, issue #200). Returns an
|
|
27
|
+
* error string, or null when the geometry is sound. Fields left undefined
|
|
28
|
+
* are not judged (stop-required policy stays with the caller). */
|
|
29
|
+
export function validateProtectiveGeometry(args) {
|
|
30
|
+
const { side, refPrice } = args;
|
|
31
|
+
if (!Number.isFinite(refPrice) || refPrice <= 0)
|
|
32
|
+
return null; // no reference — cannot judge
|
|
33
|
+
const dirWord = side === 'buy' ? 'long' : 'short';
|
|
34
|
+
if (args.stopPrice !== undefined) {
|
|
35
|
+
const stop = args.stopPrice;
|
|
36
|
+
if (!Number.isFinite(stop) || stop <= 0) {
|
|
37
|
+
return 'stopPrice must be a positive finite number.';
|
|
38
|
+
}
|
|
39
|
+
if ((side === 'buy' && stop >= refPrice) || (side === 'sell' && stop <= refPrice)) {
|
|
40
|
+
return (`stopPrice ${stop} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: a ${dirWord}'s ` +
|
|
41
|
+
`protective stop must be ${side === 'buy' ? 'below' : 'above'} entry. A wrong-side stop is ` +
|
|
42
|
+
`instantly "breached" and the stop-watcher closes the position seconds after entry.`);
|
|
43
|
+
}
|
|
44
|
+
const distanceBps = (Math.abs(refPrice - stop) / refPrice) * 10_000;
|
|
45
|
+
if (distanceBps < MIN_STOP_DISTANCE_BPS) {
|
|
46
|
+
return (`stopPrice ${stop} is only ${distanceBps.toFixed(1)}bps from entry ${refPrice} — below the ` +
|
|
47
|
+
`${MIN_STOP_DISTANCE_BPS}bps discipline floor. Place the stop at a structural level ` +
|
|
48
|
+
`(≥ max(0.3×ATR, ${MIN_STOP_DISTANCE_BPS}bps) from entry).`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (args.invalidationPrice !== undefined) {
|
|
52
|
+
const inv = args.invalidationPrice;
|
|
53
|
+
if (!Number.isFinite(inv) || inv <= 0) {
|
|
54
|
+
return 'invalidation_price must be a positive finite number.';
|
|
55
|
+
}
|
|
56
|
+
if ((side === 'buy' && inv >= refPrice) || (side === 'sell' && inv <= refPrice)) {
|
|
57
|
+
return (`invalidation_price ${inv} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: ` +
|
|
58
|
+
`invalidation must be ${side === 'buy' ? 'below' : 'above'} entry (it marks where the thesis ` +
|
|
59
|
+
`is WRONG, not where it profits). Fix the pinned plan before entering.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (args.targetPrice !== undefined) {
|
|
63
|
+
const target = args.targetPrice;
|
|
64
|
+
if (!Number.isFinite(target) || target <= 0) {
|
|
65
|
+
return 'target_price must be a positive finite number.';
|
|
66
|
+
}
|
|
67
|
+
if ((side === 'buy' && target <= refPrice) || (side === 'sell' && target >= refPrice)) {
|
|
68
|
+
return (`target_price ${target} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: ` +
|
|
69
|
+
`the profit target must be ${side === 'buy' ? 'above' : 'below'} entry.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
16
74
|
// ---- close_position ----
|
|
17
75
|
/** The five legitimate reasons to manually close an otherwise-bracketed
|
|
18
76
|
* position (see SKILL.md v2.10.0 Position Management Discipline). Any other
|