@sharpe-terminal/mcp-server 1.4.0 → 1.6.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 +17 -7
- package/dist/api-client.d.ts +38 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +119 -10
- package/dist/api-client.js.map +1 -1
- package/dist/composite-tools.d.ts +27 -45
- package/dist/composite-tools.d.ts.map +1 -1
- package/dist/composite-tools.js +372 -546
- package/dist/composite-tools.js.map +1 -1
- package/dist/contracts/manifest.d.ts +52 -0
- package/dist/contracts/manifest.d.ts.map +1 -0
- package/dist/contracts/manifest.js +9 -0
- package/dist/contracts/manifest.js.map +1 -0
- package/dist/endpoint-tools.d.ts +107 -0
- package/dist/endpoint-tools.d.ts.map +1 -0
- package/dist/endpoint-tools.js +382 -0
- package/dist/endpoint-tools.js.map +1 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +143 -101
- package/dist/server.js.map +1 -1
- package/dist/tool-runtime.d.ts +33 -38
- package/dist/tool-runtime.d.ts.map +1 -1
- package/dist/tool-runtime.js +113 -238
- package/dist/tool-runtime.js.map +1 -1
- package/package.json +1 -1
- package/dist/tools.d.ts +0 -567
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -1166
- package/dist/tools.js.map +0 -1
package/dist/composite-tools.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
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 { endpointTool, toolRoutes } from "./endpoint-tools.js";
|
|
6
14
|
import { splitPaginated } from "./tool-runtime.js";
|
|
7
|
-
import { rowsForFundingCoin } from "./tools.js";
|
|
8
15
|
// ── Formatting helpers ────────────────────────────────────────────────
|
|
9
16
|
export function fmtUsd(value) {
|
|
10
17
|
if (value == null)
|
|
@@ -31,72 +38,33 @@ export function fmtRate(value) {
|
|
|
31
38
|
return "N/A";
|
|
32
39
|
return `${(value * 100).toFixed(4)}%`;
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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);
|
|
41
|
+
/** An annualized fraction (0.25 = 25%) as a percentage, or N/A. */
|
|
42
|
+
export function fmtApr(value) {
|
|
43
|
+
const n = finiteOrNull(value);
|
|
44
|
+
return n == null ? "N/A" : `${(n * 100).toFixed(1)}%`;
|
|
51
45
|
}
|
|
52
46
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* on KuCoin: $33K of OI at -16,862% APR, 2026-09-24 audit).
|
|
57
|
-
*
|
|
47
|
+
* The open-interest floor (USD) the composites ask the API to apply: funding
|
|
48
|
+
* extremes (`min_oi_usd`), the spot-perp perp leg and both cross-exchange legs
|
|
49
|
+
* (`minOiUsd`, `minVolUsd`). Unfloored, every extreme was a dust market (ESIM
|
|
50
|
+
* on KuCoin: $33K of OI at -16,862% APR, 2026-09-24 audit). The API applies
|
|
51
|
+
* it; unknown open interest fails it there.
|
|
58
52
|
*/
|
|
59
53
|
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
54
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
55
|
+
* `max_age_h` on the funding calls: v1 leaves out rows not written for six
|
|
56
|
+
* hours (V1_MAX_AGE_HOURS in the funding read model), so a keyless answer
|
|
57
|
+
* counts the same rows as a keyed one. v1 ignores the parameter.
|
|
69
58
|
*/
|
|
70
|
-
export
|
|
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
|
-
}
|
|
59
|
+
export const FUNDING_MAX_AGE_HOURS = 6;
|
|
76
60
|
function isRecord(value) {
|
|
77
61
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
78
62
|
}
|
|
79
63
|
function finiteOrNull(value) {
|
|
80
64
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
81
65
|
}
|
|
82
|
-
|
|
83
|
-
|
|
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];
|
|
66
|
+
function text(value) {
|
|
67
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
100
68
|
}
|
|
101
69
|
/** Rows of a list payload that may be wrapped as `{ data, pagination }` or `{ items }`. */
|
|
102
70
|
function listRows(payload) {
|
|
@@ -105,69 +73,8 @@ function listRows(payload) {
|
|
|
105
73
|
rows = rows.items;
|
|
106
74
|
return Array.isArray(rows) ? rows.filter(isRecord) : null;
|
|
107
75
|
}
|
|
108
|
-
|
|
109
|
-
|
|
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)`;
|
|
76
|
+
function records(value) {
|
|
77
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
171
78
|
}
|
|
172
79
|
function safeGet(obj, ...keys) {
|
|
173
80
|
let current = obj;
|
|
@@ -181,8 +88,55 @@ function safeGet(obj, ...keys) {
|
|
|
181
88
|
}
|
|
182
89
|
return current;
|
|
183
90
|
}
|
|
184
|
-
|
|
91
|
+
/** A market's name with its asset class when it is not a token (TSLL is a stock, not a coin). */
|
|
92
|
+
function marketLabel(row) {
|
|
93
|
+
const coin = text(row.base_coin) ?? text(row.symbol) ?? "?";
|
|
94
|
+
const assetClass = text(row.asset_class);
|
|
95
|
+
return assetClass == null || assetClass === "crypto"
|
|
96
|
+
? coin
|
|
97
|
+
: `${coin} (${assetClass})`;
|
|
98
|
+
}
|
|
99
|
+
// ── Freshness ─────────────────────────────────────────────────────────
|
|
100
|
+
/** One answer's freshness, as the Python server prints it: `dataset · as of … · status[ · degraded] (warnings)`. */
|
|
101
|
+
export function freshnessLine(freshness) {
|
|
102
|
+
const parts = [
|
|
103
|
+
freshness.dataset_id || "data",
|
|
104
|
+
freshness.as_of ? `as of ${freshness.as_of}` : "as of unknown",
|
|
105
|
+
freshness.freshness_status,
|
|
106
|
+
];
|
|
107
|
+
if (freshness.runtime_status === "degraded")
|
|
108
|
+
parts.push("degraded");
|
|
109
|
+
const line = parts.join(" · ");
|
|
110
|
+
return freshness.warnings.length > 0
|
|
111
|
+
? `${line} (${freshness.warnings.join("; ")})`
|
|
112
|
+
: line;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The report with a closing line naming the freshness of every answer it read
|
|
116
|
+
* (engine L5 WP-S12), each once, in the order the calls were made. Nothing
|
|
117
|
+
* changes when no answer said anything.
|
|
118
|
+
*/
|
|
119
|
+
export function withFreshnessReport(report, answers) {
|
|
120
|
+
const seen = [];
|
|
121
|
+
const keys = new Set();
|
|
122
|
+
for (const answer of answers) {
|
|
123
|
+
const freshness = freshnessOf(answer);
|
|
124
|
+
if (freshness === null)
|
|
125
|
+
continue;
|
|
126
|
+
const key = JSON.stringify(freshness);
|
|
127
|
+
if (keys.has(key))
|
|
128
|
+
continue;
|
|
129
|
+
keys.add(key);
|
|
130
|
+
seen.push(freshness);
|
|
131
|
+
}
|
|
132
|
+
return seen.length === 0
|
|
133
|
+
? report
|
|
134
|
+
: `${report}\n\n**Data freshness:** ${seen.map(freshnessLine).join("; ")}`;
|
|
135
|
+
}
|
|
136
|
+
/** Calls an endpoint tool's routes (from the manifest) and keeps a failure as a data gap instead of throwing. */
|
|
137
|
+
async function safeCall(callApi, toolName, args, label) {
|
|
185
138
|
try {
|
|
139
|
+
const { apiPath, fallbackPath } = toolRoutes(endpointTool(toolName));
|
|
186
140
|
const data = await callApi(apiPath, fallbackPath, args);
|
|
187
141
|
return { data, error: null };
|
|
188
142
|
}
|
|
@@ -191,6 +145,20 @@ async function safeCall(callApi, apiPath, fallbackPath, args, label) {
|
|
|
191
145
|
return { data: null, error: `[${label}] ${msg}` };
|
|
192
146
|
}
|
|
193
147
|
}
|
|
148
|
+
async function settleAll(tasks) {
|
|
149
|
+
const results = await Promise.allSettled(tasks);
|
|
150
|
+
return results.map((r) => r.status === "fulfilled"
|
|
151
|
+
? r.value
|
|
152
|
+
: { data: null, error: "Promise rejected" });
|
|
153
|
+
}
|
|
154
|
+
function finish(lines, settled) {
|
|
155
|
+
const errors = settled.filter((s) => s.error).map((s) => s.error);
|
|
156
|
+
if (errors.length > 0) {
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push(`**Data gaps:** ${errors.join("; ")}`);
|
|
159
|
+
}
|
|
160
|
+
return withFreshnessReport(lines.join("\n"), settled.map((s) => s.data));
|
|
161
|
+
}
|
|
194
162
|
// ── Price-prediction coin resolution ──────────────────────────────────
|
|
195
163
|
/**
|
|
196
164
|
* Find the prediction row for a ticker in the endpoint's own coverage list.
|
|
@@ -224,169 +192,142 @@ export function findPredictionRow(payload, ticker) {
|
|
|
224
192
|
return null;
|
|
225
193
|
}
|
|
226
194
|
// ── analyze_coin ──────────────────────────────────────────────────────
|
|
195
|
+
function capitalize(value) {
|
|
196
|
+
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
function predictionLines(prediction, coinUpper) {
|
|
199
|
+
const row = prediction.data
|
|
200
|
+
? findPredictionRow(prediction.data, coinUpper)
|
|
201
|
+
: null;
|
|
202
|
+
if (!row) {
|
|
203
|
+
return prediction.data
|
|
204
|
+
? [
|
|
205
|
+
`\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.*`,
|
|
206
|
+
]
|
|
207
|
+
: ["\n*Price prediction data unavailable*"];
|
|
208
|
+
}
|
|
209
|
+
const bias = String(row.direction ?? row.bias ?? "unknown");
|
|
210
|
+
const score = finiteOrNull(row.consensusScore ?? row.score);
|
|
211
|
+
const price = finiteOrNull(row.spotPrice ?? row.price);
|
|
212
|
+
const change24h = finiteOrNull(row.priceChange24h ?? row.price_change_24h);
|
|
213
|
+
const lines = [
|
|
214
|
+
"",
|
|
215
|
+
`**Price:** ${fmtUsd(price)} | **24h:** ${change24h != null ? fmtPct(change24h) : ""}`,
|
|
216
|
+
`**Prediction:** ${capitalize(bias)} (score: ${score != null ? `${score}/100` : "N/A"})`,
|
|
217
|
+
];
|
|
218
|
+
const signals = row.signals ?? row.technical;
|
|
219
|
+
if (Array.isArray(signals)) {
|
|
220
|
+
const parts = records(signals)
|
|
221
|
+
.map((s) => {
|
|
222
|
+
const label = text(s.label ?? s.name);
|
|
223
|
+
const sigBias = text(s.bias);
|
|
224
|
+
return label && sigBias ? `${label}: ${sigBias}` : null;
|
|
225
|
+
})
|
|
226
|
+
.filter((part) => part !== null)
|
|
227
|
+
.slice(0, 6);
|
|
228
|
+
if (parts.length > 0)
|
|
229
|
+
lines.push(`**Signals:** ${parts.join(", ")}`);
|
|
230
|
+
}
|
|
231
|
+
else if (isRecord(signals)) {
|
|
232
|
+
const parts = [];
|
|
233
|
+
if (signals.rsi != null)
|
|
234
|
+
parts.push(`RSI ${Number(signals.rsi).toFixed(0)}`);
|
|
235
|
+
if (signals.ema_trend)
|
|
236
|
+
parts.push(`EMA ${signals.ema_trend}`);
|
|
237
|
+
if (parts.length > 0)
|
|
238
|
+
lines.push(`**Technicals:** ${parts.join(", ")}`);
|
|
239
|
+
}
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
function fundingRowLine(row) {
|
|
243
|
+
const market = [text(row.exchange) ?? "Unknown", text(row.symbol)]
|
|
244
|
+
.filter(Boolean)
|
|
245
|
+
.join(" ");
|
|
246
|
+
return ` ${market}: ${fmtRate(finiteOrNull(row.rate_8h))} per 8h (${fmtApr(row.apr)} APR) | OI ${fmtUsd(finiteOrNull(row.open_interest))}`;
|
|
247
|
+
}
|
|
248
|
+
/** One asset of a `summary=1` answer: the API's average and median across venues. */
|
|
249
|
+
function fundingSummaryLine(entry) {
|
|
250
|
+
const stale = finiteOrNull(entry.funding_venues_stale) ?? 0;
|
|
251
|
+
const parts = [
|
|
252
|
+
` **Average (${marketLabel(entry)}):** ${fmtRate(finiteOrNull(entry.funding_avg_8h))} per 8h (${text(entry.funding_method) ?? "n/a"})`,
|
|
253
|
+
`**Median:** ${fmtRate(finiteOrNull(entry.funding_median_8h))} per 8h`,
|
|
254
|
+
`${finiteOrNull(entry.funding_venues_used) ?? 0} venues`,
|
|
255
|
+
];
|
|
256
|
+
let line = parts.join(" | ");
|
|
257
|
+
if (stale > 0)
|
|
258
|
+
line += `, ${stale} stale venues left out`;
|
|
259
|
+
if (entry.funding_all_stale === true)
|
|
260
|
+
line += " *(every venue is stale: the figures are over stale rates)*";
|
|
261
|
+
return line;
|
|
262
|
+
}
|
|
227
263
|
export async function analyzeCoin(callApi, coin) {
|
|
228
264
|
const coinUpper = coin.toUpperCase();
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
safeCall(callApi, "/api/v1/futures/data", "/api/futures/data", { chart: "oi-snapshot", coin: coinUpper, timeframe: "3M" }, "oi"),
|
|
265
|
+
const fundingScope = {
|
|
266
|
+
type: "current",
|
|
267
|
+
coin: coinUpper,
|
|
268
|
+
max_age_h: FUNDING_MAX_AGE_HOURS,
|
|
269
|
+
};
|
|
270
|
+
const settled = await settleAll([
|
|
236
271
|
// No `coin` param: the endpoint is slug-keyed and the caller gives us a
|
|
237
272
|
// ticker, so ask for the coverage list and match the ticker in it rather
|
|
238
273
|
// than shipping a guessed slug that can resolve to another asset.
|
|
239
|
-
safeCall(callApi, "
|
|
240
|
-
// The
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
.map((s) => s.error);
|
|
274
|
+
safeCall(callApi, "get_price_prediction", {}, "prediction"),
|
|
275
|
+
// The API resolves `coin` to an asset (PEPE brings its 1000PEPE and KPEPE
|
|
276
|
+
// contracts) and orders the markets by their own open interest.
|
|
277
|
+
safeCall(callApi, "get_funding_rates", { ...fundingScope, sort: "-open_interest" }, "funding"),
|
|
278
|
+
// The asset's funding across venues: the funding grid's Avg Rate.
|
|
279
|
+
safeCall(callApi, "get_funding_rates", { ...fundingScope, summary: "1" }, "funding-summary"),
|
|
280
|
+
// The asset's open-interest total (L4 oi.asset_total_usd): /global's row,
|
|
281
|
+
// every contract of the asset once, lots included, venues the API keeps
|
|
282
|
+
// out of totals left out, latest reading within six hours.
|
|
283
|
+
safeCall(callApi, "get_global_overview", {}, "open-interest"),
|
|
284
|
+
// The dollar value paid at the next settlement: the coin's own rows, never
|
|
285
|
+
// `totals` (which stays scoped to the whole class).
|
|
286
|
+
safeCall(callApi, "get_funding_settlement", { coin: coinUpper, window: "current" }, "settlement"),
|
|
287
|
+
]);
|
|
288
|
+
const [prediction, funding, summary, global, settlement] = settled;
|
|
255
289
|
const lines = [`## ${coinUpper} Analysis`];
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
}
|
|
290
|
+
lines.push(...predictionLines(prediction, coinUpper));
|
|
291
|
+
// Funding: the markets in the API's open-interest order, then its summary.
|
|
292
|
+
const fundingRows = funding.data ? (listRows(funding.data) ?? []) : null;
|
|
293
|
+
if (fundingRows === null) {
|
|
294
|
+
lines.push("\n*Funding rate data unavailable*");
|
|
300
295
|
}
|
|
301
|
-
else if (
|
|
302
|
-
lines.push(
|
|
296
|
+
else if (fundingRows.length === 0) {
|
|
297
|
+
lines.push("\n*No funding rates found for this coin*");
|
|
303
298
|
}
|
|
304
299
|
else {
|
|
305
|
-
lines.push("
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
}
|
|
300
|
+
lines.push("");
|
|
301
|
+
lines.push("**Funding (current, largest markets by open interest):**");
|
|
302
|
+
for (const row of fundingRows.slice(0, 10))
|
|
303
|
+
lines.push(fundingRowLine(row));
|
|
304
|
+
for (const entry of records(safeGet(summary.data, "summary"))) {
|
|
305
|
+
lines.push(fundingSummaryLine(entry));
|
|
357
306
|
}
|
|
358
307
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
308
|
+
// Open interest: the asset's row on the global board, found by the asset
|
|
309
|
+
// symbol the funding answer resolved (coin=1000PEPE files under PEPE).
|
|
310
|
+
if (global.data) {
|
|
311
|
+
const assetSymbol = fundingRows?.map((row) => text(row.asset_symbol)).find(Boolean) ??
|
|
312
|
+
coinUpper;
|
|
313
|
+
const row = records(safeGet(global.data, "rows")).find((candidate) => text(candidate.symbol) === assetSymbol);
|
|
364
314
|
lines.push("");
|
|
365
315
|
lines.push("**Open Interest:**");
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
}
|
|
316
|
+
if (row) {
|
|
317
|
+
lines.push(` **Total:** ${fmtUsd(finiteOrNull(row.openInterestUsd))} across ${finiteOrNull(row.venueCount) ?? "N/A"} venues | 1h ${fmtPct(finiteOrNull(row.oiChange1hPct))} | 24h ${fmtPct(finiteOrNull(row.oiChange24hPct))}`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
lines.push(` *No open-interest total for ${assetSymbol} on the global board.*`);
|
|
321
|
+
}
|
|
322
|
+
const excluded = records(safeGet(global.data, "metrics", "openInterestExcludedVenues"))
|
|
323
|
+
.map((venue) => {
|
|
324
|
+
const exchange = text(venue.exchange);
|
|
325
|
+
const reason = text(venue.reason);
|
|
326
|
+
return exchange ? `${exchange}${reason ? ` (${reason})` : ""}` : null;
|
|
327
|
+
})
|
|
328
|
+
.filter((venue) => venue !== null);
|
|
329
|
+
if (excluded.length > 0) {
|
|
330
|
+
lines.push(` *Left out of the total: ${excluded.join("; ")}*`);
|
|
390
331
|
}
|
|
391
332
|
}
|
|
392
333
|
else {
|
|
@@ -395,195 +336,151 @@ export async function analyzeCoin(callApi, coin) {
|
|
|
395
336
|
// Settlement: dollar value actually paid at the next settlement.
|
|
396
337
|
if (settlement.data) {
|
|
397
338
|
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");
|
|
339
|
+
const rows = records(payload.rows ?? payload.data);
|
|
405
340
|
lines.push("");
|
|
406
341
|
lines.push("**Settlement (next, estimated):**");
|
|
407
|
-
if (
|
|
342
|
+
if (rows.length === 0) {
|
|
408
343
|
lines.push(" No funding data for the next settlement.");
|
|
409
344
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
345
|
+
for (const row of rows) {
|
|
346
|
+
const net = finiteOrNull(safeGet(row, "windows", "current", "net"));
|
|
347
|
+
const prefix = rows.length > 1 ? `${marketLabel(row)}: ` : "";
|
|
348
|
+
if (net == null) {
|
|
349
|
+
lines.push(` ${prefix}No funding data for the next settlement.`);
|
|
350
|
+
}
|
|
351
|
+
else if (net > 0) {
|
|
352
|
+
lines.push(` ${prefix}Longs pay ${fmtUsd(net)} to shorts.`);
|
|
353
|
+
}
|
|
354
|
+
else if (net < 0) {
|
|
355
|
+
lines.push(` ${prefix}Shorts pay ${fmtUsd(-net)} to longs.`);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
lines.push(` ${prefix}No net funding at the next settlement.`);
|
|
359
|
+
}
|
|
418
360
|
}
|
|
419
361
|
}
|
|
420
362
|
else {
|
|
421
363
|
lines.push("\n*Settlement data unavailable*");
|
|
422
364
|
}
|
|
423
|
-
|
|
424
|
-
lines.push("");
|
|
425
|
-
lines.push(`**Data gaps:** ${errors.join("; ")}`);
|
|
426
|
-
}
|
|
427
|
-
return lines.join("\n");
|
|
365
|
+
return finish(lines, settled);
|
|
428
366
|
}
|
|
429
367
|
// ── market_briefing ───────────────────────────────────────────────────
|
|
368
|
+
function fundingExtremeLine(row) {
|
|
369
|
+
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)`;
|
|
370
|
+
}
|
|
430
371
|
export async function marketBriefing(callApi) {
|
|
431
|
-
const
|
|
432
|
-
safeCall(callApi, "
|
|
433
|
-
|
|
434
|
-
safeCall(callApi, "
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
safeCall(callApi, "
|
|
372
|
+
const settled = await settleAll([
|
|
373
|
+
safeCall(callApi, "get_market_overview", {}, "market-overview"),
|
|
374
|
+
// Ranked by the API: missing 24h changes are never ranked.
|
|
375
|
+
safeCall(callApi, "get_narratives", { sort: "-change24h", top: 5, bottom: 3 }, "narratives"),
|
|
376
|
+
// The book's extremes, ranked by the API on the 8h-equivalent rate among
|
|
377
|
+
// fresh markets with a usable interval above the floor.
|
|
378
|
+
safeCall(callApi, "get_funding_rates", {
|
|
379
|
+
type: "current",
|
|
380
|
+
extremes: 3,
|
|
381
|
+
min_oi_usd: LIQUIDITY_FLOOR_USD,
|
|
382
|
+
max_age_h: FUNDING_MAX_AGE_HOURS,
|
|
383
|
+
}, "funding"),
|
|
384
|
+
// No coin filter, so `totals` reflects the whole requested class.
|
|
385
|
+
safeCall(callApi, "get_funding_settlement", { window: "current" }, "settlement"),
|
|
438
386
|
]);
|
|
439
|
-
const settled = results.map((r) => r.status === "fulfilled"
|
|
440
|
-
? r.value
|
|
441
|
-
: { data: null, error: "Promise rejected" });
|
|
442
387
|
const [overview, narratives, funding, settlement] = settled;
|
|
443
|
-
const errors = settled
|
|
444
|
-
.filter((s) => s.error)
|
|
445
|
-
.map((s) => s.error);
|
|
446
388
|
const lines = ["## Market Briefing"];
|
|
447
389
|
// Market overview
|
|
448
|
-
if (overview.data
|
|
390
|
+
if (isRecord(overview.data)) {
|
|
449
391
|
const d = overview.data;
|
|
450
|
-
const mcap = d.total_market_cap;
|
|
451
|
-
const vol = (d.total_volume_24h ?? d.total_volume);
|
|
452
|
-
|
|
453
|
-
const
|
|
392
|
+
const mcap = finiteOrNull(d.total_market_cap);
|
|
393
|
+
const vol = finiteOrNull(d.total_volume_24h ?? d.total_volume);
|
|
394
|
+
// Served in percent (57.3 = 57.3%); `btc_dominance` is the same figure.
|
|
395
|
+
const btcDom = finiteOrNull(d.btc_dominance_pct ?? d.btc_dominance);
|
|
396
|
+
const fg = d.fgi ?? d.fear_greed_index ?? d.fear_greed;
|
|
454
397
|
lines.push("");
|
|
455
398
|
lines.push("### Market Stats");
|
|
456
399
|
if (mcap != null)
|
|
457
400
|
lines.push(`**Total Market Cap:** ${fmtUsd(mcap)}`);
|
|
458
401
|
if (vol != null)
|
|
459
402
|
lines.push(`**24h Volume:** ${fmtUsd(vol)}`);
|
|
460
|
-
if (btcDom != null)
|
|
461
|
-
|
|
462
|
-
lines.push(`**BTC Dominance:** ${pct.toFixed(1)}%`);
|
|
463
|
-
}
|
|
403
|
+
if (btcDom != null)
|
|
404
|
+
lines.push(`**BTC Dominance:** ${btcDom.toFixed(1)}%`);
|
|
464
405
|
if (fg != null) {
|
|
465
|
-
const fgVal =
|
|
466
|
-
let fgLabel =
|
|
406
|
+
const fgVal = isRecord(fg) ? fg.value : fg;
|
|
407
|
+
let fgLabel = isRecord(fg) ? (text(fg.label) ?? "") : "";
|
|
467
408
|
if (!fgLabel)
|
|
468
|
-
fgLabel = (d.fgi_classification ?? ""
|
|
409
|
+
fgLabel = text(d.fgi_classification) ?? "";
|
|
469
410
|
lines.push(`**Fear & Greed:** ${fgVal} ${fgLabel}`);
|
|
470
411
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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) {
|
|
412
|
+
for (const [key, heading] of [
|
|
413
|
+
["top_gainers", "### Top Gainers (24h)"],
|
|
414
|
+
["top_losers", "### Top Losers (24h)"],
|
|
415
|
+
]) {
|
|
416
|
+
const movers = records(d[key]);
|
|
417
|
+
if (movers.length === 0)
|
|
418
|
+
continue;
|
|
485
419
|
lines.push("");
|
|
486
|
-
lines.push(
|
|
487
|
-
for (const
|
|
488
|
-
const sym = (
|
|
489
|
-
|
|
490
|
-
lines.push(` ${sym}: ${fmtPct(chg)}`);
|
|
420
|
+
lines.push(heading);
|
|
421
|
+
for (const m of movers.slice(0, 5)) {
|
|
422
|
+
const sym = text(m.symbol) ?? text(m.name) ?? "?";
|
|
423
|
+
lines.push(` ${sym}: ${fmtPct(finiteOrNull(m.price_change_24h ?? m.change))}`);
|
|
491
424
|
}
|
|
492
425
|
}
|
|
493
426
|
}
|
|
494
427
|
else {
|
|
495
428
|
lines.push("\n*Market overview data unavailable*");
|
|
496
429
|
}
|
|
497
|
-
// Narratives
|
|
498
|
-
if (narratives.data) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
});
|
|
430
|
+
// Narratives, as the API ranked them.
|
|
431
|
+
if (isRecord(narratives.data)) {
|
|
432
|
+
const top = narratives.data.top;
|
|
433
|
+
const bottom = narratives.data.bottom;
|
|
434
|
+
if (Array.isArray(top) && Array.isArray(bottom)) {
|
|
513
435
|
lines.push("");
|
|
514
436
|
lines.push("### Top Narratives (24h)");
|
|
515
|
-
for (const n of
|
|
516
|
-
|
|
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)})`);
|
|
437
|
+
for (const n of records(top)) {
|
|
438
|
+
lines.push(` **${text(n.name) ?? "?"}:** ${fmtPct(finiteOrNull(n.change24h))} (mcap: ${fmtUsd(finiteOrNull(n.marketCap))})`);
|
|
520
439
|
}
|
|
521
440
|
lines.push("");
|
|
522
441
|
lines.push("### Worst Narratives (24h)");
|
|
523
|
-
for (const n of
|
|
524
|
-
|
|
525
|
-
const chg = (n.change24h ?? n.price_change_24h ?? n.change_24h);
|
|
526
|
-
lines.push(` **${name}:** ${fmtPct(chg)}`);
|
|
442
|
+
for (const n of records(bottom)) {
|
|
443
|
+
lines.push(` **${text(n.name) ?? "?"}:** ${fmtPct(finiteOrNull(n.change24h))}`);
|
|
527
444
|
}
|
|
528
445
|
}
|
|
446
|
+
else {
|
|
447
|
+
lines.push("\n*Narrative ranking unavailable*");
|
|
448
|
+
}
|
|
529
449
|
}
|
|
530
450
|
else {
|
|
531
451
|
lines.push("\n*Narrative data unavailable*");
|
|
532
452
|
}
|
|
533
|
-
// Funding highlights
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
453
|
+
// Funding highlights: the API's extremes, never re-ranked here.
|
|
454
|
+
const extremes = safeGet(funding.data, "extremes");
|
|
455
|
+
if (isRecord(extremes)) {
|
|
456
|
+
const highest = records(extremes.highest);
|
|
457
|
+
const lowest = records(extremes.lowest);
|
|
458
|
+
lines.push("");
|
|
459
|
+
lines.push("### Funding Rate Highlights");
|
|
460
|
+
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.*`);
|
|
461
|
+
const omitted = isRecord(extremes.omitted) ? extremes.omitted : {};
|
|
462
|
+
for (const [key, reason] of [
|
|
463
|
+
["stale", "stale, past the funding freshness SLA"],
|
|
464
|
+
["no_interval", "no rate or no published settlement interval"],
|
|
465
|
+
[
|
|
466
|
+
"below_floor",
|
|
467
|
+
`open interest below ${fmtUsd(LIQUIDITY_FLOOR_USD)} or unknown`,
|
|
468
|
+
],
|
|
469
|
+
]) {
|
|
470
|
+
const count = finiteOrNull(omitted[key]);
|
|
471
|
+
if (count)
|
|
472
|
+
lines.push(`*${count} markets left out: ${reason}.*`);
|
|
541
473
|
}
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
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
|
-
}
|
|
474
|
+
if (highest.length === 0 && lowest.length === 0) {
|
|
475
|
+
lines.push("*No market clears the floor.*");
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
lines.push("**Highest (longs paying most):**");
|
|
479
|
+
for (const row of highest)
|
|
480
|
+
lines.push(fundingExtremeLine(row));
|
|
481
|
+
lines.push("**Lowest (shorts paying most):**");
|
|
482
|
+
for (const row of lowest)
|
|
483
|
+
lines.push(fundingExtremeLine(row));
|
|
587
484
|
}
|
|
588
485
|
}
|
|
589
486
|
else {
|
|
@@ -591,11 +488,10 @@ export async function marketBriefing(callApi) {
|
|
|
591
488
|
}
|
|
592
489
|
// Funding settlement: whole-market dollar total, not a per-row rate.
|
|
593
490
|
if (settlement.data) {
|
|
594
|
-
const
|
|
595
|
-
const
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
const shortPaid = safeGet(totals, "current", "short_paid");
|
|
491
|
+
const totals = settlement.data.totals;
|
|
492
|
+
const net = finiteOrNull(safeGet(totals, "current", "net"));
|
|
493
|
+
const longPaid = finiteOrNull(safeGet(totals, "current", "long_paid"));
|
|
494
|
+
const shortPaid = finiteOrNull(safeGet(totals, "current", "short_paid"));
|
|
599
495
|
lines.push("");
|
|
600
496
|
lines.push("### Funding Settlement (next, estimated)");
|
|
601
497
|
if (net == null) {
|
|
@@ -614,83 +510,51 @@ export async function marketBriefing(callApi) {
|
|
|
614
510
|
else {
|
|
615
511
|
lines.push("\n*Funding settlement data unavailable*");
|
|
616
512
|
}
|
|
617
|
-
|
|
618
|
-
lines.push("");
|
|
619
|
-
lines.push(`**Data gaps:** ${errors.join("; ")}`);
|
|
620
|
-
}
|
|
621
|
-
return lines.join("\n");
|
|
513
|
+
return finish(lines, settled);
|
|
622
514
|
}
|
|
623
515
|
// ── find_opportunities ────────────────────────────────────────────────
|
|
624
|
-
/**
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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;
|
|
516
|
+
/** A row's own status flags, printed only when they say something. */
|
|
517
|
+
function statusSuffix(row) {
|
|
518
|
+
const parts = [text(row.executionStatus) ?? "n/a"];
|
|
519
|
+
const pair = text(row.pairStatus);
|
|
520
|
+
if (pair && pair !== "eligible")
|
|
521
|
+
parts.push(pair);
|
|
522
|
+
if (row.fundingIsStale === true)
|
|
523
|
+
parts.push("funding stale");
|
|
524
|
+
return parts.join(" | ");
|
|
642
525
|
}
|
|
643
526
|
export async function findOpportunities(callApi) {
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
527
|
+
const settled = await settleAll([
|
|
528
|
+
// The API floors the perp leg's open interest (unknown fails it) and
|
|
529
|
+
// keeps its board order: net APR first, then gross APR.
|
|
530
|
+
safeCall(callApi, "get_arbitrage_spot_perp", { exchange: "all", direction: "all", minOiUsd: LIQUIDITY_FLOOR_USD }, "spot-perp-arb"),
|
|
531
|
+
// Both legs must clear the floor; the API ranks executable rows first and
|
|
532
|
+
// cuts the board to the rows shown.
|
|
533
|
+
safeCall(callApi, "get_arbitrage_cross_exchange", {
|
|
534
|
+
minOiUsd: LIQUIDITY_FLOOR_USD,
|
|
535
|
+
minVolUsd: LIQUIDITY_FLOOR_USD,
|
|
536
|
+
limit: 10,
|
|
537
|
+
}, "cross-exchange-arb"),
|
|
538
|
+
// The whole book's funding stats (the v1 derivatives overview's
|
|
539
|
+
// computation, as APRs).
|
|
540
|
+
safeCall(callApi, "get_funding_rates", { type: "current", stats: "1" }, "funding"),
|
|
650
541
|
]);
|
|
651
|
-
const settled = results.map((r) => r.status === "fulfilled"
|
|
652
|
-
? r.value
|
|
653
|
-
: { data: null, error: "Promise rejected" });
|
|
654
542
|
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
543
|
const floorLabel = fmtUsd(LIQUIDITY_FLOOR_USD);
|
|
544
|
+
const lines = ["## Arbitrage Opportunities"];
|
|
662
545
|
// Spot-perp basis trades
|
|
663
546
|
if (spotPerp.data) {
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
}
|
|
547
|
+
const rows = listRows(spotPerp.data) ?? [];
|
|
548
|
+
lines.push("");
|
|
549
|
+
lines.push("### Spot-Perp Basis Trades (Top 10)");
|
|
550
|
+
lines.push("*Long spot + short perp (or vice versa) to capture funding*");
|
|
551
|
+
lines.push(`*Perp legs with at least ${floorLabel} open interest, in the API's order: net APR first, then gross APR.*`);
|
|
552
|
+
lines.push("");
|
|
553
|
+
if (rows.length === 0) {
|
|
554
|
+
lines.push(" *No spot-perp row clears the liquidity floor.*");
|
|
691
555
|
}
|
|
692
|
-
|
|
693
|
-
lines.push("
|
|
556
|
+
for (const a of rows.slice(0, 10)) {
|
|
557
|
+
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
558
|
}
|
|
695
559
|
}
|
|
696
560
|
else {
|
|
@@ -698,72 +562,34 @@ export async function findOpportunities(callApi) {
|
|
|
698
562
|
}
|
|
699
563
|
// Cross-exchange funding arb
|
|
700
564
|
if (crossEx.data) {
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
}
|
|
565
|
+
const rows = listRows(crossEx.data) ?? [];
|
|
566
|
+
lines.push("");
|
|
567
|
+
lines.push("### Cross-Exchange Funding Arb (Top 10)");
|
|
568
|
+
lines.push("*Long on low-rate exchange, short on high-rate exchange*");
|
|
569
|
+
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.*`);
|
|
570
|
+
lines.push("");
|
|
571
|
+
if (rows.length === 0) {
|
|
572
|
+
lines.push(" *No cross-exchange row clears the liquidity floor.*");
|
|
730
573
|
}
|
|
731
|
-
|
|
732
|
-
lines.push("
|
|
574
|
+
for (const a of rows.slice(0, 10)) {
|
|
575
|
+
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
576
|
}
|
|
734
577
|
}
|
|
735
578
|
else {
|
|
736
579
|
lines.push("\n*Cross-exchange arbitrage data unavailable*");
|
|
737
580
|
}
|
|
738
|
-
// Funding rate summary
|
|
739
|
-
|
|
740
|
-
if (
|
|
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) {
|
|
581
|
+
// Funding rate summary: the API's market stats.
|
|
582
|
+
const market = safeGet(funding.data, "market");
|
|
583
|
+
if (isRecord(market)) {
|
|
764
584
|
lines.push("");
|
|
765
|
-
lines.push(
|
|
585
|
+
lines.push("### Funding Rate Summary");
|
|
586
|
+
lines.push("*The API's market stats: the fresh primary contract of every (venue, asset), all asset classes.*");
|
|
587
|
+
lines.push(`**Avg APR:** ${fmtApr(market.funding_apr_mean)} | **OI-weighted APR:** ${fmtApr(market.funding_apr_oi_weighted)}`);
|
|
588
|
+
lines.push(`**Positive (longs pay):** ${finiteOrNull(market.positive) ?? "N/A"} | **Negative (shorts pay):** ${finiteOrNull(market.negative) ?? "N/A"}`);
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
lines.push("\n*Funding rate data unavailable*");
|
|
766
592
|
}
|
|
767
|
-
return lines
|
|
593
|
+
return finish(lines, settled);
|
|
768
594
|
}
|
|
769
595
|
//# sourceMappingURL=composite-tools.js.map
|