@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
package/src/collector.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { StockError, fail, makeSecurity } from './market-model.mjs'
|
|
2
2
|
// 报价时间距获取时间不超过该秒数视为实时; 免凭据公开源常缺延迟声明, 由时间戳差派生
|
|
3
3
|
const REALTIME_WINDOW_SECONDS = 90
|
|
4
|
-
// 适配器未声明延迟但带报价时间时, 按时差派生 delayKind; 缺时间戳保持 unknown
|
|
5
|
-
|
|
4
|
+
// 适配器未声明延迟但带报价时间时, 按时差派生 delayKind; 缺时间戳保持 unknown 不编造。
|
|
5
|
+
// 非交易时段(休市/午休/未开盘)报价停在最后成交那一刻, 时差是"多久没新数据"而不是"数据延迟", 单独标 closed 交给界面报时间点。
|
|
6
|
+
export function deriveDelay(quote, now, trading = true) {
|
|
6
7
|
if (quote.delayKind !== 'unknown' || !quote.quoteAt) return quote
|
|
7
8
|
const lagSeconds = Math.max(0, Math.round((now() - Date.parse(quote.quoteAt)) / 1000))
|
|
8
9
|
if (!Number.isFinite(lagSeconds)) return quote
|
|
9
|
-
if (lagSeconds <= REALTIME_WINDOW_SECONDS) quote.delayKind = 'realtime'
|
|
10
|
-
|
|
10
|
+
if (lagSeconds <= REALTIME_WINDOW_SECONDS) { quote.delayKind = 'realtime'; return quote }
|
|
11
|
+
if (!trading) { quote.delayKind = 'closed'; quote.delaySeconds = null; return quote }
|
|
12
|
+
quote.delayKind = 'delayed'; quote.delaySeconds = lagSeconds
|
|
11
13
|
return quote
|
|
12
14
|
}
|
|
13
15
|
const BUSINESS = entry => JSON.stringify(entry && { ...entry, lastAttemptAt: undefined, quote: entry.quote && { ...entry.quote, receivedAt: undefined } })
|
|
@@ -15,6 +17,12 @@ export function createCollector({ getControl, router, registry, executor, resolv
|
|
|
15
17
|
const quotes = new Map(), marketPhases = new Map(), flights = new Map(), rounds = new Map(), blocked = new Map(), failures = new Map(), controllers = new Set()
|
|
16
18
|
let generation = 0, timer, stopped = false, lastStart = -Infinity, manualWaiters = 0
|
|
17
19
|
const entries = () => Object.fromEntries([...quotes].map(([id, value]) => [id, structuredClone(value)]))
|
|
20
|
+
// 缓存新鲜度: 全部自选都有未过期报价才算新鲜, 用于进入面板时跳过重复拉取
|
|
21
|
+
const freshWithin = ms => {
|
|
22
|
+
const order = getControl().watchlist.order
|
|
23
|
+
if (!order.length) return false
|
|
24
|
+
return order.every(id => { const quote = quotes.get(id)?.quote; return quote && Number.isFinite(quote.receivedAt) && now() - quote.receivedAt < ms })
|
|
25
|
+
}
|
|
18
26
|
function apply(id, entry) {
|
|
19
27
|
const before = quotes.get(id)
|
|
20
28
|
quotes.set(id, entry); touched()
|
|
@@ -42,7 +50,9 @@ export function createCollector({ getControl, router, registry, executor, resolv
|
|
|
42
50
|
for (const quote of result.items) {
|
|
43
51
|
if (!requested.has(quote.securityId)) continue
|
|
44
52
|
if (map.has(quote.securityId) || quote.providerInstanceId !== instance.id) fail('BAD_PAYLOAD', '报价身份或来源冲突', 502)
|
|
45
|
-
|
|
53
|
+
// 只有明确处于非交易时段才改口径; 日历缺失(unknown)不能当成休市, 否则等于凭空断言收盘
|
|
54
|
+
const marketState = calendarState(securities.find(s => s.id === quote.securityId)?.exchange).state
|
|
55
|
+
deriveDelay(quote, now, marketState === 'unknown' || marketState === 'trading' || marketState === 'auction')
|
|
46
56
|
if (quote.delayKind === 'unknown' && !route.acceptUnknownDelay) fail('BAD_PAYLOAD', '路由不接受未知延迟行情', 422)
|
|
47
57
|
if (route.maxDelaySeconds !== null && quote.delayKind !== 'unknown' && quote.delaySeconds > route.maxDelaySeconds) fail('BAD_PAYLOAD', '报价延迟超过路由限制', 422)
|
|
48
58
|
map.set(quote.securityId, quote)
|
|
@@ -152,7 +162,7 @@ export function createCollector({ getControl, router, registry, executor, resolv
|
|
|
152
162
|
timer = timers.setTimeout(() => { refresh(undefined, { periodic: true }).catch(() => {}).finally(reconcile) }, Math.max(1000, wake - now())); timer?.unref?.()
|
|
153
163
|
}
|
|
154
164
|
return {
|
|
155
|
-
refresh, entries, reconcile,
|
|
165
|
+
refresh, entries, reconcile, freshWithin,
|
|
156
166
|
resume() { blocked.clear(); failures.clear(); reconcile() },
|
|
157
167
|
reset() { generation++; for (const c of controllers) c.abort(); flights.clear(); quotes.clear(); marketPhases.clear(); blocked.clear(); failures.clear(); lastStart = -Infinity; touched(); changed(); reconcile() },
|
|
158
168
|
cancelIdle() { timers.clearTimeout(timer); if (!active() && !manualWaiters) for (const c of controllers) c.abort() },
|
package/src/market-model.mjs
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
// 每市场的币种/时区/价格精度/代码形态/探针门控——securitySchema 与 makeSecurity 的派生源(来源标注见 design.md)
|
|
2
|
+
// sessions 是该市场连续竞价的时段(市场本地时间), 用来判断报价此刻处在交易中/午休/休市;
|
|
3
|
+
// 只描述周内时点, 法定节假日仍以数据本身(最后一根K线的日期)为准。
|
|
2
4
|
export const MARKET_META = {
|
|
3
|
-
XSHG: { currency: 'CNY', timezone: 'Asia/Shanghai', priceScale: 2, codePattern: /^\d{6}$/, verifiedByDefault: true },
|
|
4
|
-
XSHE: { currency: 'CNY', timezone: 'Asia/Shanghai', priceScale: 2, codePattern: /^\d{6}$/, verifiedByDefault: true },
|
|
5
|
-
XHKG: { currency: 'HKD', timezone: 'Asia/Hong_Kong', priceScale: 2, codePattern: /^\d{4,5}$/, verifiedByDefault: true },
|
|
6
|
-
XNAS: { currency: 'USD', timezone: 'America/New_York', priceScale: 2, codePattern: /^[A-Z]{1,5}$/, verifiedByDefault: true },
|
|
7
|
-
XNYS: { currency: 'USD', timezone: 'America/New_York', priceScale: 2, codePattern: /^[A-Z]{1,5}$/, verifiedByDefault: true },
|
|
8
|
-
XJPX: { currency: 'JPY', timezone: 'Asia/Tokyo', priceScale: 0, codePattern: /^\d{4}$/, verifiedByDefault: true },
|
|
9
|
-
XKRX: { currency: 'KRW', timezone: 'Asia/Seoul', priceScale: 0, codePattern: /^\d{6}$/, verifiedByDefault: true },
|
|
10
|
-
XLON: { currency: 'GBP', timezone: 'Europe/London', priceScale: 2, codePattern: /^[A-Z0-9.]{1,10}$/, verifiedByDefault: true },
|
|
5
|
+
XSHG: { currency: 'CNY', timezone: 'Asia/Shanghai', priceScale: 2, codePattern: /^\d{6}$/, verifiedByDefault: true, sessions: [['09:30', '11:30'], ['13:00', '15:00']] },
|
|
6
|
+
XSHE: { currency: 'CNY', timezone: 'Asia/Shanghai', priceScale: 2, codePattern: /^\d{6}$/, verifiedByDefault: true, sessions: [['09:30', '11:30'], ['13:00', '15:00']] },
|
|
7
|
+
XHKG: { currency: 'HKD', timezone: 'Asia/Hong_Kong', priceScale: 2, codePattern: /^\d{4,5}$/, verifiedByDefault: true, sessions: [['09:30', '12:00'], ['13:00', '16:00']] },
|
|
8
|
+
XNAS: { currency: 'USD', timezone: 'America/New_York', priceScale: 2, codePattern: /^[A-Z]{1,5}$/, verifiedByDefault: true, sessions: [['09:30', '16:00']] },
|
|
9
|
+
XNYS: { currency: 'USD', timezone: 'America/New_York', priceScale: 2, codePattern: /^[A-Z]{1,5}$/, verifiedByDefault: true, sessions: [['09:30', '16:00']] },
|
|
10
|
+
XJPX: { currency: 'JPY', timezone: 'Asia/Tokyo', priceScale: 0, codePattern: /^\d{4}$/, verifiedByDefault: true, sessions: [['09:00', '11:30'], ['12:30', '15:30']] },
|
|
11
|
+
XKRX: { currency: 'KRW', timezone: 'Asia/Seoul', priceScale: 0, codePattern: /^\d{6}$/, verifiedByDefault: true, sessions: [['09:00', '15:30']] },
|
|
12
|
+
XLON: { currency: 'GBP', timezone: 'Europe/London', priceScale: 2, codePattern: /^[A-Z0-9.]{1,10}$/, verifiedByDefault: true, sessions: [['08:00', '16:30']] },
|
|
11
13
|
}
|
|
14
|
+
// 报价此刻落在市场交易日的哪个阶段; 周末恒为休市, 节假日不做推断(没有日历能力时会误判)
|
|
15
|
+
export function sessionStateOf(exchange, nowMs) {
|
|
16
|
+
const meta = MARKET_META[exchange]
|
|
17
|
+
if (!meta?.sessions?.length) return 'unknown'
|
|
18
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone: meta.timezone, hour12: false, weekday: 'short', hour: '2-digit', minute: '2-digit' }).formatToParts(new Date(nowMs))
|
|
19
|
+
const pick = type => parts.find(part => part.type === type)?.value
|
|
20
|
+
if (pick('weekday') === 'Sat' || pick('weekday') === 'Sun') return 'closed'
|
|
21
|
+
const minutes = (Number(pick('hour')) % 24) * 60 + Number(pick('minute'))
|
|
22
|
+
const bounds = meta.sessions.map(([from, to]) => [toMinutes(from), toMinutes(to)])
|
|
23
|
+
if (minutes < bounds[0][0]) return 'preopen'
|
|
24
|
+
if (bounds.some(([from, to]) => minutes >= from && minutes < to)) return 'trading'
|
|
25
|
+
// 午休只存在于两段之间: 只比前一段的收尾会把收盘后也算成午休
|
|
26
|
+
if (bounds.some(([, to], index) => index < bounds.length - 1 && minutes >= to && minutes < bounds[index + 1][0])) return 'break'
|
|
27
|
+
return 'closed'
|
|
28
|
+
}
|
|
29
|
+
const toMinutes = stamp => { const [hh, mm] = stamp.split(':').map(Number); return hh * 60 + mm }
|
|
12
30
|
|
|
13
31
|
export function securityId(exchange, code, instrument = 'equity') {
|
|
14
32
|
const meta = MARKET_META[exchange]
|
|
@@ -22,12 +40,15 @@ export function parseSecurityId(securityIdValue) {
|
|
|
22
40
|
if (parts.length !== 3 || instrument !== 'equity' || !MARKET_META[exchange]) throw new Error('不支持的证券身份')
|
|
23
41
|
return { exchange, instrument, code: securityId(exchange, code, instrument).split(':')[2], meta: MARKET_META[exchange] }
|
|
24
42
|
}
|
|
43
|
+
// 定点基准是分;来源允许多于两位小数(美股常见三位),超出部分四舍五入到分
|
|
25
44
|
function cents(value) {
|
|
26
45
|
if (value === null || value === undefined || value === '') return null
|
|
27
46
|
const text = String(value)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
47
|
+
const match = /^(\d+)(?:\.(\d{1,6}))?$/.exec(text)
|
|
48
|
+
if (!match) return null
|
|
49
|
+
const [, whole, fraction = ''] = match
|
|
50
|
+
let n = BigInt(whole) * 100n + BigInt(fraction.slice(0, 2).padEnd(2, '0'))
|
|
51
|
+
if (fraction.length > 2 && Number(fraction[2]) >= 5) n += 1n
|
|
31
52
|
return n > 0n ? n : null
|
|
32
53
|
}
|
|
33
54
|
function decimal(n) {
|
|
@@ -61,6 +82,13 @@ export function makeSecurity(exchange, code, officialName, extra = {}) {
|
|
|
61
82
|
return { ...extra, id: securityId(exchange, code), exchange, code, officialName, instrument: 'equity', currency: meta.currency, timezone: meta.timezone, priceScale: meta.priceScale, listingStatus: 'listed' }
|
|
62
83
|
}
|
|
63
84
|
|
|
85
|
+
// 成交量单位换算: 沪深来源按手报价(1 手 = 100 股), 港美来源直接给股。对外统一按股呈现。
|
|
86
|
+
export const LOTS_EXCHANGES = new Set(['XSHG', 'XSHE'])
|
|
87
|
+
export const sharesFromLots = (volume, exchange) => (volume === null || volume === undefined ? null : LOTS_EXCHANGES.has(exchange) ? volume * 100 : volume)
|
|
88
|
+
// 成交额单位换算: 沪深来源按万元给, 港美来源直接给元。
|
|
89
|
+
export const LOTS_AMOUNT_EXCHANGES = new Set(['XSHG', 'XSHE'])
|
|
90
|
+
export const amountFromWan = (amount, exchange) => (amount === null || amount === undefined ? null : LOTS_AMOUNT_EXCHANGES.has(exchange) ? Math.round(amount * 10000) : amount)
|
|
91
|
+
|
|
64
92
|
export class StockError extends Error {
|
|
65
93
|
constructor(code, message, status = 400, extra = {}) {
|
|
66
94
|
super(message)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 东财公开行情接口适配器:quotes/catalog/search/kline。非官方端点,无SLA;延迟未知,f124为来源秒级时间戳。
|
|
2
|
-
import { normalizeQuote, makeSecurity, securityId, StockError, MARKET_META } from '../market-model.mjs'
|
|
2
|
+
import { normalizeQuote, makeSecurity, securityId, StockError, MARKET_META, sharesFromLots } from '../market-model.mjs'
|
|
3
3
|
// 官方默认端点表(key 供 instance.endpoints 覆盖, 反代/镜像场景由 ctx.endpoints 提供同名 key)
|
|
4
4
|
const DEFAULT_ENDPOINTS = {
|
|
5
5
|
quotes: 'https://push2.eastmoney.com/api/qt/ulist.np/get',
|
|
@@ -11,19 +11,25 @@ const DEFAULT_ENDPOINTS = {
|
|
|
11
11
|
const endpointOf = (ctx, key) => ctx.endpoints?.[key] || DEFAULT_ENDPOINTS[key]
|
|
12
12
|
// 东财市场代码:XSHG=1,XSHE=0;美股 secid 前缀由搜索 MktNum 动态归属(usSymbols),其余市场实测前缀。
|
|
13
13
|
const MARKET = { XSHG: '1', XSHE: '0' }
|
|
14
|
-
// 搜索联想 MktNum→交易所全集(实测: 1/0 沪深, 116 港, 105/106/107
|
|
15
|
-
const SEARCH_EXCHANGE = { 1: 'XSHG', 0: 'XSHE', 116: 'XHKG', 105: 'XNAS', 106: 'XNYS', 107: 'XNYS' }
|
|
14
|
+
// 搜索联想 MktNum→交易所全集(实测: 1/0 沪深, 116 港, 105/106/107 美三大, 176/177/155 日韩英)
|
|
15
|
+
const SEARCH_EXCHANGE = { 1: 'XSHG', 0: 'XSHE', 116: 'XHKG', 105: 'XNAS', 106: 'XNYS', 107: 'XNYS', 176: 'XJPX', 177: 'XKRX', 155: 'XLON' }
|
|
16
16
|
const SECID_PREFIX = { XSHG: '1', XSHE: '0', XHKG: '116', XNAS: '105', XNYS: '106', XJPX: '176', XKRX: '177', XLON: '155' }
|
|
17
17
|
const US_DEFAULT_MKT = '105'
|
|
18
|
+
// 美股三大市场号: 105 纳斯达克 / 106 纽约 / 107 美交所。secid 前缀必须精确匹配, 探针期据此逐个试探。
|
|
19
|
+
const US_MKTNUMS = ['105', '106', '107']
|
|
20
|
+
const isUSExchange = exchange => exchange === 'XNAS' || exchange === 'XNYS'
|
|
18
21
|
// klt 周期映射(实测): 日101/周102/月103; trends2 无 klt。
|
|
19
22
|
const KLT = { '1d': 101, '1w': 102, '1M': 103 }
|
|
20
|
-
|
|
21
|
-
const
|
|
23
|
+
// 目录 fs 按市场给出; 英股 m:155 含债券与结构性产品, 加 t:1 只取股票; 日韩不支持 t: 细分, 全量即股票
|
|
24
|
+
const LIST_FS = { XSHG: 'm:1+t:2,m:1+t:23', XSHE: 'm:0+t:6,m:0+t:80', XHKG: 'm:116+t:3,m:116+t:4', XNAS: 'm:105+t:1', XNYS: 'm:106+t:1,m:107+t:1', XJPX: 'm:176', XKRX: 'm:177', XLON: 'm:155+t:1' }
|
|
25
|
+
const SYMBOL_PREFIX = { 1: 'XSHG', 0: 'XSHE', 116: 'XHKG', 105: 'XNAS', 106: 'XNYS', 107: 'XNYS', 176: 'XJPX', 177: 'XKRX', 155: 'XLON' }
|
|
22
26
|
// 探针代表证券(实测可用的各市场流动性代表),quotes/kline 探针按市场取用。
|
|
23
27
|
const PROBE_SECURITY = { XSHG: '600519', XSHE: '000001', XHKG: '00700', XNAS: 'AAPL', XNYS: 'BABA', XJPX: '7203', XKRX: '005930', XLON: 'SHEL' }
|
|
24
28
|
// klines CSV 前 7 列固定语义(实测 fields2=f51-f57); f53=close 在 f54=high 前为东财原生序。
|
|
25
29
|
const KLINE_FIELDS = 'f51,f52,f53,f54,f55,f56,f57'
|
|
26
30
|
const TREND_FIELDS = 'f51,f52,f53,f54,f55,f56,f57,f58'
|
|
31
|
+
// 分时时间戳的来源时区: 东财统一用北京时间, 与证券所属市场无关
|
|
32
|
+
const SOURCE_ZONE = 'Asia/Shanghai'
|
|
27
33
|
// quotes secid 与 kline secidOf 同源(八市场前缀表);美股 quotes 同样走 usSymbols 动态归属。
|
|
28
34
|
const symbolOf = security => `${SECID_PREFIX[security.exchange]}.${security.code}`
|
|
29
35
|
const num = value => value == null || typeof value === 'number' && !Number.isFinite(value) ? null : value === '-' ? null : String(value)
|
|
@@ -40,7 +46,7 @@ async function getJson(ctx, url) {
|
|
|
40
46
|
return response.json().catch(() => { throw new StockError('BAD_PAYLOAD', '来源响应不是JSON', 502) })
|
|
41
47
|
}
|
|
42
48
|
export const eastmoney = {
|
|
43
|
-
descriptor: { adapterId: 'eastmoney', capabilities: ['quotes', 'catalog', 'search', 'kline'], markets: ['XSHG', 'XSHE', 'XHKG', 'XNAS', 'XNYS', 'XJPX', 'XKRX', 'XLON'], instruments: ['equity'], maxQuoteBatch: 60, credential: 'none', description: '
|
|
49
|
+
descriptor: { adapterId: 'eastmoney', capabilities: ['quotes', 'catalog', 'search', 'kline'], markets: ['XSHG', 'XSHE', 'XHKG', 'XNAS', 'XNYS', 'XJPX', 'XKRX', 'XLON'], instruments: ['equity'], maxQuoteBatch: 60, credential: 'none', description: '东方财富公开接口:免凭据,八市场目录,行情/K线/搜索,非官方无SLA', endpointKeys: [{ key: 'quotes', label: '行情批量' }, { key: 'list', label: '证券目录' }, { key: 'search', label: '搜索联想' }, { key: 'kline', label: 'K线历史' }, { key: 'trend', label: '分时' }] },
|
|
44
50
|
async probe(request, ctx) {
|
|
45
51
|
if (request.capability === 'catalog' || request.capability === 'search') {
|
|
46
52
|
// 探针词按市场用代表证券代码(茅台/平安只对沪深有效, 其他市场返回 0 项会误判 denied)。
|
|
@@ -55,14 +61,19 @@ export const eastmoney = {
|
|
|
55
61
|
if (!result.candles.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
|
|
56
62
|
return { status: 'ready', capability: 'kline', market: request.market, instrument: 'equity' }
|
|
57
63
|
}
|
|
64
|
+
// 美股 secid 前缀由搜索建立的映射决定, 探针期还没有映射; 逐个候选前缀试一次, 命中即视为就绪
|
|
58
65
|
const security = request.securities?.[0] || { exchange: request.market, code: PROBE_SECURITY[request.market] || '000001' }
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
const candidates = isUSExchange(security.exchange) ? US_MKTNUMS : [null]
|
|
67
|
+
for (const mktNum of candidates) {
|
|
68
|
+
const scope = mktNum ? { ...ctx, usSymbols: new Map([[`US:${security.code}`, { mktNum, at: ctx.now() }]]) } : ctx
|
|
69
|
+
const rows = await this.quotes({ ...request, securities: [security] }, scope)
|
|
70
|
+
if (rows.items.length) return { status: 'ready', capability: 'quotes', market: request.market, instrument: 'equity' }
|
|
71
|
+
}
|
|
72
|
+
throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
|
|
62
73
|
},
|
|
63
74
|
async quotes(request, ctx) {
|
|
64
75
|
// 美股 secid 走 usSymbols 动态归属(miss 默认 NASDAQ);其余市场静态前缀表。
|
|
65
|
-
const secidOf = security => (security.exchange
|
|
76
|
+
const secidOf = security => isUSExchange(security.exchange) ? `${ctx.usSymbols?.get(`US:${security.code}`)?.mktNum || US_DEFAULT_MKT}.${security.code}` : `${SECID_PREFIX[security.exchange]}.${security.code}`
|
|
66
77
|
const symbols = new Map(request.securities.map(s => [secidOf(s), s]))
|
|
67
78
|
const body = await getJson(ctx, `${endpointOf(ctx, 'quotes')}?secids=${[...symbols.keys()].join(',')}&fields=f2,f3,f4,f5,f6,f12,f13,f14,f15,f16,f17,f18,f124&fltt=2&invt=2`)
|
|
68
79
|
const rows = body?.data?.diff
|
|
@@ -80,7 +91,7 @@ export const eastmoney = {
|
|
|
80
91
|
items.push(normalizeQuote({
|
|
81
92
|
securityId: security.id, providerInstanceId: request.providerInstanceId, adapterId: 'eastmoney', providerSymbol: `${row.f13}.${row.f12}`,
|
|
82
93
|
last: num(row.f2), previousClose: num(row.f18), open: num(row.f17), high: num(row.f15), low: num(row.f16),
|
|
83
|
-
volume: num(row.f5), volumeUnit: 'share', amount: num(row.f6), amountUnit: currency,
|
|
94
|
+
volume: sharesFromLots(num(row.f5), security.exchange), volumeUnit: 'share', amount: num(row.f6), amountUnit: currency,
|
|
84
95
|
quoteAt, timestampKind: 'trade',
|
|
85
96
|
receivedAt: ctx.now(), delayKind: 'unknown', delaySeconds: null, securityState: 'unknown',
|
|
86
97
|
}))
|
|
@@ -115,16 +126,18 @@ export const eastmoney = {
|
|
|
115
126
|
return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), items, errors: [], complete }
|
|
116
127
|
},
|
|
117
128
|
async search(request, ctx) {
|
|
118
|
-
|
|
129
|
+
// 不限市场时扩大候选量: 单次响应要覆盖调用方负责的多个市场
|
|
130
|
+
const count = request.market ? Math.min(20, request.limit || 10) : Math.min(50, (request.limit || 10) * 3)
|
|
131
|
+
const url = `${endpointOf(ctx, 'search')}?input=${encodeURIComponent(request.q)}&type=14&token=D43BF722C8E33BDC906FB84D85E326E8&count=${count}`
|
|
119
132
|
const body = await getJson(ctx, url)
|
|
120
133
|
const rows = body?.QuotationCodeTable?.Data
|
|
121
134
|
if (!Array.isArray(rows)) return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), items: [], errors: [] }
|
|
122
135
|
const items = []
|
|
123
136
|
for (const row of rows) {
|
|
124
137
|
const exchange = SEARCH_EXCHANGE[row.MktNum]
|
|
125
|
-
if (!exchange || exchange !== request.market) continue
|
|
138
|
+
if (!exchange || (request.market && exchange !== request.market)) continue
|
|
126
139
|
// 美股候选写 usSymbols(代码→MktNum/JYS), kline 美股动态归属与腾讯 suffix 唯一数据来源
|
|
127
|
-
if ((exchange
|
|
140
|
+
if (isUSExchange(exchange) && ctx.usSymbols && US_MKTNUMS.includes(String(row.MktNum))) ctx.usSymbols.set(`US:${row.Code}`, { mktNum: row.MktNum, jys: row.JYS || null, at: ctx.now() })
|
|
128
141
|
const meta = MARKET_META[exchange]
|
|
129
142
|
if (!meta || !meta.codePattern.test(row.Code)) continue
|
|
130
143
|
items.push(makeSecurity(exchange, row.Code, row.Name, { cnspell: typeof row.PinYin === 'string' ? row.PinYin.toLowerCase() : '', catalogSource: request.providerInstanceId }))
|
|
@@ -169,12 +182,16 @@ export const eastmoney = {
|
|
|
169
182
|
const url = `${endpointOf(ctx, 'trend')}?secid=${secid}&fields1=f1,f2,f3,f4,f5,f6,f7,f8&fields2=${TREND_FIELDS}&iscr=0&ndays=1`
|
|
170
183
|
const body = await getJson(ctx, url)
|
|
171
184
|
if (!body?.data?.trends) throw new StockError('NO_DATA', '来源未返回此证券分时', 422)
|
|
185
|
+
// 东财分时的时间戳一律按北京时间给出(八市场实测: 日股 08:00 对应东京 09:00 开盘, 英股 16:00 对应伦敦 08:00),
|
|
186
|
+
// 不是市场本地时间; 之前按市场时区解释, 日韩英的横轴整体偏了几小时。
|
|
172
187
|
const candles = body.data.trends.map(row => {
|
|
173
188
|
const cols = row.split(',')
|
|
174
189
|
if (cols.length < 8) throw new StockError('BAD_PAYLOAD', '分时列数不足', 502)
|
|
175
190
|
const [, time] = cols[0].split(' ')
|
|
176
191
|
const num = v => { const n = parseFloat(v); return Number.isFinite(n) ? n : null }
|
|
177
|
-
|
|
192
|
+
// at 是来源时刻的 Unix 秒:只留 HH:MM 会让客户端按索引自造时间轴,横轴整体平移
|
|
193
|
+
// f56 与日K 同为手: 沪深要按 100 股换算, 否则分时图例的量和日K/详情差两个数量级
|
|
194
|
+
return { time, at: zonedUnixSeconds(cols[0], SOURCE_ZONE), open: num(cols[1]), close: num(cols[2]), high: num(cols[3]), low: num(cols[4]), volume: sharesFromLots(num(cols[5]), request.market), amount: num(cols[6]), avg: num(cols[7]) }
|
|
178
195
|
})
|
|
179
196
|
return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), complete: true, preClose: num_or_null(body.data.preClose), candles }
|
|
180
197
|
},
|
|
@@ -182,6 +199,24 @@ export const eastmoney = {
|
|
|
182
199
|
|
|
183
200
|
function num_or_null(v) { const n = parseFloat(v); return Number.isFinite(n) ? n : null }
|
|
184
201
|
|
|
202
|
+
|
|
203
|
+
// 把指定时区的"YYYY-MM-DD HH:MM"换算成 Unix 秒, 时刻属于哪个时区由调用方给定;
|
|
204
|
+
// 无时区元数据或格式不符时返回 null, 调用方回退自造时间轴
|
|
205
|
+
function zonedUnixSeconds(stamp, timeZone) {
|
|
206
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/.exec(stamp)
|
|
207
|
+
if (!match || !timeZone) return null
|
|
208
|
+
const [, y, mo, d, hh, mm] = match.map(Number)
|
|
209
|
+
const localAsUtc = Date.UTC(y, mo - 1, d, hh, mm)
|
|
210
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
211
|
+
timeZone, hour12: false,
|
|
212
|
+
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
213
|
+
}).formatToParts(new Date(localAsUtc))
|
|
214
|
+
const rendered = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
|
215
|
+
const asUtc = Date.UTC(Number(rendered.year), Number(rendered.month) - 1, Number(rendered.day),
|
|
216
|
+
Number(rendered.hour) % 24, Number(rendered.minute), Number(rendered.second))
|
|
217
|
+
return Math.floor((localAsUtc - (asUtc - localAsUtc)) / 1000)
|
|
218
|
+
}
|
|
219
|
+
|
|
185
220
|
// klines CSV → 合同响应: 逐行解析/升序/重复拒绝; complete 由 dktotal 与请求量判定(源截断标志)。
|
|
186
221
|
function buildKlineResponse(request, data, ctx) {
|
|
187
222
|
const candles = []
|
|
@@ -192,7 +227,7 @@ function buildKlineResponse(request, data, ctx) {
|
|
|
192
227
|
if (cols[0] <= prevTime) throw new StockError('BAD_PAYLOAD', 'K线时间戳重复或乱序', 502)
|
|
193
228
|
prevTime = cols[0]
|
|
194
229
|
const num = v => { const n = parseFloat(v); return Number.isFinite(n) ? n : null }
|
|
195
|
-
candles.push({ time: cols[0], open: num(cols[1]), close: num(cols[2]), high: num(cols[3]), low: num(cols[4]), volume: num(cols[5]), amount: num(cols[6]) })
|
|
230
|
+
candles.push({ time: cols[0], open: num(cols[1]), close: num(cols[2]), high: num(cols[3]), low: num(cols[4]), volume: sharesFromLots(num(cols[5]), request.market), amount: num(cols[6]) })
|
|
196
231
|
}
|
|
197
232
|
const total = Number(data.dktotal)
|
|
198
233
|
return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), complete: Number.isFinite(total) ? request.limit <= total : true, preClose: num_or_null(data.preKPrice), candles }
|
package/src/providers/sina.mjs
CHANGED
|
@@ -1,98 +1,121 @@
|
|
|
1
|
-
// 新浪公开行情接口适配器:quotes/catalog。非官方端点,需Referer,无SLA;延迟未知,时间为来源快照时间。
|
|
2
|
-
import { normalizeQuote, parseQuoteTime, makeSecurity, securityId, StockError } from '../market-model.mjs'
|
|
3
|
-
const ENDPOINT = 'https://hq.sinajs.cn/list='
|
|
4
|
-
const LIST_ENDPOINT = 'https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData'
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
1
|
+
// 新浪公开行情接口适配器:quotes(沪深港美)/catalog(沪深)。非官方端点,需Referer,无SLA;延迟未知,时间为来源快照时间。
|
|
2
|
+
import { normalizeQuote, parseQuoteTime, makeSecurity, securityId, StockError } from '../market-model.mjs'
|
|
3
|
+
const ENDPOINT = 'https://hq.sinajs.cn/list='
|
|
4
|
+
const LIST_ENDPOINT = 'https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData'
|
|
5
|
+
const HEADERS = { Referer: 'https://finance.sina.com.cn/', 'User-Agent': 'Mozilla/5.0' }
|
|
6
|
+
// 报价前缀与币种随市场派生(单一事实源);美股代码在来源侧是小写
|
|
7
|
+
const BY_MARKET = {
|
|
8
|
+
XSHG: { prefix: 'sh', currency: 'CNY' },
|
|
9
|
+
XSHE: { prefix: 'sz', currency: 'CNY' },
|
|
10
|
+
XHKG: { prefix: 'rt_hk', currency: 'HKD' },
|
|
11
|
+
XNAS: { prefix: 'gb_', currency: 'USD' },
|
|
12
|
+
XNYS: { prefix: 'gb_', currency: 'USD' },
|
|
13
|
+
}
|
|
14
|
+
const symbolOf = security => {
|
|
15
|
+
const meta = BY_MARKET[security.exchange]
|
|
16
|
+
const code = meta.prefix === 'gb_' ? String(security.code).toLowerCase() : security.code
|
|
17
|
+
return `${meta.prefix}${code}`
|
|
18
|
+
}
|
|
19
|
+
// 探针代表证券(各市场实测可达的流动性标的)
|
|
20
|
+
const PROBE_SECURITY = { XSHG: '600519', XSHE: '000001', XHKG: '00700', XNAS: 'AAPL', XNYS: 'BABA' }
|
|
21
|
+
// 新浪价格固定三位以上小数,A股最小变动0.01,裁剪到两位不损失精度;港美同理按分呈现
|
|
22
|
+
const trim = value => /^\d+(\.\d+)?$/.test(value) ? Number(value).toFixed(2) : value
|
|
23
|
+
const count = value => value == null || Number.isNaN(Number(value)) ? null : String(Math.round(Number(value)))
|
|
24
|
+
// 各市场字段位置:沪深34列、港股25列、美股36列,同一行内的列义由前缀决定
|
|
25
|
+
const FIELD_MAP = {
|
|
26
|
+
cn: f => ({ open: f[1], previousClose: f[2], last: f[3], high: f[4], low: f[5], volume: f[8], amount: f[9], quoteAt: `${f[30]} ${f[31]}` }),
|
|
27
|
+
hk: f => ({ open: f[2], previousClose: f[3], high: f[4], low: f[5], last: f[6], volume: f[12], amount: f[11], quoteAt: `${String(f[17]).replace(/\//g, '-')} ${f[18]}` }),
|
|
28
|
+
us: f => ({ open: f[5], previousClose: f[26], last: f[1], high: f[6], low: f[7], volume: f[10], quoteAt: f[3] }),
|
|
29
|
+
}
|
|
30
|
+
const kindOf = providerSymbol => providerSymbol.startsWith('rt_hk') ? 'hk' : providerSymbol.startsWith('gb_') ? 'us' : 'cn'
|
|
31
|
+
const MIN_FIELDS = { cn: 32, hk: 20, us: 28 }
|
|
32
|
+
export const sina = {
|
|
33
|
+
descriptor: { adapterId: 'sina', capabilities: ['quotes', 'catalog'], markets: ['XSHG', 'XSHE', 'XHKG', 'XNAS', 'XNYS'], instruments: ['equity'], maxQuoteBatch: 60, credential: 'none', description: '新浪公开接口:免凭据,沪深港美行情与沪深目录' },
|
|
34
|
+
async probe(request, ctx) {
|
|
35
|
+
if (request.capability === 'catalog') {
|
|
36
|
+
// 探针只拉一页验证端点连通,不做全量分页。
|
|
37
|
+
const response = await ctx.fetch(`${LIST_ENDPOINT}?page=1&num=5&sort=symbol&asc=1&node=${request.market === 'XSHG' ? 'sh_a' : 'sz_a'}`, { headers: HEADERS, signal: ctx.signal, redirect: 'error' })
|
|
38
|
+
if (!response.ok) throw new StockError('NETWORK', '来源服务暂不可用', 502, { retryable: true })
|
|
39
|
+
const rows = await response.json()
|
|
40
|
+
if (!Array.isArray(rows) || !rows.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
|
|
41
|
+
return { status: 'ready', capability: 'catalog', market: request.market, instrument: 'equity' }
|
|
42
|
+
}
|
|
43
|
+
const security = request.securities?.[0] || { exchange: request.market, code: PROBE_SECURITY[request.market] || '600519' }
|
|
44
|
+
const rows = await this.quotes({ ...request, securities: [security] }, ctx)
|
|
45
|
+
if (!rows.items.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
|
|
46
|
+
return { status: 'ready', capability: 'quotes', market: request.market, instrument: 'equity' }
|
|
47
|
+
},
|
|
48
|
+
async catalog(request, ctx) {
|
|
49
|
+
// 分市场节点sh_a/sz_a直接取本市场全量,分页间保持来源要求的节流间隔。
|
|
50
|
+
const PAGE_SIZE = 80, MAX_PAGES = 100, PAGE_GAP = 600
|
|
51
|
+
const prefix = request.market === 'XSHG' ? 'sh' : 'sz', node = request.market === 'XSHG' ? 'sh_a' : 'sz_a'
|
|
52
|
+
const items = [], seen = new Set()
|
|
53
|
+
let page = 1, complete = true, retriedShort = false
|
|
54
|
+
while (page <= MAX_PAGES) {
|
|
55
|
+
let rows
|
|
56
|
+
try {
|
|
57
|
+
const response = await ctx.fetch(`${LIST_ENDPOINT}?page=${page}&num=${PAGE_SIZE}&sort=symbol&asc=1&node=${node}`, { headers: HEADERS, signal: ctx.signal, redirect: 'error' })
|
|
58
|
+
if (!response.ok) throw new StockError('NETWORK', '来源服务暂不可用', 502, { retryable: true })
|
|
59
|
+
rows = await response.json()
|
|
60
|
+
} catch (e) {
|
|
61
|
+
if (ctx.signal.aborted) throw new StockError('CANCELLED', '请求已取消', 503)
|
|
62
|
+
if (e instanceof StockError) throw e
|
|
63
|
+
throw new StockError('NETWORK', '无法连接行情来源', 502, { retryable: true })
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(rows)) throw new StockError('BAD_PAYLOAD', '目录字段结构异常', 502)
|
|
66
|
+
if (rows.length < PAGE_SIZE && !retriedShort) {
|
|
67
|
+
// 连续请求过密时来源会降级返回短页,退避后重拉本页一次再终止。
|
|
68
|
+
retriedShort = true
|
|
69
|
+
await new Promise((resolve, reject) => { const t = setTimeout(resolve, 3 * 1000); ctx.signal.addEventListener('abort', () => { clearTimeout(t); reject(new StockError('CANCELLED', '请求已取消', 503)) }, { once: true }) })
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
retriedShort = false
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
if (typeof row.symbol !== 'string' || !row.symbol.startsWith(prefix) || !/^\d{6}$/.test(row.code || '') || typeof row.name !== 'string' || !row.name) continue
|
|
75
|
+
const id = securityId(request.market, row.code)
|
|
76
|
+
if (seen.has(id)) throw new StockError('BAD_PAYLOAD', '目录身份冲突', 502)
|
|
77
|
+
seen.add(id)
|
|
78
|
+
items.push(makeSecurity(request.market, row.code, row.name, { cnspell: '', catalogSource: request.providerInstanceId }))
|
|
79
|
+
}
|
|
80
|
+
if (rows.length < PAGE_SIZE) break
|
|
81
|
+
if (page === MAX_PAGES) complete = false
|
|
82
|
+
page++
|
|
83
|
+
await new Promise((resolve, reject) => { const t = setTimeout(resolve, PAGE_GAP); ctx.signal.addEventListener('abort', () => { clearTimeout(t); reject(new StockError('CANCELLED', '请求已取消', 503)) }, { once: true }) })
|
|
84
|
+
}
|
|
85
|
+
return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), items, errors: [], complete }
|
|
86
|
+
},
|
|
87
|
+
async quotes(request, ctx) {
|
|
88
|
+
const symbols = new Map(request.securities.map(s => [symbolOf(s), s]))
|
|
89
|
+
let response
|
|
90
|
+
try {
|
|
91
|
+
response = await ctx.fetch(ENDPOINT + [...symbols.keys()].join(','), { headers: HEADERS, signal: ctx.signal, redirect: 'error' })
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (ctx.signal.aborted) throw new StockError('CANCELLED', '请求已取消', 503)
|
|
94
|
+
if (e instanceof StockError) throw e
|
|
95
|
+
throw new StockError('NETWORK', '无法连接行情来源', 502, { retryable: true })
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) throw new StockError('NETWORK', '来源服务暂不可用', 502, { retryable: true })
|
|
98
|
+
const text = new TextDecoder('gbk').decode(await response.arrayBuffer())
|
|
99
|
+
const seen = new Set(), items = []
|
|
100
|
+
for (const line of text.split(';')) {
|
|
101
|
+
const match = /var hq_str_(\w+)="([^"]*)"/.exec(line)
|
|
102
|
+
if (!match) continue
|
|
103
|
+
const providerSymbol = match[1], security = symbols.get(providerSymbol)
|
|
104
|
+
if (!security) continue
|
|
105
|
+
if (seen.has(providerSymbol)) throw new StockError('BAD_PAYLOAD', '报价身份冲突', 502)
|
|
106
|
+
seen.add(providerSymbol)
|
|
107
|
+
const f = match[2].split(','), kind = kindOf(providerSymbol)
|
|
108
|
+
// 停牌行来源返回空串,按无值处理而不是伪造零价
|
|
109
|
+
if (f.length < MIN_FIELDS[kind]) throw new StockError('BAD_PAYLOAD', '来源字段数量异常', 502)
|
|
110
|
+
const mapped = FIELD_MAP[kind](f)
|
|
111
|
+
items.push(normalizeQuote({
|
|
112
|
+
securityId: security.id, providerInstanceId: request.providerInstanceId, adapterId: 'sina', providerSymbol,
|
|
113
|
+
last: trim(mapped.last), previousClose: trim(mapped.previousClose), open: trim(mapped.open), high: trim(mapped.high), low: trim(mapped.low),
|
|
114
|
+
volume: count(mapped.volume), volumeUnit: 'share', amount: count(mapped.amount), amountUnit: BY_MARKET[security.exchange].currency,
|
|
115
|
+
quoteAt: parseQuoteTime(mapped.quoteAt), timestampKind: 'trade',
|
|
116
|
+
receivedAt: ctx.now(), delayKind: 'unknown', delaySeconds: null, securityState: 'unknown',
|
|
117
|
+
}))
|
|
118
|
+
}
|
|
119
|
+
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: '来源未返回此证券报价' })) }
|
|
120
|
+
},
|
|
121
|
+
}
|