@sharpe-terminal/mcp-server 1.4.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,10 +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";
6
13
  import { splitPaginated } from "./tool-runtime.js";
7
- import { rowsForFundingCoin } from "./tools.js";
8
14
  // ── Formatting helpers ────────────────────────────────────────────────
9
15
  export function fmtUsd(value) {
10
16
  if (value == null)
@@ -31,72 +37,33 @@ export function fmtRate(value) {
31
37
  return "N/A";
32
38
  return `${(value * 100).toFixed(4)}%`;
33
39
  }
34
- const HOURS_PER_YEAR = 8760;
35
- /**
36
- * A funding row's own settlement interval. Returns null rather than a default
37
- * when the venue publishes none — see src/products/funding-rate/lib/
38
- * CALCULATIONS.md: a stored rate is never implicitly an 8-hour rate.
39
- */
40
- export function intervalHours(r) {
41
- const raw = Number(r.interval_hours ?? r.intervalHours);
42
- return Number.isFinite(raw) && raw > 0 ? raw : null;
43
- }
44
- function rateVal(r) {
45
- return Number(r.rate ?? r.funding_rate ?? 0);
46
- }
47
- /** APR for one observation: `rate x (8760 / interval_hours)`. */
48
- export function aprVal(r) {
49
- const hours = intervalHours(r);
50
- return hours == null ? Number.NaN : rateVal(r) * (HOURS_PER_YEAR / hours);
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)}%`;
51
44
  }
52
45
  /**
53
- * Minimum open interest (USD) for a market to rank in market_briefing and
54
- * find_opportunities, and the per-leg OI and volume floor sent to the
55
- * cross-exchange scanner. Without it every extreme was a dust market (ESIM
56
- * on KuCoin: $33K of OI at -16,862% APR, 2026-09-24 audit). Unknown OI fails
57
- * the floor, the same rule the cross-exchange API applies.
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.
58
51
  */
59
52
  export const LIQUIDITY_FLOOR_USD = 1_000_000;
60
- function meetsLiquidityFloor(value) {
61
- return value != null && Number(value) >= LIQUIDITY_FLOOR_USD;
62
- }
63
- /** FUNDING_RATE_FRESHNESS_SLA_SECONDS in src/products/funding-rate/lib/freshness.ts. */
64
- export const FUNDING_FRESHNESS_SLA_MS = 10 * 60 * 1000;
65
53
  /**
66
- * A funding row past its freshness SLA. The free endpoint serves stale rows
67
- * flagged `is_stale: true` rather than dropping them, so rankings skip them;
68
- * a row without the flag is judged from its `updated_at`.
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.
69
57
  */
70
- export function isStaleRow(row, nowMs = Date.now()) {
71
- if (typeof row.is_stale === "boolean")
72
- return row.is_stale;
73
- const updatedAt = Date.parse(String(row.updated_at ?? ""));
74
- return (Number.isFinite(updatedAt) && nowMs - updatedAt > FUNDING_FRESHNESS_SLA_MS);
75
- }
58
+ export const FUNDING_MAX_AGE_HOURS = 6;
76
59
  function isRecord(value) {
77
60
  return value !== null && typeof value === "object" && !Array.isArray(value);
78
61
  }
79
62
  function finiteOrNull(value) {
80
63
  return typeof value === "number" && Number.isFinite(value) ? value : null;
81
64
  }
82
- /** Sort key for a nullable number: missing sorts last in a descending sort. */
83
- function sortKey(value) {
84
- const n = Number(value);
85
- return value != null && Number.isFinite(n) ? n : Number.NEGATIVE_INFINITY;
86
- }
87
- /** An annualized fraction (0.25 = 25%) as a percentage, or N/A. */
88
- function fmtApr(value) {
89
- const n = Number(value);
90
- return value == null || !Number.isFinite(n)
91
- ? "N/A"
92
- : `${(n * 100).toFixed(1)}%`;
93
- }
94
- function median(values) {
95
- const sorted = [...values].sort((a, b) => a - b);
96
- const mid = Math.floor(sorted.length / 2);
97
- return sorted.length % 2 === 0
98
- ? (sorted[mid - 1] + sorted[mid]) / 2
99
- : sorted[mid];
65
+ function text(value) {
66
+ return typeof value === "string" && value !== "" ? value : null;
100
67
  }
101
68
  /** Rows of a list payload that may be wrapped as `{ data, pagination }` or `{ items }`. */
102
69
  function listRows(payload) {
@@ -105,69 +72,8 @@ function listRows(payload) {
105
72
  rows = rows.items;
106
73
  return Array.isArray(rows) ? rows.filter(isRecord) : null;
107
74
  }
108
- /** A venue whose latest reading trails the newest by more than this is not current OI. */
109
- export const OI_FRESHNESS_WINDOW_MS = 2 * 60 * 60 * 1000;
110
- /**
111
- * Venues the API names in `openInterestExcludedVenues`: their rows are
112
- * shown per venue but never summed (MEXC, whose figure moves 7.00x its own
113
- * trade flow), keyed to the reason the payload gives.
114
- */
115
- export function excludedOiVenues(payload) {
116
- const excluded = new Map();
117
- const listed = isRecord(payload)
118
- ? payload.openInterestExcludedVenues
119
- : undefined;
120
- if (!Array.isArray(listed))
121
- return excluded;
122
- for (const item of listed) {
123
- if (isRecord(item) && typeof item.exchange === "string") {
124
- excluded.set(item.exchange, typeof item.reason === "string" ? item.reason : "");
125
- }
126
- }
127
- return excluded;
128
- }
129
- /**
130
- * Each venue's latest open-interest reading, split into venues that are
131
- * current (within OI_FRESHNESS_WINDOW_MS of the newest reading), venues
132
- * that stopped reporting, and venues the API excludes from totals. Totals
133
- * sum every current venue: summing only the ten listed understated BTC by
134
- * 9.4% (2026-09-24 audit).
135
- */
136
- export function summarizeVenueOi(rows, excludedVenues = new Map()) {
137
- const latest = new Map();
138
- for (const item of rows) {
139
- if (!isRecord(item))
140
- continue;
141
- const value = Number(item.open_interest_value ?? item.value ?? item.oi ?? item.open_interest);
142
- const at = Date.parse(String(item.timestamp ?? item.snapshot_at ?? ""));
143
- if (!(value > 0) || !Number.isFinite(at))
144
- continue;
145
- const exchange = String(item.exchange ?? "Unknown");
146
- const prev = latest.get(exchange);
147
- if (!prev || at >= prev.at)
148
- latest.set(exchange, { exchange, value, at });
149
- }
150
- const all = [...latest.values()];
151
- const newest = Math.max(...all.map((venue) => venue.at));
152
- const byValue = (a, b) => b.value - a.value;
153
- const venues = all.filter((venue) => !excludedVenues.has(venue.exchange));
154
- return {
155
- current: venues
156
- .filter((venue) => newest - venue.at <= OI_FRESHNESS_WINDOW_MS)
157
- .sort(byValue),
158
- stale: venues
159
- .filter((venue) => newest - venue.at > OI_FRESHNESS_WINDOW_MS)
160
- .sort(byValue),
161
- excluded: all
162
- .filter((venue) => excludedVenues.has(venue.exchange))
163
- .sort(byValue),
164
- };
165
- }
166
- function fundingHighlightLine(r) {
167
- const coinName = (r.base_coin ?? r.coin ?? r.symbol ?? "?");
168
- const exchange = (r.exchange ?? "?");
169
- const aprStr = `${(aprVal(r) * 100).toFixed(1)}%`;
170
- return ` ${coinName} on ${exchange}: ${aprStr} APR (${fmtRate(rateVal(r))} per ${intervalHours(r)}h)`;
75
+ function records(value) {
76
+ return Array.isArray(value) ? value.filter(isRecord) : [];
171
77
  }
172
78
  function safeGet(obj, ...keys) {
173
79
  let current = obj;
@@ -181,6 +87,51 @@ function safeGet(obj, ...keys) {
181
87
  }
182
88
  return current;
183
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
+ }
184
135
  async function safeCall(callApi, apiPath, fallbackPath, args, label) {
185
136
  try {
186
137
  const data = await callApi(apiPath, fallbackPath, args);
@@ -191,6 +142,20 @@ async function safeCall(callApi, apiPath, fallbackPath, args, label) {
191
142
  return { data: null, error: `[${label}] ${msg}` };
192
143
  }
193
144
  }
145
+ async function settleAll(tasks) {
146
+ const results = await Promise.allSettled(tasks);
147
+ return results.map((r) => r.status === "fulfilled"
148
+ ? r.value
149
+ : { data: null, error: "Promise rejected" });
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
+ }
194
159
  // ── Price-prediction coin resolution ──────────────────────────────────
195
160
  /**
196
161
  * Find the prediction row for a ticker in the endpoint's own coverage list.
@@ -224,169 +189,142 @@ export function findPredictionRow(payload, ticker) {
224
189
  return null;
225
190
  }
226
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
+ }
227
260
  export async function analyzeCoin(callApi, coin) {
228
261
  const coinUpper = coin.toUpperCase();
229
- const tasks = [
230
- safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates",
231
- // Narrow server-side: the unfiltered accumulated book is ~16k rows, and
232
- // slicing it client-side left every coin past the alphabetical head
233
- // reporting "No funding rates found" (2026-08-01 audit).
234
- { type: "accumulated", coin: coinUpper }, "funding"),
235
- safeCall(callApi, "/api/v1/futures/data", "/api/futures/data", { chart: "oi-snapshot", coin: coinUpper, timeframe: "3M" }, "oi"),
262
+ const fundingScope = {
263
+ type: "current",
264
+ coin: coinUpper,
265
+ max_age_h: FUNDING_MAX_AGE_HOURS,
266
+ };
267
+ const settled = await settleAll([
236
268
  // No `coin` param: the endpoint is slug-keyed and the caller gives us a
237
269
  // ticker, so ask for the coverage list and match the ticker in it rather
238
270
  // than shipping a guessed slug that can resolve to another asset.
239
271
  safeCall(callApi, "/api/v1/price-prediction/data", "/api/price-prediction/data", {}, "prediction"),
240
- // The dollar value actually paid at the next settlement -- funding
241
- // rates above show the rate, not the OI-weighted amount that changes
242
- // hands. Coin-scoped, so this reads the coin's own row below, never
243
- // `totals` (which stays scoped to the whole class regardless of the
244
- // `coin` filter -- see settlement-query.ts).
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).
245
283
  safeCall(callApi, "/api/v1/funding/settlement", "/api/funding/settlement", { coin: coinUpper, window: "current" }, "settlement"),
246
- ];
247
- const results = await Promise.allSettled(tasks);
248
- const settled = results.map((r) => r.status === "fulfilled"
249
- ? r.value
250
- : { data: null, error: "Promise rejected" });
251
- const [funding, oi, prediction, settlement] = settled;
252
- const errors = settled
253
- .filter((s) => s.error)
254
- .map((s) => s.error);
284
+ ]);
285
+ const [prediction, funding, summary, global, settlement] = settled;
255
286
  const lines = [`## ${coinUpper} Analysis`];
