@dshtrading/connector-eastmoney 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,16 @@
43
43
  是派生值而非交易所开价(证据 `spikes/impl-five-day-1m-fetch/EVIDENCE.md`)。
44
44
  - **取价必须按行的 `f1` 除**:`price = f2 / 10^f1`。CN ETF 行的 `f1` 是 3,套市场级 ÷100 会把 1.059 元读成 10.59 元。
45
45
  - 未收录的 secid 在响应里**整行缺席**(不报错)→ `TRADING_SYMBOL_NOT_FOUND`;批量路径按契约由调用方回落单取。
46
- - 错误映射:非 2xx → `TRADING_UPSTREAM_ERROR`,不可达 → `TRADING_NETWORK`,非 JSON → `TRADING_EXCHANGE_ERROR`。
46
+ - 错误映射:非 2xx → `TRADING_UPSTREAM_ERROR`,不可达/超时 → `TRADING_NETWORK`,非 JSON → `TRADING_NETWORK`
47
+ (后两者里只有「不可达/超时」可重试)。
48
+ - **请求通道的硬上界**:只有网络级失败才重试,最多 3 枪、重试间**固定 200 ms** 冷却(不退避);
49
+ 单枪超时与整通硬截止(含冷却与全部枪,到点不续命)**按域分两档**——行情域(push2 /
50
+ push2his / searchapi)单枪 2 s、整通 3 s(「快速判别空回」);报表域(datacenter-web,分页
51
+ 整表、报文几百 KB)单枪 8 s、整通 20 s(3 s 会把可达端点判成故障)。两档都盖住建连、响应头
52
+ 与**响应体解析**整段:200 头已回但 body 挂住同样在单枪超时内终结。整通失败后把**该主机**
53
+ 静置 30 s(熔断,push2 / push2his / searchapi / datacenter-web 各自独立):窗口内零网络请求、
54
+ 立即抛 `TRADING_NETWORK`,消息带 `circuit open` 与剩余毫秒,到期自愈、任一成功清零。
55
+ HTTP 非 2xx 与坏 JSON 不重试、也不打开熔断。
47
56
 
48
57
  ## 能力面
49
58
 
@@ -61,6 +70,10 @@
61
70
  - **`/api/qt/stock/get` 路径级空回**(2026-10-14):同一时刻、同域、同 UA 的 `ulist.np/get`、`trends2`、
62
71
  `kline/get` 全部正常,只有该路径传输层空回(`curl` 对照 `(52) Empty reply from server`)。
63
72
  报价因此改走 `ulist.np/get`。
73
+ - **空回的归因是代理链路,不是东财边缘**(2026-10-23 16 枪二次实测):连败时 connect 均 3 ms 级
74
+ 成功、total 0.15-0.31 s、`curl (52) Empty reply from server` / `http=000 bytes=0`,而 `remote_ip`
75
+ 全是本机代理 fake-ip `198.18.0.167`(系统代理 `127.0.0.1:7890`);同刻 push2 与 push2his 一起
76
+ 空回、腾讯 200。东财边缘在失败时刻的行为未验证,换东财主机/域名无效。
64
77
  - **对突发并发极敏感**:2026-10-14 实测在飞 4 就触发「TCP+TLS 建连成功、服务端约 70 ms 后直接断连、
65
78
  无 HTTP 响应」,且失效面会从路径级扩到 `push2` 域全路径、持续 ≥12 分钟;2026-09-08 另有整 IP
66
79
  断开约 20 分钟的先例。客户端轮询应保持 ≥60 s 量级,**不要拿它跑批量探测**。
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EASTMONEY_KLINE_MAX_BARS, EastmoneyMarket, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, Interval, Kline, Ticker, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs } from "./rest.js";
1
+ import { EASTMONEY_KLINE_MAX_BARS, EastmoneyMarket, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, Interval, Kline, Ticker, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyNetworkLimits, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs } from "./rest.js";
2
2
  import { Context, Service } from "@deepseek-ai/cordis";
3
3
  import Schema from "@deepseek-ai/schemastery";
4
4
  import { Disposable, Interval as Interval$1, Kline as Kline$1, KlineCapability, MarketDataService, SouthboundHoldingHistory, Ticker as Ticker$1 } from "@dshtrading/api";
@@ -42,4 +42,4 @@ declare const ROUTER_PROVIDER = "eastmoney";
42
42
  declare function routeAllows(ctx: Context, config: Config, market: 'cn' | 'hk'): boolean;
