@sharpe-terminal/mcp-server 1.2.0 → 1.4.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.
- package/README.md +58 -158
- package/dist/api-client.d.ts +17 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +144 -9
- package/dist/api-client.js.map +1 -1
- package/dist/composite-tools.d.ts +64 -0
- package/dist/composite-tools.d.ts.map +1 -1
- package/dist/composite-tools.js +479 -230
- package/dist/composite-tools.js.map +1 -1
- package/dist/index.js +9 -226
- package/dist/index.js.map +1 -1
- package/dist/resources.d.ts +44 -5
- package/dist/resources.d.ts.map +1 -1
- package/dist/resources.js +61 -5
- package/dist/resources.js.map +1 -1
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +264 -0
- package/dist/server.js.map +1 -0
- package/dist/tool-runtime.d.ts +46 -0
- package/dist/tool-runtime.d.ts.map +1 -1
- package/dist/tool-runtime.js +265 -52
- package/dist/tool-runtime.js.map +1 -1
- package/dist/tools.d.ts +139 -75
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +412 -107
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/composite-tools.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* and return formatted markdown. Mirrors the Python server's
|
|
4
4
|
* analyze_coin, market_briefing, and find_opportunities.
|
|
5
5
|
*/
|
|
6
|
+
import { splitPaginated } from "./tool-runtime.js";
|
|
7
|
+
import { rowsForFundingCoin } from "./tools.js";
|
|
6
8
|
// ── Formatting helpers ────────────────────────────────────────────────
|
|
7
9
|
export function fmtUsd(value) {
|
|
8
10
|
if (value == null)
|
|
@@ -29,6 +31,144 @@ export function fmtRate(value) {
|
|
|
29
31
|
return "N/A";
|
|
30
32
|
return `${(value * 100).toFixed(4)}%`;
|
|
31
33
|
}
|
|
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);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
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.
|
|
58
|
+
*/
|
|
59
|
+
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
|
+
/**
|
|
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`.
|
|
69
|
+
*/
|
|
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
|
+
}
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
function finiteOrNull(value) {
|
|
80
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
81
|
+
}
|
|
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];
|
|
100
|
+
}
|
|
101
|
+
/** Rows of a list payload that may be wrapped as `{ data, pagination }` or `{ items }`. */
|
|
102
|
+
function listRows(payload) {
|
|
103
|
+
let rows = splitPaginated(payload).rows;
|
|
104
|
+
if (isRecord(rows) && "items" in rows)
|
|
105
|
+
rows = rows.items;
|
|
106
|
+
return Array.isArray(rows) ? rows.filter(isRecord) : null;
|
|
107
|
+
}
|
|
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)`;
|
|
171
|
+
}
|
|
32
172
|
function safeGet(obj, ...keys) {
|
|
33
173
|
let current = obj;
|
|
34
174
|
for (const k of keys) {
|
|
@@ -51,129 +191,164 @@ async function safeCall(callApi, apiPath, fallbackPath, args, label) {
|
|
|
51
191
|
return { data: null, error: `[${label}] ${msg}` };
|
|
52
192
|
}
|
|
53
193
|
}
|
|
54
|
-
// ──
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
194
|
+
// ── Price-prediction coin resolution ──────────────────────────────────
|
|
195
|
+
/**
|
|
196
|
+
* Find the prediction row for a ticker in the endpoint's own coverage list.
|
|
197
|
+
*
|
|
198
|
+
* The price-prediction endpoint is keyed by CoinGecko slug ("bitcoin",
|
|
199
|
+
* "avalanche-2", "matic-network"), never by ticker, and a lowercased ticker is
|
|
200
|
+
* not a slug. Guessing one either 404s or — far worse — resolves to whichever
|
|
201
|
+
* unrelated asset happens to own that slug, and tickers are shared across
|
|
202
|
+
* thousands of assets in this dataset. So the guess is a silent wrong-coin
|
|
203
|
+
* risk, never a convenience: it is resolve-or-say-so here.
|
|
204
|
+
*
|
|
205
|
+
* The list response (`GET .../price-prediction/data` with no `coin`) carries
|
|
206
|
+
* both the ticker (`coin`) and the `slug` for every covered asset, so an exact
|
|
207
|
+
* ticker match against it is the endpoint's own documented contract.
|
|
208
|
+
*/
|
|
209
|
+
export function findPredictionRow(payload, ticker) {
|
|
210
|
+
let rows = payload;
|
|
211
|
+
if (typeof rows === "object" && rows !== null && !Array.isArray(rows)) {
|
|
212
|
+
rows = rows.coins;
|
|
213
|
+
}
|
|
214
|
+
if (!Array.isArray(rows))
|
|
215
|
+
return null;
|
|
216
|
+
const wanted = ticker.toUpperCase();
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
if (typeof row !== "object" || row === null)
|
|
219
|
+
continue;
|
|
220
|
+
const rec = row;
|
|
221
|
+
if (String(rec.coin ?? "").toUpperCase() === wanted)
|
|
222
|
+
return rec;
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
69
226
|
// ── analyze_coin ──────────────────────────────────────────────────────
|
|
70
227
|
export async function analyzeCoin(callApi, coin) {
|
|
71
228
|
const coinUpper = coin.toUpperCase();
|
|
72
|
-
const cgSlug = CG_SLUGS[coinUpper] ?? coin.toLowerCase();
|
|
73
229
|
const tasks = [
|
|
74
|
-
safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates",
|
|
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"),
|
|
75
235
|
safeCall(callApi, "/api/v1/futures/data", "/api/futures/data", { chart: "oi-snapshot", coin: coinUpper, timeframe: "3M" }, "oi"),
|
|
76
|
-
|
|
236
|
+
// No `coin` param: the endpoint is slug-keyed and the caller gives us a
|
|
237
|
+
// ticker, so ask for the coverage list and match the ticker in it rather
|
|
238
|
+
// than shipping a guessed slug that can resolve to another asset.
|
|
239
|
+
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).
|
|
245
|
+
safeCall(callApi, "/api/v1/funding/settlement", "/api/funding/settlement", { coin: coinUpper, window: "current" }, "settlement"),
|
|
77
246
|
];
|
|
78
247
|
const results = await Promise.allSettled(tasks);
|
|
79
248
|
const settled = results.map((r) => r.status === "fulfilled"
|
|
80
249
|
? r.value
|
|
81
250
|
: { data: null, error: "Promise rejected" });
|
|
82
|
-
const [funding, oi, prediction] = settled;
|
|
251
|
+
const [funding, oi, prediction, settlement] = settled;
|
|
83
252
|
const errors = settled
|
|
84
253
|
.filter((s) => s.error)
|
|
85
254
|
.map((s) => s.error);
|
|
86
255
|
const lines = [`## ${coinUpper} Analysis`];
|
|
87
|
-
// Price prediction
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (parts.length > 0)
|
|
131
|
-
lines.push(`**Technicals:** ${parts.join(", ")}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
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(", ")}`);
|
|
134
299
|
}
|
|
135
300
|
}
|
|
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.*`);
|
|
303
|
+
}
|
|
136
304
|
else {
|
|
137
305
|
lines.push("\n*Price prediction data unavailable*");
|
|
138
306
|
}
|
|
139
|
-
//
|
|
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).
|
|
140
321
|
if (funding.data) {
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
});
|
|
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);
|
|
157
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 ?? "")));
|
|
158
337
|
lines.push("");
|
|
159
|
-
lines.push("**Funding
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
}
|
|
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))}`);
|
|
173
344
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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`);
|
|
177
352
|
}
|
|
178
353
|
}
|
|
179
354
|
else {
|
|
@@ -186,50 +361,65 @@ export async function analyzeCoin(callApi, coin) {
|
|
|
186
361
|
}
|
|
187
362
|
// Open interest
|
|
188
363
|
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
|
-
}
|
|
196
364
|
lines.push("");
|
|
197
365
|
lines.push("**Open Interest:**");
|
|
198
|
-
if (
|
|
199
|
-
const
|
|
200
|
-
|
|
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
|
-
}
|
|
366
|
+
if (venueOi) {
|
|
367
|
+
for (const venue of venueOi.current.slice(0, 10)) {
|
|
368
|
+
lines.push(` ${venue.exchange}: ${fmtUsd(venue.value)}`);
|
|
216
369
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.
|
|
220
|
-
.
|
|
221
|
-
for (const [exchange, oiVal] of sorted) {
|
|
222
|
-
totalOi += oiVal;
|
|
223
|
-
lines.push(` ${exchange}: ${fmtUsd(oiVal)}`);
|
|
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}`);
|
|
224
374
|
}
|
|
225
|
-
if (
|
|
226
|
-
|
|
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}.*`);
|
|
227
389
|
}
|
|
228
390
|
}
|
|
229
391
|
}
|
|
230
392
|
else {
|
|
231
393
|
lines.push("\n*Open interest data unavailable*");
|
|
232
394
|
}
|
|
395
|
+
// Settlement: dollar value actually paid at the next settlement.
|
|
396
|
+
if (settlement.data) {
|
|
397
|
+
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");
|
|
405
|
+
lines.push("");
|
|
406
|
+
lines.push("**Settlement (next, estimated):**");
|
|
407
|
+
if (net == null) {
|
|
408
|
+
lines.push(" No funding data for the next settlement.");
|
|
409
|
+
}
|
|
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.");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
lines.push("\n*Settlement data unavailable*");
|
|
422
|
+
}
|
|
233
423
|
if (errors.length > 0) {
|
|
234
424
|
lines.push("");
|
|
235
425
|
lines.push(`**Data gaps:** ${errors.join("; ")}`);
|
|
@@ -242,11 +432,14 @@ export async function marketBriefing(callApi) {
|
|
|
242
432
|
safeCall(callApi, "/api/v1/tracker/market-overview", "/api/tracker/market-overview", {}, "market-overview"),
|
|
243
433
|
safeCall(callApi, "/api/v1/narratives/data", "/api/narratives/data", {}, "narratives"),
|
|
244
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.
|
|
437
|
+
safeCall(callApi, "/api/v1/funding/settlement", "/api/funding/settlement", { window: "current" }, "settlement"),
|
|
245
438
|
]);
|
|
246
439
|
const settled = results.map((r) => r.status === "fulfilled"
|
|
247
440
|
? r.value
|
|
248
441
|
: { data: null, error: "Promise rejected" });
|
|
249
|
-
const [overview, narratives, funding] = settled;
|
|
442
|
+
const [overview, narratives, funding, settlement] = settled;
|
|
250
443
|
const errors = settled
|
|
251
444
|
.filter((s) => s.error)
|
|
252
445
|
.map((s) => s.error);
|
|
@@ -339,7 +532,7 @@ export async function marketBriefing(callApi) {
|
|
|
339
532
|
}
|
|
340
533
|
// Funding highlights
|
|
341
534
|
if (funding.data) {
|
|
342
|
-
let ratesList = funding.data;
|
|
535
|
+
let ratesList = splitPaginated(funding.data).rows;
|
|
343
536
|
if (typeof ratesList === "object" &&
|
|
344
537
|
ratesList !== null &&
|
|
345
538
|
!Array.isArray(ratesList) &&
|
|
@@ -353,22 +546,42 @@ export async function marketBriefing(callApi) {
|
|
|
353
546
|
const rec = r;
|
|
354
547
|
return (rec.rate ?? rec.funding_rate) != null;
|
|
355
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));
|
|
356
559
|
if (valid.length > 0) {
|
|
357
|
-
const
|
|
358
|
-
const sorted = [...valid].sort((a, b) => rateVal(b) - rateVal(a));
|
|
560
|
+
const sorted = [...liquid].sort((a, b) => aprVal(b) - aprVal(a));
|
|
359
561
|
lines.push("");
|
|
360
562
|
lines.push("### Funding Rate Highlights");
|
|
361
|
-
lines.push(
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
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.*`);
|
|
366
577
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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.*`);
|
|
372
585
|
}
|
|
373
586
|
}
|
|
374
587
|
}
|
|
@@ -376,6 +589,31 @@ export async function marketBriefing(callApi) {
|
|
|
376
589
|
else {
|
|
377
590
|
lines.push("\n*Funding rate data unavailable*");
|
|
378
591
|
}
|
|
592
|
+
// Funding settlement: whole-market dollar total, not a per-row rate.
|
|
593
|
+
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");
|
|
599
|
+
lines.push("");
|
|
600
|
+
lines.push("### Funding Settlement (next, estimated)");
|
|
601
|
+
if (net == null) {
|
|
602
|
+
lines.push("*No funding data for the next settlement.*");
|
|
603
|
+
}
|
|
604
|
+
else if (net > 0) {
|
|
605
|
+
lines.push(`Longs will pay ${fmtUsd(net)} to shorts (long ${fmtUsd(longPaid)} / short ${fmtUsd(shortPaid)}).`);
|
|
606
|
+
}
|
|
607
|
+
else if (net < 0) {
|
|
608
|
+
lines.push(`Shorts will pay ${fmtUsd(-net)} to longs (long ${fmtUsd(longPaid)} / short ${fmtUsd(shortPaid)}).`);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
lines.push("No net funding expected at the next settlement.");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
lines.push("\n*Funding settlement data unavailable*");
|
|
616
|
+
}
|
|
379
617
|
if (errors.length > 0) {
|
|
380
618
|
lines.push("");
|
|
381
619
|
lines.push(`**Data gaps:** ${errors.join("; ")}`);
|
|
@@ -383,10 +621,31 @@ export async function marketBriefing(callApi) {
|
|
|
383
621
|
return lines.join("\n");
|
|
384
622
|
}
|
|
385
623
|
// ── 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;
|
|
642
|
+
}
|
|
386
643
|
export async function findOpportunities(callApi) {
|
|
387
644
|
const results = await Promise.allSettled([
|
|
388
645
|
safeCall(callApi, "/api/v1/arbitrage/spot-perp", "/api/arbitrage/spot-perp", { exchange: "all", direction: "all" }, "spot-perp-arb"),
|
|
389
|
-
|
|
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"),
|
|
390
649
|
safeCall(callApi, "/api/v1/funding/rates", "/api/funding/rates", { type: "current" }, "funding"),
|
|
391
650
|
]);
|
|
392
651
|
const settled = results.map((r) => r.status === "fulfilled"
|
|
@@ -397,34 +656,37 @@ export async function findOpportunities(callApi) {
|
|
|
397
656
|
.filter((s) => s.error)
|
|
398
657
|
.map((s) => s.error);
|
|
399
658
|
const lines = ["## Arbitrage Opportunities"];
|
|
659
|
+
const fundingRows = funding.data ? listRows(funding.data) : null;
|
|
660
|
+
const perpOi = fundingRows ? perpOpenInterest(fundingRows) : null;
|
|
661
|
+
const floorLabel = fmtUsd(LIQUIDITY_FLOOR_USD);
|
|
400
662
|
// Spot-perp basis trades
|
|
401
663
|
if (spotPerp.data) {
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const sorted = [...
|
|
412
|
-
return (Math.abs(Number(b.apr ?? b.annualized_apr ?? 0)) -
|
|
413
|
-
Math.abs(Number(a.apr ?? a.annualized_apr ?? 0)));
|
|
414
|
-
});
|
|
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));
|
|
415
674
|
lines.push("");
|
|
416
|
-
lines.push("### Spot-Perp Basis Trades (Top 10 by APR)");
|
|
675
|
+
lines.push("### Spot-Perp Basis Trades (Top 10 by net APR)");
|
|
417
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.*");
|
|
418
680
|
lines.push("");
|
|
681
|
+
if (sorted.length === 0) {
|
|
682
|
+
lines.push(" *No spot-perp row clears the liquidity floor.*");
|
|
683
|
+
}
|
|
419
684
|
for (const a of sorted.slice(0, 10)) {
|
|
420
|
-
const symbol = (a.symbol ?? a.coin ?? "?");
|
|
421
|
-
const exchange = (a.exchange ?? "?");
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
|
|
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}`);
|
|
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}`);
|
|
428
690
|
}
|
|
429
691
|
}
|
|
430
692
|
else {
|
|
@@ -436,38 +698,34 @@ export async function findOpportunities(callApi) {
|
|
|
436
698
|
}
|
|
437
699
|
// Cross-exchange funding arb
|
|
438
700
|
if (crossEx.data) {
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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));
|
|
451
717
|
});
|
|
452
718
|
lines.push("");
|
|
453
|
-
lines.push("### Cross-Exchange Funding Arb (Top 10
|
|
719
|
+
lines.push("### Cross-Exchange Funding Arb (Top 10)");
|
|
454
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.*`);
|
|
455
722
|
lines.push("");
|
|
456
723
|
for (const a of sorted.slice(0, 10)) {
|
|
457
|
-
const symbol = (a.symbol ?? a.coin ?? "?");
|
|
458
|
-
const longEx = (a.longExchange ??
|
|
459
|
-
|
|
460
|
-
|
|
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}`);
|
|
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"}`);
|
|
471
729
|
}
|
|
472
730
|
}
|
|
473
731
|
else {
|
|
@@ -477,38 +735,29 @@ export async function findOpportunities(callApi) {
|
|
|
477
735
|
else {
|
|
478
736
|
lines.push("\n*Cross-exchange arbitrage data unavailable*");
|
|
479
737
|
}
|
|
480
|
-
// Funding rate summary
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
}
|
|
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)`);
|
|
512
761
|
}
|
|
513
762
|
}
|
|
514
763
|
if (errors.length > 0) {
|