256
- // Price prediction. The coverage list already carries the full score row for
257
- // every covered coin, so the matched row is the answer — no second call.
258
- const predictionRow = prediction.data
259
- ? findPredictionRow(prediction.data, coinUpper)
260
- : null;
261
- if (predictionRow) {
262
- const finalPred = predictionRow;
263
- const bias = (safeGet(finalPred, "direction") ??
264
- safeGet(finalPred, "bias") ??
265
- "unknown");
266
- const score = (safeGet(finalPred, "consensusScore") ??
267
- safeGet(finalPred, "score"));
268
- const price = (safeGet(finalPred, "spotPrice") ??
269
- safeGet(finalPred, "price"));
270
- const change24h = (safeGet(finalPred, "priceChange24h") ??
271
- safeGet(finalPred, "price_change_24h"));
272
- lines.push("");
273
- lines.push(`**Price:** ${price != null ? fmtUsd(price) : "N/A"} | **24h:** ${change24h != null ? fmtPct(change24h) : ""}`);
274
- lines.push(`**Prediction:** ${String(bias).charAt(0).toUpperCase() + String(bias).slice(1)} (score: ${score != null ? `${score}/100` : "N/A"})`);
275
- const signals = (safeGet(finalPred, "signals") ??
276
- safeGet(finalPred, "technical"));
277
- if (Array.isArray(signals) && signals.length > 0) {
278
- const parts = signals
279
- .filter((s) => typeof s === "object" && s !== null)
280
- .map((s) => {
281
- const label = (s.label ?? s.name ?? "");
282
- const sigBias = (s.bias ?? "");
283
- return label && sigBias ? `${label}: ${sigBias}` : null;
284
- })
285
- .filter(Boolean)
286
- .slice(0, 6);
287
- if (parts.length > 0)
288
- lines.push(`**Signals:** ${parts.join(", ")}`);
289
- }
290
- else if (typeof signals === "object" && signals !== null) {
291
- const sigObj = signals;
292
- const parts = [];
293
- if (sigObj.rsi != null)
294
- parts.push(`RSI ${Number(sigObj.rsi).toFixed(0)}`);
295
- if (sigObj.ema_trend)
296
- parts.push(`EMA ${sigObj.ema_trend}`);
297
- if (parts.length > 0)
298
- lines.push(`**Technicals:** ${parts.join(", ")}`);
299
- }
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*");
300
292
  }
