@lendwise/mcp 0.1.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 +76 -0
- package/dist/bin/stdio.d.ts +2 -0
- package/dist/bin/stdio.js +18 -0
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.js +19 -0
- package/dist/core/graphql/client.d.ts +3 -0
- package/dist/core/graphql/client.js +14 -0
- package/dist/core/graphql/queries.d.ts +133 -0
- package/dist/core/graphql/queries.js +207 -0
- package/dist/core/http.d.ts +20 -0
- package/dist/core/http.js +72 -0
- package/dist/core/optimizer.d.ts +58 -0
- package/dist/core/optimizer.js +77 -0
- package/dist/core/server.d.ts +12 -0
- package/dist/core/server.js +115 -0
- package/dist/core/stats.d.ts +24 -0
- package/dist/core/stats.js +31 -0
- package/dist/core/tools/find-best-markets.d.ts +32 -0
- package/dist/core/tools/find-best-markets.js +57 -0
- package/dist/core/tools/get-market-details.d.ts +74 -0
- package/dist/core/tools/get-market-details.js +70 -0
- package/dist/core/tools/get-market-history.d.ts +44 -0
- package/dist/core/tools/get-market-history.js +58 -0
- package/dist/core/tools/list-market-universe.d.ts +31 -0
- package/dist/core/tools/list-market-universe.js +33 -0
- package/dist/core/tools/optimize-allocation.d.ts +42 -0
- package/dist/core/tools/optimize-allocation.js +96 -0
- package/dist/core/tools/shared.d.ts +28 -0
- package/dist/core/tools/shared.js +41 -0
- package/package.json +33 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { OPTIMIZER_ENDPOINT } from './config.js';
|
|
2
|
+
import { ApiError, postJson } from './http.js';
|
|
3
|
+
/**
|
|
4
|
+
* Map the solver's positional result back onto the productIds it was built
|
|
5
|
+
* from. `apys[i]` must be the APY of `productIds[i]` — that invariant is the
|
|
6
|
+
* caller's to hold, and `buildApyVector` is the only supported way to get it.
|
|
7
|
+
*/
|
|
8
|
+
export function mapAllocations(productIds, apys, allocations, amountUsd) {
|
|
9
|
+
return allocations.flatMap((a) => {
|
|
10
|
+
const productId = productIds[a.vault_index];
|
|
11
|
+
const apy = apys[a.vault_index];
|
|
12
|
+
// A vault_index outside the array we sent means the request and response
|
|
13
|
+
// disagree about the market set. Dropping it is the only safe move — the
|
|
14
|
+
// alternative is attributing money to an unknown market.
|
|
15
|
+
if (productId === undefined || apy === undefined)
|
|
16
|
+
return [];
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
productId,
|
|
20
|
+
apy,
|
|
21
|
+
allocationPercent: a.allocation_percent,
|
|
22
|
+
amountUsd: amountUsd * (a.allocation_percent / 100),
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the positional APY vector for `productIds`, in that exact order.
|
|
29
|
+
* Products with no finite APY in the snapshot are reported as missing rather
|
|
30
|
+
* than defaulted to 0 — a 0 would silently tell the solver "this market is
|
|
31
|
+
* worthless" instead of "we don't know".
|
|
32
|
+
*/
|
|
33
|
+
export function buildApyVector(productIds, latestApyByProduct) {
|
|
34
|
+
const apys = [];
|
|
35
|
+
const found = [];
|
|
36
|
+
const missing = [];
|
|
37
|
+
for (const id of productIds) {
|
|
38
|
+
const apy = latestApyByProduct.get(id);
|
|
39
|
+
if (apy === undefined || !Number.isFinite(apy)) {
|
|
40
|
+
missing.push(id);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
apys.push(apy);
|
|
44
|
+
found.push(id);
|
|
45
|
+
}
|
|
46
|
+
return { apys, found, missing };
|
|
47
|
+
}
|
|
48
|
+
/** Blended APY of an allocation — the portfolio's weighted mean. */
|
|
49
|
+
export function blendedApy(allocations) {
|
|
50
|
+
const totalPercent = allocations.reduce((a, x) => a + x.allocationPercent, 0);
|
|
51
|
+
if (totalPercent === 0)
|
|
52
|
+
return 0;
|
|
53
|
+
return (allocations.reduce((a, x) => a + x.apy * x.allocationPercent, 0) /
|
|
54
|
+
totalPercent);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Call the solver through the Lendwise proxy — never optimizer.lendwise.fi directly.
|
|
58
|
+
*
|
|
59
|
+
* The response is VALIDATED, not cast. A solver that answers 200 with
|
|
60
|
+
* `success: false` (or without `allocations` at all) would otherwise blow up
|
|
61
|
+
* inside `mapAllocations`, or — worse — be reported to the user as a valid
|
|
62
|
+
* allocation of $0 across their markets.
|
|
63
|
+
*/
|
|
64
|
+
export async function optimizeVaults(apy, diversification) {
|
|
65
|
+
const body = await postJson(OPTIMIZER_ENDPOINT, { endpoint: '/optimize/vaults', data: { apy, diversification } }, 'The optimizer');
|
|
66
|
+
if (body.success !== true) {
|
|
67
|
+
throw new ApiError('The optimizer could not find an allocation for these markets.');
|
|
68
|
+
}
|
|
69
|
+
if (!Array.isArray(body.allocations)) {
|
|
70
|
+
throw new ApiError('The optimizer returned a malformed allocation.');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
success: true,
|
|
74
|
+
allocations: body.allocations,
|
|
75
|
+
resulting_diversification: body.resulting_diversification ?? diversification,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Register the five tools on a server instance.
|
|
4
|
+
*
|
|
5
|
+
* Both transports go through this one function: the stdio binary builds its own
|
|
6
|
+
* server, while mcp-handler hands us one it constructed. If registration lived in
|
|
7
|
+
* only one of those paths, the hosted and local servers would drift apart in what
|
|
8
|
+
* they expose.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerTools(server: McpServer): McpServer;
|
|
11
|
+
/** Build a fully-registered server. Used by the stdio entrypoint. */
|
|
12
|
+
export declare function createServer(): McpServer;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { RateLimitedError } from './graphql/client.js';
|
|
3
|
+
import { findBestMarkets, findBestMarketsArgs, } from './tools/find-best-markets.js';
|
|
4
|
+
import { getMarketDetails, getMarketDetailsArgs, } from './tools/get-market-details.js';
|
|
5
|
+
import { getMarketHistory, getMarketHistoryArgs, } from './tools/get-market-history.js';
|
|
6
|
+
import { listMarketUniverse, listMarketUniverseArgs, } from './tools/list-market-universe.js';
|
|
7
|
+
import { optimizeAllocation, optimizeAllocationArgs, } from './tools/optimize-allocation.js';
|
|
8
|
+
const VERSION = '0.1.0';
|
|
9
|
+
/**
|
|
10
|
+
* Run a tool and render its result as MCP text content.
|
|
11
|
+
*
|
|
12
|
+
* A 429 is surfaced as an explicitly retryable error carrying the wait — the
|
|
13
|
+
* agent has to back off, not hammer the endpoint the rate limit exists to
|
|
14
|
+
* protect. Everything else is returned as `isError` text so the model can read
|
|
15
|
+
* what went wrong and correct itself, rather than the transport throwing.
|
|
16
|
+
*/
|
|
17
|
+
async function run(handler, args) {
|
|
18
|
+
try {
|
|
19
|
+
const result = await handler(args);
|
|
20
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof RateLimitedError) {
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: 'text',
|
|
28
|
+
text: JSON.stringify({
|
|
29
|
+
error: 'rate_limited',
|
|
30
|
+
retryable: true,
|
|
31
|
+
retryAfterSeconds: error.retryAfterSeconds,
|
|
32
|
+
message: error.message,
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
isError: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: 'text',
|
|
43
|
+
text: JSON.stringify({
|
|
44
|
+
error: 'tool_failed',
|
|
45
|
+
retryable: false,
|
|
46
|
+
message: error instanceof Error ? error.message : String(error),
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
isError: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Register the five tools on a server instance.
|
|
56
|
+
*
|
|
57
|
+
* Both transports go through this one function: the stdio binary builds its own
|
|
58
|
+
* server, while mcp-handler hands us one it constructed. If registration lived in
|
|
59
|
+
* only one of those paths, the hosted and local servers would drift apart in what
|
|
60
|
+
* they expose.
|
|
61
|
+
*/
|
|
62
|
+
export function registerTools(server) {
|
|
63
|
+
// Every tool is read-only and non-destructive, but reaches the public internet.
|
|
64
|
+
const readOnly = {
|
|
65
|
+
readOnlyHint: true,
|
|
66
|
+
destructiveHint: false,
|
|
67
|
+
idempotentHint: true,
|
|
68
|
+
openWorldHint: true,
|
|
69
|
+
};
|
|
70
|
+
server.registerTool('list_market_universe', {
|
|
71
|
+
title: 'List the market universe',
|
|
72
|
+
description: 'List every asset, chain and protocol that Lendwise actually tracks, ' +
|
|
73
|
+
'with market counts. Call this FIRST — the filter values for ' +
|
|
74
|
+
'find_best_markets must come from here, not from memory.',
|
|
75
|
+
inputSchema: listMarketUniverseArgs,
|
|
76
|
+
annotations: readOnly,
|
|
77
|
+
}, (args) => run(listMarketUniverse, args));
|
|
78
|
+
server.registerTool('find_best_markets', {
|
|
79
|
+
title: 'Find the best supply markets',
|
|
80
|
+
description: 'Rank current supply markets by net APY. Filtering and sorting run ' +
|
|
81
|
+
'server-side against the latest snapshot. Defaults to markets with at ' +
|
|
82
|
+
'least $1M TVL, because a thin market’s headline APY is mostly noise.',
|
|
83
|
+
inputSchema: findBestMarketsArgs,
|
|
84
|
+
annotations: readOnly,
|
|
85
|
+
}, (args) => run(findBestMarkets, args));
|
|
86
|
+
server.registerTool('get_market_details', {
|
|
87
|
+
title: 'Get market details',
|
|
88
|
+
description: 'Full detail for one market: protocol metadata, accepted collaterals, ' +
|
|
89
|
+
'and the current APY split into base / rewards / fees with individual ' +
|
|
90
|
+
'reward items.',
|
|
91
|
+
inputSchema: getMarketDetailsArgs,
|
|
92
|
+
annotations: readOnly,
|
|
93
|
+
}, (args) => run(getMarketDetails, args));
|
|
94
|
+
server.registerTool('get_market_history', {
|
|
95
|
+
title: 'Get market APY history',
|
|
96
|
+
description: 'Daily net-APY history for one market plus mean / stddev / min / max. ' +
|
|
97
|
+
'Use this before committing to a market over a long horizon: it is what ' +
|
|
98
|
+
'separates a durable yield from a temporary reward spike.',
|
|
99
|
+
inputSchema: getMarketHistoryArgs,
|
|
100
|
+
annotations: readOnly,
|
|
101
|
+
}, (args) => run(getMarketHistory, args));
|
|
102
|
+
server.registerTool('optimize_allocation', {
|
|
103
|
+
title: 'Optimize an allocation',
|
|
104
|
+
description: 'Split an amount across chosen markets to maximise yield at a target ' +
|
|
105
|
+
'diversification, returning per-market amounts, the blended APY and a ' +
|
|
106
|
+
'projected 6-month yield.',
|
|
107
|
+
inputSchema: optimizeAllocationArgs,
|
|
108
|
+
annotations: readOnly,
|
|
109
|
+
}, (args) => run(optimizeAllocation, args));
|
|
110
|
+
return server;
|
|
111
|
+
}
|
|
112
|
+
/** Build a fully-registered server. Used by the stdio entrypoint. */
|
|
113
|
+
export function createServer() {
|
|
114
|
+
return registerTools(new McpServer({ name: 'lendwise', version: VERSION }));
|
|
115
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descriptive stats over a daily APY series.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole point of `get_market_history`: over a 6-month horizon, a
|
|
5
|
+
* durable 6% and a 12% that is a reward spike ending next week look identical
|
|
6
|
+
* from a single snapshot. Standard deviation is what separates them.
|
|
7
|
+
*/
|
|
8
|
+
export interface Stats {
|
|
9
|
+
mean: number;
|
|
10
|
+
stddev: number;
|
|
11
|
+
min: number;
|
|
12
|
+
max: number;
|
|
13
|
+
/** Number of finite observations the stats were computed from. */
|
|
14
|
+
count: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Drop non-finite values. Postgres `double precision` can hold NaN (a bad
|
|
18
|
+
* upstream APR→APY conversion lands there), and NaN poisons every downstream
|
|
19
|
+
* number — a mean of NaN is worse than a missing mean, because it looks like an
|
|
20
|
+
* answer.
|
|
21
|
+
*/
|
|
22
|
+
export declare function finiteOnly(values: readonly (number | null | undefined)[]): number[];
|
|
23
|
+
/** Population standard deviation. Returns null when there is nothing finite to describe. */
|
|
24
|
+
export declare function stats(values: readonly (number | null | undefined)[]): Stats | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Descriptive stats over a daily APY series.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole point of `get_market_history`: over a 6-month horizon, a
|
|
5
|
+
* durable 6% and a 12% that is a reward spike ending next week look identical
|
|
6
|
+
* from a single snapshot. Standard deviation is what separates them.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Drop non-finite values. Postgres `double precision` can hold NaN (a bad
|
|
10
|
+
* upstream APR→APY conversion lands there), and NaN poisons every downstream
|
|
11
|
+
* number — a mean of NaN is worse than a missing mean, because it looks like an
|
|
12
|
+
* answer.
|
|
13
|
+
*/
|
|
14
|
+
export function finiteOnly(values) {
|
|
15
|
+
return values.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
16
|
+
}
|
|
17
|
+
/** Population standard deviation. Returns null when there is nothing finite to describe. */
|
|
18
|
+
export function stats(values) {
|
|
19
|
+
const xs = finiteOnly(values);
|
|
20
|
+
if (xs.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
23
|
+
const variance = xs.reduce((acc, x) => acc + (x - mean) ** 2, 0) / xs.length;
|
|
24
|
+
return {
|
|
25
|
+
mean,
|
|
26
|
+
stddev: Math.sqrt(variance),
|
|
27
|
+
min: Math.min(...xs),
|
|
28
|
+
max: Math.max(...xs),
|
|
29
|
+
count: xs.length,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const findBestMarketsArgs: {
|
|
3
|
+
asset: z.ZodOptional<z.ZodString>;
|
|
4
|
+
chainId: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
protocol: z.ZodOptional<z.ZodEnum<{
|
|
6
|
+
aave: "aave";
|
|
7
|
+
morpho: "morpho";
|
|
8
|
+
compound: "compound";
|
|
9
|
+
}>>;
|
|
10
|
+
minTvlUsd: z.ZodDefault<z.ZodNumber>;
|
|
11
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Current best supply markets by net APY. Reads the server-side latest-snapshot
|
|
15
|
+
* query, so the ranking and the TVL floor are both applied in Postgres — this
|
|
16
|
+
* never pages the catalogue and reduces client-side.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findBestMarkets(raw: unknown): Promise<{
|
|
19
|
+
disclaimer: string;
|
|
20
|
+
warning?: string | undefined;
|
|
21
|
+
hint?: string | undefined;
|
|
22
|
+
query: {
|
|
23
|
+
asset: string | undefined;
|
|
24
|
+
chainId: number | undefined;
|
|
25
|
+
protocol: "aave" | "morpho" | "compound" | undefined;
|
|
26
|
+
minTvlUsd: number;
|
|
27
|
+
limit: number;
|
|
28
|
+
};
|
|
29
|
+
matched: number;
|
|
30
|
+
returned: number;
|
|
31
|
+
markets: import("./shared.js").DescribedMarket[];
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DEFAULT_MIN_TVL_USD, NOT_ADVICE } from '../config.js';
|
|
3
|
+
import { gql } from '../graphql/client.js';
|
|
4
|
+
import { LATEST_SUPPLY_APY, } from '../graphql/queries.js';
|
|
5
|
+
import { describeRow, isUnreliable } from './shared.js';
|
|
6
|
+
export const findBestMarketsArgs = {
|
|
7
|
+
asset: z
|
|
8
|
+
.string()
|
|
9
|
+
.optional()
|
|
10
|
+
.describe('Asset symbol, e.g. USDC. Must come from list_market_universe.'),
|
|
11
|
+
chainId: z.number().int().optional().describe('Chain ID, e.g. 1 for Ethereum.'),
|
|
12
|
+
protocol: z
|
|
13
|
+
.enum(['aave', 'morpho', 'compound'])
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('Protocol provider.'),
|
|
16
|
+
minTvlUsd: z
|
|
17
|
+
.number()
|
|
18
|
+
.nonnegative()
|
|
19
|
+
.default(DEFAULT_MIN_TVL_USD)
|
|
20
|
+
.describe('Minimum supplied TVL in USD. Defaults to $1M — in a thinner market the ' +
|
|
21
|
+
'headline APY is mostly noise. Lower it deliberately, not by accident.'),
|
|
22
|
+
limit: z.number().int().min(1).max(50).default(10),
|
|
23
|
+
};
|
|
24
|
+
const Args = z.object(findBestMarketsArgs);
|
|
25
|
+
/**
|
|
26
|
+
* Current best supply markets by net APY. Reads the server-side latest-snapshot
|
|
27
|
+
* query, so the ranking and the TVL floor are both applied in Postgres — this
|
|
28
|
+
* never pages the catalogue and reduces client-side.
|
|
29
|
+
*/
|
|
30
|
+
export async function findBestMarkets(raw) {
|
|
31
|
+
const { asset, chainId, protocol, minTvlUsd, limit } = Args.parse(raw);
|
|
32
|
+
const data = await gql(LATEST_SUPPLY_APY, {
|
|
33
|
+
filters: { asset, chainId, protocol, minTvlUsd },
|
|
34
|
+
first: limit,
|
|
35
|
+
});
|
|
36
|
+
const { items, pagination } = data.latestSupplyApy;
|
|
37
|
+
const markets = items.map(describeRow);
|
|
38
|
+
return {
|
|
39
|
+
query: { asset, chainId, protocol, minTvlUsd, limit },
|
|
40
|
+
matched: pagination.countTotal,
|
|
41
|
+
returned: markets.length,
|
|
42
|
+
markets,
|
|
43
|
+
...(markets.length === 0
|
|
44
|
+
? {
|
|
45
|
+
hint: 'No market matched. Check the filter values against ' +
|
|
46
|
+
'list_market_universe, or lower minTvlUsd.',
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
49
|
+
...(markets.some(isUnreliable)
|
|
50
|
+
? {
|
|
51
|
+
warning: 'Some markets are flagged unreliable (incomplete data for the ' +
|
|
52
|
+
'current hour). Treat their APY as provisional.',
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
55
|
+
disclaimer: NOT_ADVICE,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const getMarketDetailsArgs: {
|
|
3
|
+
productId: z.ZodString;
|
|
4
|
+
};
|
|
5
|
+
/** Full picture of one market: registry entry, collaterals, and the APY breakdown. */
|
|
6
|
+
export declare function getMarketDetails(raw: unknown): Promise<{
|
|
7
|
+
productId: string;
|
|
8
|
+
found: boolean;
|
|
9
|
+
hint: string;
|
|
10
|
+
disclaimer: string;
|
|
11
|
+
} | {
|
|
12
|
+
disclaimer: string;
|
|
13
|
+
warning?: string | undefined;
|
|
14
|
+
found: boolean;
|
|
15
|
+
product: {
|
|
16
|
+
productId: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
asset: {
|
|
19
|
+
symbol: string;
|
|
20
|
+
name: string;
|
|
21
|
+
address: string;
|
|
22
|
+
decimals: number;
|
|
23
|
+
};
|
|
24
|
+
protocol: {
|
|
25
|
+
provider: string;
|
|
26
|
+
market: string;
|
|
27
|
+
type: string;
|
|
28
|
+
version: string;
|
|
29
|
+
chain: {
|
|
30
|
+
id: number;
|
|
31
|
+
name: string;
|
|
32
|
+
};
|
|
33
|
+
address: string;
|
|
34
|
+
meta: unknown;
|
|
35
|
+
};
|
|
36
|
+
collaterals: {
|
|
37
|
+
symbol: string;
|
|
38
|
+
name: string;
|
|
39
|
+
ltv: number | null;
|
|
40
|
+
lltv: number;
|
|
41
|
+
canBeCollateral: boolean;
|
|
42
|
+
}[] | null;
|
|
43
|
+
};
|
|
44
|
+
current: {
|
|
45
|
+
rewardItems: {
|
|
46
|
+
token: string;
|
|
47
|
+
apy: number | null;
|
|
48
|
+
source: string;
|
|
49
|
+
program: string | null;
|
|
50
|
+
}[];
|
|
51
|
+
productId: string;
|
|
52
|
+
asset: string;
|
|
53
|
+
chain: string;
|
|
54
|
+
chainId: number;
|
|
55
|
+
protocol: string;
|
|
56
|
+
market: string;
|
|
57
|
+
apy: {
|
|
58
|
+
net: number | null;
|
|
59
|
+
base: number | null;
|
|
60
|
+
rewards: number | null;
|
|
61
|
+
fees: number | null;
|
|
62
|
+
};
|
|
63
|
+
tvlUsd: number | null;
|
|
64
|
+
utilizationRate: number | null;
|
|
65
|
+
asOf: string;
|
|
66
|
+
quality: {
|
|
67
|
+
status: string;
|
|
68
|
+
slots: string;
|
|
69
|
+
reliable: boolean;
|
|
70
|
+
};
|
|
71
|
+
} | null;
|
|
72
|
+
productId?: undefined;
|
|
73
|
+
hint?: undefined;
|
|
74
|
+
}>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { NOT_ADVICE } from '../config.js';
|
|
3
|
+
import { gql } from '../graphql/client.js';
|
|
4
|
+
import { MARKET_DETAILS, } from '../graphql/queries.js';
|
|
5
|
+
import { describeRow, finite } from './shared.js';
|
|
6
|
+
export const getMarketDetailsArgs = {
|
|
7
|
+
productId: z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.describe('Exact productId from find_best_markets, e.g. ' +
|
|
11
|
+
'aave:v3:ethereum:reserve:0x…:supply'),
|
|
12
|
+
};
|
|
13
|
+
const Args = z.object(getMarketDetailsArgs);
|
|
14
|
+
/** Full picture of one market: registry entry, collaterals, and the APY breakdown. */
|
|
15
|
+
export async function getMarketDetails(raw) {
|
|
16
|
+
const { productId } = Args.parse(raw);
|
|
17
|
+
const data = await gql(MARKET_DETAILS, { productId });
|
|
18
|
+
const product = data.products.items[0];
|
|
19
|
+
// A productId is either a supply or a borrow product; exactly one of these
|
|
20
|
+
// carries a row. Taking only the supply side would flag every healthy borrow
|
|
21
|
+
// market as a stalled pipeline.
|
|
22
|
+
const snapshot = data.latestSupplyApy.items[0] ?? data.latestBorrowApy.items[0];
|
|
23
|
+
if (!product) {
|
|
24
|
+
return {
|
|
25
|
+
productId,
|
|
26
|
+
found: false,
|
|
27
|
+
hint: 'No product with that id. productIds are exact — take one from ' +
|
|
28
|
+
'find_best_markets rather than composing it by hand.',
|
|
29
|
+
disclaimer: NOT_ADVICE,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
found: true,
|
|
34
|
+
product: {
|
|
35
|
+
productId: product.id,
|
|
36
|
+
kind: product.kind,
|
|
37
|
+
asset: product.asset,
|
|
38
|
+
protocol: {
|
|
39
|
+
provider: product.protocol.provider,
|
|
40
|
+
market: product.protocol.name,
|
|
41
|
+
type: product.protocol.type,
|
|
42
|
+
version: product.protocol.version,
|
|
43
|
+
chain: product.protocol.chain,
|
|
44
|
+
address: product.protocol.address,
|
|
45
|
+
meta: product.protocol.meta,
|
|
46
|
+
},
|
|
47
|
+
collaterals: product.collaterals,
|
|
48
|
+
},
|
|
49
|
+
// A product with no reading in the last 6 hours has a stale or stalled
|
|
50
|
+
// pipeline; report the absence rather than an old number.
|
|
51
|
+
current: snapshot
|
|
52
|
+
? {
|
|
53
|
+
...describeRow(snapshot),
|
|
54
|
+
rewardItems: (snapshot.apy.rewardItems ?? []).map((r) => ({
|
|
55
|
+
token: r.token.symbol,
|
|
56
|
+
apy: finite(r.apy),
|
|
57
|
+
source: r.source,
|
|
58
|
+
program: r.program,
|
|
59
|
+
})),
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
...(snapshot
|
|
63
|
+
? {}
|
|
64
|
+
: {
|
|
65
|
+
warning: 'No APY snapshot in the last 6 hours for this product — its data ' +
|
|
66
|
+
'pipeline may have stalled. Do not assume a previous value still holds.',
|
|
67
|
+
}),
|
|
68
|
+
disclaimer: NOT_ADVICE,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const getMarketHistoryArgs: {
|
|
3
|
+
productId: z.ZodString;
|
|
4
|
+
range: z.ZodDefault<z.ZodEnum<{
|
|
5
|
+
"7d": "7d";
|
|
6
|
+
"30d": "30d";
|
|
7
|
+
"90d": "90d";
|
|
8
|
+
"180d": "180d";
|
|
9
|
+
}>>;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Daily net-APY series plus its mean / stddev / min / max.
|
|
13
|
+
*
|
|
14
|
+
* The stats are the deliverable, not the series. A snapshot cannot distinguish a
|
|
15
|
+
* durable 6% from a 12% reward spike that ends next week; a 180-day standard
|
|
16
|
+
* deviation can. That is the signal a 6-month horizon actually needs.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getMarketHistory(raw: unknown): Promise<{
|
|
19
|
+
productId: string;
|
|
20
|
+
range: "7d" | "30d" | "90d" | "180d";
|
|
21
|
+
observations: number;
|
|
22
|
+
hint: string;
|
|
23
|
+
disclaimer: string;
|
|
24
|
+
netApy?: undefined;
|
|
25
|
+
interpretation?: undefined;
|
|
26
|
+
series?: undefined;
|
|
27
|
+
} | {
|
|
28
|
+
productId: string;
|
|
29
|
+
range: "7d" | "30d" | "90d" | "180d";
|
|
30
|
+
observations: number;
|
|
31
|
+
netApy: {
|
|
32
|
+
mean: number;
|
|
33
|
+
stddev: number;
|
|
34
|
+
min: number;
|
|
35
|
+
max: number;
|
|
36
|
+
};
|
|
37
|
+
interpretation: string;
|
|
38
|
+
series: {
|
|
39
|
+
date: string;
|
|
40
|
+
net: number | null;
|
|
41
|
+
}[];
|
|
42
|
+
disclaimer: string;
|
|
43
|
+
hint?: undefined;
|
|
44
|
+
}>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { NOT_ADVICE } from '../config.js';
|
|
3
|
+
import { gql } from '../graphql/client.js';
|
|
4
|
+
import { MARKET_HISTORY } from '../graphql/queries.js';
|
|
5
|
+
import { stats } from '../stats.js';
|
|
6
|
+
export const getMarketHistoryArgs = {
|
|
7
|
+
productId: z.string().min(1).describe('Exact productId from find_best_markets.'),
|
|
8
|
+
range: z
|
|
9
|
+
.enum(['7d', '30d', '90d', '180d'])
|
|
10
|
+
.default('90d')
|
|
11
|
+
.describe('How far back to look. Use 180d for a 6-month horizon.'),
|
|
12
|
+
};
|
|
13
|
+
const Args = z.object(getMarketHistoryArgs);
|
|
14
|
+
/**
|
|
15
|
+
* Daily net-APY series plus its mean / stddev / min / max.
|
|
16
|
+
*
|
|
17
|
+
* The stats are the deliverable, not the series. A snapshot cannot distinguish a
|
|
18
|
+
* durable 6% from a 12% reward spike that ends next week; a 180-day standard
|
|
19
|
+
* deviation can. That is the signal a 6-month horizon actually needs.
|
|
20
|
+
*/
|
|
21
|
+
export async function getMarketHistory(raw) {
|
|
22
|
+
const { productId, range } = Args.parse(raw);
|
|
23
|
+
const data = await gql(MARKET_HISTORY, { productId, range });
|
|
24
|
+
const items = data.supplyApyDaily.items;
|
|
25
|
+
// Non-finite APYs (Postgres double precision can hold NaN) are dropped, not
|
|
26
|
+
// returned — a NaN in a series poisons every stat computed from it.
|
|
27
|
+
const netStats = stats(items.map((d) => d.apy.net));
|
|
28
|
+
if (!netStats) {
|
|
29
|
+
return {
|
|
30
|
+
productId,
|
|
31
|
+
range,
|
|
32
|
+
observations: 0,
|
|
33
|
+
hint: 'No usable daily history for this product in that range. It may be ' +
|
|
34
|
+
'newly tracked — try a shorter range.',
|
|
35
|
+
disclaimer: NOT_ADVICE,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
productId,
|
|
40
|
+
range,
|
|
41
|
+
observations: netStats.count,
|
|
42
|
+
netApy: {
|
|
43
|
+
mean: netStats.mean,
|
|
44
|
+
stddev: netStats.stddev,
|
|
45
|
+
min: netStats.min,
|
|
46
|
+
max: netStats.max,
|
|
47
|
+
},
|
|
48
|
+
interpretation: netStats.mean > 0 && netStats.stddev / Math.abs(netStats.mean) > 0.5
|
|
49
|
+
? 'Volatile: the spread is large relative to the mean, so the current ' +
|
|
50
|
+
'APY is a poor predictor of what you would actually earn.'
|
|
51
|
+
: 'Relatively stable over this window.',
|
|
52
|
+
series: items.map((d) => ({
|
|
53
|
+
date: d.date,
|
|
54
|
+
net: Number.isFinite(d.apy.net) ? d.apy.net : null,
|
|
55
|
+
})),
|
|
56
|
+
disclaimer: NOT_ADVICE,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const listMarketUniverseArgs: {
|
|
3
|
+
kind: z.ZodDefault<z.ZodEnum<{
|
|
4
|
+
supply: "supply";
|
|
5
|
+
borrow: "borrow";
|
|
6
|
+
}>>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The discovery call. Every other tool takes an asset / chain / protocol filter,
|
|
10
|
+
* and an agent that guesses those gets an empty result and no idea why. This
|
|
11
|
+
* returns the values that actually exist, with counts, so it never has to guess.
|
|
12
|
+
*/
|
|
13
|
+
export declare function listMarketUniverse(raw: unknown): Promise<{
|
|
14
|
+
kind: "supply" | "borrow";
|
|
15
|
+
totalProducts: number;
|
|
16
|
+
assets: {
|
|
17
|
+
symbol: string;
|
|
18
|
+
markets: number;
|
|
19
|
+
}[];
|
|
20
|
+
chains: {
|
|
21
|
+
chainId: number;
|
|
22
|
+
name: string;
|
|
23
|
+
markets: number;
|
|
24
|
+
}[];
|
|
25
|
+
protocols: {
|
|
26
|
+
name: string;
|
|
27
|
+
markets: number;
|
|
28
|
+
}[];
|
|
29
|
+
usage: string;
|
|
30
|
+
disclaimer: string;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { NOT_ADVICE } from '../config.js';
|
|
3
|
+
import { gql } from '../graphql/client.js';
|
|
4
|
+
import { PRODUCT_FACETS } from '../graphql/queries.js';
|
|
5
|
+
export const listMarketUniverseArgs = {
|
|
6
|
+
kind: z
|
|
7
|
+
.enum(['supply', 'borrow'])
|
|
8
|
+
.default('supply')
|
|
9
|
+
.describe('Which side of the market to enumerate.'),
|
|
10
|
+
};
|
|
11
|
+
const Args = z.object(listMarketUniverseArgs);
|
|
12
|
+
/**
|
|
13
|
+
* The discovery call. Every other tool takes an asset / chain / protocol filter,
|
|
14
|
+
* and an agent that guesses those gets an empty result and no idea why. This
|
|
15
|
+
* returns the values that actually exist, with counts, so it never has to guess.
|
|
16
|
+
*/
|
|
17
|
+
export async function listMarketUniverse(raw) {
|
|
18
|
+
const { kind } = Args.parse(raw);
|
|
19
|
+
const data = await gql(PRODUCT_FACETS, {
|
|
20
|
+
filters: { kind, active: true },
|
|
21
|
+
});
|
|
22
|
+
const { assets, chains, protocols } = data.productFacets;
|
|
23
|
+
return {
|
|
24
|
+
kind,
|
|
25
|
+
totalProducts: protocols.reduce((a, p) => a + p.count, 0),
|
|
26
|
+
assets: assets.map((a) => ({ symbol: a.symbol, markets: a.count })),
|
|
27
|
+
chains: chains.map((c) => ({ chainId: c.id, name: c.name, markets: c.count })),
|
|
28
|
+
protocols: protocols.map((p) => ({ name: p.name, markets: p.count })),
|
|
29
|
+
usage: 'Use these exact values as the asset / chainId / protocol filters for ' +
|
|
30
|
+
'find_best_markets. Any other value will match nothing.',
|
|
31
|
+
disclaimer: NOT_ADVICE,
|
|
32
|
+
};
|
|
33
|
+
}
|