@reefclaw/openclaw-plugin 0.1.1 → 0.1.3
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.d.ts +28 -1
- package/bridge/bridge.js +145 -7
- package/bridge/utils/skill-version.d.ts +9 -0
- package/bridge/utils/skill-version.js +45 -1
- package/ccxt/binance-ban-gate.js +12 -1
- package/ccxt/binance-public.d.ts +15 -0
- package/ccxt/binance-public.js +34 -1
- package/config/agent-config-client.d.ts +22 -0
- package/config/agent-config-client.js +44 -1
- package/config/agent-config-poller.d.ts +8 -1
- package/config/agent-config-poller.js +1 -0
- package/config/entitlement-gate.d.ts +51 -0
- package/config/entitlement-gate.js +137 -0
- package/index.js +54 -23
- package/ingest/pending-entry-metadata.d.ts +31 -9
- package/ingest/pending-entry-metadata.js +70 -16
- package/ingest/position-auto-capture.js +14 -3
- package/ingest/readiness-reporter.d.ts +19 -0
- package/ingest/readiness-reporter.js +142 -0
- package/live/exchange-info-cache.d.ts +3 -1
- package/live/exchange-info-cache.js +17 -2
- package/package.json +1 -1
- package/signals/strategy-adapter.d.ts +35 -2
- package/signals/strategy-adapter.js +87 -10
- package/skills/reefclaw/SKILL.md +6 -0
- package/tools/create-order.js +26 -20
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Agent-readiness reporter — runs connect-phase health checks ON the trader's
|
|
2
|
+
// host and POSTs a self-describing report to the webapp, so a silently-broken
|
|
3
|
+
// agent (geo-blocked by Binance, clock-skewed, no tools) surfaces a plain-English
|
|
4
|
+
// dashboard alert instead of a false green. Advisory only: it NEVER blocks
|
|
5
|
+
// trading or the safety floor. See docs/AGENT_READINESS_GATE_PLAN.md (Phase 1).
|
|
6
|
+
//
|
|
7
|
+
// No token → no-op (nothing to authenticate; matches the config poller). A
|
|
8
|
+
// module-level singleton guard mirrors `pluginInitialised` in index.ts — OpenClaw
|
|
9
|
+
// calls register() multiple times per process and we must never spawn a second
|
|
10
|
+
// timer. The interval is unref()'d so it never holds the process open.
|
|
11
|
+
import { makeReadinessCheck, deriveOverallReadiness, } from '@reefclaw/shared';
|
|
12
|
+
import { logger, formatError } from '../logger.js';
|
|
13
|
+
const TAG = 'readiness';
|
|
14
|
+
const DEFAULT_INTERVAL_MS = 300_000; // 5 min — geo/clock state changes rarely.
|
|
15
|
+
const MIN_INTERVAL_MS = 60_000;
|
|
16
|
+
/** Best-effort display fact; kept in sync with the register() banner in index.ts. */
|
|
17
|
+
const PLUGIN_VERSION = '3.8.0';
|
|
18
|
+
function resolveIntervalMs(explicit) {
|
|
19
|
+
if (explicit && explicit > 0)
|
|
20
|
+
return Math.max(MIN_INTERVAL_MS, explicit);
|
|
21
|
+
const raw = Number(process.env.RC_READINESS_INTERVAL_MS);
|
|
22
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
23
|
+
return DEFAULT_INTERVAL_MS;
|
|
24
|
+
return Math.max(MIN_INTERVAL_MS, raw);
|
|
25
|
+
}
|
|
26
|
+
/** Run every connect-phase check once and assemble the report. Exported for
|
|
27
|
+
* unit tests. */
|
|
28
|
+
export async function collectReadiness(opts) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const checks = [];
|
|
31
|
+
// plugin_loaded — trivially true (this code runs inside the loaded plugin),
|
|
32
|
+
// but a positive row is what proves the report path is alive at all.
|
|
33
|
+
checks.push(makeReadinessCheck('plugin_loaded', 'pass', { checkedAt: now }));
|
|
34
|
+
// tools_registered
|
|
35
|
+
checks.push(makeReadinessCheck('tools_registered', opts.toolCount > 0 ? 'pass' : 'fail', {
|
|
36
|
+
detail: `${opts.toolCount} tools`,
|
|
37
|
+
checkedAt: now,
|
|
38
|
+
}));
|
|
39
|
+
// binance_reachable (+ clock drift from the same probe response)
|
|
40
|
+
const probe = await opts.binanceApi.probeReachability();
|
|
41
|
+
if (probe.outcome === 'reachable') {
|
|
42
|
+
checks.push(makeReadinessCheck('binance_reachable', 'pass', { checkedAt: now }));
|
|
43
|
+
const drift = probe.driftMs;
|
|
44
|
+
if (drift == null) {
|
|
45
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const abs = Math.abs(drift);
|
|
49
|
+
const status = abs <= 1000 ? 'pass' : abs <= 5000 ? 'warn' : 'fail';
|
|
50
|
+
checks.push(makeReadinessCheck('clock_in_sync', status, {
|
|
51
|
+
detail: `drift ${Math.round(drift)}ms`,
|
|
52
|
+
checkedAt: now,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (probe.outcome === 'geo_blocked') {
|
|
57
|
+
checks.push(makeReadinessCheck('binance_reachable', 'fail', { detail: 'HTTP 451', checkedAt: now }));
|
|
58
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
59
|
+
}
|
|
60
|
+
else if (probe.outcome === 'unreachable') {
|
|
61
|
+
// Network/DNS/timeout — could be transient, so warn (amber) rather than
|
|
62
|
+
// asserting a definitive failure. A persistent problem stays amber across
|
|
63
|
+
// re-checks; only the 451 geo-block is a hard red.
|
|
64
|
+
checks.push(makeReadinessCheck('binance_reachable', 'warn', { detail: 'unreachable', checkedAt: now }));
|
|
65
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// 'unknown' — the ban/weight gate paused the probe; don't assert anything.
|
|
69
|
+
checks.push(makeReadinessCheck('binance_reachable', 'unknown', { checkedAt: now }));
|
|
70
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
generatedAt: now,
|
|
75
|
+
overall: deriveOverallReadiness(checks),
|
|
76
|
+
checks,
|
|
77
|
+
agent: { pluginVersion: PLUGIN_VERSION, toolCount: opts.toolCount },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function postReadiness(apiBaseUrl, token, report, fetchImpl, timeoutMs) {
|
|
81
|
+
const url = `${apiBaseUrl.replace(/\/+$/, '')}/api/internal/agent-readiness`;
|
|
82
|
+
const ac = new AbortController();
|
|
83
|
+
const tid = setTimeout(() => ac.abort(), timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchImpl(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
'content-type': 'application/json',
|
|
89
|
+
authorization: `Bearer ${token}`,
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify(report),
|
|
92
|
+
signal: ac.signal,
|
|
93
|
+
});
|
|
94
|
+
if (res.status < 200 || res.status >= 300) {
|
|
95
|
+
logger.warn(TAG, `POST ${url} → ${res.status}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
logger.warn(TAG, `readiness POST failed: ${formatError(err)}`);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearTimeout(tid);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let reporterStarted = false;
|
|
106
|
+
/** Test-only — reset the singleton guard between unit tests. */
|
|
107
|
+
export function __resetReadinessReporterForTests() {
|
|
108
|
+
reporterStarted = false;
|
|
109
|
+
}
|
|
110
|
+
/** Fire the readiness report once at boot then on an unref'd interval. */
|
|
111
|
+
export function startReadinessReporter(opts) {
|
|
112
|
+
if (reporterStarted)
|
|
113
|
+
return;
|
|
114
|
+
if (!opts.token || !opts.token.trim()) {
|
|
115
|
+
logger.info(TAG, 'no connection token — readiness reporting disabled');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
reporterStarted = true;
|
|
119
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
120
|
+
const intervalMs = resolveIntervalMs(opts.intervalMs);
|
|
121
|
+
const timeoutMs = opts.requestTimeoutMs ?? 10_000;
|
|
122
|
+
const cycle = async () => {
|
|
123
|
+
try {
|
|
124
|
+
const report = await collectReadiness({
|
|
125
|
+
binanceApi: opts.binanceApi,
|
|
126
|
+
toolCount: opts.toolCount,
|
|
127
|
+
});
|
|
128
|
+
await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
|
|
129
|
+
if (report.overall === 'fail') {
|
|
130
|
+
const failing = report.checks.filter((c) => c.status === 'fail').map((c) => c.id).join(', ');
|
|
131
|
+
logger.warn(TAG, `readiness FAIL: ${failing}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
logger.warn(TAG, `readiness cycle failed: ${formatError(err)}`);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
void cycle();
|
|
139
|
+
const timer = setInterval(() => void cycle(), intervalMs);
|
|
140
|
+
timer.unref();
|
|
141
|
+
logger.info(TAG, `readiness reporter started (interval ${Math.round(intervalMs / 1000)}s)`);
|
|
142
|
+
}
|
|
@@ -23,7 +23,9 @@ export declare class ExchangeInfoCache {
|
|
|
23
23
|
private rules;
|
|
24
24
|
/** Set of all known symbols for normalization lookups. */
|
|
25
25
|
private knownSymbols;
|
|
26
|
-
/** Load market info from CCXT exchange instance.
|
|
26
|
+
/** Load market info from CCXT exchange instance. Throws on failure (incl.
|
|
27
|
+
* an active ban — the gate short-circuits with zero network so a boot
|
|
28
|
+
* during a 418 can't extend it); callers treat a throw as init failure. */
|
|
27
29
|
load(exchange: any): Promise<void>;
|
|
28
30
|
/** Get rules for a symbol (tries both BTC/USDT and BTC/USDT:USDT formats). */
|
|
29
31
|
getRules(symbol: string): SymbolRules | undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Exchange info cache — caches per-symbol trading rules (min notional, lot sizes, tick sizes).
|
|
2
2
|
// Loaded once on startup from CCXT loadMarkets(), used to validate and round orders before submission.
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
|
+
import { assertNotBanned, noteBinanceError, noteSuccess } from '../ccxt/binance-ban-gate.js';
|
|
4
5
|
const TAG = 'exchange-info';
|
|
5
6
|
/**
|
|
6
7
|
* Normalize symbol format for consistent lookups.
|
|
@@ -26,10 +27,24 @@ export class ExchangeInfoCache {
|
|
|
26
27
|
rules = new Map();
|
|
27
28
|
/** Set of all known symbols for normalization lookups. */
|
|
28
29
|
knownSymbols = new Set();
|
|
29
|
-
/** Load market info from CCXT exchange instance.
|
|
30
|
+
/** Load market info from CCXT exchange instance. Throws on failure (incl.
|
|
31
|
+
* an active ban — the gate short-circuits with zero network so a boot
|
|
32
|
+
* during a 418 can't extend it); callers treat a throw as init failure. */
|
|
30
33
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
34
|
async load(exchange) {
|
|
32
|
-
|
|
35
|
+
let info;
|
|
36
|
+
try {
|
|
37
|
+
// GET /fapi/v1/exchangeInfo via ccxt loadMarkets — IP weight 1
|
|
38
|
+
// (doc-verified). Ban-gated but never weight-paced (NEVER_PACE):
|
|
39
|
+
// once-per-boot and required for order validation + emergency close.
|
|
40
|
+
assertNotBanned('loadMarkets');
|
|
41
|
+
info = await exchange.loadMarkets(true); // force refresh
|
|
42
|
+
noteSuccess();
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
noteBinanceError(err);
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
33
48
|
for (const [symbol, market] of Object.entries(info)) {
|
|
34
49
|
const m = market;
|
|
35
50
|
// Only cache futures (swap) markets
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -1,6 +1,39 @@
|
|
|
1
|
-
import type { StrategyDefinition } from './types.js';
|
|
2
|
-
import type { StrategyConfig } from './conditions/types.js';
|
|
1
|
+
import type { StrategyDefinition, MarketContext, OhlcvBar } from './types.js';
|
|
2
|
+
import type { StrategyConfig, PrimaryTimeframe } from './conditions/types.js';
|
|
3
3
|
export declare function clearStrategyGatingState(): void;
|
|
4
|
+
/**
|
|
5
|
+
* Live parity for higher-timeframe strategies (the tfHours-aware-stops fix,
|
|
6
|
+
* 2026-07 — see docs/STRATEGY_RESEARCH_2026-07.md §6.4 / CLAUDE.md ★).
|
|
7
|
+
*
|
|
8
|
+
* Every implicit bar read in this engine — stop rules
|
|
9
|
+
* (`findSwingPoints(ctx.ohlcv1h.slice(-48))`), entry rules
|
|
10
|
+
* (`computeEMA(ctx.ohlcv1h…)`), conditions without a `tfHours` param
|
|
11
|
+
* (ema_proximity, stoch_rsi_extreme, adx_*, …) and `ctx.atr14` — targets the
|
|
12
|
+
* `ohlcv1h` slot. The backtest engine feeds MAIN-timeframe bars into that
|
|
13
|
+
* slot (and computes atr14 from them), so a 4h/1d strategy backtests against
|
|
14
|
+
* primary-timeframe geometry. LIVE contexts put real 1h bars there, so the
|
|
15
|
+
* same strategy would compute stops/EMAs/ATR from 1h data — a 1d ATR is ~8×
|
|
16
|
+
* the 1h ATR, so live stops came out ~8× too tight. This helper gives the
|
|
17
|
+
* evaluation the exact context shape the backtest validated: primary bars in
|
|
18
|
+
* the `ohlcv1h` slot, atr14 recomputed from them (same computeATR the
|
|
19
|
+
* backtest and live context builders use).
|
|
20
|
+
*
|
|
21
|
+
* Detection, not configuration: when the `ohlcv1h` slot already carries
|
|
22
|
+
* primary-cadence bars (median spacing ≥ 90% of the primary bar duration —
|
|
23
|
+
* i.e. a backtest context), the context is returned UNTOUCHED, so backtest
|
|
24
|
+
* behaviour is byte-identical by construction (including warm-up: the
|
|
25
|
+
* backtest engine already refuses to build a context below 50 main bars).
|
|
26
|
+
* A live 1h series can only look primary-spaced through a data gap, in
|
|
27
|
+
* which case we fall back to the untouched context (pre-fix behaviour)
|
|
28
|
+
* rather than guessing.
|
|
29
|
+
*
|
|
30
|
+
* Returns null for a LIVE context whose primary-timeframe history is below
|
|
31
|
+
* the backtest's 50-bar warm-up — the caller skips evaluation, mirroring
|
|
32
|
+
* the backtest's null-context warm-up window.
|
|
33
|
+
*
|
|
34
|
+
* Exported for tests.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolvePrimaryContext(ctx: MarketContext, tf: PrimaryTimeframe, tfBars: OhlcvBar[]): MarketContext | null;
|
|
4
37
|
/**
|
|
5
38
|
* Convert a declarative StrategyConfig into a StrategyDefinition
|
|
6
39
|
* that the signal engine and backtest engine can evaluate.
|
|
@@ -9,6 +9,7 @@ import { evaluateConditions } from './conditions/registry.js';
|
|
|
9
9
|
import { resolveDirection } from './direction-rules.js';
|
|
10
10
|
import { computeEntry } from './entry-rules.js';
|
|
11
11
|
import { computeStop } from './stop-rules.js';
|
|
12
|
+
import { computeATR } from '../shared/indicators.js';
|
|
12
13
|
/**
|
|
13
14
|
* Higher-timeframe tick gating state, keyed by
|
|
14
15
|
* `${gateNamespace}\x1f${strategyName}:${symbol}`.
|
|
@@ -41,6 +42,73 @@ function pickTimeframeBars(ctx, tf) {
|
|
|
41
42
|
return ctx.ohlcv4h ?? [];
|
|
42
43
|
return ctx.ohlcv1h;
|
|
43
44
|
}
|
|
45
|
+
const TF_MS = {
|
|
46
|
+
'1h': 3_600_000,
|
|
47
|
+
'4h': 4 * 3_600_000,
|
|
48
|
+
'1d': 24 * 3_600_000,
|
|
49
|
+
};
|
|
50
|
+
/** Median spacing of the last few bars — robust bar-cadence probe. */
|
|
51
|
+
function barSpacingMs(bars) {
|
|
52
|
+
const n = bars.length;
|
|
53
|
+
if (n < 2)
|
|
54
|
+
return 0;
|
|
55
|
+
const deltas = [];
|
|
56
|
+
for (let i = Math.max(1, n - 4); i < n; i++) {
|
|
57
|
+
deltas.push(bars[i].time.getTime() - bars[i - 1].time.getTime());
|
|
58
|
+
}
|
|
59
|
+
deltas.sort((a, b) => a - b);
|
|
60
|
+
return deltas[Math.floor(deltas.length / 2)];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Live parity for higher-timeframe strategies (the tfHours-aware-stops fix,
|
|
64
|
+
* 2026-07 — see docs/STRATEGY_RESEARCH_2026-07.md §6.4 / CLAUDE.md ★).
|
|
65
|
+
*
|
|
66
|
+
* Every implicit bar read in this engine — stop rules
|
|
67
|
+
* (`findSwingPoints(ctx.ohlcv1h.slice(-48))`), entry rules
|
|
68
|
+
* (`computeEMA(ctx.ohlcv1h…)`), conditions without a `tfHours` param
|
|
69
|
+
* (ema_proximity, stoch_rsi_extreme, adx_*, …) and `ctx.atr14` — targets the
|
|
70
|
+
* `ohlcv1h` slot. The backtest engine feeds MAIN-timeframe bars into that
|
|
71
|
+
* slot (and computes atr14 from them), so a 4h/1d strategy backtests against
|
|
72
|
+
* primary-timeframe geometry. LIVE contexts put real 1h bars there, so the
|
|
73
|
+
* same strategy would compute stops/EMAs/ATR from 1h data — a 1d ATR is ~8×
|
|
74
|
+
* the 1h ATR, so live stops came out ~8× too tight. This helper gives the
|
|
75
|
+
* evaluation the exact context shape the backtest validated: primary bars in
|
|
76
|
+
* the `ohlcv1h` slot, atr14 recomputed from them (same computeATR the
|
|
77
|
+
* backtest and live context builders use).
|
|
78
|
+
*
|
|
79
|
+
* Detection, not configuration: when the `ohlcv1h` slot already carries
|
|
80
|
+
* primary-cadence bars (median spacing ≥ 90% of the primary bar duration —
|
|
81
|
+
* i.e. a backtest context), the context is returned UNTOUCHED, so backtest
|
|
82
|
+
* behaviour is byte-identical by construction (including warm-up: the
|
|
83
|
+
* backtest engine already refuses to build a context below 50 main bars).
|
|
84
|
+
* A live 1h series can only look primary-spaced through a data gap, in
|
|
85
|
+
* which case we fall back to the untouched context (pre-fix behaviour)
|
|
86
|
+
* rather than guessing.
|
|
87
|
+
*
|
|
88
|
+
* Returns null for a LIVE context whose primary-timeframe history is below
|
|
89
|
+
* the backtest's 50-bar warm-up — the caller skips evaluation, mirroring
|
|
90
|
+
* the backtest's null-context warm-up window.
|
|
91
|
+
*
|
|
92
|
+
* Exported for tests.
|
|
93
|
+
*/
|
|
94
|
+
export function resolvePrimaryContext(ctx, tf, tfBars) {
|
|
95
|
+
if (tf === '1h')
|
|
96
|
+
return ctx;
|
|
97
|
+
const spacing = barSpacingMs(ctx.ohlcv1h);
|
|
98
|
+
if (spacing === 0 || spacing >= TF_MS[tf] * 0.9)
|
|
99
|
+
return ctx; // already primary (backtest) or undecidable
|
|
100
|
+
if (tfBars.length < 50)
|
|
101
|
+
return null; // live warm-up parity with the backtest engine
|
|
102
|
+
const highs = tfBars.map(b => b.high);
|
|
103
|
+
const lows = tfBars.map(b => b.low);
|
|
104
|
+
const closes = tfBars.map(b => b.close);
|
|
105
|
+
const atr14 = computeATR(highs, lows, closes, 14);
|
|
106
|
+
return {
|
|
107
|
+
...ctx,
|
|
108
|
+
ohlcv1h: tfBars,
|
|
109
|
+
atr14: Number.isFinite(atr14) && atr14 > 0 ? atr14 : ctx.atr14,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
44
112
|
/** Empty no-signal evaluation — used when gating skips a strategy. */
|
|
45
113
|
const SKIPPED = { direction: null, conditions: [], trade: undefined };
|
|
46
114
|
/**
|
|
@@ -80,40 +148,49 @@ export function adaptStrategy(config, gateNamespace) {
|
|
|
80
148
|
return SKIPPED;
|
|
81
149
|
}
|
|
82
150
|
lastEvaluatedBarTime.set(gateKey, latestBarTime);
|
|
151
|
+
// ─── Higher-timeframe live parity ──────────────────────────────
|
|
152
|
+
// Evaluate against a context whose implicit-1h slot carries
|
|
153
|
+
// primary-timeframe bars — see resolvePrimaryContext. Backtest
|
|
154
|
+
// contexts pass through untouched; only live contexts for 4h/1d
|
|
155
|
+
// strategies are adapted, and a live context below the backtest's
|
|
156
|
+
// 50-bar warm-up resolves to null → skip.
|
|
157
|
+
const ectx = resolvePrimaryContext(ctx, tf, tfBars);
|
|
158
|
+
if (ectx === null)
|
|
159
|
+
return SKIPPED;
|
|
83
160
|
// ─── SkipIf gates ──────────────────────────────────────────────
|
|
84
161
|
// Portfolio-wide / cross-symbol filters. If any are met the
|
|
85
162
|
// strategy is skipped this tick. Evaluated before main conditions
|
|
86
163
|
// so the bulk of the work is short-circuited.
|
|
87
164
|
if (config.skipIf && config.skipIf.length > 0) {
|
|
88
|
-
const { conditions: skipResults } = evaluateConditions(config.skipIf,
|
|
165
|
+
const { conditions: skipResults } = evaluateConditions(config.skipIf, ectx, null);
|
|
89
166
|
if (skipResults.some(c => c.met))
|
|
90
167
|
return SKIPPED;
|
|
91
168
|
}
|
|
92
169
|
// Pass 1: evaluate conditions with direction = null
|
|
93
|
-
const { conditions: pass1, condCtx } = evaluateConditions(config.conditions,
|
|
170
|
+
const { conditions: pass1, condCtx } = evaluateConditions(config.conditions, ectx, null);
|
|
94
171
|
// Determine direction
|
|
95
|
-
const direction = resolveDirection(config.directionRule,
|
|
172
|
+
const direction = resolveDirection(config.directionRule, ectx, condCtx);
|
|
96
173
|
// Pass 2: re-evaluate direction-sensitive conditions now that we know direction
|
|
97
174
|
// (orderbook_imbalance and funding_contrarian behave differently per direction)
|
|
98
175
|
const directionSensitive = new Set(['orderbook_imbalance', 'funding_contrarian', 'funding_extreme_skip', 'funding_position_ok', 'return_momentum']);
|
|
99
176
|
const hasDirSensitive = config.conditions.some(c => directionSensitive.has(c.type));
|
|
100
177
|
let finalConditions = pass1;
|
|
101
178
|
if (direction && hasDirSensitive) {
|
|
102
|
-
const { conditions: pass2 } = evaluateConditions(config.conditions,
|
|
179
|
+
const { conditions: pass2 } = evaluateConditions(config.conditions, ectx, direction);
|
|
103
180
|
// Merge: use pass2 results for direction-sensitive, pass1 for others
|
|
104
181
|
finalConditions = pass1.map((c, i) => directionSensitive.has(config.conditions[i].type) ? pass2[i] : c);
|
|
105
182
|
}
|
|
106
183
|
const allMet = finalConditions.every(c => c.met);
|
|
107
184
|
let trade;
|
|
108
185
|
if (allMet && direction) {
|
|
109
|
-
const entryZone = computeEntry(config.entryRule,
|
|
110
|
-
const stopLevel = computeStop(config.stopRule,
|
|
186
|
+
const entryZone = computeEntry(config.entryRule, ectx, direction, condCtx);
|
|
187
|
+
const stopLevel = computeStop(config.stopRule, ectx, direction, condCtx);
|
|
111
188
|
const risk = direction === 'LONG'
|
|
112
|
-
?
|
|
113
|
-
: stopLevel -
|
|
189
|
+
? ectx.currentPrice - stopLevel
|
|
190
|
+
: stopLevel - ectx.currentPrice;
|
|
114
191
|
const targets = config.targetRMultiples.map(rm => direction === 'LONG'
|
|
115
|
-
?
|
|
116
|
-
:
|
|
192
|
+
? ectx.currentPrice + risk * rm
|
|
193
|
+
: ectx.currentPrice - risk * rm);
|
|
117
194
|
trade = { entryZone, stopLevel, targets };
|
|
118
195
|
}
|
|
119
196
|
return { direction, conditions: finalConditions, trade };
|
package/skills/reefclaw/SKILL.md
CHANGED
|
@@ -39,6 +39,12 @@ You are being connected to **ReefClaw**, a supervised trading control room. Your
|
|
|
39
39
|
|
|
40
40
|
**Do not attempt to trade before the full instructions arrive.** If trading tools are visible but this file is still a bootstrap version (0.0.x), the connection has not completed — finish connecting first.
|
|
41
41
|
|
|
42
|
+
## Host requirements (check before connecting)
|
|
43
|
+
|
|
44
|
+
ReefClaw trades on Binance USD-M Futures, so **the machine running this OpenClaw must be able to reach Binance** (`fapi.binance.com`). Binance geo-restricts many locations: cheap US cloud regions — and some other jurisdictions — return **HTTP 451** ("restricted location"), and the agent then cannot fetch market data or trade. This applies even in **paper mode**, which uses live Binance prices.
|
|
45
|
+
|
|
46
|
+
If your market-data calls fail with `451`, this host is geo-blocked — move the agent to a Binance-permitted region (most EU and several Asia VPS regions work). This is a hosting requirement; ReefClaw cannot configure around it.
|
|
47
|
+
|
|
42
48
|
## Connecting (first run) — saving the connect message
|
|
43
49
|
|
|
44
50
|
The onboarding page at [reefclaw.com/onboarding](https://reefclaw.com/onboarding) gives the user a single **connect message** that carries THREE fields:
|
package/tools/create-order.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Tool: create_order — order execution with real price data + pre-trade risk gate
|
|
2
2
|
// Readiness gate: BLOCKED unless adapter.readiness === 'READY'.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { formatError } from '../logger.js';
|
|
4
5
|
import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
|
|
5
6
|
import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
|
|
@@ -293,32 +294,37 @@ export async function createOrderTool(args, deps) {
|
|
|
293
294
|
}
|
|
294
295
|
}
|
|
295
296
|
try {
|
|
296
|
-
|
|
297
|
+
// Stash the metadata BEFORE submission, keyed by a pre-generated
|
|
298
|
+
// clientOrderId (fixed 2026-07-07; supersedes the 2026-07-05 post-return
|
|
299
|
+
// stash). For market orders the user-data WS fill routinely arrives
|
|
300
|
+
// BEFORE the REST ack resolves — observed on prod (FIL 2026-07-06
|
|
301
|
+
// 20:28:17: WS capture consumed nothing and journaled metadata=none while
|
|
302
|
+
// the post-return stash landed milliseconds later). The WS
|
|
303
|
+
// ORDER_TRADE_UPDATE carries the clientOrderId (`o.c`), so
|
|
304
|
+
// onWsFillObserved falls back to it when the exchange orderId lookup
|
|
305
|
+
// misses; promote() below adds the exchange-orderId alias once the REST
|
|
306
|
+
// ack returns for the normal (WS-after-REST) ordering. If the submission
|
|
307
|
+
// throws, the unused stash entry simply expires (24h TTL, pruned).
|
|
308
|
+
let stashCid;
|
|
309
|
+
if (deps.autoCapture?.pendingEntries && metadata) {
|
|
310
|
+
stashCid = randomUUID();
|
|
311
|
+
deps.autoCapture.pendingEntries.put({
|
|
312
|
+
orderId: stashCid,
|
|
313
|
+
clientOrderId: stashCid,
|
|
314
|
+
symbol: args.symbol,
|
|
315
|
+
side: side,
|
|
316
|
+
metadata,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const order = await deps.adapter.createOrder(args.symbol, side, type, args.amount, args.price, metadata, stashCid ? { clientOrderId: stashCid } : undefined);
|
|
297
320
|
// Auto-capture entry to the Position Decision Journal — fail-open, never
|
|
298
321
|
// block the trading hot path on a webapp ingest blip.
|
|
299
322
|
if (deps.autoCapture) {
|
|
300
|
-
|
|
301
|
-
// market orders routinely come back filled>0 but with NO average price
|
|
302
|
-
// in the immediate REST response — the synchronous capture below then
|
|
303
|
-
// bails ("missing fill price; skipping capture") and, before this fix,
|
|
304
|
-
// the metadata was dropped on the floor because the stash only ran in
|
|
305
|
-
// the not-filled branch. The WS-driven onWsFillObserved captured those
|
|
306
|
-
// entries seconds later with metadata=none → the live journal filled
|
|
307
|
-
// with '(no thesis recorded)' / setup_type='unknown' rows (majority of
|
|
308
|
-
// 2026-07 live entries) even though the agent supplied full v2.10.0
|
|
309
|
-
// metadata every time. When the synchronous capture DOES succeed, the
|
|
310
|
-
// WS dedup (openedFromExchangeTradeId === orderId) skips the duplicate
|
|
311
|
-
// and the unused stash entry simply expires (24h TTL, pruned).
|
|
312
|
-
if (metadata &&
|
|
323
|
+
if (stashCid &&
|
|
313
324
|
deps.autoCapture.pendingEntries &&
|
|
314
325
|
typeof order.id === 'string' &&
|
|
315
326
|
order.id.length > 0) {
|
|
316
|
-
deps.autoCapture.pendingEntries.
|
|
317
|
-
orderId: order.id,
|
|
318
|
-
symbol: args.symbol,
|
|
319
|
-
side: side,
|
|
320
|
-
metadata,
|
|
321
|
-
});
|
|
327
|
+
deps.autoCapture.pendingEntries.promote(stashCid, order.id);
|
|
322
328
|
}
|
|
323
329
|
const filledNow = typeof order.filled === 'number' && order.filled > 0;
|
|
324
330
|
if (filledNow) {
|