@reefclaw/openclaw-plugin 0.1.3 → 0.1.4

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.
@@ -1,6 +1,7 @@
1
1
  import type { CcxtTicker, CcxtOHLCV } from '../types.js';
2
2
  import type { OrderBookDepth } from '../simulator/types.js';
3
- export declare class BinancePublicApi {
3
+ import type { PublicMarketDataApi } from './public-market-data-api.js';
4
+ export declare class BinancePublicApi implements PublicMarketDataApi {
4
5
  private exchange;
5
6
  constructor();
6
7
  /** Fetch raw CCXT ticker (includes funding rate, OI, info). Returns null on error. */
@@ -0,0 +1,25 @@
1
+ import { type IntelApiDeps } from '../tools/intel-api.js';
2
+ import type { CcxtTicker, CcxtOHLCV } from '../types.js';
3
+ import type { OrderBookDepth } from '../simulator/types.js';
4
+ import type { PublicMarketDataApi } from './public-market-data-api.js';
5
+ type RawCcxt = Record<string, any>;
6
+ export declare class IntelPublicApi implements PublicMarketDataApi {
7
+ private readonly deps;
8
+ constructor(deps: IntelApiDeps);
9
+ /** Latest price for `symbol` as a CcxtTicker (last=bid=ask=intel 1m close;
10
+ * zero modeled spread — the paper fill engine models slippage itself).
11
+ * Returns null on any intel error, matching BinancePublicApi's contract. */
12
+ fetchTicker(symbol: string): Promise<CcxtTicker | null>;
13
+ /** Intel has no raw-ticker/funding/OI HTTP route yet — null (same as a 451). */
14
+ fetchTickerRaw(): Promise<RawCcxt | null>;
15
+ fetchFundingRate(): Promise<RawCcxt | null>;
16
+ fetchOpenInterest(): Promise<RawCcxt | null>;
17
+ /** No intel depth route yet — null. The paper fill engine falls back to
18
+ * random slippage when the book is absent (see helpers.ts fetchOrderBook). */
19
+ fetchOrderBook(): Promise<OrderBookDepth | null>;
20
+ /** No intel candle route yet — null (candle-consuming analysis tools degrade
21
+ * exactly as they do under a 451; the agent's decisioning reads intel
22
+ * signals/scan tools, not raw candles). */
23
+ fetchOHLCV(): Promise<CcxtOHLCV[] | null>;
24
+ }
25
+ export {};
@@ -0,0 +1,80 @@
1
+ // Intel-backed public market-data source for PAPER mode on hosts that Binance
2
+ // geo-blocks (HTTP 451). Paper execution is fully simulated; it only needs a
3
+ // price, which the intel service serves from its own (permitted-region) feeds
4
+ // via GET /api/price/:symbol (latest 1m close, ≤~60s stale). See
5
+ // docs/AGENT_READINESS_GATE_PLAN.md sibling + the wisekid 451 findings.
6
+ //
7
+ // Selected only when plugin-config `paperMarketDataSource: 'intel'` AND paper
8
+ // mode (index.ts). Live trading NEVER uses this — live needs the real exchange.
9
+ //
10
+ // Increment 1 serves PRICE only (fetchTicker), which is the trading-critical
11
+ // path: create_order / close_position price + the PaperMarketFeed mark loop.
12
+ // Candles / order book / funding / OI have no intel HTTP route yet, so those
13
+ // return null here — the SAME shape a 451 produces today (no regression), and
14
+ // order-book absence is already handled by the fill engine's random-slippage
15
+ // fallback. Candle/book/OI intel routes are a follow-up.
16
+ import { logger } from '../logger.js';
17
+ import { fetchIntelApi } from '../tools/intel-api.js';
18
+ const TAG = 'intel-public';
19
+ /** CCXT symbol (`BTC/USDT`, `BTC/USDT:USDT`) → intel format (`BTCUSDT`). */
20
+ function toIntelSymbol(symbol) {
21
+ return symbol.replace('/', '').replace(/:.*$/, '');
22
+ }
23
+ export class IntelPublicApi {
24
+ deps;
25
+ constructor(deps) {
26
+ this.deps = deps;
27
+ logger.info(TAG, 'Intel public market-data source active (paper mode, Binance reads routed to intel)');
28
+ }
29
+ /** Latest price for `symbol` as a CcxtTicker (last=bid=ask=intel 1m close;
30
+ * zero modeled spread — the paper fill engine models slippage itself).
31
+ * Returns null on any intel error, matching BinancePublicApi's contract. */
32
+ async fetchTicker(symbol) {
33
+ const res = await fetchIntelApi(`/api/price/${toIntelSymbol(symbol)}`, this.deps);
34
+ if ('error' in res) {
35
+ logger.warn(TAG, `fetchTicker(${symbol}) via intel failed: ${res.error}`);
36
+ return null;
37
+ }
38
+ const body = res;
39
+ const price = Number(body.price);
40
+ if (!Number.isFinite(price) || price <= 0) {
41
+ logger.warn(TAG, `fetchTicker(${symbol}) via intel returned a bad price: ${body.price}`);
42
+ return null;
43
+ }
44
+ const timestamp = Number.isFinite(body.time) ? body.time : Date.now();
45
+ return {
46
+ // Key on the ORIGINAL ccxt symbol so the simulator's per-symbol maps line up.
47
+ symbol,
48
+ last: price,
49
+ bid: price,
50
+ ask: price,
51
+ baseVolume: 0,
52
+ quoteVolume: 0,
53
+ change: 0,
54
+ percentage: 0,
55
+ timestamp,
56
+ datetime: new Date(timestamp).toISOString(),
57
+ };
58
+ }
59
+ /** Intel has no raw-ticker/funding/OI HTTP route yet — null (same as a 451). */
60
+ async fetchTickerRaw() {
61
+ return null;
62
+ }
63
+ async fetchFundingRate() {
64
+ return null;
65
+ }
66
+ async fetchOpenInterest() {
67
+ return null;
68
+ }
69
+ /** No intel depth route yet — null. The paper fill engine falls back to
70
+ * random slippage when the book is absent (see helpers.ts fetchOrderBook). */
71
+ async fetchOrderBook() {
72
+ return null;
73
+ }
74
+ /** No intel candle route yet — null (candle-consuming analysis tools degrade
75
+ * exactly as they do under a 451; the agent's decisioning reads intel
76
+ * signals/scan tools, not raw candles). */
77
+ async fetchOHLCV() {
78
+ return null;
79
+ }
80
+ }
@@ -0,0 +1,12 @@
1
+ import type { CcxtTicker, CcxtOHLCV } from '../types.js';
2
+ import type { OrderBookDepth } from '../simulator/types.js';
3
+ type RawCcxt = Record<string, any>;
4
+ export interface PublicMarketDataApi {
5
+ fetchTickerRaw(symbol: string): Promise<RawCcxt | null>;
6
+ fetchTicker(symbol: string): Promise<CcxtTicker | null>;
7
+ fetchFundingRate(symbol: string): Promise<RawCcxt | null>;
8
+ fetchOpenInterest(symbol: string): Promise<RawCcxt | null>;
9
+ fetchOrderBook(symbol: string, limit?: number): Promise<OrderBookDepth | null>;
10
+ fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
11
+ }
12
+ export {};
@@ -0,0 +1,9 @@
1
+ // The public market-data surface paper mode reads (prices, candles, book,
2
+ // funding, OI). Both BinancePublicApi (direct Binance) and IntelPublicApi
3
+ // (via the intel service, for Binance-451-geo-blocked hosts) implement it, so
4
+ // the paper wiring can select a source without any consumer knowing which.
5
+ //
6
+ // Deliberately EXCLUDES probeReachability: that is Binance-specific and only
7
+ // the readiness reporter uses it — it must always probe the REAL Binance host
8
+ // to detect the 451, so it keeps a concrete BinancePublicApi, never this.
9
+ export {};
@@ -21,8 +21,23 @@ export interface PluginConfigFile {
21
21
  * prod runs the bridge under systemd and a supervisor there would
22
22
  * double-connect the relay room. Kill-switch: RC_CONNECTOR_SUPERVISOR=off. */
23
23
  connectorSupervisor?: 'on' | 'off';
24
+ /** Where PAPER mode reads market-data prices from. 'binance' (default) hits
25
+ * Binance public endpoints directly; 'intel' routes them through the intel
26
+ * service (GET /api/price/:symbol) so a host that Binance geo-blocks (HTTP
27
+ * 451) can still paper-trade. PAPER-ONLY + advisory: live trading always
28
+ * uses the real exchange, and readiness still probes real Binance to detect
29
+ * the 451. Kill-switch: RC_PAPER_MARKET_DATA=binance forces the default. */
30
+ paperMarketDataSource?: 'binance' | 'intel';
24
31
  apiBaseUrl?: string;
25
32
  intelligenceUrl?: string;
33
+ /** Exchange credentials + venue selection. `exchange.venue` picks the
34
+ * trading venue ('binance' default | 'hyperliquid'); it is LOCAL mechanism
35
+ * (never central-pushable) per docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.2.
36
+ * Until Phase 3 ships the hyperliquid adapter, a non-PAPER mode on
37
+ * venue='hyperliquid' falls back to PAPER at boot. NOTE: updatePluginConfig
38
+ * replaces this block wholesale (the documented `exchange: null` clears
39
+ * credentials), so any writer (set_exchange_credentials, SSH edits) must
40
+ * carry `venue` through or it resets to the binance default. */
26
41
  exchange?: ExchangeConfig;
27
42
  tradingMode?: TradingMode;
28
43
  microLive?: {
package/index.js CHANGED
@@ -11,6 +11,7 @@ import { homedir } from 'node:os';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { BinancePublicApi } from './ccxt/binance-public.js';
14
+ import { IntelPublicApi } from './ccxt/intel-public.js';
14
15
  import { BinancePrivateApi } from './ccxt/binance-private.js';
15
16
  import { ExchangeSimulator } from './simulator/exchange-simulator.js';
16
17
  import { ShadowTracker } from './shadow/shadow-tracker.js';
@@ -18,7 +19,8 @@ import { StateManager } from './persistence/state-manager.js';
18
19
  import { logger, setLogLevel, formatError } from './logger.js';
19
20
  import { DEFAULT_CONFIG } from './types.js';
20
21
  import { PaperAdapter } from './paper-adapter.js';
21
- import { LiveAdapter } from './live/live-adapter.js';
22
+ import { createLiveAdapter, fillExchangeId, isLiveVenueSupported, parseVenue, } from './venues/registry.js';
23
+ import { HyperliquidPublicApi } from './venues/hyperliquid/hl-public.js';
22
24
  import { loadBracketMode } from './config/brackets-config.js';
23
25
  import { loadUserDataStreamMode, loadUserDataStreamTunables, loadUserDataStreamDbWrite, getUserDataStreamIngestBaseUrl, resolveIngestToken, resolveReefclawUserId, } from './config/user-data-stream-config.js';
24
26
  import { TradeStoreClient } from './ingest/trade-store-client.js';
@@ -912,6 +914,14 @@ const paperTradingPlugin = {
912
914
  let intelligenceUrl = 'https://intel.reefclaw.com';
913
915
  let exchangeConfig = null;
914
916
  let tradingMode = 'PAPER';
917
+ // Trading venue (multi-venue Phase 0 — docs/HYPERLIQUID_INTEGRATION_PLAN.md).
918
+ // Absent config field = 'binance', so every pre-venue install is untouched.
919
+ let venue = 'binance';
920
+ // exchange.testnet as written on disk — needed independently of
921
+ // exchangeConfig because the hyperliquid venue never builds a Binance
922
+ // credential config (Phase 0 gate) but its keyless public API still needs
923
+ // the mainnet/testnet routing.
924
+ let venueTestnet = false;
915
925
  let signalsEvaluator = 'central';
916
926
  let signalsSymbols = [];
917
927
  try {
@@ -928,9 +938,18 @@ const paperTradingPlugin = {
928
938
  if (Array.isArray(rcConfig?.signals?.symbols)) {
929
939
  signalsSymbols = rcConfig.signals.symbols.map((s) => String(s).toUpperCase());
930
940
  }
931
- // Read exchange credentials (Phase 9b)
941
+ // Read exchange credentials (Phase 9b) + venue (multi-venue Phase 0)
932
942
  const exchange = rcConfig?.exchange;
933
- if (exchange?.apiKey && exchange?.secret) {
943
+ const venueParse = parseVenue(exchange?.venue);
944
+ venue = venueParse.venue;
945
+ venueTestnet = exchange?.testnet === true;
946
+ if (venueParse.unrecognized) {
947
+ logger.warn(TAG, `Unrecognized exchange.venue '${venueParse.unrecognized}' in plugin-config — treating as 'binance'`);
948
+ }
949
+ // apiKey/secret are a BINANCE credential pair; never build a Binance
950
+ // client config for another venue (a hyperliquid block carries
951
+ // walletAddress/agentPrivateKey instead — consumed from Phase 3 on).
952
+ if (venue === 'binance' && exchange?.apiKey && exchange?.secret) {
934
953
  exchangeConfig = {
935
954
  apiKey: exchange.apiKey,
936
955
  secret: exchange.secret,
@@ -964,6 +983,14 @@ const paperTradingPlugin = {
964
983
  else {
965
984
  logger.warn(TAG, 'No connection token found — get_market_intel will return errors');
966
985
  }
986
+ // Venue support gate (multi-venue Phase 0). Checked BEFORE the credentials
987
+ // gate so a hyperliquid config gets the accurate diagnosis ("venue not
988
+ // supported yet"), not a misleading "missing API keys". Fallback-to-PAPER,
989
+ // never a throw — register() must stay non-crashing.
990
+ if (tradingMode !== 'PAPER' && !isLiveVenueSupported(venue)) {
991
+ logger.warn(TAG, `Trading mode ${tradingMode} on venue '${venue}' is not supported in this build — falling back to PAPER (live support arrives in Phase 3 of docs/HYPERLIQUID_INTEGRATION_PLAN.md)`);
992
+ tradingMode = 'PAPER';
993
+ }
967
994
  // Validate trading mode vs credentials
968
995
  if (tradingMode !== 'PAPER' && !exchangeConfig) {
969
996
  logger.warn(TAG, `Trading mode ${tradingMode} requires exchange API keys — falling back to PAPER`);
@@ -1063,6 +1090,9 @@ const paperTradingPlugin = {
1063
1090
  // closure — only invoked on a fill, long after `runtime` is built), so it
1064
1091
  // follows a live<->paper reconnect.
1065
1092
  resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1093
+ // Venue tag (positions.exchange, migration 0058) — static per process;
1094
+ // a venue change requires config edit + restart.
1095
+ venue,
1066
1096
  };
1067
1097
  if (positionDecisionsClient) {
1068
1098
  logger.info(TAG, `Journal close-on-reduce-only-fill ${closeOnReduceOnlyFill ? 'ENABLED' : 'disabled'}`);
@@ -1146,6 +1176,11 @@ const paperTradingPlugin = {
1146
1176
  tradeIngest = {
1147
1177
  client: new TradeStoreClient({ baseUrl: ingestBaseUrl, ingestToken }),
1148
1178
  userId: reefclawUserId,
1179
+ // Venue-derived FillEvent.exchange — 'binance_futures' for the
1180
+ // binance venue, i.e. byte-identical to the pre-venue literal
1181
+ // WsIngest defaulted to. Half of the (exchange, exchange_trade_id)
1182
+ // audit-trail idempotency key; never a fresh string literal.
1183
+ exchange: fillExchangeId(venue),
1149
1184
  };
1150
1185
  logger.info(TAG, `User-data stream dbWrite=on — WS audit-trail ingest wired to ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1151
1186
  }
@@ -1203,7 +1238,12 @@ const paperTradingPlugin = {
1203
1238
  logger.warn(TAG, `approval mode=${approvalModeForTool} but WEBAPP_INGEST_TOKEN or REEFCLAW_USER_ID env missing — proposal path disabled`);
1204
1239
  approvalModeForTool = 'off';
1205
1240
  }
1206
- const liveAdapter = new LiveAdapter(exchangeConfig, tradingMode, microLiveConfig, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, autoCapture);
1241
+ // Venue-dispatched construction (multi-venue Phase 0). For 'binance'
1242
+ // this is a pure pass-through to `new LiveAdapter(...)` — identical
1243
+ // args, identical behavior; the unsupported-venue arm is unreachable
1244
+ // here because the isLiveVenueSupported() gate above already fell back
1245
+ // to PAPER. Phase 3 adds the hyperliquid adapter inside the factory.
1246
+ const liveAdapter = createLiveAdapter(venue, exchangeConfig, tradingMode, microLiveConfig, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, autoCapture);
1207
1247
  adapter = liveAdapter;
1208
1248
  // ---- Approval-mode Phase B — start ProposalDecisionListener ----
1209
1249
  // Started only when approval.mode='per_trade' AND proposalManagerCtx
@@ -1320,11 +1360,64 @@ const paperTradingPlugin = {
1320
1360
  else {
1321
1361
  adapter = new PaperAdapter(simulator);
1322
1362
  }
1363
+ // PAPER market-data source selection.
1364
+ //
1365
+ // Venue precedence (Hyperliquid Phase 1 — docs/HYPERLIQUID_INTEGRATION_PLAN.md):
1366
+ // the configured VENUE decides which exchange prices paper mode, full stop.
1367
+ // venue='hyperliquid' → every public read (PaperMarketFeed marks, the
1368
+ // simulator's fill prices, fetch_ticker/fetch_ohlcv/get_orderbook tools)
1369
+ // comes from Hyperliquid's keyless /info endpoints; the Binance-only
1370
+ // `paperMarketDataSource:'intel'` escape hatch does not apply (intel has
1371
+ // no HL rows until Phase 2 — silently serving Binance prices for a
1372
+ // Hyperliquid book would be a lie, the exact class the symbol-translation
1373
+ // rule forbids).
1374
+ //
1375
+ // Binance venue (default) is unchanged: a host Binance geo-blocks (HTTP
1376
+ // 451) can route PRICE reads through the intel service by setting
1377
+ // plugin-config `paperMarketDataSource:'intel'` (GET /api/price/:symbol).
1378
+ // LIVE always uses the real exchange, and the readiness reporter probes
1379
+ // the CONFIGURED venue. Kill-switch RC_PAPER_MARKET_DATA=binance forces
1380
+ // the Binance-direct default (binance venue only).
1381
+ //
1382
+ // hlPublicApi is constructed once per process when the venue is
1383
+ // hyperliquid — shared by the paper data path and the readiness probe.
1384
+ const hlPublicApi = venue === 'hyperliquid' ? new HyperliquidPublicApi({ testnet: venueTestnet }) : null;
1385
+ let configuredPaperSource;
1386
+ try {
1387
+ configuredPaperSource = readPluginConfig().paperMarketDataSource;
1388
+ }
1389
+ catch {
1390
+ configuredPaperSource = undefined;
1391
+ }
1392
+ const paperDataSource = process.env.RC_PAPER_MARKET_DATA === 'binance'
1393
+ ? 'binance'
1394
+ : configuredPaperSource ?? 'binance';
1395
+ const useIntelPaperData = venue === 'binance' &&
1396
+ !adapter.isLive && paperDataSource === 'intel' && connectionToken.length > 0;
1397
+ const marketDataApi = !adapter.isLive && hlPublicApi
1398
+ ? hlPublicApi
1399
+ : useIntelPaperData
1400
+ ? new IntelPublicApi({ connectionToken, intelligenceUrl })
1401
+ : binanceApi;
1402
+ if (!adapter.isLive && hlPublicApi) {
1403
+ logger.info(TAG, `PAPER market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1404
+ if (paperDataSource === 'intel') {
1405
+ logger.warn(TAG, 'paperMarketDataSource=intel is a Binance-venue option — ignored on venue=hyperliquid (intel has no Hyperliquid rows until Phase 2)');
1406
+ }
1407
+ }
1408
+ else if (useIntelPaperData) {
1409
+ logger.warn(TAG, 'PAPER market data routed through intel (paperMarketDataSource=intel) — prices from GET /api/price; live + readiness still use Binance directly');
1410
+ }
1411
+ else if (venue === 'binance' && !adapter.isLive && paperDataSource === 'intel' && connectionToken.length === 0) {
1412
+ logger.warn(TAG, 'paperMarketDataSource=intel requested but no connection token — falling back to Binance direct (which will 451 on a geo-blocked host)');
1413
+ }
1323
1414
  // Build tool dependencies
1324
1415
  // `simDeps` for tools that need the simulator directly (ticker, market structure, risk scenario)
1325
1416
  // `adapterDeps` for the 7 adapter-based trading tools
1326
- const simDeps = { binanceApi, simulator };
1327
- const adapterDeps = { binanceApi, adapter };
1417
+ // Both carry `marketDataApi` (Binance direct, or intel in paper mode) so
1418
+ // every paper market-data read follows the selected source at once.
1419
+ const simDeps = { binanceApi: marketDataApi, simulator };
1420
+ const adapterDeps = { binanceApi: marketDataApi, adapter };
1328
1421
  // ---- Plugin runtime (mutable holder) ----
1329
1422
  // Owns the adapter + mode + stop-watcher and exposes `reconnect()` so the
1330
1423
  // operator can swap credentials / trading mode from the dashboard without
@@ -1357,7 +1450,7 @@ const paperTradingPlugin = {
1357
1450
  // resting limits fill. Paper-only — started here when the boot adapter is
1358
1451
  // paper; PluginRuntime.reconnect() stops it on a switch to live and
1359
1452
  // restarts it on a switch back to paper. Reuses the ban-gated fetchTicker.
1360
- const marketFeed = new PaperMarketFeed(binanceApi, simulator);
1453
+ const marketFeed = new PaperMarketFeed(marketDataApi, simulator);
1361
1454
  if (!adapter.isLive) {
1362
1455
  marketFeed.start();
1363
1456
  }
@@ -2022,16 +2115,19 @@ const paperTradingPlugin = {
2022
2115
  pluginInitialised = true;
2023
2116
  logger.info(TAG, `Registered ${gatedTools.length} tools (gate mode=${toolGate.getMode()}): ${toolNames.join(', ')}. Plugin v3.8.0 (${runtime.mode} mode)`);
2024
2117
  // Agent-readiness reporter (docs/AGENT_READINESS_GATE_PLAN.md Phase 1):
2025
- // probe host→Binance reachability (HTTP 451 geo-block) + clock drift on the
2026
- // host and POST a plain-English report to the webapp, so a silently-broken
2027
- // agent shows an actionable dashboard alert instead of a false green.
2028
- // Advisory + fire-and-forget; no token no-op. Runs here once (guarded by
2029
- // the pluginInitialised early-return once per process) + on an unref'd
2030
- // interval inside the reporter.
2118
+ // probe host→venue reachability (Binance HTTP 451 geo-block / Hyperliquid
2119
+ // /info) + clock drift on the host and POST a plain-English report to the
2120
+ // webapp, so a silently-broken agent shows an actionable dashboard alert
2121
+ // instead of a false green. Probes ONLY the configured venue on a
2122
+ // Binance-451 host trading Hyperliquid a Binance probe would be a
2123
+ // permanent false alarm. Advisory + fire-and-forget; no token → no-op.
2124
+ // Runs here once (guarded by the pluginInitialised early-return → once per
2125
+ // process) + on an unref'd interval inside the reporter.
2031
2126
  startReadinessReporter({
2032
2127
  apiBaseUrl,
2033
2128
  token: resolveIngestToken({ connectionToken }),
2034
- binanceApi,
2129
+ venue,
2130
+ publicApi: hlPublicApi ?? binanceApi,
2035
2131
  toolCount: toolNames.length,
2036
2132
  });
2037
2133
  maybeStartConnectorSupervisor();
@@ -21,6 +21,11 @@ export interface AutoCaptureContext {
21
21
  * from the current adapter (so it follows a runtime reconnect). Tags the
22
22
  * journal position row so paper and live entries can be segregated. */
23
23
  resolveMode?: () => 'paper' | 'live';
24
+ /** Venue this gateway trades ('binance' | 'hyperliquid'). Static per process
25
+ * — changing venue requires a config edit + restart (plan §5.2), so unlike
26
+ * `resolveMode` this is a plain value, not a resolver. Tags journal rows
27
+ * (positions.exchange, migration 0058). */
28
+ venue?: 'binance' | 'hyperliquid';
24
29
  }
25
30
  export interface CreateOrderInputs {
26
31
  symbol: string;
@@ -85,6 +85,7 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
85
85
  currentSize: filledQty,
86
86
  avgEntryPrice: fillPrice,
87
87
  mode: ctx.resolveMode?.(),
88
+ exchange: ctx.venue,
88
89
  };
89
90
  // postPosition is awaited — we need the UUID before posting the entry row.
90
91
  const positionId = await ctx.decisionsClient.postPosition(ctx.userId, upsert);
@@ -253,6 +254,7 @@ export async function onWsFillObserved(ctx, fill) {
253
254
  currentSize: fill.fillSize,
254
255
  avgEntryPrice: fill.fillPrice,
255
256
  mode: ctx.resolveMode?.(),
257
+ exchange: ctx.venue,
256
258
  };
257
259
  const positionId = await ctx.decisionsClient.postPosition(ctx.userId, upsert);
258
260
  if (!positionId) {
@@ -332,8 +334,12 @@ async function handleReduceOnlyExit(ctx, fill) {
332
334
  regimeConfidence: 0.5,
333
335
  fillPrice: fill.fillPrice,
334
336
  fillSize: Math.abs(fill.fillSize),
335
- // realizedPnl is Binance-exact (summed across exit fills). When it's a true
336
- // break-even 0 the webapp recomputes from entries server-side harmless.
337
+ // realizedPnl is Binance-exact (summed across exit fills); the zeros below
338
+ // are filled in PER-METRIC by the webapp close route (price-based R from
339
+ // the pinned invalidation_price, mfe/give-back from the last review — see
340
+ // webapp lib/api/close-metrics.ts). The route's old all-four-zero gate
341
+ // meant this non-zero PnL used to suppress that fill-in entirely, persisting
342
+ // realized_r=0 on every bracket_fill close (the 2026-07-11 zero-hole).
337
343
  realizedPnl,
338
344
  realizedR: 0,
339
345
  mfeRAtClose: 0,
@@ -17,6 +17,10 @@ export interface PositionUpsertPayload {
17
17
  * journal row so paper and live positions can be segregated. Omitted only
18
18
  * if the active adapter can't be resolved (treated as legacy/NULL). */
19
19
  mode?: 'paper' | 'live';
20
+ /** Venue the position was opened on ('binance' | 'hyperliquid') — journal /
21
+ * analytics segmentation (fees + funding cadence differ per venue).
22
+ * Absent → NULL (legacy binance), mirroring `mode`. Migration 0058. */
23
+ exchange?: string;
20
24
  }
21
25
  export interface PositionEntryPayload {
22
26
  positionId: string;
@@ -1,9 +1,23 @@
1
- import { type ReadinessReport } from '@reefclaw/shared';
2
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import { type ReadinessReport, type VenueId } from '@reefclaw/shared';
2
+ /** Venue-agnostic reachability probe BinancePublicApi.probeReachability and
3
+ * HyperliquidPublicApi.probeReachability both return exactly this shape. */
4
+ export interface VenueReachabilityProbe {
5
+ probeReachability(): Promise<{
6
+ outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
7
+ driftMs: number | null;
8
+ }>;
9
+ }
3
10
  export interface ReadinessReporterOptions {
4
11
  apiBaseUrl: string;
5
12
  token: string;
6
- binanceApi: Pick<BinancePublicApi, 'probeReachability'>;
13
+ /** Which venue this agent trades — decides the reachability check id
14
+ * (binance_reachable vs hyperliquid_reachable) and which host `publicApi`
15
+ * probes. The reporter probes ONLY the configured venue: on a
16
+ * Binance-451-blocked host trading Hyperliquid, a Binance probe would be
17
+ * a permanent false alarm (plan §5.11). */
18
+ venue: VenueId;
19
+ /** Public API of the CONFIGURED venue (probe only). */
20
+ publicApi: VenueReachabilityProbe;
7
21
  /** Number of trading tools registered (a health signal). */
8
22
  toolCount: number;
9
23
  fetchImpl?: typeof fetch;
@@ -11,8 +25,17 @@ export interface ReadinessReporterOptions {
11
25
  requestTimeoutMs?: number;
12
26
  }
13
27
  /** Run every connect-phase check once and assemble the report. Exported for
14
- * unit tests. */
15
- export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'binanceApi' | 'toolCount'>): Promise<ReadinessReport>;
28
+ * unit tests.
29
+ *
30
+ * `bootWarmup` marks the FIRST cycle, which fires during gateway boot while
31
+ * the event loop is congested (WS start, snapshot, seed all racing). That
32
+ * delay between the probe's server-time response and the local `Date.now()`
33
+ * read inflates the apparent clock drift — observed −4112ms at boot
34
+ * self-correcting to −108ms on the next 5-min cycle. During warm-up a
35
+ * warn/fail drift is reported 'unknown' (not amber/red) so the readiness
36
+ * banner doesn't cry-wolf for ~5 min after every restart; a genuinely
37
+ * skewed clock still surfaces on cycle 2. */
38
+ export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount'>, bootWarmup?: boolean): Promise<ReadinessReport>;
16
39
  /** Test-only — reset the singleton guard between unit tests. */
17
40
  export declare function __resetReadinessReporterForTests(): void;
18
41
  /** Fire the readiness report once at boot then on an unref'd interval. */
@@ -24,10 +24,24 @@ function resolveIntervalMs(explicit) {
24
24
  return Math.max(MIN_INTERVAL_MS, raw);
25
25
  }
26
26
  /** Run every connect-phase check once and assemble the report. Exported for
27
- * unit tests. */
28
- export async function collectReadiness(opts) {
27
+ * unit tests.
28
+ *
29
+ * `bootWarmup` marks the FIRST cycle, which fires during gateway boot while
30
+ * the event loop is congested (WS start, snapshot, seed all racing). That
31
+ * delay between the probe's server-time response and the local `Date.now()`
32
+ * read inflates the apparent clock drift — observed −4112ms at boot
33
+ * self-correcting to −108ms on the next 5-min cycle. During warm-up a
34
+ * warn/fail drift is reported 'unknown' (not amber/red) so the readiness
35
+ * banner doesn't cry-wolf for ~5 min after every restart; a genuinely
36
+ * skewed clock still surfaces on cycle 2. */
37
+ export async function collectReadiness(opts, bootWarmup = false) {
29
38
  const now = Date.now();
30
39
  const checks = [];
40
+ // The venue decides which reachability check this report carries; the copy
41
+ // for both ids lives in shared/src/readiness.ts. Clock drift comes from the
42
+ // same probe on both venues (Binance fapi serverTime / Hyperliquid
43
+ // exchangeStatus.time — live-verified 2026-07-11).
44
+ const reachId = opts.venue === 'hyperliquid' ? 'hyperliquid_reachable' : 'binance_reachable';
31
45
  // plugin_loaded — trivially true (this code runs inside the loaded plugin),
32
46
  // but a positive row is what proves the report path is alive at all.
33
47
  checks.push(makeReadinessCheck('plugin_loaded', 'pass', { checkedAt: now }));
@@ -36,37 +50,46 @@ export async function collectReadiness(opts) {
36
50
  detail: `${opts.toolCount} tools`,
37
51
  checkedAt: now,
38
52
  }));
39
- // binance_reachable (+ clock drift from the same probe response)
40
- const probe = await opts.binanceApi.probeReachability();
53
+ // venue reachability (+ clock drift from the same probe response)
54
+ const probe = await opts.publicApi.probeReachability();
41
55
  if (probe.outcome === 'reachable') {
42
- checks.push(makeReadinessCheck('binance_reachable', 'pass', { checkedAt: now }));
56
+ checks.push(makeReadinessCheck(reachId, 'pass', { checkedAt: now }));
43
57
  const drift = probe.driftMs;
44
58
  if (drift == null) {
45
59
  checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
46
60
  }
47
61
  else {
48
62
  const abs = Math.abs(drift);
49
- const status = abs <= 1000 ? 'pass' : abs <= 5000 ? 'warn' : 'fail';
63
+ const rawStatus = abs <= 1000 ? 'pass' : abs <= 5000 ? 'warn' : 'fail';
64
+ // Boot warm-up: a non-pass reading on the first cycle is untrustworthy
65
+ // (event-loop congestion inflates the measured drift) — report 'unknown'
66
+ // and let the next cycle confirm, rather than flash the banner amber/red.
67
+ const status = bootWarmup && rawStatus !== 'pass' ? 'unknown' : rawStatus;
50
68
  checks.push(makeReadinessCheck('clock_in_sync', status, {
51
- detail: `drift ${Math.round(drift)}ms`,
69
+ detail: bootWarmup && rawStatus !== 'pass'
70
+ ? `drift ${Math.round(drift)}ms (boot warm-up — rechecking)`
71
+ : `drift ${Math.round(drift)}ms`,
52
72
  checkedAt: now,
53
73
  }));
54
74
  }
55
75
  }
56
76
  else if (probe.outcome === 'geo_blocked') {
57
- checks.push(makeReadinessCheck('binance_reachable', 'fail', { detail: 'HTTP 451', checkedAt: now }));
77
+ checks.push(makeReadinessCheck(reachId, 'fail', {
78
+ detail: opts.venue === 'binance' ? 'HTTP 451' : 'blocked (HTTP 451/403)',
79
+ checkedAt: now,
80
+ }));
58
81
  checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
59
82
  }
60
83
  else if (probe.outcome === 'unreachable') {
61
84
  // Network/DNS/timeout — could be transient, so warn (amber) rather than
62
85
  // asserting a definitive failure. A persistent problem stays amber across
63
- // re-checks; only the 451 geo-block is a hard red.
64
- checks.push(makeReadinessCheck('binance_reachable', 'warn', { detail: 'unreachable', checkedAt: now }));
86
+ // re-checks; only the geo-block is a hard red.
87
+ checks.push(makeReadinessCheck(reachId, 'warn', { detail: 'unreachable', checkedAt: now }));
65
88
  checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
66
89
  }
67
90
  else {
68
91
  // 'unknown' — the ban/weight gate paused the probe; don't assert anything.
69
- checks.push(makeReadinessCheck('binance_reachable', 'unknown', { checkedAt: now }));
92
+ checks.push(makeReadinessCheck(reachId, 'unknown', { checkedAt: now }));
70
93
  checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
71
94
  }
72
95
  return {
@@ -74,7 +97,7 @@ export async function collectReadiness(opts) {
74
97
  generatedAt: now,
75
98
  overall: deriveOverallReadiness(checks),
76
99
  checks,
77
- agent: { pluginVersion: PLUGIN_VERSION, toolCount: opts.toolCount },
100
+ agent: { pluginVersion: PLUGIN_VERSION, toolCount: opts.toolCount, venue: opts.venue },
78
101
  };
79
102
  }
80
103
  async function postReadiness(apiBaseUrl, token, report, fetchImpl, timeoutMs) {
@@ -119,12 +142,9 @@ export function startReadinessReporter(opts) {
119
142
  const fetchImpl = opts.fetchImpl ?? fetch;
120
143
  const intervalMs = resolveIntervalMs(opts.intervalMs);
121
144
  const timeoutMs = opts.requestTimeoutMs ?? 10_000;
122
- const cycle = async () => {
145
+ const cycle = async (bootWarmup) => {
123
146
  try {
124
- const report = await collectReadiness({
125
- binanceApi: opts.binanceApi,
126
- toolCount: opts.toolCount,
127
- });
147
+ const report = await collectReadiness({ venue: opts.venue, publicApi: opts.publicApi, toolCount: opts.toolCount }, bootWarmup);
128
148
  await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
129
149
  if (report.overall === 'fail') {
130
150
  const failing = report.checks.filter((c) => c.status === 'fail').map((c) => c.id).join(', ');
@@ -135,8 +155,9 @@ export function startReadinessReporter(opts) {
135
155
  logger.warn(TAG, `readiness cycle failed: ${formatError(err)}`);
136
156
  }
137
157
  };
138
- void cycle();
139
- const timer = setInterval(() => void cycle(), intervalMs);
158
+ // First fire is the boot cycle (warm-up); the interval cycles are steady-state.
159
+ void cycle(true);
160
+ const timer = setInterval(() => void cycle(false), intervalMs);
140
161
  timer.unref();
141
162
  logger.info(TAG, `readiness reporter started (interval ${Math.round(intervalMs / 1000)}s)`);
142
163
  }
@@ -1,3 +1,4 @@
1
+ import type { VenueId } from '@reefclaw/shared';
1
2
  import type { BracketId, BracketRole } from './bracket-types.js';
2
3
  /** Generate a fresh 16-char hex bracketId. */
3
4
  export declare function generateBracketId(): BracketId;
@@ -16,3 +17,11 @@ export declare function parseBracketCid(cid: string): {
16
17
  /** Cheap type-guard for "is this a reefclaw-managed bracket clientOrderId"
17
18
  * — true for both the current and legacy schemes. */
18
19
  export declare function isBracketCid(cid: string): boolean;
20
+ /** Venue-aware parseBracketCid. Binance delegates to the existing dual-scheme
21
+ * parser; other venues return null until their scheme ships. */
22
+ export declare function parseBracketClientId(venue: VenueId, cid: string): {
23
+ bracketId: BracketId;
24
+ role: BracketRole;
25
+ } | null;
26
+ /** Venue-aware isBracketCid. */
27
+ export declare function isBracketClientId(venue: VenueId, cid: string): boolean;
@@ -79,3 +79,21 @@ export function parseBracketCid(cid) {
79
79
  export function isBracketCid(cid) {
80
80
  return CID_REGEX.test(cid) || LEGACY_CID_REGEX.test(cid);
81
81
  }
82
+ // ---- Venue-dispatched recognition (multi-venue Phase 0 seam) ----
83
+ //
84
+ // ALL bracket client-order-id recognition stays centralized in THIS module —
85
+ // the brackets.md rule ("never hardcode an rc-/bkt regex elsewhere") extends
86
+ // per-venue. Binance = the bkt/rc- schemes above. Hyperliquid client order
87
+ // ids are 128-bit hex cloids with a different scheme (hl-cloid.ts, Phase 3 of
88
+ // docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.7) and will be dispatched from
89
+ // here; until then the hyperliquid arm recognises nothing, which is correct —
90
+ // this build never emits an HL bracket order.
91
+ /** Venue-aware parseBracketCid. Binance delegates to the existing dual-scheme
92
+ * parser; other venues return null until their scheme ships. */
93
+ export function parseBracketClientId(venue, cid) {
94
+ return venue === 'binance' ? parseBracketCid(cid) : null;
95
+ }
96
+ /** Venue-aware isBracketCid. */
97
+ export function isBracketClientId(venue, cid) {
98
+ return venue === 'binance' ? isBracketCid(cid) : false;
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "ReefClaw trading plugin for OpenClaw \u2014 paper trading with real Binance market data, plus the ReefClaw dashboard connector (supervised by OpenClaw, no service manager needed). Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -22,7 +22,7 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
- "@reefclaw/shared": "0.1.0",
25
+ "@reefclaw/shared": "0.1.1",
26
26
  "ccxt": "4.5.37",
27
27
  "json5": "2.2.3",
28
28
  "ws": "8.19.0"
@@ -1,11 +1,11 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
3
  import type { CcxtOrder } from '../types.js';
4
4
  import { type ClosePositionArgs } from './assessment-validation.js';
5
5
  import { type AutoCaptureContext } from '../ingest/position-auto-capture.js';
6
6
  import { type ExitGateMode } from '../config/position-review-config.js';
7
7
  export declare function closePositionTool(args: ClosePositionArgs, deps: {
8
- binanceApi: BinancePublicApi;
8
+ binanceApi: PublicMarketDataApi;
9
9
  adapter: IExchangeAdapter;
10
10
  autoCapture?: AutoCaptureContext;
11
11
  /** Override for tests — production reads from plugin-config.json on each
@@ -1,4 +1,4 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
3
  import type { CcxtOrder } from '../types.js';
4
4
  import { type AutoCaptureContext } from '../ingest/position-auto-capture.js';
@@ -21,7 +21,7 @@ export declare function createOrderTool(args: {
21
21
  realization_rule?: unknown;
22
22
  supersedes_id?: string;
23
23
  }, deps: {
24
- binanceApi: BinancePublicApi;
24
+ binanceApi: PublicMarketDataApi;
25
25
  adapter: IExchangeAdapter;
26
26
  autoCapture?: AutoCaptureContext;
27
27
  /** Approval-mode wiring. The same ProposalManager handles both modes;
@@ -1,11 +1,11 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  import type { CcxtOHLCV } from '../types.js';
3
3
  export declare function fetchOhlcvTool(args: {
4
4
  symbol: string;
5
5
  timeframe?: string;
6
6
  limit?: number;
7
7
  }, deps: {
8
- binanceApi: BinancePublicApi;
8
+ binanceApi: PublicMarketDataApi;
9
9
  }): Promise<CcxtOHLCV[] | {
10
10
  error: string;
11
11
  }>;
@@ -1,10 +1,10 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  import type { ExchangeSimulator } from '../simulator/exchange-simulator.js';
3
3
  import type { CcxtTicker } from '../types.js';
4
4
  export declare function fetchTickerTool(args: {
5
5
  symbol: string;
6
6
  }, deps: {
7
- binanceApi: BinancePublicApi;
7
+ binanceApi: PublicMarketDataApi;
8
8
  simulator: ExchangeSimulator;
9
9
  }): Promise<CcxtTicker | {
10
10
  error: string;
@@ -1,4 +1,4 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  export interface CryptoMetricsResult {
3
3
  symbol: string;
4
4
  fundingRate: number | null;
@@ -12,7 +12,7 @@ export interface CryptoMetricsResult {
12
12
  export declare function getCryptoMetricsTool(args: {
13
13
  symbol: string;
14
14
  }, deps: {
15
- binanceApi: BinancePublicApi;
15
+ binanceApi: PublicMarketDataApi;
16
16
  }): Promise<CryptoMetricsResult | {
17
17
  error: string;
18
18
  }>;
@@ -1,4 +1,4 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  /**
3
3
  * Average True Range using Wilder's smoothing.
4
4
  * Input: OHLCV candles array, period (default 14).
@@ -41,7 +41,7 @@ export declare function getMarketStructureTool(args: {
41
41
  symbol: string;
42
42
  timeframes?: string[];
43
43
  }, deps: {
44
- binanceApi: BinancePublicApi;
44
+ binanceApi: PublicMarketDataApi;
45
45
  }): Promise<MarketStructureResult | {
46
46
  error: string;
47
47
  }>;
@@ -1,4 +1,4 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  export interface OrderbookResult {
3
3
  symbol: string;
4
4
  bids: [number, number][];
@@ -15,7 +15,7 @@ export declare function getOrderbookTool(args: {
15
15
  symbol: string;
16
16
  depth?: number;
17
17
  }, deps: {
18
- binanceApi: BinancePublicApi;
18
+ binanceApi: PublicMarketDataApi;
19
19
  }): Promise<OrderbookResult | {
20
20
  error: string;
21
21
  }>;
@@ -1,4 +1,4 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  export interface VolumeAnalysisResult {
3
3
  symbol: string;
4
4
  timeframe: string;
@@ -15,7 +15,7 @@ export declare function getVolumeAnalysisTool(args: {
15
15
  symbol: string;
16
16
  timeframe?: string;
17
17
  }, deps: {
18
- binanceApi: BinancePublicApi;
18
+ binanceApi: PublicMarketDataApi;
19
19
  }): Promise<VolumeAnalysisResult | {
20
20
  error: string;
21
21
  }>;
@@ -1,12 +1,13 @@
1
- import type { BinancePublicApi } from '../ccxt/binance-public.js';
1
+ import type { PublicMarketDataApi } from '../ccxt/public-market-data-api.js';
2
2
  import type { ExchangeSimulator } from '../simulator/exchange-simulator.js';
3
3
  import type { CcxtTicker } from '../types.js';
4
4
  /**
5
- * Fetch the latest ticker from Binance and update the simulator.
5
+ * Fetch the latest ticker from the configured market-data source (Binance, or
6
+ * intel in paper mode on a geo-blocked host) and update the simulator.
6
7
  * Returns the ticker on success, or an error object on failure.
7
8
  */
8
9
  export declare function fetchCurrentPrice(symbol: string, deps: {
9
- binanceApi: BinancePublicApi;
10
+ binanceApi: PublicMarketDataApi;
10
11
  simulator: ExchangeSimulator;
11
12
  }): Promise<CcxtTicker | {
12
13
  error: string;
@@ -16,7 +17,7 @@ export declare function fetchCurrentPrice(symbol: string, deps: {
16
17
  * Best-effort: never throws. Skips fetch if cached book is fresh enough.
17
18
  */
18
19
  export declare function fetchOrderBook(symbol: string, deps: {
19
- binanceApi: BinancePublicApi;
20
+ binanceApi: PublicMarketDataApi;
20
21
  simulator: ExchangeSimulator;
21
22
  }): Promise<void>;
22
23
  /** Type guard: check if the result is an error object */
package/tools/helpers.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Shared helpers for tool implementations.
2
2
  /**
3
- * Fetch the latest ticker from Binance and update the simulator.
3
+ * Fetch the latest ticker from the configured market-data source (Binance, or
4
+ * intel in paper mode on a geo-blocked host) and update the simulator.
4
5
  * Returns the ticker on success, or an error object on failure.
5
6
  */
6
7
  export async function fetchCurrentPrice(symbol, deps) {
package/types.d.ts CHANGED
@@ -102,9 +102,28 @@ export interface PluginConfig {
102
102
  quoteCurrency: string;
103
103
  }
104
104
  export declare const DEFAULT_CONFIG: PluginConfig;
105
- /** Exchange credentials for shadow/live modes. Read from openclaw.json. */
105
+ /** Exchange credentials for shadow/live modes. Read from plugin-config.json's
106
+ * `exchange` block (openclaw.json legacy fallback). Which fields matter
107
+ * depends on `venue` (docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.2):
108
+ *
109
+ * binance (default, and the only live-supported venue until Phase 3) —
110
+ * apiKey + secret (HMAC pair) are required; walletAddress/agentPrivateKey
111
+ * are ignored.
112
+ * hyperliquid — walletAddress (MASTER wallet address, 0x…, used for
113
+ * queries; never its private key) + agentPrivateKey (an approved
114
+ * agent/API wallet's key — signs orders, cannot withdraw). apiKey/secret
115
+ * are meaningless. Boot falls back to PAPER for this venue until the
116
+ * Phase 3 adapter ships.
117
+ *
118
+ * apiKey/secret stay required at the type level because every constructed
119
+ * instance today feeds BinancePrivateApi; the on-disk JSON is parsed, not
120
+ * type-constructed, so an HL block without them is readable. Phase 3
121
+ * restructures this into a per-venue discriminated union. */
106
122
  export interface ExchangeConfig {
123
+ venue?: 'binance' | 'hyperliquid';
107
124
  apiKey: string;
108
125
  secret: string;
109
126
  testnet?: boolean;
127
+ walletAddress?: string;
128
+ agentPrivateKey?: string;
110
129
  }
@@ -0,0 +1,52 @@
1
+ import type { CcxtTicker, CcxtOHLCV } from '../../types.js';
2
+ import type { OrderBookDepth } from '../../simulator/types.js';
3
+ import type { PublicMarketDataApi } from '../../ccxt/public-market-data-api.js';
4
+ export interface HyperliquidPublicApiOptions {
5
+ testnet?: boolean;
6
+ /** Test seam — injected ccxt exchange instance. */
7
+ exchange?: any;
8
+ fetchImpl?: typeof fetch;
9
+ }
10
+ export declare class HyperliquidPublicApi implements PublicMarketDataApi {
11
+ private exchange;
12
+ private readonly testnet;
13
+ private readonly fetchImpl;
14
+ private readonly tickerTtlMs;
15
+ /** One upstream fetchTickers call serves every symbol within the TTL. */
16
+ private tickersCache;
17
+ private tickersInflight;
18
+ constructor(opts?: HyperliquidPublicApiOptions);
19
+ private baseUrl;
20
+ /** Canonical/ccxt symbol → this venue's ccxt symbol, or null (logged) when
21
+ * the symbol isn't a Hyperliquid USDC perp — a caller bug we surface
22
+ * loudly rather than silently translating quote assets. */
23
+ private venueSymbol;
24
+ /** Fetch-all-tickers with a short TTL + inflight dedup. Returns a map keyed
25
+ * by ccxt symbol, or null on failure. */
26
+ private getTickers;
27
+ fetchTickerRaw(symbol: string): Promise<Record<string, any> | null>;
28
+ /** Ticker from the cached all-assets snapshot. Hyperliquid's asset contexts
29
+ * carry mark/mid rather than a trade-tape bid/ask; absent fields fall back
30
+ * to `last` with zero modeled spread — the paper fill engine models
31
+ * slippage itself (same convention as IntelPublicApi). */
32
+ fetchTicker(symbol: string): Promise<CcxtTicker | null>;
33
+ fetchFundingRate(symbol: string): Promise<Record<string, any> | null>;
34
+ fetchOpenInterest(symbol: string): Promise<Record<string, any> | null>;
35
+ /** l2Book — Hyperliquid serves at most 20 levels/side (plan §3.6). */
36
+ fetchOrderBook(symbol: string, limit?: number): Promise<OrderBookDepth | null>;
37
+ /** candleSnapshot — only the most recent 5000 candles exist per (coin,
38
+ * interval) (plan §3.6); requests inside that window behave like Binance. */
39
+ fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
40
+ /** Probe Hyperliquid reachability from this host — the readiness gate's
41
+ * venue signal, mirroring BinancePublicApi.probeReachability's outcome
42
+ * shape. Hand-rolled POST /info `exchangeStatus` (weight 2) so the probe
43
+ * has zero ccxt-method-shape dependence; the response's `time` field
44
+ * (verified live 2026-07-11: `{"specialStatuses":null,"time":…}`) doubles
45
+ * as the clock-drift source. Geo classification is best-effort — HL's
46
+ * API-level geo behavior is UNVERIFIED (plan §3.9); a 403/451 maps to
47
+ * geo_blocked, anything else non-2xx/network maps to unreachable. */
48
+ probeReachability(): Promise<{
49
+ outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
50
+ driftMs: number | null;
51
+ }>;
52
+ }
@@ -0,0 +1,285 @@
1
+ // Hyperliquid public market-data source (keyless) — Phase 1 of
2
+ // docs/HYPERLIQUID_INTEGRATION_PLAN.md. Implements PublicMarketDataApi so
3
+ // PAPER mode on venue='hyperliquid' prices the simulator, the stop-watcher,
4
+ // and every market-data tool from Hyperliquid instead of Binance. Live
5
+ // trading does NOT use this class (the Phase 3 adapter owns its own reads).
6
+ //
7
+ // Protocol facts (verified 2026-07-11, plan §3 + live probe):
8
+ // - REST is POST-only: /info on api.hyperliquid.xyz (testnet:
9
+ // api.hyperliquid-testnet.xyz). CCXT@4.5.37 wraps everything we need.
10
+ // - `exchangeStatus` (weight 2) returns `{specialStatuses, time}` — the
11
+ // reachability probe AND a clock-drift source in one call.
12
+ // - metaAndAssetCtxs-class info requests are weight 20 and return data for
13
+ // ALL assets at once → fetchTicker is served from one cached fetchTickers
14
+ // upstream call (TTL below), so N symbols cost the same as one. The full
15
+ // address/IP rate gate is a Phase 3 concern (live cadences); paper-mode
16
+ // cadence here is bounded by the cache: ≤ (60s/TTL) weight-20 calls/min
17
+ // (~300/1200 IP budget at the 4s default).
18
+ // - CCXT auto-monetization landmine (plan §3.8): initializeClient() only
19
+ // fires on AUTHENTICATED clients, and this class is keyless — but we pin
20
+ // `builderFee:false, refSet:true` in options anyway so a future
21
+ // credentialed refactor can never silently enroll CCXT's builder fee or
22
+ // referral code. hl-public.test.ts asserts these options forever.
23
+ //
24
+ // Error contract mirrors BinancePublicApi/IntelPublicApi: null on ANY failure,
25
+ // never throw to consumers. Binance-shaped symbols (…/USDT) are a caller bug
26
+ // on this venue — logged clearly, null returned (never silently translated).
27
+ import { createRequire } from 'node:module';
28
+ import { toCcxtSymbol } from '@reefclaw/shared';
29
+ import { logger } from '../../logger.js';
30
+ const TAG = 'hl-public';
31
+ // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
32
+ // (same pattern as binance-public.ts / binance-private.ts).
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ let ccxtCjs;
35
+ try {
36
+ const _require = createRequire(import.meta.url);
37
+ ccxtCjs = _require('ccxt');
38
+ }
39
+ catch {
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ ccxtCjs = require('ccxt');
42
+ }
43
+ /** Base URLs verified against the official docs + pinned ccxt source (plan §3.1). */
44
+ const HL_MAINNET_API = 'https://api.hyperliquid.xyz';
45
+ const HL_TESTNET_API = 'https://api.hyperliquid-testnet.xyz';
46
+ const DEFAULT_TICKER_TTL_MS = 4_000;
47
+ function resolveTickerTtlMs() {
48
+ const raw = Number(process.env.RC_HL_TICKER_TTL_MS);
49
+ if (!Number.isFinite(raw) || raw < 500)
50
+ return DEFAULT_TICKER_TTL_MS;
51
+ return raw;
52
+ }
53
+ export class HyperliquidPublicApi {
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ exchange;
56
+ testnet;
57
+ fetchImpl;
58
+ tickerTtlMs = resolveTickerTtlMs();
59
+ /** One upstream fetchTickers call serves every symbol within the TTL. */
60
+ tickersCache = null;
61
+ tickersInflight = null;
62
+ constructor(opts = {}) {
63
+ this.testnet = opts.testnet === true;
64
+ this.fetchImpl = opts.fetchImpl ?? fetch;
65
+ if (opts.exchange) {
66
+ this.exchange = opts.exchange;
67
+ }
68
+ else {
69
+ const HlClass = ccxtCjs.hyperliquid ?? ccxtCjs.default?.hyperliquid;
70
+ if (!HlClass) {
71
+ throw new Error('CCXT hyperliquid class not found — check ccxt version');
72
+ }
73
+ this.exchange = new HlClass({
74
+ enableRateLimit: true,
75
+ options: {
76
+ // ★ Never let CCXT enroll its own builder fee / referral code —
77
+ // plan §3.8. Keyless clients can't sign those actions anyway, but
78
+ // the pin is deliberate defense-in-depth for future refactors.
79
+ builderFee: false,
80
+ refSet: true,
81
+ },
82
+ });
83
+ if (this.testnet) {
84
+ this.exchange.setSandboxMode(true);
85
+ }
86
+ }
87
+ logger.info(TAG, `Hyperliquid public API initialized (no auth${this.testnet ? ', TESTNET' : ''})`);
88
+ }
89
+ baseUrl() {
90
+ return this.testnet ? HL_TESTNET_API : HL_MAINNET_API;
91
+ }
92
+ /** Canonical/ccxt symbol → this venue's ccxt symbol, or null (logged) when
93
+ * the symbol isn't a Hyperliquid USDC perp — a caller bug we surface
94
+ * loudly rather than silently translating quote assets. */
95
+ venueSymbol(symbol, ctx) {
96
+ try {
97
+ return toCcxtSymbol('hyperliquid', symbol);
98
+ }
99
+ catch (err) {
100
+ logger.warn(TAG, `${ctx}(${symbol}) — not a Hyperliquid symbol (${err instanceof Error ? err.message : String(err)}); use USDC pairs like BTC/USDC`);
101
+ return null;
102
+ }
103
+ }
104
+ /** Fetch-all-tickers with a short TTL + inflight dedup. Returns a map keyed
105
+ * by ccxt symbol, or null on failure. */
106
+ async getTickers() {
107
+ const now = Date.now();
108
+ if (this.tickersCache && now - this.tickersCache.at < this.tickerTtlMs) {
109
+ return this.tickersCache.bySymbol;
110
+ }
111
+ if (this.tickersInflight)
112
+ return this.tickersInflight;
113
+ this.tickersInflight = (async () => {
114
+ try {
115
+ const raw = await this.exchange.fetchTickers();
116
+ const bySymbol = new Map(Object.entries(raw ?? {}));
117
+ this.tickersCache = { at: Date.now(), bySymbol };
118
+ return bySymbol;
119
+ }
120
+ catch (err) {
121
+ logger.error(TAG, `fetchTickers failed: ${err instanceof Error ? err.message : String(err)}`);
122
+ return null;
123
+ }
124
+ finally {
125
+ this.tickersInflight = null;
126
+ }
127
+ })();
128
+ return this.tickersInflight;
129
+ }
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ async fetchTickerRaw(symbol) {
132
+ const vs = this.venueSymbol(symbol, 'fetchTickerRaw');
133
+ if (!vs)
134
+ return null;
135
+ const tickers = await this.getTickers();
136
+ return tickers?.get(vs) ?? null;
137
+ }
138
+ /** Ticker from the cached all-assets snapshot. Hyperliquid's asset contexts
139
+ * carry mark/mid rather than a trade-tape bid/ask; absent fields fall back
140
+ * to `last` with zero modeled spread — the paper fill engine models
141
+ * slippage itself (same convention as IntelPublicApi). */
142
+ async fetchTicker(symbol) {
143
+ const vs = this.venueSymbol(symbol, 'fetchTicker');
144
+ if (!vs)
145
+ return null;
146
+ const tickers = await this.getTickers();
147
+ const raw = tickers?.get(vs);
148
+ if (!raw) {
149
+ if (tickers)
150
+ logger.warn(TAG, `fetchTicker(${symbol}) — ${vs} not in Hyperliquid universe`);
151
+ return null;
152
+ }
153
+ const last = Number(raw.last ?? raw.close ?? raw.markPrice ?? NaN);
154
+ if (!Number.isFinite(last) || last <= 0) {
155
+ logger.warn(TAG, `fetchTicker(${symbol}) — no usable price on ticker`);
156
+ return null;
157
+ }
158
+ const timestamp = Number.isFinite(raw.timestamp) ? Number(raw.timestamp) : Date.now();
159
+ return {
160
+ // Key on the symbol the CALLER used so per-symbol maps line up.
161
+ symbol,
162
+ last,
163
+ bid: Number(raw.bid ?? NaN) > 0 ? Number(raw.bid) : last,
164
+ ask: Number(raw.ask ?? NaN) > 0 ? Number(raw.ask) : last,
165
+ baseVolume: Number(raw.baseVolume ?? 0) || 0,
166
+ quoteVolume: Number(raw.quoteVolume ?? 0) || 0,
167
+ change: Number(raw.change ?? 0) || 0,
168
+ percentage: Number(raw.percentage ?? 0) || 0,
169
+ timestamp,
170
+ datetime: raw.datetime ?? new Date(timestamp).toISOString(),
171
+ };
172
+ }
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ async fetchFundingRate(symbol) {
175
+ const vs = this.venueSymbol(symbol, 'fetchFundingRate');
176
+ if (!vs)
177
+ return null;
178
+ try {
179
+ const r = await this.exchange.fetchFundingRate(vs);
180
+ return r ?? null;
181
+ }
182
+ catch (err) {
183
+ logger.error(TAG, `fetchFundingRate(${symbol}) failed: ${err instanceof Error ? err.message : String(err)}`);
184
+ return null;
185
+ }
186
+ }
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ async fetchOpenInterest(symbol) {
189
+ const vs = this.venueSymbol(symbol, 'fetchOpenInterest');
190
+ if (!vs)
191
+ return null;
192
+ try {
193
+ const r = await this.exchange.fetchOpenInterest(vs);
194
+ return r ?? null;
195
+ }
196
+ catch (err) {
197
+ logger.error(TAG, `fetchOpenInterest(${symbol}) failed: ${err instanceof Error ? err.message : String(err)}`);
198
+ return null;
199
+ }
200
+ }
201
+ /** l2Book — Hyperliquid serves at most 20 levels/side (plan §3.6). */
202
+ async fetchOrderBook(symbol, limit = 20) {
203
+ const vs = this.venueSymbol(symbol, 'fetchOrderBook');
204
+ if (!vs)
205
+ return null;
206
+ try {
207
+ const raw = await this.exchange.fetchOrderBook(vs, Math.min(limit, 20));
208
+ return {
209
+ bids: (raw.bids ?? []).map((l) => [l[0], l[1]]),
210
+ asks: (raw.asks ?? []).map((l) => [l[0], l[1]]),
211
+ timestamp: raw.timestamp ?? Date.now(),
212
+ };
213
+ }
214
+ catch (err) {
215
+ logger.error(TAG, `fetchOrderBook(${symbol}) failed: ${err instanceof Error ? err.message : String(err)}`);
216
+ return null;
217
+ }
218
+ }
219
+ /** candleSnapshot — only the most recent 5000 candles exist per (coin,
220
+ * interval) (plan §3.6); requests inside that window behave like Binance. */
221
+ async fetchOHLCV(symbol, timeframe = '1h', limit = 100) {
222
+ const vs = this.venueSymbol(symbol, 'fetchOHLCV');
223
+ if (!vs)
224
+ return null;
225
+ try {
226
+ const raw = await this.exchange.fetchOHLCV(vs, timeframe, undefined, limit);
227
+ const valid = [];
228
+ for (const candle of raw) {
229
+ if (candle.length >= 6 &&
230
+ typeof candle[0] === 'number' &&
231
+ typeof candle[1] === 'number' &&
232
+ typeof candle[2] === 'number' &&
233
+ typeof candle[3] === 'number' &&
234
+ typeof candle[4] === 'number' &&
235
+ typeof candle[5] === 'number') {
236
+ valid.push(candle);
237
+ }
238
+ }
239
+ return valid;
240
+ }
241
+ catch (err) {
242
+ logger.error(TAG, `fetchOHLCV(${symbol}, ${timeframe}) failed: ${err instanceof Error ? err.message : String(err)}`);
243
+ return null;
244
+ }
245
+ }
246
+ /** Probe Hyperliquid reachability from this host — the readiness gate's
247
+ * venue signal, mirroring BinancePublicApi.probeReachability's outcome
248
+ * shape. Hand-rolled POST /info `exchangeStatus` (weight 2) so the probe
249
+ * has zero ccxt-method-shape dependence; the response's `time` field
250
+ * (verified live 2026-07-11: `{"specialStatuses":null,"time":…}`) doubles
251
+ * as the clock-drift source. Geo classification is best-effort — HL's
252
+ * API-level geo behavior is UNVERIFIED (plan §3.9); a 403/451 maps to
253
+ * geo_blocked, anything else non-2xx/network maps to unreachable. */
254
+ async probeReachability() {
255
+ const ac = new AbortController();
256
+ const tid = setTimeout(() => ac.abort(), 10_000);
257
+ try {
258
+ const res = await this.fetchImpl(`${this.baseUrl()}/info`, {
259
+ method: 'POST',
260
+ headers: { 'content-type': 'application/json' },
261
+ body: JSON.stringify({ type: 'exchangeStatus' }),
262
+ signal: ac.signal,
263
+ });
264
+ if (res.status === 451 || res.status === 403) {
265
+ logger.warn(TAG, `probeReachability: HTTP ${res.status} (geo/policy block)`);
266
+ return { outcome: 'geo_blocked', driftMs: null };
267
+ }
268
+ if (res.status < 200 || res.status >= 300) {
269
+ logger.warn(TAG, `probeReachability: HTTP ${res.status}`);
270
+ return { outcome: 'unreachable', driftMs: null };
271
+ }
272
+ const body = (await res.json());
273
+ const serverTime = Number(body?.time);
274
+ const driftMs = Number.isFinite(serverTime) ? serverTime - Date.now() : null;
275
+ return { outcome: 'reachable', driftMs };
276
+ }
277
+ catch (err) {
278
+ logger.warn(TAG, `probeReachability failed: ${err instanceof Error ? err.message : String(err)}`);
279
+ return { outcome: 'unreachable', driftMs: null };
280
+ }
281
+ finally {
282
+ clearTimeout(tid);
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,19 @@
1
+ import { fillExchangeId, parseVenue, type VenueId } from '@reefclaw/shared';
2
+ import { LiveAdapter } from '../live/live-adapter.js';
3
+ export type { VenueId };
4
+ export { fillExchangeId, parseVenue };
5
+ /** Venues this build can construct a LIVE adapter for. PAPER mode is
6
+ * venue-flavored only by its market-data source (PaperMarketFeed / chart) and
7
+ * is not gated here. */
8
+ export declare const SUPPORTED_LIVE_VENUES: ReadonlySet<VenueId>;
9
+ export declare function isLiveVenueSupported(venue: VenueId): boolean;
10
+ type BinanceLiveAdapterArgs = ConstructorParameters<typeof LiveAdapter>;
11
+ /** Construct the live adapter for a venue.
12
+ *
13
+ * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to
14
+ * the inline construction this factory replaced (Phase 0 no-behavior-change
15
+ * rule; the args tuple is derived from the constructor so the two can never
16
+ * drift). Hyperliquid: throws — reaching this arm means the boot-time
17
+ * isLiveVenueSupported() fallback-to-PAPER gate was bypassed, which is a bug,
18
+ * not a user state. */
19
+ export declare function createLiveAdapter(venue: VenueId, ...args: BinanceLiveAdapterArgs): LiveAdapter;
@@ -0,0 +1,40 @@
1
+ // Venue registry — the single seam where a trading venue's LIVE adapter is
2
+ // constructed (Phase 0 of docs/HYPERLIQUID_INTEGRATION_PLAN.md §5.1/§7.1).
3
+ //
4
+ // Phase 0 scope: Binance is the ONLY venue this build can trade live;
5
+ // 'hyperliquid' is a recognised-but-unsupported config value that boot handles
6
+ // by falling back to PAPER (never by crashing register() — OpenClaw treats a
7
+ // throwing register as "ignored" and the agent silently loses every tool).
8
+ // Phase 3 adds the HyperliquidLiveAdapter arm HERE and nowhere else, so the
9
+ // boot path never grows a second venue branch.
10
+ //
11
+ // Venue is LOCAL mechanism (TOOL_DISTRIBUTION_ARCHITECTURE.md §2 decision
12
+ // rule: it holds keys + is part of the safety floor) — it is read from
13
+ // ~/.reefclaw/plugin-config.json `exchange.venue` and MUST never be settable
14
+ // from the central config channel.
15
+ import { fillExchangeId, parseVenue } from '@reefclaw/shared';
16
+ import { LiveAdapter } from '../live/live-adapter.js';
17
+ export { fillExchangeId, parseVenue };
18
+ /** Venues this build can construct a LIVE adapter for. PAPER mode is
19
+ * venue-flavored only by its market-data source (PaperMarketFeed / chart) and
20
+ * is not gated here. */
21
+ export const SUPPORTED_LIVE_VENUES = new Set(['binance']);
22
+ export function isLiveVenueSupported(venue) {
23
+ return SUPPORTED_LIVE_VENUES.has(venue);
24
+ }
25
+ /** Construct the live adapter for a venue.
26
+ *
27
+ * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to
28
+ * the inline construction this factory replaced (Phase 0 no-behavior-change
29
+ * rule; the args tuple is derived from the constructor so the two can never
30
+ * drift). Hyperliquid: throws — reaching this arm means the boot-time
31
+ * isLiveVenueSupported() fallback-to-PAPER gate was bypassed, which is a bug,
32
+ * not a user state. */
33
+ export function createLiveAdapter(venue, ...args) {
34
+ switch (venue) {
35
+ case 'binance':
36
+ return new LiveAdapter(...args);
37
+ case 'hyperliquid':
38
+ throw new Error("venue 'hyperliquid' has no live adapter in this build — arrives in Phase 3 of docs/HYPERLIQUID_INTEGRATION_PLAN.md");
39
+ }
40
+ }