@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/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
- export function deriveDelay(quote, now) {
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
- else { quote.delayKind = 'delayed'; quote.delaySeconds = lagSeconds }
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
- deriveDelay(quote, now)
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() },
@@ -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
- if (!/^\d+(?:\.\d{1,2})?$/.test(text)) return null
29
- const [whole, fraction = ''] = text.split('.')
30
- const n = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0'))
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
- 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' }
21
- const SYMBOL_PREFIX = { 1: 'XSHG', 0: 'XSHE', 116: 'XHKG', 105: 'XNAS', 106: 'XNYS', 107: 'XNYS' }
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: '东方财富公开接口:免凭据,沪深港美目录,八市场行情/K线/搜索,非官方无SLA', endpointKeys: [{ key: 'quotes', label: '行情批量' }, { key: 'list', label: '证券目录' }, { key: 'search', label: '搜索联想' }, { key: 'kline', label: 'K线历史' }, { key: 'trend', label: '分时' }] },
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 rows = await this.quotes({ ...request, securities: [security] }, ctx)
60
- if (!rows.items.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
61
- return { status: 'ready', capability: 'quotes', market: request.market, instrument: 'equity' }
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 === 'XNAS' || security.exchange === 'XNYS') ? `${ctx.usSymbols?.get(`US:${security.code}`)?.mktNum || US_DEFAULT_MKT}.${security.code}` : `${SECID_PREFIX[security.exchange]}.${security.code}`
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
- const url = `${endpointOf(ctx, 'search')}?input=${encodeURIComponent(request.q)}&type=14&token=D43BF722C8E33BDC906FB84D85E326E8&count=${Math.min(20, request.limit || 10)}`
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 === 'XNAS' || exchange === 'XNYS') && ctx.usSymbols && ['105', '106', '107'].includes(row.MktNum)) ctx.usSymbols.set(`US:${row.Code}`, { mktNum: row.MktNum, jys: row.JYS || null, at: ctx.now() })
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
- return { time, open: num(cols[1]), close: num(cols[2]), high: num(cols[3]), low: num(cols[4]), volume: num(cols[5]), amount: num(cols[6]), avg: num(cols[7]) }
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 }
@@ -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 LIST_HEADERS = { Referer: 'https://finance.sina.com.cn/', 'User-Agent': 'Mozilla/5.0' }
6
- const symbolOf = security => `${security.exchange === 'XSHG' ? 'sh' : 'sz'}${security.code}`
7
- // 新浪价格固定三位小数,A股最小变动0.01,第三位恒为0,裁剪到两位不损失精度。
8
- const trim = value => /^\d+(\.\d+)?$/.test(value) ? Number(value).toFixed(2) : value
9
- export const sina = {
10
- descriptor: { adapterId: 'sina', capabilities: ['quotes', 'catalog'], markets: ['XSHG', 'XSHE'], instruments: ['equity'], maxQuoteBatch: 60, credential: 'none', description: '新浪公开接口:免凭据,仅沪深行情与目录' },
11
- async probe(request, ctx) {
12
- if (request.capability === 'catalog') {
13
- // 探针只拉一页验证端点连通,不做全量分页。
14
- const response = await ctx.fetch(`${LIST_ENDPOINT}?page=1&num=5&sort=symbol&asc=1&node=${request.market === 'XSHG' ? 'sh_a' : 'sz_a'}`, { headers: LIST_HEADERS, signal: ctx.signal, redirect: 'error' })
15
- if (!response.ok) throw new StockError('NETWORK', '来源服务暂不可用', 502, { retryable: true })
16
- const rows = await response.json()
17
- if (!Array.isArray(rows) || !rows.length) throw new StockError('NO_DATA', '来源未返回可校验数据,暂不能确认能力就绪', 422)
18
- return { status: 'ready', capability: 'catalog', market: request.market, instrument: 'equity' }
19
- }
20
- const security = request.securities?.[0] || { exchange: request.market, code: request.market === 'XSHG' ? '600519' : '000001' }
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 catalog(request, ctx) {
26
- // 分市场节点sh_a/sz_a直接取本市场全量,分页间保持来源要求的节流间隔。
27
- const PAGE_SIZE = 80, MAX_PAGES = 100, PAGE_GAP = 600
28
- const prefix = request.market === 'XSHG' ? 'sh' : 'sz', node = request.market === 'XSHG' ? 'sh_a' : 'sz_a'
29
- const items = [], seen = new Set()
30
- let page = 1, complete = true, retriedShort = false
31
- while (page <= MAX_PAGES) {
32
- let rows
33
- try {
34
- const response = await ctx.fetch(`${LIST_ENDPOINT}?page=${page}&num=${PAGE_SIZE}&sort=symbol&asc=1&node=${node}`, { headers: LIST_HEADERS, signal: ctx.signal, redirect: 'error' })
35
- if (!response.ok) throw new StockError('NETWORK', '来源服务暂不可用', 502, { retryable: true })
36
- rows = await response.json()
37
- } catch (e) {
38
- if (ctx.signal.aborted) throw new StockError('CANCELLED', '请求已取消', 503)
39
- if (e instanceof StockError) throw e
40
- throw new StockError('NETWORK', '无法连接行情来源', 502, { retryable: true })
41
- }
42
- if (!Array.isArray(rows)) throw new StockError('BAD_PAYLOAD', '目录字段结构异常', 502)
43
- if (rows.length < PAGE_SIZE && !retriedShort) {
44
- // 连续请求过密时来源会降级返回短页,退避后重拉本页一次再终止。
45
- retriedShort = true
46
- await new Promise((resolve, reject) => { const t = setTimeout(resolve, 3 * 1000); ctx.signal.addEventListener('abort', () => { clearTimeout(t); reject(new StockError('CANCELLED', '请求已取消', 503)) }, { once: true }) })
47
- continue
48
- }
49
- retriedShort = false
50
- for (const row of rows) {
51
- if (typeof row.symbol !== 'string' || !row.symbol.startsWith(prefix) || !/^\d{6}$/.test(row.code || '') || typeof row.name !== 'string' || !row.name) continue
52
- const id = securityId(request.market, row.code)
53
- if (seen.has(id)) throw new StockError('BAD_PAYLOAD', '目录身份冲突', 502)
54
- seen.add(id)
55
- items.push(makeSecurity(request.market, row.code, row.name, { cnspell: '', catalogSource: request.providerInstanceId }))
56
- }
57
- if (rows.length < PAGE_SIZE) break
58
- if (page === MAX_PAGES) complete = false
59
- page++
60
- await new Promise((resolve, reject) => { const t = setTimeout(resolve, PAGE_GAP); ctx.signal.addEventListener('abort', () => { clearTimeout(t); reject(new StockError('CANCELLED', '请求已取消', 503)) }, { once: true }) })
61
- }
62
- return { providerInstanceId: request.providerInstanceId, fetchedAt: ctx.now(), items, errors: [], complete }
63
- },
64
- async quotes(request, ctx) {
65
- const symbols = new Map(request.securities.map(s => [symbolOf(s), s]))
66
- let response
67
- try {
68
- response = await ctx.fetch(ENDPOINT + [...symbols.keys()].join(','), { headers: { Referer: 'https://finance.sina.com.cn/', 'User-Agent': 'Mozilla/5.0' }, signal: ctx.signal, redirect: 'error' })
69
- } catch (e) {
70
- if (ctx.signal.aborted) throw new StockError('CANCELLED', '请求已取消', 503)
71
- if (e instanceof StockError) throw e
72
- throw new StockError('NETWORK', '无法连接行情来源', 502, { retryable: true })
73
- }
74
- if (!response.ok) throw new StockError('NETWORK', '来源服务暂不可用', 502, { retryable: true })
75
- const text = new TextDecoder('gbk').decode(await response.arrayBuffer())
76
- const seen = new Set(), items = []
77
- for (const line of text.split(';')) {
78
- const match = /var hq_str_(sh|sz)(\d{6})="([^"]*)"/.exec(line)
79
- if (!match) continue
80
- const providerSymbol = `${match[1]}${match[2]}`, security = symbols.get(providerSymbol)
81
- if (!security) continue
82
- if (seen.has(providerSymbol)) throw new StockError('BAD_PAYLOAD', '报价身份冲突', 502)
83
- seen.add(providerSymbol)
84
- const f = match[3].split(',')
85
- if (f.length < 32) throw new StockError('BAD_PAYLOAD', '来源字段数量异常', 502)
86
- // 新浪volume为股,amount为元;时间为"yyyy-MM-dd,HH:mm:ss"两个字段。
87
- items.push(normalizeQuote({
88
- securityId: security.id, providerInstanceId: request.providerInstanceId, adapterId: 'sina', providerSymbol,
89
- last: trim(f[3]), previousClose: trim(f[2]), open: trim(f[1]), high: trim(f[4]), low: trim(f[5]),
90
- volume: f[8] == null || Number.isNaN(Number(f[8])) ? null : String(Math.round(Number(f[8]))), volumeUnit: 'share',
91
- amount: f[9] == null || Number.isNaN(Number(f[9])) ? null : String(Math.round(Number(f[9]))), amountUnit: 'CNY',
92
- quoteAt: parseQuoteTime(`${f[30]} ${f[31]}`), timestampKind: 'trade',
93
- receivedAt: ctx.now(), delayKind: 'unknown', delaySeconds: null, securityState: 'unknown',
94
- }))
95
- }
96
- 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: '来源未返回此证券报价' })) }
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
+ }