@guiie/buda-mcp 1.3.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/PUBLISH_CHECKLIST.md +69 -70
  3. package/README.md +4 -4
  4. package/dist/http.js +17 -0
  5. package/dist/index.js +10 -0
  6. package/dist/tools/calculate_position_size.d.ts +48 -0
  7. package/dist/tools/calculate_position_size.d.ts.map +1 -0
  8. package/dist/tools/calculate_position_size.js +111 -0
  9. package/dist/tools/dead_mans_switch.d.ts +84 -0
  10. package/dist/tools/dead_mans_switch.d.ts.map +1 -0
  11. package/dist/tools/dead_mans_switch.js +236 -0
  12. package/dist/tools/market_sentiment.d.ts +30 -0
  13. package/dist/tools/market_sentiment.d.ts.map +1 -0
  14. package/dist/tools/market_sentiment.js +104 -0
  15. package/dist/tools/price_history.d.ts.map +1 -1
  16. package/dist/tools/price_history.js +2 -40
  17. package/dist/tools/simulate_order.d.ts +45 -0
  18. package/dist/tools/simulate_order.d.ts.map +1 -0
  19. package/dist/tools/simulate_order.js +139 -0
  20. package/dist/tools/technical_indicators.d.ts +39 -0
  21. package/dist/tools/technical_indicators.d.ts.map +1 -0
  22. package/dist/tools/technical_indicators.js +223 -0
  23. package/dist/types.d.ts +9 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/utils.d.ts +7 -1
  26. package/dist/utils.d.ts.map +1 -1
  27. package/dist/utils.js +47 -0
  28. package/marketplace/README.md +1 -1
  29. package/marketplace/claude-listing.md +35 -1
  30. package/marketplace/gemini-tools.json +230 -1
  31. package/marketplace/openapi.yaml +1 -1
  32. package/package.json +1 -1
  33. package/server.json +2 -2
  34. package/src/http.ts +17 -0
  35. package/src/index.ts +10 -0
  36. package/src/tools/calculate_position_size.ts +141 -0
  37. package/src/tools/dead_mans_switch.ts +314 -0
  38. package/src/tools/market_sentiment.ts +141 -0
  39. package/src/tools/price_history.ts +2 -54
  40. package/src/tools/simulate_order.ts +182 -0
  41. package/src/tools/technical_indicators.ts +282 -0
  42. package/src/types.ts +12 -0
  43. package/src/utils.ts +53 -1
  44. package/test/run-all.ts +197 -0
  45. package/test/unit.ts +505 -1
@@ -3,24 +3,9 @@ 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: number;
17
- high: number;
18
- low: number;
19
- close: number;
20
- volume: number;
21
- trade_count: number;
22
- }
23
-
24
9
  export const toolSchema = {
25
10
  name: "get_price_history",
26
11
  description:
@@ -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: p,
126
- high: p,
127
- low: p,
128
- close: p,
129
- volume: v,
130
- trade_count: 1,
131
- });
132
- } else {
133
- const candle = buckets.get(bucketStart)!;
134
- if (p > candle.high) candle.high = p;
135
- if (p < candle.low) candle.low = p;
136
- candle.close = p;
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) / 100;
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
+ }
@@ -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
+ }
package/src/types.ts CHANGED
@@ -133,3 +133,15 @@ export interface OrdersResponse {
133
133
  total_pages: number;
134
134
  };
135
135
  }
136
+
137
+ // ----- OHLCV Candles -----
138
+
139
+ export interface OhlcvCandle {
140
+ time: string;
141
+ open: number;
142
+ high: number;
143
+ low: number;
144
+ close: number;
145
+ volume: number;
146
+ trade_count: number;
147
+ }
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Amount } from "./types.js";
1
+ import type { Amount, OhlcvCandle } from "./types.js";
2
2
 
3
3
  /**
4
4
  * Flattens a Buda API Amount tuple [value_string, currency] into a typed object.
@@ -19,3 +19,55 @@ export function getLiquidityRating(spreadPct: number): "high" | "medium" | "low"
19
19
  if (spreadPct <= 1.0) return "medium";
20
20
  return "low";
21
21
  }
22
+
23
+ const PERIOD_MS: Record<string, number> = {
24
+ "1h": 60 * 60 * 1000,
25
+ "4h": 4 * 60 * 60 * 1000,
26
+ "1d": 24 * 60 * 60 * 1000,
27
+ };
28
+
29
+ /**
30
+ * Aggregates raw Buda trade entries (newest-first) into OHLCV candles for the given period.
31
+ * Entries must be in the format [timestamp_ms, amount, price, direction].
32
+ * Returns candles sorted ascending by bucket start time.
33
+ */
34
+ export function aggregateTradesToCandles(
35
+ entries: [string, string, string, string][],
36
+ period: string,
37
+ ): OhlcvCandle[] {
38
+ const periodMs = PERIOD_MS[period];
39
+ if (!periodMs) throw new Error(`Unknown period: ${period}`);
40
+
41
+ const sorted = [...entries].sort(([a], [b]) => parseInt(a, 10) - parseInt(b, 10));
42
+ const buckets = new Map<number, OhlcvCandle>();
43
+
44
+ for (const [tsMs, amount, price] of sorted) {
45
+ const ts = parseInt(tsMs, 10);
46
+ const bucketStart = Math.floor(ts / periodMs) * periodMs;
47
+ const p = parseFloat(price);
48
+ const v = parseFloat(amount);
49
+
50
+ if (!buckets.has(bucketStart)) {
51
+ buckets.set(bucketStart, {
52
+ time: new Date(bucketStart).toISOString(),
53
+ open: p,
54
+ high: p,
55
+ low: p,
56
+ close: p,
57
+ volume: v,
58
+ trade_count: 1,
59
+ });
60
+ } else {
61
+ const candle = buckets.get(bucketStart)!;
62
+ if (p > candle.high) candle.high = p;
63
+ if (p < candle.low) candle.low = p;
64
+ candle.close = p;
65
+ candle.volume = parseFloat((candle.volume + v).toFixed(8));
66
+ candle.trade_count++;
67
+ }
68
+ }
69
+
70
+ return Array.from(buckets.entries())
71
+ .sort(([a], [b]) => a - b)
72
+ .map(([, candle]) => candle);
73
+ }