@helloxiaohu/plugin-stock-market 0.0.2
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/README.md +134 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/market-client.d.ts +27 -0
- package/dist/lib/market-client.d.ts.map +1 -0
- package/dist/lib/market-client.js +698 -0
- package/dist/lib/market-client.js.map +1 -0
- package/dist/lib/stock-market.plugin.d.ts +10 -0
- package/dist/lib/stock-market.plugin.d.ts.map +1 -0
- package/dist/lib/stock-market.plugin.js +33 -0
- package/dist/lib/stock-market.plugin.js.map +1 -0
- package/dist/lib/stock-market.strategy.d.ts +119 -0
- package/dist/lib/stock-market.strategy.d.ts.map +1 -0
- package/dist/lib/stock-market.strategy.js +99 -0
- package/dist/lib/stock-market.strategy.js.map +1 -0
- package/dist/lib/tools/basic-info.tool.d.ts +16 -0
- package/dist/lib/tools/basic-info.tool.d.ts.map +1 -0
- package/dist/lib/tools/basic-info.tool.js +83 -0
- package/dist/lib/tools/basic-info.tool.js.map +1 -0
- package/dist/lib/tools/kline-history.tool.d.ts +26 -0
- package/dist/lib/tools/kline-history.tool.d.ts.map +1 -0
- package/dist/lib/tools/kline-history.tool.js +68 -0
- package/dist/lib/tools/kline-history.tool.js.map +1 -0
- package/dist/lib/tools/money-flow.tool.d.ts +16 -0
- package/dist/lib/tools/money-flow.tool.d.ts.map +1 -0
- package/dist/lib/tools/money-flow.tool.js +80 -0
- package/dist/lib/tools/money-flow.tool.js.map +1 -0
- package/dist/lib/tools/quote-realtime.tool.d.ts +16 -0
- package/dist/lib/tools/quote-realtime.tool.d.ts.map +1 -0
- package/dist/lib/tools/quote-realtime.tool.js +65 -0
- package/dist/lib/tools/quote-realtime.tool.js.map +1 -0
- package/dist/lib/tools/technical-indicators.tool.d.ts +16 -0
- package/dist/lib/tools/technical-indicators.tool.d.ts.map +1 -0
- package/dist/lib/tools/technical-indicators.tool.js +88 -0
- package/dist/lib/tools/technical-indicators.tool.js.map +1 -0
- package/dist/lib/tools/us-stock-search.tool.d.ts +16 -0
- package/dist/lib/tools/us-stock-search.tool.d.ts.map +1 -0
- package/dist/lib/tools/us-stock-search.tool.js +42 -0
- package/dist/lib/tools/us-stock-search.tool.js.map +1 -0
- package/dist/lib/toolset.d.ts +10 -0
- package/dist/lib/toolset.d.ts.map +1 -0
- package/dist/lib/toolset.js +31 -0
- package/dist/lib/toolset.js.map +1 -0
- package/dist/lib/types.d.ts +85 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +13 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 股票市场数据客户端
|
|
3
|
+
* 支持新浪财经 (A股、港股) 和腾讯财经 (美股)
|
|
4
|
+
*/
|
|
5
|
+
import { MarketType } from './types.js';
|
|
6
|
+
import iconv from 'iconv-lite';
|
|
7
|
+
const SINA_QUOTE_URL = 'https://hq.sinajs.cn/list=';
|
|
8
|
+
const SINA_KLINE_URL = 'https://quotes.sina.cn/cn/api/json_v2.php/CN_MarketDataService.getKLineData';
|
|
9
|
+
const YAHOO_FINANCE_QUOTE_URL = 'https://query1.finance.yahoo.com/v8/finance/chart/';
|
|
10
|
+
const YAHOO_FINANCE_SEARCH_URL = 'https://query1.finance.yahoo.com/v1/finance/search';
|
|
11
|
+
const EASTMONEY_MONEY_FLOW_URL = 'https://push2.eastmoney.com/api/qt/stock/fflow/kline/get';
|
|
12
|
+
const EASTMONEY_MONEY_FLOW_DAY_URL = 'https://push2.eastmoney.com/api/qt/stock/fflow/daykline/get';
|
|
13
|
+
const EASTMONEY_MONEY_FLOW_HIS_URL = 'https://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get';
|
|
14
|
+
const SINA_MONEY_FLOW_URL = 'https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/MoneyFlow.ssl_qsfx_lscjfb';
|
|
15
|
+
const EASTMONEY_UT = 'fa5fd1943c7b386f172d6893dbfba10b';
|
|
16
|
+
const DEFAULT_HEADERS = {
|
|
17
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
18
|
+
'Referer': 'https://finance.sina.com.cn',
|
|
19
|
+
'Accept': '*/*',
|
|
20
|
+
};
|
|
21
|
+
const EXPECTED_NETWORK_ERROR_CODES = new Set([
|
|
22
|
+
'ECONNRESET',
|
|
23
|
+
'ENOTFOUND',
|
|
24
|
+
'EAI_AGAIN',
|
|
25
|
+
'ETIMEDOUT',
|
|
26
|
+
'ECONNABORTED',
|
|
27
|
+
'UND_ERR_SOCKET',
|
|
28
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
29
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
30
|
+
'UND_ERR_RESPONSE_STATUS_CODE',
|
|
31
|
+
]);
|
|
32
|
+
function getErrorCode(error) {
|
|
33
|
+
const err = error;
|
|
34
|
+
return String(err?.code || err?.cause?.code || err?.errno || '').trim().toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
function getErrorStatus(error) {
|
|
37
|
+
const err = error;
|
|
38
|
+
const status = err?.status || err?.response?.status || err?.cause?.statusCode;
|
|
39
|
+
return Number.isFinite(status) ? Number(status) : null;
|
|
40
|
+
}
|
|
41
|
+
function briefError(error) {
|
|
42
|
+
const err = error;
|
|
43
|
+
const code = getErrorCode(error);
|
|
44
|
+
const status = getErrorStatus(error);
|
|
45
|
+
const message = String(err?.message || err?.cause?.message || 'unknown error').replace(/\s+/g, ' ').trim();
|
|
46
|
+
const parts = [code ? `code=${code}` : '', status ? `status=${status}` : '', `msg=${message}`].filter(Boolean);
|
|
47
|
+
return parts.join(' ');
|
|
48
|
+
}
|
|
49
|
+
function isExpectedNetworkError(error) {
|
|
50
|
+
const code = getErrorCode(error);
|
|
51
|
+
if (code && EXPECTED_NETWORK_ERROR_CODES.has(code))
|
|
52
|
+
return true;
|
|
53
|
+
const status = getErrorStatus(error);
|
|
54
|
+
if (status === 403 || status === 429 || (status !== null && status >= 500))
|
|
55
|
+
return true;
|
|
56
|
+
const message = String(error?.message || '').toLowerCase();
|
|
57
|
+
return message.includes('socket hang up') || message.includes('fetch failed');
|
|
58
|
+
}
|
|
59
|
+
export function detectMarket(code) {
|
|
60
|
+
const cleanCode = code.replace(/^(sh|sz|hk|us)/i, '');
|
|
61
|
+
if (/^[a-zA-Z]{1,5}$/.test(cleanCode) || /^us/i.test(code)) {
|
|
62
|
+
return MarketType.US;
|
|
63
|
+
}
|
|
64
|
+
if (/^\d{5}$/.test(cleanCode) || /^hk/i.test(code)) {
|
|
65
|
+
return MarketType.HK;
|
|
66
|
+
}
|
|
67
|
+
if (/^6\d{5}$/.test(cleanCode)) {
|
|
68
|
+
return MarketType.SH;
|
|
69
|
+
}
|
|
70
|
+
if (/^(0|3)\d{5}$/.test(cleanCode)) {
|
|
71
|
+
return MarketType.SZ;
|
|
72
|
+
}
|
|
73
|
+
return MarketType.SH;
|
|
74
|
+
}
|
|
75
|
+
export function formatCodeForSina(code, market) {
|
|
76
|
+
const cleanCode = code.replace(/^(sh|sz|hk|us)/i, '');
|
|
77
|
+
const detectedMarket = market || detectMarket(code);
|
|
78
|
+
switch (detectedMarket) {
|
|
79
|
+
case MarketType.HK:
|
|
80
|
+
return `hk${cleanCode.padStart(5, '0')}`;
|
|
81
|
+
case MarketType.SH:
|
|
82
|
+
return `sh${cleanCode.padStart(6, '0')}`;
|
|
83
|
+
case MarketType.SZ:
|
|
84
|
+
return `sz${cleanCode.padStart(6, '0')}`;
|
|
85
|
+
default:
|
|
86
|
+
return cleanCode;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function normalizeCode(code, market) {
|
|
90
|
+
const cleanCode = code.replace(/^(sh|sz|hk|us)/i, '');
|
|
91
|
+
const detectedMarket = market || detectMarket(code);
|
|
92
|
+
if (detectedMarket === MarketType.US) {
|
|
93
|
+
return cleanCode.toUpperCase();
|
|
94
|
+
}
|
|
95
|
+
if (detectedMarket === MarketType.HK) {
|
|
96
|
+
return cleanCode.padStart(5, '0');
|
|
97
|
+
}
|
|
98
|
+
return cleanCode.padStart(6, '0');
|
|
99
|
+
}
|
|
100
|
+
async function fetchSinaQuotes(sinaCodes) {
|
|
101
|
+
const result = new Map();
|
|
102
|
+
if (sinaCodes.length === 0)
|
|
103
|
+
return result;
|
|
104
|
+
const url = `${SINA_QUOTE_URL}${sinaCodes.join(',')}`;
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch(url, {
|
|
107
|
+
method: 'GET',
|
|
108
|
+
headers: DEFAULT_HEADERS,
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
return result;
|
|
112
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
113
|
+
const text = iconv.decode(buffer, 'gbk');
|
|
114
|
+
const regex = /var hq_str_([^=]+)="([^"]*)"/g;
|
|
115
|
+
let match;
|
|
116
|
+
while ((match = regex.exec(text)) !== null) {
|
|
117
|
+
const sinaCode = match[1];
|
|
118
|
+
const data = match[2];
|
|
119
|
+
if (!data || data.trim() === '')
|
|
120
|
+
continue;
|
|
121
|
+
const fields = data.split(',');
|
|
122
|
+
if (fields.length < 10)
|
|
123
|
+
continue;
|
|
124
|
+
const quote = parseSinaQuoteFields(sinaCode, fields);
|
|
125
|
+
if (quote) {
|
|
126
|
+
result.set(quote.code, quote);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const level = isExpectedNetworkError(error) ? 'warn' : 'error';
|
|
132
|
+
console[level](`[stock-market] Sina quotes failed: ${briefError(error)}`);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
function parseSinaQuoteFields(sinaCode, fields) {
|
|
137
|
+
try {
|
|
138
|
+
const code = sinaCode.replace(/^(sh|sz|hk)/, '');
|
|
139
|
+
const market = detectMarket(code);
|
|
140
|
+
let name = '';
|
|
141
|
+
let open = 0, preClose = 0, price = 0, high = 0, low = 0;
|
|
142
|
+
let volume = 0, amount = 0, change = 0, changePercent = 0;
|
|
143
|
+
let date = '', time = '';
|
|
144
|
+
if (market === MarketType.HK) {
|
|
145
|
+
name = fields[1] || fields[0] || '';
|
|
146
|
+
open = parseFloat(fields[2]) || 0;
|
|
147
|
+
preClose = parseFloat(fields[3]) || 0;
|
|
148
|
+
high = parseFloat(fields[4]) || 0;
|
|
149
|
+
low = parseFloat(fields[5]) || 0;
|
|
150
|
+
price = parseFloat(fields[6]) || 0;
|
|
151
|
+
change = parseFloat(fields[7]);
|
|
152
|
+
if (Number.isNaN(change))
|
|
153
|
+
change = price - preClose;
|
|
154
|
+
changePercent = parseFloat(fields[8]);
|
|
155
|
+
if (Number.isNaN(changePercent))
|
|
156
|
+
changePercent = preClose > 0 ? (change / preClose) * 100 : 0;
|
|
157
|
+
amount = parseFloat(fields[11]) || 0;
|
|
158
|
+
volume = parseFloat(fields[12]) || 0;
|
|
159
|
+
date = fields[17] || '';
|
|
160
|
+
time = fields[18] || '';
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
name = fields[0] || '';
|
|
164
|
+
open = parseFloat(fields[1]) || 0;
|
|
165
|
+
preClose = parseFloat(fields[2]) || 0;
|
|
166
|
+
price = parseFloat(fields[3]) || 0;
|
|
167
|
+
high = parseFloat(fields[4]) || 0;
|
|
168
|
+
low = parseFloat(fields[5]) || 0;
|
|
169
|
+
volume = parseFloat(fields[8]) || 0;
|
|
170
|
+
amount = parseFloat(fields[9]) || 0;
|
|
171
|
+
change = price - preClose;
|
|
172
|
+
changePercent = preClose > 0 ? (change / preClose) * 100 : 0;
|
|
173
|
+
date = fields[30] || '';
|
|
174
|
+
time = fields[31] || '';
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
code: normalizeCode(code, market),
|
|
178
|
+
name,
|
|
179
|
+
market,
|
|
180
|
+
price,
|
|
181
|
+
open,
|
|
182
|
+
high,
|
|
183
|
+
low,
|
|
184
|
+
preClose,
|
|
185
|
+
volume,
|
|
186
|
+
amount,
|
|
187
|
+
change,
|
|
188
|
+
changePercent: Math.round(changePercent * 100) / 100,
|
|
189
|
+
time: `${date} ${time}`.trim(),
|
|
190
|
+
source: 'sina'
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export async function fetchRealtimeQuotes(codes) {
|
|
198
|
+
const result = new Map();
|
|
199
|
+
const sinaCodes = [];
|
|
200
|
+
const usCodes = [];
|
|
201
|
+
for (const code of codes) {
|
|
202
|
+
const market = detectMarket(code);
|
|
203
|
+
if (market === MarketType.US) {
|
|
204
|
+
usCodes.push(code.toUpperCase().replace(/^US/i, ''));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
sinaCodes.push(formatCodeForSina(code, market));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const sinaQuotes = await fetchSinaQuotes(sinaCodes);
|
|
211
|
+
sinaQuotes.forEach((quote, code) => result.set(code, quote));
|
|
212
|
+
const usQuotes = await fetchUSStockQuotes(usCodes);
|
|
213
|
+
usQuotes.forEach((quote, code) => result.set(code, quote));
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
async function fetchUSStockQuotes(symbols) {
|
|
217
|
+
const result = new Map();
|
|
218
|
+
if (symbols.length === 0)
|
|
219
|
+
return result;
|
|
220
|
+
try {
|
|
221
|
+
for (const symbol of symbols) {
|
|
222
|
+
const quote = await fetchSingleUSQuote(symbol);
|
|
223
|
+
if (quote) {
|
|
224
|
+
result.set(quote.code, quote);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
const level = isExpectedNetworkError(error) ? 'warn' : 'error';
|
|
230
|
+
console[level](`[stock-market] US quotes batch failed: ${briefError(error)}`);
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
async function fetchSingleUSQuote(symbol) {
|
|
235
|
+
try {
|
|
236
|
+
const url = `${YAHOO_FINANCE_QUOTE_URL}${symbol}?interval=1d&range=1d`;
|
|
237
|
+
const response = await fetch(url, {
|
|
238
|
+
method: 'GET',
|
|
239
|
+
headers: {
|
|
240
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
if (!response.ok)
|
|
244
|
+
return null;
|
|
245
|
+
const data = await response.json();
|
|
246
|
+
if (!data?.chart?.result?.[0])
|
|
247
|
+
return null;
|
|
248
|
+
const result = data.chart.result[0];
|
|
249
|
+
const meta = result.meta || {};
|
|
250
|
+
const quote = result.indicators?.quote?.[0] || {};
|
|
251
|
+
const price = meta.regularMarketPrice || 0;
|
|
252
|
+
const preClose = meta.chartPreviousClose || meta.previousClose || 0;
|
|
253
|
+
const change = price - preClose;
|
|
254
|
+
const changePercent = preClose > 0 ? (change / preClose) * 100 : 0;
|
|
255
|
+
return {
|
|
256
|
+
code: symbol.toUpperCase(),
|
|
257
|
+
name: meta.shortName || symbol,
|
|
258
|
+
market: MarketType.US,
|
|
259
|
+
price: Math.round(price * 100) / 100,
|
|
260
|
+
open: Math.round((quote.open?.[0] || 0) * 100) / 100,
|
|
261
|
+
high: Math.round((quote.high?.[0] || 0) * 100) / 100,
|
|
262
|
+
low: Math.round((quote.low?.[0] || 0) * 100) / 100,
|
|
263
|
+
preClose: Math.round(preClose * 100) / 100,
|
|
264
|
+
volume: quote.volume?.[0] || 0,
|
|
265
|
+
amount: 0,
|
|
266
|
+
change: Math.round(change * 100) / 100,
|
|
267
|
+
changePercent: Math.round(changePercent * 100) / 100,
|
|
268
|
+
time: meta.regularMarketTime ? new Date(meta.regularMarketTime * 1000).toISOString() : '',
|
|
269
|
+
source: 'yahoo'
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
const level = isExpectedNetworkError(error) ? 'warn' : 'error';
|
|
274
|
+
console[level](`[stock-market] US quote ${symbol} failed: ${briefError(error)}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export async function fetchKlineData(code, period = 'day', count = 100) {
|
|
279
|
+
const market = detectMarket(code);
|
|
280
|
+
if (market === MarketType.US) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const sinaCode = formatCodeForSina(code, market);
|
|
284
|
+
try {
|
|
285
|
+
const params = new URLSearchParams({
|
|
286
|
+
symbol: sinaCode,
|
|
287
|
+
scale: period === 'day' ? '240' : period === 'week' ? '1680' : '7200',
|
|
288
|
+
datalen: String(count),
|
|
289
|
+
});
|
|
290
|
+
const url = `${SINA_KLINE_URL}?${params}`;
|
|
291
|
+
const response = await fetch(url, {
|
|
292
|
+
method: 'GET',
|
|
293
|
+
headers: DEFAULT_HEADERS,
|
|
294
|
+
});
|
|
295
|
+
if (!response.ok)
|
|
296
|
+
return null;
|
|
297
|
+
const data = await response.json();
|
|
298
|
+
if (!Array.isArray(data) || data.length === 0)
|
|
299
|
+
return null;
|
|
300
|
+
const points = data.map(item => ({
|
|
301
|
+
date: item.day || '',
|
|
302
|
+
open: parseFloat(item.open) || 0,
|
|
303
|
+
high: parseFloat(item.high) || 0,
|
|
304
|
+
low: parseFloat(item.low) || 0,
|
|
305
|
+
close: parseFloat(item.close) || 0,
|
|
306
|
+
volume: parseFloat(item.volume) || 0,
|
|
307
|
+
}));
|
|
308
|
+
return {
|
|
309
|
+
code: normalizeCode(code, market),
|
|
310
|
+
market,
|
|
311
|
+
period,
|
|
312
|
+
data: points
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
const level = isExpectedNetworkError(error) ? 'warn' : 'error';
|
|
317
|
+
console[level](`[stock-market] K-line failed: ${briefError(error)}`);
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
export function calculateMA(data, periods) {
|
|
322
|
+
const result = {};
|
|
323
|
+
for (const period of periods) {
|
|
324
|
+
if (data.length < period)
|
|
325
|
+
continue;
|
|
326
|
+
const slice = data.slice(-period);
|
|
327
|
+
const sum = slice.reduce((acc, item) => acc + item.close, 0);
|
|
328
|
+
result[`ma${period}`] = Math.round((sum / period) * 100) / 100;
|
|
329
|
+
}
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
export function calculateMACD(data) {
|
|
333
|
+
if (data.length < 35)
|
|
334
|
+
return null;
|
|
335
|
+
const closes = data.map(d => d.close);
|
|
336
|
+
const ema12 = calculateEMA(closes, 12);
|
|
337
|
+
const ema26 = calculateEMA(closes, 26);
|
|
338
|
+
if (!ema12 || !ema26)
|
|
339
|
+
return null;
|
|
340
|
+
const dif = ema12 - ema26;
|
|
341
|
+
const deaList = [];
|
|
342
|
+
for (let i = 0; i < data.length; i++) {
|
|
343
|
+
const ema12Val = calculateEMA(closes.slice(0, i + 1), 12);
|
|
344
|
+
const ema26Val = calculateEMA(closes.slice(0, i + 1), 26);
|
|
345
|
+
if (ema12Val && ema26Val) {
|
|
346
|
+
deaList.push(ema12Val - ema26Val);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const dea = deaList.length >= 9 ? calculateEMA(deaList, 9) || 0 : 0;
|
|
350
|
+
const macd = (dif - dea) * 2;
|
|
351
|
+
return {
|
|
352
|
+
dif: Math.round(dif * 100) / 100,
|
|
353
|
+
dea: Math.round(dea * 100) / 100,
|
|
354
|
+
macd: Math.round(macd * 100) / 100
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function calculateEMA(data, period) {
|
|
358
|
+
if (data.length < period)
|
|
359
|
+
return null;
|
|
360
|
+
const k = 2 / (period + 1);
|
|
361
|
+
let ema = data.slice(0, period).reduce((a, b) => a + b) / period;
|
|
362
|
+
for (let i = period; i < data.length; i++) {
|
|
363
|
+
ema = data[i] * k + ema * (1 - k);
|
|
364
|
+
}
|
|
365
|
+
return ema;
|
|
366
|
+
}
|
|
367
|
+
export function calculateRSI(data, period = 14) {
|
|
368
|
+
if (data.length < period + 1)
|
|
369
|
+
return null;
|
|
370
|
+
let gains = 0;
|
|
371
|
+
let losses = 0;
|
|
372
|
+
for (let i = data.length - period; i < data.length; i++) {
|
|
373
|
+
const change = data[i].close - data[i - 1].close;
|
|
374
|
+
if (change > 0) {
|
|
375
|
+
gains += change;
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
losses -= change;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const avgGain = gains / period;
|
|
382
|
+
const avgLoss = losses / period;
|
|
383
|
+
if (avgLoss === 0)
|
|
384
|
+
return 100;
|
|
385
|
+
const rs = avgGain / avgLoss;
|
|
386
|
+
return Math.round((100 - (100 / (1 + rs))) * 100) / 100;
|
|
387
|
+
}
|
|
388
|
+
export function calculateKDJ(data) {
|
|
389
|
+
if (data.length < 9)
|
|
390
|
+
return null;
|
|
391
|
+
const period = 9;
|
|
392
|
+
const recent = data.slice(-period);
|
|
393
|
+
const highest = Math.max(...recent.map(d => d.high));
|
|
394
|
+
const lowest = Math.min(...recent.map(d => d.low));
|
|
395
|
+
const close = recent[recent.length - 1].close;
|
|
396
|
+
if (highest === lowest)
|
|
397
|
+
return null;
|
|
398
|
+
const rsv = ((close - lowest) / (highest - lowest)) * 100;
|
|
399
|
+
const k = Math.round(rsv * 100) / 100;
|
|
400
|
+
const d = Math.round(k * 100) / 100;
|
|
401
|
+
const j = Math.round((3 * k - 2 * d) * 100) / 100;
|
|
402
|
+
return { k, d, j };
|
|
403
|
+
}
|
|
404
|
+
export async function searchStock(keyword) {
|
|
405
|
+
const results = [];
|
|
406
|
+
const q = (keyword || '').trim();
|
|
407
|
+
if (!q)
|
|
408
|
+
return [];
|
|
409
|
+
try {
|
|
410
|
+
const url = `${YAHOO_FINANCE_SEARCH_URL}?q=${encodeURIComponent(q)}"esCount=10&newsCount=0`;
|
|
411
|
+
const response = await fetch(url, {
|
|
412
|
+
method: 'GET',
|
|
413
|
+
headers: {
|
|
414
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
if (response.ok) {
|
|
418
|
+
const data = await response.json();
|
|
419
|
+
const quotes = Array.isArray(data?.quotes) ? data.quotes : [];
|
|
420
|
+
for (const item of quotes) {
|
|
421
|
+
if (item?.quoteType === 'EQUITY' || item?.quoteType === 'ETF') {
|
|
422
|
+
const symbol = String(item.symbol || '').trim();
|
|
423
|
+
if (!symbol)
|
|
424
|
+
continue;
|
|
425
|
+
const shortName = String(item.shortname || item.longname || symbol).trim();
|
|
426
|
+
if (!shortName)
|
|
427
|
+
continue;
|
|
428
|
+
results.push({
|
|
429
|
+
code: symbol,
|
|
430
|
+
name: shortName,
|
|
431
|
+
market: MarketType.US,
|
|
432
|
+
type: item.quoteType
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
const level = response.status === 403 || response.status === 429 || response.status >= 500 ? 'warn' : 'error';
|
|
439
|
+
console[level](`[stock-market] Yahoo search non-OK: status=${response.status}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
const level = isExpectedNetworkError(error) ? 'warn' : 'error';
|
|
444
|
+
console[level](`[stock-market] Yahoo search failed: ${briefError(error)}`);
|
|
445
|
+
}
|
|
446
|
+
if (results.length > 0) {
|
|
447
|
+
return dedupeUSSearchResults(results);
|
|
448
|
+
}
|
|
449
|
+
return fallbackUSStockSearch(q);
|
|
450
|
+
}
|
|
451
|
+
export async function fetchMoneyFlow(code) {
|
|
452
|
+
const market = detectMarket(code);
|
|
453
|
+
if (market === MarketType.US || market === MarketType.HK) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const normalizedCode = normalizeCode(code, market);
|
|
457
|
+
const secid = market === MarketType.SH ? `1.${normalizedCode}` : `0.${normalizedCode}`;
|
|
458
|
+
try {
|
|
459
|
+
const kline = await fetchMoneyFlowKline(secid);
|
|
460
|
+
if (kline) {
|
|
461
|
+
const parsed = parseMoneyFlowKline(kline);
|
|
462
|
+
if (parsed) {
|
|
463
|
+
return {
|
|
464
|
+
code: normalizedCode,
|
|
465
|
+
name: '',
|
|
466
|
+
market,
|
|
467
|
+
mainNetInflow: parsed.mainNetInflow,
|
|
468
|
+
mainNetInflowPercent: parsed.mainNetInflowPercent,
|
|
469
|
+
superLargeNetInflow: parsed.superLargeNetInflow,
|
|
470
|
+
largeNetInflow: parsed.largeNetInflow,
|
|
471
|
+
mediumNetInflow: parsed.mediumNetInflow,
|
|
472
|
+
smallNetInflow: parsed.smallNetInflow,
|
|
473
|
+
time: parsed.time,
|
|
474
|
+
source: 'eastmoney'
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
const level = isExpectedNetworkError(error) ? 'warn' : 'error';
|
|
481
|
+
console[level](`[stock-market] Money flow failed: ${briefError(error)}`);
|
|
482
|
+
}
|
|
483
|
+
// Fallback: Sina historical money-flow endpoint is usually more stable.
|
|
484
|
+
return fetchMoneyFlowFromSina(normalizedCode, market);
|
|
485
|
+
}
|
|
486
|
+
async function fetchMoneyFlowKline(secid) {
|
|
487
|
+
const endpoints = [
|
|
488
|
+
EASTMONEY_MONEY_FLOW_HIS_URL,
|
|
489
|
+
EASTMONEY_MONEY_FLOW_DAY_URL,
|
|
490
|
+
EASTMONEY_MONEY_FLOW_URL,
|
|
491
|
+
];
|
|
492
|
+
const profiles = [
|
|
493
|
+
{ klt: '101', lmt: '1' },
|
|
494
|
+
{ klt: '1', lmt: '1' },
|
|
495
|
+
];
|
|
496
|
+
const failures = [];
|
|
497
|
+
for (const endpoint of endpoints) {
|
|
498
|
+
for (const profile of profiles) {
|
|
499
|
+
try {
|
|
500
|
+
const params = new URLSearchParams({
|
|
501
|
+
secid,
|
|
502
|
+
klt: profile.klt,
|
|
503
|
+
lmt: profile.lmt,
|
|
504
|
+
ut: EASTMONEY_UT,
|
|
505
|
+
fields1: 'f1,f2,f3,f7',
|
|
506
|
+
fields2: 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61,f62,f63',
|
|
507
|
+
});
|
|
508
|
+
const url = `${endpoint}?${params}`;
|
|
509
|
+
const response = await fetch(url, {
|
|
510
|
+
method: 'GET',
|
|
511
|
+
headers: {
|
|
512
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
513
|
+
'Referer': 'https://data.eastmoney.com/',
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
if (!response.ok)
|
|
517
|
+
continue;
|
|
518
|
+
const data = await response.json();
|
|
519
|
+
const klines = data?.data?.klines;
|
|
520
|
+
if (Array.isArray(klines) && klines.length > 0) {
|
|
521
|
+
const latest = klines[klines.length - 1];
|
|
522
|
+
if (typeof latest === 'string' && latest.trim()) {
|
|
523
|
+
if (parseMoneyFlowKline(latest)) {
|
|
524
|
+
return latest;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (Array.isArray(latest)) {
|
|
528
|
+
const merged = latest.join(',');
|
|
529
|
+
if (parseMoneyFlowKline(merged)) {
|
|
530
|
+
return merged;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
failures.push(`${endpoint}(${profile.klt}/${profile.lmt}) ${briefError(error)}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (failures.length > 0) {
|
|
541
|
+
const samples = failures.slice(0, 2).join(' | ');
|
|
542
|
+
const suffix = failures.length > 2 ? ` | +${failures.length - 2} more` : '';
|
|
543
|
+
console.warn(`[stock-market] Money-flow endpoints failed for secid=${secid}: ${samples}${suffix}`);
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
function parseMoneyFlowKline(kline) {
|
|
548
|
+
const parts = (kline || '').split(',');
|
|
549
|
+
if (parts.length < 2)
|
|
550
|
+
return null;
|
|
551
|
+
const toNumber = (raw) => {
|
|
552
|
+
const value = Number(raw);
|
|
553
|
+
return Number.isFinite(value) ? value : 0;
|
|
554
|
+
};
|
|
555
|
+
// f51-f63 order:
|
|
556
|
+
// [date, main, small, medium, large, super, main%, small%, medium%, large%, super%, close, chg%]
|
|
557
|
+
if (parts.length >= 13) {
|
|
558
|
+
return {
|
|
559
|
+
time: parts[0] || '',
|
|
560
|
+
mainNetInflow: toNumber(parts[1]),
|
|
561
|
+
smallNetInflow: toNumber(parts[2]),
|
|
562
|
+
mediumNetInflow: toNumber(parts[3]),
|
|
563
|
+
largeNetInflow: toNumber(parts[4]),
|
|
564
|
+
superLargeNetInflow: toNumber(parts[5]),
|
|
565
|
+
mainNetInflowPercent: toNumber(parts[6]),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
// Compatible fallback for legacy/variant field order.
|
|
569
|
+
if (parts.length >= 12) {
|
|
570
|
+
return {
|
|
571
|
+
time: parts[0] || '',
|
|
572
|
+
mainNetInflow: toNumber(parts[1]),
|
|
573
|
+
mainNetInflowPercent: toNumber(parts[3]),
|
|
574
|
+
superLargeNetInflow: toNumber(parts[5]),
|
|
575
|
+
largeNetInflow: toNumber(parts[7]),
|
|
576
|
+
mediumNetInflow: toNumber(parts[9]),
|
|
577
|
+
smallNetInflow: toNumber(parts[11]),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
// Some endpoints may return a compact variant:
|
|
581
|
+
// [date, main, main%, super, large, small]
|
|
582
|
+
if (parts.length >= 6) {
|
|
583
|
+
const percentCandidate = toNumber(parts[2]);
|
|
584
|
+
return {
|
|
585
|
+
time: parts[0] || '',
|
|
586
|
+
mainNetInflow: toNumber(parts[1]),
|
|
587
|
+
mainNetInflowPercent: Math.abs(percentCandidate) <= 100 ? percentCandidate : 0,
|
|
588
|
+
superLargeNetInflow: toNumber(parts[3]),
|
|
589
|
+
largeNetInflow: toNumber(parts[4]),
|
|
590
|
+
mediumNetInflow: 0,
|
|
591
|
+
smallNetInflow: toNumber(parts[5]),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
async function fetchMoneyFlowFromSina(normalizedCode, market) {
|
|
597
|
+
const sinaCode = market === MarketType.SH ? `sh${normalizedCode}` : `sz${normalizedCode}`;
|
|
598
|
+
try {
|
|
599
|
+
const params = new URLSearchParams({
|
|
600
|
+
page: '1',
|
|
601
|
+
num: '1',
|
|
602
|
+
sort: 'opendate',
|
|
603
|
+
asc: '0',
|
|
604
|
+
daima: sinaCode,
|
|
605
|
+
});
|
|
606
|
+
const response = await fetch(`${SINA_MONEY_FLOW_URL}?${params.toString()}`, {
|
|
607
|
+
method: 'GET',
|
|
608
|
+
headers: {
|
|
609
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
610
|
+
'Referer': 'https://vip.stock.finance.sina.com.cn/',
|
|
611
|
+
'Accept': 'application/json, text/plain, */*',
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
if (!response.ok) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
const payload = await response.json();
|
|
618
|
+
const row = Array.isArray(payload) ? payload[0] : null;
|
|
619
|
+
if (!row) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
const toNumber = (raw) => {
|
|
623
|
+
const value = Number(raw);
|
|
624
|
+
return Number.isFinite(value) ? value : 0;
|
|
625
|
+
};
|
|
626
|
+
const ratioRaw = toNumber(row.ratioamount);
|
|
627
|
+
const ratioPercent = Math.abs(ratioRaw) <= 1 ? ratioRaw * 100 : ratioRaw;
|
|
628
|
+
return {
|
|
629
|
+
code: normalizedCode,
|
|
630
|
+
name: '',
|
|
631
|
+
market,
|
|
632
|
+
mainNetInflow: toNumber(row.netamount),
|
|
633
|
+
mainNetInflowPercent: ratioPercent,
|
|
634
|
+
superLargeNetInflow: toNumber(row.r0_net),
|
|
635
|
+
largeNetInflow: toNumber(row.r1_net),
|
|
636
|
+
mediumNetInflow: toNumber(row.r2_net),
|
|
637
|
+
smallNetInflow: toNumber(row.r3_net),
|
|
638
|
+
time: String(row.opendate || ''),
|
|
639
|
+
source: 'sina'
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
const level = isExpectedNetworkError(error) ? 'warn' : 'error';
|
|
644
|
+
console[level](`[stock-market] Sina money-flow failed: ${briefError(error)}`);
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function dedupeUSSearchResults(items) {
|
|
649
|
+
const map = new Map();
|
|
650
|
+
for (const item of items) {
|
|
651
|
+
const key = (item.code || '').toUpperCase();
|
|
652
|
+
if (!key)
|
|
653
|
+
continue;
|
|
654
|
+
if (!map.has(key)) {
|
|
655
|
+
map.set(key, {
|
|
656
|
+
...item,
|
|
657
|
+
code: key
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return Array.from(map.values()).slice(0, 20);
|
|
662
|
+
}
|
|
663
|
+
function fallbackUSStockSearch(keyword) {
|
|
664
|
+
const q = (keyword || '').trim().toLowerCase();
|
|
665
|
+
if (!q)
|
|
666
|
+
return [];
|
|
667
|
+
const universe = [
|
|
668
|
+
{ code: 'AAPL', name: 'Apple Inc.', market: MarketType.US, type: 'EQUITY' },
|
|
669
|
+
{ code: 'MSFT', name: 'Microsoft Corporation', market: MarketType.US, type: 'EQUITY' },
|
|
670
|
+
{ code: 'GOOGL', name: 'Alphabet Inc. Class A', market: MarketType.US, type: 'EQUITY' },
|
|
671
|
+
{ code: 'AMZN', name: 'Amazon.com, Inc.', market: MarketType.US, type: 'EQUITY' },
|
|
672
|
+
{ code: 'META', name: 'Meta Platforms, Inc.', market: MarketType.US, type: 'EQUITY' },
|
|
673
|
+
{ code: 'NVDA', name: 'NVIDIA Corporation', market: MarketType.US, type: 'EQUITY' },
|
|
674
|
+
{ code: 'TSLA', name: 'Tesla, Inc.', market: MarketType.US, type: 'EQUITY' },
|
|
675
|
+
{ code: 'AMD', name: 'Advanced Micro Devices, Inc.', market: MarketType.US, type: 'EQUITY' },
|
|
676
|
+
{ code: 'BABA', name: 'Alibaba Group Holding Limited', market: MarketType.US, type: 'EQUITY' },
|
|
677
|
+
{ code: 'PDD', name: 'PDD Holdings Inc.', market: MarketType.US, type: 'EQUITY' },
|
|
678
|
+
{ code: 'SPY', name: 'SPDR S&P 500 ETF Trust', market: MarketType.US, type: 'ETF' },
|
|
679
|
+
{ code: 'QQQ', name: 'Invesco QQQ Trust', market: MarketType.US, type: 'ETF' },
|
|
680
|
+
];
|
|
681
|
+
const matched = universe.filter((item) => {
|
|
682
|
+
const code = item.code.toLowerCase();
|
|
683
|
+
const name = item.name.toLowerCase();
|
|
684
|
+
return code.includes(q) || name.includes(q);
|
|
685
|
+
});
|
|
686
|
+
if (matched.length > 0)
|
|
687
|
+
return matched;
|
|
688
|
+
if (/^[a-z]{1,5}$/i.test(q)) {
|
|
689
|
+
return [{
|
|
690
|
+
code: q.toUpperCase(),
|
|
691
|
+
name: `${q.toUpperCase()} (symbol)`,
|
|
692
|
+
market: MarketType.US,
|
|
693
|
+
type: 'EQUITY'
|
|
694
|
+
}];
|
|
695
|
+
}
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
//# sourceMappingURL=market-client.js.map
|