xstock-mcp 1.0.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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/index.js +827 -0
- package/package.json +27 -0
- package/src/data/binance.ts +87 -0
- package/src/data/coingecko.ts +113 -0
- package/src/data/tencent.ts +187 -0
- package/src/data/yahoo.ts +139 -0
- package/src/index.ts +7 -0
- package/src/server.ts +40 -0
- package/src/tools/crypto.ts +145 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/kline.ts +91 -0
- package/src/tools/quotes.ts +52 -0
- package/src/tools/search.ts +54 -0
- package/src/tools/types.ts +44 -0
- package/src/tools/us-market.ts +73 -0
- package/src/utils/indicators.ts +0 -0
- package/src/utils/symbol.ts +0 -0
- package/test/data-binance.ts +68 -0
- package/test/data-coingecko.ts +71 -0
- package/test/data-tencent.ts +90 -0
- package/test/data-yahoo.ts +84 -0
- package/tsconfig.json +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xstock-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Stock & Crypto analysis MCP Server",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"xstock-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsup src/index.ts --format cjs --no-splitting --outDir dist",
|
|
11
|
+
"dev": "ts-node src/index.ts",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
16
|
+
"axios": "^1.6.0",
|
|
17
|
+
"iconv-lite": "^0.7.2",
|
|
18
|
+
"yahoo-finance2": "^3.15.2",
|
|
19
|
+
"zod": "^4.4.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.0.0",
|
|
23
|
+
"ts-node": "^10.9.0",
|
|
24
|
+
"tsup": "^8.5.1",
|
|
25
|
+
"typescript": "^5.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface CryptoTicker {
|
|
6
|
+
symbol: string;
|
|
7
|
+
price: number;
|
|
8
|
+
change: number;
|
|
9
|
+
changePercent: string;
|
|
10
|
+
high: number;
|
|
11
|
+
low: number;
|
|
12
|
+
volume: number;
|
|
13
|
+
quoteVolume: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CryptoKlineBar {
|
|
17
|
+
date: string;
|
|
18
|
+
open: number;
|
|
19
|
+
high: number;
|
|
20
|
+
low: number;
|
|
21
|
+
close: number;
|
|
22
|
+
volume: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Utils ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
// 自动补全 USDT 后缀
|
|
28
|
+
export function normalizeSymbol(symbol: string): string {
|
|
29
|
+
const s = symbol.toUpperCase();
|
|
30
|
+
return s.endsWith("USDT") ? s : s + "USDT";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Fetch Functions ──────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export async function fetchCryptoTickers(symbols: string[]): Promise<CryptoTicker[]> {
|
|
36
|
+
process.stderr.write(`[binance] fetchCryptoTickers symbols=${symbols.join(",")}\n`);
|
|
37
|
+
const results = await Promise.all(
|
|
38
|
+
symbols.map(async (raw) => {
|
|
39
|
+
const sym = normalizeSymbol(raw);
|
|
40
|
+
try {
|
|
41
|
+
const res = await axios.get(
|
|
42
|
+
`https://api.binance.com/api/v3/ticker/24hr?symbol=${sym}`,
|
|
43
|
+
{ timeout: 5000 }
|
|
44
|
+
);
|
|
45
|
+
const d = res.data;
|
|
46
|
+
process.stderr.write(`[binance] ${sym} price=${d.lastPrice}\n`);
|
|
47
|
+
return {
|
|
48
|
+
symbol: sym,
|
|
49
|
+
price: parseFloat(d.lastPrice),
|
|
50
|
+
change: parseFloat(d.priceChange),
|
|
51
|
+
changePercent: parseFloat(d.priceChangePercent).toFixed(2) + "%",
|
|
52
|
+
high: parseFloat(d.highPrice),
|
|
53
|
+
low: parseFloat(d.lowPrice),
|
|
54
|
+
volume: parseFloat(d.volume),
|
|
55
|
+
quoteVolume: parseFloat(d.quoteVolume),
|
|
56
|
+
} as CryptoTicker;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
process.stderr.write(`[binance] 请求失败 symbol=${sym} err=${e}\n`);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
return results.filter(Boolean) as CryptoTicker[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function fetchCryptoKlines(
|
|
67
|
+
symbol: string,
|
|
68
|
+
interval: string = "1d",
|
|
69
|
+
limit: number = 100
|
|
70
|
+
): Promise<CryptoKlineBar[]> {
|
|
71
|
+
const sym = normalizeSymbol(symbol);
|
|
72
|
+
process.stderr.write(`[binance] fetchCryptoKlines symbol=${sym} interval=${interval} limit=${limit}\n`);
|
|
73
|
+
const res = await axios.get(
|
|
74
|
+
`https://api.binance.com/api/v3/klines?symbol=${sym}&interval=${interval}&limit=${limit}`,
|
|
75
|
+
{ timeout: 5000 }
|
|
76
|
+
);
|
|
77
|
+
const bars: CryptoKlineBar[] = res.data.map((k: unknown[]) => ({
|
|
78
|
+
date: new Date(k[0] as number).toISOString().slice(0, 10),
|
|
79
|
+
open: parseFloat(k[1] as string),
|
|
80
|
+
high: parseFloat(k[2] as string),
|
|
81
|
+
low: parseFloat(k[3] as string),
|
|
82
|
+
close: parseFloat(k[4] as string),
|
|
83
|
+
volume: parseFloat(k[5] as string),
|
|
84
|
+
}));
|
|
85
|
+
process.stderr.write(`[binance] fetchCryptoKlines 返回 ${bars.length} 条\n`);
|
|
86
|
+
return bars;
|
|
87
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
const BASE = "https://api.coingecko.com/api/v3";
|
|
4
|
+
const FEAR_GREED_URL = "https://api.alternative.me/fng/?limit=1";
|
|
5
|
+
|
|
6
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface GlobalMarket {
|
|
9
|
+
totalMarketCapUsd: number;
|
|
10
|
+
totalVolumeUsd: number;
|
|
11
|
+
btcDominance: number;
|
|
12
|
+
ethDominance: number;
|
|
13
|
+
activeCryptos: number;
|
|
14
|
+
marketCapChangePercent24h: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CoinInfo {
|
|
18
|
+
rank: number;
|
|
19
|
+
id: string;
|
|
20
|
+
symbol: string;
|
|
21
|
+
name: string;
|
|
22
|
+
price: number;
|
|
23
|
+
changePercent24h: number;
|
|
24
|
+
marketCap: number;
|
|
25
|
+
volume24h: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CategoryInfo {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
marketCap: number;
|
|
32
|
+
marketCapChange24h: number;
|
|
33
|
+
volume24h: number;
|
|
34
|
+
topCoins: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FearGreed {
|
|
38
|
+
value: number; // 0-100
|
|
39
|
+
label: string; // "Extreme Fear" | "Fear" | "Neutral" | "Greed" | "Extreme Greed"
|
|
40
|
+
timestamp: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Fetch Functions ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export async function fetchGlobalMarket(): Promise<GlobalMarket> {
|
|
46
|
+
process.stderr.write(`[coingecko] fetchGlobalMarket\n`);
|
|
47
|
+
const res = await axios.get(`${BASE}/global`, { timeout: 8000 });
|
|
48
|
+
const d = res.data.data;
|
|
49
|
+
const result: GlobalMarket = {
|
|
50
|
+
totalMarketCapUsd: d.total_market_cap?.usd ?? 0,
|
|
51
|
+
totalVolumeUsd: d.total_volume?.usd ?? 0,
|
|
52
|
+
btcDominance: parseFloat(d.market_cap_percentage?.btc?.toFixed(2) ?? "0"),
|
|
53
|
+
ethDominance: parseFloat(d.market_cap_percentage?.eth?.toFixed(2) ?? "0"),
|
|
54
|
+
activeCryptos: d.active_cryptocurrencies ?? 0,
|
|
55
|
+
marketCapChangePercent24h: parseFloat(d.market_cap_change_percentage_24h_usd?.toFixed(2) ?? "0"),
|
|
56
|
+
};
|
|
57
|
+
process.stderr.write(`[coingecko] globalMarket btcDominance=${result.btcDominance}%\n`);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function fetchTopCoins(limit: number = 50): Promise<CoinInfo[]> {
|
|
62
|
+
process.stderr.write(`[coingecko] fetchTopCoins limit=${limit}\n`);
|
|
63
|
+
const res = await axios.get(`${BASE}/coins/markets`, {
|
|
64
|
+
timeout: 8000,
|
|
65
|
+
params: {
|
|
66
|
+
vs_currency: "usd",
|
|
67
|
+
order: "market_cap_desc",
|
|
68
|
+
per_page: limit,
|
|
69
|
+
page: 1,
|
|
70
|
+
sparkline: false,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const coins: CoinInfo[] = res.data.map((c: Record<string, unknown>, i: number) => ({
|
|
74
|
+
rank: i + 1,
|
|
75
|
+
id: c.id,
|
|
76
|
+
symbol: (c.symbol as string).toUpperCase(),
|
|
77
|
+
name: c.name,
|
|
78
|
+
price: c.current_price,
|
|
79
|
+
changePercent24h: parseFloat(((c.price_change_percentage_24h as number) ?? 0).toFixed(2)),
|
|
80
|
+
marketCap: c.market_cap,
|
|
81
|
+
volume24h: c.total_volume,
|
|
82
|
+
}));
|
|
83
|
+
process.stderr.write(`[coingecko] fetchTopCoins 返回 ${coins.length} 条\n`);
|
|
84
|
+
return coins;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function fetchCategories(): Promise<CategoryInfo[]> {
|
|
88
|
+
process.stderr.write(`[coingecko] fetchCategories\n`);
|
|
89
|
+
const res = await axios.get(`${BASE}/coins/categories`, { timeout: 8000 });
|
|
90
|
+
const categories: CategoryInfo[] = res.data.map((c: Record<string, unknown>) => ({
|
|
91
|
+
id: c.id,
|
|
92
|
+
name: c.name,
|
|
93
|
+
marketCap: c.market_cap ?? 0,
|
|
94
|
+
marketCapChange24h: parseFloat(((c.market_cap_change_24h as number) ?? 0).toFixed(2)),
|
|
95
|
+
volume24h: c.volume_24h ?? 0,
|
|
96
|
+
topCoins: (c.top_3_coins as string[] | null) ?? [],
|
|
97
|
+
}));
|
|
98
|
+
process.stderr.write(`[coingecko] fetchCategories 返回 ${categories.length} 条\n`);
|
|
99
|
+
return categories;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function fetchFearGreed(): Promise<FearGreed> {
|
|
103
|
+
process.stderr.write(`[coingecko] fetchFearGreed\n`);
|
|
104
|
+
const res = await axios.get(FEAR_GREED_URL, { timeout: 5000 });
|
|
105
|
+
const d = res.data.data[0];
|
|
106
|
+
const result: FearGreed = {
|
|
107
|
+
value: parseInt(d.value),
|
|
108
|
+
label: d.value_classification,
|
|
109
|
+
timestamp: new Date(parseInt(d.timestamp) * 1000).toISOString().slice(0, 10),
|
|
110
|
+
};
|
|
111
|
+
process.stderr.write(`[coingecko] fearGreed value=${result.value} label=${result.label}\n`);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import iconv from "iconv-lite";
|
|
3
|
+
|
|
4
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface AShareQuote {
|
|
7
|
+
code: string;
|
|
8
|
+
name: string;
|
|
9
|
+
price: number;
|
|
10
|
+
change: number;
|
|
11
|
+
changePercent: string;
|
|
12
|
+
volume: string;
|
|
13
|
+
marketCap: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HKQuote {
|
|
17
|
+
code: string;
|
|
18
|
+
name: string;
|
|
19
|
+
price: number;
|
|
20
|
+
prevClose: number;
|
|
21
|
+
open: number;
|
|
22
|
+
high: number;
|
|
23
|
+
low: number;
|
|
24
|
+
volume: string;
|
|
25
|
+
change: number;
|
|
26
|
+
changePercent: string;
|
|
27
|
+
time: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface KlineBar {
|
|
31
|
+
date: string;
|
|
32
|
+
open: number;
|
|
33
|
+
close: number;
|
|
34
|
+
high: number;
|
|
35
|
+
low: number;
|
|
36
|
+
volume: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── URL Builders ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function aQuoteUrl(code: string) {
|
|
42
|
+
return `https://qt.gtimg.cn/q=s_${resolveASharePrefix(code)}${code}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hkQuoteUrl(code: string) {
|
|
46
|
+
return `https://qt.gtimg.cn/q=r_hk${code}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function aKlineUrl(code: string, period: string) {
|
|
50
|
+
return `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${resolveASharePrefix(code)}${code},${period},,,100,qfq`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hkKlineUrl(code: string, period: string) {
|
|
54
|
+
return `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=hk${code},${period},,,100,qfq`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Utils ────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
// 6/5 开头=上证,8/9 开头=北交所,其余=深证
|
|
60
|
+
export function resolveASharePrefix(code: string): "sh" | "sz" | "bj" {
|
|
61
|
+
const c = code.replace(/^(sh|sz|bj)/i, "");
|
|
62
|
+
if (/^[56]/.test(c)) return "sh";
|
|
63
|
+
if (/^[89]/.test(c)) return "bj";
|
|
64
|
+
return "sz";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function periodParam(period: "daily" | "weekly" | "monthly"): string {
|
|
68
|
+
return { daily: "day", weekly: "week", monthly: "month" }[period];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Parsers ──────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function parseAShareQuote(raw: string, code: string): AShareQuote | null {
|
|
74
|
+
const match = raw.match(/v_s_\w+="([^"]+)"/);
|
|
75
|
+
if (!match) return null;
|
|
76
|
+
const f = match[1].split("~");
|
|
77
|
+
return {
|
|
78
|
+
code,
|
|
79
|
+
name: f[1],
|
|
80
|
+
price: parseFloat(f[3]),
|
|
81
|
+
change: parseFloat(f[4]),
|
|
82
|
+
changePercent: f[5] + "%",
|
|
83
|
+
volume: f[6],
|
|
84
|
+
marketCap: f[9],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseHKQuote(raw: string, code: string): HKQuote | null {
|
|
89
|
+
const match = raw.match(/v_r_hk\w+="([^"]+)"/);
|
|
90
|
+
if (!match) return null;
|
|
91
|
+
const f = match[1].split("~");
|
|
92
|
+
return {
|
|
93
|
+
code,
|
|
94
|
+
name: f[1],
|
|
95
|
+
price: parseFloat(f[3]),
|
|
96
|
+
prevClose: parseFloat(f[4]),
|
|
97
|
+
open: parseFloat(f[5]),
|
|
98
|
+
volume: f[6],
|
|
99
|
+
time: f[30],
|
|
100
|
+
change: parseFloat(f[31]),
|
|
101
|
+
changePercent: f[32] + "%",
|
|
102
|
+
high: parseFloat(f[33]),
|
|
103
|
+
low: parseFloat(f[34]),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseKline(data: unknown, key: string): KlineBar[] {
|
|
108
|
+
const d = data as Record<string, unknown>;
|
|
109
|
+
const dataMap = d?.["data"] as Record<string, unknown> | undefined;
|
|
110
|
+
const stockData = (dataMap?.[key] ?? {}) as Record<string, unknown>;
|
|
111
|
+
const raw = (stockData?.["qfqday"] ?? stockData?.["day"] ?? []) as string[][];
|
|
112
|
+
return raw.slice(-100).map((k) => ({
|
|
113
|
+
date: k[0],
|
|
114
|
+
open: parseFloat(k[1]),
|
|
115
|
+
close: parseFloat(k[2]),
|
|
116
|
+
high: parseFloat(k[3]),
|
|
117
|
+
low: parseFloat(k[4]),
|
|
118
|
+
volume: parseFloat(k[5]),
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Fetch Functions ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export async function fetchAShareQuotes(codes: string[]): Promise<AShareQuote[]> {
|
|
125
|
+
process.stderr.write(`[tencent] fetchAShareQuotes codes=${codes.join(",")}\n`);
|
|
126
|
+
const results = await Promise.all(
|
|
127
|
+
codes.map(async (raw) => {
|
|
128
|
+
const code = raw.replace(/^(sh|sz|bj)/i, "");
|
|
129
|
+
try {
|
|
130
|
+
const res = await axios.get(aQuoteUrl(code), { timeout: 5000, responseType: "arraybuffer" });
|
|
131
|
+
const text = iconv.decode(Buffer.from(res.data), "gbk");
|
|
132
|
+
const quote = parseAShareQuote(text, code);
|
|
133
|
+
if (!quote) process.stderr.write(`[tencent] A股解析失败 code=${code}\n`);
|
|
134
|
+
return quote;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
process.stderr.write(`[tencent] A股请求失败 code=${code} err=${e}\n`);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
return results.filter(Boolean) as AShareQuote[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function fetchHKQuotes(codes: string[]): Promise<HKQuote[]> {
|
|
145
|
+
process.stderr.write(`[tencent] fetchHKQuotes codes=${codes.join(",")}\n`);
|
|
146
|
+
const results = await Promise.all(
|
|
147
|
+
codes.map(async (raw) => {
|
|
148
|
+
const code = raw.replace(/^hk/i, "").padStart(5, "0");
|
|
149
|
+
try {
|
|
150
|
+
const res = await axios.get(hkQuoteUrl(code), { timeout: 5000, responseType: "arraybuffer" });
|
|
151
|
+
const text = iconv.decode(Buffer.from(res.data), "gbk");
|
|
152
|
+
const quote = parseHKQuote(text, code);
|
|
153
|
+
if (!quote) process.stderr.write(`[tencent] 港股解析失败 code=${code}\n`);
|
|
154
|
+
return quote;
|
|
155
|
+
} catch (e) {
|
|
156
|
+
process.stderr.write(`[tencent] 港股请求失败 code=${code} err=${e}\n`);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
return results.filter(Boolean) as HKQuote[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function fetchAShareKline(
|
|
165
|
+
code: string,
|
|
166
|
+
period: "daily" | "weekly" | "monthly" = "daily"
|
|
167
|
+
): Promise<KlineBar[]> {
|
|
168
|
+
const stripped = code.replace(/^(sh|sz|bj)/i, "");
|
|
169
|
+
const prefix = resolveASharePrefix(stripped);
|
|
170
|
+
process.stderr.write(`[tencent] fetchAShareKline code=${stripped} period=${period}\n`);
|
|
171
|
+
const res = await axios.get(aKlineUrl(stripped, periodParam(period)), { timeout: 5000 });
|
|
172
|
+
const bars = parseKline(res.data, `${prefix}${stripped}`);
|
|
173
|
+
process.stderr.write(`[tencent] fetchAShareKline 返回 ${bars.length} 条\n`);
|
|
174
|
+
return bars;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function fetchHKKline(
|
|
178
|
+
code: string,
|
|
179
|
+
period: "daily" | "weekly" | "monthly" = "daily"
|
|
180
|
+
): Promise<KlineBar[]> {
|
|
181
|
+
const normalized = code.replace(/^hk/i, "").padStart(5, "0");
|
|
182
|
+
process.stderr.write(`[tencent] fetchHKKline code=${normalized} period=${period}\n`);
|
|
183
|
+
const res = await axios.get(hkKlineUrl(normalized, periodParam(period)), { timeout: 5000 });
|
|
184
|
+
const bars = parseKline(res.data, `hk${normalized}`);
|
|
185
|
+
process.stderr.write(`[tencent] fetchHKKline 返回 ${bars.length} 条\n`);
|
|
186
|
+
return bars;
|
|
187
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { default as YahooFinance } from "yahoo-finance2";
|
|
2
|
+
|
|
3
|
+
// v3 需要实例化
|
|
4
|
+
const yf = new YahooFinance({ suppressNotices: ["yahooSurvey"] });
|
|
5
|
+
|
|
6
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface USQuote {
|
|
9
|
+
symbol: string;
|
|
10
|
+
name: string;
|
|
11
|
+
price: number;
|
|
12
|
+
change: number;
|
|
13
|
+
changePercent: string;
|
|
14
|
+
open: number;
|
|
15
|
+
high: number;
|
|
16
|
+
low: number;
|
|
17
|
+
prevClose: number;
|
|
18
|
+
volume: number;
|
|
19
|
+
marketCap: number | null;
|
|
20
|
+
currency: string;
|
|
21
|
+
exchange: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface USKlineBar {
|
|
25
|
+
date: string;
|
|
26
|
+
open: number;
|
|
27
|
+
high: number;
|
|
28
|
+
low: number;
|
|
29
|
+
close: number;
|
|
30
|
+
volume: number;
|
|
31
|
+
adjClose: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StockProfile {
|
|
35
|
+
symbol: string;
|
|
36
|
+
name: string;
|
|
37
|
+
sector: string | null;
|
|
38
|
+
industry: string | null;
|
|
39
|
+
description: string | null;
|
|
40
|
+
website: string | null;
|
|
41
|
+
employees: number | null;
|
|
42
|
+
country: string | null;
|
|
43
|
+
currency: string;
|
|
44
|
+
marketCap: number | null;
|
|
45
|
+
pe: number | null;
|
|
46
|
+
eps: number | null;
|
|
47
|
+
beta: number | null;
|
|
48
|
+
fiftyTwoWeekHigh: number | null;
|
|
49
|
+
fiftyTwoWeekLow: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Fetch Functions ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export async function fetchUSQuotes(symbols: string[]): Promise<USQuote[]> {
|
|
55
|
+
process.stderr.write(`[yahoo] fetchUSQuotes symbols=${symbols.join(",")}\n`);
|
|
56
|
+
const results = await Promise.all(
|
|
57
|
+
symbols.map(async (symbol) => {
|
|
58
|
+
try {
|
|
59
|
+
const q = await yf.quote(symbol);
|
|
60
|
+
const result: USQuote = {
|
|
61
|
+
symbol: q.symbol,
|
|
62
|
+
name: (q as Record<string, unknown>).longName as string ?? (q as Record<string, unknown>).shortName as string ?? symbol,
|
|
63
|
+
price: q.regularMarketPrice ?? 0,
|
|
64
|
+
change: q.regularMarketChange ?? 0,
|
|
65
|
+
changePercent: ((q.regularMarketChangePercent ?? 0).toFixed(2)) + "%",
|
|
66
|
+
open: q.regularMarketOpen ?? 0,
|
|
67
|
+
high: q.regularMarketDayHigh ?? 0,
|
|
68
|
+
low: q.regularMarketDayLow ?? 0,
|
|
69
|
+
prevClose: q.regularMarketPreviousClose ?? 0,
|
|
70
|
+
volume: q.regularMarketVolume ?? 0,
|
|
71
|
+
marketCap: (q as Record<string, unknown>).marketCap as number ?? null,
|
|
72
|
+
currency: q.currency ?? "USD",
|
|
73
|
+
exchange: (q as Record<string, unknown>).fullExchangeName as string ?? q.exchange ?? "",
|
|
74
|
+
};
|
|
75
|
+
process.stderr.write(`[yahoo] ${symbol} price=${result.price}\n`);
|
|
76
|
+
return result;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
process.stderr.write(`[yahoo] 请求失败 symbol=${symbol} err=${e}\n`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
return results.filter(Boolean) as USQuote[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function fetchUSKlines(
|
|
87
|
+
symbol: string,
|
|
88
|
+
startDate: string, // YYYY-MM-DD
|
|
89
|
+
endDate: string, // YYYY-MM-DD
|
|
90
|
+
interval: "1d" | "1wk" | "1mo" = "1d"
|
|
91
|
+
): Promise<USKlineBar[]> {
|
|
92
|
+
process.stderr.write(`[yahoo] fetchUSKlines symbol=${symbol} start=${startDate} end=${endDate} interval=${interval}\n`);
|
|
93
|
+
const rows = await yf.historical(symbol, {
|
|
94
|
+
period1: startDate,
|
|
95
|
+
period2: endDate,
|
|
96
|
+
interval,
|
|
97
|
+
});
|
|
98
|
+
const bars: USKlineBar[] = rows.map((r) => ({
|
|
99
|
+
date: r.date.toISOString().slice(0, 10),
|
|
100
|
+
open: r.open ?? 0,
|
|
101
|
+
high: r.high ?? 0,
|
|
102
|
+
low: r.low ?? 0,
|
|
103
|
+
close: r.close ?? 0,
|
|
104
|
+
volume: r.volume ?? 0,
|
|
105
|
+
adjClose: r.adjClose ?? null,
|
|
106
|
+
}));
|
|
107
|
+
process.stderr.write(`[yahoo] fetchUSKlines 返回 ${bars.length} 条\n`);
|
|
108
|
+
return bars;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function fetchStockProfile(symbol: string): Promise<StockProfile> {
|
|
112
|
+
process.stderr.write(`[yahoo] fetchStockProfile symbol=${symbol}\n`);
|
|
113
|
+
const summary = await yf.quoteSummary(symbol, {
|
|
114
|
+
modules: ["price", "summaryProfile", "defaultKeyStatistics"],
|
|
115
|
+
});
|
|
116
|
+
const price = summary.price as Record<string, unknown> | undefined;
|
|
117
|
+
const profile = summary.summaryProfile as Record<string, unknown> | undefined;
|
|
118
|
+
const stats = summary.defaultKeyStatistics as Record<string, unknown> | undefined;
|
|
119
|
+
|
|
120
|
+
const result: StockProfile = {
|
|
121
|
+
symbol,
|
|
122
|
+
name: price?.longName as string ?? price?.shortName as string ?? symbol,
|
|
123
|
+
sector: profile?.sector as string ?? null,
|
|
124
|
+
industry: profile?.industry as string ?? null,
|
|
125
|
+
description: profile?.longBusinessSummary as string ?? null,
|
|
126
|
+
website: profile?.website as string ?? null,
|
|
127
|
+
employees: profile?.fullTimeEmployees as number ?? null,
|
|
128
|
+
country: profile?.country as string ?? null,
|
|
129
|
+
currency: price?.currency as string ?? "USD",
|
|
130
|
+
marketCap: price?.marketCap as number ?? null,
|
|
131
|
+
pe: price?.trailingPE as number ?? null,
|
|
132
|
+
eps: stats?.trailingEps as number ?? null,
|
|
133
|
+
beta: stats?.beta as number ?? null,
|
|
134
|
+
fiftyTwoWeekHigh: price?.fiftyTwoWeekHigh as number ?? null,
|
|
135
|
+
fiftyTwoWeekLow: price?.fiftyTwoWeekLow as number ?? null,
|
|
136
|
+
};
|
|
137
|
+
process.stderr.write(`[yahoo] fetchStockProfile ${symbol} sector=${result.sector}\n`);
|
|
138
|
+
return result;
|
|
139
|
+
}
|
package/src/index.ts
ADDED
package/src/server.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import {
|
|
4
|
+
ListToolsRequestSchema,
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { tools, toolMap } from "./tools/index";
|
|
8
|
+
|
|
9
|
+
const server = new Server(
|
|
10
|
+
{ name: "stock-mcp", version: "1.0.0" },
|
|
11
|
+
{ capabilities: { tools: {} } }
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
// 返回所有已注册的 tools 列表
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
17
|
+
tools: tools.map((t) => t.tool) as any,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// 路由到对应 tool handler
|
|
21
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
22
|
+
const { name, arguments: args } = request.params;
|
|
23
|
+
const def = toolMap.get(name);
|
|
24
|
+
if (!def) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text" as const, text: `Unknown tool: ${name}` }],
|
|
27
|
+
isError: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return def.handler((args ?? {}) as Record<string, unknown>) as any;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export async function startServer(): Promise<void> {
|
|
34
|
+
const transport = new StdioServerTransport();
|
|
35
|
+
await server.connect(transport);
|
|
36
|
+
process.stderr.write(`[stock-mcp] server started — ${tools.length} tools registered\n`);
|
|
37
|
+
for (const t of tools) {
|
|
38
|
+
process.stderr.write(` · ${t.tool.name}\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|