@reefclaw/openclaw-plugin 0.1.13 → 0.1.15

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 (71) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-config.d.ts +16 -5
  5. package/bridge/gateway/gateway-config.js +68 -12
  6. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  7. package/bridge/gateway/gateway-ws-client.js +41 -11
  8. package/bridge/gateway/poller.js +18 -8
  9. package/bridge/providers/emergency-commands.d.ts +9 -1
  10. package/bridge/providers/emergency-commands.js +38 -1
  11. package/bridge/providers/gateway.d.ts +51 -1
  12. package/bridge/providers/gateway.js +209 -22
  13. package/bridge/providers/onboarding-commands.d.ts +11 -0
  14. package/bridge/providers/onboarding-commands.js +5 -5
  15. package/bridge/providers/risk-calculator.d.ts +61 -2
  16. package/bridge/providers/risk-calculator.js +92 -20
  17. package/bridge/utils/skill-signing.js +8 -3
  18. package/ccxt/binance-public.d.ts +17 -5
  19. package/ccxt/binance-public.js +31 -3
  20. package/config/operator-provenance.d.ts +6 -0
  21. package/config/operator-provenance.js +50 -0
  22. package/config/plugin-config-io.d.ts +15 -1
  23. package/config/plugin-config-io.js +29 -0
  24. package/exchange-adapter.d.ts +13 -0
  25. package/index.js +230 -176
  26. package/ingest/event-loop-monitor.d.ts +22 -0
  27. package/ingest/event-loop-monitor.js +190 -0
  28. package/ingest/position-auto-capture.d.ts +5 -0
  29. package/ingest/position-auto-capture.js +14 -5
  30. package/ingest/readiness-reporter.d.ts +26 -6
  31. package/ingest/readiness-reporter.js +137 -9
  32. package/ingest/skill-version-reader.d.ts +16 -0
  33. package/ingest/skill-version-reader.js +64 -0
  34. package/live/approval-lifecycle.d.ts +30 -0
  35. package/live/approval-lifecycle.js +80 -0
  36. package/live/bracket-types.d.ts +9 -0
  37. package/live/live-adapter.d.ts +0 -1
  38. package/live/user-data-stream.js +10 -2
  39. package/onboarding/runtime.d.ts +34 -1
  40. package/onboarding/runtime.js +56 -5
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +6 -5
  43. package/risk/pre-trade-check.js +18 -5
  44. package/simulator/exchange-simulator.d.ts +45 -2
  45. package/simulator/exchange-simulator.js +96 -4
  46. package/simulator/types.d.ts +17 -0
  47. package/skills/reefclaw/SKILL.md +6 -11
  48. package/strategy/condition-registry.js +9 -2
  49. package/strategy/evaluator.d.ts +5 -0
  50. package/tools/attach-brackets.js +50 -1
  51. package/tools/cancel-all-orders.js +9 -1
  52. package/tools/create-order.js +18 -1
  53. package/tools/get-bracket-config.d.ts +21 -2
  54. package/tools/get-bracket-config.js +18 -2
  55. package/tools/set-trading-mode.js +6 -3
  56. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
  57. package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
  58. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  59. package/venues/hyperliquid/hl-brackets.js +45 -13
  60. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  61. package/venues/hyperliquid/hl-fill-ingest.js +88 -0
  62. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  63. package/venues/hyperliquid/hl-live-adapter.js +116 -7
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
  68. package/venues/registry.js +8 -7
  69. package/wave9/paper-admission-guard.d.ts +12 -1
  70. package/wave9/paper-admission-guard.js +12 -1
  71. package/scripts/assemble.mjs +0 -130
@@ -1,11 +1,12 @@
1
1
  // GatewayProvider — real OpenClaw gateway integration.
2
2
  // Connects to the OpenClaw gateway via HTTP and WebSocket,
3
3
  // streams live trading data, and emits ProviderEvents.
4
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { logger, formatError } from '../logger.js';
8
8
  import { isTradingMode } from '../types.js';
9
+ import { toIntelSymbol } from '@reefclaw/shared';
9
10
  import { GatewayHttpClient } from '../gateway/gateway-http-client.js';
10
11
  import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
11
12
  import { discoverTools } from '../gateway/tool-discovery.js';
@@ -13,7 +14,7 @@ import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, ex
13
14
  import { Poller } from '../gateway/poller.js';
14
15
  import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
15
16
  import { parseIdentityName } from '../utils/identity-name.js';
16
- import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
17
+ import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, DRAWDOWN_ZONE_THRESHOLDS, TENANT_LIMIT_BOUNDS, boundedNum, validateDrawdownLadder, } from './risk-calculator.js';
17
18
  import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
