@guiie/buda-mcp 1.4.0 → 1.4.1

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 CHANGED
@@ -7,6 +7,16 @@ This project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.4.1] – 2026-04-11
11
+
12
+ ### Fixed
13
+
14
+ - **`simulate_order`**: `taker_fee` returned by Buda API is already expressed as a percentage (`0.8` = 0.8%), not a decimal. Dividing by 100 before use gives correct fee calculations. Previously this caused fee_amount and total_cost to be ~100× too large.
15
+ - Integration test (`test/run-all.ts`): added live checks for all 5 v1.4.0 tools; fixed field name `candles_available` (was `candles_used`).
16
+ - Unit test mocks: updated `taker_fee` mock values from `"0.008"`/`"0.005"` to `"0.8"`/`"0.5"` to match the real Buda API format.
17
+
18
+ ---
19
+
10
20
  ## [1.4.0] – 2026-04-11
11
21
 
12
22
  ### Added
@@ -67,7 +67,7 @@ export async function handleSimulateOrder(args, client, cache) {
67
67
  };
68
68
  }
69
69
  const mid = (minAsk + maxBid) / 2;
70
- const takerFeeRate = parseFloat(market.taker_fee);
70
+ const takerFeeRate = parseFloat(market.taker_fee) / 100;
71
71
  const orderTypeAssumed = price !== undefined ? "limit" : "market";
72
72
  let estimatedFillPrice;
73
73
  if (orderTypeAssumed === "market") {
@@ -306,6 +306,93 @@
306
306
  },
307
307
  "required": ["market_id"]
308
308
  }
309
+ },
310
+ {
311
+ "name": "get_balances",
312
+ "description": "Returns all currency balances for the authenticated Buda.com account as flat typed objects. Each currency entry includes total amount, available amount (not frozen), frozen amount, and pending withdrawal amount — all as floats with separate _currency fields. Requires BUDA_API_KEY and BUDA_API_SECRET. Auth-gated. Example: 'How much BTC do I have available to trade right now?'",
313
+ "parameters": {
314
+ "type": "OBJECT",
315
+ "properties": {},
316
+ "required": []
317
+ }
318
+ },
319
+ {
320
+ "name": "get_orders",
321
+ "description": "Returns orders for a given Buda.com market as flat typed objects. All monetary amounts are floats with separate _currency fields (e.g. amount + amount_currency). Filterable by state: pending, active, traded, canceled, canceled_and_traded. Supports pagination via per and page. Requires BUDA_API_KEY and BUDA_API_SECRET. Auth-gated. Example: 'Show my open limit orders on BTC-CLP.'",
322
+ "parameters": {
323
+ "type": "OBJECT",
324
+ "properties": {
325
+ "market_id": {
326
+ "type": "STRING",
327
+ "description": "Market identifier (e.g. 'BTC-CLP', 'ETH-BTC'). Case-insensitive."
328
+ },
329
+ "state": {
330
+ "type": "STRING",
331
+ "description": "Filter by order state: 'pending', 'active', 'traded', 'canceled', 'canceled_and_traded'. Omit to return all orders."
332
+ },
333
+ "per": {
334
+ "type": "INTEGER",
335
+ "description": "Results per page (default: 20, max: 300)."
336
+ },
337
+ "page": {
338
+ "type": "INTEGER",
339
+ "description": "Page number (default: 1)."
340
+ }
341
+ },
342
+ "required": ["market_id"]
343
+ }
344
+ },
345
+ {
346
+ "name": "place_order",
347
+ "description": "Places a limit or market order on Buda.com. IMPORTANT: Requires confirmation_token='CONFIRM' to execute — any other value rejects without placing an order, preventing accidental execution. Requires BUDA_API_KEY and BUDA_API_SECRET. Auth-gated. WARNING: Only use on locally-run instances — never on a publicly exposed server.",
348
+ "parameters": {
349
+ "type": "OBJECT",
350
+ "properties": {
351
+ "market_id": {
352
+ "type": "STRING",
353
+ "description": "Market identifier (e.g. 'BTC-CLP', 'ETH-BTC'). Case-insensitive."
354
+ },
355
+ "type": {
356
+ "type": "STRING",
357
+ "description": "Order side: 'Bid' to buy, 'Ask' to sell."
358
+ },
359
+ "price_type": {
360
+ "type": "STRING",
361
+ "description": "Order type: 'limit' places at a specific price, 'market' executes immediately at best available price."
362
+ },
363
+ "amount": {
364
+ "type": "NUMBER",
365
+ "description": "Order size in the market's base currency (e.g. BTC amount for BTC-CLP)."
366
+ },
367
+ "limit_price": {
368
+ "type": "NUMBER",
369
+ "description": "Limit price in quote currency. Required when price_type is 'limit'. For Bid orders: highest price you will pay. For Ask orders: lowest price you will accept."
370
+ },
371
+ "confirmation_token": {
372
+ "type": "STRING",
373
+ "description": "Safety confirmation. Must equal exactly 'CONFIRM' (case-sensitive) to place the order. Any other value rejects the request."
374
+ }
375
+ },
376
+ "required": ["market_id", "type", "price_type", "amount", "confirmation_token"]
377
+ }
378
+ },
379
+ {
380
+ "name": "cancel_order",
381
+ "description": "Cancels an open order by numeric ID on Buda.com. IMPORTANT: Requires confirmation_token='CONFIRM' to execute — any other value rejects without cancelling. Requires BUDA_API_KEY and BUDA_API_SECRET. Auth-gated.",
382
+ "parameters": {
383
+ "type": "OBJECT",
384
+ "properties": {
385
+ "order_id": {
386
+ "type": "INTEGER",
387
+ "description": "The numeric ID of the order to cancel."
388
+ },
389
+ "confirmation_token": {
390
+ "type": "STRING",
391
+ "description": "Safety confirmation. Must equal exactly 'CONFIRM' (case-sensitive) to cancel the order. Any other value rejects the request."
392
+ }
393
+ },
394
+ "required": ["order_id", "confirmation_token"]
395
+ }
309
396
  }
