@mzzsfy/dsh-stock-assistant 0.3.1 → 0.3.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 +10 -7
- package/package.json +5 -2
- package/src/application.mjs +34 -15
- package/src/catalog.mjs +60 -31
- package/src/client.js +1288 -190
- package/src/collector.mjs +16 -6
- package/src/market-model.mjs +39 -11
- package/src/providers/eastmoney.mjs +51 -16
- package/src/providers/sina.mjs +121 -98
- package/src/providers/tencent.mjs +23 -14
- package/src/providers/tushare.mjs +1 -1
- package/src/providers/xueqiu.mjs +81 -0
- package/src/schemas.mjs +32 -9
- package/src/service.mjs +17 -6
- package/vendor/lightweight-charts.standalone.production.js +7 -0
- package//345/205/274/345/256/271/346/200/247/346/265/213/350/257/225.md +101 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 腾讯公开行情接口适配器:quotes+kline(备源)。非官方端点,无SLA,随时可能失效;延迟未知,行情时间戳为来源快照时间。
|
|
2
|
-
import { normalizeQuote, parseQuoteTime, StockError } from '../market-model.mjs'
|
|
2
|
+
import { normalizeQuote, parseQuoteTime, StockError, MARKET_META, sharesFromLots, amountFromWan } from '../market-model.mjs'
|
|
3
3
|
// 官方默认端点表(key 供 instance.endpoints 覆盖, 反代/镜像场景由 ctx.endpoints 提供同名 key)
|
|
4
4
|
const DEFAULT_ENDPOINTS = {
|
|
5
5
|
quotes: 'https://qt.gtimg.cn/q=',
|
|
@@ -11,7 +11,9 @@ const SYMBOL_PREFIX = { XSHG: 'sh', XSHE: 'sz', XHKG: 'hk' }
|
|
|
11
11
|
// 美股 symbol 后缀由搜索联想 JYS 字段映射(2026-10-30 实测: suggest 返回 JYS="NASDAQ"/"NYSE"/"AMEX";
|
|
12
12
|
// 腾讯侧 .OQ/.N/裸代码同价有效, .A/.U 形态返回空数据——AMEX 走裸代码兜底)。
|
|
13
13
|
const JYS_TO_SUFFIX = { NASDAQ: 'OQ', NYSE: 'N', AMEX: '' }
|
|
14
|
-
|
|
14
|
+
// 美股行情端点要 us 前缀(实测 usAAPL 有效, 裸 AAPL 返回空);K线另有 JYS 后缀逻辑, 不复用这张表。
|
|
15
|
+
const isUSExchange = exchange => exchange === 'XNAS' || exchange === 'XNYS'
|
|
16
|
+
const symbolOf = security => isUSExchange(security.exchange) ? `us${security.code}` : SYMBOL_PREFIX[security.exchange] ? `${SYMBOL_PREFIX[security.exchange]}${security.code}` : `${security.code}`
|
|
15
17
|
// kline request 用 market 字段(exchange 是 security 形态的字段名); 港股 code 统一 5 位对齐腾讯 hk00700 实测形态。
|
|
16
18
|
const klineSymbolOf = request => {
|
|
17
19
|
const prefix = SYMBOL_PREFIX[request.market]
|
|
@@ -22,6 +24,13 @@ const klineSymbolOf = request => {
|
|
|
22
24
|
// kline 数据键随复权/周期漂移(qfqday/day/qfqweek/qfqmonth),取第一个数组值兜底。
|
|
23
25
|
const firstArray = data => Object.values(data).find(Array.isArray)
|
|
24
26
|
const num = v => { const n = parseFloat(v); return Number.isFinite(n) ? n : null }
|
|
27
|
+
// 行情时间戳三种写法: 沪深紧凑数字, 港股斜杠, 美股带连字符
|
|
28
|
+
const quoteStamp = raw => {
|
|
29
|
+
const compact = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(raw || '')
|
|
30
|
+
if (compact) return `${compact[1]}-${compact[2]}-${compact[3]} ${compact[4]}:${compact[5]}:${compact[6]}`
|
|
31
|
+
const loose = /^(\d{4})[-/](\d{2})[-/](\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(raw || '')
|
|
32
|
+
return loose ? `${loose[1]}-${loose[2]}-${loose[3]} ${loose[4]}:${loose[5]}:${loose[6] || '00'}` : null
|
|
33
|
+
}
|
|
25
34
|
|
|
26
35
|
export const tencent = {
|
|
27
36
|
descriptor: { adapterId: 'tencent', capabilities: ['quotes', 'kline'], markets: ['XSHG', 'XSHE', 'XHKG', 'XNAS', 'XNYS'], instruments: ['equity'], maxQuoteBatch: 60, credential: 'none', description: '腾讯公开接口:免凭据,行情与K线备源,非官方无SLA', endpointKeys: [{ key: 'quotes', label: '行情批量' }, { key: 'kline', label: 'K线历史' }] },
|
|
@@ -29,12 +38,12 @@ export const tencent = {
|
|
|
29
38
|
// kline 探针直接拉代表证券 3 根日K(quotes 不覆盖港股);quotes 探针沿用代表证券行。
|
|
30
39
|
if (request.capability === 'kline') {
|
|
31
40
|
const code = { XSHG: '600519', XSHE: '000001', XHKG: '00700' }[request.market]
|
|
32
|
-
if (!code) throw new StockError('UNSUPPORTED_CAPABILITY', '
|
|
41
|
+
if (!code) throw new StockError('UNSUPPORTED_CAPABILITY', '腾讯来源不提供该市场K线', 422)
|
|
33
42
|
const result = await this.kline({ ...request, securityId: `${request.market}:equity:${code}`, code, period: '1d', adjust: 'qfq', limit: 3 }, ctx)
|
|
34
43
|
if (!result.candles.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
|
|
35
44
|
return { status: 'ready', capability: 'kline', market: request.market, instrument: 'equity' }
|
|
36
45
|
}
|
|
37
|
-
const PROBE = { XSHG: '600519', XSHE: '000001' }
|
|
46
|
+
const PROBE = { XSHG: '600519', XSHE: '000001', XHKG: '00700', XNAS: 'AAPL', XNYS: 'BABA' }
|
|
38
47
|
const security = request.securities?.[0] || { exchange: request.market, code: PROBE[request.market] || '000001' }
|
|
39
48
|
const rows = await this.quotes({ ...request, securities: [security] }, ctx)
|
|
40
49
|
if (!rows.items.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
|
|
@@ -54,33 +63,33 @@ export const tencent = {
|
|
|
54
63
|
const text = new TextDecoder('gbk').decode(await response.arrayBuffer())
|
|
55
64
|
const seen = new Set(), items = []
|
|
56
65
|
for (const line of text.split(';')) {
|
|
57
|
-
const match = /v_(sh|sz)
|
|
66
|
+
const match = /v_((?:sh|sz|hk)\d{5,6}|us[A-Za-z.]+)="([^"]*)"/.exec(line)
|
|
58
67
|
if (!match) continue
|
|
59
|
-
const providerSymbol =
|
|
68
|
+
const providerSymbol = match[1], security = symbols.get(providerSymbol)
|
|
60
69
|
if (!security) continue
|
|
61
70
|
if (seen.has(providerSymbol)) throw new StockError('BAD_PAYLOAD', '报价身份冲突', 502)
|
|
62
71
|
seen.add(providerSymbol)
|
|
63
|
-
const f = match[
|
|
72
|
+
const f = match[2].split('~')
|
|
64
73
|
if (f.length < 38) throw new StockError('BAD_PAYLOAD', '来源字段数量异常', 502)
|
|
65
|
-
const
|
|
74
|
+
const volume = num(f[36]), amount = num(f[37])
|
|
66
75
|
items.push(normalizeQuote({
|
|
67
76
|
securityId: security.id, providerInstanceId: request.providerInstanceId, adapterId: 'tencent', providerSymbol,
|
|
68
77
|
last: f[3], previousClose: f[4], open: f[5], high: f[33], low: f[34],
|
|
69
|
-
volume:
|
|
70
|
-
amount:
|
|
71
|
-
quoteAt:
|
|
78
|
+
volume: volume === null ? null : String(sharesFromLots(volume, security.exchange)), volumeUnit: 'share',
|
|
79
|
+
amount: amount === null ? null : String(amountFromWan(amount, security.exchange)), amountUnit: MARKET_META[security.exchange]?.currency || 'CNY',
|
|
80
|
+
quoteAt: parseQuoteTime(quoteStamp(f[30])), timestampKind: 'trade',
|
|
72
81
|
receivedAt: ctx.now(), delayKind: 'unknown', delaySeconds: null, securityState: 'unknown',
|
|
73
82
|
}))
|
|
74
83
|
}
|
|
75
84
|
return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), items, errors: request.securities.filter(s => !seen.has(symbolOf(s))).map(s => ({ securityId: s.id, code: 'NO_DATA', message: '来源未返回此证券报价' })) }
|
|
76
85
|
},
|
|
77
86
|
async kline(request, ctx) {
|
|
78
|
-
if (request.period === 'trend') throw new StockError('UNSUPPORTED_CAPABILITY', '
|
|
87
|
+
if (request.period === 'trend') throw new StockError('UNSUPPORTED_CAPABILITY', '腾讯来源不提供分时数据', 422)
|
|
79
88
|
let symbol = klineSymbolOf(request)
|
|
80
89
|
if (request.market === 'XNAS' || request.market === 'XNYS') {
|
|
81
90
|
const mapped = ctx.usSymbols?.get(`US:${request.code}`)
|
|
82
91
|
const suffix = mapped ? JYS_TO_SUFFIX[mapped.jys] : undefined
|
|
83
|
-
if (suffix === undefined) throw new StockError('UNSUPPORTED_CAPABILITY', '
|
|
92
|
+
if (suffix === undefined) throw new StockError('UNSUPPORTED_CAPABILITY', '腾讯来源的美股K线需先经搜索建立代码映射', 422)
|
|
84
93
|
symbol = suffix ? `us${request.code}.${suffix}` : `us${request.code}`
|
|
85
94
|
}
|
|
86
95
|
const param = `${symbol},${{ '1d': 'day', '1w': 'week', '1M': 'month' }[request.period]},,,${request.limit},qfq`
|
|
@@ -103,7 +112,7 @@ export const tencent = {
|
|
|
103
112
|
if (!Array.isArray(row) || row.length < 6) throw new StockError('BAD_PAYLOAD', 'K线列数不足', 502)
|
|
104
113
|
if (String(row[0]) <= prevTime) throw new StockError('BAD_PAYLOAD', 'K线时间戳重复或乱序', 502)
|
|
105
114
|
prevTime = String(row[0])
|
|
106
|
-
candles.push({ time: String(row[0]), open: num(row[1]), close: num(row[2]), high: num(row[3]), low: num(row[4]), volume: num(row[5]) })
|
|
115
|
+
candles.push({ time: String(row[0]), open: num(row[1]), close: num(row[2]), high: num(row[3]), low: num(row[4]), volume: sharesFromLots(num(row[5]), request.market) })
|
|
107
116
|
}
|
|
108
117
|
// preClose 实测定案: data.{symbol}.qt.{symbol}[4] 为昨收(2026-10-30 实测 sz000001 值 11.54 与东财 f18 一致)。
|
|
109
118
|
const qt = Array.isArray(data?.qt?.[symbol]) ? data.qt[symbol] : null
|
|
@@ -84,7 +84,7 @@ export const tushare = {
|
|
|
84
84
|
seen.add(row.ts_code)
|
|
85
85
|
const security = securities.get(row.ts_code)
|
|
86
86
|
items.push(normalizeQuote({ securityId: security.id, last: row.close, previousClose: row.pre_close, open: row.open, high: row.high, low: row.low,
|
|
87
|
-
volume: row.vol == null ? null : String(row.vol), volumeUnit: 'share', amount: row.amount == null ? null : String(row.amount), amountUnit: 'CNY',
|
|
87
|
+
volume: row.vol == null ? null : String(Number(row.vol) * 100), volumeUnit: 'share', amount: row.amount == null ? null : String(row.amount), amountUnit: 'CNY',
|
|
88
88
|
providerInstanceId: request.providerInstanceId, adapterId: 'tushare', providerSymbol: row.ts_code, quoteAt: parseQuoteTime(row.trade_time), timestampKind: 'trade', receivedAt: ctx.now(), delayKind: 'unknown', delaySeconds: null, securityState: 'unknown' }))
|
|
89
89
|
}
|
|
90
90
|
return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), items, errors: request.securities.filter(s => !seen.has(symbol(s))).map(s => ({ securityId: s.id, code: 'NO_DATA', message: '来源未返回此证券报价' })) }
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// 雪球公开行情接口适配器:quotes(沪深港美)。
|
|
2
|
+
// 与新浪/东财的差别:同一请求可混装多市场,且额外给出换手率/振幅/市值/总股本。
|
|
3
|
+
// 接口需要 xq_a_token cookie,匿名访问首页即可拿到,本适配器惰性获取并按 TTL 缓存。
|
|
4
|
+
import { normalizeQuote, parseQuoteTime, StockError } from '../market-model.mjs'
|
|
5
|
+
const ENDPOINT = 'https://stock.xueqiu.com/v5/stock/batch/quote.json'
|
|
6
|
+
const HOME = 'https://xueqiu.com/'
|
|
7
|
+
const HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }
|
|
8
|
+
// cookie 有效期远长于一次会话, 缓存到进程内并按 TTL 刷新, 避免每个请求都先访问首页
|
|
9
|
+
const TOKEN_TTL = 30 * 60 * 1000
|
|
10
|
+
let token = { value: null, until: 0 }
|
|
11
|
+
// 沪深用大写市场前缀, 港股与美股直接用代码本身(实测 HK00700 返回空, 00700 才有数据)
|
|
12
|
+
const symbolOf = security => (security.exchange === 'XSHG' ? 'SH' : security.exchange === 'XSHE' ? 'SZ' : '') + security.code
|
|
13
|
+
const PROBE_SECURITY = { XSHG: '600519', XSHE: '000001', XHKG: '00700', XNAS: 'AAPL', XNYS: 'BABA' }
|
|
14
|
+
const quote = value => { const n = Number(value); return Number.isFinite(n) ? n.toFixed(2) : null }
|
|
15
|
+
|
|
16
|
+
export const xueqiu = {
|
|
17
|
+
descriptor: { adapterId: 'xueqiu', capabilities: ['quotes'], markets: ['XSHG', 'XSHE', 'XHKG', 'XNAS', 'XNYS'], instruments: ['equity'], maxQuoteBatch: 60, credential: 'none', description: '雪球公开接口:免凭据(匿名 token),沪深港美行情,可混装多市场,额外提供换手率与市值' },
|
|
18
|
+
async probe(request, ctx) {
|
|
19
|
+
const security = request.securities?.[0] || { exchange: request.market, code: PROBE_SECURITY[request.market] }
|
|
20
|
+
if (!security.code) throw new StockError('UNSUPPORTED_CAPABILITY', '雪球来源不提供该市场行情', 422)
|
|
21
|
+
const rows = await this.quotes({ ...request, securities: [security] }, ctx)
|
|
22
|
+
if (!rows.items.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
|
|
23
|
+
return { status: 'ready', capability: 'quotes', market: request.market, instrument: 'equity' }
|
|
24
|
+
},
|
|
25
|
+
async quotes(request, ctx) {
|
|
26
|
+
const symbols = new Map(request.securities.map(s => [symbolOf(s), s]))
|
|
27
|
+
let response
|
|
28
|
+
try {
|
|
29
|
+
response = await ctx.fetch(`${ENDPOINT}?symbol=${[...symbols.keys()].join(',')}`, { headers: { ...HEADERS, Cookie: await cookieOf(ctx), Referer: HOME }, signal: ctx.signal, redirect: 'error' })
|
|
30
|
+
// token 过期时来源返回 400/刷新一次重试, 让缓存失真自愈
|
|
31
|
+
if (response.status === 400) {
|
|
32
|
+
token = { value: null, until: 0 }
|
|
33
|
+
response = await ctx.fetch(`${ENDPOINT}?symbol=${[...symbols.keys()].join(',')}`, { headers: { ...HEADERS, Cookie: await cookieOf(ctx) }, signal: ctx.signal, redirect: 'error' })
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (ctx.signal.aborted) throw new StockError('CANCELLED', '请求已取消', 503)
|
|
37
|
+
if (e instanceof StockError) throw e
|
|
38
|
+
throw new StockError('NETWORK', '无法连接行情来源', 502, { retryable: true })
|
|
39
|
+
}
|
|
40
|
+
if (!response.ok) throw new StockError('NETWORK', '来源服务暂不可用', 502, { retryable: true })
|
|
41
|
+
const body = await response.json().catch(() => { throw new StockError('BAD_PAYLOAD', '来源响应不是JSON', 502) })
|
|
42
|
+
const items = body?.data?.items
|
|
43
|
+
if (!Array.isArray(items)) throw new StockError('BAD_PAYLOAD', '来源字段结构异常', 502)
|
|
44
|
+
const seen = new Set(), result = []
|
|
45
|
+
for (const entry of items) {
|
|
46
|
+
const row = entry?.quote
|
|
47
|
+
if (!row) continue
|
|
48
|
+
const security = symbols.get(String(row.symbol || ''))
|
|
49
|
+
if (!security) continue
|
|
50
|
+
if (seen.has(security.id)) throw new StockError('BAD_PAYLOAD', '报价身份冲突', 502)
|
|
51
|
+
seen.add(security.id)
|
|
52
|
+
// 雪球 volume 统一是股, amount 统一是元; 时间戳是毫秒 Unix
|
|
53
|
+
result.push(normalizeQuote({
|
|
54
|
+
securityId: security.id, providerInstanceId: request.providerInstanceId, adapterId: 'xueqiu', providerSymbol: row.symbol,
|
|
55
|
+
last: quote(row.current), previousClose: quote(row.last_close), open: quote(row.open), high: quote(row.high), low: quote(row.low),
|
|
56
|
+
volume: Number.isFinite(Number(row.volume)) ? String(Math.round(Number(row.volume))) : null, volumeUnit: 'share',
|
|
57
|
+
amount: Number.isFinite(Number(row.amount)) ? String(Math.round(Number(row.amount))) : null, amountUnit: String(row.currency || ''),
|
|
58
|
+
quoteAt: Number.isFinite(Number(row.timestamp)) ? new Date(Number(row.timestamp)).toISOString() : null, timestampKind: 'trade',
|
|
59
|
+
receivedAt: ctx.now(), delayKind: 'unknown', delaySeconds: null, securityState: 'unknown',
|
|
60
|
+
turnoverRate: Number.isFinite(Number(row.turnover_rate)) ? Number(row.turnover_rate) : null,
|
|
61
|
+
marketCapital: Number.isFinite(Number(row.market_capital)) ? Math.round(Number(row.market_capital)) : null,
|
|
62
|
+
}))
|
|
63
|
+
}
|
|
64
|
+
return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), items: result, errors: request.securities.filter(s => !seen.has(s.id)).map(s => ({ securityId: s.id, code: 'NO_DATA', message: '来源未返回此证券报价' })) }
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function cookieOf(ctx) {
|
|
69
|
+
if (token.value && token.until > ctx.now()) return token.value
|
|
70
|
+
let response
|
|
71
|
+
try { response = await ctx.fetch(HOME, { headers: HEADERS, signal: ctx.signal, redirect: 'follow' }) } catch (e) {
|
|
72
|
+
if (ctx.signal.aborted) throw new StockError('CANCELLED', '请求已取消', 503)
|
|
73
|
+
if (e instanceof StockError) throw e
|
|
74
|
+
throw new StockError('NETWORK', '无法连接行情来源', 502, { retryable: true })
|
|
75
|
+
}
|
|
76
|
+
const raw = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []
|
|
77
|
+
const value = raw.map(c => c.split(';')[0]).filter(c => c.includes('=')).join('; ')
|
|
78
|
+
if (!value) throw new StockError('BAD_PAYLOAD', '来源未返回访问令牌', 502)
|
|
79
|
+
token = { value, until: ctx.now() + TOKEN_TTL }
|
|
80
|
+
return value
|
|
81
|
+
}
|
package/src/schemas.mjs
CHANGED
|
@@ -2,6 +2,13 @@ import { z } from 'zod'
|
|
|
2
2
|
import { fail } from './market-model.mjs'
|
|
3
3
|
import { MARKET_META } from './market-model.mjs'
|
|
4
4
|
const id = z.string().regex(/^[a-z][a-z0-9-]{0,47}$/)
|
|
5
|
+
// 用户标在图上并参与盈亏计算的价格点位。kind 决定它在盈亏里扮演什么角色:
|
|
6
|
+
// cost 是持仓成本, target 是打算卖出的价
|
|
7
|
+
export const markSchema = z.strictObject({ id: z.string().min(1).max(40), price: z.number().positive(), kind: z.enum(['cost', 'target']) })
|
|
8
|
+
export const marksSchema = z.array(markSchema).max(12).default([]).superRefine((list, ctx) => {
|
|
9
|
+
if (new Set(list.map(mark => mark.id)).size !== list.length) ctx.addIssue({ code: 'custom', message: '价格标记 id 重复' })
|
|
10
|
+
if (new Set(list.map(mark => `${mark.kind}:${mark.price}`)).size !== list.length) ctx.addIssue({ code: 'custom', message: '同类型的价格不能重复' })
|
|
11
|
+
})
|
|
5
12
|
const rate = z.number().int().positive().max(100000)
|
|
6
13
|
export const capability = z.enum(['quotes', 'catalog', 'search', 'calendar', 'kline'])
|
|
7
14
|
export const marketSchema = z.enum(Object.keys(MARKET_META))
|
|
@@ -21,6 +28,8 @@ export const klineQuerySchema = z.strictObject({
|
|
|
21
28
|
securityId: z.string().regex(/^[\w]+:equity:[\w.]{1,15}$/),
|
|
22
29
|
period: z.enum(['trend', '1d', '1w', '1M']),
|
|
23
30
|
limit: z.coerce.number().int().min(20).max(1000).default(250),
|
|
31
|
+
// 强制刷新:客户端详情页刷新走 ?force=1,服务端据此跳过缓存读;缺省按缓存
|
|
32
|
+
force: z.enum(['0', '1']).optional(),
|
|
24
33
|
})
|
|
25
34
|
// 路由上限: 八市场×(quotes/catalog/search/calendar/kline) 全开 40 条封顶
|
|
26
35
|
const configSchema = z.strictObject({ providers: z.array(providerSchema).max(8), routes: z.array(routeSchema).max(40) })
|
|
@@ -53,7 +62,7 @@ export function parseConfiguration(value) {
|
|
|
53
62
|
}
|
|
54
63
|
return config
|
|
55
64
|
}
|
|
56
|
-
export const controlSchema = z.object({ revision: z.number().int().nonnegative(), routingRevision: z.number().int().nonnegative(), watchlist: z.object({ order: z.array(z.string()).max(100), items: z.record(z.string(), securitySchema.safeExtend({ alias: z.string(), pinned: z.boolean(),
|
|
65
|
+
export const controlSchema = z.object({ revision: z.number().int().nonnegative(), routingRevision: z.number().int().nonnegative(), watchlist: z.object({ order: z.array(z.string()).max(100), items: z.record(z.string(), securitySchema.safeExtend({ alias: z.string(), pinned: z.boolean(), marks: marksSchema, group: z.string().refine(s => [...s.trim()].length <= 12 && !/[\u0000-\u001f]/.test(s)).default('') })) }), preferences: preferencesSchema, providers: z.array(providerSchema), routes: z.array(routeSchema), operations: z.array(z.object({ id: z.uuid(), hash: z.string(), at: z.number(), result: z.object({ revision: z.number() }).passthrough(), action: z.string(), payload: z.unknown() })).max(256) }).superRefine((control, ctx) => {
|
|
57
66
|
const w = control.watchlist
|
|
58
67
|
if (new Set(w.order).size !== w.order.length || w.order.length !== Object.keys(w.items).length || w.order.some(id => w.items[id]?.id !== id)) ctx.addIssue({ code: 'custom', message: '自选序列与证券不一致' })
|
|
59
68
|
let unpinned = false
|
|
@@ -61,15 +70,29 @@ export const controlSchema = z.object({ revision: z.number().int().nonnegative()
|
|
|
61
70
|
try { parseConfiguration({ providers: control.providers, routes: control.routes }) } catch { ctx.addIssue({ code: 'custom', message: '来源配置不一致' }) }
|
|
62
71
|
})
|
|
63
72
|
export const domainSpec = { name: 'stock_assistant', version: 1, layout: 'single', tables: { control: { valueSchema: controlSchema }, cache: { valueSchema: z.object({ kind: z.string(), source: z.string(), fetchedAt: z.number(), payload: z.unknown() }) } } }
|
|
64
|
-
//
|
|
73
|
+
// 推荐配置:四个免凭据源,东财做八市场主力,其余按实测最强能力分派备份——公开源无SLA,能覆盖的市场都串上备源。
|
|
65
74
|
export const defaultConfig = () => {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
{ id:
|
|
70
|
-
{ id:
|
|
71
|
-
{ id:
|
|
75
|
+
const budget = (requestsPerMinute, capabilityLimits = {}) => ({ requestsPerMinute, dailyRequestLimit: null, billingUnit: 'request', symbolCreditsPerMinute: null, capabilityLimits })
|
|
76
|
+
const providers = [
|
|
77
|
+
{ id: 'em-default', adapterId: 'eastmoney', enabled: true, credentialEnv: null, quotaGroup: 'free-quote', budget: budget(60, { kline: 30 }) },
|
|
78
|
+
{ id: 'sn-backup', adapterId: 'sina', enabled: true, credentialEnv: null, quotaGroup: 'free-list', budget: budget(120, { catalog: 60 }) },
|
|
79
|
+
{ id: 'tx-backup', adapterId: 'tencent', enabled: true, credentialEnv: null, quotaGroup: 'free-backup', budget: budget(60, { kline: 20 }) },
|
|
80
|
+
{ id: 'xq-backup', adapterId: 'xueqiu', enabled: true, credentialEnv: null, quotaGroup: 'free-xq', budget: budget(60) },
|
|
81
|
+
]
|
|
82
|
+
// 备用源只在探针可验证的组合上声明: 腾讯美股K线需先由搜索建立代码映射, 探针期必然不可用
|
|
83
|
+
const klineBackup = new Set(['XSHG', 'XSHE', 'XHKG'])
|
|
84
|
+
// 目录覆盖八市场: 东财 clist 的 fs 对日韩英同样有节点(日股 3752 / 韩股 2866 / 英股股票 4608), 全量翻页在 50 页上限内
|
|
85
|
+
const catalogCovered = new Set(['XSHG', 'XSHE', 'XHKG', 'XNAS', 'XNYS', 'XJPX', 'XKRX', 'XLON'])
|
|
86
|
+
// 报价备源按市场派: 沪深港走新浪(免cookie、单位已是股), 美股走雪球(时间戳是UTC毫秒, 与东财同口径)
|
|
87
|
+
const quoteBackupOf = market => (market === 'XNAS' || market === 'XNYS' ? 'xq-backup' : ['XSHG', 'XSHE', 'XHKG'].includes(market) ? 'sn-backup' : null)
|
|
88
|
+
const route = (market, capability, primary, fallback = null) => ({ id: `${market.toLowerCase()}-${capability}`, capability, market, instrument: 'equity', primaryInstanceId: primary, fallbackInstanceIds: fallback ? [fallback] : [], failoverMode: fallback ? 'network' : 'off', maxDelaySeconds: null, acceptUnknownDelay: true })
|
|
89
|
+
const routes = ['XSHG', 'XSHE', 'XHKG', 'XNAS', 'XNYS', 'XJPX', 'XKRX', 'XLON'].flatMap(market => [
|
|
90
|
+
// 目录统一走东财: 一个源覆盖八个市场, 不必为沪深单开新浪分支;目录不支持备用源
|
|
91
|
+
...(catalogCovered.has(market) ? [route(market, 'catalog', 'em-default')] : []),
|
|
92
|
+
route(market, 'search', 'em-default'),
|
|
93
|
+
route(market, 'quotes', 'em-default', quoteBackupOf(market)),
|
|
94
|
+
route(market, 'kline', 'em-default', klineBackup.has(market) ? 'tx-backup' : null),
|
|
72
95
|
])
|
|
73
|
-
return { providers
|
|
96
|
+
return { providers, routes }
|
|
74
97
|
}
|
|
75
98
|
export const initialControl = () => ({ revision: 0, routingRevision: 0, watchlist: { order: [], items: {} }, preferences: { refreshSeconds: 15, colorMode: 'red-up' }, ...defaultConfig(), operations: [] })
|
package/src/service.mjs
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { fail, parseSecurityId } from './market-model.mjs'
|
|
3
|
-
import { securitySchema } from './schemas.mjs'
|
|
3
|
+
import { securitySchema, marksSchema } from './schemas.mjs'
|
|
4
4
|
// 自选身份八市场: parseSecurityId 是唯一事实源(市场/代码形态随 MARKET_META)
|
|
5
5
|
const securityId = z.string().refine(v => { try { parseSecurityId(v); return true } catch { return false } })
|
|
6
6
|
const payloads = {
|
|
7
7
|
add: z.strictObject({ securityId }), remove: z.strictObject({ securityId }),
|
|
8
8
|
restore: z.strictObject({ removeOperationId: z.uuid() }),
|
|
9
9
|
alias: z.strictObject({ securityId, alias: z.string().refine(s => [...s].length <= 30) }),
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
marks: z.strictObject({ securityId, marks: marksSchema }),
|
|
11
|
+
// 组名进来就是用户输入, 校验在入口做: 只靠 state 写入后的不变式兜底会把它报成 500 内部错误
|
|
12
|
+
group: z.strictObject({ securityIds: z.array(securityId).min(1), group: z.string().refine(s => { const t = s.trim(); return [...t].length <= 12 && !/[\u0000-\u001f]/.test(t) }, '分组名最长 12 字且不能含控制字符') }),
|
|
13
|
+
groupRename: z.strictObject({ from: z.string().min(1), to: z.string().refine(s => { const t = s.trim(); return t.length > 0 && [...t].length <= 12 && !/[\u0000-\u001f]/.test(t) }, '分组名最长 12 字且不能含控制字符') }),
|
|
13
14
|
groupRemove: z.strictObject({ group: z.string().min(1) }),
|
|
14
15
|
pin: z.strictObject({ securityId, pinned: z.boolean() }),
|
|
15
16
|
move: z.strictObject({ securityId, beforeId: securityId.nullable() }),
|
|
17
|
+
// 拖拽排序: 提交完整显示序, 前端算好顺序一次落库, 避免逐行 move 的中间态
|
|
18
|
+
reorder: z.strictObject({ order: z.array(securityId).min(1) }),
|
|
16
19
|
}
|
|
17
20
|
export function createWatchlistService(state, resolveSecurity, now = Date.now) {
|
|
18
21
|
return {
|
|
@@ -24,12 +27,20 @@ export function createWatchlistService(state, resolveSecurity, now = Date.now) {
|
|
|
24
27
|
const w = control.watchlist, item = w.items[p.securityId]
|
|
25
28
|
const insert = (id, beforeId) => { const index = w.order.indexOf(beforeId); w.order.splice(index < 0 ? w.order.length : index, 0, id) }
|
|
26
29
|
const regionEnd = pinned => w.order.find(id => w.items[id].pinned !== pinned && !w.items[id].pinned) || null
|
|
30
|
+
if (operation.action === 'reorder') {
|
|
31
|
+
const ids = p.order
|
|
32
|
+
if (ids.length !== w.order.length || new Set(ids).size !== ids.length || ids.some(id => !w.items[id])) fail('INVALID_ORDER', '排序参数无效')
|
|
33
|
+
// 置顶项必须连续占据最前段: 与渲染分段一致, 防止拖动把置顶项沉底
|
|
34
|
+
if (ids.some((id, index) => !w.items[id].pinned && ids.slice(index + 1).some(next => w.items[next].pinned))) fail('INVALID_ORDER', '置顶项必须保持在列表最前')
|
|
35
|
+
w.order = ids
|
|
36
|
+
return control
|
|
37
|
+
}
|
|
27
38
|
if (operation.action === 'add') {
|
|
28
39
|
if (item) return control
|
|
29
40
|
if (w.order.length >= 100) fail('LIMIT_REACHED', '最多添加100只自选', 422)
|
|
30
41
|
const security = resolveSecurity(p.securityId)
|
|
31
42
|
if (!security || !securitySchema.safeParse(security).success) fail('CANDIDATE_EXPIRED', '候选已过期,请重新搜索', 409)
|
|
32
|
-
w.items[p.securityId] = { ...security, alias: '', pinned: false,
|
|
43
|
+
w.items[p.securityId] = { ...security, alias: '', pinned: false, marks: [], group: '' }; w.order.push(p.securityId)
|
|
33
44
|
} else if (operation.action === 'restore') {
|
|
34
45
|
const deleted = control.operations.find(o => o.id === p.removeOperationId && o.action === 'remove')
|
|
35
46
|
const restore = deleted?.result.restore
|
|
@@ -52,7 +63,7 @@ export function createWatchlistService(state, resolveSecurity, now = Date.now) {
|
|
|
52
63
|
control.operationResult = { restore: { item, previousId: w.order[i - 1] || null, nextId: w.order[i + 1] || null } }
|
|
53
64
|
w.order.splice(i, 1); delete w.items[p.securityId]
|
|
54
65
|
} else if (operation.action === 'alias') item.alias = p.alias.trim()
|
|
55
|
-
else if (operation.action === '
|
|
66
|
+
else if (operation.action === 'marks') item.marks = p.marks
|
|
56
67
|
else if (operation.action === 'pin') {
|
|
57
68
|
if (item.pinned === p.pinned) return control
|
|
58
69
|
w.order = w.order.filter(id => id !== item.id); item.pinned = p.pinned
|