18
19
  import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
19
20
  const TAG = 'gateway';
@@ -219,6 +220,7 @@ export class GatewayProvider {
219
220
  agentStateInterval = null;
220
221
  toolRetryInterval = null;
221
222
  regimeInterval = null;
223
+ tradingParamsInterval = null;
222
224
  signalInterval = null;
223
225
  missionInterval = null;
224
226
  analyticsInterval = null;
@@ -239,6 +241,17 @@ export class GatewayProvider {
239
241
  atrSampleCount = 0;
240
242
  // ---- Current regime (Phase 9e: regime-conditional risk) ----
241
243
  currentRegime = 'UNKNOWN';
244
+ /** ★ The tenant's ENFORCED risk limits, mirrored from intel
245
+ * `/api/trading-params` — the same row the plugin's `preTradeRiskCheck`
246
+ * rejects orders against. Null until the first fetch lands (then
247
+ * DEFAULT_RISK_LIMITS applies). Before this existed the dashboard's risk
248
+ * banner ran on a hardcoded ladder that no enforcer used, so it could show
249
+ * a red "Gate fail" for a limit nothing blocked on — and, in the other
250
+ * direction, stay green while the plugin rejected every entry. */
251
+ riskLimits = null;
252
+ /** Tenant drawdown-zone boundaries. Drives the RED-zone auto-flatten, so a
253
+ * stale/stricter local ladder here can flatten a live book early. */
254
+ drawdownThresholds = null;
242
255
  // ---- Trade tracking ----
243
256
  /** Timestamp of the most recent trade (fill event) — used in buildAgentState */
244
257
  lastTradeTs = null;
@@ -351,6 +364,10 @@ export class GatewayProvider {
351
364
  clearInterval(this.regimeInterval);
352
365
  this.regimeInterval = null;
353
366
  }
367
+ if (this.tradingParamsInterval) {
368
+ clearInterval(this.tradingParamsInterval);
369
+ this.tradingParamsInterval = null;
370
+ }
354
371
  if (this.signalInterval) {
355
372
  clearInterval(this.signalInterval);
356
373
  this.signalInterval = null;
@@ -572,10 +589,11 @@ export class GatewayProvider {
572
589
  const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
573
590
  // Pass a FRESH, all-symbols order snapshot so Kill cancels every working
574
591
  // order portfolio-wide and correctly identifies (and preserves) protective
575
- // reduceOnly brackets. fetchFreshOpenOrders falls back to this.openOrders on
576
- // any fetch error, so this never blocks the kill.
577
- const freshOrders = await this.fetchFreshOpenOrders();
578
- const result = await _executeKill(ctx, freshOrders);
592
+ // reduceOnly brackets. On a failed fresh read Kill still runs against the
593
+ // ≤60s cache (never blocked) but reports executed:false a stale cache
594
+ // cannot verify coverage (audit 2026-07-26 F6).
595
+ const freshOrders = await this.fetchFreshOpenOrdersOrNull();
596
+ const result = await _executeKill(ctx, freshOrders, this.openOrders);
579
597
  this.agentMode = 'STOPPED';
580
598
  this.emitAgentState();
581
599
  return result;
@@ -660,16 +678,26 @@ export class GatewayProvider {
660
678
  // Thin wrappers — real logic lives in providers/onboarding-commands.ts
661
679
  // so unit tests can drive pure functions with a mocked HTTP client.
662
680
  async setTradingMode(mode, acknowledged) {
663
- return executeSetTradingMode({ http: this.http, toolMap: this.toolMap }, mode, acknowledged);
681
+ return executeSetTradingMode(
682
+ // operatorToken = the rc_* connection token — the plugin refuses the
683
+ // four state-mutating operator tools without it (audit F12).
684
+ { http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, mode, acknowledged);
664
685
  }
665
686
  async setExchangeCredentials(apiKey, secret, testnet) {
666
- return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
687
+ return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
667
688
  }
668
689
  async testExchangeCredentials(apiKey, secret, testnet) {
669
- return executeTestExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
690
+ return executeTestExchangeCredentials(
691
+ // operatorToken required since the SkillSpector hardening: the plugin
692
+ // refuses the credential-validation call without dashboard provenance.
693
+ { http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, apiKey, secret, testnet);
670
694
  }
671
695
  async clearExchangeCredentials() {
672
- return executeClearExchangeCredentials({ http: this.http, toolMap: this.toolMap });
696
+ return executeClearExchangeCredentials({
697
+ http: this.http,
698
+ toolMap: this.toolMap,
699
+ operatorToken: this.config.connectionToken,
700
+ });
673
701
  }
674
702
  /** Operator-only. Read the current bracket-orders config from the plugin. */
675
703
  async getBracketConfig() {
@@ -677,7 +705,7 @@ export class GatewayProvider {
677
705
  }
678
706
  /** Operator-only. Flip requireStopLoss or requireTakeProfit. */
679
707
  async setBracketRequirement(flag, value) {
680
- return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap }, flag, value);
708
+ return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, flag, value);
681
709
  }
682
710
  async getSnapshot() {
683
711
  logger.info(TAG, 'Reconciliation snapshot requested');
@@ -851,7 +879,13 @@ export class GatewayProvider {
851
879
  // Step 3: Create WS client and connect FIRST to obtain device token.
852
880
  // OpenClaw's REST API requires a device token from the WS handshake,
853
881
  // not the raw gateway auth token. Tool discovery must wait for this.
854
- this.wsClient = new GatewayWsClient(this.config);
882
+ // Least privilege: operator.admin (cron management) is requested only
883
+ // while the once-per-install heartbeat-cron ensure is still owed — after
884
+ // the marker exists, every session runs on operator.read/write alone.
885
+ this.wsClient = new GatewayWsClient({
886
+ ...this.config,
887
+ requestAdminScope: !existsSync(join(homedir(), '.openclaw', 'workspace', 'heartbeat-cron-ensured.json')),
888
+ });
855
889
  this.wireWsEvents();
856
890
  // Wait for WS handshake to complete (or fail)
857
891
  const deviceToken = await this.waitForDeviceToken();
@@ -916,6 +950,9 @@ export class GatewayProvider {
916
950
  }
917
951
  // Step 6: Start regime poller if intelligence service is configured
918
952
  this.startRegimePoller();
953
+ // Step 6b: Mirror the tenant's ENFORCED risk limits. A safety input, so it
954
+ // runs even when the intel display pollers are shed.
955
+ this.startTradingParamsPoller();
919
956
  // Step 7: Start signal poller if intelligence service is configured
920
957
  this.startSignalPoller();
921
958
  // Step 8: Start mission poller if intelligence service is configured
@@ -1195,6 +1232,28 @@ export class GatewayProvider {
1195
1232
  canonicalSymbol(s) {
1196
1233
  return typeof s === 'string' ? s.replace(/:[A-Z]+$/, '') : s;
1197
1234
  }
1235
+ /** Canonical symbol → the intel DB symbol for this box's venue.
1236
+ * binance: 'BTC/USDT' → 'BTCUSDT'; hyperliquid: 'BTC/USDC' → 'HL_BTC'.
1237
+ *
1238
+ * ★ The old venue-blind `.replace('/','')` produced 'BTCUSDC' on a
1239
+ * hyperliquid box — a symbol no intel row has ever carried (intel
1240
+ * namespaces every HL row under the 'HL_' prefix). That silently broke the
1241
+ * regime/signal/mission/analytics pollers AND wrote every trade result to a
1242
+ * symbol Kelly sizing would never read back, so a hyperliquid box stayed at
1243
+ * `insufficient_history` even once the reporting path itself worked.
1244
+ *
1245
+ * FAILS OPEN: an unmappable symbol falls back to the legacy concatenation
1246
+ * so intel answers "no data for <echo>" — honest and debuggable — rather
1247
+ * than the caller throwing and dropping the report entirely. */
1248
+ toIntelSymbol(symbol) {
1249
+ const canonical = this.canonicalSymbol(symbol);
1250
+ try {
1251
+ return toIntelSymbol(this.config.venue ?? 'binance', canonical);
1252
+ }
1253
+ catch {
1254
+ return canonical.replace('/', '');
1255
+ }
1256
+ }
1198
1257
  async onPollerPositions(positions) {
1199
1258
  // Canonicalize symbols at this single position-ingress chokepoint so
1200
1259
  // the ENTIRE skill (position-diff, openTradeEntries keys, snapshots,
@@ -1610,7 +1669,9 @@ export class GatewayProvider {
1610
1669
  /** Simple heat score (0–100) matching webapp's HeatGauge logic */
1611
1670
  computeHeatScoreSimple(metrics) {
1612
1671
  const { grossExposure, dailyDrawdown, ordersPerMinute, cancelsPerMinute } = metrics.utilization;
1613
- const limits = DEFAULT_RISK_LIMITS;
1672
+ // Same enforced limits the breach detector uses — otherwise the heat gauge
1673
+ // and the gate disagree about what "hot" means.
1674
+ const limits = this.riskLimits ?? DEFAULT_RISK_LIMITS;
1614
1675
  const scores = [
1615
1676
  limits.position.maxGrossExposure > 0 ? (grossExposure / limits.position.maxGrossExposure) * 100 : 0,
1616
1677
  limits.loss.maxDailyDrawdown < 0 ? (dailyDrawdown / limits.loss.maxDailyDrawdown) * 100 : 0,
@@ -1737,10 +1798,15 @@ export class GatewayProvider {
1737
1798
  return this.balance;
1738
1799
  }
1739
1800
  }
1740
- async fetchFreshOpenOrders() {
1801
+ /** Strict fresh read — null on ANY failure (missing tool, thrown fetch,
1802
+ * non-array garbage). Callers whose verdict claims order-state coverage
1803
+ * (Kill) MUST distinguish "confirmed list" from "unknown"; collapsing a
1804
+ * failed read into the ≤60s cache here was how a stale-empty cache became
1805
+ * a green "No open orders to cancel" (audit 2026-07-26 F6). */
1806
+ async fetchFreshOpenOrdersOrNull() {
1741
1807
  const tool = this.toolMap.fetch_open_orders;
1742
1808
  if (!tool || !this.http)
1743
- return this.openOrders;
1809
+ return null;
1744
1810
  try {
1745
1811
  // Fetch ALL open orders (no symbol filter). Multi-symbol orphan orders
1746
1812
  // submitted while the browser was disconnected must show up in the
@@ -1749,7 +1815,7 @@ export class GatewayProvider {
1749
1815
  const result = await this.http.invoke(tool, {});
1750
1816
  if (!Array.isArray(result.data)) {
1751
1817
  logger.warn(TAG, `fetch_open_orders returned non-array: ${typeof result.data}`);
1752
- return this.openOrders;
1818
+ return null;
1753
1819
  }
1754
1820
  const mapped = result.data
1755
1821
  .map((o) => mapCcxtOrder(o))
@@ -1761,10 +1827,15 @@ export class GatewayProvider {
1761
1827
  return mapped;
1762
1828
  }
1763
1829
  catch (err) {
1764
- logger.warn(TAG, `Fresh open orders fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
1765
- return this.openOrders;
1830
+ logger.warn(TAG, `Fresh open orders fetch failed: ${err instanceof Error ? err.message : String(err)}`);
1831
+ return null;
1766
1832
  }
1767
1833
  }
1834
+ /** Cached-fallback wrapper for DISPLAY surfaces (the reconcile snapshot):
1835
+ * stale data beats a blank panel there. Never use for emergency verdicts. */
1836
+ async fetchFreshOpenOrders() {
1837
+ return (await this.fetchFreshOpenOrdersOrNull()) ?? this.openOrders;
1838
+ }
1768
1839
  async fetchAgentHealth() {
1769
1840
  const ws = this.wsClient;
1770
1841
  if (!ws)
@@ -2261,6 +2332,8 @@ export class GatewayProvider {
2261
2332
  atrData: this.atrData ?? undefined,
2262
2333
  regime: this.currentRegime,
2263
2334
  realizedPnlToday: this.realizedPnlToday ?? undefined,
2335
+ baseLimits: this.riskLimits ?? undefined,
2336
+ drawdownThresholds: this.drawdownThresholds ?? undefined,
2264
2337
  });
2265
2338
  // Store pruned timestamps back (non-mutating function returns new arrays)
2266
2339
  this.orderTimestamps = result.prunedOrderTimestamps;
@@ -2309,7 +2382,13 @@ export class GatewayProvider {
2309
2382
  const url = this.config.intelligenceUrl;
2310
2383
  const token = this.config.connectionToken;
2311
2384
  if (!url || !token) {
2312
- logger.debug(TAG, 'Intelligence not configured skipping trade result report');
2385
+ // NEVER debug-level. This is the only writer of intel's `trade_results`,
2386
+ // and those rows are what `minTradesForKelly` counts and what
2387
+ // `get_agent_profile` derives its tier from. Silently skipping here is
2388
+ // indistinguishable from "the agent has never traded" — which is exactly
2389
+ // how it read for months. See DEFAULT_INTELLIGENCE_URL in gateway-config.
2390
+ logger.warn(TAG, `Intelligence not configured (url=${url ? 'set' : 'MISSING'}, token=${token ? 'set' : 'MISSING'}) — DROPPING trade result for ${symbol}. `
2391
+ + 'Kelly sizing and the agent profile are computed from these rows and will stay pinned at their no-history defaults.');
2313
2392
  return;
2314
2393
  }
2315
2394
  const direction = entry.side === 'long' ? 'LONG' : 'SHORT';
@@ -2321,7 +2400,7 @@ export class GatewayProvider {
2321
2400
  const durationSeconds = Math.floor((Date.now() - new Date(entry.entryTime).getTime()) / 1000);
2322
2401
  const tradeResult = {
2323
2402
  missionId: entry.missionId ?? `trade-${Date.now()}`,
2324
- symbol: symbol.replace('/', ''), // BTC/USDT → BTCUSDT for Intelligence API
2403
+ symbol: this.toIntelSymbol(symbol), // venue-aware: BTC/USDT → BTCUSDT, BTC/USDC HL_BTC
2325
2404
  direction,
2326
2405
  strategy: this.lastKnownStrategy.name,
2327
2406
  regime: entry.regime ?? this.currentRegime,
@@ -2406,9 +2485,9 @@ export class GatewayProvider {
2406
2485
  }
2407
2486
  }
2408
2487
  // ---- Intelligence service polling ----
2409
- /** Symbol formatted for intelligence API (BTC/USDT BTCUSDT). */
2488
+ /** The configured symbol as the intel DB spells it. */
2410
2489
  get intelligenceSymbol() {
2411
- return this.config.symbol.replace('/', '');
2490
+ return this.toIntelSymbol(this.config.symbol);
2412
2491
  }
2413
2492
  /** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
2414
2493
  startIntelligencePoller(name, endpoint, intervalMs, onData, intervalRef, delayFirstMs = 0) {
@@ -2420,6 +2499,21 @@ export class GatewayProvider {
2420
2499
  }
2421
2500
  return;
2422
2501
  }
2502
+ // These five read-pollers (regime 60s, signals 10s, missions 10s,
2503
+ // analytics 30s, decision-trace 30s ≈ 22 req/min per box) were dormant for
2504
+ // as long as `intelligenceUrl` had no default. Giving it one turns them on
2505
+ // everywhere, which is the intended behaviour — they feed the dashboard's
2506
+ // regime/signals/missions panels — but it IS new load on an intel API with
2507
+ // a known heap leak. Kill-switch so that load can be shed in one restart
2508
+ // WITHOUT taking the trade_results writer down with it: the writer is what
2509
+ // Kelly sizing and the agent tier are computed from, and it does not go
2510
+ // through this function.
2511
+ if (process.env.RC_SKILL_INTEL_POLL === 'off') {
2512
+ if (name === 'regime') {
2513
+ logger.warn(TAG, 'RC_SKILL_INTEL_POLL=off — intelligence read-polling disabled (trade-result reporting is unaffected)');
2514
+ }
2515
+ return;
2516
+ }
2423
2517
  const symbol = this.intelligenceSymbol;
2424
2518
  const fullUrl = `${url}/api/${endpoint}/${symbol}`;
2425
2519
  logger.info(TAG, `Starting ${name} poller: ${fullUrl}`);
@@ -2474,6 +2568,99 @@ export class GatewayProvider {
2474
2568
  this.fire('regimeUpdate', { ...data, event: 'regime_update', symbol });
2475
2569
  }, 'regimeInterval');
2476
2570
  }
2571
+ /**
2572
+ * Mirror the tenant's trading params into the risk limits the dashboard
2573
+ * banner and the RED-zone auto-flatten run on.
2574
+ *
2575
+ * Deliberately NOT routed through `startIntelligencePoller`: that helper
2576
+ * appends `/${symbol}` and is gated by `RC_SKILL_INTEL_POLL=off`. These
2577
+ * limits are a safety input, not a dashboard read — shedding intel display
2578
+ * load must not silently revert the operator's configured limits to the
2579
+ * fallback ladder.
2580
+ *
2581
+ * Fail-open by design: a failed fetch keeps the last good values (or the
2582
+ * fallback constants on a cold start). We never tighten limits from a
2583
+ * parse failure, and we never widen them past what the plugin enforces —
2584
+ * the plugin re-reads the same row for the gate that actually blocks.
2585
+ */
2586
+ startTradingParamsPoller() {
2587
+ const url = this.config.intelligenceUrl;
2588
+ const token = this.config.connectionToken;
2589
+ if (!url || !token)
2590
+ return;
2591
+ const fullUrl = `${url}/api/trading-params`;
2592
+ logger.info(TAG, `Starting trading-params poller: ${fullUrl}`);
2593
+ const poll = async () => {
2594
+ if (!this.started)
2595
+ return;
2596
+ try {
2597
+ const res = await fetch(fullUrl, {
2598
+ headers: { Authorization: `Bearer ${token}` },
2599
+ signal: AbortSignal.timeout(10_000),
2600
+ });
2601
+ if (!res.ok) {
2602
+ this.trackIntelError('tradingParams', `HTTP ${res.status}`);
2603
+ return;
2604
+ }
2605
+ const body = (await res.json());
2606
+ const p = body?.params;
2607
+ if (!p)
2608
+ return;
2609
+ // Every field is bounds-checked against hardcoded bands
2610
+ // (TENANT_LIMIT_BOUNDS / validateDrawdownLadder) — CLAUDE.md
2611
+ // load-bearing rule: central config moves knobs only WITHIN hardcoded
2612
+ // bounds; it must never be able to disable a safety mechanism.
2613
+ // Out-of-band → field ignored, previous value kept.
2614
+ const B = TENANT_LIMIT_BOUNDS;
2615
+ const maxPositionSize = boundedNum(p.maxPositionSize, B.maxPositionSize.min, B.maxPositionSize.max);
2616
+ const maxOpenPositions = boundedNum(p.maxOpenPositions, B.maxOpenPositions.min, B.maxOpenPositions.max);
2617
+ const maxGrossExposure = boundedNum(p.maxGrossExposure, B.maxGrossExposure.min, B.maxGrossExposure.max);
2618
+ const maxPerTradeLoss = boundedNum(p.maxPerTradeLoss, B.maxPerTradeLoss.min, B.maxPerTradeLoss.max);
2619
+ // Validate the zone ladder FIRST — the loss limit below reads from the
2620
+ // ACCEPTED ladder, so a rejected row can't leak its RED into
2621
+ // maxDailyDrawdown (which drives the dailyDrawdown AUTO_PAUSE breach).
2622
+ const base = this.drawdownThresholds ?? DRAWDOWN_ZONE_THRESHOLDS;
2623
+ const next = {
2624
+ YELLOW: boundedNum(p.drawdownYellow, -1, 0) ?? base.YELLOW,
2625
+ ORANGE: boundedNum(p.drawdownOrange, -1, 0) ?? base.ORANGE,
2626
+ RED: boundedNum(p.drawdownRed, -1, 0) ?? base.RED,
2627
+ };
2628
+ const ladderError = validateDrawdownLadder(next);
2629
+ if (ladderError === null) {
2630
+ this.drawdownThresholds = next;
2631
+ }
2632
+ else {
2633
+ logger.warn(TAG, `Ignoring drawdown zones from trading params: ${ladderError}`);
2634
+ }
2635
+ const prev = this.riskLimits ?? DEFAULT_RISK_LIMITS;
2636
+ const acceptedRed = this.drawdownThresholds?.RED;
2637
+ this.riskLimits = {
2638
+ position: {
2639
+ maxPositionSize: maxPositionSize ?? prev.position.maxPositionSize,
2640
+ maxOpenPositions: maxOpenPositions ?? prev.position.maxOpenPositions,
2641
+ maxGrossExposure: maxGrossExposure ?? prev.position.maxGrossExposure,
2642
+ // Intel has no separate net-tilt param; the gross cap bounds it
2643
+ // (|net| ≤ gross always, so this can never fire before gross).
2644
+ maxNetExposure: maxGrossExposure ?? prev.position.maxNetExposure,
2645
+ },
2646
+ loss: {
2647
+ // Drawdown limit tracks the operator's RED boundary rather than a
2648
+ // third independent number.
2649
+ maxDailyDrawdown: acceptedRed ?? prev.loss.maxDailyDrawdown,
2650
+ maxPerTradeLoss: maxPerTradeLoss != null ? -Math.abs(maxPerTradeLoss) : prev.loss.maxPerTradeLoss,
2651
+ maxDailyLoss: prev.loss.maxDailyLoss,
2652
+ },
2653
+ rate: prev.rate,
2654
+ };
2655
+ this.intelErrors.tradingParams = 0;
2656
+ }
2657
+ catch (err) {
2658
+ this.trackIntelError('tradingParams', formatError(err));
2659
+ }
2660
+ };
2661
+ void poll();
2662
+ this.tradingParamsInterval = setInterval(() => { void poll(); }, 60_000);
2663
+ }
2477
2664
  startSignalPoller() {
2478
2665
  this.startIntelligencePoller('signals', 'signals', 10_000, (raw) => {
2479
2666
  const data = raw;
@@ -38,6 +38,17 @@ export interface ClearExchangeCredentialsOutcome {
38
38
  export interface OnboardingContext {
39
39
  http: GatewayHttpClient | null;
40
40
  toolMap: ToolMap;
41
+ /** rc_* connection token, injected as `operator_token` on the operator-only
42
+ * tools: the four state-MUTATING ones (set_trading_mode /
43
+ * set_exchange_credentials / clear_exchange_credentials /
44
+ * set_bracket_requirement, audit 2026-07-26 F12) plus
45
+ * test_exchange_credentials (read-only, but a live authenticated exchange
46
+ * call with caller-supplied keys — without the gate it would hand a
47
+ * prompt-injected agent a credential-validation oracle). The plugin refuses
48
+ * these calls without it — the operator-provenance proof that the request
49
+ * came through the dashboard path, which the agent cannot forge
50
+ * conversationally (chat redaction strips rc_* tokens). */
51
+ operatorToken?: string;
41
52
  }
42
53
  export type BracketMode = 'off' | 'observe' | 'enforce';
43
54
  export type BracketRequirementFlag = 'requireStopLoss' | 'requireTakeProfit';
@@ -23,7 +23,7 @@ export async function executeSetTradingMode(ctx, mode, acknowledged) {
23
23
  };
24
24
  }
25
25
  try {
26
- const result = await ctx.http.invoke(tool, { mode, acknowledged });
26
+ const result = await ctx.http.invoke(tool, { mode, acknowledged, operator_token: ctx.operatorToken });
27
27
  return result.data;
28
28
  }
29
29
  catch (err) {
@@ -57,7 +57,7 @@ export async function executeTestExchangeCredentials(ctx, apiKey, secret, testne
57
57
  };
58
58
  }
59
59
  try {
60
- const result = await ctx.http.invoke(tool, { apiKey, secret, testnet });
60
+ const result = await ctx.http.invoke(tool, { apiKey, secret, testnet, operator_token: ctx.operatorToken });
61
61
  return result.data;
62
62
  }
63
63
  catch (err) {
@@ -93,7 +93,7 @@ export async function executeClearExchangeCredentials(ctx) {
93
93
  };
94
94
  }
95
95
  try {
96
- const result = await ctx.http.invoke(tool, { confirm: true });
96
+ const result = await ctx.http.invoke(tool, { confirm: true, operator_token: ctx.operatorToken });
97
97
  return result.data;
98
98
  }
99
99
  catch (err) {
@@ -162,7 +162,7 @@ export async function executeSetBracketRequirement(ctx, flag, value) {
162
162
  };
163
163
  }
164
164
  try {
165
- const result = await ctx.http.invoke(tool, { flag, value });
165
+ const result = await ctx.http.invoke(tool, { flag, value, operator_token: ctx.operatorToken });
166
166
  return result.data;
167
167
  }
168
168
  catch (err) {
@@ -194,7 +194,7 @@ export async function executeSetExchangeCredentials(ctx, apiKey, secret, testnet
194
194
  };
195
195
  }
196
196
  try {
197
- const result = await ctx.http.invoke(tool, { apiKey, secret, testnet });
197
+ const result = await ctx.http.invoke(tool, { apiKey, secret, testnet, operator_token: ctx.operatorToken });
198
198
  return result.data;
199
199
  }
200
200
  catch (err) {
@@ -10,11 +10,53 @@ export declare const REGIME_LIMIT_PROFILES: Record<string, Partial<{
10
10
  }>>;
11
11
  /** Apply regime-conditional adjustments to limits. */
12
12
  export declare function applyRegimeAdjustment(baseLimits: RiskLimits, regime: string): RiskLimits;
13
+ /** Fallback zone boundaries, used only until the tenant's trading params load.
14
+ * The AUTHORITATIVE values are the operator's `drawdownYellow/Orange/Red`
15
+ * trading params — the same numbers the plugin's pre-trade gate rejects on.
16
+ * Keeping a second hardcoded ladder here is how the skill ended up
17
+ * auto-flattening at -2.5% on an account configured to -4%. */
13
18
  export declare const DRAWDOWN_ZONE_THRESHOLDS: {
14
19
  readonly YELLOW: -0.01;
15
20
  readonly ORANGE: -0.02;
16
21
  readonly RED: -0.025;
17
22
  };
23
+ export interface DrawdownThresholds {
24
+ YELLOW: number;
25
+ ORANGE: number;
26
+ RED: number;
27
+ }
28
+ /** The auto-flatten trigger can never be configured looser than −10%. */
29
+ export declare const AUTO_FLATTEN_RED_FLOOR = -0.1;
30
+ /** Sanity bands for tenant limit fields. Values outside → field ignored,
31
+ * previous value kept. Wide on purpose: these reject nonsense, not policy. */
32
+ export declare const TENANT_LIMIT_BOUNDS: {
33
+ readonly maxPositionSize: {
34
+ readonly min: 1;
35
+ readonly max: 1000000000;
36
+ };
37
+ readonly maxOpenPositions: {
38
+ readonly min: 1;
39
+ readonly max: 100;
40
+ };
41
+ readonly maxGrossExposure: {
42
+ readonly min: 0.01;
43
+ readonly max: 20;
44
+ };
45
+ readonly maxPerTradeLoss: {
46
+ readonly min: 0.01;
47
+ readonly max: 10000000;
48
+ };
49
+ };
50
+ /** Finite number within [min, max], else null. */
51
+ export declare function boundedNum(v: unknown, min: number, max: number): number | null;
52
+ /**
53
+ * Validate a tenant drawdown-zone ladder. Returns null when acceptable, else
54
+ * a human-readable reason. Requirements: all finite; YELLOW ≤ 0 (zones are
55
+ * losses); strictly monotonic YELLOW > ORANGE > RED (a scrambled ladder would
56
+ * put the book straight into RED and auto-flatten it); RED no looser than
57
+ * AUTO_FLATTEN_RED_FLOOR.
58
+ */
59
+ export declare function validateDrawdownLadder(t: DrawdownThresholds): string | null;
18
60
  export type DrawdownZone = 'GREEN' | 'YELLOW' | 'ORANGE' | 'RED';
19
61
  /** State snapshot needed to compute equity. */
20
62
  export interface EquityState {
@@ -36,6 +78,12 @@ export interface RiskState {
36
78
  };
37
79
  regime?: string;
38
80
  realizedPnlToday?: number;
81
+ /** The tenant's configured limits (from intel `/api/trading-params`) — the
82
+ * SAME numbers the plugin's pre-trade gate enforces. Omitted until the
83
+ * first fetch lands, in which case DEFAULT_RISK_LIMITS applies. */
84
+ baseLimits?: RiskLimits;
85
+ /** The tenant's configured drawdown-zone boundaries. Omitted → fallback. */
86
+ drawdownThresholds?: DrawdownThresholds;
39
87
  }
40
88
  /** Result of computeRiskMetrics including pruned timestamp arrays. */
41
89
  export interface RiskMetricsResult {
@@ -51,7 +99,7 @@ export declare function computeVolFactor(state: {
51
99
  /** Apply volatility adjustment to base limits. Tightens position/exposure, leaves loss/rate unchanged. */
52
100
  export declare function applyVolatilityAdjustment(baseLimits: RiskLimits, volFactor: number): RiskLimits;
53
101
  /** Determine drawdown zone from drawdown ratio (e.g. -0.015 = -1.5%). */
54
- export declare function getDrawdownZone(drawdownRatio: number): DrawdownZone;
102
+ export declare function getDrawdownZone(drawdownRatio: number, thresholds?: DrawdownThresholds): DrawdownZone;
55
103
  /** Human-readable message for a drawdown zone. */
56
104
  export declare function getDrawdownZoneMessage(zone: DrawdownZone, ratio: number): string;
57
105
  /**
@@ -80,6 +128,17 @@ export declare function countRecentEvents(timestamps: number[]): {
80
128
  };
81
129
  /**
82
130
  * Detect risk limit breaches from current metrics.
131
+ *
132
+ * ★ CRITICAL is reserved for the limits that are ACTUALLY ENFORCED — the
133
+ * tenant's trading params, which the plugin's `preTradeRiskCheck` rejects
134
+ * orders against (`plugin/src/risk/pre-trade-check.ts`). A red "Gate fail" on
135
+ * the dashboard must mean "the agent is being blocked right now".
136
+ *
137
+ * `advisoryLimits` carries the vol/regime-TIGHTENED view. Those multipliers
138
+ * live only in this file — no enforcer applies them — so they may raise a
139
+ * WARNING (amber, indication) but must never produce a CRITICAL. Conflating
140
+ * the two is what put a permanent red "Gate fail · Gross exposure 121%" on a
141
+ * live book whose 1.5x enforced limit was never even approached.
83
142
  */
84
143
  export declare function detectBreaches(metrics: {
85
144
  grossExposure: number;
@@ -88,7 +147,7 @@ export declare function detectBreaches(metrics: {
88
147
  dailyLoss?: number;
89
148
  ordersPerMinute: number;
90
149
  cancelsPerMinute: number;
91
- }, limits: RiskLimits): RiskBreach[];
150
+ }, limits: RiskLimits, advisoryLimits?: RiskLimits): RiskBreach[];
92
151
  /**
93
152
  * Compute full risk metrics from a state snapshot.
94
153
  * Returns metrics + pruned timestamp arrays for the caller to store.