310
397
  ]
311
398
  }
@@ -14,7 +14,7 @@ info:
14
14
  stdio server. Deploy locally with mcp-proxy:
15
15
  mcp-proxy --port 8000 -- npx -y @guiie/buda-mcp
16
16
  Or point `servers[0].url` at your hosted instance.
17
- version: 1.3.0
17
+ version: 1.4.0
18
18
  contact:
19
19
  url: https://github.com/gtorreal/buda-mcp
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guiie/buda-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "mcpName": "io.github.gtorreal/buda-mcp",
5
5
  "description": "MCP server for Buda.com's public cryptocurrency exchange API (Chile, Colombia, Peru)",
6
6
  "type": "module",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/gtorreal/buda-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.4.0",
9
+ "version": "1.4.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@guiie/buda-mcp",
14
- "version": "1.4.0",
14
+ "version": "1.4.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }
@@ -99,7 +99,7 @@ export async function handleSimulateOrder(
99
99
  }
100
100
 
101
101
  const mid = (minAsk + maxBid) / 2;
102
- const takerFeeRate = parseFloat(market.taker_fee);
102
+ const takerFeeRate = parseFloat(market.taker_fee) / 100;
103
103
  const orderTypeAssumed = price !== undefined ? "limit" : "market";
104
104
 
105
105
  let estimatedFillPrice: number;
package/test/run-all.ts CHANGED
@@ -18,6 +18,12 @@ try {
18
18
  }
19
19
 
20
20
  import { BudaClient } from "../src/client.js";
