@reefclaw/openclaw-plugin 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/bridge/gateway/event-parser.d.ts +6 -1
  2. package/bridge/gateway/event-parser.js +19 -2
  3. package/bridge/gateway/poller.d.ts +1 -0
  4. package/bridge/gateway/poller.js +14 -2
  5. package/bridge/providers/gateway.d.ts +22 -2
  6. package/bridge/providers/gateway.js +67 -9
  7. package/ccxt/public-market-data-api.d.ts +14 -0
  8. package/ccxt/public-market-data-api.js +15 -1
  9. package/config/plugin-config-io.d.ts +7 -0
  10. package/config/plugin-config-io.js +15 -0
  11. package/index.js +116 -31
  12. package/ingest/position-auto-capture.d.ts +68 -0
  13. package/ingest/position-auto-capture.js +321 -23
  14. package/ingest/position-decisions-client.d.ts +7 -2
  15. package/ingest/position-decisions-client.js +13 -3
  16. package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
  17. package/ingest/reconcile-db-vs-exchange.js +66 -10
  18. package/live/fill-price.d.ts +13 -0
  19. package/live/fill-price.js +37 -0
  20. package/live/live-adapter.d.ts +33 -1
  21. package/live/live-adapter.js +176 -47
  22. package/live/position-state-store.d.ts +4 -0
  23. package/live/stop-watcher.d.ts +8 -1
  24. package/live/stop-watcher.js +5 -2
  25. package/onboarding/runtime.d.ts +18 -0
  26. package/onboarding/runtime.js +45 -3
  27. package/openclaw.plugin.json +1 -1
  28. package/package.json +2 -2
  29. package/portfolio/reentry-tracker.d.ts +36 -0
  30. package/portfolio/reentry-tracker.js +127 -0
  31. package/scripts/assemble.mjs +18 -2
  32. package/signals/conditions/registry.js +11 -2
  33. package/signals/strategy-adapter.js +17 -7
  34. package/simulator/exchange-simulator.d.ts +12 -0
  35. package/simulator/exchange-simulator.js +73 -3
  36. package/simulator/types.d.ts +4 -0
  37. package/skills/reefclaw/SKILL.md +2 -0
  38. package/tools/assessment-validation.d.ts +21 -0
  39. package/tools/assessment-validation.js +58 -0
  40. package/tools/attach-brackets.js +165 -0
  41. package/tools/audit-bracket-protection.js +157 -1
  42. package/tools/bracket-control.d.ts +12 -0
  43. package/tools/bracket-control.js +35 -0
  44. package/tools/create-order.d.ts +7 -0
  45. package/tools/create-order.js +42 -3
  46. package/tools/get-setup-detail.js +12 -1
  47. package/tools/modify-stop.js +5 -5
  48. package/tools/modify-target.js +5 -5
  49. package/tools/scan-pairs.d.ts +4 -0
  50. package/tools/scan-pairs.js +4 -1
  51. package/tools/set-trading-mode.js +23 -6
  52. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  53. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  54. package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
  55. package/venues/hyperliquid/hl-live-adapter.js +380 -5
  56. package/venues/hyperliquid/hl-public.js +8 -1