43
43
  declare function apply(ctx: Context, config: Config): void;
44
44
  //#endregion
45
- export { Config, EASTMONEY_KLINE_MAX_BARS, EastmoneyMarket, EastmoneyMarketDataService, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, type Interval, type Kline, ROUTER_PROVIDER, TRADING_CN_MARKET_DATA_KEY, TRADING_HK_MARKET_DATA_KEY, type Ticker, TradingServiceError, apply, eastmoneyKlineCapabilities, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, parseReportDateToUtcMs, routeAllows, toEastmoneySecid, utc8WallTimeToEpochMs };
45
+ export { Config, EASTMONEY_KLINE_MAX_BARS, EastmoneyMarket, EastmoneyMarketDataService, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, type Interval, type Kline, ROUTER_PROVIDER, TRADING_CN_MARKET_DATA_KEY, TRADING_HK_MARKET_DATA_KEY, type Ticker, TradingServiceError, apply, eastmoneyKlineCapabilities, eastmoneyNetworkLimits, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, parseReportDateToUtcMs, routeAllows, toEastmoneySecid, utc8WallTimeToEpochMs };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EASTMONEY_KLINE_MAX_BARS, EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs } from "./rest.js";
1
+ import { EASTMONEY_KLINE_MAX_BARS, EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyNetworkLimits, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs } from "./rest.js";
2
2
  import { Service } from "@deepseek-ai/cordis";
3
3
  import { defineTool } from "@deepseek-ai/dsh-tools";
4
4
  import Schema from "@deepseek-ai/schemastery";
@@ -125,4 +125,4 @@ function apply(ctx, config) {
125
125
  });
126
126
  }
127
127
  //#endregion
128
- export { Config, EASTMONEY_KLINE_MAX_BARS, EastmoneyMarketDataService, EastmoneyRestClient, INTERVAL_VOCABULARY, ROUTER_PROVIDER, TRADING_CN_MARKET_DATA_KEY, TRADING_HK_MARKET_DATA_KEY, TradingServiceError, apply, eastmoneyKlineCapabilities, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, parseReportDateToUtcMs, routeAllows, toEastmoneySecid, utc8WallTimeToEpochMs };
128
+ export { Config, EASTMONEY_KLINE_MAX_BARS, EastmoneyMarketDataService, EastmoneyRestClient, INTERVAL_VOCABULARY, ROUTER_PROVIDER, TRADING_CN_MARKET_DATA_KEY, TRADING_HK_MARKET_DATA_KEY, TradingServiceError, apply, eastmoneyKlineCapabilities, eastmoneyNetworkLimits, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, parseReportDateToUtcMs, routeAllows, toEastmoneySecid, utc8WallTimeToEpochMs };
@@ -0,0 +1,15 @@
1
+ //#region src/resilience.d.ts
2
+ /**
3
+ * @dshtrading/connector-eastmoney/resilience
4
+ * 请求通道的韧性缝:注入式时钟(固定冷却与整通硬截止)+ 按主机的短期熔断(负缓存)。
5
+ * 只有 rest.ts 的 requestJson 消费它——一个事实只有一个家。
6
+ */
7
+ /** 注入式时钟:冷却等待与整通硬截止都读它,单测据此断言而不真等。 */
8
+ interface EastmoneyClock {
9
+ /** 当前时刻(ms)。 */
10
+ now(): number;
11
+ /** 等待 ms 毫秒。 */
12
+ sleep(ms: number): Promise<void>;
13
+ }
14
+ //#endregion
15
+ export { EastmoneyClock };
@@ -0,0 +1,46 @@
1
+ //#region src/resilience.ts
2
+ /** 生产用时钟:真实时间与真实 timer。 */
3
+ const systemClock = {
4
+ now: () => Date.now(),
5
+ sleep: (ms) => new Promise((resolve) => {
6
+ setTimeout(resolve, ms);
7
+ })
8
+ };
9
+ /**
10
+ * 熔断键:URL → 主机。粒度按 host,即 push2 / push2his / searchapi / datacenter-web 各自独立
11
+ * ——按 host 是最小诚实的粒度,不会让数据中心接口被行情故障连坐。构造出来的 URL 都能解析;
12
+ * 解析失败时退回原串,不把两个无法解析的 URL 静默并成同一个键。
13
+ */
14
+ function hostOf(url) {
15
+ try {
16
+ return new URL(url).host;
17
+ } catch {
18
+ return url;
19
+ }
20
+ }
21
+ /**
22
+ * 按主机的短期负缓存(熔断):一次整通网络级失败把该主机记成不可用,窗口内对该主机的调用
23
+ * 零网络请求直接失败,到期自动恢复(读时即过期,不需要定时器),任一成功即清零。
24
+ */
25
+ var HostCircuitBreaker = class {
26
+ /** 主机 → 熔断到期时刻(ms,与注入时钟同源)。 */
27
+ openUntil = /* @__PURE__ */ new Map();
28
+ /** 熔断中返回剩余毫秒数(>0);未熔断或窗口已过返回 undefined。 */
29
+ remaining(host, now) {
30
+ const until = this.openUntil.get(host);
31
+ if (until === void 0) return void 0;
32
+ const leftMs = until - now;
33
+ if (leftMs > 0) return leftMs;
34
+ this.openUntil.delete(host);
35
+ }
36
+ /** 打开该主机的熔断窗口:now + ttlMs 之前对该主机一律零请求失败。 */
37
+ trip(host, now, ttlMs) {
38
+ this.openUntil.set(host, now + ttlMs);
39
+ }
40
+ /** 任一成功即清零:在飞的兄弟请求成功可推翻另一个请求刚为同一主机打开的窗口。 */
41
+ clear(host) {
42
+ this.openUntil.delete(host);
43
+ }
44
+ };
45
+ //#endregion
46
+ export { HostCircuitBreaker, hostOf, systemClock };
package/lib/rest.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { EastmoneyClock } from "./resilience.js";
1
2
  import { Interval as Interval$1, Kline as Kline$1, KlineCapability, SouthboundHoldingHistory, Ticker as Ticker$1, TradingErrorCode } from "@dshtrading/api";
