@reefclaw/openclaw-plugin 0.1.6 → 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 (52) 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 +107 -29
  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 +6 -0
  26. package/onboarding/runtime.js +13 -2
  27. package/package.json +2 -2
  28. package/portfolio/reentry-tracker.d.ts +36 -0
  29. package/portfolio/reentry-tracker.js +127 -0
  30. package/signals/conditions/registry.js +11 -2
  31. package/signals/strategy-adapter.js +17 -7
  32. package/simulator/exchange-simulator.d.ts +12 -0
  33. package/simulator/exchange-simulator.js +73 -3
  34. package/simulator/types.d.ts +4 -0
  35. package/skills/reefclaw/SKILL.md +2 -0
  36. package/tools/assessment-validation.d.ts +21 -0
  37. package/tools/assessment-validation.js +58 -0
  38. package/tools/attach-brackets.js +165 -0
  39. package/tools/audit-bracket-protection.js +157 -1
  40. package/tools/bracket-control.d.ts +12 -0
  41. package/tools/bracket-control.js +35 -0
  42. package/tools/create-order.js +30 -2
  43. package/tools/get-setup-detail.js +12 -1
  44. package/tools/modify-stop.js +5 -5
  45. package/tools/modify-target.js +5 -5
  46. package/tools/scan-pairs.d.ts +4 -0
  47. package/tools/scan-pairs.js +4 -1
  48. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  49. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  50. package/venues/hyperliquid/hl-live-adapter.d.ts +61 -3
  51. package/venues/hyperliquid/hl-live-adapter.js +380 -5
  52. 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,
@@ -1391,7 +1401,17 @@ const paperTradingPlugin = {
1391
1401
  // are status='open' but absent from the exchange (closed on-exchange
1392
1402
  // while down / state-store wiped). Same trusted-snapshot-only rule —
1393
1403
  // 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));
1404
+ await reconcileDbOpenVsExchange({
1405
+ decisionsClient: positionDecisionsClient,
1406
+ userId: positionDecisionsUserId,
1407
+ // This boot pass only runs in the live-adapter init path and
1408
+ // diffs against the LIVE snapshot — scope the DB read to the
1409
+ // live book so open paper rows aren't misread as orphans.
1410
+ resolveMode: () => 'live',
1411
+ // …and to the ACTIVE venue (issue #209 item 5): the other
1412
+ // venue's open rows can never appear in this snapshot.
1413
+ resolveExchange: () => venue,
1414
+ }, trusted.map((p) => p.symbol));
1395
1415
  }
