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