21
+ import { MemoryCache } from "../src/cache.js";
22
+ import { handleSimulateOrder } from "../src/tools/simulate_order.js";
23
+ import { handleCalculatePositionSize } from "../src/tools/calculate_position_size.js";
24
+ import { handleMarketSentiment } from "../src/tools/market_sentiment.js";
25
+ import { handleTechnicalIndicators } from "../src/tools/technical_indicators.js";
26
+ import { handleScheduleCancelAll, handleDisarmCancelTimer } from "../src/tools/dead_mans_switch.js";
21
27
  import type {
22
28
  MarketsResponse,
23
29
  TickerResponse,
@@ -225,6 +231,167 @@ try {
225
231
  failures++;
226
232
  }
227
233
 
234
+ // ----------------------------------------------------------------
235
+ // 9. simulate_order
236
+ // ----------------------------------------------------------------
237
+ section(`simulate_order — ${TEST_MARKET} market buy`);
238
+ {
239
+ const cache = new MemoryCache();
240
+ try {
241
+ const result = await handleSimulateOrder(
242
+ { market_id: TEST_MARKET, side: "buy", amount: 0.001 },
243
+ client,
244
+ cache,
245
+ );
246
+ if (result.isError) throw new Error(result.content[0].text);
247
+ const parsed = JSON.parse(result.content[0].text) as {
248
+ simulation: boolean;
249
+ estimated_fill_price: number;
250
+ fee_amount: number;
251
+ fee_rate_pct: number;
252
+ total_cost: number;
253
+ slippage_vs_mid_pct: number;
254
+ order_type_assumed: string;
255
+ };
256
+ if (parsed.simulation !== true) throw new Error("simulation flag must be true");
257
+ pass("simulation: true", "✓");
258
+ pass("order_type_assumed", parsed.order_type_assumed);
259
+ pass("estimated_fill_price", `${parsed.estimated_fill_price.toLocaleString()} CLP`);
260
+ pass("fee_rate_pct", `${parsed.fee_rate_pct}%`);
261
+ pass("fee_amount", `${parsed.fee_amount.toFixed(2)} CLP`);
262
+ pass("total_cost", `${parsed.total_cost.toFixed(2)} CLP`);
263
+ pass("slippage_vs_mid_pct", `${parsed.slippage_vs_mid_pct}%`);
264
+ } catch (err) {
265
+ fail("simulate_order", err);
266
+ failures++;
267
+ }
268
+ }
269
+
270
+ // ----------------------------------------------------------------
271
+ // 10. calculate_position_size
272
+ // ----------------------------------------------------------------
273
+ section(`calculate_position_size — ${TEST_MARKET}`);
274
+ {
275
+ // Fetch live ticker to use real entry/stop prices
276
+ try {
277
+ const tickerData = await client.get<TickerResponse>(
278
+ `/markets/${TEST_MARKET.toLowerCase()}/ticker`,
279
+ );
280
+ const lastPrice = parseFloat(tickerData.ticker.last_price[0]);
281
+ const entryPrice = lastPrice;
282
+ const stopLossPrice = parseFloat((lastPrice * 0.97).toFixed(0)); // 3% below entry
283
+
284
+ const result = handleCalculatePositionSize({
285
+ market_id: TEST_MARKET,
286
+ capital: 1_000_000,
287
+ risk_pct: 2,
288
+ entry_price: entryPrice,
289
+ stop_loss_price: stopLossPrice,
290
+ });
291
+ if (result.isError) throw new Error(result.content[0].text);
292
+ const parsed = JSON.parse(result.content[0].text) as {
293
+ side: string;
294
+ units: number;
295
+ capital_at_risk: number;
296
+ position_value: number;
297
+ fee_impact: number;
298
+ fee_currency: string;
299
+ };
300
+ pass("side", parsed.side);
301
+ pass("units", `${parsed.units} BTC`);
302
+ pass("capital_at_risk", `${parsed.capital_at_risk.toLocaleString()} CLP`);
303
+ pass("position_value", `${parsed.position_value.toLocaleString()} CLP`);
304
+ pass("fee_impact", `${parsed.fee_impact.toFixed(2)} ${parsed.fee_currency}`);
305
+ } catch (err) {
306
+ fail("calculate_position_size", err);
307
+ failures++;
308
+ }
309
+ }
310
+
311
+ // ----------------------------------------------------------------
312
+ // 11. get_market_sentiment
313
+ // ----------------------------------------------------------------
314
+ section(`get_market_sentiment — ${TEST_MARKET}`);
315
+ {
316
+ const cache = new MemoryCache();
317
+ try {
318
+ const result = await handleMarketSentiment({ market_id: TEST_MARKET }, client, cache);
319
+ if (result.isError) throw new Error(result.content[0].text);
320
+ const parsed = JSON.parse(result.content[0].text) as {
321
+ score: number;
322
+ label: string;
323
+ component_breakdown: {
324
+ price_variation_24h_pct: number;
325
+ volume_ratio: number;
326
+ spread_pct: number;
327
+ };
328
+ disclaimer: string;
329
+ };
330
+ if (!["bearish", "neutral", "bullish"].includes(parsed.label)) {
331
+ throw new Error(`unexpected label: ${parsed.label}`);
332
+ }
333
+ if (typeof parsed.score !== "number" || parsed.score < -100 || parsed.score > 100) {
334
+ throw new Error(`score out of range: ${parsed.score}`);
335
+ }
336
+ pass("score", String(parsed.score));
337
+ pass("label", parsed.label);
338
+ pass("price_variation_24h_pct", `${parsed.component_breakdown.price_variation_24h_pct}%`);
339
+ pass("volume_ratio", String(parsed.component_breakdown.volume_ratio));
340
+ pass("spread_pct", `${parsed.component_breakdown.spread_pct}%`);
341
+ pass("disclaimer", parsed.disclaimer.length > 0 ? "present" : "MISSING");
342
+ } catch (err) {
343
+ fail("get_market_sentiment", err);
344
+ failures++;
345
+ }
346
+ }
347
+
348
+ // ----------------------------------------------------------------
349
+ // 12. get_technical_indicators
350
+ // ----------------------------------------------------------------
351
+ section(`get_technical_indicators — ${TEST_MARKET} (1h, limit 1000)`);
352
+ {
353
+ try {
354
+ const result = await handleTechnicalIndicators(
355
+ { market_id: TEST_MARKET, period: "1h", limit: 1000 },
356
+ client,
357
+ );
358
+ if (result.isError) throw new Error(result.content[0].text);
359
+ const parsed = JSON.parse(result.content[0].text) as {
360
+ candles_used?: number;
361
+ candles_available?: number;
362
+ warning?: string;
363
+ indicators: {
364
+ rsi: number | null;
365
+ macd: { line: number; signal: number; histogram: number } | null;
366
+ bollinger_bands: { upper: number; mid: number; lower: number } | null;
367
+ sma_20: number;
368
+ sma_50: number;
369
+ } | null;
370
+ signals: { rsi_signal: string; macd_signal: string; bb_signal: string };
371
+ disclaimer: string;
372
+ };
373
+
374
+ if (parsed.warning === "insufficient_data") {
375
+ pass("warning", `insufficient_data (${parsed.candles_available} candles available, need 50)`);
376
+ } else {
377
+ pass("candles_used", String(parsed.candles_used));
378
+ if (!parsed.indicators) throw new Error("indicators is null without a warning");
379
+ pass("rsi", String(parsed.indicators.rsi));
380
+ pass("rsi_signal", parsed.signals.rsi_signal);
381
+ pass("macd_histogram", String(parsed.indicators.macd?.histogram));
382
+ pass("macd_signal", parsed.signals.macd_signal);
383
+ pass("bb_upper", String(parsed.indicators.bollinger_bands?.upper));
384
+ pass("bb_signal", parsed.signals.bb_signal);
385
+ pass("sma_20", String(parsed.indicators.sma_20));
386
+ pass("sma_50", String(parsed.indicators.sma_50));
387
+ pass("disclaimer", parsed.disclaimer.length > 0 ? "present" : "MISSING");
388
+ }
389
+ } catch (err) {
390
+ fail("get_technical_indicators", err);
391
+ failures++;
392
+ }
393
+ }
394
+
228
395
  // ----------------------------------------------------------------
229
396
  // Auth tools: get_balances, get_orders, place_order, cancel_order
230
397
  // ----------------------------------------------------------------
@@ -263,6 +430,34 @@ if (!client.hasAuth()) {
263
430
  // cancel_order — confirmation guard test (must reject without CONFIRM)
264
431
  console.log(" Skipping: cancel_order live execution (destructive — requires confirmation_token=CONFIRM)");
265
432
  pass("cancel_order guard", "confirmation_token check enforced at tool layer (code-audited)");
433
+
434
+ // schedule_cancel_all — arm then immediately disarm (non-destructive)
435
+ try {
436
+ const armResult = await handleScheduleCancelAll(
437
+ { market_id: TEST_MARKET, ttl_seconds: 300, confirmation_token: "CONFIRM" },
438
+ client,
439
+ );
440
+ if (armResult.isError) throw new Error(armResult.content[0].text);
441
+ const armed = JSON.parse(armResult.content[0].text) as {
442
+ active: boolean;
443
+ expires_at: string;
444
+ ttl_seconds: number;
445
+ warning: string;
446
+ };
447
+ if (!armed.active) throw new Error("active should be true after CONFIRM");
448
+ pass("schedule_cancel_all active", armed.active ? "true" : "false");
449
+ pass("schedule_cancel_all expires_at", armed.expires_at);
450
+ pass("schedule_cancel_all warning", armed.warning.length > 0 ? "present" : "MISSING");
451
+
452
+ // Immediately disarm so no orders are cancelled
453
+ const disarmResult = handleDisarmCancelTimer({ market_id: TEST_MARKET });
454
+ if (disarmResult.isError) throw new Error(disarmResult.content[0].text);
455
+ const disarmed = JSON.parse(disarmResult.content[0].text) as { disarmed: boolean };
456
+ pass("disarm_cancel_timer", disarmed.disarmed ? "timer cleared ✓" : "FAILED to disarm");
457
+ } catch (err) {
458
+ fail("schedule_cancel_all / disarm_cancel_timer", err);
459
+ failures++;
460
+ }
266
461
  }
267
462
 
268
463
  // ----------------------------------------------------------------
@@ -271,6 +466,8 @@ if (!client.hasAuth()) {
271
466
  section("Summary");
272
467
  if (failures === 0) {
273
468
  console.log(" All tools returned valid data from the live Buda API.");
469
+ console.log(" Coverage: simulate_order, calculate_position_size, get_market_sentiment,");
470
+ console.log(" get_technical_indicators, schedule_cancel_all/disarm (auth-gated if credentials set).");
274
471
  } else {
275
472
  console.error(` ${failures} tool(s) failed. See errors above.`);
276
473
  process.exit(1);
package/test/unit.ts CHANGED
@@ -666,7 +666,7 @@ await test("handleMarketSummary returns correct liquidity_rating from mocked API
666
666
 
667
667
  section("i. simulate_order");
668
668
 
669
- function makeMockFetchForSimulate(takerFee = "0.008"): typeof fetch {
669
+ function makeMockFetchForSimulate(takerFee = "0.8"): typeof fetch {
670
670
  const mockTicker = {
671
671
  ticker: {
672
672
  market_id: "BTC-CLP",
@@ -720,7 +720,7 @@ await test("market buy: estimated_fill_price = min_ask, simulation: true", async
720
720
  assertEqual(parsed.simulation, true, "simulation flag must be true");
721
721
  assertEqual(parsed.estimated_fill_price, 65100000, "market buy fills at min_ask");
722
722
  assertEqual(parsed.order_type_assumed, "market", "order_type_assumed should be market");
723
- assertEqual(parsed.fee_rate_pct, 0.8, "fee rate should be 0.8% for crypto");
723
+ assertEqual(parsed.fee_rate_pct, 0.8, "fee_rate_pct should be 0.8 for crypto (0.8% taker fee)");
724
724
  } finally {
725
725
  globalThis.fetch = savedFetch;
726
726
  }
@@ -758,14 +758,14 @@ await test("limit order: order_type_assumed = 'limit'", async () => {
758
758
 
759
759
  await test("stablecoin market uses 0.5% fee", async () => {
760
760
  const savedFetch = globalThis.fetch;
761
- globalThis.fetch = makeMockFetchForSimulate("0.005");
761
+ globalThis.fetch = makeMockFetchForSimulate("0.5");
762
762
  try {
763
763
  const client = new BudaClient("https://www.buda.com/api/v2");
764
764
  const cache = new MemoryCache();
765
765
  const result = await handleSimulateOrder({ market_id: "BTC-CLP", side: "buy", amount: 1 }, client, cache);
766
766
  assert(!result.isError, "should not be an error");
767
767
  const parsed = JSON.parse(result.content[0].text) as { fee_rate_pct: number };
768
- assertEqual(parsed.fee_rate_pct, 0.5, "fee_rate_pct should be 0.5 for stablecoin");
768
+ assertEqual(parsed.fee_rate_pct, 0.5, "fee_rate_pct should be 0.5 for stablecoin (0.5% taker fee)");
769
769
  } finally {
770
770
  globalThis.fetch = savedFetch;
771
771
  }