1396
1416
  else {
1397
1417
  logger.warn(TAG, 'Startup reconciliation skipped — positions fetch untrusted (null); state-store left untouched. Runtime drift_detected + periodic reconcilers cover the gap.');
@@ -1408,27 +1428,30 @@ const paperTradingPlugin = {
1408
1428
  else {
1409
1429
  adapter = new PaperAdapter(simulator);
1410
1430
  }
1411
- // PAPER market-data source selection.
1431
+ // Public market-data source selection.
1412
1432
  //
1413
1433
  // 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
1434
+ // the configured VENUE decides which exchange serves public reads, full
1435
+ // stop, in BOTH books. venue='hyperliquid' → every public read
1436
+ // (PaperMarketFeed marks, the simulator's fill prices,
1437
+ // fetch_ticker/fetch_ohlcv/get_orderbook tools paper AND live) comes
1438
+ // from Hyperliquid's keyless /info endpoints; the Binance-only
1418
1439
  // `paperMarketDataSource:'intel'` escape hatch does not apply (intel has
1419
1440
  // no HL rows until Phase 2 — silently serving Binance prices for a
1420
1441
  // Hyperliquid book would be a lie, the exact class the symbol-translation
1421
- // rule forbids).
1442
+ // rule forbids). Pre-fix this was gated on !adapter.isLive, so a LIVE
1443
+ // boot on venue=hyperliquid fell through to Binance for all six
1444
+ // market-data tools while executing on HL.
1422
1445
  //
1423
1446
  // Binance venue (default) is unchanged: a host Binance geo-blocks (HTTP
1424
- // 451) can route PRICE reads through the intel service by setting
1447
+ // 451) can route PAPER price reads through the intel service by setting
1425
1448
  // 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).
1449
+ // The readiness reporter probes the CONFIGURED venue. Kill-switch
1450
+ // RC_PAPER_MARKET_DATA=binance forces the Binance-direct default
1451
+ // (binance venue only).
1429
1452
  //
1430
1453
  // hlPublicApi is constructed once per process when the venue is
1431
- // hyperliquid — shared by the paper data path and the readiness probe.
1454
+ // hyperliquid — shared by the market-data path and the readiness probe.
1432
1455
  const hlPublicApi = venue === 'hyperliquid' ? new HyperliquidPublicApi({ testnet: venueTestnet }) : null;
1433
1456
  let configuredPaperSource;
1434
1457
  try {
@@ -1440,20 +1463,24 @@ const paperTradingPlugin = {
1440
1463
  const paperDataSource = process.env.RC_PAPER_MARKET_DATA === 'binance'
1441
1464
  ? 'binance'
1442
1465
  : configuredPaperSource ?? 'binance';
1443
- const useIntelPaperData = venue === 'binance' &&
1444
- !adapter.isLive && paperDataSource === 'intel' && connectionToken.length > 0;
1445
- const marketDataApi = !adapter.isLive && hlPublicApi
1466
+ const marketDataSource = resolveMarketDataSource({
1467
+ venue,
1468
+ isLive: adapter.isLive,
1469
+ paperDataSource,
1470
+ hasConnectionToken: connectionToken.length > 0,
1471
+ });
1472
+ const marketDataApi = marketDataSource === 'hyperliquid' && hlPublicApi
1446
1473
  ? hlPublicApi
1447
- : useIntelPaperData
1474
+ : marketDataSource === 'intel'
1448
1475
  ? new IntelPublicApi({ connectionToken, intelligenceUrl })
1449
1476
  : 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)`);
1477
+ if (marketDataSource === 'hyperliquid') {
1478
+ logger.info(TAG, `${adapter.isLive ? 'LIVE' : 'PAPER'} market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1452
1479
  if (paperDataSource === 'intel') {
1453
1480
  logger.warn(TAG, 'paperMarketDataSource=intel is a Binance-venue option — ignored on venue=hyperliquid (intel has no Hyperliquid rows until Phase 2)');
1454
1481
  }
1455
1482
  }
1456
- else if (useIntelPaperData) {
1483
+ else if (marketDataSource === 'intel') {
1457
1484
  logger.warn(TAG, 'PAPER market data routed through intel (paperMarketDataSource=intel) — prices from GET /api/price; live + readiness still use Binance directly');
1458
1485
  }
1459
1486
  else if (venue === 'binance' && !adapter.isLive && paperDataSource === 'intel' && connectionToken.length === 0) {
@@ -1479,15 +1506,10 @@ const paperTradingPlugin = {
1479
1506
  // see stop-watcher.ts. Operators can override / revert with zero deploy
1480
1507
  // via plugin-config.json `stopWatcher.intervalMs`. Best-effort read;
1481
1508
  // 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
- }
1509
+ const stopWatcherIntervalMs = loadStopWatcherIntervalMs();
1510
+ if (stopWatcherIntervalMs !== undefined) {
1511
+ logger.info(TAG, `Stop-watcher interval overridden via plugin-config: ${stopWatcherIntervalMs}ms`);
1489
1512
  }
1490
- catch { /* best-effort — default applies */ }
1491
1513
  const wave9OperationLock = new SerialTradingOperationLock();
1492
1514
  const bootstrapWatcher = stopWatcherIntervalMs !== undefined
1493
1515
  ? new PositionWatcher(adapter, stopWatcherIntervalMs, wave9OperationLock)
@@ -1502,6 +1524,23 @@ const paperTradingPlugin = {
1502
1524
  if (!adapter.isLive) {
1503
1525
  marketFeed.start();
1504
1526
  }
1527
+ // Journal capture for stop-watcher auto-closes (issue #199). Wired onto
1528
+ // EVERY watcher — the bootstrap one here, reconnect-created ones via the
1529
+ // runtime's onWatcherCreated hook — so a watcher close always journals its
1530
+ // close and drops the state-store mapping (no more phantom positions).
1531
+ const wireWatcherJournalCapture = (watcher) => {
1532
+ watcher.on('stop_closed', (ev) => {
1533
+ onStopWatcherClose(autoCapture, {
1534
+ symbol: ev.symbol,
1535
+ stopPrice: ev.stopPrice,
1536
+ markPrice: ev.markPrice,
1537
+ order: ev.order,
1538
+ }).catch((err) => {
1539
+ logger.warn(TAG, `stop-watcher close capture failed: ${formatError(err)}`);
1540
+ });
1541
+ });
1542
+ };
1543
+ wireWatcherJournalCapture(bootstrapWatcher);
1505
1544
  const runtime = new PluginRuntime({
1506
1545
  adapter,
1507
1546
  mode: tradingMode,
@@ -1509,7 +1548,45 @@ const paperTradingPlugin = {
1509
1548
  stopWatcher: bootstrapWatcher,
1510
1549
  marketFeed,
1511
1550
  operationLock: wave9OperationLock,
1551
+ onWatcherCreated: wireWatcherJournalCapture,
1512
1552
  });
1553
+ // Periodic DB-vs-exchange sweep (issues #199/#203): the boot-only reconcile
1554
+ // left phantom-open journal rows alive for days between restarts. Runs for
1555
+ // BOTH books (the paper book previously had NO reconcile at all); trusted-
1556
+ // snapshot discipline preserved (null → skip). First pass shortly after
1557
+ // boot so restart-deploys heal existing phantoms immediately.
1558
+ const dbReconcileSweep = startPeriodicDbReconcile({
1559
+ decisionsClient: positionDecisionsClient,
1560
+ userId: positionDecisionsUserId,
1561
+ resolveAdapter: () => runtime.adapter,
1562
+ // Scope each tick's DB read to the ACTIVE book (deferred closure —
1563
+ // follows a set_trading_mode reconnect). Without this, the sweep
1564
+ // misreads the inactive book's open rows as orphans and synthetically
1565
+ // closes them (they can never appear in this adapter's snapshot).
1566
+ resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1567
+ // Venue axis (issue #209 item 5): scope to the configured venue so a
1568
+ // two-venue tenant's other-venue rows are never misread as orphans.
1569
+ resolveExchange: () => venue,
1570
+ describeLastExit: (symbol) => {
1571
+ const a = runtime.adapter;
1572
+ if (a instanceof PaperAdapter) {
1573
+ const canon = symbol.split(':')[0];
1574
+ const th = a.getSimulator().getState().tradeHistory;
1575
+ for (let i = th.length - 1; i >= 0; i--) {
1576
+ if (th[i].symbol.split(':')[0] === canon) {
1577
+ const t = th[i];
1578
+ return `paper engine last trade: close ${t.side} ${t.quantity} @ ${t.exitPrice} at ${t.closedAt} (pnl ${t.realizedPnl.toFixed(4)})`;
1579
+ }
1580
+ }
1581
+ return 'paper engine has NO trade record for this symbol';
1582
+ }
1583
+ return undefined;
1584
+ },
1585
+ });
1586
+ if (dbReconcileSweep) {
1587
+ const bootSweep = setTimeout(() => { void dbReconcileSweep.runOnce(); }, 15_000);
1588
+ bootSweep.unref?.();
1589
+ }
1513
1590
  const intelDeps = { connectionToken, apiBaseUrl };
1514
1591
  // venue rides the deps so every intel tool maps agent-facing symbols to
1515
1592
  // the venue's intel namespace (plan §5.3) — absent/'binance' is
@@ -2426,6 +2503,7 @@ const paperTradingPlugin = {
2426
2503
  execute: async (_id, params) => jsonResult(await scanPairsTool(params ?? {}, intelApiDeps, {
2427
2504
  decisionsClient: positionDecisionsClient,
2428
2505
  userId: positionDecisionsUserId,
2506
+ reentryTracker,
2429
2507
  })),
2430
2508
  },
2431
2509
  {