301
- else if (prediction.data) {
302
- lines.push(`\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.*`);
293
+ else if (fundingRows.length === 0) {
294
+ lines.push("\n*No funding rates found for this coin*");
303
295
  }
304
296
  else {
305
- lines.push("\n*Price prediction data unavailable*");
306
- }
307
- // Open interest per venue. Read before the funding section, which lists
308
- // venues in open-interest order.
309
- let oiRows = oi.data;
310
- const oiExcluded = excludedOiVenues(oiRows);
311
- if (isRecord(oiRows) && "data" in oiRows)
312
- oiRows = oiRows.data;
313
- const venueOi = Array.isArray(oiRows)
314
- ? summarizeVenueOi(oiRows, oiExcluded)
315
- : null;
316
- // Funding. type=accumulated rows carry realised sums of settled funding
317
- // (acc_1d, acc_7d), not a per-interval rate, and arrive ordered by
318
- // exchange name. List the largest venues by open interest and summarise
319
- // every row: averaging the first ten alphabetical rows skipped Bybit, OKX
320
- // and Hyperliquid (2026-09-24 audit).
321
- if (funding.data) {
322
- const ratesList = listRows(funding.data);
323
- if (ratesList) {
324
- // Exact identity match only: a `symbol.startsWith` prefix test folded
325
- // unrelated markets in (BTC pulling BTCDOM, SOL pulling SOLAYER/SOLV).
326
- // The API scopes `coin` to an asset, so PEPE's rows include its
327
- // 1000PEPE and KPEPE lot contracts; rowsForFundingCoin keeps them.
328
- const coinRates = rowsForFundingCoin(ratesList, coinUpper);
329
- if (coinRates.length > 0) {
330
- const oiByExchange = new Map((venueOi?.current ?? []).map((venue) => [
331
- venue.exchange,
332
- venue.value,
333
- ]));
334
- const venueOiOf = (rec) => oiByExchange.get(String(rec.exchange ?? "")) ?? -1;
335
- const listed = [...coinRates].sort((a, b) => venueOiOf(b) - venueOiOf(a) ||
336
- String(a.exchange ?? "").localeCompare(String(b.exchange ?? "")));
337
- lines.push("");
338
- lines.push("**Funding (realised 1d / 7d sums, largest venues by OI):**");
339
- for (const rec of listed.slice(0, 10)) {
340
- const market = [rec.exchange ?? "Unknown", rec.symbol]
341
- .filter(Boolean)
342
- .join(" ");
343
- lines.push(` ${market}: 1d ${fmtRate(finiteOrNull(rec.acc_1d))} | 7d ${fmtRate(finiteOrNull(rec.acc_7d))}`);
344
- }
345
- const settled = coinRates.filter((rec) => finiteOrNull(rec.acc_1d) != null);
346
- if (settled.length > 0) {
347
- // Row count and venue count differ: one venue can list the same coin
348
- // as both a linear and an inverse market.
349
- const exchanges = new Set(settled.map((rec) => String(rec.exchange ?? "Unknown")));
350
- const oneDay = median(settled.map((rec) => rec.acc_1d));
351
- lines.push(` **Median 1d realised funding:** ${fmtRate(oneDay)} across ${settled.length} markets on ${exchanges.size} exchanges`);
352
- }
353
- }
354
- else {
355
- lines.push("\n*No funding rates found for this coin*");
356
- }
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));
357
303
  }