@@ -170,8 +170,13 @@ export interface ParsedEvent<E extends ProviderEventName = ProviderEventName> {
170
170
  export declare class EventParser {
171
171
  /** Buffered assistant text per runId, with timestamps for expiry */
172
172
  private assistantBuffers;
173
- /** Last decision timestamp (set on lifecycle 'start') */
173
+ /** Last decision timestamp (set on lifecycle 'end' of a non-errored run) */
174
174
  private lastDecisionTs;
175
+ /** Runs that emitted a lifecycle 'error' — their 'end' must NOT count as a
176
+ * decision. Issue #194: bumping on 'start' kept "last decision" fresh
177
+ * through an 18h all-models-failed outage, so the dashboard's 2×-interval
178
+ * "unresponsive" rung could never fire. Bounded (oldest evicted). */
179
+ private erroredRuns;
175
180
  /** Error timestamps within the rolling window */
176
181
  private errorTimestamps;
177
182
  /** Symbol for balance currency extraction */
@@ -417,8 +417,13 @@ const BUFFER_INACTIVITY_MS = 120_000; // 120 seconds — matches webapp streamin
417
417
  export class EventParser {
418
418
  /** Buffered assistant text per runId, with timestamps for expiry */
419
419
  assistantBuffers = new Map();
420
- /** Last decision timestamp (set on lifecycle 'start') */
420
+ /** Last decision timestamp (set on lifecycle 'end' of a non-errored run) */
421
421
  lastDecisionTs = null;
422
+ /** Runs that emitted a lifecycle 'error' — their 'end' must NOT count as a
423
+ * decision. Issue #194: bumping on 'start' kept "last decision" fresh
424
+ * through an 18h all-models-failed outage, so the dashboard's 2×-interval
425
+ * "unresponsive" rung could never fire. Bounded (oldest evicted). */
426
+ erroredRuns = new Set();
422
427
  /** Error timestamps within the rolling window */
423
428
  errorTimestamps = [];
424
429
  /** Symbol for balance currency extraction */
@@ -465,10 +470,15 @@ export class EventParser {
465
470
  }
466
471
  switch (phase) {
467
472
  case 'start':
468
- this.lastDecisionTs = new Date().toISOString();
469
473
  logger.debug(TAG, `Agent turn started: ${runId}`);
470
474
  return [];
471
475
  case 'end': {
476
+ // A decision = a turn that ENDED without erroring. Bumping on 'start'
477
+ // (pre-#194) kept the timestamp fresh while every run failed, hiding
478
+ // an all-models-failed outage from the unresponsive heuristics.
479
+ if (!this.erroredRuns.delete(runId)) {
480
+ this.lastDecisionTs = new Date().toISOString();
481
+ }
472
482
  logger.debug(TAG, `Agent turn ended: ${runId}`);
473
483
  return this.flushAssistantBuffer(runId);
474
484
  }
@@ -476,6 +486,13 @@ export class EventParser {
476
486
  const errMsg = data.error ?? 'Unknown agent error';
477
487
  logger.warn(TAG, `Agent error (run ${runId}): ${errMsg}`);
478
488
  this.errorTimestamps.push(Date.now());
489
+ this.erroredRuns.add(runId);
490
+ // Bound the set: an errored run whose 'end' never arrives would leak.
491
+ if (this.erroredRuns.size > 100) {
492
+ const oldest = this.erroredRuns.values().next().value;
493
+ if (oldest !== undefined)
494
+ this.erroredRuns.delete(oldest);
495
+ }
479
496
  // Still flush the buffer — partial responses are better than nothing
480
497
  return this.flushAssistantBuffer(runId);
481
498
  }
@@ -4,6 +4,7 @@ import type { TickerData, CandleData, OrderData, MarketStructureData, CryptoMetr
4
4
  export declare const TICKER_INTERVAL_MS = 5000;
5
5
  export declare const OHLCV_INTERVAL_MS = 15000;
6
6
  export declare const POSITIONS_INTERVAL_MS = 10000;
7
+ export declare const OPEN_ORDERS_INTERVAL_MS = 60000;
7
8
  export declare const MARKET_STRUCTURE_INTERVAL_MS = 60000;
8
9
  /** CCXT position — minimal shape needed by poller */
9
10
  export interface CcxtPosition {
@@ -25,7 +25,17 @@ export const OHLCV_INTERVAL_MS = 15_000;
25
25
  // startup handshake is extra live-system surface area for no extra weight.
26
26
  export const POSITIONS_INTERVAL_MS = 10_000;
27
27
  const BALANCE_INTERVAL_MS = 10_000;
28
- const OPEN_ORDERS_INTERVAL_MS = 3_000;
28
+ // 2026-07-21: 3s → 60s, and the poll is now UNSCOPED (no symbol filter). Its
29
+ // only consumer is the fallback cache behind Kill and the reconcile snapshot
30
+ // (no event emission — live order updates ride the WS stream), and both of
31
+ // those are portfolio-wide by contract: a { symbol }-scoped cache silently
32
+ // degraded them to one symbol whenever the fresh all-symbol fetch failed.
33
+ // Unscoped fetchOpenOrders costs Binance weight 80 (regular + algo merged) vs
34
+ // 2 scoped, so the cadence drops to 60s (80/min, vs 40/min before; the old 3s
35
+ // cadence unscoped would be 1600/min ≈ the whole ceiling). Matches the
36
+ // plugin's own 60s algo-refresh; in userDataStream enforce mode the read is
37
+ // served from the WS store at zero REST weight anyway.
38
+ export const OPEN_ORDERS_INTERVAL_MS = 60_000;
29
39
  // 2026-05-15: 30s → 60s. get_market_structure fetches SIX timeframes of
30
40
  // OHLCV per poll (the heaviest single skill poll). Multi-timeframe trend
31
41
  // structure does not meaningfully change in 30s; 60s keeps the Agent-State
@@ -357,7 +367,9 @@ export class Poller {
357
367
  }
358
368
  async pollOpenOrders() {
359
369
  const tool = this.toolMap.fetch_open_orders;
360
- const result = await this.http.invoke(tool, { symbol: this.symbol });
370
+ // Fetch ALL open orders (no symbol filter) — this cache is the fallback
371
+ // for the portfolio-wide Kill + reconcile snapshot (see interval comment).
372
+ const result = await this.http.invoke(tool, {});
361
373
  const orders = [];
362
374
  if (Array.isArray(result.data)) {
363
375
  for (const ccxt of result.data) {
@@ -61,6 +61,18 @@ export declare class GatewayProvider implements OpenClawProvider {
61
61
  /** Track whether we've received at least one balance poll (needed for equity calculation). */
62
62
  private hasReceivedBalance;
63
63
  private readonly quoteCurrency;
64
+ /** issue #213 RED-zone debounce: consecutive RED evaluations + when the
65
+ * streak began + when the position snapshot last refreshed. A RED verdict
66
+ * must PERSIST and SURVIVE a positions refresh before auto-flatten fires —
67
+ * in paper mode the wallet drops by FULL NOTIONAL at open, so a balance
68
+ * poll landing before the new position reaches the snapshot reads the
69
+ * entry's own notional as a phantom drawdown (observed live: a 38%-of-NAV
70
+ * entry reported as a -38.41% RED zone 2s after its fill and was
71
+ * auto-flattened). A race artifact cannot outlive one positions poll; a
72
+ * real drawdown easily persists. */
73
+ private redZoneStreak;
74
+ private redZoneSince;
75
+ private positionsUpdatedAt;
64
76
  private orderTimestamps;
65
77
  private cancelTimestamps;
66
78
  private agentStateInterval;
@@ -301,8 +313,16 @@ export declare class GatewayProvider implements OpenClawProvider {
301
313
  */
302
314
  private reportTradeResult;
303
315
  /**
304
- * Seed lastTradeTs from Intelligence API on startup. One-shot, best-effort.
305
- * Prevents "Last Trade: Never" after every restart when the agent is flat.
316
+ * Seed lastTradeTs from the webapp's exchange-keyed fills ledger on startup.
317
+ * One-shot, best-effort. Prevents "Last Trade: Never" after every restart
318
+ * when the agent is flat.
319
+ *
320
+ * ★ Issue #194: the previous seed read intel's `trade_results` (closed
321
+ * round-trips only, single-symbol), which showed "last trade 365h ago"
322
+ * while the fills ledger had fills from the previous evening. The ledger
323
+ * (`GET /api/internal/trades`) is the source of truth for fills — seed from
324
+ * it, filtered to the current trading book. On any failure leave null:
325
+ * truthful-unknown beats confidently-wrong.
306
326
  */
307
327
  private seedLastTradeTs;
308
328
  /** Symbol formatted for intelligence API (BTC/USDT → BTCUSDT). */
@@ -154,6 +154,18 @@ export class GatewayProvider {
154
154
  /** Track whether we've received at least one balance poll (needed for equity calculation). */
155
155
  hasReceivedBalance = false;
156
156
  quoteCurrency;
157
+ /** issue #213 RED-zone debounce: consecutive RED evaluations + when the
158
+ * streak began + when the position snapshot last refreshed. A RED verdict
159
+ * must PERSIST and SURVIVE a positions refresh before auto-flatten fires —
160
+ * in paper mode the wallet drops by FULL NOTIONAL at open, so a balance
161
+ * poll landing before the new position reaches the snapshot reads the
162
+ * entry's own notional as a phantom drawdown (observed live: a 38%-of-NAV
163
+ * entry reported as a -38.41% RED zone 2s after its fill and was
164
+ * auto-flattened). A race artifact cannot outlive one positions poll; a
165
+ * real drawdown easily persists. */
166
+ redZoneStreak = 0;
167
+ redZoneSince = 0;
168
+ positionsUpdatedAt = 0;
157
169
  // ---- Rate tracking ----
158
170
  orderTimestamps = [];
159
171
  cancelTimestamps = [];
@@ -658,6 +670,7 @@ export class GatewayProvider {
658
670
  }
659
671
  // Update internal state with fresh data (poller may be paused during reconnect)
660
672
  this.positions = positions;
673
+ this.positionsUpdatedAt = Date.now();
661
674
  this.balance = balance;
662
675
  this.openOrders = openOrders;
663
676
  // Compute current NAV for portfolio percent calculation (consistent with computeRiskMetrics)
@@ -1201,6 +1214,7 @@ export class GatewayProvider {
1201
1214
  }
1202
1215
  }
1203
1216
  this.positions = positions;
1217
+ this.positionsUpdatedAt = Date.now();
1204
1218
  this.hasReceivedPositions = true;
1205
1219
  this.trySetSessionStartNav();
1206
1220
  this.emitRiskUpdate();
@@ -1890,10 +1904,32 @@ export class GatewayProvider {
1890
1904
  // physically impossible under normal market conditions on a bracketed
1891
1905
  // account. If the math says worse than that, it's bad data — log loudly
1892
1906
  // and skip instead of closing real positions on a calculation error.
1907
+ // 4. Persistence debounce (issue #213): the balance poll and the position
1908
+ // snapshot are not atomic. In PAPER mode the simulator deducts FULL
1909
+ // NOTIONAL from the wallet at open, so in the seconds after a fill the
1910
+ // wallet already reflects the deduction while `this.positions` does not
1911
+ // yet contain the new position — equity spuriously drops by exactly the
1912
+ // entry's notional and any entry >2.5% of NAV reads as a RED drawdown
1913
+ // (observed live: -38.41% reported 2s after a 38%-of-NAV entry; the
1914
+ // just-opened position was auto-flattened). A RED verdict therefore
1915
+ // only licenses the flatten once it (a) persists across REQUIRED_STREAK
1916
+ // consecutive evaluations, (b) has been standing MIN_RED_PERSISTENCE_MS,
1917
+ // and (c) has SURVIVED at least one positions refresh since it began —
1918
+ // the race artifact cannot outlive one positions poll (~10s), while a
1919
+ // real -2.5% drawdown trivially persists 25s. Cost on a real event:
1920
+ // the flatten fires ~25-35s later. Cost of a false one: a closed
1921
+ // position, a rejected-orders window, and a corrupted trading record.
1893
1922
  const uptimeMs = Date.now() - this.startedAt;
1894
1923
  const STARTUP_GRACE_PERIOD_MS = 30_000;
1895
1924
  const IMPOSSIBLE_DRAWDOWN = -0.5;
1925
+ const REQUIRED_RED_STREAK = 3;
1926
+ const MIN_RED_PERSISTENCE_MS = 25_000;
1896
1927
  if (metrics.drawdownZone === 'RED' && this.agentMode === 'ACTIVE') {
1928
+ if (this.redZoneStreak === 0)
1929
+ this.redZoneSince = Date.now();
1930
+ this.redZoneStreak++;
1931
+ const redForMs = Date.now() - this.redZoneSince;
1932
+ const survivedPositionsRefresh = this.positionsUpdatedAt > this.redZoneSince;
1897
1933
  if (uptimeMs < STARTUP_GRACE_PERIOD_MS) {
1898
1934
  logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but within startup grace period (${Math.round(uptimeMs / 1000)}s) — skipping auto-flatten`);
1899
1935
  }
@@ -1903,6 +1939,11 @@ export class GatewayProvider {
1903
1939
  else if (metrics.utilization.dailyDrawdown < IMPOSSIBLE_DRAWDOWN) {
1904
1940
  logger.error(TAG, `RED zone drawdown ${(metrics.utilization.dailyDrawdown * 100).toFixed(1)}% exceeds sanity floor ${IMPOSSIBLE_DRAWDOWN * 100}% — refusing auto-flatten, likely equity calculation error (balance.total=${this.balance.total}, sessionStartNav=${this.sessionStartNav}, positions=${this.positions.length})`);
1905
1941
  }
1942
+ else if (this.redZoneStreak < REQUIRED_RED_STREAK ||
1943
+ redForMs < MIN_RED_PERSISTENCE_MS ||
1944
+ !survivedPositionsRefresh) {
1945
+ logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — deferring auto-flatten until the verdict persists (streak ${this.redZoneStreak}/${REQUIRED_RED_STREAK}, ${Math.round(redForMs / 1000)}s/${MIN_RED_PERSISTENCE_MS / 1000}s, survivedPositionsRefresh=${survivedPositionsRefresh}). A fresh entry's notional reads as phantom drawdown until the position snapshot catches up (issue #213); a real drawdown will persist and flatten on a later evaluation.`);
1946
+ }
1906
1947
  else {
1907
1948
  logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — auto-flattening all positions`);
1908
1949
  // Route through executeEmergency (NOT executeFlatten directly) so the
@@ -1917,6 +1958,11 @@ export class GatewayProvider {
1917
1958
  });
1918
1959
  }
1919
1960
  }
1961
+ else {
1962
+ // Any non-RED evaluation clears the streak — the race artifact resolves
1963
+ // the moment the positions snapshot includes the new position.
1964
+ this.redZoneStreak = 0;
1965
+ }
1920
1966
  // Include position snapshots + balance in risk_update so the webapp can
1921
1967
  // update KPI/PnL without depending on market_data ticker events.
1922
1968
  const equity = this.computeEquity();
@@ -2097,27 +2143,39 @@ export class GatewayProvider {
2097
2143
  this.inFlightReports.push(report);
2098
2144
  }
2099
2145
  /**
2100
- * Seed lastTradeTs from Intelligence API on startup. One-shot, best-effort.
2101
- * Prevents "Last Trade: Never" after every restart when the agent is flat.
2146
+ * Seed lastTradeTs from the webapp's exchange-keyed fills ledger on startup.
2147
+ * One-shot, best-effort. Prevents "Last Trade: Never" after every restart
2148
+ * when the agent is flat.
2149
+ *
2150
+ * ★ Issue #194: the previous seed read intel's `trade_results` (closed
2151
+ * round-trips only, single-symbol), which showed "last trade 365h ago"
2152
+ * while the fills ledger had fills from the previous evening. The ledger
2153
+ * (`GET /api/internal/trades`) is the source of truth for fills — seed from
2154
+ * it, filtered to the current trading book. On any failure leave null:
2155
+ * truthful-unknown beats confidently-wrong.
2102
2156
  */
2103
2157
  async seedLastTradeTs() {
2104
2158
  if (this.lastTradeTs)
2105
2159
  return; // Already set (e.g. from WS event during init)
2106
- const url = this.config.intelligenceUrl;
2107
2160
  const token = this.config.connectionToken;
2108
- if (!url || !token)
2161
+ if (!token)
2109
2162
  return;
2163
+ // www is load-bearing: reefclaw.com 307-redirects and Node fetch strips
2164
+ // the Authorization header on cross-origin redirect (same as bridge.ts).
2165
+ const base = (process.env.REEFCLAW_API_URL || 'https://www.reefclaw.com').replace(/\/$/, '');
2166
+ const mode = this.tradingMode === 'PAPER' ? 'paper' : 'live';
2110
2167
  try {
2111
- const res = await fetch(`${url}/api/analytics/${this.config.symbol.replace('/', '')}/trades?limit=1`, {
2168
+ const res = await fetch(`${base}/api/internal/trades?limit=1&mode=${mode}`, {
2112
2169
  headers: { Authorization: `Bearer ${token}` },
2113
2170
  signal: AbortSignal.timeout(5_000),
2114
2171
  });
2115
2172
  if (!res.ok)
2116
2173
  return;
2117
- const trades = await res.json();
2118
- if (trades.length > 0 && trades[0].timestamp) {
2119
- this.lastTradeTs = trades[0].timestamp;
2120
- logger.info(TAG, `Seeded lastTradeTs from Intelligence: ${this.lastTradeTs}`);
2174
+ const body = (await res.json());
2175
+ const ts = body.trades?.[0]?.fillTimestamp;
2176
+ if (ts) {
2177
+ this.lastTradeTs = ts;
2178
+ logger.info(TAG, `Seeded lastTradeTs from trades ledger (${mode}): ${this.lastTradeTs}`);
2121
2179
  this.emitAgentState(); // Re-emit with updated timestamp
2122
2180
  }
2123
2181
  }
@@ -9,4 +9,18 @@ export interface PublicMarketDataApi {
9
9
  fetchOrderBook(symbol: string, limit?: number): Promise<OrderBookDepth | null>;
10
10
  fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
11
11
  }
12
+ /** Which exchange serves the public market-data tools (fetch_ticker,
13
+ * fetch_ohlcv, get_orderbook, get_market_structure, …).
14
+ *
15
+ * The configured VENUE decides, in BOTH books — a hyperliquid venue must
16
+ * never read Binance prices (live included: the agent would be running
17
+ * technical analysis on the wrong exchange's data while trading real money
18
+ * on HL). The intel price relay is a Binance-venue, paper-only escape hatch
19
+ * for 451-geo-blocked hosts. */
20
+ export declare function resolveMarketDataSource(opts: {
21
+ venue: 'binance' | 'hyperliquid';
22
+ isLive: boolean;
23
+ paperDataSource: 'binance' | 'intel';
24
+ hasConnectionToken: boolean;
25
+ }): 'hyperliquid' | 'intel' | 'binance';
12
26
  export {};
@@ -6,4 +6,18 @@
6
6
  // Deliberately EXCLUDES probeReachability: that is Binance-specific and only
7
7
  // the readiness reporter uses it — it must always probe the REAL Binance host
8
8
  // to detect the 451, so it keeps a concrete BinancePublicApi, never this.
9
- export {};
9
+ /** Which exchange serves the public market-data tools (fetch_ticker,
10
+ * fetch_ohlcv, get_orderbook, get_market_structure, …).
11
+ *
12
+ * The configured VENUE decides, in BOTH books — a hyperliquid venue must
13
+ * never read Binance prices (live included: the agent would be running
14
+ * technical analysis on the wrong exchange's data while trading real money
15
+ * on HL). The intel price relay is a Binance-venue, paper-only escape hatch
16
+ * for 451-geo-blocked hosts. */
17
+ export function resolveMarketDataSource(opts) {
18
+ if (opts.venue === 'hyperliquid')
19
+ return 'hyperliquid';
20
+ if (!opts.isLive && opts.paperDataSource === 'intel' && opts.hasConnectionToken)
21
+ return 'intel';
22
+ return 'binance';
23
+ }
@@ -195,6 +195,13 @@ export interface PluginConfigFile {
195
195
  [extra: string]: unknown;
196
196
  }
197
197
  export declare function defaultConfigPath(): string;
198
+ /** Best-effort read of the operator's stop-watcher cadence override
199
+ * (`stopWatcher.intervalMs`). Undefined (default applies) unless the config
200
+ * holds a finite positive number. Read at every watcher CONSTRUCTION — boot
201
+ * AND runtime reconnects — so a mode/credential swap can't silently revert
202
+ * the override to the default (same read-at-build-time pattern as
203
+ * loadBracketMode). */
204
+ export declare function loadStopWatcherIntervalMs(path?: string): number | undefined;
198
205
  /** Read the config file. Returns `{}` if the file doesn't exist.
199
206
  * Throws if the file exists but is unreadable or not valid JSON — callers
200
207
  * should treat that as an abort signal, not silently overwrite. */
@@ -38,6 +38,21 @@ export function readOpenClawConnection() {
38
38
  export function defaultConfigPath() {
39
39
  return join(homedir(), '.reefclaw', 'plugin-config.json');
40
40
  }
41
+ /** Best-effort read of the operator's stop-watcher cadence override
42
+ * (`stopWatcher.intervalMs`). Undefined (default applies) unless the config
43
+ * holds a finite positive number. Read at every watcher CONSTRUCTION — boot
44
+ * AND runtime reconnects — so a mode/credential swap can't silently revert
45
+ * the override to the default (same read-at-build-time pattern as
46
+ * loadBracketMode). */
47
+ export function loadStopWatcherIntervalMs(path) {
48
+ try {
49
+ const swMs = readPluginConfig(path).stopWatcher?.intervalMs;
50
+ if (typeof swMs === 'number' && Number.isFinite(swMs) && swMs > 0)
51
+ return swMs;
52
+ }
53
+ catch { /* best-effort — default applies */ }
54
+ return undefined;
55
+ }
41
56
  /** Read the config file. Returns `{}` if the file doesn't exist.
42
57
  * Throws if the file exists but is unreadable or not valid JSON — callers
43
58
  * should treat that as an abort signal, not silently overwrite. */
package/index.js CHANGED
@@ -12,6 +12,7 @@ import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { BinancePublicApi } from './ccxt/binance-public.js';
14
14
  import { IntelPublicApi } from './ccxt/intel-public.js';
15
+ import { resolveMarketDataSource } from './ccxt/public-market-data-api.js';
15
16
  import { BinancePrivateApi } from './ccxt/binance-private.js';
16
17
  import { ExchangeSimulator } from './simulator/exchange-simulator.js';
17
18
  import { ShadowTracker } from './shadow/shadow-tracker.js';
@@ -31,7 +32,9 @@ import { PositionDecisionsClient } from './ingest/position-decisions-client.js';
31
32
  import { PositionStateStore } from './live/position-state-store.js';
32
33
  import { PendingEntryStore } from './ingest/pending-entry-metadata.js';
33
34
  import { onReconcilerObservedClose, reconcileStateStoreOnStartup } from './ingest/reconciler-cleanup.js';
34
- import { reconcileDbOpenVsExchange } from './ingest/reconcile-db-vs-exchange.js';
35
+ import { reconcileDbOpenVsExchange, startPeriodicDbReconcile } from './ingest/reconcile-db-vs-exchange.js';
36
+ import { onStopWatcherClose } from './ingest/position-auto-capture.js';
37
+ import { ReentryTracker } from './portfolio/reentry-tracker.js';
35
38
  import { startReadinessReporter } from './ingest/readiness-reporter.js';
36
39
  import { IntelMicrostructureAssembler } from './live/microstructure-assembler.js';
37
40
  import { recordPositionReviewsTool } from './tools/record-position-reviews.js';
@@ -44,7 +47,7 @@ import { queryReviewOutcomesTool } from './tools/query-review-outcomes.js';
44
47
  import { loadPositionReviewMode } from './config/position-review-config.js';
45
48
  import { installSignalHandlers } from './lifecycle/install-signal-handlers.js';
46
49
  import { SerialTradingOperationLock } from './lifecycle/trading-operation-lock.js';
47
- import { readPluginConfig, readOpenClawConnection } from './config/plugin-config-io.js';
50
+ import { readPluginConfig, readOpenClawConnection, loadStopWatcherIntervalMs } from './config/plugin-config-io.js';
48
51
  import { startConnectorSupervisor, hasBundledBridge } from './connector-supervisor.js';
49
52
  import { ToolGate } from './config/tool-gate.js';
50
53
  import { gateStore } from './config/gate-store.js';
@@ -1135,6 +1138,9 @@ const paperTradingPlugin = {
1135
1138
  // position doesn't leak as status='open' forever (close-bypass class). On by
1136
1139
  // default; env kill-switch RC_JOURNAL_CLOSE_ON_FILL=off disables it.
1137
1140
  const closeOnReduceOnlyFill = process.env.RC_JOURNAL_CLOSE_ON_FILL !== 'off';
1141
+ // Re-entry tracker (issue #204): every close path records the exit so
1142
+ // scan_pairs can flag setups already traded within the current signal bar.
1143
+ const reentryTracker = new ReentryTracker();
1138
1144
  const autoCapture = {
1139
1145
  decisionsClient: positionDecisionsClient,
1140
1146
  stateStore: positionStateStore,
@@ -1146,6 +1152,10 @@ const paperTradingPlugin = {
1146
1152
  // closure — only invoked on a fill, long after `runtime` is built), so it
1147
1153
  // follows a live<->paper reconnect.
1148
1154
  resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1155
+ // Active adapter at capture time (deferred closure, same as resolveMode).
1156
+ // Drives the stale-state defense in onCreateOrderFilled (issue #199).
1157
+ resolveAdapter: () => runtime.adapter,
1158
+ reentryTracker,
1149
1159
  // Venue tag (positions.exchange, migration 0058) — static per process;
1150
1160
  // a venue change requires config edit + restart.
1151
1161
  venue,
@@ -1171,9 +1181,16 @@ const paperTradingPlugin = {
1171
1181
  // ingest credentials. See docs/APPROVAL_MODE_DESIGN.md §2 + §7.3.
1172
1182
  let proposalDecisionListener;
1173
1183
  if (tradingMode === 'MICRO_LIVE' || tradingMode === 'LIVE') {
1174
- if (!exchangeConfig) {
1184
+ // Per-VENUE credential presence (rehearsal find #4): this safety check
1185
+ // predated the venue seam and tested only the Binance shape, so a
1186
+ // PERSISTED HL MICRO_LIVE (tradingMode written by the runtime flip)
1187
+ // silently fell back to PAPER on every reboot — while the same config
1188
+ // flipped live fine at runtime. The per-venue gate above is the real
1189
+ // validation; this remains the defense-in-depth backstop.
1190
+ const liveCredsPresent = venue === 'hyperliquid' ? hlCredentials !== null : exchangeConfig !== null;
1191
+ if (!liveCredsPresent) {
1175
1192
  // Already handled above (falls back to PAPER), but safety check
1176
- logger.error(TAG, `${tradingMode} mode requires exchange config — this should not happen`);
1193
+ logger.error(TAG, `${tradingMode} mode requires ${venue} exchange credentials — this should not happen`);
1177
1194
  adapter = new PaperAdapter(simulator);
1178
1195
  tradingMode = 'PAPER';
1179
1196
  }
@@ -1391,7 +1408,17 @@ const paperTradingPlugin = {
1391
1408
  // are status='open' but absent from the exchange (closed on-exchange
1392
1409
  // while down / state-store wiped). Same trusted-snapshot-only rule —
1393
1410
  // a null fetch must never be read as "flat" and close the book.
1394
- await reconcileDbOpenVsExchange({ decisionsClient: positionDecisionsClient, userId: positionDecisionsUserId }, trusted.map((p) => p.symbol));
1411
+ await reconcileDbOpenVsExchange({
1412
+ decisionsClient: positionDecisionsClient,
1413
+ userId: positionDecisionsUserId,
1414
+ // This boot pass only runs in the live-adapter init path and
1415
+ // diffs against the LIVE snapshot — scope the DB read to the
1416
+ // live book so open paper rows aren't misread as orphans.
1417
+ resolveMode: () => 'live',
1418
+ // …and to the ACTIVE venue (issue #209 item 5): the other
1419
+ // venue's open rows can never appear in this snapshot.
1420
+ resolveExchange: () => venue,
1421
+ }, trusted.map((p) => p.symbol));
1395
1422
  }
1396
1423
  else {
1397
1424
  logger.warn(TAG, 'Startup reconciliation skipped — positions fetch untrusted (null); state-store left untouched. Runtime drift_detected + periodic reconcilers cover the gap.');
@@ -1408,27 +1435,30 @@ const paperTradingPlugin = {
1408
1435
  else {
1409
1436
  adapter = new PaperAdapter(simulator);
1410
1437
  }
1411
- // PAPER market-data source selection.
1438
+ // Public market-data source selection.
1412
1439
  //
1413
1440
  // Venue precedence (Hyperliquid Phase 1 — docs/HYPERLIQUID_INTEGRATION_PLAN.md):
1414
- // the configured VENUE decides which exchange prices paper mode, full stop.
1415
- // venue='hyperliquid' → every public read (PaperMarketFeed marks, the
1416
- // simulator's fill prices, fetch_ticker/fetch_ohlcv/get_orderbook tools)
1417
- // comes from Hyperliquid's keyless /info endpoints; the Binance-only
1441
+ // the configured VENUE decides which exchange serves public reads, full
1442
+ // stop, in BOTH books. venue='hyperliquid' → every public read
1443
+ // (PaperMarketFeed marks, the simulator's fill prices,
1444
+ // fetch_ticker/fetch_ohlcv/get_orderbook tools paper AND live) comes
1445
+ // from Hyperliquid's keyless /info endpoints; the Binance-only
1418
1446
  // `paperMarketDataSource:'intel'` escape hatch does not apply (intel has
1419
1447
  // no HL rows until Phase 2 — silently serving Binance prices for a
1420
1448
  // Hyperliquid book would be a lie, the exact class the symbol-translation
1421
- // rule forbids).
1449
+ // rule forbids). Pre-fix this was gated on !adapter.isLive, so a LIVE
1450
+ // boot on venue=hyperliquid fell through to Binance for all six
1451
+ // market-data tools while executing on HL.
1422
1452
  //
1423
1453
  // Binance venue (default) is unchanged: a host Binance geo-blocks (HTTP
1424
- // 451) can route PRICE reads through the intel service by setting
1454
+ // 451) can route PAPER price reads through the intel service by setting
1425
1455
  // plugin-config `paperMarketDataSource:'intel'` (GET /api/price/:symbol).
1426
- // LIVE always uses the real exchange, and the readiness reporter probes
1427
- // the CONFIGURED venue. Kill-switch RC_PAPER_MARKET_DATA=binance forces
1428
- // the Binance-direct default (binance venue only).
1456
+ // The readiness reporter probes the CONFIGURED venue. Kill-switch
1457
+ // RC_PAPER_MARKET_DATA=binance forces the Binance-direct default
1458
+ // (binance venue only).
1429
1459
  //
1430
1460
  // hlPublicApi is constructed once per process when the venue is
1431
- // hyperliquid — shared by the paper data path and the readiness probe.
1461
+ // hyperliquid — shared by the market-data path and the readiness probe.
1432
1462
  const hlPublicApi = venue === 'hyperliquid' ? new HyperliquidPublicApi({ testnet: venueTestnet }) : null;
1433
1463
  let configuredPaperSource;
1434
1464
  try {
@@ -1440,20 +1470,24 @@ const paperTradingPlugin = {
1440
1470
  const paperDataSource = process.env.RC_PAPER_MARKET_DATA === 'binance'
1441
1471
  ? 'binance'
1442
1472
  : configuredPaperSource ?? 'binance';
1443
- const useIntelPaperData = venue === 'binance' &&
1444
- !adapter.isLive && paperDataSource === 'intel' && connectionToken.length > 0;
1445
- const marketDataApi = !adapter.isLive && hlPublicApi
1473
+ const marketDataSource = resolveMarketDataSource({
1474
+ venue,
1475
+ isLive: adapter.isLive,
1476
+ paperDataSource,
1477
+ hasConnectionToken: connectionToken.length > 0,
1478
+ });
1479
+ const marketDataApi = marketDataSource === 'hyperliquid' && hlPublicApi
1446
1480
  ? hlPublicApi
1447
- : useIntelPaperData
1481
+ : marketDataSource === 'intel'
1448
1482
  ? new IntelPublicApi({ connectionToken, intelligenceUrl })
1449
1483
  : binanceApi;
1450
- if (!adapter.isLive && hlPublicApi) {
1451
- logger.info(TAG, `PAPER market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1484
+ if (marketDataSource === 'hyperliquid') {
1485
+ logger.info(TAG, `${adapter.isLive ? 'LIVE' : 'PAPER'} market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1452
1486
  if (paperDataSource === 'intel') {
1453
1487
  logger.warn(TAG, 'paperMarketDataSource=intel is a Binance-venue option — ignored on venue=hyperliquid (intel has no Hyperliquid rows until Phase 2)');
1454
1488
  }
1455
1489
  }
1456
- else if (useIntelPaperData) {
1490
+ else if (marketDataSource === 'intel') {
1457
1491
  logger.warn(TAG, 'PAPER market data routed through intel (paperMarketDataSource=intel) — prices from GET /api/price; live + readiness still use Binance directly');
1458
1492
  }
1459
1493
  else if (venue === 'binance' && !adapter.isLive && paperDataSource === 'intel' && connectionToken.length === 0) {
@@ -1479,15 +1513,10 @@ const paperTradingPlugin = {
1479
1513
  // see stop-watcher.ts. Operators can override / revert with zero deploy
1480
1514
  // via plugin-config.json `stopWatcher.intervalMs`. Best-effort read;
1481
1515
  // any failure falls back to the (already-reduced) default.
1482
- let stopWatcherIntervalMs;
1483
- try {
1484
- const swMs = readPluginConfig().stopWatcher?.intervalMs;
1485
- if (typeof swMs === 'number' && Number.isFinite(swMs) && swMs > 0) {
1486
- stopWatcherIntervalMs = swMs;
1487
- logger.info(TAG, `Stop-watcher interval overridden via plugin-config: ${swMs}ms`);
1488
- }
1516
+ const stopWatcherIntervalMs = loadStopWatcherIntervalMs();
1517
+ if (stopWatcherIntervalMs !== undefined) {
1518
+ logger.info(TAG, `Stop-watcher interval overridden via plugin-config: ${stopWatcherIntervalMs}ms`);
1489
1519
  }
1490
- catch { /* best-effort — default applies */ }
1491
1520
  const wave9OperationLock = new SerialTradingOperationLock();
1492
1521
  const bootstrapWatcher = stopWatcherIntervalMs !== undefined
1493
1522
  ? new PositionWatcher(adapter, stopWatcherIntervalMs, wave9OperationLock)
@@ -1502,6 +1531,23 @@ const paperTradingPlugin = {
1502
1531
  if (!adapter.isLive) {
1503
1532
  marketFeed.start();
1504
1533
  }
1534
+ // Journal capture for stop-watcher auto-closes (issue #199). Wired onto
1535
+ // EVERY watcher — the bootstrap one here, reconnect-created ones via the
1536
+ // runtime's onWatcherCreated hook — so a watcher close always journals its
1537
+ // close and drops the state-store mapping (no more phantom positions).
1538
+ const wireWatcherJournalCapture = (watcher) => {
1539
+ watcher.on('stop_closed', (ev) => {
1540
+ onStopWatcherClose(autoCapture, {
1541
+ symbol: ev.symbol,
1542
+ stopPrice: ev.stopPrice,
1543
+ markPrice: ev.markPrice,
1544
+ order: ev.order,
1545
+ }).catch((err) => {
1546
+ logger.warn(TAG, `stop-watcher close capture failed: ${formatError(err)}`);
1547
+ });
1548
+ });
1549
+ };
1550
+ wireWatcherJournalCapture(bootstrapWatcher);
1505
1551
  const runtime = new PluginRuntime({
1506
1552
  adapter,
1507
1553
  mode: tradingMode,
@@ -1509,7 +1555,45 @@ const paperTradingPlugin = {
1509
1555
  stopWatcher: bootstrapWatcher,
1510
1556
  marketFeed,
1511
1557
  operationLock: wave9OperationLock,
1558
+ onWatcherCreated: wireWatcherJournalCapture,
1512
1559
  });
1560
+ // Periodic DB-vs-exchange sweep (issues #199/#203): the boot-only reconcile
1561
+ // left phantom-open journal rows alive for days between restarts. Runs for
1562
+ // BOTH books (the paper book previously had NO reconcile at all); trusted-
1563
+ // snapshot discipline preserved (null → skip). First pass shortly after
1564
+ // boot so restart-deploys heal existing phantoms immediately.
1565
+ const dbReconcileSweep = startPeriodicDbReconcile({
1566
+ decisionsClient: positionDecisionsClient,
1567
+ userId: positionDecisionsUserId,
1568
+ resolveAdapter: () => runtime.adapter,
1569
+ // Scope each tick's DB read to the ACTIVE book (deferred closure —
1570
+ // follows a set_trading_mode reconnect). Without this, the sweep
1571
+ // misreads the inactive book's open rows as orphans and synthetically
1572
+ // closes them (they can never appear in this adapter's snapshot).
1573
+ resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1574
+ // Venue axis (issue #209 item 5): scope to the configured venue so a
1575
+ // two-venue tenant's other-venue rows are never misread as orphans.
1576
+ resolveExchange: () => venue,
1577
+ describeLastExit: (symbol) => {
1578
+ const a = runtime.adapter;
1579
+ if (a instanceof PaperAdapter) {
1580
+ const canon = symbol.split(':')[0];
1581
+ const th = a.getSimulator().getState().tradeHistory;
1582
+ for (let i = th.length - 1; i >= 0; i--) {
1583
+ if (th[i].symbol.split(':')[0] === canon) {
1584
+ const t = th[i];
1585
+ return `paper engine last trade: close ${t.side} ${t.quantity} @ ${t.exitPrice} at ${t.closedAt} (pnl ${t.realizedPnl.toFixed(4)})`;
1586
+ }
1587
+ }
1588
+ return 'paper engine has NO trade record for this symbol';
1589
+ }
1590
+ return undefined;
1591
+ },
1592
+ });
1593
+ if (dbReconcileSweep) {
1594
+ const bootSweep = setTimeout(() => { void dbReconcileSweep.runOnce(); }, 15_000);
1595
+ bootSweep.unref?.();
1596
+ }
1513
1597
  const intelDeps = { connectionToken, apiBaseUrl };
1514
1598
  // venue rides the deps so every intel tool maps agent-facing symbols to
1515
1599
  // the venue's intel namespace (plan §5.3) — absent/'binance' is
@@ -2426,6 +2510,7 @@ const paperTradingPlugin = {
2426
2510
  execute: async (_id, params) => jsonResult(await scanPairsTool(params ?? {}, intelApiDeps, {
2427
2511
  decisionsClient: positionDecisionsClient,
2428
2512
  userId: positionDecisionsUserId,
2513
+ reentryTracker,
2429
2514
  })),
2430
2515
  },
2431
2516
  {