@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/PUBLISH_CHECKLIST.md +71 -63
  3. package/README.md +4 -4
  4. package/dist/http.js +39 -0
  5. package/dist/index.js +29 -0
  6. package/dist/tools/arbitrage.d.ts +35 -0
  7. package/dist/tools/arbitrage.d.ts.map +1 -0
  8. package/dist/tools/arbitrage.js +142 -0
  9. package/dist/tools/balances.d.ts.map +1 -1
  10. package/dist/tools/balances.js +24 -4
  11. package/dist/tools/calculate_position_size.d.ts +48 -0
  12. package/dist/tools/calculate_position_size.d.ts.map +1 -0
  13. package/dist/tools/calculate_position_size.js +111 -0
  14. package/dist/tools/compare_markets.d.ts.map +1 -1
  15. package/dist/tools/compare_markets.js +11 -10
  16. package/dist/tools/dead_mans_switch.d.ts +84 -0
  17. package/dist/tools/dead_mans_switch.d.ts.map +1 -0
  18. package/dist/tools/dead_mans_switch.js +236 -0
  19. package/dist/tools/market_sentiment.d.ts +30 -0
  20. package/dist/tools/market_sentiment.d.ts.map +1 -0
  21. package/dist/tools/market_sentiment.js +104 -0
  22. package/dist/tools/market_summary.d.ts +43 -0
  23. package/dist/tools/market_summary.d.ts.map +1 -0
  24. package/dist/tools/market_summary.js +81 -0
  25. package/dist/tools/markets.d.ts.map +1 -1
  26. package/dist/tools/markets.js +4 -2
  27. package/dist/tools/orderbook.d.ts.map +1 -1
  28. package/dist/tools/orderbook.js +14 -4
  29. package/dist/tools/orders.d.ts.map +1 -1
  30. package/dist/tools/orders.js +41 -3
  31. package/dist/tools/price_history.d.ts.map +1 -1
  32. package/dist/tools/price_history.js +5 -43
  33. package/dist/tools/simulate_order.d.ts +45 -0
  34. package/dist/tools/simulate_order.d.ts.map +1 -0
  35. package/dist/tools/simulate_order.js +139 -0
  36. package/dist/tools/spread.d.ts.map +1 -1
  37. package/dist/tools/spread.js +10 -8
  38. package/dist/tools/technical_indicators.d.ts +39 -0
  39. package/dist/tools/technical_indicators.d.ts.map +1 -0
  40. package/dist/tools/technical_indicators.js +223 -0
  41. package/dist/tools/ticker.d.ts.map +1 -1
  42. package/dist/tools/ticker.js +24 -3
  43. package/dist/tools/trades.d.ts.map +1 -1
  44. package/dist/tools/trades.js +17 -3
  45. package/dist/tools/volume.d.ts.map +1 -1
  46. package/dist/tools/volume.js +21 -3
  47. package/dist/types.d.ts +9 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/utils.d.ts +23 -0
  50. package/dist/utils.d.ts.map +1 -0
  51. package/dist/utils.js +68 -0
  52. package/marketplace/README.md +1 -1
  53. package/marketplace/claude-listing.md +60 -14
  54. package/marketplace/gemini-tools.json +183 -9
  55. package/marketplace/openapi.yaml +335 -119
  56. package/package.json +1 -1
  57. package/server.json +2 -2
  58. package/src/http.ts +44 -0
  59. package/src/index.ts +34 -0
  60. package/src/tools/arbitrage.ts +202 -0
  61. package/src/tools/balances.ts +27 -4
  62. package/src/tools/calculate_position_size.ts +141 -0
  63. package/src/tools/compare_markets.ts +11 -10
  64. package/src/tools/dead_mans_switch.ts +314 -0
  65. package/src/tools/market_sentiment.ts +141 -0
  66. package/src/tools/market_summary.ts +124 -0
  67. package/src/tools/markets.ts +4 -2
  68. package/src/tools/orderbook.ts +15 -4
  69. package/src/tools/orders.ts +45 -4
  70. package/src/tools/price_history.ts +5 -57
  71. package/src/tools/simulate_order.ts +182 -0
  72. package/src/tools/spread.ts +10 -8
  73. package/src/tools/technical_indicators.ts +282 -0
  74. package/src/tools/ticker.ts +27 -3
  75. package/src/tools/trades.ts +18 -3
  76. package/src/tools/volume.ts +24 -3
  77. package/src/types.ts +12 -0
  78. package/src/utils.ts +73 -0
  79. package/test/unit.ts +758 -0
@@ -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 type { OrdersResponse } from "../types.js";
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
- "Get orders for a given Buda.com market. Filter by state (pending, active, traded, canceled). " +
11
- "Requires BUDA_API_KEY and BUDA_API_SECRET environment variables.",
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: data.orders, meta: data.meta }, null, 2),
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/volume) price history for a Buda.com market. " +
30
- "Candle timestamps are UTC bucket boundaries (e.g. '2026-04-10T12:00:00.000Z' for 1h). " +
31
- "Supports 1h, 4h, and 1d candle periods.",
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
- // Buda returns trades newest-first; sort ascending so open = first chronological price
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
+ }
@@ -8,8 +8,10 @@ import type { TickerResponse } from "../types.js";
8
8
  export const toolSchema = {
9
9
  name: "get_spread",
10
10
  description:
11
- "Calculate the bid/ask spread for a Buda.com market. " +
12
- "Returns the best bid, best ask, absolute spread, and spread as a percentage of the ask price.",
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.toString(),
75
- best_ask: ask.toString(),
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
+ }