@reefclaw/openclaw-plugin 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge/bridge.js +110 -5
- package/bridge/connector.js +14 -2
- package/bridge/gateway/heartbeat-cron.js +30 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/gateway/tool-discovery.d.ts +1 -1
- package/bridge/gateway/tool-discovery.js +4 -0
- package/bridge/provider.d.ts +36 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +37 -2
- package/bridge/providers/gateway.js +164 -9
- package/bridge/providers/mock.js +1 -0
- package/bridge/providers/onboarding-commands.d.ts +12 -1
- package/bridge/providers/onboarding-commands.js +25 -0
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +7 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +5 -2
- package/config/agent-config-client.js +13 -0
- package/config/agent-config-poller.js +5 -1
- package/config/gate-store.d.ts +9 -0
- package/config/gate-store.js +17 -2
- package/config/plugin-config-io.js +24 -2
- package/config/tool-gate.js +1 -0
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +73 -6
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/openclaw.plugin.json +2 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/simulator/exchange-simulator.d.ts +22 -0
- package/simulator/exchange-simulator.js +74 -32
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/create-order.js +49 -7
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/hl-provision-agent-wallet.js +18 -0
- package/tools/hl-submit-agent-approval.d.ts +27 -0
- package/tools/hl-submit-agent-approval.js +140 -0
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/scan-pairs.js +20 -11
- package/types.d.ts +7 -0
|
@@ -66,9 +66,18 @@ export class IntelMicrostructureAssembler {
|
|
|
66
66
|
return cached.block;
|
|
67
67
|
// Map canonical "BTC/USDT" → intel "BTCUSDT".
|
|
68
68
|
const intelSymbol = canonical.replace('/', '');
|
|
69
|
+
// cacheTtlMs mirrors the agent-facing tools (get_resting_liquidity /
|
|
70
|
+
// get_liquidation_pulse) so the assembler and the agent's own calls share
|
|
71
|
+
// one round-trip per heartbeat instead of duplicate-fetching.
|
|
69
72
|
const [restingResult, pulseResult] = await Promise.allSettled([
|
|
70
|
-
fetchIntelApi(`/api/resting-liquidity/${enc(intelSymbol)}`, this.intelDeps
|
|
71
|
-
|
|
73
|
+
fetchIntelApi(`/api/resting-liquidity/${enc(intelSymbol)}`, this.intelDeps, {
|
|
74
|
+
cacheTtlMs: 15_000,
|
|
75
|
+
timeoutMs: 10_000,
|
|
76
|
+
}),
|
|
77
|
+
fetchIntelApi(`/api/liquidation-pulse?symbol=${enc(intelSymbol)}&window_seconds=60`, this.intelDeps, {
|
|
78
|
+
cacheTtlMs: 10_000,
|
|
79
|
+
timeoutMs: 10_000,
|
|
80
|
+
}),
|
|
72
81
|
]);
|
|
73
82
|
const block = { symbol: canonical, updatedAt: ts };
|
|
74
83
|
let anySignal = false;
|
|
@@ -44,6 +44,9 @@ export interface ProposalDecisionListenerHealth {
|
|
|
44
44
|
firesFailedTrading: number;
|
|
45
45
|
patchFailures: number;
|
|
46
46
|
pollFailures: number;
|
|
47
|
+
/** Distinct proposals seen holding a claim with no result — each one is an
|
|
48
|
+
* operator-actionable reconciliation, not a retryable error. */
|
|
49
|
+
strandedObserved: number;
|
|
47
50
|
lastTickAt: string | null;
|
|
48
51
|
}
|
|
49
52
|
export declare class ProposalDecisionListener {
|
|
@@ -56,6 +59,11 @@ export declare class ProposalDecisionListener {
|
|
|
56
59
|
* that finds ≥1 pending. */
|
|
57
60
|
private currentIntervalMs;
|
|
58
61
|
private health;
|
|
62
|
+
/** Stranded rows already reported by THIS process — the poll re-reports them
|
|
63
|
+
* every tick (they never clear themselves), so dedupe the ERROR log. A
|
|
64
|
+
* restart deliberately re-reports: if it's still stranded, it still needs
|
|
65
|
+
* reconciling. */
|
|
66
|
+
private readonly strandedReported;
|
|
59
67
|
/** Claim tokens are process-local by design. A new process must never steal
|
|
60
68
|
* an old process's durable claim, because it cannot know whether the
|
|
61
69
|
* exchange accepted an order just before the crash. */
|
|
@@ -82,6 +90,19 @@ export declare class ProposalDecisionListener {
|
|
|
82
90
|
private scheduleNextTick;
|
|
83
91
|
private runTick;
|
|
84
92
|
private tick;
|
|
93
|
+
/** Report claims that outlived any plausible fire attempt.
|
|
94
|
+
*
|
|
95
|
+
* These are proposals a listener (usually a previous incarnation of this
|
|
96
|
+
* process) claimed and then died before reporting an outcome. Claims never
|
|
97
|
+
* expire and the work queue skips claimed rows, so nothing retries them —
|
|
98
|
+
* before this, the operator's approval simply produced no position and no
|
|
99
|
+
* message. We can't safely re-fire (the order may already be live), so the
|
|
100
|
+
* actionable thing is the deterministic client-order id: it answers "did
|
|
101
|
+
* this ever reach the exchange?" definitively.
|
|
102
|
+
*
|
|
103
|
+
* Logged at ERROR once per row per process so a restart re-surfaces it,
|
|
104
|
+
* without spamming every 3 s tick. */
|
|
105
|
+
private reportStranded;
|
|
85
106
|
private fetchPending;
|
|
86
107
|
private claimPending;
|
|
87
108
|
private forgetClaim;
|
|
@@ -51,8 +51,14 @@ export class ProposalDecisionListener {
|
|
|
51
51
|
firesFailedTrading: 0,
|
|
52
52
|
patchFailures: 0,
|
|
53
53
|
pollFailures: 0,
|
|
54
|
+
strandedObserved: 0,
|
|
54
55
|
lastTickAt: null,
|
|
55
56
|
};
|
|
57
|
+
/** Stranded rows already reported by THIS process — the poll re-reports them
|
|
58
|
+
* every tick (they never clear themselves), so dedupe the ERROR log. A
|
|
59
|
+
* restart deliberately re-reports: if it's still stranded, it still needs
|
|
60
|
+
* reconciling. */
|
|
61
|
+
strandedReported = new Set();
|
|
56
62
|
/** Claim tokens are process-local by design. A new process must never steal
|
|
57
63
|
* an old process's durable claim, because it cannot know whether the
|
|
58
64
|
* exchange accepted an order just before the crash. */
|
|
@@ -171,6 +177,36 @@ export class ProposalDecisionListener {
|
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
}
|
|
180
|
+
/** Report claims that outlived any plausible fire attempt.
|
|
181
|
+
*
|
|
182
|
+
* These are proposals a listener (usually a previous incarnation of this
|
|
183
|
+
* process) claimed and then died before reporting an outcome. Claims never
|
|
184
|
+
* expire and the work queue skips claimed rows, so nothing retries them —
|
|
185
|
+
* before this, the operator's approval simply produced no position and no
|
|
186
|
+
* message. We can't safely re-fire (the order may already be live), so the
|
|
187
|
+
* actionable thing is the deterministic client-order id: it answers "did
|
|
188
|
+
* this ever reach the exchange?" definitively.
|
|
189
|
+
*
|
|
190
|
+
* Logged at ERROR once per row per process so a restart re-surfaces it,
|
|
191
|
+
* without spamming every 3 s tick. */
|
|
192
|
+
reportStranded(rows) {
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
if (this.strandedReported.has(row.id))
|
|
195
|
+
continue;
|
|
196
|
+
this.strandedReported.add(row.id);
|
|
197
|
+
this.health.strandedObserved++;
|
|
198
|
+
let cid = '(unavailable)';
|
|
199
|
+
try {
|
|
200
|
+
cid = proposalEntryClientOrderId(this.opts.adapter, row.proposalUuid);
|
|
201
|
+
}
|
|
202
|
+
catch { /* CID derivation is best-effort — the report still goes out */ }
|
|
203
|
+
logger.error(TAG, `STRANDED approved proposal ${row.id} (${row.symbol} ${row.side}) — claimed at ` +
|
|
204
|
+
`${row.claimedAt ?? 'unknown'} and never reported a result, so no listener will ` +
|
|
205
|
+
`retry it. The order may or may not have reached the exchange. Reconcile by ` +
|
|
206
|
+
`clientOrderId=${cid}: if present on the exchange the entry is live (attach/verify ` +
|
|
207
|
+
`its brackets); if absent, nothing was submitted and the proposal can be re-made.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
174
210
|
async fetchPending() {
|
|
175
211
|
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/pending-decisions`;
|
|
176
212
|
try {
|
|
@@ -197,6 +233,9 @@ export class ProposalDecisionListener {
|
|
|
197
233
|
return [];
|
|
198
234
|
}
|
|
199
235
|
const body = await res.json();
|
|
236
|
+
if (Array.isArray(body.stranded) && body.stranded.length > 0) {
|
|
237
|
+
this.reportStranded(body.stranded);
|
|
238
|
+
}
|
|
200
239
|
return Array.isArray(body.proposals) ? body.proposals : [];
|
|
201
240
|
}
|
|
202
241
|
catch (err) {
|
|
@@ -68,6 +68,18 @@ export declare class ProposalManager {
|
|
|
68
68
|
* + hard expiry synchronously; the actual POST runs in the background.
|
|
69
69
|
* Caller may correlate via the returned proposalUuid before the post lands. */
|
|
70
70
|
propose(userId: string, req: ProposalRequest): ProposeResult;
|
|
71
|
+
/** Cancel every outstanding proposal for this tenant because the approval
|
|
72
|
+
* path is being disabled (mode flipped off, or the live adapter went away).
|
|
73
|
+
*
|
|
74
|
+
* Without this, flipping `per_trade` → `off` — or a live→PAPER swap — leaves
|
|
75
|
+
* rows the operator can still see and approve while no listener exists to
|
|
76
|
+
* fire them: the card sits there, Approve "works", and nothing ever happens
|
|
77
|
+
* (design doc §10 row 12). The webapp refuses to cancel rows a listener has
|
|
78
|
+
* already claimed, so this can never disown an order that may be live.
|
|
79
|
+
*
|
|
80
|
+
* Best-effort by design: awaited by the caller only for logging. A failure
|
|
81
|
+
* is bounded by hard expiry (≤4 min) and must never block an adapter swap. */
|
|
82
|
+
cancelAll(userId: string, reason: 'mode_disabled'): Promise<void>;
|
|
71
83
|
/** Await all in-flight POSTs. Used at shutdown so we don't lose proposals. */
|
|
72
84
|
drain(): Promise<void>;
|
|
73
85
|
getHealth(): ProposalManagerHealth;
|
package/live/proposal-manager.js
CHANGED
|
@@ -50,6 +50,53 @@ export class ProposalManager {
|
|
|
50
50
|
this.inFlight.add(promise);
|
|
51
51
|
return { proposalUuid, setupBucket, hardExpiresAt };
|
|
52
52
|
}
|
|
53
|
+
/** Cancel every outstanding proposal for this tenant because the approval
|
|
54
|
+
* path is being disabled (mode flipped off, or the live adapter went away).
|
|
55
|
+
*
|
|
56
|
+
* Without this, flipping `per_trade` → `off` — or a live→PAPER swap — leaves
|
|
57
|
+
* rows the operator can still see and approve while no listener exists to
|
|
58
|
+
* fire them: the card sits there, Approve "works", and nothing ever happens
|
|
59
|
+
* (design doc §10 row 12). The webapp refuses to cancel rows a listener has
|
|
60
|
+
* already claimed, so this can never disown an order that may be live.
|
|
61
|
+
*
|
|
62
|
+
* Best-effort by design: awaited by the caller only for logging. A failure
|
|
63
|
+
* is bounded by hard expiry (≤4 min) and must never block an adapter swap. */
|
|
64
|
+
async cancelAll(userId, reason) {
|
|
65
|
+
const url = `${this.opts.baseUrl}/api/internal/proposed_orders/cancel-all`;
|
|
66
|
+
try {
|
|
67
|
+
const ac = new AbortController();
|
|
68
|
+
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
69
|
+
let res;
|
|
70
|
+
try {
|
|
71
|
+
res = await this.opts.fetchImpl(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'content-type': 'application/json',
|
|
75
|
+
authorization: `Bearer ${this.opts.ingestToken}`,
|
|
76
|
+
'x-user-id': userId,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({ reason }),
|
|
79
|
+
signal: ac.signal,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
clearTimeout(tid);
|
|
84
|
+
}
|
|
85
|
+
if (res.status < 200 || res.status >= 300) {
|
|
86
|
+
logger.warn(TAG, `cancel-all (${reason}) returned HTTP ${res.status}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const body = (await res.json().catch(() => ({})));
|
|
90
|
+
const cancelled = typeof body.cancelled === 'number' ? body.cancelled : 0;
|
|
91
|
+
const inFlight = Array.isArray(body.inFlight) ? body.inFlight.length : 0;
|
|
92
|
+
if (cancelled > 0 || inFlight > 0) {
|
|
93
|
+
logger.info(TAG, `cancel-all (${reason}): cancelled=${cancelled} inFlight-uncancellable=${inFlight}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.warn(TAG, `cancel-all (${reason}) failed: ${formatError(err)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
53
100
|
/** Await all in-flight POSTs. Used at shutdown so we don't lose proposals. */
|
|
54
101
|
async drain() {
|
|
55
102
|
if (this.inFlight.size === 0)
|
package/live/stop-watcher.d.ts
CHANGED
|
@@ -37,8 +37,20 @@ export interface Wave9StopCloseLifecycle {
|
|
|
37
37
|
resolveCandidateId(position: CcxtPosition): Promise<string | undefined> | string | undefined;
|
|
38
38
|
settleAfterClose(candidateId: string, symbol: string): Promise<Wave9StopCloseOutcome>;
|
|
39
39
|
}
|
|
40
|
+
/** True when this position carries a protective stop we are supposed to be
|
|
41
|
+
* enforcing. Used to decide whether an unusable mark is an ALARM (a stop we
|
|
42
|
+
* cannot evaluate) or simply uninteresting (no stop set). */
|
|
43
|
+
export declare function hasEnforceableStop(position: CcxtPosition): boolean;
|
|
44
|
+
/** True when the mark cannot be trusted for a protective decision — either the
|
|
45
|
+
* venue gave us nothing usable, or (paper) it is the fabricated entryPrice
|
|
46
|
+
* fallback. Both mean "we do not know where price is", NOT "price is fine". */
|
|
47
|
+
export declare function isMarkUnusable(position: CcxtPosition): boolean;
|
|
40
48
|
/** Decide whether a position has crossed its stop.
|
|
41
|
-
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
49
|
+
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
50
|
+
*
|
|
51
|
+
* NOTE: a `false` here means "not breached OR not knowable". Callers that care
|
|
52
|
+
* about the difference must check isMarkUnusable() — see tick(), which alarms
|
|
53
|
+
* on an unknowable mark rather than treating it as safe. */
|
|
42
54
|
export declare function isStopBreached(position: CcxtPosition): boolean;
|
|
43
55
|
export declare class PositionWatcher extends EventEmitter {
|
|
44
56
|
private interval;
|
|
@@ -53,6 +65,9 @@ export declare class PositionWatcher extends EventEmitter {
|
|
|
53
65
|
/** Symbols we've already logged a breach for this session, to avoid spamming
|
|
54
66
|
* the log every tick while the close is in flight. */
|
|
55
67
|
private notifiedBreach;
|
|
68
|
+
/** Symbols already alarmed for an unusable mark, so the ERROR fires once per
|
|
69
|
+
* episode rather than every tick. Cleared as soon as a usable mark returns. */
|
|
70
|
+
private notifiedUnusableMark;
|
|
56
71
|
constructor(adapter: IExchangeAdapter, intervalMs?: number, operationLock?: TradingOperationLock);
|
|
57
72
|
/** Configure this after the durable execution ledger is ready. Runtime
|
|
58
73
|
* lifecycle hooks reapply it to every watcher after reconnect. */
|
package/live/stop-watcher.js
CHANGED
|
@@ -28,18 +28,35 @@ const TAG = 'stop-watcher';
|
|
|
28
28
|
// revert with zero deploy via plugin-config.json `stopWatcher.intervalMs`.
|
|
29
29
|
// See docs/EFFICIENCY_QUICK_WINS_PLAN.md + the Binance ban-gate context.
|
|
30
30
|
export const DEFAULT_INTERVAL_MS = 10_000;
|
|
31
|
+
/** True when this position carries a protective stop we are supposed to be
|
|
32
|
+
* enforcing. Used to decide whether an unusable mark is an ALARM (a stop we
|
|
33
|
+
* cannot evaluate) or simply uninteresting (no stop set). */
|
|
34
|
+
export function hasEnforceableStop(position) {
|
|
35
|
+
const stop = position.stopPrice;
|
|
36
|
+
return stop !== undefined && stop !== null && Number.isFinite(stop) && stop > 0;
|
|
37
|
+
}
|
|
38
|
+
/** True when the mark cannot be trusted for a protective decision — either the
|
|
39
|
+
* venue gave us nothing usable, or (paper) it is the fabricated entryPrice
|
|
40
|
+
* fallback. Both mean "we do not know where price is", NOT "price is fine". */
|
|
41
|
+
export function isMarkUnusable(position) {
|
|
42
|
+
if (position.markPriceStale === true)
|
|
43
|
+
return true;
|
|
44
|
+
const mark = position.markPrice;
|
|
45
|
+
return !Number.isFinite(mark) || mark <= 0;
|
|
46
|
+
}
|
|
31
47
|
/** Decide whether a position has crossed its stop.
|
|
32
|
-
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
48
|
+
* Exported for direct unit-testing without spinning up a watcher loop.
|
|
49
|
+
*
|
|
50
|
+
* NOTE: a `false` here means "not breached OR not knowable". Callers that care
|
|
51
|
+
* about the difference must check isMarkUnusable() — see tick(), which alarms
|
|
52
|
+
* on an unknowable mark rather than treating it as safe. */
|
|
33
53
|
export function isStopBreached(position) {
|
|
34
|
-
|
|
35
|
-
if (stop === undefined || stop === null || !Number.isFinite(stop) || stop <= 0) {
|
|
54
|
+
if (!hasEnforceableStop(position))
|
|
36
55
|
return false;
|
|
37
|
-
|
|
38
|
-
const mark = position.markPrice;
|
|
39
|
-
if (!Number.isFinite(mark) || mark <= 0) {
|
|
56
|
+
if (isMarkUnusable(position))
|
|
40
57
|
return false;
|
|
41
|
-
|
|
42
|
-
return position.side === 'long' ?
|
|
58
|
+
const stop = position.stopPrice;
|
|
59
|
+
return position.side === 'long' ? position.markPrice <= stop : position.markPrice >= stop;
|
|
43
60
|
}
|
|
44
61
|
export class PositionWatcher extends EventEmitter {
|
|
45
62
|
interval = null;
|
|
@@ -54,6 +71,9 @@ export class PositionWatcher extends EventEmitter {
|
|
|
54
71
|
/** Symbols we've already logged a breach for this session, to avoid spamming
|
|
55
72
|
* the log every tick while the close is in flight. */
|
|
56
73
|
notifiedBreach = new Set();
|
|
74
|
+
/** Symbols already alarmed for an unusable mark, so the ERROR fires once per
|
|
75
|
+
* episode rather than every tick. Cleared as soon as a usable mark returns. */
|
|
76
|
+
notifiedUnusableMark = new Set();
|
|
57
77
|
constructor(adapter, intervalMs = DEFAULT_INTERVAL_MS, operationLock) {
|
|
58
78
|
super();
|
|
59
79
|
this.adapter = adapter;
|
|
@@ -82,6 +102,7 @@ export class PositionWatcher extends EventEmitter {
|
|
|
82
102
|
}
|
|
83
103
|
this.closePending.clear();
|
|
84
104
|
this.notifiedBreach.clear();
|
|
105
|
+
this.notifiedUnusableMark.clear();
|
|
85
106
|
logger.info(TAG, 'Stopped');
|
|
86
107
|
}
|
|
87
108
|
/** Run one check cycle. Exposed for tests (skip setInterval). */
|
|
@@ -115,7 +136,26 @@ export class PositionWatcher extends EventEmitter {
|
|
|
115
136
|
if (!openSymbols.has(sym))
|
|
116
137
|
this.closePending.delete(sym);
|
|
117
138
|
}
|
|
139
|
+
for (const sym of this.notifiedUnusableMark) {
|
|
140
|
+
if (!openSymbols.has(sym))
|
|
141
|
+
this.notifiedUnusableMark.delete(sym);
|
|
142
|
+
}
|
|
118
143
|
for (const position of positions) {
|
|
144
|
+
// A position with a stop we CANNOT evaluate is unprotected, not healthy.
|
|
145
|
+
// Silence here is what let a breached stop run for 35h: the mark was a
|
|
146
|
+
// fabricated entryPrice fallback, isStopBreached read false, and the
|
|
147
|
+
// watcher skipped it every tick without ever saying so. Alarm instead.
|
|
148
|
+
if (hasEnforceableStop(position) && isMarkUnusable(position)) {
|
|
149
|
+
if (!this.notifiedUnusableMark.has(position.symbol)) {
|
|
150
|
+
logger.error(TAG, `STOP UNENFORCEABLE: ${position.symbol} ${position.side} has stop=${position.stopPrice} ` +
|
|
151
|
+
`but no usable mark (markPrice=${position.markPrice}` +
|
|
152
|
+
`${position.markPriceStale ? ', stale/fabricated' : ''}). The position is running ` +
|
|
153
|
+
'UNPROTECTED — the watcher cannot evaluate the breach. Check the quote feed for this symbol.');
|
|
154
|
+
this.notifiedUnusableMark.add(position.symbol);
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
this.notifiedUnusableMark.delete(position.symbol);
|
|
119
159
|
if (!isStopBreached(position))
|
|
120
160
|
continue;
|
|
121
161
|
if (this.closePending.has(position.symbol))
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.24",
|
|
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": {
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"clear_exchange_credentials",
|
|
73
73
|
"hl_provision_agent_wallet",
|
|
74
74
|
"hl_agent_wallet_status",
|
|
75
|
+
"hl_submit_agent_approval",
|
|
75
76
|
"get_bracket_config",
|
|
76
77
|
"set_bracket_requirement"
|
|
77
78
|
]
|
package/package.json
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
7
|
-
"openclaw": {
|
|
8
|
-
"extensions": [
|
|
9
|
-
"./index.js"
|
|
10
|
-
],
|
|
11
|
-
"compat": {
|
|
12
|
-
"pluginApi": ">=2026.6.0"
|
|
13
|
-
},
|
|
14
|
-
"build": {
|
|
15
|
-
"openclawVersion": "2026.6.11"
|
|
16
|
-
}
|
|
17
|
-
},
|
|
18
|
-
"files": [
|
|
19
|
-
"**/*",
|
|
20
|
-
"!scripts/**"
|
|
21
|
-
],
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=20"
|
|
24
|
-
},
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"@reefclaw/shared": "0.1.4",
|
|
27
|
-
"ccxt": "4.5.37",
|
|
28
|
-
"json5": "2.2.3",
|
|
29
|
-
"ws": "8.21.1"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "node scripts/assemble.mjs",
|
|
33
|
-
"verify": "node scripts/verify-shared-contract.mjs",
|
|
34
|
-
"prepublishOnly": "node scripts/verify-shared-contract.mjs"
|
|
35
|
-
},
|
|
36
|
-
"license": "MIT",
|
|
37
|
-
"homepage": "https://reefclaw.com"
|
|
38
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
+
"version": "0.1.24",
|
|
4
|
+
"description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode — it cannot trade real funds until you supply exchange credentials and walk the PAPER→MICRO_LIVE→LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw — a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.js"
|
|
10
|
+
],
|
|
11
|
+
"compat": {
|
|
12
|
+
"pluginApi": ">=2026.6.0"
|
|
13
|
+
},
|
|
14
|
+
"build": {
|
|
15
|
+
"openclawVersion": "2026.6.11"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"**/*",
|
|
20
|
+
"!scripts/**"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@reefclaw/shared": "0.1.4",
|
|
27
|
+
"ccxt": "4.5.37",
|
|
28
|
+
"json5": "2.2.3",
|
|
29
|
+
"ws": "8.21.1"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node scripts/assemble.mjs",
|
|
33
|
+
"verify": "node scripts/verify-shared-contract.mjs",
|
|
34
|
+
"prepublishOnly": "node scripts/verify-shared-contract.mjs"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://reefclaw.com"
|
|
38
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SimulatorState } from '../simulator/types.js';
|
|
2
2
|
export declare class StateManager {
|
|
3
3
|
private readonly statePath;
|
|
4
|
+
private lastLoadedStat?;
|
|
4
5
|
private saveTimer;
|
|
5
6
|
private pendingState;
|
|
6
7
|
constructor(pluginId: string, baseDir?: string);
|
|
@@ -37,6 +38,12 @@ export declare class StateManager {
|
|
|
37
38
|
* missing-vs-corrupt distinction as load(): a corrupt file is quarantined
|
|
38
39
|
* (preserved), never silently overwritten by the default the caller seeds. */
|
|
39
40
|
loadSync(): SimulatorState | null;
|
|
41
|
+
/** loadSync that returns null when the file is unchanged since the last
|
|
42
|
+
* loadSync. The reload exists for the two-process case (another process
|
|
43
|
+
* wrote the file); an unchanged file means the caller's replaceState would
|
|
44
|
+
* be a no-op re-parse of the whole trade history — which grows with the
|
|
45
|
+
* account's age and was being paid on every paper-mode tool call. */
|
|
46
|
+
loadSyncIfChanged(): SimulatorState | null;
|
|
40
47
|
/** Synchronous load-or-create-default — for use in synchronous plugin register(). */
|
|
41
48
|
loadOrDefaultSync(startingBalance: number, quoteCurrency: string): SimulatorState;
|
|
42
49
|
private saveSyncSafe;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Saves/loads state to ~/.openclaw/plugins/reefclaw-paper-trading/state.json
|
|
3
3
|
// Uses atomic writes (temp file + rename) to prevent corruption.
|
|
4
4
|
import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { join, dirname } from 'node:path';
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
@@ -11,6 +11,7 @@ import { createDefaultState } from '../simulator/types.js';
|
|
|
11
11
|
const TAG = 'state-manager';
|
|
12
12
|
export class StateManager {
|
|
13
13
|
statePath;
|
|
14
|
+
lastLoadedStat;
|
|
14
15
|
saveTimer = null;
|
|
15
16
|
pendingState = null;
|
|
16
17
|
constructor(pluginId, baseDir) {
|
|
@@ -131,8 +132,17 @@ export class StateManager {
|
|
|
131
132
|
return null;
|
|
132
133
|
}
|
|
133
134
|
try {
|
|
135
|
+
// Stat BEFORE read: a write racing in between then causes one extra
|
|
136
|
+
// reload next time, never a skipped-but-needed one.
|
|
137
|
+
let stat;
|
|
138
|
+
try {
|
|
139
|
+
const s = statSync(this.statePath, { bigint: true });
|
|
140
|
+
stat = { mtimeNs: s.mtimeNs, size: s.size };
|
|
141
|
+
}
|
|
142
|
+
catch { /* stat raced a delete — plain read below decides */ }
|
|
134
143
|
const raw = readFileSync(this.statePath, 'utf-8');
|
|
135
144
|
const state = JSON.parse(raw);
|
|
145
|
+
this.lastLoadedStat = stat;
|
|
136
146
|
logger.info(TAG, `Loaded state: ${state.positions.length} positions, ${state.openOrders.length} orders, ${state.tradeHistory.length} trades`);
|
|
137
147
|
return state;
|
|
138
148
|
}
|
|
@@ -141,6 +151,23 @@ export class StateManager {
|
|
|
141
151
|
return null;
|
|
142
152
|
}
|
|
143
153
|
}
|
|
154
|
+
/** loadSync that returns null when the file is unchanged since the last
|
|
155
|
+
* loadSync. The reload exists for the two-process case (another process
|
|
156
|
+
* wrote the file); an unchanged file means the caller's replaceState would
|
|
157
|
+
* be a no-op re-parse of the whole trade history — which grows with the
|
|
158
|
+
* account's age and was being paid on every paper-mode tool call. */
|
|
159
|
+
loadSyncIfChanged() {
|
|
160
|
+
try {
|
|
161
|
+
const s = statSync(this.statePath, { bigint: true });
|
|
162
|
+
if (this.lastLoadedStat &&
|
|
163
|
+
this.lastLoadedStat.mtimeNs === s.mtimeNs &&
|
|
164
|
+
this.lastLoadedStat.size === s.size) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch { /* missing/unstatable — loadSync handles + logs it */ }
|
|
169
|
+
return this.loadSync();
|
|
170
|
+
}
|
|
144
171
|
/** Synchronous load-or-create-default — for use in synchronous plugin register(). */
|
|
145
172
|
loadOrDefaultSync(startingBalance, quoteCurrency) {
|
|
146
173
|
const loaded = this.loadSync();
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import type { SimulatorState, OrderBookDepth, SimulationConfig, ExecutionStats, PositionMetadata, CloseReason } from './types.js';
|
|
3
3
|
import type { CcxtOrder, CcxtBalance, CcxtPosition, CcxtTicker } from '../types.js';
|
|
4
|
+
/** Canonical key for the per-symbol quote/orderbook caches and for matching a
|
|
5
|
+
* ticker against stored positions/orders.
|
|
6
|
+
*
|
|
7
|
+
* CCXT echoes the VENUE-UNIFIED symbol back from fetchTicker: ask Binance USDM
|
|
8
|
+
* for 'ETH/USDT' and the ticker returns as 'ETH/USDT:USDT'. Positions and
|
|
9
|
+
* orders, however, are stored under whatever form the caller opened them with,
|
|
10
|
+
* so `lastTicker.get(position.symbol)` silently missed for every position held
|
|
11
|
+
* in the un-suffixed form. The consequences were all silent:
|
|
12
|
+
* - getPositions()/computeEquity() fell back to entryPrice, so the mark was
|
|
13
|
+
* FROZEN at entry — the stop-watcher compared a frozen mark, never saw a
|
|
14
|
+
* breach, and the position ran unprotected past its stop indefinitely;
|
|
15
|
+
* - createOrder() threw 'No ticker data for X' on the market leg, so the
|
|
16
|
+
* agent could not close by hand either (both automatic and manual exits
|
|
17
|
+
* were dead at once);
|
|
18
|
+
* - MFE, take-profit legs and resting-limit fills never advanced.
|
|
19
|
+
* Keying both sides through this normalizer makes the caches format-agnostic,
|
|
20
|
+
* which also repairs books already persisted with a mix of both forms.
|
|
21
|
+
*
|
|
22
|
+
* Same regex as webapp/src/lib/symbols.ts normalizeSymbol and
|
|
23
|
+
* plugin/src/venues/symbols.ts stripSettleSuffix — kept inline so the
|
|
24
|
+
* simulator keeps its zero-import-for-hot-path shape. */
|
|
25
|
+
export declare function tickerKey(symbol: string): string;
|
|
4
26
|
export declare class ExchangeSimulator extends EventEmitter {
|
|
5
27
|
private state;
|
|
6
28
|
private lastTicker;
|