358
304
  }
359
- else {
360
- lines.push("\n*Funding rate data unavailable*");
361
- }
362
- // Open interest
363
- if (oi.data) {
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);
364
311
  lines.push("");
365
312
  lines.push("**Open Interest:**");
366
- if (venueOi) {
367
- for (const venue of venueOi.current.slice(0, 10)) {
368
- lines.push(` ${venue.exchange}: ${fmtUsd(venue.value)}`);
369
- }
370
- if (venueOi.current.length > 0) {
371
- const total = venueOi.current.reduce((sum, venue) => sum + venue.value, 0);
372
- const listedNote = venueOi.current.length > 10 ? " (top 10 listed)" : "";
373
- lines.push(` **Total:** ${fmtUsd(total)} across ${venueOi.current.length} exchanges${listedNote}`);
374
- }
375
- if (venueOi.excluded.length > 0) {
376
- const excludedVenues = venueOi.excluded
377
- .map((venue) => {
378
- const reason = oiExcluded.get(venue.exchange);
379
- return `${venue.exchange} ${fmtUsd(venue.value)}${reason ? ` (${reason})` : ""}`;
380
- })
381
- .join("; ");
382
- lines.push(` *Excluded from the total, per-venue value only: ${excludedVenues}.*`);
383
- }
384
- if (venueOi.stale.length > 0) {
385
- const staleVenues = venueOi.stale
386
- .map((venue) => `${venue.exchange} (${new Date(venue.at).toISOString().slice(0, 16)}Z)`)
387
- .join(", ");
388
- lines.push(` *Excluded from the total, latest reading over 2h older than the newest: ${staleVenues}.*`);
389
- }
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))}`);
315
+ }
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("; ")}*`);
390
328
  }
391
329
  }
