@sharpe-terminal/mcp-server 1.2.0 → 1.5.0

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,8 +1,16 @@
1
1
  /**
2
- * Composite tools that call multiple API endpoints concurrently
3
- * and return formatted markdown. Mirrors the Python server's
4
- * analyze_coin, market_briefing, and find_opportunities.
2
+ * Composite tools that call several API endpoints concurrently and return
3
+ * formatted markdown. Mirrors the Python server's analyze_coin,
4
+ * market_briefing and find_opportunities line for line.
5
+ *
6
+ * Every number a composite prints is a field the API serves, the value the web
7
+ * page and /api/v1 give for the same request (engine L4 WP-D14): the
8
+ * rankings, floors, averages and APRs are request parameters and response
9
+ * fields, never recomputed here. A report ends with the freshness of every
10
+ * answer it read.
5
11
  */
12
+ import { freshnessOf } from "./api-client.js";
13
+ import { splitPaginated } from "./tool-runtime.js";
6
14
  // ── Formatting helpers ────────────────────────────────────────────────
7
15
  export function fmtUsd(value) {
8
16
  if (value == null)
@@ -29,6 +37,44 @@ export function fmtRate(value) {
29
37
  return "N/A";
30
38
  return `${(value * 100).toFixed(4)}%`;
31
39
  }
40
+ /** An annualized fraction (0.25 = 25%) as a percentage, or N/A. */
41
+ export function fmtApr(value) {
42
+ const n = finiteOrNull(value);
43
+ return n == null ? "N/A" : `${(n * 100).toFixed(1)}%`;
44
+ }
45
+ /**
46
+ * The open-interest floor (USD) the composites ask the API to apply: funding
47
+ * extremes (`min_oi_usd`), the spot-perp perp leg and both cross-exchange legs
48
+ * (`minOiUsd`, `minVolUsd`). Unfloored, every extreme was a dust market (ESIM
49
+ * on KuCoin: $33K of OI at -16,862% APR, 2026-09-24 audit). The API applies
50
+ * it; unknown open interest fails it there.
51
+ */
52
+ export const LIQUIDITY_FLOOR_USD = 1_000_000;
53
+ /**
54
+ * `max_age_h` on the funding calls: v1 leaves out rows not written for six
55
+ * hours (V1_MAX_AGE_HOURS in the funding read model), so a keyless answer
56
+ * counts the same rows as a keyed one. v1 ignores the parameter.
57
+ */
58
+ export const FUNDING_MAX_AGE_HOURS = 6;
59
+ function isRecord(value) {
60
+ return value !== null && typeof value === "object" && !Array.isArray(value);
61
+ }
62
+ function finiteOrNull(value) {
63
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
64
+ }
65
+ function text(value) {
66
+ return typeof value === "string" && value !== "" ? value : null;
67
+ }
68
+ /** Rows of a list payload that may be wrapped as `{ data, pagination }` or `{ items }`. */
69
+ function listRows(payload) {
70
+ let rows = splitPaginated(payload).rows;
71
+ if (isRecord(rows) && "items" in rows)
72
+ rows = rows.items;
73
+ return Array.isArray(rows) ? rows.filter(isRecord) : null;
74
+ }
75
+ function records(value) {
76
+ return Array.isArray(value) ? value.filter(isRecord) : [];
77
+ }
32
78
  function safeGet(obj, ...keys) {
33
79
  let current = obj;
34
80
  for (const k of keys) {
@@ -41,6 +87,51 @@ function safeGet(obj, ...keys) {
41
87
  }
42
88
  return current;
43
89
  }
90
+ /** A market's name with its asset class when it is not a token (TSLL is a stock, not a coin). */
91
+ function marketLabel(row) {
92
+ const coin = text(row.base_coin) ?? text(row.symbol) ?? "?";
93
+ const assetClass = text(row.asset_class);
94
+ return assetClass == null || assetClass === "crypto"
95
+ ? coin
96
+ : `${coin} (${assetClass})`;
97
+ }
98
+ // ── Freshness ─────────────────────────────────────────────────────────
99
+ /** One answer's freshness, as the Python server prints it: `dataset · as of … · status[ · degraded] (warnings)`. */
100
+ export function freshnessLine(freshness) {
101
+ const parts = [
102
+ freshness.dataset_id || "data",
103
+ freshness.as_of ? `as of ${freshness.as_of}` : "as of unknown",
104
+ freshness.freshness_status,
105
+ ];
106
+ if (freshness.runtime_status === "degraded")
107
+ parts.push("degraded");
108
+ const line = parts.join(" · ");
109
+ return freshness.warnings.length > 0
110
+ ? `${line} (${freshness.warnings.join("; ")})`
111
+ : line;
112
+ }
113
+ /**
114
+ * The report with a closing line naming the freshness of every answer it read
115
+ * (engine L5 WP-S12), each once, in the order the calls were made. Nothing
116
+ * changes when no answer said anything.
117
+ */
118
+ export function withFreshnessReport(report, answers) {
119
+ const seen = [];
120
+ const keys = new Set();
121
+ for (const answer of answers) {
122
+ const freshness = freshnessOf(answer);
123
+ if (freshness === null)
124
+ continue;
125
+ const key = JSON.stringify(freshness);
126
+ if (keys.has(key))
127
+ continue;
128
+ keys.add(key);
129
+ seen.push(freshness);
130
+ }
131
+ return seen.length === 0
132
+ ? report
133
+ : `${report}\n\n**Data freshness:** ${seen.map(freshnessLine).join("; ")}`;
134
+ }
44
135
  async function safeCall(callApi, apiPath, fallbackPath, args, label) {
45
136
  try {
46
137
  const data = await callApi(apiPath, fallbackPath, args);
@@ -51,384 +142,416 @@ async function safeCall(callApi, apiPath, fallbackPath, args, label) {
51
142
  return { data: null, error: `[${label}] ${msg}` };
52
143
  }
53
144
  }
54
- // ── CoinGecko slug map ────────────────────────────────────────────────
55
- const CG_SLUGS = {
56
- BTC: "bitcoin",
57
- ETH: "ethereum",
58
- SOL: "solana",
59
- DOGE: "dogecoin",
60
- XRP: "ripple",
61
- ADA: "cardano",
62
- AVAX: "avalanche-2",
63
- DOT: "polkadot",
64
- LINK: "chainlink",
65
- MATIC: "matic-network",
66
- SUI: "sui",
67
- APT: "aptos",
68
- };
69
- // ── analyze_coin ──────────────────────────────────────────────────────
70
- export async function analyzeCoin(callApi, coin) {
71
- const coinUpper = coin.toUpperCase();
72
- const cgSlug = CG_SLUGS[coinUpper] ?? coin.toLowerCase();
73
- const tasks = [
74
- safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { type: "accumulated" }, "funding"),
75
- safeCall(callApi, "/api/v1/futures/data", "/api/futures/data", { chart: "oi-snapshot", coin: coinUpper, timeframe: "3M" }, "oi"),
76
- safeCall(callApi, "/api/v1/price-prediction/data", "/api/price-prediction/data", { coin: cgSlug }, "prediction"),
77
- ];
145
+ async function settleAll(tasks) {
78
146
  const results = await Promise.allSettled(tasks);
79
- const settled = results.map((r) => r.status === "fulfilled"
147
+ return results.map((r) => r.status === "fulfilled"
80
148
  ? r.value
81
149
  : { data: null, error: "Promise rejected" });
82
- const [funding, oi, prediction] = settled;
83
- const errors = settled
84
- .filter((s) => s.error)
85
- .map((s) => s.error);
150
+ }
151
+ function finish(lines, settled) {
152
+ const errors = settled.filter((s) => s.error).map((s) => s.error);
153
+ if (errors.length > 0) {
154
+ lines.push("");
155
+ lines.push(`**Data gaps:** ${errors.join("; ")}`);
156
+ }
157
+ return withFreshnessReport(lines.join("\n"), settled.map((s) => s.data));
158
+ }
159
+ // ── Price-prediction coin resolution ──────────────────────────────────
160
+ /**
161
+ * Find the prediction row for a ticker in the endpoint's own coverage list.
162
+ *
163
+ * The price-prediction endpoint is keyed by CoinGecko slug ("bitcoin",
164
+ * "avalanche-2", "matic-network"), never by ticker, and a lowercased ticker is
165
+ * not a slug. Guessing one either 404s or — far worse — resolves to whichever
166
+ * unrelated asset happens to own that slug, and tickers are shared across
167
+ * thousands of assets in this dataset. So the guess is a silent wrong-coin
168
+ * risk, never a convenience: it is resolve-or-say-so here.
169
+ *
170
+ * The list response (`GET .../price-prediction/data` with no `coin`) carries
171
+ * both the ticker (`coin`) and the `slug` for every covered asset, so an exact
172
+ * ticker match against it is the endpoint's own documented contract.
173
+ */
174
+ export function findPredictionRow(payload, ticker) {
175
+ let rows = payload;
176
+ if (typeof rows === "object" && rows !== null && !Array.isArray(rows)) {
177
+ rows = rows.coins;
178
+ }
179
+ if (!Array.isArray(rows))
180
+ return null;
181
+ const wanted = ticker.toUpperCase();
182
+ for (const row of rows) {
183
+ if (typeof row !== "object" || row === null)
184
+ continue;
185
+ const rec = row;
186
+ if (String(rec.coin ?? "").toUpperCase() === wanted)
187
+ return rec;
188
+ }
189
+ return null;
190
+ }
191
+ // ── analyze_coin ──────────────────────────────────────────────────────
192
+ function capitalize(value) {
193
+ return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
194
+ }
195
+ function predictionLines(prediction, coinUpper) {
196
+ const row = prediction.data
197
+ ? findPredictionRow(prediction.data, coinUpper)
198
+ : null;
199
+ if (!row) {
200
+ return prediction.data
201
+ ? [
202
+ `\n*No price prediction for ${coinUpper}: this ticker is not in the price-prediction coverage universe. Call get_price_prediction with no coin to list covered tickers and their slugs.*`,
203
+ ]
204
+ : ["\n*Price prediction data unavailable*"];
205
+ }
206
+ const bias = String(row.direction ?? row.bias ?? "unknown");
207
+ const score = finiteOrNull(row.consensusScore ?? row.score);
208
+ const price = finiteOrNull(row.spotPrice ?? row.price);
209
+ const change24h = finiteOrNull(row.priceChange24h ?? row.price_change_24h);
210
+ const lines = [
211
+ "",
212
+ `**Price:** ${fmtUsd(price)} | **24h:** ${change24h != null ? fmtPct(change24h) : ""}`,
213
+ `**Prediction:** ${capitalize(bias)} (score: ${score != null ? `${score}/100` : "N/A"})`,
214
+ ];
215
+ const signals = row.signals ?? row.technical;
216
+ if (Array.isArray(signals)) {
217
+ const parts = records(signals)
218
+ .map((s) => {
219
+ const label = text(s.label ?? s.name);
220
+ const sigBias = text(s.bias);
221
+ return label && sigBias ? `${label}: ${sigBias}` : null;
222
+ })
223
+ .filter((part) => part !== null)
224
+ .slice(0, 6);
225
+ if (parts.length > 0)
226
+ lines.push(`**Signals:** ${parts.join(", ")}`);
227
+ }
228
+ else if (isRecord(signals)) {
229
+ const parts = [];
230
+ if (signals.rsi != null)
231
+ parts.push(`RSI ${Number(signals.rsi).toFixed(0)}`);
232
+ if (signals.ema_trend)
233
+ parts.push(`EMA ${signals.ema_trend}`);
234
+ if (parts.length > 0)
235
+ lines.push(`**Technicals:** ${parts.join(", ")}`);
236
+ }
237
+ return lines;
238
+ }
239
+ function fundingRowLine(row) {
240
+ const market = [text(row.exchange) ?? "Unknown", text(row.symbol)]
241
+ .filter(Boolean)
242
+ .join(" ");
243
+ return ` ${market}: ${fmtRate(finiteOrNull(row.rate_8h))} per 8h (${fmtApr(row.apr)} APR) | OI ${fmtUsd(finiteOrNull(row.open_interest))}`;
244
+ }
245
+ /** One asset of a `summary=1` answer: the API's average and median across venues. */
246
+ function fundingSummaryLine(entry) {
247
+ const stale = finiteOrNull(entry.funding_venues_stale) ?? 0;
248
+ const parts = [
249
+ ` **Average (${marketLabel(entry)}):** ${fmtRate(finiteOrNull(entry.funding_avg_8h))} per 8h (${text(entry.funding_method) ?? "n/a"})`,
250
+ `**Median:** ${fmtRate(finiteOrNull(entry.funding_median_8h))} per 8h`,
251
+ `${finiteOrNull(entry.funding_venues_used) ?? 0} venues`,
252
+ ];
253
+ let line = parts.join(" | ");
254
+ if (stale > 0)
255
+ line += `, ${stale} stale venues left out`;
256
+ if (entry.funding_all_stale === true)
257
+ line += " *(every venue is stale: the figures are over stale rates)*";
258
+ return line;
259
+ }
260
+ export async function analyzeCoin(callApi, coin) {
261
+ const coinUpper = coin.toUpperCase();
262
+ const fundingScope = {
263
+ type: "current",
264
+ coin: coinUpper,
265
+ max_age_h: FUNDING_MAX_AGE_HOURS,
266
+ };
267
+ const settled = await settleAll([
268
+ // No `coin` param: the endpoint is slug-keyed and the caller gives us a
269
+ // ticker, so ask for the coverage list and match the ticker in it rather
270
+ // than shipping a guessed slug that can resolve to another asset.
271
+ safeCall(callApi, "/api/v1/price-prediction/data", "/api/price-prediction/data", {}, "prediction"),
272
+ // The API resolves `coin` to an asset (PEPE brings its 1000PEPE and KPEPE
273
+ // contracts) and orders the markets by their own open interest.
274
+ safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { ...fundingScope, sort: "-open_interest" }, "funding"),
275
+ // The asset's funding across venues: the funding grid's Avg Rate.
276
+ safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { ...fundingScope, summary: "1" }, "funding-summary"),
277
+ // The asset's open-interest total (L4 oi.asset_total_usd): /global's row,
278
+ // every contract of the asset once, lots included, venues the API keeps
279
+ // out of totals left out, latest reading within six hours.
280
+ safeCall(callApi, "/api/v1/global/overview", "/api/global/overview", {}, "open-interest"),
281
+ // The dollar value paid at the next settlement: the coin's own rows, never
282
+ // `totals` (which stays scoped to the whole class).
283
+ safeCall(callApi, "/api/v1/funding/settlement", "/api/funding/settlement", { coin: coinUpper, window: "current" }, "settlement"),
284
+ ]);
285
+ const [prediction, funding, summary, global, settlement] = settled;
86
286
  const lines = [`## ${coinUpper} Analysis`];
87
- // Price prediction
88
- if (prediction.data) {
89
- let pred = prediction.data;
90
- if (Array.isArray(pred) && pred.length > 0)
91
- pred = pred[0];
92
- if (typeof pred === "object" && pred !== null) {
93
- const coinObj = (pred.coin ?? pred);
94
- if (typeof coinObj === "object" && coinObj !== null) {
95
- const finalPred = coinObj;
96
- const bias = (safeGet(finalPred, "direction") ??
97
- safeGet(finalPred, "bias") ??
98
- "unknown");
99
- const score = (safeGet(finalPred, "consensusScore") ??
100
- safeGet(finalPred, "score"));
101
- const price = (safeGet(finalPred, "spotPrice") ??
102
- safeGet(finalPred, "price"));
103
- const change24h = (safeGet(finalPred, "priceChange24h") ??
104
- safeGet(finalPred, "price_change_24h"));
105
- lines.push("");
106
- lines.push(`**Price:** ${price != null ? fmtUsd(price) : "N/A"} | **24h:** ${change24h != null ? fmtPct(change24h) : ""}`);
107
- lines.push(`**Prediction:** ${String(bias).charAt(0).toUpperCase() + String(bias).slice(1)} (score: ${score != null ? `${score}/100` : "N/A"})`);
108
- const signals = (safeGet(finalPred, "signals") ??
109
- safeGet(finalPred, "technical"));
110
- if (Array.isArray(signals) && signals.length > 0) {
111
- const parts = signals
112
- .filter((s) => typeof s === "object" && s !== null)
113
- .map((s) => {
114
- const label = (s.label ?? s.name ?? "");
115
- const sigBias = (s.bias ?? "");
116
- return label && sigBias ? `${label}: ${sigBias}` : null;
117
- })
118
- .filter(Boolean)
119
- .slice(0, 6);
120
- if (parts.length > 0)
121
- lines.push(`**Signals:** ${parts.join(", ")}`);
122
- }
123
- else if (typeof signals === "object" && signals !== null) {
124
- const sigObj = signals;
125
- const parts = [];
126
- if (sigObj.rsi != null)
127
- parts.push(`RSI ${Number(sigObj.rsi).toFixed(0)}`);
128
- if (sigObj.ema_trend)
129
- parts.push(`EMA ${sigObj.ema_trend}`);
130
- if (parts.length > 0)
131
- lines.push(`**Technicals:** ${parts.join(", ")}`);
132
- }
133
- }
134
- }
287
+ lines.push(...predictionLines(prediction, coinUpper));
288
+ // Funding: the markets in the API's open-interest order, then its summary.
289
+ const fundingRows = funding.data ? (listRows(funding.data) ?? []) : null;
290
+ if (fundingRows === null) {
291
+ lines.push("\n*Funding rate data unavailable*");
292
+ }
293
+ else if (fundingRows.length === 0) {
294
+ lines.push("\n*No funding rates found for this coin*");
135
295
  }
136
296
  else {
137
- lines.push("\n*Price prediction data unavailable*");
297
+ lines.push("");
298
+ lines.push("**Funding (current, largest markets by open interest):**");
299
+ for (const row of fundingRows.slice(0, 10))
300
+ lines.push(fundingRowLine(row));
301
+ for (const entry of records(safeGet(summary.data, "summary"))) {
302
+ lines.push(fundingSummaryLine(entry));
303
+ }
138
304
  }
139
- // Funding rates
140
- if (funding.data) {
141
- let ratesList = funding.data;
142
- if (typeof ratesList === "object" &&
143
- ratesList !== null &&
144
- !Array.isArray(ratesList) &&
145
- "items" in ratesList) {
146
- ratesList = ratesList.items;
305
+ // Open interest: the asset's row on the global board, found by the asset
306
+ // symbol the funding answer resolved (coin=1000PEPE files under PEPE).
307
+ if (global.data) {
308
+ const assetSymbol = fundingRows?.map((row) => text(row.asset_symbol)).find(Boolean) ??
309
+ coinUpper;
310
+ const row = records(safeGet(global.data, "rows")).find((candidate) => text(candidate.symbol) === assetSymbol);
311
+ lines.push("");
312
+ lines.push("**Open Interest:**");
313
+ if (row) {
314
+ lines.push(` **Total:** ${fmtUsd(finiteOrNull(row.openInterestUsd))} across ${finiteOrNull(row.venueCount) ?? "N/A"} venues | 1h ${fmtPct(finiteOrNull(row.oiChange1hPct))} | 24h ${fmtPct(finiteOrNull(row.oiChange24hPct))}`);
147
315
  }
148
- if (Array.isArray(ratesList)) {
149
- const coinRates = ratesList.filter((r) => {
150
- if (typeof r !== "object" || r === null)
151
- return false;
152
- const rec = r;
153
- const baseCoin = String(rec.base_coin ?? rec.coin ?? "").toUpperCase();
154
- const symbol = String(rec.symbol ?? "").toUpperCase();
155
- return baseCoin === coinUpper || symbol.startsWith(coinUpper);
156
- });
157
- if (coinRates.length > 0) {
158
- lines.push("");
159
- lines.push("**Funding Rates:**");
160
- const rateValues = [];
161
- for (const r of coinRates.slice(0, 10)) {
162
- const rec = r;
163
- const exchange = String(rec.exchange ?? "Unknown");
164
- const rate = (rec.rate ?? rec.acc_1d ?? rec.funding_rate);
165
- const acc7d = rec.acc_7d;
166
- if (rate != null) {
167
- rateValues.push(rate);
168
- let label = ` ${exchange}: ${fmtRate(rate)}`;
169
- if (acc7d != null)
170
- label += ` (7d: ${fmtRate(acc7d)})`;
171
- lines.push(label);
172
- }
173
- }
174
- if (rateValues.length > 0) {
175
- const avg = rateValues.reduce((a, b) => a + b, 0) / rateValues.length;
176
- lines.push(` **Avg:** ${fmtRate(avg)} across ${rateValues.length} exchanges`);
177
- }
178
- }
179
- else {
180
- lines.push("\n*No funding rates found for this coin*");
181
- }
316
+ else {
317
+ lines.push(` *No open-interest total for ${assetSymbol} on the global board.*`);
318
+ }
319
+ const excluded = records(safeGet(global.data, "metrics", "openInterestExcludedVenues"))
320
+ .map((venue) => {
321
+ const exchange = text(venue.exchange);
322
+ const reason = text(venue.reason);
323
+ return exchange ? `${exchange}${reason ? ` (${reason})` : ""}` : null;
324
+ })
325
+ .filter((venue) => venue !== null);
326
+ if (excluded.length > 0) {
327
+ lines.push(` *Left out of the total: ${excluded.join("; ")}*`);
182
328
  }
183
329
  }
184
330
  else {
185
- lines.push("\n*Funding rate data unavailable*");
331
+ lines.push("\n*Open interest data unavailable*");
186
332
  }
187
- // Open interest
188
- if (oi.data) {
189
- let oiData = oi.data;
190
- if (typeof oiData === "object" &&
191
- oiData !== null &&
192
- !Array.isArray(oiData) &&
193
- "data" in oiData) {
194
- oiData = oiData.data;
195
- }
333
+ // Settlement: dollar value actually paid at the next settlement.
334
+ if (settlement.data) {
335
+ const payload = settlement.data;
336
+ const rows = records(payload.rows ?? payload.data);
196
337
  lines.push("");
197
- lines.push("**Open Interest:**");
198
- if (Array.isArray(oiData)) {
199
- const latestByExchange = new Map();
200
- const latestTsByExchange = new Map();
201
- for (const item of oiData) {
202
- if (typeof item !== "object" || item === null)
203
- continue;
204
- const rec = item;
205
- const exchange = String(rec.exchange ?? "Unknown");
206
- const oiVal = Number(rec.open_interest_value ??
207
- rec.value ??
208
- rec.oi ??
209
- rec.open_interest ??
210
- 0);
211
- const ts = String(rec.timestamp ?? rec.snapshot_at ?? "");
212
- if (oiVal > 0 && ts >= (latestTsByExchange.get(exchange) ?? "")) {
213
- latestByExchange.set(exchange, oiVal);
214
- latestTsByExchange.set(exchange, ts);
215
- }
338
+ lines.push("**Settlement (next, estimated):**");
339
+ if (rows.length === 0) {
340
+ lines.push(" No funding data for the next settlement.");
341
+ }
342
+ for (const row of rows) {
343
+ const net = finiteOrNull(safeGet(row, "windows", "current", "net"));
344
+ const prefix = rows.length > 1 ? `${marketLabel(row)}: ` : "";
345
+ if (net == null) {
346
+ lines.push(` ${prefix}No funding data for the next settlement.`);
347
+ }
348
+ else if (net > 0) {
349
+ lines.push(` ${prefix}Longs pay ${fmtUsd(net)} to shorts.`);
216
350
  }
217
- let totalOi = 0;
218
- const sorted = [...latestByExchange.entries()]
219
- .sort((a, b) => b[1] - a[1])
220
- .slice(0, 10);
221
- for (const [exchange, oiVal] of sorted) {
222
- totalOi += oiVal;
223
- lines.push(` ${exchange}: ${fmtUsd(oiVal)}`);
351
+ else if (net < 0) {
352
+ lines.push(` ${prefix}Shorts pay ${fmtUsd(-net)} to longs.`);
224
353
  }
225
- if (latestByExchange.size > 0) {
226
- lines.push(` **Total:** ${fmtUsd(totalOi)} across ${latestByExchange.size} exchanges`);
354
+ else {
355
+ lines.push(` ${prefix}No net funding at the next settlement.`);
227
356
  }
228
357
  }
229
358
  }
230
359
  else {
231
- lines.push("\n*Open interest data unavailable*");
360
+ lines.push("\n*Settlement data unavailable*");
232
361
  }
233
- if (errors.length > 0) {
234
- lines.push("");
235
- lines.push(`**Data gaps:** ${errors.join("; ")}`);
236
- }
237
- return lines.join("\n");
362
+ return finish(lines, settled);
238
363
  }
239
364
  // ── market_briefing ───────────────────────────────────────────────────
365
+ function fundingExtremeLine(row) {
366
+ return ` ${marketLabel(row)} on ${text(row.exchange) ?? "?"}: ${fmtApr(row.apr)} APR (${fmtRate(finiteOrNull(row.rate_8h))} per 8h; raw ${fmtRate(finiteOrNull(row.rate))} per ${finiteOrNull(row.interval_hours) ?? "?"}h)`;
367
+ }
240
368
  export async function marketBriefing(callApi) {
241
- const results = await Promise.allSettled([
369
+ const settled = await settleAll([
242
370
  safeCall(callApi, "/api/v1/tracker/market-overview", "/api/tracker/market-overview", {}, "market-overview"),
243
- safeCall(callApi, "/api/v1/narratives/data", "/api/narratives/data", {}, "narratives"),
244
- safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { type: "current" }, "funding"),
371
+ // Ranked by the API: missing 24h changes are never ranked.
372
+ safeCall(callApi, "/api/v1/narratives/data", "/api/narratives/data", { sort: "-change24h", top: 5, bottom: 3 }, "narratives"),
373
+ // The book's extremes, ranked by the API on the 8h-equivalent rate among
374
+ // fresh markets with a usable interval above the floor.
375
+ safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", {
376
+ type: "current",
377
+ extremes: 3,
378
+ min_oi_usd: LIQUIDITY_FLOOR_USD,
379
+ max_age_h: FUNDING_MAX_AGE_HOURS,
380
+ }, "funding"),
381
+ // No coin filter, so `totals` reflects the whole requested class.
382
+ safeCall(callApi, "/api/v1/funding/settlement", "/api/funding/settlement", { window: "current" }, "settlement"),
245
383
  ]);
246
- const settled = results.map((r) => r.status === "fulfilled"
247
- ? r.value
248
- : { data: null, error: "Promise rejected" });
249
- const [overview, narratives, funding] = settled;
250
- const errors = settled
251
- .filter((s) => s.error)
252
- .map((s) => s.error);
384
+ const [overview, narratives, funding, settlement] = settled;
253
385
  const lines = ["## Market Briefing"];
254
386
  // Market overview
255
- if (overview.data && typeof overview.data === "object") {
387
+ if (isRecord(overview.data)) {
256
388
  const d = overview.data;
257
- const mcap = d.total_market_cap;
258
- const vol = (d.total_volume_24h ?? d.total_volume);
259
- const btcDom = d.btc_dominance;
260
- const fg = (d.fgi ?? d.fear_greed_index ?? d.fear_greed);
389
+ const mcap = finiteOrNull(d.total_market_cap);
390
+ const vol = finiteOrNull(d.total_volume_24h ?? d.total_volume);
391
+ // Served in percent (57.3 = 57.3%); `btc_dominance` is the same figure.
392
+ const btcDom = finiteOrNull(d.btc_dominance_pct ?? d.btc_dominance);
393
+ const fg = d.fgi ?? d.fear_greed_index ?? d.fear_greed;
261
394
  lines.push("");
262
395
  lines.push("### Market Stats");
263
396
  if (mcap != null)
264
397
  lines.push(`**Total Market Cap:** ${fmtUsd(mcap)}`);
265
398
  if (vol != null)
266
399
  lines.push(`**24h Volume:** ${fmtUsd(vol)}`);
267
- if (btcDom != null) {
268
- const pct = btcDom > 1 ? btcDom : btcDom * 100;
269
- lines.push(`**BTC Dominance:** ${pct.toFixed(1)}%`);
270
- }
400
+ if (btcDom != null)
401
+ lines.push(`**BTC Dominance:** ${btcDom.toFixed(1)}%`);
271
402
  if (fg != null) {
272
- const fgVal = typeof fg === "object" ? fg.value : fg;
273
- let fgLabel = typeof fg === "object" ? (fg.label ?? "") : "";
403
+ const fgVal = isRecord(fg) ? fg.value : fg;
404
+ let fgLabel = isRecord(fg) ? (text(fg.label) ?? "") : "";
274
405
  if (!fgLabel)
275
- fgLabel = (d.fgi_classification ?? "");
406
+ fgLabel = text(d.fgi_classification) ?? "";
276
407
  lines.push(`**Fear & Greed:** ${fgVal} ${fgLabel}`);
277
408
  }
278
- // Top gainers
279
- const gainers = (d.top_gainers ?? []);
280
- if (Array.isArray(gainers) && gainers.length > 0) {
281
- lines.push("");
282
- lines.push("### Top Gainers (24h)");
283
- for (const g of gainers.slice(0, 5)) {
284
- const sym = (g.symbol ?? g.name ?? "?");
285
- const chg = (g.price_change_24h ?? g.change);
286
- lines.push(` ${sym}: ${fmtPct(chg)}`);
287
- }
288
- }
289
- // Top losers
290
- const losers = (d.top_losers ?? []);
291
- if (Array.isArray(losers) && losers.length > 0) {
409
+ for (const [key, heading] of [
410
+ ["top_gainers", "### Top Gainers (24h)"],
411
+ ["top_losers", "### Top Losers (24h)"],
412
+ ]) {
413
+ const movers = records(d[key]);
414
+ if (movers.length === 0)
415
+ continue;
292
416
  lines.push("");
293
- lines.push("### Top Losers (24h)");
294
- for (const l of losers.slice(0, 5)) {
295
- const sym = (l.symbol ?? l.name ?? "?");
296
- const chg = (l.price_change_24h ?? l.change);
297
- lines.push(` ${sym}: ${fmtPct(chg)}`);
417
+ lines.push(heading);
418
+ for (const m of movers.slice(0, 5)) {
419
+ const sym = text(m.symbol) ?? text(m.name) ?? "?";
420
+ lines.push(` ${sym}: ${fmtPct(finiteOrNull(m.price_change_24h ?? m.change))}`);
298
421
  }
299
422
  }
300
423
  }
301
424
  else {
302
425
  lines.push("\n*Market overview data unavailable*");
303
426
  }
304
- // Narratives
305
- if (narratives.data) {
306
- let narrList = narratives.data;
307
- if (typeof narrList === "object" &&
308
- narrList !== null &&
309
- !Array.isArray(narrList)) {
310
- const obj = narrList;
311
- narrList = obj.narratives ?? obj.items ?? narrList;
312
- }
313
- if (Array.isArray(narrList) && narrList.length > 0) {
314
- const typed = narrList.filter((n) => typeof n === "object" && n !== null);
315
- const sortedNarr = [...typed].sort((a, b) => {
316
- const aChg = Number(a.change24h ?? a.price_change_24h ?? a.change_24h ?? 0);
317
- const bChg = Number(b.change24h ?? b.price_change_24h ?? b.change_24h ?? 0);
318
- return bChg - aChg;
319
- });
427
+ // Narratives, as the API ranked them.
428
+ if (isRecord(narratives.data)) {
429
+ const top = narratives.data.top;
430
+ const bottom = narratives.data.bottom;
431
+ if (Array.isArray(top) && Array.isArray(bottom)) {
320
432
  lines.push("");
321
433
  lines.push("### Top Narratives (24h)");
322
- for (const n of sortedNarr.slice(0, 5)) {
323
- const name = (n.name ?? n.narrative ?? "?");
324
- const chg = (n.change24h ?? n.price_change_24h ?? n.change_24h);
325
- const mcapN = (n.marketCap ?? n.market_cap);
326
- lines.push(` **${name}:** ${fmtPct(chg)} (mcap: ${fmtUsd(mcapN)})`);
434
+ for (const n of records(top)) {
435
+ lines.push(` **${text(n.name) ?? "?"}:** ${fmtPct(finiteOrNull(n.change24h))} (mcap: ${fmtUsd(finiteOrNull(n.marketCap))})`);
327
436
  }
328
437
  lines.push("");
329
438
  lines.push("### Worst Narratives (24h)");
330
- for (const n of sortedNarr.slice(-3)) {
331
- const name = (n.name ?? n.narrative ?? "?");
332
- const chg = (n.change24h ?? n.price_change_24h ?? n.change_24h);
333
- lines.push(` **${name}:** ${fmtPct(chg)}`);
439
+ for (const n of records(bottom)) {
440
+ lines.push(` **${text(n.name) ?? "?"}:** ${fmtPct(finiteOrNull(n.change24h))}`);
334
441
  }
335
442
  }
443
+ else {
444
+ lines.push("\n*Narrative ranking unavailable*");
445
+ }
336
446
  }
337
447
  else {
338
448
  lines.push("\n*Narrative data unavailable*");
339
449
  }
340
- // Funding highlights
341
- if (funding.data) {
342
- let ratesList = funding.data;
343
- if (typeof ratesList === "object" &&
344
- ratesList !== null &&
345
- !Array.isArray(ratesList) &&
346
- "items" in ratesList) {
347
- ratesList = ratesList.items;
450
+ // Funding highlights: the API's extremes, never re-ranked here.
451
+ const extremes = safeGet(funding.data, "extremes");
452
+ if (isRecord(extremes)) {
453
+ const highest = records(extremes.highest);
454
+ const lowest = records(extremes.lowest);
455
+ lines.push("");
456
+ lines.push("### Funding Rate Highlights");
457
+ lines.push(`*Ranked by the API on the 8h-equivalent rate (rate x 8 / interval_hours) among fresh markets with at least ${fmtUsd(LIQUIDITY_FLOOR_USD)} open interest; APR = rate x 8760 / interval_hours.*`);
458
+ const omitted = isRecord(extremes.omitted) ? extremes.omitted : {};
459
+ for (const [key, reason] of [
460
+ ["stale", "stale, past the funding freshness SLA"],
461
+ ["no_interval", "no rate or no published settlement interval"],
462
+ [
463
+ "below_floor",
464
+ `open interest below ${fmtUsd(LIQUIDITY_FLOOR_USD)} or unknown`,
465
+ ],
466
+ ]) {
467
+ const count = finiteOrNull(omitted[key]);
468
+ if (count)
469
+ lines.push(`*${count} markets left out: ${reason}.*`);
348
470
  }
349
- if (Array.isArray(ratesList) && ratesList.length > 0) {
350
- const valid = ratesList.filter((r) => {
351
- if (typeof r !== "object" || r === null)
352
- return false;
353
- const rec = r;
354
- return (rec.rate ?? rec.funding_rate) != null;
355
- });
356
- if (valid.length > 0) {
357
- const rateVal = (r) => Number(r.rate ?? r.funding_rate ?? 0);
358
- const sorted = [...valid].sort((a, b) => rateVal(b) - rateVal(a));
359
- lines.push("");
360
- lines.push("### Funding Rate Highlights");
361
- lines.push("**Highest (longs paying most):**");
362
- for (const r of sorted.slice(0, 3)) {
363
- const coinName = (r.base_coin ?? r.coin ?? r.symbol ?? "?");
364
- const exchange = (r.exchange ?? "?");
365
- lines.push(` ${coinName} on ${exchange}: ${fmtRate(rateVal(r))}`);
366
- }
367
- lines.push("**Lowest (shorts paying most):**");
368
- for (const r of sorted.slice(-3)) {
369
- const coinName = (r.base_coin ?? r.coin ?? r.symbol ?? "?");
370
- const exchange = (r.exchange ?? "?");
371
- lines.push(` ${coinName} on ${exchange}: ${fmtRate(rateVal(r))}`);
372
- }
373
- }
471
+ if (highest.length === 0 && lowest.length === 0) {
472
+ lines.push("*No market clears the floor.*");
473
+ }
474
+ else {
475
+ lines.push("**Highest (longs paying most):**");
476
+ for (const row of highest)
477
+ lines.push(fundingExtremeLine(row));
478
+ lines.push("**Lowest (shorts paying most):**");
479
+ for (const row of lowest)
480
+ lines.push(fundingExtremeLine(row));
374
481
  }
375
482
  }
376
483
  else {
377
484
  lines.push("\n*Funding rate data unavailable*");
378
485
  }
379
- if (errors.length > 0) {
486
+ // Funding settlement: whole-market dollar total, not a per-row rate.
487
+ if (settlement.data) {
488
+ const totals = settlement.data.totals;
489
+ const net = finiteOrNull(safeGet(totals, "current", "net"));
490
+ const longPaid = finiteOrNull(safeGet(totals, "current", "long_paid"));
491
+ const shortPaid = finiteOrNull(safeGet(totals, "current", "short_paid"));
380
492
  lines.push("");
381
- lines.push(`**Data gaps:** ${errors.join("; ")}`);
493
+ lines.push("### Funding Settlement (next, estimated)");
494
+ if (net == null) {
495
+ lines.push("*No funding data for the next settlement.*");
496
+ }
497
+ else if (net > 0) {
498
+ lines.push(`Longs will pay ${fmtUsd(net)} to shorts (long ${fmtUsd(longPaid)} / short ${fmtUsd(shortPaid)}).`);
499
+ }
500
+ else if (net < 0) {
501
+ lines.push(`Shorts will pay ${fmtUsd(-net)} to longs (long ${fmtUsd(longPaid)} / short ${fmtUsd(shortPaid)}).`);
502
+ }
503
+ else {
504
+ lines.push("No net funding expected at the next settlement.");
505
+ }
506
+ }
507
+ else {
508
+ lines.push("\n*Funding settlement data unavailable*");
382
509
  }
383
- return lines.join("\n");
510
+ return finish(lines, settled);
384
511
  }
385
512
  // ── find_opportunities ────────────────────────────────────────────────
513
+ /** A row's own status flags, printed only when they say something. */
514
+ function statusSuffix(row) {
515
+ const parts = [text(row.executionStatus) ?? "n/a"];
516
+ const pair = text(row.pairStatus);
517
+ if (pair && pair !== "eligible")
518
+ parts.push(pair);
519
+ if (row.fundingIsStale === true)
520
+ parts.push("funding stale");
521
+ return parts.join(" | ");
522
+ }
386
523
  export async function findOpportunities(callApi) {
387
- const results = await Promise.allSettled([
388
- safeCall(callApi, "/api/v1/arbitrage/spot-perp", "/api/arbitrage/spot-perp", { exchange: "all", direction: "all" }, "spot-perp-arb"),
389
- safeCall(callApi, "/api/v1/arbitrage/cross-exchange", "/api/arbitrage/cross-exchange", {}, "cross-exchange-arb"),
390
- safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { type: "current" }, "funding"),
524
+ const settled = await settleAll([
525
+ // The API floors the perp leg's open interest (unknown fails it) and
526
+ // keeps its board order: net APR first, then gross APR.
527
+ safeCall(callApi, "/api/v1/arbitrage/spot-perp", "/api/arbitrage/spot-perp", { exchange: "all", direction: "all", minOiUsd: LIQUIDITY_FLOOR_USD }, "spot-perp-arb"),
528
+ // Both legs must clear the floor; the API ranks executable rows first and
529
+ // cuts the board to the rows shown.
530
+ safeCall(callApi, "/api/v1/arbitrage/cross-exchange", "/api/arbitrage/cross-exchange", {
531
+ minOiUsd: LIQUIDITY_FLOOR_USD,
532
+ minVolUsd: LIQUIDITY_FLOOR_USD,
533
+ limit: 10,
534
+ }, "cross-exchange-arb"),
535
+ // The whole book's funding stats (the v1 derivatives overview's
536
+ // computation, as APRs).
537
+ safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { type: "current", stats: "1" }, "funding"),
391
538
  ]);
392
- const settled = results.map((r) => r.status === "fulfilled"
393
- ? r.value
394
- : { data: null, error: "Promise rejected" });
395
539
  const [spotPerp, crossEx, funding] = settled;
396
- const errors = settled
397
- .filter((s) => s.error)
398
- .map((s) => s.error);
540
+ const floorLabel = fmtUsd(LIQUIDITY_FLOOR_USD);
399
541
  const lines = ["## Arbitrage Opportunities"];
400
542
  // Spot-perp basis trades
401
543
  if (spotPerp.data) {
402
- let arbList = spotPerp.data;
403
- if (typeof arbList === "object" &&
404
- arbList !== null &&
405
- !Array.isArray(arbList) &&
406
- "items" in arbList) {
407
- arbList = arbList.items;
408
- }
409
- if (Array.isArray(arbList) && arbList.length > 0) {
410
- const typed = arbList.filter((a) => typeof a === "object" && a !== null);
411
- const sorted = [...typed].sort((a, b) => {
412
- return (Math.abs(Number(b.apr ?? b.annualized_apr ?? 0)) -
413
- Math.abs(Number(a.apr ?? a.annualized_apr ?? 0)));
414
- });
415
- lines.push("");
416
- lines.push("### Spot-Perp Basis Trades (Top 10 by APR)");
417
- lines.push("*Long spot + short perp (or vice versa) to capture funding*");
418
- lines.push("");
419
- for (const a of sorted.slice(0, 10)) {
420
- const symbol = (a.symbol ?? a.coin ?? "?");
421
- const exchange = (a.exchange ?? "?");
422
- const apr = (a.apr ?? a.annualized_apr ?? 0);
423
- const rate = (a.fundingRate ?? a.rate ?? a.funding_rate);
424
- const direction = (a.direction ?? "?");
425
- const aprStr = apr != null ? `${apr.toFixed(1)}%` : "N/A";
426
- const rateStr = rate != null ? fmtRate(rate) : "";
427
- lines.push(` **${symbol}** on ${exchange} | APR: ${aprStr} | Rate: ${rateStr} | Dir: ${direction}`);
428
- }
544
+ const rows = listRows(spotPerp.data) ?? [];
545
+ lines.push("");
546
+ lines.push("### Spot-Perp Basis Trades (Top 10)");
547
+ lines.push("*Long spot + short perp (or vice versa) to capture funding*");
548
+ lines.push(`*Perp legs with at least ${floorLabel} open interest, in the API's order: net APR first, then gross APR.*`);
549
+ lines.push("");
550
+ if (rows.length === 0) {
551
+ lines.push(" *No spot-perp row clears the liquidity floor.*");
429
552
  }
430
- else {
431
- lines.push("\n*No spot-perp opportunities found*");
553
+ for (const a of rows.slice(0, 10)) {
554
+ lines.push(` **${text(a.symbol) ?? "?"}** on ${text(a.exchange) ?? "?"} | Net APR: ${fmtApr(a.netApr)} | Gross APR: ${fmtApr(a.apr)} | Rate: ${fmtRate(finiteOrNull(a.fundingRate))} per ${finiteOrNull(a.intervalHours) ?? "?"}h | Dir: ${text(a.direction) ?? "?"} | OI ${fmtUsd(finiteOrNull(a.perpOpenInterestUsd))} | ${statusSuffix(a)}`);
432
555
  }
433
556
  }
434
557
  else {
@@ -436,85 +559,34 @@ export async function findOpportunities(callApi) {
436
559
  }
437
560
  // Cross-exchange funding arb
438
561
  if (crossEx.data) {
439
- let cxList = crossEx.data;
440
- if (typeof cxList === "object" &&
441
- cxList !== null &&
442
- !Array.isArray(cxList) &&
443
- "items" in cxList) {
444
- cxList = cxList.items;
445
- }
446
- if (Array.isArray(cxList) && cxList.length > 0) {
447
- const typed = cxList.filter((a) => typeof a === "object" && a !== null);
448
- const sorted = [...typed].sort((a, b) => {
449
- return (Math.abs(Number(b.apr ?? b.spread_apr ?? b.annualized_apr ?? 0)) -
450
- Math.abs(Number(a.apr ?? a.spread_apr ?? a.annualized_apr ?? 0)));
451
- });
452
- lines.push("");
453
- lines.push("### Cross-Exchange Funding Arb (Top 10 by APR)");
454
- lines.push("*Long on low-rate exchange, short on high-rate exchange*");
455
- lines.push("");
456
- for (const a of sorted.slice(0, 10)) {
457
- const symbol = (a.symbol ?? a.coin ?? "?");
458
- const longEx = (a.longExchange ??
459
- a.long_exchange ??
460
- a.exchange_long ??
461
- "?");
462
- const shortEx = (a.shortExchange ??
463
- a.short_exchange ??
464
- a.exchange_short ??
465
- "?");
466
- const apr = (a.apr ?? a.spread_apr ?? a.annualized_apr ?? 0);
467
- const spread = (a.spreadRate ?? a.spread ?? a.spread_rate);
468
- const aprStr = apr != null ? `${apr.toFixed(1)}%` : "N/A";
469
- const spreadStr = spread != null ? fmtRate(spread) : "";
470
- lines.push(` **${symbol}** | Long ${longEx} / Short ${shortEx} | APR: ${aprStr} | Spread: ${spreadStr}`);
471
- }
562
+ const rows = listRows(crossEx.data) ?? [];
563
+ lines.push("");
564
+ lines.push("### Cross-Exchange Funding Arb (Top 10)");
565
+ lines.push("*Long on low-rate exchange, short on high-rate exchange*");
566
+ lines.push(`*Both legs have at least ${floorLabel} open interest and 24h volume, in the API's order: executable (book-priced) rows first by net APR, then indicative rows by gross APR.*`);
567
+ lines.push("");
568
+ if (rows.length === 0) {
569
+ lines.push(" *No cross-exchange row clears the liquidity floor.*");
472
570
  }
473
- else {
474
- lines.push("\n*No cross-exchange opportunities found*");
571
+ for (const a of rows.slice(0, 10)) {
572
+ lines.push(` **${text(a.symbol) ?? "?"}** | Long ${text(a.longExchange) ?? "?"} / Short ${text(a.shortExchange) ?? "?"} | Net APR: ${fmtApr(a.netApr)} | Gross APR: ${fmtApr(a.apr)} | Spread: ${fmtRate(finiteOrNull(a.spreadRate))} | ${statusSuffix(a)}`);
475
573
  }
476
574
  }
477
575
  else {
478
576
  lines.push("\n*Cross-exchange arbitrage data unavailable*");
479
577
  }
480
- // Funding rate summary
481
- if (funding.data) {
482
- let ratesList = funding.data;
483
- if (typeof ratesList === "object" &&
484
- ratesList !== null &&
485
- !Array.isArray(ratesList) &&
486
- "items" in ratesList) {
487
- ratesList = ratesList.items;
488
- }
489
- if (Array.isArray(ratesList) && ratesList.length > 0) {
490
- const valid = ratesList.filter((r) => {
491
- if (typeof r !== "object" || r === null)
492
- return false;
493
- const rec = r;
494
- return (rec.rate ?? rec.funding_rate) != null;
495
- });
496
- if (valid.length > 0) {
497
- const allRates = valid.map((r) => Number(r.rate ?? r.funding_rate ?? 0));
498
- const avg = allRates.reduce((a, b) => a + b, 0) / allRates.length;
499
- const positive = allRates.filter((r) => r > 0).length;
500
- const negative = allRates.filter((r) => r < 0).length;
501
- lines.push("");
502
- lines.push("### Funding Rate Summary");
503
- lines.push(`**Avg Rate:** ${fmtRate(avg)}`);
504
- lines.push(`**Positive (longs pay):** ${positive} | **Negative (shorts pay):** ${negative}`);
505
- const sentiment = positive > negative * 1.5
506
- ? "bullish"
507
- : negative > positive * 1.5
508
- ? "bearish"
509
- : "neutral";
510
- lines.push(`**Market Sentiment:** ${sentiment} (based on funding rate skew)`);
511
- }
512
- }
513
- }
514
- if (errors.length > 0) {
578
+ // Funding rate summary: the API's market stats.
579
+ const market = safeGet(funding.data, "market");
580
+ if (isRecord(market)) {
515
581
  lines.push("");
516
- lines.push(`**Data gaps:** ${errors.join("; ")}`);
582
+ lines.push("### Funding Rate Summary");
583
+ lines.push("*The API's market stats: the fresh primary contract of every (venue, asset), all asset classes.*");
584
+ lines.push(`**Avg APR:** ${fmtApr(market.funding_apr_mean)} | **OI-weighted APR:** ${fmtApr(market.funding_apr_oi_weighted)}`);
585
+ lines.push(`**Positive (longs pay):** ${finiteOrNull(market.positive) ?? "N/A"} | **Negative (shorts pay):** ${finiteOrNull(market.negative) ?? "N/A"}`);
586
+ }
587
+ else {
588
+ lines.push("\n*Funding rate data unavailable*");
517
589
  }
518
- return lines.join("\n");
590
+ return finish(lines, settled);
519
591
  }
520
592
  //# sourceMappingURL=composite-tools.js.map