@guiie/buda-mcp 1.3.0 → 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 +31 -0
- package/PUBLISH_CHECKLIST.md +69 -70
- package/README.md +4 -4
- package/dist/http.js +17 -0
- package/dist/index.js +10 -0
- 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/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/price_history.d.ts.map +1 -1
- package/dist/tools/price_history.js +2 -40
- 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/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/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +7 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +47 -0
- package/marketplace/README.md +1 -1
- package/marketplace/claude-listing.md +35 -1
- package/marketplace/gemini-tools.json +143 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/http.ts +17 -0
- package/src/index.ts +10 -0
- package/src/tools/calculate_position_size.ts +141 -0
- package/src/tools/dead_mans_switch.ts +314 -0
- package/src/tools/market_sentiment.ts +141 -0
- package/src/tools/price_history.ts +2 -54
- package/src/tools/simulate_order.ts +182 -0
- package/src/tools/technical_indicators.ts +282 -0
- package/src/types.ts +12 -0
- package/src/utils.ts +53 -1
- package/test/unit.ts +505 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateMarketId } from "../validation.js";
|
|
4
|
+
|
|
5
|
+
export const toolSchema = {
|
|
6
|
+
name: "calculate_position_size",
|
|
7
|
+
description:
|
|
8
|
+
"Calculates position size based on your capital, risk tolerance, entry price, and stop-loss. " +
|
|
9
|
+
"Determines how many units to buy or sell so that a stop-loss hit costs exactly risk_pct% of capital. " +
|
|
10
|
+
"Fully client-side — no API call is made. " +
|
|
11
|
+
"Example: 'How many BTC can I buy on BTC-CLP if I have 1,000,000 CLP, risk 2%, entry 80,000,000 CLP, stop at 78,000,000 CLP?'",
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: "object" as const,
|
|
14
|
+
properties: {
|
|
15
|
+
market_id: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Market ID (e.g. 'BTC-CLP', 'ETH-COP'). Used to derive the quote currency.",
|
|
18
|
+
},
|
|
19
|
+
capital: {
|
|
20
|
+
type: "number",
|
|
21
|
+
description: "Total available capital in the quote currency (e.g. CLP for BTC-CLP).",
|
|
22
|
+
},
|
|
23
|
+
risk_pct: {
|
|
24
|
+
type: "number",
|
|
25
|
+
description: "Percentage of capital to risk on this trade (0.1–10, e.g. 2 = 2%).",
|
|
26
|
+
},
|
|
27
|
+
entry_price: {
|
|
28
|
+
type: "number",
|
|
29
|
+
description: "Planned entry price in quote currency.",
|
|
30
|
+
},
|
|
31
|
+
stop_loss_price: {
|
|
32
|
+
type: "number",
|
|
33
|
+
description:
|
|
34
|
+
"Stop-loss price in quote currency. Must be below entry for buys, above entry for sells.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ["market_id", "capital", "risk_pct", "entry_price", "stop_loss_price"],
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type CalculatePositionSizeArgs = {
|
|
42
|
+
market_id: string;
|
|
43
|
+
capital: number;
|
|
44
|
+
risk_pct: number;
|
|
45
|
+
entry_price: number;
|
|
46
|
+
stop_loss_price: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function handleCalculatePositionSize(
|
|
50
|
+
args: CalculatePositionSizeArgs,
|
|
51
|
+
): { content: Array<{ type: "text"; text: string }>; isError?: boolean } {
|
|
52
|
+
const { market_id, capital, risk_pct, entry_price, stop_loss_price } = args;
|
|
53
|
+
|
|
54
|
+
const validationError = validateMarketId(market_id);
|
|
55
|
+
if (validationError) {
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text", text: JSON.stringify({ error: validationError, code: "INVALID_MARKET_ID" }) }],
|
|
58
|
+
isError: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (stop_loss_price === entry_price) {
|
|
63
|
+
return {
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: "text",
|
|
67
|
+
text: JSON.stringify({
|
|
68
|
+
error: "stop_loss_price must differ from entry_price.",
|
|
69
|
+
code: "INVALID_STOP_LOSS",
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
isError: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const quoteCurrency = market_id.split("-")[1].toUpperCase();
|
|
78
|
+
const baseCurrency = market_id.split("-")[0].toUpperCase();
|
|
79
|
+
const side: "buy" | "sell" = stop_loss_price < entry_price ? "buy" : "sell";
|
|
80
|
+
|
|
81
|
+
const capitalAtRisk = capital * (risk_pct / 100);
|
|
82
|
+
const riskPerUnit = Math.abs(entry_price - stop_loss_price);
|
|
83
|
+
const units = capitalAtRisk / riskPerUnit;
|
|
84
|
+
const positionValue = units * entry_price;
|
|
85
|
+
const feeImpact = parseFloat((positionValue * 0.008).toFixed(8));
|
|
86
|
+
|
|
87
|
+
const riskRewardNote =
|
|
88
|
+
`${side === "buy" ? "Buy" : "Sell"} ${units.toFixed(8)} ${baseCurrency} at ${entry_price} ${quoteCurrency} ` +
|
|
89
|
+
`with stop at ${stop_loss_price} ${quoteCurrency}. ` +
|
|
90
|
+
`Risking ${risk_pct}% of capital (${capitalAtRisk.toFixed(2)} ${quoteCurrency}) ` +
|
|
91
|
+
`on a ${riskPerUnit.toFixed(2)} ${quoteCurrency}/unit move. ` +
|
|
92
|
+
`Estimated entry fee: ${feeImpact.toFixed(2)} ${quoteCurrency} (0.8% taker, conservative estimate).`;
|
|
93
|
+
|
|
94
|
+
const result = {
|
|
95
|
+
market_id: market_id.toUpperCase(),
|
|
96
|
+
side,
|
|
97
|
+
units: parseFloat(units.toFixed(8)),
|
|
98
|
+
base_currency: baseCurrency,
|
|
99
|
+
capital_at_risk: parseFloat(capitalAtRisk.toFixed(2)),
|
|
100
|
+
position_value: parseFloat(positionValue.toFixed(2)),
|
|
101
|
+
fee_impact: feeImpact,
|
|
102
|
+
fee_currency: quoteCurrency,
|
|
103
|
+
risk_reward_note: riskRewardNote,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function register(server: McpServer): void {
|
|
112
|
+
server.tool(
|
|
113
|
+
toolSchema.name,
|
|
114
|
+
toolSchema.description,
|
|
115
|
+
{
|
|
116
|
+
market_id: z
|
|
117
|
+
.string()
|
|
118
|
+
.describe("Market ID (e.g. 'BTC-CLP', 'ETH-COP'). Used to derive the quote currency."),
|
|
119
|
+
capital: z
|
|
120
|
+
.number()
|
|
121
|
+
.positive()
|
|
122
|
+
.describe("Total available capital in the quote currency (e.g. CLP for BTC-CLP)."),
|
|
123
|
+
risk_pct: z
|
|
124
|
+
.number()
|
|
125
|
+
.min(0.1)
|
|
126
|
+
.max(10)
|
|
127
|
+
.describe("Percentage of capital to risk on this trade (0.1–10, e.g. 2 = 2%)."),
|
|
128
|
+
entry_price: z
|
|
129
|
+
.number()
|
|
130
|
+
.positive()
|
|
131
|
+
.describe("Planned entry price in quote currency."),
|
|
132
|
+
stop_loss_price: z
|
|
133
|
+
.number()
|
|
134
|
+
.positive()
|
|
135
|
+
.describe(
|
|
136
|
+
"Stop-loss price in quote currency. Must be below entry for buys, above entry for sells.",
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
(args) => handleCalculatePositionSize(args),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
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 type { OrdersResponse, OrderResponse } from "../types.js";
|
|
6
|
+
|
|
7
|
+
// ---- Module-level timer state (persists across HTTP requests / tool invocations) ----
|
|
8
|
+
|
|
9
|
+
interface TimerEntry {
|
|
10
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
ttlSeconds: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const timers = new Map<string, TimerEntry>();
|
|
16
|
+
|
|
17
|
+
async function cancelAllOrdersForMarket(marketId: string, client: BudaClient): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
const data = await client.get<OrdersResponse>(
|
|
20
|
+
`/markets/${marketId}/orders`,
|
|
21
|
+
{ state: "pending", per: 300 },
|
|
22
|
+
);
|
|
23
|
+
const orders = data.orders ?? [];
|
|
24
|
+
await Promise.allSettled(
|
|
25
|
+
orders.map((order) =>
|
|
26
|
+
client.put<OrderResponse>(`/orders/${order.id}`, { state: "canceling" }),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
timers.delete(marketId);
|
|
30
|
+
} catch {
|
|
31
|
+
// Swallow errors — the timer has fired; we cannot surface them to the caller
|
|
32
|
+
timers.delete(marketId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function armTimer(marketId: string, ttlSeconds: number, client: BudaClient): TimerEntry {
|
|
37
|
+
const existing = timers.get(marketId);
|
|
38
|
+
if (existing) clearTimeout(existing.timeout);
|
|
39
|
+
|
|
40
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
41
|
+
const timeout = setTimeout(() => {
|
|
42
|
+
void cancelAllOrdersForMarket(marketId, client);
|
|
43
|
+
}, ttlSeconds * 1000);
|
|
44
|
+
|
|
45
|
+
const entry: TimerEntry = { timeout, expiresAt, ttlSeconds };
|
|
46
|
+
timers.set(marketId, entry);
|
|
47
|
+
return entry;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- Tool schemas ----
|
|
51
|
+
|
|
52
|
+
export const toolSchema = {
|
|
53
|
+
name: "schedule_cancel_all",
|
|
54
|
+
description:
|
|
55
|
+
"WARNING: timer state is lost on server restart. Not suitable as a production dead man's switch " +
|
|
56
|
+
"on hosted deployments (e.g. Railway). Use only on locally-run instances.\n\n" +
|
|
57
|
+
"Arms an in-memory dead man's switch: if not renewed within ttl_seconds, all open orders for the " +
|
|
58
|
+
"market are automatically cancelled. Requires confirmation_token='CONFIRM' to activate. " +
|
|
59
|
+
"Use renew_cancel_timer to reset the countdown, or disarm_cancel_timer to cancel without touching orders. " +
|
|
60
|
+
"Requires BUDA_API_KEY and BUDA_API_SECRET environment variables.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object" as const,
|
|
63
|
+
properties: {
|
|
64
|
+
market_id: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Market ID to protect (e.g. 'BTC-CLP').",
|
|
67
|
+
},
|
|
68
|
+
ttl_seconds: {
|
|
69
|
+
type: "number",
|
|
70
|
+
description: "Seconds before all orders are cancelled if not renewed (10–300).",
|
|
71
|
+
},
|
|
72
|
+
confirmation_token: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "Must equal exactly 'CONFIRM' (case-sensitive) to arm the switch.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
required: ["market_id", "ttl_seconds", "confirmation_token"],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const renewToolSchema = {
|
|
82
|
+
name: "renew_cancel_timer",
|
|
83
|
+
description:
|
|
84
|
+
"Resets the dead man's switch TTL for a market, preventing automatic order cancellation. " +
|
|
85
|
+
"No confirmation required. Requires an active timer set by schedule_cancel_all. " +
|
|
86
|
+
"Requires BUDA_API_KEY and BUDA_API_SECRET environment variables.",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object" as const,
|
|
89
|
+
properties: {
|
|
90
|
+
market_id: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Market ID whose timer should be renewed (e.g. 'BTC-CLP').",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
required: ["market_id"],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const disarmToolSchema = {
|
|
100
|
+
name: "disarm_cancel_timer",
|
|
101
|
+
description:
|
|
102
|
+
"Disarms the dead man's switch for a market without cancelling any orders. " +
|
|
103
|
+
"No confirmation required. Safe to call even if no timer is active. " +
|
|
104
|
+
"Requires BUDA_API_KEY and BUDA_API_SECRET environment variables.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object" as const,
|
|
107
|
+
properties: {
|
|
108
|
+
market_id: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Market ID whose timer should be disarmed (e.g. 'BTC-CLP').",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ["market_id"],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ---- Handlers (exported for unit tests) ----
|
|
118
|
+
|
|
119
|
+
type ScheduleArgs = {
|
|
120
|
+
market_id: string;
|
|
121
|
+
ttl_seconds: number;
|
|
122
|
+
confirmation_token: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export async function handleScheduleCancelAll(
|
|
126
|
+
args: ScheduleArgs,
|
|
127
|
+
client: BudaClient,
|
|
128
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
|
|
129
|
+
const { market_id, ttl_seconds, confirmation_token } = args;
|
|
130
|
+
|
|
131
|
+
if (confirmation_token !== "CONFIRM") {
|
|
132
|
+
return {
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: "text",
|
|
136
|
+
text: JSON.stringify({
|
|
137
|
+
error:
|
|
138
|
+
"Dead man's switch not armed. confirmation_token must equal 'CONFIRM' to activate. " +
|
|
139
|
+
"Review the parameters and set confirmation_token='CONFIRM' to proceed.",
|
|
140
|
+
code: "CONFIRMATION_REQUIRED",
|
|
141
|
+
market_id,
|
|
142
|
+
ttl_seconds,
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
isError: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const validationError = validateMarketId(market_id);
|
|
151
|
+
if (validationError) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: JSON.stringify({ error: validationError, code: "INVALID_MARKET_ID" }) }],
|
|
154
|
+
isError: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const id = market_id.toLowerCase();
|
|
159
|
+
const entry = armTimer(id, ttl_seconds, client);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: "text",
|
|
165
|
+
text: JSON.stringify({
|
|
166
|
+
active: true,
|
|
167
|
+
market_id: market_id.toUpperCase(),
|
|
168
|
+
expires_at: new Date(entry.expiresAt).toISOString(),
|
|
169
|
+
ttl_seconds,
|
|
170
|
+
warning: "in-memory only — timer is lost on server restart. Not suitable for hosted deployments.",
|
|
171
|
+
}),
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type MarketOnlyArgs = { market_id: string };
|
|
178
|
+
|
|
179
|
+
export function handleRenewCancelTimer(
|
|
180
|
+
{ market_id }: MarketOnlyArgs,
|
|
181
|
+
client: BudaClient,
|
|
182
|
+
): { content: Array<{ type: "text"; text: string }>; isError?: boolean } {
|
|
183
|
+
const validationError = validateMarketId(market_id);
|
|
184
|
+
if (validationError) {
|
|
185
|
+
return {
|
|
186
|
+
content: [{ type: "text", text: JSON.stringify({ error: validationError, code: "INVALID_MARKET_ID" }) }],
|
|
187
|
+
isError: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const id = market_id.toLowerCase();
|
|
192
|
+
const existing = timers.get(id);
|
|
193
|
+
if (!existing) {
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: JSON.stringify({
|
|
199
|
+
error: `No active dead man's switch for market ${market_id.toUpperCase()}. Arm one first with schedule_cancel_all.`,
|
|
200
|
+
code: "NO_ACTIVE_TIMER",
|
|
201
|
+
market_id: market_id.toUpperCase(),
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
isError: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const entry = armTimer(id, existing.ttlSeconds, client);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: JSON.stringify({
|
|
216
|
+
active: true,
|
|
217
|
+
market_id: market_id.toUpperCase(),
|
|
218
|
+
expires_at: new Date(entry.expiresAt).toISOString(),
|
|
219
|
+
ttl_seconds: entry.ttlSeconds,
|
|
220
|
+
}),
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function handleDisarmCancelTimer(
|
|
227
|
+
{ market_id }: MarketOnlyArgs,
|
|
228
|
+
): { content: Array<{ type: "text"; text: string }>; isError?: boolean } {
|
|
229
|
+
const validationError = validateMarketId(market_id);
|
|
230
|
+
if (validationError) {
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: JSON.stringify({ error: validationError, code: "INVALID_MARKET_ID" }) }],
|
|
233
|
+
isError: true,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const id = market_id.toLowerCase();
|
|
238
|
+
const existing = timers.get(id);
|
|
239
|
+
if (!existing) {
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: JSON.stringify({
|
|
245
|
+
disarmed: false,
|
|
246
|
+
market_id: market_id.toUpperCase(),
|
|
247
|
+
note: "No active timer for this market.",
|
|
248
|
+
}),
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
clearTimeout(existing.timeout);
|
|
255
|
+
timers.delete(id);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "text",
|
|
261
|
+
text: JSON.stringify({
|
|
262
|
+
disarmed: true,
|
|
263
|
+
market_id: market_id.toUpperCase(),
|
|
264
|
+
}),
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- Registration ----
|
|
271
|
+
|
|
272
|
+
export function register(server: McpServer, client: BudaClient): void {
|
|
273
|
+
server.tool(
|
|
274
|
+
toolSchema.name,
|
|
275
|
+
toolSchema.description,
|
|
276
|
+
{
|
|
277
|
+
market_id: z
|
|
278
|
+
.string()
|
|
279
|
+
.describe("Market ID to protect (e.g. 'BTC-CLP')."),
|
|
280
|
+
ttl_seconds: z
|
|
281
|
+
.number()
|
|
282
|
+
.int()
|
|
283
|
+
.min(10)
|
|
284
|
+
.max(300)
|
|
285
|
+
.describe("Seconds before all orders are cancelled if not renewed (10–300)."),
|
|
286
|
+
confirmation_token: z
|
|
287
|
+
.string()
|
|
288
|
+
.describe("Must equal exactly 'CONFIRM' (case-sensitive) to arm the switch."),
|
|
289
|
+
},
|
|
290
|
+
(args) => handleScheduleCancelAll(args, client),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
server.tool(
|
|
294
|
+
renewToolSchema.name,
|
|
295
|
+
renewToolSchema.description,
|
|
296
|
+
{
|
|
297
|
+
market_id: z
|
|
298
|
+
.string()
|
|
299
|
+
.describe("Market ID whose timer should be renewed (e.g. 'BTC-CLP')."),
|
|
300
|
+
},
|
|
301
|
+
(args) => handleRenewCancelTimer(args, client),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
server.tool(
|
|
305
|
+
disarmToolSchema.name,
|
|
306
|
+
disarmToolSchema.description,
|
|
307
|
+
{
|
|
308
|
+
market_id: z
|
|
309
|
+
.string()
|
|
310
|
+
.describe("Market ID whose timer should be disarmed (e.g. 'BTC-CLP')."),
|
|
311
|
+
},
|
|
312
|
+
(args) => handleDisarmCancelTimer(args),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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, VolumeResponse } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export const toolSchema = {
|
|
9
|
+
name: "get_market_sentiment",
|
|
10
|
+
description:
|
|
11
|
+
"Computes a composite sentiment score (−100 to +100) for a Buda.com market based on " +
|
|
12
|
+
"24h price variation (40%), volume vs 7-day average (35%), and bid/ask spread vs baseline (25%). " +
|
|
13
|
+
"Returns a score, a label (bearish/neutral/bullish), and a full component breakdown. " +
|
|
14
|
+
"Example: 'Is the BTC-CLP market currently bullish or bearish?'",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object" as const,
|
|
17
|
+
properties: {
|
|
18
|
+
market_id: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "Market ID (e.g. 'BTC-CLP', 'ETH-BTC', 'BTC-USDT').",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["market_id"],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function clamp(value: number, min: number, max: number): number {
|
|
28
|
+
return Math.min(max, Math.max(min, value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isStablecoinPair(marketId: string): boolean {
|
|
32
|
+
return /-(USDT|USDC|DAI|TUSD)$/i.test(marketId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type MarketSentimentArgs = { market_id: string };
|
|
36
|
+
|
|
37
|
+
export async function handleMarketSentiment(
|
|
38
|
+
{ market_id }: MarketSentimentArgs,
|
|
39
|
+
client: BudaClient,
|
|
40
|
+
cache: MemoryCache,
|
|
41
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
|
|
42
|
+
const validationError = validateMarketId(market_id);
|
|
43
|
+
if (validationError) {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: JSON.stringify({ error: validationError, code: "INVALID_MARKET_ID" }) }],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const id = market_id.toLowerCase();
|
|
52
|
+
|
|
53
|
+
const [tickerData, volumeData] = await Promise.all([
|
|
54
|
+
cache.getOrFetch<TickerResponse>(
|
|
55
|
+
`ticker:${id}`,
|
|
56
|
+
CACHE_TTL.TICKER,
|
|
57
|
+
() => client.get<TickerResponse>(`/markets/${id}/ticker`),
|
|
58
|
+
),
|
|
59
|
+
client.get<VolumeResponse>(`/markets/${id}/volume`),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const ticker = tickerData.ticker;
|
|
63
|
+
const vol = volumeData.volume;
|
|
64
|
+
|
|
65
|
+
const bid = parseFloat(ticker.max_bid[0]);
|
|
66
|
+
const ask = parseFloat(ticker.min_ask[0]);
|
|
67
|
+
const priceVariation24h = parseFloat(ticker.price_variation_24h);
|
|
68
|
+
|
|
69
|
+
const ask24h = parseFloat(vol.ask_volume_24h[0]);
|
|
70
|
+
const bid24h = parseFloat(vol.bid_volume_24h[0]);
|
|
71
|
+
const ask7d = parseFloat(vol.ask_volume_7d[0]);
|
|
72
|
+
const bid7d = parseFloat(vol.bid_volume_7d[0]);
|
|
73
|
+
|
|
74
|
+
const spreadPct = ask > 0 ? ((ask - bid) / ask) * 100 : 0;
|
|
75
|
+
const spreadBaseline = isStablecoinPair(market_id) ? 0.3 : 1.0;
|
|
76
|
+
|
|
77
|
+
const volume24h = ask24h + bid24h;
|
|
78
|
+
const volume7d = ask7d + bid7d;
|
|
79
|
+
const volumeRatio = volume7d > 0 ? (volume24h * 7) / volume7d : 1;
|
|
80
|
+
|
|
81
|
+
// Price component: ±5% daily change → ±100 on this sub-score
|
|
82
|
+
const priceRaw = clamp(priceVariation24h * 2000, -100, 100);
|
|
83
|
+
const priceScore = parseFloat((priceRaw * 0.4).toFixed(4));
|
|
84
|
+
|
|
85
|
+
// Volume component: ratio vs 7d daily average
|
|
86
|
+
const volumeRaw = clamp((volumeRatio - 1) * 100, -100, 100);
|
|
87
|
+
const volumeScore = parseFloat((volumeRaw * 0.35).toFixed(4));
|
|
88
|
+
|
|
89
|
+
// Spread component: tighter spread is bullish
|
|
90
|
+
const spreadRaw = clamp((1 - spreadPct / spreadBaseline) * 100, -100, 100);
|
|
91
|
+
const spreadScore = parseFloat((spreadRaw * 0.25).toFixed(4));
|
|
92
|
+
|
|
93
|
+
const score = parseFloat((priceScore + volumeScore + spreadScore).toFixed(1));
|
|
94
|
+
const label: "bearish" | "neutral" | "bullish" =
|
|
95
|
+
score < -20 ? "bearish" : score > 20 ? "bullish" : "neutral";
|
|
96
|
+
|
|
97
|
+
const result = {
|
|
98
|
+
market_id: ticker.market_id,
|
|
99
|
+
score,
|
|
100
|
+
label,
|
|
101
|
+
component_breakdown: {
|
|
102
|
+
price_variation_24h_pct: parseFloat((priceVariation24h * 100).toFixed(4)),
|
|
103
|
+
volume_ratio: parseFloat(volumeRatio.toFixed(4)),
|
|
104
|
+
spread_pct: parseFloat(spreadPct.toFixed(4)),
|
|
105
|
+
spread_baseline_pct: spreadBaseline,
|
|
106
|
+
price_score: priceScore,
|
|
107
|
+
volume_score: volumeScore,
|
|
108
|
+
spread_score: spreadScore,
|
|
109
|
+
},
|
|
110
|
+
data_timestamp: new Date().toISOString(),
|
|
111
|
+
disclaimer:
|
|
112
|
+
"Sentiment is derived from market microstructure data only. Not investment advice.",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const msg =
|
|
120
|
+
err instanceof BudaApiError
|
|
121
|
+
? { error: err.message, code: err.status, path: err.path }
|
|
122
|
+
: { error: String(err), code: "UNKNOWN" };
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: "text", text: JSON.stringify(msg) }],
|
|
125
|
+
isError: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function register(server: McpServer, client: BudaClient, cache: MemoryCache): void {
|
|
131
|
+
server.tool(
|
|
132
|
+
toolSchema.name,
|
|
133
|
+
toolSchema.description,
|
|
134
|
+
{
|
|
135
|
+
market_id: z
|
|
136
|
+
.string()
|
|
137
|
+
.describe("Market ID (e.g. 'BTC-CLP', 'ETH-BTC', 'BTC-USDT')."),
|
|
138
|
+
},
|
|
139
|
+
(args) => handleMarketSentiment(args, client, cache),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -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
|
-
|
|
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(),
|