@guiie/buda-mcp 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/PUBLISH_CHECKLIST.md +71 -63
- package/README.md +4 -4
- package/dist/http.js +39 -0
- package/dist/index.js +29 -0
- package/dist/tools/arbitrage.d.ts +35 -0
- package/dist/tools/arbitrage.d.ts.map +1 -0
- package/dist/tools/arbitrage.js +142 -0
- package/dist/tools/balances.d.ts.map +1 -1
- package/dist/tools/balances.js +24 -4
- package/dist/tools/calculate_position_size.d.ts +48 -0
- package/dist/tools/calculate_position_size.d.ts.map +1 -0
- package/dist/tools/calculate_position_size.js +111 -0
- package/dist/tools/compare_markets.d.ts.map +1 -1
- package/dist/tools/compare_markets.js +11 -10
- package/dist/tools/dead_mans_switch.d.ts +84 -0
- package/dist/tools/dead_mans_switch.d.ts.map +1 -0
- package/dist/tools/dead_mans_switch.js +236 -0
- package/dist/tools/market_sentiment.d.ts +30 -0
- package/dist/tools/market_sentiment.d.ts.map +1 -0
- package/dist/tools/market_sentiment.js +104 -0
- package/dist/tools/market_summary.d.ts +43 -0
- package/dist/tools/market_summary.d.ts.map +1 -0
- package/dist/tools/market_summary.js +81 -0
- package/dist/tools/markets.d.ts.map +1 -1
- package/dist/tools/markets.js +4 -2
- package/dist/tools/orderbook.d.ts.map +1 -1
- package/dist/tools/orderbook.js +14 -4
- package/dist/tools/orders.d.ts.map +1 -1
- package/dist/tools/orders.js +41 -3
- package/dist/tools/price_history.d.ts.map +1 -1
- package/dist/tools/price_history.js +5 -43
- package/dist/tools/simulate_order.d.ts +45 -0
- package/dist/tools/simulate_order.d.ts.map +1 -0
- package/dist/tools/simulate_order.js +139 -0
- package/dist/tools/spread.d.ts.map +1 -1
- package/dist/tools/spread.js +10 -8
- package/dist/tools/technical_indicators.d.ts +39 -0
- package/dist/tools/technical_indicators.d.ts.map +1 -0
- package/dist/tools/technical_indicators.js +223 -0
- package/dist/tools/ticker.d.ts.map +1 -1
- package/dist/tools/ticker.js +24 -3
- package/dist/tools/trades.d.ts.map +1 -1
- package/dist/tools/trades.js +17 -3
- package/dist/tools/volume.d.ts.map +1 -1
- package/dist/tools/volume.js +21 -3
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +23 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +68 -0
- package/marketplace/README.md +1 -1
- package/marketplace/claude-listing.md +60 -14
- package/marketplace/gemini-tools.json +183 -9
- package/marketplace/openapi.yaml +335 -119
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/http.ts +44 -0
- package/src/index.ts +34 -0
- package/src/tools/arbitrage.ts +202 -0
- package/src/tools/balances.ts +27 -4
- package/src/tools/calculate_position_size.ts +141 -0
- package/src/tools/compare_markets.ts +11 -10
- package/src/tools/dead_mans_switch.ts +314 -0
- package/src/tools/market_sentiment.ts +141 -0
- package/src/tools/market_summary.ts +124 -0
- package/src/tools/markets.ts +4 -2
- package/src/tools/orderbook.ts +15 -4
- package/src/tools/orders.ts +45 -4
- package/src/tools/price_history.ts +5 -57
- package/src/tools/simulate_order.ts +182 -0
- package/src/tools/spread.ts +10 -8
- package/src/tools/technical_indicators.ts +282 -0
- package/src/tools/ticker.ts +27 -3
- package/src/tools/trades.ts +18 -3
- package/src/tools/volume.ts +24 -3
- package/src/types.ts +12 -0
- package/src/utils.ts +73 -0
- package/test/unit.ts +758 -0
package/src/tools/orders.ts
CHANGED
|
@@ -2,13 +2,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { BudaClient, BudaApiError } from "../client.js";
|
|
4
4
|
import { validateMarketId } from "../validation.js";
|
|
5
|
-
import
|
|
5
|
+
import { flattenAmount } from "../utils.js";
|
|
6
|
+
import type { OrdersResponse, Amount } from "../types.js";
|
|
6
7
|
|
|
7
8
|
export const toolSchema = {
|
|
8
9
|
name: "get_orders",
|
|
9
10
|
description:
|
|
10
|
-
"
|
|
11
|
-
"
|
|
11
|
+
"Returns orders for a given Buda.com market as flat typed objects. All monetary amounts are floats " +
|
|
12
|
+
"with separate _currency fields (e.g. amount + amount_currency). Filterable by state: pending, " +
|
|
13
|
+
"active, traded, canceled. Supports pagination via per and page. " +
|
|
14
|
+
"Requires BUDA_API_KEY and BUDA_API_SECRET. " +
|
|
15
|
+
"Example: 'Show my open limit orders on BTC-CLP.'",
|
|
12
16
|
inputSchema: {
|
|
13
17
|
type: "object" as const,
|
|
14
18
|
properties: {
|
|
@@ -34,6 +38,10 @@ export const toolSchema = {
|
|
|
34
38
|
},
|
|
35
39
|
};
|
|
36
40
|
|
|
41
|
+
function flattenAmountField(amount: Amount): { value: number; currency: string } {
|
|
42
|
+
return flattenAmount(amount);
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
export function register(server: McpServer, client: BudaClient): void {
|
|
38
46
|
server.tool(
|
|
39
47
|
toolSchema.name,
|
|
@@ -83,11 +91,44 @@ export function register(server: McpServer, client: BudaClient): void {
|
|
|
83
91
|
Object.keys(params).length > 0 ? params : undefined,
|
|
84
92
|
);
|
|
85
93
|
|
|
94
|
+
const orders = data.orders.map((o) => {
|
|
95
|
+
const amount = flattenAmountField(o.amount);
|
|
96
|
+
const originalAmount = flattenAmountField(o.original_amount);
|
|
97
|
+
const tradedAmount = flattenAmountField(o.traded_amount);
|
|
98
|
+
const totalExchanged = flattenAmountField(o.total_exchanged);
|
|
99
|
+
const paidFee = flattenAmountField(o.paid_fee);
|
|
100
|
+
const limitPrice = o.limit ? flattenAmountField(o.limit) : null;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: o.id,
|
|
104
|
+
type: o.type,
|
|
105
|
+
state: o.state,
|
|
106
|
+
created_at: o.created_at,
|
|
107
|
+
market_id: o.market_id,
|
|
108
|
+
fee_currency: o.fee_currency,
|
|
109
|
+
price_type: o.price_type,
|
|
110
|
+
order_type: o.order_type,
|
|
111
|
+
client_id: o.client_id,
|
|
112
|
+
limit_price: limitPrice ? limitPrice.value : null,
|
|
113
|
+
limit_price_currency: limitPrice ? limitPrice.currency : null,
|
|
114
|
+
amount: amount.value,
|
|
115
|
+
amount_currency: amount.currency,
|
|
116
|
+
original_amount: originalAmount.value,
|
|
117
|
+
original_amount_currency: originalAmount.currency,
|
|
118
|
+
traded_amount: tradedAmount.value,
|
|
119
|
+
traded_amount_currency: tradedAmount.currency,
|
|
120
|
+
total_exchanged: totalExchanged.value,
|
|
121
|
+
total_exchanged_currency: totalExchanged.currency,
|
|
122
|
+
paid_fee: paidFee.value,
|
|
123
|
+
paid_fee_currency: paidFee.currency,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
86
127
|
return {
|
|
87
128
|
content: [
|
|
88
129
|
{
|
|
89
130
|
type: "text",
|
|
90
|
-
text: JSON.stringify({ orders
|
|
131
|
+
text: JSON.stringify({ orders, meta: data.meta }, null, 2),
|
|
91
132
|
},
|
|
92
133
|
],
|
|
93
134
|
};
|
|
@@ -3,32 +3,17 @@ import { z } from "zod";
|
|
|
3
3
|
import { BudaClient, BudaApiError } from "../client.js";
|
|
4
4
|
import { MemoryCache } from "../cache.js";
|
|
5
5
|
import { validateMarketId } from "../validation.js";
|
|
6
|
+
import { aggregateTradesToCandles } from "../utils.js";
|
|
6
7
|
import type { TradesResponse } from "../types.js";
|
|
7
8
|
|
|
8
|
-
const PERIOD_MS: Record<string, number> = {
|
|
9
|
-
"1h": 60 * 60 * 1000,
|
|
10
|
-
"4h": 4 * 60 * 60 * 1000,
|
|
11
|
-
"1d": 24 * 60 * 60 * 1000,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
interface OhlcvCandle {
|
|
15
|
-
time: string;
|
|
16
|
-
open: string;
|
|
17
|
-
high: string;
|
|
18
|
-
low: string;
|
|
19
|
-
close: string;
|
|
20
|
-
volume: string;
|
|
21
|
-
trade_count: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
9
|
export const toolSchema = {
|
|
25
10
|
name: "get_price_history",
|
|
26
11
|
description:
|
|
27
12
|
"IMPORTANT: Candles are aggregated client-side from raw trades (Buda has no native candlestick " +
|
|
28
13
|
"endpoint) — fetching more trades via the 'limit' parameter gives deeper history but slower " +
|
|
29
|
-
"responses. Returns OHLCV (open/high/low/close
|
|
30
|
-
"Candle timestamps are UTC bucket boundaries
|
|
31
|
-
"
|
|
14
|
+
"responses. Returns OHLCV candles (open/high/low/close as floats in quote currency; volume as float " +
|
|
15
|
+
"in base currency) for periods 1h, 4h, or 1d. Candle timestamps are UTC bucket boundaries. " +
|
|
16
|
+
"Example: 'Show me the hourly BTC-CLP price chart for the past 24 hours.'",
|
|
32
17
|
inputSchema: {
|
|
33
18
|
type: "object" as const,
|
|
34
19
|
properties: {
|
|
@@ -104,44 +89,7 @@ export function register(server: McpServer, client: BudaClient, _cache: MemoryCa
|
|
|
104
89
|
};
|
|
105
90
|
}
|
|
106
91
|
|
|
107
|
-
|
|
108
|
-
// and close = last chronological price within each candle bucket.
|
|
109
|
-
const sortedEntries = [...entries].sort(
|
|
110
|
-
([a], [b]) => parseInt(a, 10) - parseInt(b, 10),
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
const periodMs = PERIOD_MS[period];
|
|
114
|
-
const buckets = new Map<number, OhlcvCandle>();
|
|
115
|
-
|
|
116
|
-
for (const [tsMs, amount, price, _direction] of sortedEntries) {
|
|
117
|
-
const ts = parseInt(tsMs, 10);
|
|
118
|
-
const bucketStart = Math.floor(ts / periodMs) * periodMs;
|
|
119
|
-
const p = parseFloat(price);
|
|
120
|
-
const v = parseFloat(amount);
|
|
121
|
-
|
|
122
|
-
if (!buckets.has(bucketStart)) {
|
|
123
|
-
buckets.set(bucketStart, {
|
|
124
|
-
time: new Date(bucketStart).toISOString(),
|
|
125
|
-
open: price,
|
|
126
|
-
high: price,
|
|
127
|
-
low: price,
|
|
128
|
-
close: price,
|
|
129
|
-
volume: amount,
|
|
130
|
-
trade_count: 1,
|
|
131
|
-
});
|
|
132
|
-
} else {
|
|
133
|
-
const candle = buckets.get(bucketStart)!;
|
|
134
|
-
if (p > parseFloat(candle.high)) candle.high = price;
|
|
135
|
-
if (p < parseFloat(candle.low)) candle.low = price;
|
|
136
|
-
candle.close = price;
|
|
137
|
-
candle.volume = (parseFloat(candle.volume) + v).toFixed(8);
|
|
138
|
-
candle.trade_count++;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const candles = Array.from(buckets.entries())
|
|
143
|
-
.sort(([a], [b]) => a - b)
|
|
144
|
-
.map(([, candle]) => candle);
|
|
92
|
+
const candles = aggregateTradesToCandles(entries, period);
|
|
145
93
|
|
|
146
94
|
const result = {
|
|
147
95
|
market_id: market_id.toUpperCase(),
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BudaClient, BudaApiError } from "../client.js";
|
|
4
|
+
import { MemoryCache, CACHE_TTL } from "../cache.js";
|
|
5
|
+
import { validateMarketId } from "../validation.js";
|
|
6
|
+
import type { TickerResponse, MarketResponse } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export const toolSchema = {
|
|
9
|
+
name: "simulate_order",
|
|
10
|
+
description:
|
|
11
|
+
"Simulates a buy or sell order on Buda.com using live ticker data — no order is placed. " +
|
|
12
|
+
"Returns estimated fill price, fee, total cost, and slippage vs mid-price. " +
|
|
13
|
+
"Omit 'price' for a market order simulation; supply 'price' for a limit order simulation. " +
|
|
14
|
+
"All outputs are labelled simulation: true — this tool never places a real order. " +
|
|
15
|
+
"Example: 'How much would it cost to buy 0.01 BTC on BTC-CLP right now?'",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object" as const,
|
|
18
|
+
properties: {
|
|
19
|
+
market_id: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Market ID (e.g. 'BTC-CLP', 'ETH-BTC').",
|
|
22
|
+
},
|
|
23
|
+
side: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "'buy' or 'sell'.",
|
|
26
|
+
},
|
|
27
|
+
amount: {
|
|
28
|
+
type: "number",
|
|
29
|
+
description: "Order size in base currency (e.g. BTC for BTC-CLP).",
|
|
30
|
+
},
|
|
31
|
+
price: {
|
|
32
|
+
type: "number",
|
|
33
|
+
description:
|
|
34
|
+
"Limit price in quote currency. Omit for a market order simulation.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ["market_id", "side", "amount"],
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type SimulateOrderArgs = {
|
|
42
|
+
market_id: string;
|
|
43
|
+
side: "buy" | "sell";
|
|
44
|
+
amount: number;
|
|
45
|
+
price?: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export async function handleSimulateOrder(
|
|
49
|
+
args: SimulateOrderArgs,
|
|
50
|
+
client: BudaClient,
|
|
51
|
+
cache: MemoryCache,
|
|
52
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
|
|
53
|
+
const { market_id, side, amount, price } = args;
|
|
54
|
+
|
|
55
|
+
const validationError = validateMarketId(market_id);
|
|
56
|
+
if (validationError) {
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: JSON.stringify({ error: validationError, code: "INVALID_MARKET_ID" }) }],
|
|
59
|
+
isError: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const id = market_id.toLowerCase();
|
|
65
|
+
|
|
66
|
+
const [tickerData, marketData] = await Promise.all([
|
|
67
|
+
cache.getOrFetch<TickerResponse>(
|
|
68
|
+
`ticker:${id}`,
|
|
69
|
+
CACHE_TTL.TICKER,
|
|
70
|
+
() => client.get<TickerResponse>(`/markets/${id}/ticker`),
|
|
71
|
+
),
|
|
72
|
+
cache.getOrFetch<MarketResponse>(
|
|
73
|
+
`market:${id}`,
|
|
74
|
+
CACHE_TTL.MARKETS,
|
|
75
|
+
() => client.get<MarketResponse>(`/markets/${id}`),
|
|
76
|
+
),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const ticker = tickerData.ticker;
|
|
80
|
+
const market = marketData.market;
|
|
81
|
+
|
|
82
|
+
const minAsk = parseFloat(ticker.min_ask[0]);
|
|
83
|
+
const maxBid = parseFloat(ticker.max_bid[0]);
|
|
84
|
+
const quoteCurrency = ticker.min_ask[1];
|
|
85
|
+
|
|
86
|
+
if (isNaN(minAsk) || isNaN(maxBid) || minAsk <= 0 || maxBid <= 0) {
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: JSON.stringify({
|
|
92
|
+
error: "Unable to simulate: invalid or zero bid/ask values in ticker.",
|
|
93
|
+
code: "INVALID_TICKER",
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
isError: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const mid = (minAsk + maxBid) / 2;
|
|
102
|
+
const takerFeeRate = parseFloat(market.taker_fee);
|
|
103
|
+
const orderTypeAssumed = price !== undefined ? "limit" : "market";
|
|
104
|
+
|
|
105
|
+
let estimatedFillPrice: number;
|
|
106
|
+
|
|
107
|
+
if (orderTypeAssumed === "market") {
|
|
108
|
+
estimatedFillPrice = side === "buy" ? minAsk : maxBid;
|
|
109
|
+
} else {
|
|
110
|
+
// Limit order: fill at provided price if it crosses the spread, otherwise at limit price
|
|
111
|
+
if (side === "buy") {
|
|
112
|
+
estimatedFillPrice = price! >= minAsk ? minAsk : price!;
|
|
113
|
+
} else {
|
|
114
|
+
estimatedFillPrice = price! <= maxBid ? maxBid : price!;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const grossValue = amount * estimatedFillPrice;
|
|
119
|
+
const feeAmount = parseFloat((grossValue * takerFeeRate).toFixed(8));
|
|
120
|
+
const totalCost = side === "buy"
|
|
121
|
+
? parseFloat((grossValue + feeAmount).toFixed(8))
|
|
122
|
+
: parseFloat((grossValue - feeAmount).toFixed(8));
|
|
123
|
+
|
|
124
|
+
const slippageVsMidPct = parseFloat(
|
|
125
|
+
(((estimatedFillPrice - mid) / mid) * 100).toFixed(4),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const result = {
|
|
129
|
+
simulation: true,
|
|
130
|
+
market_id: ticker.market_id,
|
|
131
|
+
side,
|
|
132
|
+
amount,
|
|
133
|
+
order_type_assumed: orderTypeAssumed,
|
|
134
|
+
estimated_fill_price: parseFloat(estimatedFillPrice.toFixed(2)),
|
|
135
|
+
price_currency: quoteCurrency,
|
|
136
|
+
fee_amount: feeAmount,
|
|
137
|
+
fee_currency: quoteCurrency,
|
|
138
|
+
fee_rate_pct: parseFloat((takerFeeRate * 100).toFixed(3)),
|
|
139
|
+
total_cost: totalCost,
|
|
140
|
+
slippage_vs_mid_pct: slippageVsMidPct,
|
|
141
|
+
mid_price: parseFloat(mid.toFixed(2)),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
146
|
+
};
|
|
147
|
+
} catch (err) {
|
|
148
|
+
const msg =
|
|
149
|
+
err instanceof BudaApiError
|
|
150
|
+
? { error: err.message, code: err.status, path: err.path }
|
|
151
|
+
: { error: String(err), code: "UNKNOWN" };
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: JSON.stringify(msg) }],
|
|
154
|
+
isError: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function register(server: McpServer, client: BudaClient, cache: MemoryCache): void {
|
|
160
|
+
server.tool(
|
|
161
|
+
toolSchema.name,
|
|
162
|
+
toolSchema.description,
|
|
163
|
+
{
|
|
164
|
+
market_id: z
|
|
165
|
+
.string()
|
|
166
|
+
.describe("Market ID (e.g. 'BTC-CLP', 'ETH-BTC')."),
|
|
167
|
+
side: z
|
|
168
|
+
.enum(["buy", "sell"])
|
|
169
|
+
.describe("'buy' or 'sell'."),
|
|
170
|
+
amount: z
|
|
171
|
+
.number()
|
|
172
|
+
.positive()
|
|
173
|
+
.describe("Order size in base currency (e.g. BTC for BTC-CLP)."),
|
|
174
|
+
price: z
|
|
175
|
+
.number()
|
|
176
|
+
.positive()
|
|
177
|
+
.optional()
|
|
178
|
+
.describe("Limit price in quote currency. Omit for a market order simulation."),
|
|
179
|
+
},
|
|
180
|
+
(args) => handleSimulateOrder(args, client, cache),
|
|
181
|
+
);
|
|
182
|
+
}
|
package/src/tools/spread.ts
CHANGED
|
@@ -8,8 +8,10 @@ import type { TickerResponse } from "../types.js";
|
|
|
8
8
|
export const toolSchema = {
|
|
9
9
|
name: "get_spread",
|
|
10
10
|
description:
|
|
11
|
-
"
|
|
12
|
-
"
|
|
11
|
+
"Returns the best bid, best ask, absolute spread, and spread percentage for a Buda.com market. " +
|
|
12
|
+
"All prices are floats in the quote currency (e.g. CLP). spread_percentage is a float in percent " +
|
|
13
|
+
"(e.g. 0.15 means 0.15%). Use this to evaluate liquidity before placing a large order. " +
|
|
14
|
+
"Example: 'Is BTC-CLP liquid enough to buy 10M CLP without significant slippage?'",
|
|
13
15
|
inputSchema: {
|
|
14
16
|
type: "object" as const,
|
|
15
17
|
properties: {
|
|
@@ -70,12 +72,12 @@ export function register(server: McpServer, client: BudaClient, cache: MemoryCac
|
|
|
70
72
|
|
|
71
73
|
const result = {
|
|
72
74
|
market_id: ticker.market_id,
|
|
73
|
-
currency,
|
|
74
|
-
best_bid: bid
|
|
75
|
-
best_ask: ask
|
|
76
|
-
spread_absolute: spreadAbs.toFixed(2),
|
|
77
|
-
spread_percentage: spreadPct.toFixed(4)
|
|
78
|
-
last_price: ticker.last_price[0],
|
|
75
|
+
price_currency: currency,
|
|
76
|
+
best_bid: bid,
|
|
77
|
+
best_ask: ask,
|
|
78
|
+
spread_absolute: parseFloat(spreadAbs.toFixed(2)),
|
|
79
|
+
spread_percentage: parseFloat(spreadPct.toFixed(4)),
|
|
80
|
+
last_price: parseFloat(ticker.last_price[0]),
|
|
79
81
|
};
|
|
80
82
|
|
|
81
83
|
return {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BudaClient, BudaApiError } from "../client.js";
|
|
4
|
+
import { validateMarketId } from "../validation.js";
|
|
5
|
+
import { aggregateTradesToCandles } from "../utils.js";
|
|
6
|
+
import type { TradesResponse } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export const toolSchema = {
|
|
9
|
+
name: "get_technical_indicators",
|
|
10
|
+
description:
|
|
11
|
+
"Computes RSI (14), MACD (12/26/9), Bollinger Bands (20, 2σ), SMA 20, and SMA 50 " +
|
|
12
|
+
"from Buda trade history — no external data or libraries. " +
|
|
13
|
+
"Uses at least 500 trades for reliable results (set limit=1000 for maximum depth). " +
|
|
14
|
+
"Returns latest indicator values and signal interpretations (overbought/oversold, crossover, band position). " +
|
|
15
|
+
"If fewer than 50 candles are available after aggregation, returns a structured warning instead. " +
|
|
16
|
+
"Example: 'Is BTC-CLP RSI overbought on the 4-hour chart?'",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object" as const,
|
|
19
|
+
properties: {
|
|
20
|
+
market_id: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Market ID (e.g. 'BTC-CLP', 'ETH-BTC').",
|
|
23
|
+
},
|
|
24
|
+
period: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Candle period: '1h', '4h', or '1d'. Default: '1h'.",
|
|
27
|
+
},
|
|
28
|
+
limit: {
|
|
29
|
+
type: "number",
|
|
30
|
+
description:
|
|
31
|
+
"Number of raw trades to fetch (default: 500, max: 1000). " +
|
|
32
|
+
"More trades = more candles = more reliable indicators.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ["market_id"],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ---- Pure math helpers ----
|
|
40
|
+
|
|
41
|
+
function sma(values: number[], n: number): number {
|
|
42
|
+
const slice = values.slice(-n);
|
|
43
|
+
return slice.reduce((sum, v) => sum + v, 0) / slice.length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ema(values: number[], period: number): number[] {
|
|
47
|
+
if (values.length < period) return [];
|
|
48
|
+
const k = 2 / (period + 1);
|
|
49
|
+
const result: number[] = [];
|
|
50
|
+
// Seed with SMA of first `period` values
|
|
51
|
+
result.push(values.slice(0, period).reduce((s, v) => s + v, 0) / period);
|
|
52
|
+
for (let i = period; i < values.length; i++) {
|
|
53
|
+
result.push(values[i] * k + result[result.length - 1] * (1 - k));
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rsi(closes: number[], period: number = 14): number | null {
|
|
59
|
+
if (closes.length < period + 1) return null;
|
|
60
|
+
const gains: number[] = [];
|
|
61
|
+
const losses: number[] = [];
|
|
62
|
+
for (let i = 1; i < closes.length; i++) {
|
|
63
|
+
const diff = closes[i] - closes[i - 1];
|
|
64
|
+
gains.push(diff > 0 ? diff : 0);
|
|
65
|
+
losses.push(diff < 0 ? -diff : 0);
|
|
66
|
+
}
|
|
67
|
+
// Use simple average for initial, then Wilder's smoothing
|
|
68
|
+
let avgGain = gains.slice(0, period).reduce((s, v) => s + v, 0) / period;
|
|
69
|
+
let avgLoss = losses.slice(0, period).reduce((s, v) => s + v, 0) / period;
|
|
70
|
+
for (let i = period; i < gains.length; i++) {
|
|
71
|
+
avgGain = (avgGain * (period - 1) + gains[i]) / period;
|
|
72
|
+
avgLoss = (avgLoss * (period - 1) + losses[i]) / period;
|
|
73
|
+
}
|
|
74
|
+
if (avgLoss === 0) return 100;
|
|
75
|
+
const rs = avgGain / avgLoss;
|
|
76
|
+
return parseFloat((100 - 100 / (1 + rs)).toFixed(2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface MacdResult {
|
|
80
|
+
line: number;
|
|
81
|
+
signal: number;
|
|
82
|
+
histogram: number;
|
|
83
|
+
prev_histogram: number | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function macd(
|
|
87
|
+
closes: number[],
|
|
88
|
+
fast: number = 12,
|
|
89
|
+
slow: number = 26,
|
|
90
|
+
signalPeriod: number = 9,
|
|
91
|
+
): MacdResult | null {
|
|
92
|
+
if (closes.length < slow + signalPeriod) return null;
|
|
93
|
+
const ema12 = ema(closes, fast);
|
|
94
|
+
const ema26 = ema(closes, slow);
|
|
95
|
+
// Align: ema26 starts at index (slow-1) of closes; ema12 starts at index (fast-1)
|
|
96
|
+
// The MACD line length = ema26.length (shorter)
|
|
97
|
+
const offset = slow - fast;
|
|
98
|
+
const macdLine: number[] = ema26.map((e26, i) => ema12[i + offset] - e26);
|
|
99
|
+
const signalLine = ema(macdLine, signalPeriod);
|
|
100
|
+
if (signalLine.length === 0) return null;
|
|
101
|
+
const lastMacd = macdLine[macdLine.length - 1];
|
|
102
|
+
const lastSignal = signalLine[signalLine.length - 1];
|
|
103
|
+
const histogram = lastMacd - lastSignal;
|
|
104
|
+
const prevHistogram =
|
|
105
|
+
macdLine.length > 1 && signalLine.length > 1
|
|
106
|
+
? macdLine[macdLine.length - 2] - signalLine[signalLine.length - 2]
|
|
107
|
+
: null;
|
|
108
|
+
return {
|
|
109
|
+
line: parseFloat(lastMacd.toFixed(2)),
|
|
110
|
+
signal: parseFloat(lastSignal.toFixed(2)),
|
|
111
|
+
histogram: parseFloat(histogram.toFixed(2)),
|
|
112
|
+
prev_histogram: prevHistogram !== null ? parseFloat(prevHistogram.toFixed(2)) : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface BollingerResult {
|
|
117
|
+
upper: number;
|
|
118
|
+
mid: number;
|
|
119
|
+
lower: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function bollingerBands(closes: number[], period: number = 20, stdMult: number = 2): BollingerResult | null {
|
|
123
|
+
if (closes.length < period) return null;
|
|
124
|
+
const slice = closes.slice(-period);
|
|
125
|
+
const mid = slice.reduce((s, v) => s + v, 0) / period;
|
|
126
|
+
const variance = slice.reduce((s, v) => s + (v - mid) ** 2, 0) / period;
|
|
127
|
+
const std = Math.sqrt(variance);
|
|
128
|
+
return {
|
|
129
|
+
upper: parseFloat((mid + stdMult * std).toFixed(2)),
|
|
130
|
+
mid: parseFloat(mid.toFixed(2)),
|
|
131
|
+
lower: parseFloat((mid - stdMult * std).toFixed(2)),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- Tool handler ----
|
|
136
|
+
|
|
137
|
+
const MIN_CANDLES = 50;
|
|
138
|
+
|
|
139
|
+
type TechnicalIndicatorsArgs = {
|
|
140
|
+
market_id: string;
|
|
141
|
+
period: "1h" | "4h" | "1d";
|
|
142
|
+
limit?: number;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export async function handleTechnicalIndicators(
|
|
146
|
+
args: TechnicalIndicatorsArgs,
|
|
147
|
+
client: BudaClient,
|
|
148
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
|
|
149
|
+
const { market_id, period, limit } = args;
|
|
150
|
+
|
|
151
|
+
const validationError = validateMarketId(market_id);
|
|
152
|
+
if (validationError) {
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text", text: JSON.stringify({ error: validationError, code: "INVALID_MARKET_ID" }) }],
|
|
155
|
+
isError: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const id = market_id.toLowerCase();
|
|
161
|
+
const tradesLimit = Math.max(limit ?? 500, 500);
|
|
162
|
+
|
|
163
|
+
const data = await client.get<TradesResponse>(
|
|
164
|
+
`/markets/${id}/trades`,
|
|
165
|
+
{ limit: tradesLimit },
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const candles = aggregateTradesToCandles(data.trades.entries, period);
|
|
169
|
+
const closes = candles.map((c) => c.close);
|
|
170
|
+
|
|
171
|
+
if (candles.length < MIN_CANDLES) {
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text",
|
|
176
|
+
text: JSON.stringify({
|
|
177
|
+
market_id: market_id.toUpperCase(),
|
|
178
|
+
period,
|
|
179
|
+
indicators: null,
|
|
180
|
+
warning: "insufficient_data",
|
|
181
|
+
candles_available: candles.length,
|
|
182
|
+
minimum_required: MIN_CANDLES,
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rsiValue = rsi(closes, 14);
|
|
190
|
+
const macdResult = macd(closes, 12, 26, 9);
|
|
191
|
+
const bbResult = bollingerBands(closes, 20, 2);
|
|
192
|
+
const sma20 = parseFloat(sma(closes, 20).toFixed(2));
|
|
193
|
+
const sma50 = parseFloat(sma(closes, 50).toFixed(2));
|
|
194
|
+
const lastClose = closes[closes.length - 1];
|
|
195
|
+
|
|
196
|
+
// Signal interpretations
|
|
197
|
+
const rsiSignal: "overbought" | "oversold" | "neutral" =
|
|
198
|
+
rsiValue !== null && rsiValue > 70
|
|
199
|
+
? "overbought"
|
|
200
|
+
: rsiValue !== null && rsiValue < 30
|
|
201
|
+
? "oversold"
|
|
202
|
+
: "neutral";
|
|
203
|
+
|
|
204
|
+
let macdSignal: "bullish_crossover" | "bearish_crossover" | "neutral" = "neutral";
|
|
205
|
+
if (macdResult && macdResult.prev_histogram !== null) {
|
|
206
|
+
if (macdResult.histogram > 0 && macdResult.prev_histogram <= 0) {
|
|
207
|
+
macdSignal = "bullish_crossover";
|
|
208
|
+
} else if (macdResult.histogram < 0 && macdResult.prev_histogram >= 0) {
|
|
209
|
+
macdSignal = "bearish_crossover";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const bbSignal: "above_upper" | "below_lower" | "within_bands" =
|
|
214
|
+
bbResult && lastClose > bbResult.upper
|
|
215
|
+
? "above_upper"
|
|
216
|
+
: bbResult && lastClose < bbResult.lower
|
|
217
|
+
? "below_lower"
|
|
218
|
+
: "within_bands";
|
|
219
|
+
|
|
220
|
+
const result = {
|
|
221
|
+
market_id: market_id.toUpperCase(),
|
|
222
|
+
period,
|
|
223
|
+
candles_used: candles.length,
|
|
224
|
+
indicators: {
|
|
225
|
+
rsi: rsiValue,
|
|
226
|
+
macd: macdResult
|
|
227
|
+
? { line: macdResult.line, signal: macdResult.signal, histogram: macdResult.histogram }
|
|
228
|
+
: null,
|
|
229
|
+
bollinger_bands: bbResult,
|
|
230
|
+
sma_20: sma20,
|
|
231
|
+
sma_50: sma50,
|
|
232
|
+
},
|
|
233
|
+
signals: {
|
|
234
|
+
rsi_signal: rsiSignal,
|
|
235
|
+
macd_signal: macdSignal,
|
|
236
|
+
bb_signal: bbSignal,
|
|
237
|
+
},
|
|
238
|
+
disclaimer:
|
|
239
|
+
"Technical indicators are computed from Buda trade history. Not investment advice.",
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
244
|
+
};
|
|
245
|
+
} catch (err) {
|
|
246
|
+
const msg =
|
|
247
|
+
err instanceof BudaApiError
|
|
248
|
+
? { error: err.message, code: err.status, path: err.path }
|
|
249
|
+
: { error: String(err), code: "UNKNOWN" };
|
|
250
|
+
return {
|
|
251
|
+
content: [{ type: "text", text: JSON.stringify(msg) }],
|
|
252
|
+
isError: true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function register(server: McpServer, client: BudaClient): void {
|
|
258
|
+
server.tool(
|
|
259
|
+
toolSchema.name,
|
|
260
|
+
toolSchema.description,
|
|
261
|
+
{
|
|
262
|
+
market_id: z
|
|
263
|
+
.string()
|
|
264
|
+
.describe("Market ID (e.g. 'BTC-CLP', 'ETH-BTC')."),
|
|
265
|
+
period: z
|
|
266
|
+
.enum(["1h", "4h", "1d"])
|
|
267
|
+
.default("1h")
|
|
268
|
+
.describe("Candle period: '1h', '4h', or '1d'. Default: '1h'."),
|
|
269
|
+
limit: z
|
|
270
|
+
.number()
|
|
271
|
+
.int()
|
|
272
|
+
.min(500)
|
|
273
|
+
.max(1000)
|
|
274
|
+
.optional()
|
|
275
|
+
.describe(
|
|
276
|
+
"Number of raw trades to fetch (default: 500, max: 1000). " +
|
|
277
|
+
"More trades = more candles = more reliable indicators.",
|
|
278
|
+
),
|
|
279
|
+
},
|
|
280
|
+
(args) => handleTechnicalIndicators(args, client),
|
|
281
|
+
);
|
|
282
|
+
}
|