@reefclaw/openclaw-plugin 0.1.3 → 0.1.5

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, venueQuoteCurrency, } 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';
@@ -874,11 +876,24 @@ const paperTradingPlugin = {
874
876
  return;
875
877
  }
876
878
  logger.info(TAG, 'Initializing paper trading plugin...');
877
- // Resolve config from plugin settings
879
+ // Resolve config from plugin settings. The paper wallet's quote currency
880
+ // follows the VENUE (USDC on hyperliquid, USDT on binance — issue #174:
881
+ // a hardcoded USDT wallet reads as $0 equity on the USDC venue). The
882
+ // authoritative plugin-config read happens further down, AFTER the
883
+ // simulator exists — peek only the venue here; same file, same parse
884
+ // rules, and an unreadable config falls back to the binance default
885
+ // exactly like the main read does.
886
+ let paperQuoteCurrency = DEFAULT_CONFIG.quoteCurrency;
887
+ try {
888
+ paperQuoteCurrency = venueQuoteCurrency(parseVenue(readPluginConfig().exchange?.venue).venue);
889
+ }
890
+ catch {
891
+ /* unreadable plugin-config → binance default, matching the main read */
892
+ }
878
893
  const pluginConfig = {
879
894
  startingBalance: DEFAULT_CONFIG.startingBalance,
880
895
  symbol: DEFAULT_CONFIG.symbol,
881
- quoteCurrency: DEFAULT_CONFIG.quoteCurrency,
896
+ quoteCurrency: paperQuoteCurrency,
882
897
  };
883
898
  logger.info(TAG, `Config: ${pluginConfig.startingBalance} ${pluginConfig.quoteCurrency}, symbol: ${pluginConfig.symbol}`);
884
899
  // Load or initialize state (sync — OpenClaw ignores async register())