392
330
  else {
@@ -395,195 +333,151 @@ export async function analyzeCoin(callApi, coin) {
395
333
  // Settlement: dollar value actually paid at the next settlement.
396
334
  if (settlement.data) {
397
335
  const payload = settlement.data;
398
- const rows = payload.rows ?? payload.data;
399
- const row = Array.isArray(rows)
400
- ? rows.find((r) => typeof r === "object" &&
401
- r !== null &&
402
- String(r.base_coin ?? "").toUpperCase() === coinUpper)
403
- : undefined;
404
- const net = safeGet(row, "windows", "current", "net");
336
+ const rows = records(payload.rows ?? payload.data);
405
337
  lines.push("");
406
338
  lines.push("**Settlement (next, estimated):**");
407
- if (net == null) {
339
+ if (rows.length === 0) {
408
340
  lines.push(" No funding data for the next settlement.");
409
341
  }
410
- else if (net > 0) {
411
- lines.push(` Longs pay ${fmtUsd(net)} to shorts.`);
412
- }
413
- else if (net < 0) {
414
- lines.push(` Shorts pay ${fmtUsd(-net)} to longs.`);
415
- }
416
- else {
417
- lines.push(" No net funding at the next settlement.");
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.`);
350
+ }
351
+ else if (net < 0) {
352
+ lines.push(` ${prefix}Shorts pay ${fmtUsd(-net)} to longs.`);
353
+ }
354
+ else {
355
+ lines.push(` ${prefix}No net funding at the next settlement.`);
356
+ }
418
357
  }
419
358
  }
420
359
  else {
421
360
  lines.push("\n*Settlement data unavailable*");
422
361
  }
423
- if (errors.length > 0) {
424
- lines.push("");
425
- lines.push(`**Data gaps:** ${errors.join("; ")}`);
426
- }
427
- return lines.join("\n");
362
+ return finish(lines, settled);
428
363
  }
429
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
+ }
430
368
  export async function marketBriefing(callApi) {
431
- const results = await Promise.allSettled([
369
+ const settled = await settleAll([
432
370
  safeCall(callApi, "/api/v1/tracker/market-overview", "/api/tracker/market-overview", {}, "market-overview"),
433
- safeCall(callApi, "/api/v1/narratives/data", "/api/narratives/data", {}, "narratives"),
434
- safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { type: "current" }, "funding"),
435
- // No coin filter, so `totals` correctly reflects the whole requested
436
- // class (all, by default) rather than one coin's row.
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.
437
382
  safeCall(callApi, "/api/v1/funding/settlement", "/api/funding/settlement", { window: "current" }, "settlement"),
438
383
  ]);
439
- const settled = results.map((r) => r.status === "fulfilled"
440
- ? r.value
441
- : { data: null, error: "Promise rejected" });
442
384
  const [overview, narratives, funding, settlement] = settled;
443
- const errors = settled
444
- .filter((s) => s.error)
445
- .map((s) => s.error);
446
385
  const lines = ["## Market Briefing"];
447
386
  // Market overview
448
- if (overview.data && typeof overview.data === "object") {
387
+ if (isRecord(overview.data)) {
449
388
  const d = overview.data;
450
- const mcap = d.total_market_cap;
451
- const vol = (d.total_volume_24h ?? d.total_volume);
452
- const btcDom = d.btc_dominance;
453
- 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;
454
394
  lines.push("");
455
395
  lines.push("### Market Stats");
456
396
  if (mcap != null)
457
397
  lines.push(`**Total Market Cap:** ${fmtUsd(mcap)}`);
458
398
  if (vol != null)
459
399
  lines.push(`**24h Volume:** ${fmtUsd(vol)}`);
460
- if (btcDom != null) {
461
- const pct = btcDom > 1 ? btcDom : btcDom * 100;
462
- lines.push(`**BTC Dominance:** ${pct.toFixed(1)}%`);
463
- }
400
+ if (btcDom != null)
401
+ lines.push(`**BTC Dominance:** ${btcDom.toFixed(1)}%`);
464
402
  if (fg != null) {
465
- const fgVal = typeof fg === "object" ? fg.value : fg;
466
- let fgLabel = typeof fg === "object" ? (fg.label ?? "") : "";
403
+ const fgVal = isRecord(fg) ? fg.value : fg;
404
+ let fgLabel = isRecord(fg) ? (text(fg.label) ?? "") : "";
467
405
  if (!fgLabel)
468
- fgLabel = (d.fgi_classification ?? "");
406
+ fgLabel = text(d.fgi_classification) ?? "";
469
407
  lines.push(`**Fear & Greed:** ${fgVal} ${fgLabel}`);
470
408
  }
471
- // Top gainers
472
- const gainers = (d.top_gainers ?? []);
473
- if (Array.isArray(gainers) && gainers.length > 0) {
474
- lines.push("");
475
- lines.push("### Top Gainers (24h)");
476
- for (const g of gainers.slice(0, 5)) {
477
- const sym = (g.symbol ?? g.name ?? "?");
478
- const chg = (g.price_change_24h ?? g.change);
479
- lines.push(` ${sym}: ${fmtPct(chg)}`);
480
- }
481
- }
482
- // Top losers
483
- const losers = (d.top_losers ?? []);
484
- 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;
485
416
  lines.push("");
486
- lines.push("### Top Losers (24h)");
487
- for (const l of losers.slice(0, 5)) {
488
- const sym = (l.symbol ?? l.name ?? "?");
489
- const chg = (l.price_change_24h ?? l.change);
490
- 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))}`);
491
421
  }
492
422
  }
493
423
  }
494
424
  else {
495
425
  lines.push("\n*Market overview data unavailable*");
496
426
  }
497
- // Narratives
498
- if (narratives.data) {
499
- let narrList = narratives.data;
500
- if (typeof narrList === "object" &&
501
- narrList !== null &&
502
- !Array.isArray(narrList)) {
503
- const obj = narrList;
504
- narrList = obj.narratives ?? obj.items ?? narrList;
505
- }
506
- if (Array.isArray(narrList) && narrList.length > 0) {
507
- const typed = narrList.filter((n) => typeof n === "object" && n !== null);
508
- const sortedNarr = [...typed].sort((a, b) => {
509
- const aChg = Number(a.change24h ?? a.price_change_24h ?? a.change_24h ?? 0);
510
- const bChg = Number(b.change24h ?? b.price_change_24h ?? b.change_24h ?? 0);
511
- return bChg - aChg;
512
- });
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)) {
513
432
  lines.push("");
514
433
  lines.push("### Top Narratives (24h)");
515
- for (const n of sortedNarr.slice(0, 5)) {
516
- const name = (n.name ?? n.narrative ?? "?");
517
- const chg = (n.change24h ?? n.price_change_24h ?? n.change_24h);
518
- const mcapN = (n.marketCap ?? n.market_cap);
519
- 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))})`);
520
436
  }
521
437
  lines.push("");
522
438
  lines.push("### Worst Narratives (24h)");
523
- for (const n of sortedNarr.slice(-3)) {
524
- const name = (n.name ?? n.narrative ?? "?");
525
- const chg = (n.change24h ?? n.price_change_24h ?? n.change_24h);
526
- lines.push(` **${name}:** ${fmtPct(chg)}`);
439
+ for (const n of records(bottom)) {
440
+ lines.push(` **${text(n.name) ?? "?"}:** ${fmtPct(finiteOrNull(n.change24h))}`);
527
441
  }
528
442
  }
443
+ else {
444
+ lines.push("\n*Narrative ranking unavailable*");
445
+ }
529
446
  }
530
447
  else {
531
448
  lines.push("\n*Narrative data unavailable*");
532
449
  }
533
- // Funding highlights
534
- if (funding.data) {
535
- let ratesList = splitPaginated(funding.data).rows;
536
- if (typeof ratesList === "object" &&
537
- ratesList !== null &&
538
- !Array.isArray(ratesList) &&
539
- "items" in ratesList) {
540
- 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}.*`);
541
470
  }