2
3
  //#region src/rest.d.ts
3
4
  declare class TradingServiceError extends Error {
@@ -58,6 +59,14 @@ declare function toEastmoneySecid(symbol: string, market?: EastmoneyMarket): {
58
59
  canonical: string;
59
60
  market: EastmoneyMarket;
60
61
  };
62
+ /**
63
+ * 按域取网络口径:报表域(datacenter-web)用 DATACENTER_*,其余主机用行情那套。
64
+ * 导出是为了让用例直接断言这条分界,而不必等真实 8 s 计时。
65
+ */
66
+ declare function eastmoneyNetworkLimits(host: string, dataCenterHost: string): {
67
+ timeoutMs: number;
68
+ budgetMs: number;
69
+ };
61
70
  interface EastmoneyRestOptions {
62
71
  baseUrl?: string;
63
72
  historyBaseUrl?: string;
@@ -67,6 +76,15 @@ interface EastmoneyRestOptions {
67
76
  fetchImpl?: typeof fetch;
68
77
  /** 本实例服务的市场(形态解析与价格倍率都按它分流;缺省 cn)。 */
69
78
  market?: EastmoneyMarket;
79
+ /** 单枪超时(ms),缺省 2000:挂住的请求到点 abort 成 TRADING_NETWORK,不存在无上界的等待。 */
80
+ timeoutMs?: number;
81
+ /**
82
+ * 显式覆盖「一次取数整通硬截止」(ms)。不传时按域取缺省:行情域 3000、报表域
83
+ * (datacenter-web)20000——分界理由见 DATACENTER_* 常量。
84
+ */
85
+ requestBudgetMs?: number;
86
+ /** 时钟注入(固定冷却的 sleep 与整通硬截止计时都读它);缺省真实 timer。 */
87
+ clock?: EastmoneyClock;
70
88
  }
71
89
  declare class EastmoneyRestClient {
72
90
  readonly baseUrl: string;
@@ -77,9 +95,46 @@ declare class EastmoneyRestClient {
77
95
  /** 本实例的市场(cn/hk 单包双实例;形态解析必须与注册市场一致)。 */
78
96
  readonly market: EastmoneyMarket;
79
97
  private readonly fetchImpl;
98
+ /** 单枪超时的显式覆盖(ms);未给时按域取缺省。 */
99
+ private readonly timeoutMsOverride;
100
+ /** 整通硬截止的显式覆盖(ms);未给时按域取缺省。 */
101
+ private readonly requestBudgetMsOverride;
102
+ /** 请求通道时钟(冷却与整通截止)。 */
103
+ private readonly clock;
104
+ /** 按主机的短期熔断(一次整通网络级失败后该主机静默 CIRCUIT_OPEN_MS)。 */
105
+ private readonly circuit;
80
106
  /** 降级派生快照的 60s TTL 缓存(键=secid;只在降级分支读写,见 DERIVED_QUOTE_TTL_MS)。 */
81
107
  private readonly derivedQuoteCache;
82
108
  constructor(options?: EastmoneyRestOptions);
109
+ /**
110
+ * 单次 GET + JSON,带**单枪超时**:超时盖的是**整枪**——建连、响应头与**响应体解析**合成一个
111
+ * promise 与超时竞速(照 connector-tencent 的 #fetchWithTimeout 的 timeoutMs + AbortController
112
+ * 范式,并补上那边只盖到 fetch 返回、没盖住 body 读取的缺口)。到点以 TRADING_NETWORK +
113
+ * `request timed out after <n>ms` 终结这一枪且保持可重试,故「200 头已到、body 永不结束」这类
114
+ * 挂住同样受 3 枪上界与整通硬截止约束,不存在无上界的等待。
115
+ *
116
+ * 网络级异常(undici `fetch failed` / 服务端收到即断的空回 / 超时)抛 TRADING_NETWORK 并标为
117
+ * 可重试;HTTP 非 2xx 抛 TRADING_UPSTREAM_ERROR、解析失败抛 TRADING_NETWORK(后两者不可重试
118
+ * ——4xx/5xx 与 WAF/HTML 错误页不是主机抖动)。
119
+ */
120
+ private requestOnce;
121
+ /** 该主机的网络口径:显式覆盖优先,其次按域(报表域 datacenter-web 见 DATACENTER_* 常量)。 */
122
+ private networkLimits;
123
+ /**
124
+ * 取 JSON:**有界重试 + 按主机短期熔断**(2026-10-23 二次实测口径)。
125
+ *
126
+ * 实测形态是「连接建立之后的 HTTP 空回」(connect 3ms 级成功、total 0.15-0.31s、curl (52)
127
+ * Empty reply / http=000 bytes=0),不是 connect 失败也不是超时;最长连败 6 枪跨 22.7 秒,
128
+ * 恢复一次后又再度失败。故:
129
+ * - 只有网络级可重试失败才重试,最多 MAX_ATTEMPTS 枪(硬上界 3);
130
+ * - 重试之间是**固定**冷却 RETRY_COOLDOWN_MS(不是退避、不递增);
131
+ * - 一次取数整通有硬截止(含冷却与全部枪):行情域 REQUEST_BUDGET_MS、报表域
132
+ * DATACENTER_BUDGET_MS(2026-10-27 起按域分口径,见该常量注释);剩不下「一次冷却 +
133
+ * 下一枪」就直接收尾,枪不会越过截止点,也就不存在续命;
134
+ * - 整通失败后把**该主机**记进熔断窗口 CIRCUIT_OPEN_MS,窗口内对该主机的调用零网络请求
135
+ * 立即抛 TRADING_NETWORK,到期自动恢复,任一成功即清零;
136
+ * - HTTP 非 2xx 与 JSON 解析失败不可重试、也**不打开**熔断(一个 404/WAF 页不是主机故障)。
137
+ */
83
138
  private requestJson;
84
139
  getTicker(symbol: string): Promise<Ticker$1>;
85
140
  /**
@@ -165,4 +220,4 @@ declare class EastmoneyRestClient {
165
220
  getSouthboundHoldings(symbol: string): Promise<SouthboundHoldingHistory>;
166
221
  }
167
222
  //#endregion
168
- export { EASTMONEY_KLINE_MAX_BARS, EastmoneyMarket, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, type Interval$1 as Interval, type Kline$1 as Kline, type Ticker$1 as Ticker, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs };
223
+ export { EASTMONEY_KLINE_MAX_BARS, EastmoneyMarket, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, type Interval$1 as Interval, type Kline$1 as Kline, type Ticker$1 as Ticker, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyNetworkLimits, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs };
package/lib/rest.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { HostCircuitBreaker, hostOf, systemClock } from "./resilience.js";
1
2
  //#region src/rest.ts
2
3
  var TradingServiceError = class extends Error {
3
4
  code;
@@ -217,6 +218,39 @@ function toEastmoneySecid(symbol, market = "cn") {
217
218
  };
218
219
  }
219
220
  /**
221
+ * 请求通道的硬上界(2026-10-23 实测口径,见 .agents/notes/implemented/bug-fix/
222
+ * 2026-10-23-tencent-kline-host-fallback-and-eastmoney-network-retry.md):单枪超时、枪数上界、
223
+ * 固定冷却、一次取数整通硬截止、熔断窗口。每个都是硬数字,不随负载或失败次数变化。
224
+ */
225
+ const DEFAULT_TIMEOUT_MS = 2e3;
226
+ const MAX_ATTEMPTS = 3;
227
+ const RETRY_COOLDOWN_MS = 200;
228
+ const REQUEST_BUDGET_MS = 3e3;
229
+ const CIRCUIT_OPEN_MS = 3e4;
230
+ /**
231
+ * 报表域(datacenter-web)单独一套口径,与行情域的 2s/3s 分开。
232
+ *
233
+ * 报表端点是分页整表:pageSize 上限 1000、报文几百 KB,比行情响应大两个数量级。用行情
234
+ * 那套「快速判别空回」的 2s/3s 会把可达端点判成故障——2026-10-27 CI 实测:GitHub runner
235
+ * 上首枪就吃满 2000ms 单枪上限、剩 772ms 的第二枪又超时;而 400 KB 报文在 1 Mbps 链路上
236
+ * 本身就要约 3.2 s,弱网终端同样过不去。行情域口径不变(空回判别靠快)。
237
+ */
238
+ const DATACENTER_TIMEOUT_MS = 8e3;
239
+ const DATACENTER_BUDGET_MS = 2e4;
240
+ /**
241
+ * 按域取网络口径:报表域(datacenter-web)用 DATACENTER_*,其余主机用行情那套。
242
+ * 导出是为了让用例直接断言这条分界,而不必等真实 8 s 计时。
243
+ */
244
+ function eastmoneyNetworkLimits(host, dataCenterHost) {
245
+ return host === dataCenterHost ? {
246
+ timeoutMs: DATACENTER_TIMEOUT_MS,
247
+ budgetMs: DATACENTER_BUDGET_MS
248
+ } : {
249
+ timeoutMs: DEFAULT_TIMEOUT_MS,
250
+ budgetMs: REQUEST_BUDGET_MS
251
+ };
252
+ }
253
+ /**
220
254
  * 东财数据中心「返回数据为空」业务码(2026-10-13 实测):非港股通标的与
221
255
  * pageNumber 越界都返回 `{ result: null, success: false, code: 9201,
222
256
  * message: "返回数据为空" }`,HTTP 200。它不是故障,是「该标的无此序列」,
@@ -270,6 +304,14 @@ var EastmoneyRestClient = class {
270
304
  /** 本实例的市场(cn/hk 单包双实例;形态解析必须与注册市场一致)。 */
271
305
  market;
272
306
  fetchImpl;
307
+ /** 单枪超时的显式覆盖(ms);未给时按域取缺省。 */
308
+ timeoutMsOverride;
309
+ /** 整通硬截止的显式覆盖(ms);未给时按域取缺省。 */
310
+ requestBudgetMsOverride;
311
+ /** 请求通道时钟(冷却与整通截止)。 */
312
+ clock;
313
+ /** 按主机的短期熔断(一次整通网络级失败后该主机静默 CIRCUIT_OPEN_MS)。 */
314
+ circuit = new HostCircuitBreaker();
273
315
  /** 降级派生快照的 60s TTL 缓存(键=secid;只在降级分支读写,见 DERIVED_QUOTE_TTL_MS)。 */
274
316
  derivedQuoteCache = /* @__PURE__ */ new Map();
275
317
  constructor(options = {}) {
@@ -279,19 +321,125 @@ var EastmoneyRestClient = class {
279
321
  this.dataCenterBaseUrl = options.dataCenterBaseUrl ?? "https://datacenter-web.eastmoney.com";
280
322
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
281
323
  this.market = options.market ?? "cn";
324
+ this.timeoutMsOverride = options.timeoutMs;
325
+ this.requestBudgetMsOverride = options.requestBudgetMs;
326
+ this.clock = options.clock ?? systemClock;
282
327
  }
283
- async requestJson(url, referer = "https://quote.eastmoney.com/") {
328
+ /**
329
+ * 单次 GET + JSON,带**单枪超时**:超时盖的是**整枪**——建连、响应头与**响应体解析**合成一个
330
+ * promise 与超时竞速(照 connector-tencent 的 #fetchWithTimeout 的 timeoutMs + AbortController
331
+ * 范式,并补上那边只盖到 fetch 返回、没盖住 body 读取的缺口)。到点以 TRADING_NETWORK +
332
+ * `request timed out after <n>ms` 终结这一枪且保持可重试,故「200 头已到、body 永不结束」这类
333
+ * 挂住同样受 3 枪上界与整通硬截止约束,不存在无上界的等待。
334
+ *
335
+ * 网络级异常(undici `fetch failed` / 服务端收到即断的空回 / 超时)抛 TRADING_NETWORK 并标为
336
+ * 可重试;HTTP 非 2xx 抛 TRADING_UPSTREAM_ERROR、解析失败抛 TRADING_NETWORK(后两者不可重试
337
+ * ——4xx/5xx 与 WAF/HTML 错误页不是主机抖动)。
338
+ */
339
+ async requestOnce(url, referer, timeoutMs) {
340
+ /** 超时口径的唯一出口:可重试,消息带毫秒数,便于把事故归因到本枪。 */
341
+ const timedOut = () => ({
342
+ ok: false,
343
+ retryable: true,
344
+ error: new TradingServiceError("TRADING_NETWORK", `Eastmoney network error: request timed out after ${timeoutMs}ms`)
345
+ });
346
+ const controller = new AbortController();
347
+ const attempt = async () => {
348
+ let res;
349
+ try {
350
+ res = await this.fetchImpl(url, {
351
+ headers: {
352
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
353
+ "Referer": referer
354
+ },
355
+ signal: controller.signal
356
+ });
357
+ } catch (err) {
358
+ if (controller.signal.aborted) return timedOut();
359
+ return {
360
+ ok: false,
361
+ retryable: true,
362
+ error: new TradingServiceError("TRADING_NETWORK", `Eastmoney network error: ${err instanceof Error ? err.message : String(err)}`, err)
363
+ };
364
+ }
365
+ if (!res.ok) return {
366
+ ok: false,
367
+ retryable: false,
368
+ error: new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney HTTP request failed: status ${res.status} ${res.statusText}`)
369
+ };
370
+ try {
371
+ return {
372
+ ok: true,
373
+ body: await res.json()
374
+ };
375
+ } catch (err) {
376
+ if (controller.signal.aborted) return timedOut();
377
+ return {
378
+ ok: false,
379
+ retryable: false,
380
+ error: new TradingServiceError("TRADING_NETWORK", `Eastmoney network error: ${err instanceof Error ? err.message : String(err)}`, err)
381
+ };
382
+ }
383
+ };
384
+ let timer;
385
+ const timeout = new Promise((resolve) => {
386
+ timer = setTimeout(() => {
387
+ controller.abort(new DOMException(`request timed out after ${timeoutMs}ms`, "TimeoutError"));
388
+ resolve(timedOut());
389
+ }, timeoutMs);
390
+ });
284
391
  try {
285
- const res = await this.fetchImpl(url, { headers: {
286
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
287
- "Referer": referer
288
- } });
289
- if (!res.ok) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney HTTP request failed: status ${res.status} ${res.statusText}`);
290
- return await res.json();
291
- } catch (err) {
292
- if (err instanceof TradingServiceError) throw err;
293
- throw new TradingServiceError("TRADING_NETWORK", `Eastmoney network error: ${err instanceof Error ? err.message : String(err)}`, err);
392
+ return await Promise.race([attempt(), timeout]);
393
+ } finally {
394
+ clearTimeout(timer);
395
+ }
396
+ }
397
+ /** 该主机的网络口径:显式覆盖优先,其次按域(报表域 datacenter-web 见 DATACENTER_* 常量)。 */
398
+ networkLimits(host) {
399
+ const byHost = eastmoneyNetworkLimits(host, hostOf(this.dataCenterBaseUrl));
400
+ return {
401
+ timeoutMs: this.timeoutMsOverride ?? byHost.timeoutMs,
402
+ budgetMs: this.requestBudgetMsOverride ?? byHost.budgetMs
403
+ };
404
+ }
405
+ /**
406
+ * 取 JSON:**有界重试 + 按主机短期熔断**(2026-10-23 二次实测口径)。
407
+ *
408
+ * 实测形态是「连接建立之后的 HTTP 空回」(connect 3ms 级成功、total 0.15-0.31s、curl (52)
409
+ * Empty reply / http=000 bytes=0),不是 connect 失败也不是超时;最长连败 6 枪跨 22.7 秒,
410
+ * 恢复一次后又再度失败。故:
411
+ * - 只有网络级可重试失败才重试,最多 MAX_ATTEMPTS 枪(硬上界 3);
412
+ * - 重试之间是**固定**冷却 RETRY_COOLDOWN_MS(不是退避、不递增);
413
+ * - 一次取数整通有硬截止(含冷却与全部枪):行情域 REQUEST_BUDGET_MS、报表域
414
+ * DATACENTER_BUDGET_MS(2026-10-27 起按域分口径,见该常量注释);剩不下「一次冷却 +
415
+ * 下一枪」就直接收尾,枪不会越过截止点,也就不存在续命;
416
+ * - 整通失败后把**该主机**记进熔断窗口 CIRCUIT_OPEN_MS,窗口内对该主机的调用零网络请求
417
+ * 立即抛 TRADING_NETWORK,到期自动恢复,任一成功即清零;
418
+ * - HTTP 非 2xx 与 JSON 解析失败不可重试、也**不打开**熔断(一个 404/WAF 页不是主机故障)。
419
+ */
420
+ async requestJson(url, referer = "https://quote.eastmoney.com/") {
421
+ const host = hostOf(url);
422
+ const openForMs = this.circuit.remaining(host, this.clock.now());
423
+ if (openForMs !== void 0) throw new TradingServiceError("TRADING_NETWORK", `Eastmoney ${host}: circuit open, ${openForMs}ms remaining`);
424
+ const limits = this.networkLimits(host);
425
+ const deadlineAt = this.clock.now() + limits.budgetMs;
426
+ let lastError;
427
+ for (let shot = 1; shot <= MAX_ATTEMPTS; shot += 1) {
428
+ const remainingMs = deadlineAt - this.clock.now();
429
+ if (remainingMs <= 0) break;
430
+ const result = await this.requestOnce(url, referer, Math.min(limits.timeoutMs, remainingMs));
431
+ if (result.ok) {
432
+ this.circuit.clear(host);
433
+ return result.body;
434
+ }
435
+ if (!result.retryable) throw result.error;
436
+ lastError = result.error;
437
+ if (shot === MAX_ATTEMPTS) break;
438
+ if (deadlineAt - this.clock.now() <= RETRY_COOLDOWN_MS) break;
439
+ await this.clock.sleep(RETRY_COOLDOWN_MS);
294
440
  }
441
+ this.circuit.trip(host, this.clock.now(), CIRCUIT_OPEN_MS);
442
+ throw lastError ?? new TradingServiceError("TRADING_NETWORK", `Eastmoney ${host}: request budget exhausted`);
295
443
  }
296
444
  async getTicker(symbol) {
297
445
  const { secid, canonical, market } = toEastmoneySecid(symbol, this.market);
@@ -600,4 +748,4 @@ var EastmoneyRestClient = class {
600
748
  }
601
749
  };
602
750
  //#endregion
603
- export { EASTMONEY_KLINE_MAX_BARS, EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs };
751
+ export { EASTMONEY_KLINE_MAX_BARS, EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyNetworkLimits, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/connector-eastmoney",
3
3
  "description": "东方财富公开行情连接器(cn + hk 单包双市场):经 push2 / push2his 公共 REST 提供 tradingCnMarketData / tradingHkMarketData 与 <market>_get_ticker / <market>_get_klines 工具,免密、无官方授权,纯行情无交易通道。",
4
- "version": "0.6.3",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -21,12 +21,12 @@
21
21
  ],
22
22
  "license": "PolyForm-Noncommercial-1.0.0",
23
23
  "dependencies": {
24
- "@dshtrading/api": "^0.6.3"
24
+ "@dshtrading/api": "^0.7.0"
25
25
  },
26
26
  "peerDependencies": {
27
- "@deepseek-ai/cordis": ">=4.0.0",
28
- "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2",
29
- "@deepseek-ai/schemastery": ">=3.18.0"
27
+ "@deepseek-ai/cordis": ">=4.1.0",
28
+ "@deepseek-ai/dsh-tools": ">=0.2.2-rc.1",
29
+ "@deepseek-ai/schemastery": ">=3.19.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "tsdown": "^0.22.0",