@@ -912,6 +927,14 @@ const paperTradingPlugin = {
912
927
  let intelligenceUrl = 'https://intel.reefclaw.com';
913
928
  let exchangeConfig = null;
914
929
  let tradingMode = 'PAPER';
930
+ // Trading venue (multi-venue Phase 0 — docs/HYPERLIQUID_INTEGRATION_PLAN.md).
931
+ // Absent config field = 'binance', so every pre-venue install is untouched.
932
+ let venue = 'binance';
933
+ // exchange.testnet as written on disk — needed independently of
934
+ // exchangeConfig because the hyperliquid venue never builds a Binance
935
+ // credential config (Phase 0 gate) but its keyless public API still needs
936
+ // the mainnet/testnet routing.
937
+ let venueTestnet = false;
915
938
  let signalsEvaluator = 'central';
916
939
  let signalsSymbols = [];
917
940
  try {
@@ -928,9 +951,18 @@ const paperTradingPlugin = {
928
951
  if (Array.isArray(rcConfig?.signals?.symbols)) {
929
952
  signalsSymbols = rcConfig.signals.symbols.map((s) => String(s).toUpperCase());
930
953
  }
931
- // Read exchange credentials (Phase 9b)
954
+ // Read exchange credentials (Phase 9b) + venue (multi-venue Phase 0)
932
955
  const exchange = rcConfig?.exchange;
933
- if (exchange?.apiKey && exchange?.secret) {
956
+ const venueParse = parseVenue(exchange?.venue);
957
+ venue = venueParse.venue;
958
+ venueTestnet = exchange?.testnet === true;
959
+ if (venueParse.unrecognized) {
960
+ logger.warn(TAG, `Unrecognized exchange.venue '${venueParse.unrecognized}' in plugin-config — treating as 'binance'`);
961
+ }
962
+ // apiKey/secret are a BINANCE credential pair; never build a Binance
963
+ // client config for another venue (a hyperliquid block carries
964
+ // walletAddress/agentPrivateKey instead — consumed from Phase 3 on).
965
+ if (venue === 'binance' && exchange?.apiKey && exchange?.secret) {
934
966
  exchangeConfig = {
935
967
  apiKey: exchange.apiKey,
936
968
  secret: exchange.secret,
@@ -964,6 +996,14 @@ const paperTradingPlugin = {
964
996
  else {
965
997
  logger.warn(TAG, 'No connection token found — get_market_intel will return errors');
966
998
  }
999
+ // Venue support gate (multi-venue Phase 0). Checked BEFORE the credentials
1000
+ // gate so a hyperliquid config gets the accurate diagnosis ("venue not
1001
+ // supported yet"), not a misleading "missing API keys". Fallback-to-PAPER,
1002
+ // never a throw — register() must stay non-crashing.
1003
+ if (tradingMode !== 'PAPER' && !isLiveVenueSupported(venue)) {
1004
+ 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)`);
1005
+ tradingMode = 'PAPER';
1006
+ }
967
1007
  // Validate trading mode vs credentials
968
1008
  if (tradingMode !== 'PAPER' && !exchangeConfig) {
969
1009
  logger.warn(TAG, `Trading mode ${tradingMode} requires exchange API keys — falling back to PAPER`);
@@ -1063,6 +1103,9 @@ const paperTradingPlugin = {
1063
1103
  // closure — only invoked on a fill, long after `runtime` is built), so it
1064
1104
  // follows a live<->paper reconnect.
1065
1105
  resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1106
+ // Venue tag (positions.exchange, migration 0058) — static per process;
1107
+ // a venue change requires config edit + restart.
1108
+ venue,
1066
1109
  };
1067
1110
  if (positionDecisionsClient) {
1068
1111
  logger.info(TAG, `Journal close-on-reduce-only-fill ${closeOnReduceOnlyFill ? 'ENABLED' : 'disabled'}`);
@@ -1146,6 +1189,11 @@ const paperTradingPlugin = {
1146
1189
  tradeIngest = {
1147
1190
  client: new TradeStoreClient({ baseUrl: ingestBaseUrl, ingestToken }),
1148
1191
  userId: reefclawUserId,
1192
+ // Venue-derived FillEvent.exchange — 'binance_futures' for the
1193
+ // binance venue, i.e. byte-identical to the pre-venue literal
1194
+ // WsIngest defaulted to. Half of the (exchange, exchange_trade_id)
1195
+ // audit-trail idempotency key; never a fresh string literal.
1196
+ exchange: fillExchangeId(venue),
1149
1197
  };
1150
1198
  logger.info(TAG, `User-data stream dbWrite=on — WS audit-trail ingest wired to ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1151
1199
  }
@@ -1203,7 +1251,12 @@ const paperTradingPlugin = {
1203
1251
  logger.warn(TAG, `approval mode=${approvalModeForTool} but WEBAPP_INGEST_TOKEN or REEFCLAW_USER_ID env missing — proposal path disabled`);
1204
1252
  approvalModeForTool = 'off';
1205
1253
  }
1206
- const liveAdapter = new LiveAdapter(exchangeConfig, tradingMode, microLiveConfig, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, autoCapture);
1254
+ // Venue-dispatched construction (multi-venue Phase 0). For 'binance'
1255
+ // this is a pure pass-through to `new LiveAdapter(...)` — identical
1256
+ // args, identical behavior; the unsupported-venue arm is unreachable
1257
+ // here because the isLiveVenueSupported() gate above already fell back
1258
+ // to PAPER. Phase 3 adds the hyperliquid adapter inside the factory.
1259
+ const liveAdapter = createLiveAdapter(venue, exchangeConfig, tradingMode, microLiveConfig, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, autoCapture);
1207
1260
  adapter = liveAdapter;
1208
1261
  // ---- Approval-mode Phase B — start ProposalDecisionListener ----
1209
1262
  // Started only when approval.mode='per_trade' AND proposalManagerCtx
@@ -1320,11 +1373,64 @@ const paperTradingPlugin = {
1320
1373
  else {
1321
1374
  adapter = new PaperAdapter(simulator);
1322
1375
  }
1376
+ // PAPER market-data source selection.
1377
+ //
1378
+ // Venue precedence (Hyperliquid Phase 1 — docs/HYPERLIQUID_INTEGRATION_PLAN.md):
1379
+ // the configured VENUE decides which exchange prices paper mode, full stop.
1380
+ // venue='hyperliquid' → every public read (PaperMarketFeed marks, the
1381
+ // simulator's fill prices, fetch_ticker/fetch_ohlcv/get_orderbook tools)
1382
+ // comes from Hyperliquid's keyless /info endpoints; the Binance-only
1383
+ // `paperMarketDataSource:'intel'` escape hatch does not apply (intel has
1384
+ // no HL rows until Phase 2 — silently serving Binance prices for a
1385
+ // Hyperliquid book would be a lie, the exact class the symbol-translation
1386
+ // rule forbids).
1387
+ //
1388
+ // Binance venue (default) is unchanged: a host Binance geo-blocks (HTTP
1389
+ // 451) can route PRICE reads through the intel service by setting
1390
+ // plugin-config `paperMarketDataSource:'intel'` (GET /api/price/:symbol).
1391
+ // LIVE always uses the real exchange, and the readiness reporter probes
1392
+ // the CONFIGURED venue. Kill-switch RC_PAPER_MARKET_DATA=binance forces
1393
+ // the Binance-direct default (binance venue only).
1394
+ //
1395
+ // hlPublicApi is constructed once per process when the venue is
1396
+ // hyperliquid — shared by the paper data path and the readiness probe.
1397
+ const hlPublicApi = venue === 'hyperliquid' ? new HyperliquidPublicApi({ testnet: venueTestnet }) : null;
1398
+ let configuredPaperSource;
1399
+ try {
1400
+ configuredPaperSource = readPluginConfig().paperMarketDataSource;
1401
+ }
1402
+ catch {
1403
+ configuredPaperSource = undefined;
1404
+ }
1405
+ const paperDataSource = process.env.RC_PAPER_MARKET_DATA === 'binance'
1406
+ ? 'binance'
1407
+ : configuredPaperSource ?? 'binance';
1408
+ const useIntelPaperData = venue === 'binance' &&
1409
+ !adapter.isLive && paperDataSource === 'intel' && connectionToken.length > 0;
1410
+ const marketDataApi = !adapter.isLive && hlPublicApi
1411
+ ? hlPublicApi
1412
+ : useIntelPaperData
1413
+ ? new IntelPublicApi({ connectionToken, intelligenceUrl })
1414
+ : binanceApi;
1415
+ if (!adapter.isLive && hlPublicApi) {
1416
+ logger.info(TAG, `PAPER market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1417
+ if (paperDataSource === 'intel') {
1418
+ logger.warn(TAG, 'paperMarketDataSource=intel is a Binance-venue option — ignored on venue=hyperliquid (intel has no Hyperliquid rows until Phase 2)');
1419
+ }
1420
+ }
1421
+ else if (useIntelPaperData) {
1422
+ logger.warn(TAG, 'PAPER market data routed through intel (paperMarketDataSource=intel) — prices from GET /api/price; live + readiness still use Binance directly');
1423
+ }
1424
+ else if (venue === 'binance' && !adapter.isLive && paperDataSource === 'intel' && connectionToken.length === 0) {
1425
+ logger.warn(TAG, 'paperMarketDataSource=intel requested but no connection token — falling back to Binance direct (which will 451 on a geo-blocked host)');
1426
+ }
1323
1427
  // Build tool dependencies
1324
1428
  // `simDeps` for tools that need the simulator directly (ticker, market structure, risk scenario)
1325
1429
  // `adapterDeps` for the 7 adapter-based trading tools
1326
- const simDeps = { binanceApi, simulator };
1327
- const adapterDeps = { binanceApi, adapter };
1430
+ // Both carry `marketDataApi` (Binance direct, or intel in paper mode) so
1431
+ // every paper market-data read follows the selected source at once.
1432
+ const simDeps = { binanceApi: marketDataApi, simulator };
1433
+ const adapterDeps = { binanceApi: marketDataApi, adapter };
1328
1434
  // ---- Plugin runtime (mutable holder) ----
1329
1435
  // Owns the adapter + mode + stop-watcher and exposes `reconnect()` so the
1330
1436
  // operator can swap credentials / trading mode from the dashboard without
@@ -1357,7 +1463,7 @@ const paperTradingPlugin = {
1357
1463
  // resting limits fill. Paper-only — started here when the boot adapter is
1358
1464
  // paper; PluginRuntime.reconnect() stops it on a switch to live and
1359
1465
  // restarts it on a switch back to paper. Reuses the ban-gated fetchTicker.
1360
- const marketFeed = new PaperMarketFeed(binanceApi, simulator);
1466
+ const marketFeed = new PaperMarketFeed(marketDataApi, simulator);
1361
1467
  if (!adapter.isLive) {
1362
1468
  marketFeed.start();
1363
1469
  }
@@ -2022,16 +2128,19 @@ const paperTradingPlugin = {
2022
2128
  pluginInitialised = true;
2023
2129
  logger.info(TAG, `Registered ${gatedTools.length} tools (gate mode=${toolGate.getMode()}): ${toolNames.join(', ')}. Plugin v3.8.0 (${runtime.mode} mode)`);
2024
2130
  // 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.
2131
+ // probe host→venue reachability (Binance HTTP 451 geo-block / Hyperliquid
2132
+ // /info) + clock drift on the host and POST a plain-English report to the
2133
+ // webapp, so a silently-broken agent shows an actionable dashboard alert
2134
+ // instead of a false green. Probes ONLY the configured venue on a
2135
+ // Binance-451 host trading Hyperliquid a Binance probe would be a
2136
+ // permanent false alarm. Advisory + fire-and-forget; no token → no-op.
2137
+ // Runs here once (guarded by the pluginInitialised early-return → once per
2138
+ // process) + on an unref'd interval inside the reporter.
2031
2139
  startReadinessReporter({
2032
2140
  apiBaseUrl,
2033
2141
  token: resolveIngestToken({ connectionToken }),
2034
- binanceApi,
2142
+ venue,
2143
+ publicApi: hlPublicApi ?? binanceApi,
2035
2144
  toolCount: toolNames.length,
2036
2145
  });
2037
2146
  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;