@reefclaw/connect 0.1.5 → 0.1.7

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.
Files changed (38) hide show
  1. package/assets/bridge/bridge.d.ts +28 -1
  2. package/assets/bridge/bridge.js +145 -7
  3. package/assets/bridge/utils/skill-version.d.ts +9 -0
  4. package/assets/bridge/utils/skill-version.js +45 -1
  5. package/assets/plugin/ccxt/binance-ban-gate.js +12 -1
  6. package/assets/plugin/ccxt/binance-public.d.ts +15 -0
  7. package/assets/plugin/ccxt/binance-public.js +34 -1
  8. package/assets/plugin/config/agent-config-client.d.ts +22 -0
  9. package/assets/plugin/config/agent-config-client.js +44 -1
  10. package/assets/plugin/config/agent-config-poller.d.ts +8 -1
  11. package/assets/plugin/config/agent-config-poller.js +1 -0
  12. package/assets/plugin/config/entitlement-gate.d.ts +51 -0
  13. package/assets/plugin/config/entitlement-gate.js +137 -0
  14. package/assets/plugin/index.js +54 -23
  15. package/assets/plugin/ingest/pending-entry-metadata.d.ts +31 -9
  16. package/assets/plugin/ingest/pending-entry-metadata.js +70 -16
  17. package/assets/plugin/ingest/position-auto-capture.js +14 -3
  18. package/assets/plugin/ingest/readiness-reporter.d.ts +19 -0
  19. package/assets/plugin/ingest/readiness-reporter.js +142 -0
  20. package/assets/plugin/live/exchange-info-cache.d.ts +3 -1
  21. package/assets/plugin/live/exchange-info-cache.js +17 -2
  22. package/assets/plugin/signals/strategy-adapter.d.ts +35 -2
  23. package/assets/plugin/signals/strategy-adapter.js +87 -10
  24. package/assets/plugin/tools/create-order.js +26 -20
  25. package/assets/shared/index.d.ts +2 -0
  26. package/assets/shared/index.js +1 -0
  27. package/assets/shared/readiness.d.ts +50 -0
  28. package/assets/shared/readiness.js +58 -0
  29. package/assets/shared/signals/strategy-adapter.d.ts +35 -2
  30. package/assets/shared/signals/strategy-adapter.js +87 -10
  31. package/assets/skill/SKILL.md +6 -0
  32. package/dist/cli.js +36 -1
  33. package/dist/validate.js +39 -7
  34. package/package.json +1 -1
  35. package/assets/shared/signals/indicators-extended.d.ts +0 -52
  36. package/assets/shared/signals/indicators-extended.js +0 -284
  37. package/assets/shared/signals/indicators.d.ts +0 -15
  38. package/assets/shared/signals/indicators.js +0 -107
@@ -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
- const info = await exchange.loadMarkets(true); // force refresh
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
@@ -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, ctx, null);
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, ctx, null);
170
+ const { conditions: pass1, condCtx } = evaluateConditions(config.conditions, ectx, null);
94
171
  // Determine direction
95
- const direction = resolveDirection(config.directionRule, ctx, condCtx);
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, ctx, direction);
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, ctx, direction, condCtx);
110
- const stopLevel = computeStop(config.stopRule, ctx, direction, condCtx);
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
- ? ctx.currentPrice - stopLevel
113
- : stopLevel - ctx.currentPrice;
189
+ ? ectx.currentPrice - stopLevel
190
+ : stopLevel - ectx.currentPrice;
114
191
  const targets = config.targetRMultiples.map(rm => direction === 'LONG'
115
- ? ctx.currentPrice + risk * rm
116
- : ctx.currentPrice - risk * rm);
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
- const order = await deps.adapter.createOrder(args.symbol, side, type, args.amount, args.price, metadata);
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
- // ALWAYS stash the metadata first (fixed 2026-07-05). Binance futures
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.put({
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) {
@@ -8,3 +8,5 @@ export type { FillEvent, FillSource } from './fills.js';
8
8
  export { redactTokens, redactTokensInPayload, REDACTED_TOKEN } from './redact.js';
9
9
  export type { Direction, OhlcvBar, TradeFlowBucket, GlobalMarketContext, MarketContext, SignalCondition, StrategyEvaluation, StrategyDefinition, SignalEvent, StrategyState, SignalSnapshot, } from './signals/types.js';
10
10
  export type { ConditionResult, ConditionContext, ConditionFn, ConditionConfig, EntryRuleConfig, StopRuleConfig, DirectionRule, PrimaryTimeframe, StrategyConfig, } from './signals/conditions/types.js';
11
+ export type { ReadinessStatus, ReadinessPhase, ReadinessCheckId, ReadinessCheck, ReadinessReport, } from './readiness.js';
12
+ export { READINESS_CHECK_COPY, makeReadinessCheck, deriveOverallReadiness, } from './readiness.js';
@@ -3,3 +3,4 @@ export { VALID_CHANNELS, VALID_EMERGENCY_ACTIONS } from './protocol.js';
3
3
  export { logger, setLogLevel, formatError } from './logger.js';
4
4
  export { VALID_TRADING_MODES, isTradingMode, validateModeTransition, modeRequiresCredentials, } from './trading-mode.js';
5
5
  export { redactTokens, redactTokensInPayload, REDACTED_TOKEN } from './redact.js';
6
+ export { READINESS_CHECK_COPY, makeReadinessCheck, deriveOverallReadiness, } from './readiness.js';
@@ -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.