@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.
- package/ccxt/binance-public.d.ts +2 -1
- package/ccxt/intel-public.d.ts +25 -0
- package/ccxt/intel-public.js +80 -0
- package/ccxt/public-market-data-api.d.ts +12 -0
- package/ccxt/public-market-data-api.js +9 -0
- package/config/plugin-config-io.d.ts +15 -0
- package/index.js +110 -14
- package/ingest/position-auto-capture.d.ts +5 -0
- package/ingest/position-auto-capture.js +8 -2
- package/ingest/position-decisions-client.d.ts +4 -0
- package/ingest/readiness-reporter.d.ts +28 -5
- package/ingest/readiness-reporter.js +40 -19
- package/live/bracket-id.d.ts +9 -0
- package/live/bracket-id.js +18 -0
- package/package.json +2 -2
- package/tools/close-position.d.ts +2 -2
- package/tools/create-order.d.ts +2 -2
- package/tools/fetch-ohlcv.d.ts +2 -2
- package/tools/fetch-ticker.d.ts +2 -2
- package/tools/get-crypto-metrics.d.ts +2 -2
- package/tools/get-market-structure.d.ts +2 -2
- package/tools/get-orderbook.d.ts +2 -2
- package/tools/get-volume-analysis.d.ts +2 -2
- package/tools/helpers.d.ts +5 -4
- package/tools/helpers.js +2 -1
- package/types.d.ts +20 -1
- package/venues/hyperliquid/hl-public.d.ts +52 -0
- package/venues/hyperliquid/hl-public.js +285 -0
- package/venues/registry.d.ts +19 -0
- package/venues/registry.js +40 -0
package/ccxt/binance-public.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CcxtTicker, CcxtOHLCV } from '../types.js';
|
|
2
2
|
import type { OrderBookDepth } from '../simulator/types.js';
|
|
3
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
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(
|
|
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→
|
|
2026
|
-
// host and POST a plain-English report to the
|
|
2027
|
-
// agent shows an actionable dashboard alert
|
|
2028
|
-
//
|
|
2029
|
-
//
|
|
2030
|
-
//
|
|
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
|
-
|
|
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)
|
|
336
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
40
|
-
const probe = await opts.
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
64
|
-
checks.push(makeReadinessCheck(
|
|
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(
|
|
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
|
-
|
|
139
|
-
|
|
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
|
}
|
package/live/bracket-id.d.ts
CHANGED
|
@@ -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;
|
package/live/bracket-id.js
CHANGED
|
@@ -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
|
+
"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.
|
|
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 {
|
|
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:
|
|
8
|
+
binanceApi: PublicMarketDataApi;
|
|
9
9
|
adapter: IExchangeAdapter;
|
|
10
10
|
autoCapture?: AutoCaptureContext;
|
|
11
11
|
/** Override for tests — production reads from plugin-config.json on each
|
package/tools/create-order.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
24
|
+
binanceApi: PublicMarketDataApi;
|
|
25
25
|
adapter: IExchangeAdapter;
|
|
26
26
|
autoCapture?: AutoCaptureContext;
|
|
27
27
|
/** Approval-mode wiring. The same ProposalManager handles both modes;
|
package/tools/fetch-ohlcv.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
8
|
+
binanceApi: PublicMarketDataApi;
|
|
9
9
|
}): Promise<CcxtOHLCV[] | {
|
|
10
10
|
error: string;
|
|
11
11
|
}>;
|
package/tools/fetch-ticker.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
7
|
+
binanceApi: PublicMarketDataApi;
|
|
8
8
|
simulator: ExchangeSimulator;
|
|
9
9
|
}): Promise<CcxtTicker | {
|
|
10
10
|
error: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
15
|
+
binanceApi: PublicMarketDataApi;
|
|
16
16
|
}): Promise<CryptoMetricsResult | {
|
|
17
17
|
error: string;
|
|
18
18
|
}>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
44
|
+
binanceApi: PublicMarketDataApi;
|
|
45
45
|
}): Promise<MarketStructureResult | {
|
|
46
46
|
error: string;
|
|
47
47
|
}>;
|
package/tools/get-orderbook.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
18
|
+
binanceApi: PublicMarketDataApi;
|
|
19
19
|
}): Promise<OrderbookResult | {
|
|
20
20
|
error: string;
|
|
21
21
|
}>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
18
|
+
binanceApi: PublicMarketDataApi;
|
|
19
19
|
}): Promise<VolumeAnalysisResult | {
|
|
20
20
|
error: string;
|
|
21
21
|
}>;
|
package/tools/helpers.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
+
}
|