542
- if (Array.isArray(ratesList) && ratesList.length > 0) {
543
- const valid = ratesList.filter((r) => {
544
- if (typeof r !== "object" || r === null)
545
- return false;
546
- const rec = r;
547
- return (rec.rate ?? rec.funding_rate) != null;
548
- });
549
- // A stale rate is not where funding is now; skip it before ranking.
550
- const fresh = valid.filter((r) => !isStaleRow(r));
551
- // `rate` is settled over the row's own interval (1h/2h/4h/8h/24h), so
552
- // raw rates are not comparable across venues: ranking them un-normalized
553
- // understated every 1h venue by 8x. Rank on APR instead. A row with no
554
- // usable interval cannot be annualized honestly and is never assumed 8h.
555
- const rankable = fresh.filter((r) => intervalHours(r) != null);
556
- // Only markets someone can hold rank: unfloored, the extremes were
557
- // always dust (HFT on Kraken, $2.5K of OI at -17,404% APR).
558
- const liquid = rankable.filter((r) => meetsLiquidityFloor(r.open_interest));
559
- if (valid.length > 0) {
560
- const sorted = [...liquid].sort((a, b) => aprVal(b) - aprVal(a));
561
- lines.push("");
562
- lines.push("### Funding Rate Highlights");
563
- lines.push(`*Ranked by APR = rate x (8760 / interval_hours), so 1h, 4h and 8h venues are comparable, across the ${liquid.length} markets with at least ${fmtUsd(LIQUIDITY_FLOOR_USD)} open interest.*`);
564
- if (sorted.length > 0) {
565
- lines.push("**Highest (longs paying most):**");
566
- for (const r of sorted.slice(0, 3)) {
567
- lines.push(fundingHighlightLine(r));
568
- }
569
- lines.push("**Lowest (shorts paying most):**");
570
- for (const r of sorted.slice(-3)) {
571
- lines.push(fundingHighlightLine(r));
572
- }
573
- }
574
- const stale = valid.length - fresh.length;
575
- if (stale > 0) {
576
- lines.push(`*${stale} row(s) omitted: stale, past the 10-minute funding freshness SLA.*`);
577
- }
578
- const skipped = fresh.length - rankable.length;
579
- if (skipped > 0) {
580
- lines.push(`*${skipped} row(s) omitted: no settlement interval published, so their rate cannot be annualized.*`);
581
- }
582
- const thin = rankable.length - liquid.length;
583
- if (thin > 0) {
584
- lines.push(`*${thin} row(s) omitted: open interest below ${fmtUsd(LIQUIDITY_FLOOR_USD)} or unknown.*`);
585
- }
586
- }
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));
587
481
  }
588
482
  }
