@reefclaw/connect 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bridge/bridge.d.ts +28 -1
- package/assets/bridge/bridge.js +99 -4
- package/assets/plugin/ccxt/binance-ban-gate.js +9 -0
- package/assets/plugin/config/agent-config-client.d.ts +22 -0
- package/assets/plugin/config/agent-config-client.js +44 -1
- package/assets/plugin/config/agent-config-poller.d.ts +8 -1
- package/assets/plugin/config/agent-config-poller.js +1 -0
- package/assets/plugin/config/entitlement-gate.d.ts +51 -0
- package/assets/plugin/config/entitlement-gate.js +137 -0
- package/assets/plugin/index.js +40 -23
- package/assets/plugin/ingest/pending-entry-metadata.d.ts +31 -9
- package/assets/plugin/ingest/pending-entry-metadata.js +70 -16
- package/assets/plugin/ingest/position-auto-capture.js +14 -3
- package/assets/plugin/ingest/readiness-reporter.d.ts +19 -0
- package/assets/plugin/ingest/readiness-reporter.js +142 -0
- package/assets/plugin/live/exchange-info-cache.d.ts +3 -1
- package/assets/plugin/live/exchange-info-cache.js +17 -2
- package/assets/plugin/signals/strategy-adapter.d.ts +35 -2
- package/assets/plugin/signals/strategy-adapter.js +87 -10
- package/assets/plugin/tools/create-order.js +26 -20
- package/assets/shared/readiness.d.ts +50 -0
- package/assets/shared/readiness.js +58 -0
- package/assets/shared/signals/strategy-adapter.d.ts +35 -2
- package/assets/shared/signals/strategy-adapter.js +87 -10
- package/dist/daemon.js +104 -0
- package/dist/validate.js +6 -6
- package/package.json +1 -1
|
@@ -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 };
|
|
@@ -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) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type ReadinessStatus = 'pass' | 'warn' | 'fail' | 'unknown';
|
|
2
|
+
/** Which lifecycle phase a check belongs to. `connect` runs with NO Binance API
|
|
3
|
+
* keys (covers paper trading too); `golive` needs keys (Phase 2). */
|
|
4
|
+
export type ReadinessPhase = 'connect' | 'golive';
|
|
5
|
+
export type ReadinessCheckId = 'binance_reachable' | 'clock_in_sync' | 'plugin_loaded' | 'tools_registered';
|
|
6
|
+
export interface ReadinessCheck {
|
|
7
|
+
/** Stable machine id. Widened to string so the webapp can render checks from a
|
|
8
|
+
* newer plugin it doesn't have the union for. */
|
|
9
|
+
id: ReadinessCheckId | string;
|
|
10
|
+
label: string;
|
|
11
|
+
status: ReadinessStatus;
|
|
12
|
+
phase: ReadinessPhase;
|
|
13
|
+
/** Short machine/human detail, e.g. "HTTP 451" or "drift 1200ms". */
|
|
14
|
+
detail?: string;
|
|
15
|
+
/** Plain-English fix, present only when the status is warn/fail. */
|
|
16
|
+
fixHint?: string;
|
|
17
|
+
/** epoch ms when this check ran. */
|
|
18
|
+
checkedAt: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ReadinessReport {
|
|
21
|
+
/** Bumped only if the payload shape changes incompatibly. */
|
|
22
|
+
schemaVersion: 1;
|
|
23
|
+
generatedAt: number;
|
|
24
|
+
overall: ReadinessStatus;
|
|
25
|
+
checks: ReadinessCheck[];
|
|
26
|
+
/** Best-effort agent facts for display. */
|
|
27
|
+
agent?: {
|
|
28
|
+
pluginVersion?: string;
|
|
29
|
+
toolCount?: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
interface CheckCopy {
|
|
33
|
+
label: string;
|
|
34
|
+
phase: ReadinessPhase;
|
|
35
|
+
fixHint: string;
|
|
36
|
+
}
|
|
37
|
+
/** The canonical connect-phase checks + their plain-English fixes. Single source
|
|
38
|
+
* of copy; the plugin inlines these into each report so the webapp stays dumb. */
|
|
39
|
+
export declare const READINESS_CHECK_COPY: Record<ReadinessCheckId, CheckCopy>;
|
|
40
|
+
/** Build a self-describing check, pulling label/phase/fixHint from the copy map.
|
|
41
|
+
* fixHint is attached only when there is something to fix (warn/fail). */
|
|
42
|
+
export declare function makeReadinessCheck(id: ReadinessCheckId, status: ReadinessStatus, opts?: {
|
|
43
|
+
detail?: string;
|
|
44
|
+
checkedAt?: number;
|
|
45
|
+
}): ReadinessCheck;
|
|
46
|
+
/** Worst status across the checks: fail > warn > unknown > pass. An all-unknown
|
|
47
|
+
* (or empty) set is 'unknown', so a report that couldn't run anything doesn't
|
|
48
|
+
* masquerade as a clean pass. */
|
|
49
|
+
export declare function deriveOverallReadiness(checks: ReadinessCheck[]): ReadinessStatus;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Agent readiness — the check-result contract shared by the plugin (which runs
|
|
2
|
+
// the checks ON the trader's host) and the webapp (which stores + renders them).
|
|
3
|
+
//
|
|
4
|
+
// The plugin emits SELF-DESCRIBING checks (label + fixHint inline) so the webapp
|
|
5
|
+
// renders generically without duplicating the copy. Advisory only: readiness
|
|
6
|
+
// explains failures, it never blocks trading or the safety floor.
|
|
7
|
+
// See docs/AGENT_READINESS_GATE_PLAN.md.
|
|
8
|
+
/** The canonical connect-phase checks + their plain-English fixes. Single source
|
|
9
|
+
* of copy; the plugin inlines these into each report so the webapp stays dumb. */
|
|
10
|
+
export const READINESS_CHECK_COPY = {
|
|
11
|
+
binance_reachable: {
|
|
12
|
+
label: 'Binance reachable',
|
|
13
|
+
phase: 'connect',
|
|
14
|
+
fixHint: 'Your agent host is geo-blocked by Binance (HTTP 451). Run OpenClaw from a Binance-permitted region — most EU / several Asia VPS regions work.',
|
|
15
|
+
},
|
|
16
|
+
clock_in_sync: {
|
|
17
|
+
label: 'Host clock in sync',
|
|
18
|
+
phase: 'connect',
|
|
19
|
+
fixHint: 'The host clock drifts from Binance server time. Enable NTP/chrony so signed orders are not rejected (-1021).',
|
|
20
|
+
},
|
|
21
|
+
plugin_loaded: {
|
|
22
|
+
label: 'ReefClaw plugin loaded',
|
|
23
|
+
phase: 'connect',
|
|
24
|
+
fixHint: 'The trading plugin did not load. Check the OpenClaw gateway logs for a plugin-load error.',
|
|
25
|
+
},
|
|
26
|
+
tools_registered: {
|
|
27
|
+
label: 'Trading tools registered',
|
|
28
|
+
phase: 'connect',
|
|
29
|
+
fixHint: 'No trading tools are registered. Ensure tools.alsoAllow includes "group:plugins", then restart the gateway.',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
/** Build a self-describing check, pulling label/phase/fixHint from the copy map.
|
|
33
|
+
* fixHint is attached only when there is something to fix (warn/fail). */
|
|
34
|
+
export function makeReadinessCheck(id, status, opts = {}) {
|
|
35
|
+
const copy = READINESS_CHECK_COPY[id];
|
|
36
|
+
const needsFix = status === 'warn' || status === 'fail';
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
label: copy.label,
|
|
40
|
+
phase: copy.phase,
|
|
41
|
+
status,
|
|
42
|
+
...(opts.detail !== undefined ? { detail: opts.detail } : {}),
|
|
43
|
+
...(needsFix ? { fixHint: copy.fixHint } : {}),
|
|
44
|
+
checkedAt: opts.checkedAt ?? Date.now(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Worst status across the checks: fail > warn > unknown > pass. An all-unknown
|
|
48
|
+
* (or empty) set is 'unknown', so a report that couldn't run anything doesn't
|
|
49
|
+
* masquerade as a clean pass. */
|
|
50
|
+
export function deriveOverallReadiness(checks) {
|
|
51
|
+
if (checks.some((c) => c.status === 'fail'))
|
|
52
|
+
return 'fail';
|
|
53
|
+
if (checks.some((c) => c.status === 'warn'))
|
|
54
|
+
return 'warn';
|
|
55
|
+
if (checks.length === 0 || checks.every((c) => c.status === 'unknown'))
|
|
56
|
+
return 'unknown';
|
|
57
|
+
return 'pass';
|
|
58
|
+
}
|
|
@@ -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.
|
|
@@ -4,6 +4,7 @@ import { evaluateConditions } from './conditions/registry.js';
|
|
|
4
4
|
import { resolveDirection } from './direction-rules.js';
|
|
5
5
|
import { computeEntry } from './entry-rules.js';
|
|
6
6
|
import { computeStop } from './stop-rules.js';
|
|
7
|
+
import { computeATR } from '../shared/indicators.js';
|
|
7
8
|
/**
|
|
8
9
|
* Higher-timeframe tick gating state, keyed by
|
|
9
10
|
* `${gateNamespace}\x1f${strategyName}:${symbol}`.
|
|
@@ -36,6 +37,73 @@ function pickTimeframeBars(ctx, tf) {
|
|
|
36
37
|
return ctx.ohlcv4h ?? [];
|
|
37
38
|
return ctx.ohlcv1h;
|
|
38
39
|
}
|
|
40
|
+
const TF_MS = {
|
|
41
|
+
'1h': 3_600_000,
|
|
42
|
+
'4h': 4 * 3_600_000,
|
|
43
|
+
'1d': 24 * 3_600_000,
|
|
44
|
+
};
|
|
45
|
+
/** Median spacing of the last few bars — robust bar-cadence probe. */
|
|
46
|
+
function barSpacingMs(bars) {
|
|
47
|
+
const n = bars.length;
|
|
48
|
+
if (n < 2)
|
|
49
|
+
return 0;
|
|
50
|
+
const deltas = [];
|
|
51
|
+
for (let i = Math.max(1, n - 4); i < n; i++) {
|
|
52
|
+
deltas.push(bars[i].time.getTime() - bars[i - 1].time.getTime());
|
|
53
|
+
}
|
|
54
|
+
deltas.sort((a, b) => a - b);
|
|
55
|
+
return deltas[Math.floor(deltas.length / 2)];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Live parity for higher-timeframe strategies (the tfHours-aware-stops fix,
|
|
59
|
+
* 2026-07 — see docs/STRATEGY_RESEARCH_2026-07.md §6.4 / CLAUDE.md ★).
|
|
60
|
+
*
|
|
61
|
+
* Every implicit bar read in this engine — stop rules
|
|
62
|
+
* (`findSwingPoints(ctx.ohlcv1h.slice(-48))`), entry rules
|
|
63
|
+
* (`computeEMA(ctx.ohlcv1h…)`), conditions without a `tfHours` param
|
|
64
|
+
* (ema_proximity, stoch_rsi_extreme, adx_*, …) and `ctx.atr14` — targets the
|
|
65
|
+
* `ohlcv1h` slot. The backtest engine feeds MAIN-timeframe bars into that
|
|
66
|
+
* slot (and computes atr14 from them), so a 4h/1d strategy backtests against
|
|
67
|
+
* primary-timeframe geometry. LIVE contexts put real 1h bars there, so the
|
|
68
|
+
* same strategy would compute stops/EMAs/ATR from 1h data — a 1d ATR is ~8×
|
|
69
|
+
* the 1h ATR, so live stops came out ~8× too tight. This helper gives the
|
|
70
|
+
* evaluation the exact context shape the backtest validated: primary bars in
|
|
71
|
+
* the `ohlcv1h` slot, atr14 recomputed from them (same computeATR the
|
|
72
|
+
* backtest and live context builders use).
|
|
73
|
+
*
|
|
74
|
+
* Detection, not configuration: when the `ohlcv1h` slot already carries
|
|
75
|
+
* primary-cadence bars (median spacing ≥ 90% of the primary bar duration —
|
|
76
|
+
* i.e. a backtest context), the context is returned UNTOUCHED, so backtest
|
|
77
|
+
* behaviour is byte-identical by construction (including warm-up: the
|
|
78
|
+
* backtest engine already refuses to build a context below 50 main bars).
|
|
79
|
+
* A live 1h series can only look primary-spaced through a data gap, in
|
|
80
|
+
* which case we fall back to the untouched context (pre-fix behaviour)
|
|
81
|
+
* rather than guessing.
|
|
82
|
+
*
|
|
83
|
+
* Returns null for a LIVE context whose primary-timeframe history is below
|
|
84
|
+
* the backtest's 50-bar warm-up — the caller skips evaluation, mirroring
|
|
85
|
+
* the backtest's null-context warm-up window.
|
|
86
|
+
*
|
|
87
|
+
* Exported for tests.
|
|
88
|
+
*/
|
|
89
|
+
export function resolvePrimaryContext(ctx, tf, tfBars) {
|
|
90
|
+
if (tf === '1h')
|
|
91
|
+
return ctx;
|
|
92
|
+
const spacing = barSpacingMs(ctx.ohlcv1h);
|
|
93
|
+
if (spacing === 0 || spacing >= TF_MS[tf] * 0.9)
|
|
94
|
+
return ctx; // already primary (backtest) or undecidable
|
|
95
|
+
if (tfBars.length < 50)
|
|
96
|
+
return null; // live warm-up parity with the backtest engine
|
|
97
|
+
const highs = tfBars.map(b => b.high);
|
|
98
|
+
const lows = tfBars.map(b => b.low);
|
|
99
|
+
const closes = tfBars.map(b => b.close);
|
|
100
|
+
const atr14 = computeATR(highs, lows, closes, 14);
|
|
101
|
+
return {
|
|
102
|
+
...ctx,
|
|
103
|
+
ohlcv1h: tfBars,
|
|
104
|
+
atr14: Number.isFinite(atr14) && atr14 > 0 ? atr14 : ctx.atr14,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
39
107
|
/** Empty no-signal evaluation — used when gating skips a strategy. */
|
|
40
108
|
const SKIPPED = { direction: null, conditions: [], trade: undefined };
|
|
41
109
|
/**
|
|
@@ -75,40 +143,49 @@ export function adaptStrategy(config, gateNamespace) {
|
|
|
75
143
|
return SKIPPED;
|
|
76
144
|
}
|
|
77
145
|
lastEvaluatedBarTime.set(gateKey, latestBarTime);
|
|
146
|
+
// ─── Higher-timeframe live parity ──────────────────────────────
|
|
147
|
+
// Evaluate against a context whose implicit-1h slot carries
|
|
148
|
+
// primary-timeframe bars — see resolvePrimaryContext. Backtest
|
|
149
|
+
// contexts pass through untouched; only live contexts for 4h/1d
|
|
150
|
+
// strategies are adapted, and a live context below the backtest's
|
|
151
|
+
// 50-bar warm-up resolves to null → skip.
|
|
152
|
+
const ectx = resolvePrimaryContext(ctx, tf, tfBars);
|
|
153
|
+
if (ectx === null)
|
|
154
|
+
return SKIPPED;
|
|
78
155
|
// ─── SkipIf gates ──────────────────────────────────────────────
|
|
79
156
|
// Portfolio-wide / cross-symbol filters. If any are met the
|
|
80
157
|
// strategy is skipped this tick. Evaluated before main conditions
|
|
81
158
|
// so the bulk of the work is short-circuited.
|
|
82
159
|
if (config.skipIf && config.skipIf.length > 0) {
|
|
83
|
-
const { conditions: skipResults } = evaluateConditions(config.skipIf,
|
|
160
|
+
const { conditions: skipResults } = evaluateConditions(config.skipIf, ectx, null);
|
|
84
161
|
if (skipResults.some(c => c.met))
|
|
85
162
|
return SKIPPED;
|
|
86
163
|
}
|
|
87
164
|
// Pass 1: evaluate conditions with direction = null
|
|
88
|
-
const { conditions: pass1, condCtx } = evaluateConditions(config.conditions,
|
|
165
|
+
const { conditions: pass1, condCtx } = evaluateConditions(config.conditions, ectx, null);
|
|
89
166
|
// Determine direction
|
|
90
|
-
const direction = resolveDirection(config.directionRule,
|
|
167
|
+
const direction = resolveDirection(config.directionRule, ectx, condCtx);
|
|
91
168
|
// Pass 2: re-evaluate direction-sensitive conditions now that we know direction
|
|
92
169
|
// (orderbook_imbalance and funding_contrarian behave differently per direction)
|
|
93
170
|
const directionSensitive = new Set(['orderbook_imbalance', 'funding_contrarian', 'funding_extreme_skip', 'funding_position_ok', 'return_momentum']);
|
|
94
171
|
const hasDirSensitive = config.conditions.some(c => directionSensitive.has(c.type));
|
|
95
172
|
let finalConditions = pass1;
|
|
96
173
|
if (direction && hasDirSensitive) {
|
|
97
|
-
const { conditions: pass2 } = evaluateConditions(config.conditions,
|
|
174
|
+
const { conditions: pass2 } = evaluateConditions(config.conditions, ectx, direction);
|
|
98
175
|
// Merge: use pass2 results for direction-sensitive, pass1 for others
|
|
99
176
|
finalConditions = pass1.map((c, i) => directionSensitive.has(config.conditions[i].type) ? pass2[i] : c);
|
|
100
177
|
}
|
|
101
178
|
const allMet = finalConditions.every(c => c.met);
|
|
102
179
|
let trade;
|
|
103
180
|
if (allMet && direction) {
|
|
104
|
-
const entryZone = computeEntry(config.entryRule,
|
|
105
|
-
const stopLevel = computeStop(config.stopRule,
|
|
181
|
+
const entryZone = computeEntry(config.entryRule, ectx, direction, condCtx);
|
|
182
|
+
const stopLevel = computeStop(config.stopRule, ectx, direction, condCtx);
|
|
106
183
|
const risk = direction === 'LONG'
|
|
107
|
-
?
|
|
108
|
-
: stopLevel -
|
|
184
|
+
? ectx.currentPrice - stopLevel
|
|
185
|
+
: stopLevel - ectx.currentPrice;
|
|
109
186
|
const targets = config.targetRMultiples.map(rm => direction === 'LONG'
|
|
110
|
-
?
|
|
111
|
-
:
|
|
187
|
+
? ectx.currentPrice + risk * rm
|
|
188
|
+
: ectx.currentPrice - risk * rm);
|
|
112
189
|
trade = { entryZone, stopLevel, targets };
|
|
113
190
|
}
|
|
114
191
|
return { direction, conditions: finalConditions, trade };
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Keep the bridge running across reboots. Linux/systemd-user is implemented
|
|
2
|
+
// fully; macOS and Windows fall back to printing the manual run command (a
|
|
3
|
+
// launchd/Task-Scheduler unit is a follow-up). The bridge reads its config from
|
|
4
|
+
// ~/.openclaw/openclaw.json, so until the user pastes their connect message the
|
|
5
|
+
// service will start, find no token, and restart — harmless; it connects within
|
|
6
|
+
// seconds of the agent writing the config.
|
|
7
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { homedir, userInfo } from 'node:os';
|
|
10
|
+
import { BRIDGE_DIR } from './paths.js';
|
|
11
|
+
import { run, which } from './exec.js';
|
|
12
|
+
import { step, ok, info, warn } from './ui.js';
|
|
13
|
+
const SERVICE_NAME = 'reefclaw-bridge';
|
|
14
|
+
const NODE = process.execPath; // absolute path to the node running the installer
|
|
15
|
+
/**
|
|
16
|
+
* Build the systemd user-unit text. Pure + exported so the path-quoting is
|
|
17
|
+
* unit-testable. Both `node` (process.execPath) and `bridgeDir` (under the
|
|
18
|
+
* user's home) can contain spaces. Quoting rules differ per directive:
|
|
19
|
+
* - ExecStart= is parsed with shell-like word splitting, so an unquoted
|
|
20
|
+
* `ExecStart=/home/a b/node …` reads the binary as `/home/a` — QUOTE both
|
|
21
|
+
* the binary and the script path.
|
|
22
|
+
* - WorkingDirectory= takes the raw value after `=` as a single path (no word
|
|
23
|
+
* splitting) — spaces are safe UNQUOTED, and quotes are treated as literal
|
|
24
|
+
* characters, failing the unit with "path is not absolute" (verified live
|
|
25
|
+
* on systemd 255 / Ubuntu 24.04). Do NOT quote it.
|
|
26
|
+
*/
|
|
27
|
+
export function buildSystemdUnit(node, bridgeDir) {
|
|
28
|
+
const indexJs = join(bridgeDir, 'index.js');
|
|
29
|
+
return `[Unit]
|
|
30
|
+
Description=ReefClaw connector - bridges OpenClaw to the ReefClaw dashboard
|
|
31
|
+
After=network-online.target
|
|
32
|
+
Wants=network-online.target
|
|
33
|
+
|
|
34
|
+
[Service]
|
|
35
|
+
Type=simple
|
|
36
|
+
WorkingDirectory=${bridgeDir}
|
|
37
|
+
ExecStart="${node}" "${indexJs}" --provider gateway --log-level info
|
|
38
|
+
Restart=always
|
|
39
|
+
RestartSec=5s
|
|
40
|
+
|
|
41
|
+
[Install]
|
|
42
|
+
WantedBy=default.target
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
function manualHint() {
|
|
46
|
+
warn('Could not set up an auto-start service on this OS yet.');
|
|
47
|
+
info('Keep the connector running with this command (leave it open / use your own service manager):');
|
|
48
|
+
info(` "${NODE}" "${join(BRIDGE_DIR, 'index.js')}" --provider gateway`);
|
|
49
|
+
}
|
|
50
|
+
function installSystemd() {
|
|
51
|
+
if (!which('systemctl'))
|
|
52
|
+
return false;
|
|
53
|
+
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
54
|
+
mkdirSync(unitDir, { recursive: true });
|
|
55
|
+
const unit = buildSystemdUnit(NODE, BRIDGE_DIR);
|
|
56
|
+
writeFileSync(join(unitDir, `${SERVICE_NAME}.service`), unit, 'utf-8');
|
|
57
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
58
|
+
const enabled = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
|
|
59
|
+
if (!enabled.ok) {
|
|
60
|
+
warn('systemd --user enable/start did not succeed:');
|
|
61
|
+
if (enabled.stderr.trim())
|
|
62
|
+
info(enabled.stderr.trim().split('\n').slice(-2).join('\n'));
|
|
63
|
+
info(`Try: systemctl --user enable --now ${SERVICE_NAME}.service`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// `enable --now` can exit 0 while the unit failed to load (e.g. a bad unit
|
|
67
|
+
// file setting) — verify the unit actually came up before claiming ✓.
|
|
68
|
+
// 'active' = running; 'activating' = the expected pre-token restart loop
|
|
69
|
+
// (the bridge exits until the user pastes their connect message, and
|
|
70
|
+
// Restart=always re-launches it). Anything else (inactive/failed) means the
|
|
71
|
+
// unit never loaded.
|
|
72
|
+
const active = run('systemctl', ['--user', 'is-active', `${SERVICE_NAME}.service`]);
|
|
73
|
+
const state = active.stdout.trim();
|
|
74
|
+
if (state !== 'active' && state !== 'activating') {
|
|
75
|
+
warn(`the service did not come up (state: ${state || 'unknown'}).`);
|
|
76
|
+
info(`Inspect: systemctl --user status ${SERVICE_NAME}.service`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
// Linger lets the user service run without an active login session (servers).
|
|
80
|
+
// Best-effort: needs privileges; non-fatal if it fails.
|
|
81
|
+
const linger = run('loginctl', ['enable-linger', userInfo().username]);
|
|
82
|
+
if (linger.ok) {
|
|
83
|
+
info('enabled linger (service survives logout / reboot)');
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
info('note: run `sudo loginctl enable-linger $USER` so the connector survives logout.');
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
export function installDaemon() {
|
|
91
|
+
step('Starting the connector as a background service');
|
|
92
|
+
if (process.platform === 'linux') {
|
|
93
|
+
if (installSystemd()) {
|
|
94
|
+
ok(`connector running as a systemd user service (${SERVICE_NAME})`);
|
|
95
|
+
info(`logs: journalctl --user -u ${SERVICE_NAME} -f`);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
manualHint();
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
// macOS / Windows: manual for now (launchd / Task Scheduler unit is a follow-up).
|
|
102
|
+
manualHint();
|
|
103
|
+
return false;
|
|
104
|
+
}
|
package/dist/validate.js
CHANGED
|
@@ -7,13 +7,10 @@ import { step, ok, warn, info } from './ui.js';
|
|
|
7
7
|
export async function checkGateway(port) {
|
|
8
8
|
step('Checking the local OpenClaw gateway');
|
|
9
9
|
const url = `http://127.0.0.1:${port}/`;
|
|
10
|
+
const ctrl = new AbortController();
|
|
11
|
+
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
10
12
|
try {
|
|
11
|
-
|
|
12
|
-
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
13
|
-
await fetch(url, { signal: ctrl.signal }).catch((e) => {
|
|
14
|
-
throw e;
|
|
15
|
-
});
|
|
16
|
-
clearTimeout(t);
|
|
13
|
+
await fetch(url, { signal: ctrl.signal });
|
|
17
14
|
ok(`gateway reachable on port ${port}`);
|
|
18
15
|
return true;
|
|
19
16
|
}
|
|
@@ -25,4 +22,7 @@ export async function checkGateway(port) {
|
|
|
25
22
|
info('Make sure OpenClaw is running (the agent must be up for trading to work).');
|
|
26
23
|
return false;
|
|
27
24
|
}
|
|
25
|
+
finally {
|
|
26
|
+
clearTimeout(t);
|
|
27
|
+
}
|
|
28
28
|
}
|