589
483
  else {
@@ -591,11 +485,10 @@ export async function marketBriefing(callApi) {
591
485
  }
592
486
  // Funding settlement: whole-market dollar total, not a per-row rate.
593
487
  if (settlement.data) {
594
- const payload = settlement.data;
595
- const totals = payload.totals;
596
- const net = safeGet(totals, "current", "net");
597
- const longPaid = safeGet(totals, "current", "long_paid");
598
- const shortPaid = safeGet(totals, "current", "short_paid");
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"));
599
492
  lines.push("");
600
493
  lines.push("### Funding Settlement (next, estimated)");
601
494
  if (net == null) {
@@ -614,83 +507,51 @@ export async function marketBriefing(callApi) {
614
507
  else {
615
508
  lines.push("\n*Funding settlement data unavailable*");
616
509
  }
617
- if (errors.length > 0) {
618
- lines.push("");
619
- lines.push(`**Data gaps:** ${errors.join("; ")}`);
620
- }
621
- return lines.join("\n");
510
+ return finish(lines, settled);
622
511
  }
623
512
  // ── find_opportunities ────────────────────────────────────────────────
624
- /**
625
- * Largest non-stale market open interest per `${exchange}:${base_coin}` in
626
- * the current funding book. The spot-perp feed carries neither OI nor a
627
- * staleness flag, so its perp legs are checked against this.
628
- */
629
- function perpOpenInterest(rows) {
630
- const byPair = new Map();
631
- for (const row of rows) {
632
- if (isStaleRow(row))
633
- continue;
634
- const oi = Number(row.open_interest);
635
- if (!(oi > 0))
636
- continue;
637
- const key = `${String(row.exchange ?? "")}:${String(row.base_coin ?? "").toUpperCase()}`;
638
- if (oi > (byPair.get(key) ?? 0))
639
- byPair.set(key, oi);
640
- }
641
- return byPair;
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(" | ");
642
522
  }
643
523
  export async function findOpportunities(callApi) {
644
- const results = await Promise.allSettled([
645
- safeCall(callApi, "/api/v1/arbitrage/spot-perp", "/api/arbitrage/spot-perp", { exchange: "all", direction: "all" }, "spot-perp-arb"),
646
- // The scanner applies no floor by default, which let a leg with $10K of
647
- // OI lead at 8,492% APR (2026-09-24 audit). Both legs must clear it.
648
- safeCall(callApi, "/api/v1/arbitrage/cross-exchange", "/api/arbitrage/cross-exchange", { minOiUsd: LIQUIDITY_FLOOR_USD, minVolUsd: LIQUIDITY_FLOOR_USD }, "cross-exchange-arb"),
649
- 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"),
650
538
  ]);
651
- const settled = results.map((r) => r.status === "fulfilled"
652
- ? r.value
653
- : { data: null, error: "Promise rejected" });
654
539
  const [spotPerp, crossEx, funding] = settled;
655
- const errors = settled
656
- .filter((s) => s.error)
657
- .map((s) => s.error);
658
- const lines = ["## Arbitrage Opportunities"];
659
- const fundingRows = funding.data ? listRows(funding.data) : null;
660
- const perpOi = fundingRows ? perpOpenInterest(fundingRows) : null;
661
540
  const floorLabel = fmtUsd(LIQUIDITY_FLOOR_USD);
541
+ const lines = ["## Arbitrage Opportunities"];
662
542
  // Spot-perp basis trades
663
543
  if (spotPerp.data) {
664
- const arbList = listRows(spotPerp.data);
665
- if (arbList && arbList.length > 0) {
666
- // A perp leg whose OI is unknown, stale or under the floor is not an
667
- // executable trade; unfiltered, frozen CoinEx rows at the +/-1.5% cap
668
- // filled nine of the top ten.
669
- const liquid = perpOi
670
- ? arbList.filter((a) => (perpOi.get(`${String(a.exchange ?? "")}:${String(a.symbol ?? a.coin ?? "").toUpperCase()}`) ?? 0) >= LIQUIDITY_FLOOR_USD)
671
- : arbList;
672
- // Rank on the fee-adjusted net APR the route itself sorts by.
673
- const sorted = [...liquid].sort((a, b) => sortKey(b.netApr ?? b.apr) - sortKey(a.netApr ?? a.apr));
674
- lines.push("");
675
- lines.push("### Spot-Perp Basis Trades (Top 10 by net APR)");
676
- lines.push("*Long spot + short perp (or vice versa) to capture funding*");
677
- lines.push(perpOi
678
- ? `*Perp legs with at least ${floorLabel} open interest in the funding book: ${liquid.length} of ${arbList.length} rows.*`
679
- : "*Liquidity floor not applied: funding book unavailable.*");
680
- lines.push("");
681
- if (sorted.length === 0) {
682
- lines.push(" *No spot-perp row clears the liquidity floor.*");
683
- }
684
- for (const a of sorted.slice(0, 10)) {
685
- const symbol = String(a.symbol ?? a.coin ?? "?");
686
- const exchange = String(a.exchange ?? "?");
687
- const rate = finiteOrNull(a.fundingRate ?? a.rate ?? a.funding_rate);
688
- const direction = String(a.direction ?? "?");
689
- lines.push(` **${symbol}** on ${exchange} | Net APR: ${fmtApr(a.netApr)} | Gross APR: ${fmtApr(a.apr ?? a.annualized_apr)} | Rate: ${fmtRate(rate)} | Dir: ${direction}`);
690
- }
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.*");
691
552
  }
692
- else {
693
- 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)}`);
694
555
  }
695
556
  }
696
557
  else {
@@ -698,72 +559,34 @@ export async function findOpportunities(callApi) {
698
559
  }
699
560
  // Cross-exchange funding arb
700
561
  if (crossEx.data) {
701
- const cxList = listRows(crossEx.data);
702
- if (cxList && cxList.length > 0) {
703
- // Executable rows (book-priced and fresh) rank first on net APR, which
704
- // prices the entry spread; indicative rows follow on gross APR.
705
- const executable = (a) => a.executionStatus != null
706
- ? a.executionStatus === "executable"
707
- : a.netApr != null;
708
- const grossApr = (a) => a.apr ?? a.spread_apr ?? a.annualized_apr;
709
- const sorted = [...cxList].sort((a, b) => {
710
- const aExec = executable(a);
711
- const bExec = executable(b);
712
- if (aExec !== bExec)
713
- return aExec ? -1 : 1;
714
- return aExec
715
- ? sortKey(b.netApr) - sortKey(a.netApr)
716
- : sortKey(grossApr(b)) - sortKey(grossApr(a));
717
- });
718
- lines.push("");
719
- lines.push("### Cross-Exchange Funding Arb (Top 10)");
720
- lines.push("*Long on low-rate exchange, short on high-rate exchange*");
721
- lines.push(`*Both legs have at least ${floorLabel} open interest and 24h volume. Executable (book-priced) rows rank first by net APR, then indicative rows by gross APR.*`);
722
- lines.push("");
723
- for (const a of sorted.slice(0, 10)) {
724
- const symbol = String(a.symbol ?? a.coin ?? "?");
725
- const longEx = String(a.longExchange ?? a.long_exchange ?? a.exchange_long ?? "?");
726
- const shortEx = String(a.shortExchange ?? a.short_exchange ?? a.exchange_short ?? "?");
727
- const spread = finiteOrNull(a.spreadRate ?? a.spread ?? a.spread_rate);
728
- lines.push(` **${symbol}** | Long ${longEx} / Short ${shortEx} | Net APR: ${fmtApr(a.netApr)} | Gross APR: ${fmtApr(grossApr(a))} | Spread: ${fmtRate(spread)} | ${executable(a) ? "executable" : "indicative"}`);
729
- }
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.*");
730
570
  }
731
- else {
732
- 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)}`);
733
573
  }
734
574
  }
735
575
  else {
736
576
  lines.push("\n*Cross-exchange arbitrage data unavailable*");
737
577
  }
738
- // Funding rate summary. `rate` is per the row's own interval, so a raw
739
- // average mixed 1h, 4h and 8h units; average APR over liquid markets.
740
- if (fundingRows && fundingRows.length > 0) {
741
- const valid = fundingRows.filter((r) => (r.rate ?? r.funding_rate) != null);
742
- const liquid = valid.filter((r) => !isStaleRow(r) &&
743
- intervalHours(r) != null &&
744
- meetsLiquidityFloor(r.open_interest));
745
- if (liquid.length > 0) {
746
- const aprs = liquid.map(aprVal);
747
- const avg = aprs.reduce((a, b) => a + b, 0) / aprs.length;
748
- const positive = aprs.filter((v) => v > 0).length;
749
- const negative = aprs.filter((v) => v < 0).length;
750
- lines.push("");
751
- lines.push("### Funding Rate Summary");
752
- lines.push(`*${liquid.length} of ${valid.length} markets: fresh, with at least ${floorLabel} open interest and a published settlement interval.*`);
753
- lines.push(`**Avg APR:** ${fmtApr(avg)} (rate x 8760 / interval_hours, averaged across markets)`);
754
- lines.push(`**Positive (longs pay):** ${positive} | **Negative (shorts pay):** ${negative}`);
755
- const sentiment = positive > negative * 1.5
756
- ? "bullish"
757
- : negative > positive * 1.5
758
- ? "bearish"
759
- : "neutral";
760
- lines.push(`**Market Sentiment:** ${sentiment} (based on funding rate skew)`);
761
- }
762
- }
763
- if (errors.length > 0) {
578
+ // Funding rate summary: the API's market stats.
579
+ const market = safeGet(funding.data, "market");
580
+ if (isRecord(market)) {
764
581
  lines.push("");
765
- 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*");
766
589
  }
767
- return lines.join("\n");
590
+ return finish(lines, settled);
768
591
  }
769
592
  //# sourceMappingURL=composite-tools.js.map