@dshtrading/connector-eastmoney 0.6.0 → 0.6.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 CHANGED
@@ -1,34 +1,81 @@
1
- # @dshtrading/connector-template
1
+ # @dshtrading/connector-eastmoney
2
2
 
3
- **本包是脚手架源,不是可安装的连接器**:不入任何 bundle 依赖,仅在 `pnpm -r build/test`
4
- 基线内保证可编译、冒烟测试绿。真实接入用生成器:
3
+ > **状态:已实证(本出口可用)**。2026-10-14 实测:报价(`push2.eastmoney.com` `ulist.np/get`)、
4
+ > 日 K 与分钟 K(`push2his.eastmoney.com` `kline/get` / `trends2`)从本开发出口返回真实数据,
5
+ > 批量报价把自选 7 行压成 2 次请求。证据 `spikes/impl-eastmoney-quote-endpoint/EVIDENCE.md`、
6
+ > `spikes/impl-eastmoney-hk/`、`spikes/impl-five-day-1m-fetch/EVIDENCE.md`。
5
7
 
6
- ```sh
7
- node scripts/new-connector.mjs --slug bybit --title Bybit --market crypto
8
- ```
8
+ 东方财富公开 REST 行情连接器:**cn + hk 单包双市场**(照 connector-tencent 的多实例模式),
9
+ 实现 `@dshtrading/api` 的 `MarketDataService` 契约并提供 `<market>_get_ticker` / `<market>_get_klines`
10
+ 两工具。**纯行情,无交易通道**(东财公共端点不提供交易 API)。
11
+
12
+ ## 配置
13
+
14
+ | 字段 | 默认 | 说明 |
15
+ |---|---|---|
16
+ | `enabled` | `true` | 是否激活本连接器 |
17
+ | `market` | `'cn'` | `cn` = A 股,`hk` = 港股;同一 bare 包名挂两行、行 id 不同即两个实例 |
18
+
19
+ ## 接线
20
+
21
+ - **数据面行**:`packages/cn/cordis.patch.yml` 与 `packages/hk/cordis.patch.yml` 各挂一行,注册
22
+ `(cn, eastmoney)` / `(hk, eastmoney)` 进 `tradingMarketDataRegistry`,不占根市场键。
23
+ - **preset 行**:`packages/{cn,hk}/assets/preset/*/agent.cordis.yml`;`routeAllows` 只在该市场
24
+ `provider === 'eastmoney'` 时激活本实例。
25
+ - **服务键**:cn → `tradingCnMarketData`,hk → `tradingHkMarketData`(isolate 组键 = 服务名)。
26
+
27
+ ## 上游端点
9
28
 
10
- 生成后按 [docs/guides/connector-playbook.md](../../docs/guides/connector-playbook.md) 的填写顺序
11
- (src/rest.ts 头部清单 7 项)实现 TODO。每个 TODO 的参照段都在 connector-okx
12
- (packages/connector-okx/),它是本仓第一个真实 TradeService 连接器。
29
+ | 用途 | 端点 |
30
+ |---|---|
31
+ | 报价(单只与批量同端点) | `push2.eastmoney.com/api/qt/ulist.np/get?secids=<secid,…>` |
32
+ | 5m 及以上 K 线 | `push2his.eastmoney.com/api/qt/stock/kline/get?klt=…&fqt=1` |
33
+ | 1m 分时(多日窗口) | `push2his.eastmoney.com/api/qt/stock/trends2/get?ndays=5` |
34
+ | 名册检索 | `searchapi.eastmoney.com/api/suggest/get?type=14` |
35
+ | 港股通持股(hk 实例) | 数据中心 `RPT_MUTUAL_STOCK_HOLDRANKS` 报表 |
13
36
 
14
- ## 结构
37
+ ## 周期与契约
15
38
 
16
- - `src/rest.ts` —— 错误载体(TradingServiceError)+ 通用请求管线(超时/错误映射/
17
- 模拟盘头/注入 fetch)直接可用;签名与端点 TODO。
18
- - `src/index.ts` —— Config(enabled/env/dryRun/liveTrading + 三 ref 凭证组)、
19
- 闸门三态判定、凭证解析(BYOK + 环境变量回落)、MarketDataService/TradeService 骨架、
20
- 7 个市场前缀工具注册(duplicate-safe)、apply(enabled=false 静默退出)。
21
- - `test/template.test.ts` —— 结构冒烟 7 用例。
39
+ - 周期词汇 `1m/5m/15m/30m/1h/1d/1w/1M`(`klt` 1/5/15/30/60/101/102/103),前复权 `fqt=1`,cn 与 hk 同面。
40
+ - **1m 只能走 `trends2`**:`kline/get` 的 `klt=1` 只给当日(`lmt` 与 `end` 被上游忽略),多日窗口的唯一通道是
41
+ `ndays`;`ndays=5` 是上限(`ndays=10` → `rc:102` / 0 行),cn 一次约 1205 根、hk 约 1655 根。
42
+ - **`ndays>1` 时历史交易日的开价恒为 `0.00`**:实现按「同日前一根 close」重建并夹进当根 `[low, high]`,
43
+ 是派生值而非交易所开价(证据 `spikes/impl-five-day-1m-fetch/EVIDENCE.md`)。
44
+ - **取价必须按行的 `f1` 除**:`price = f2 / 10^f1`。CN ETF 行的 `f1` 是 3,套市场级 ÷100 会把 1.059 元读成 10.59 元。
45
+ - 未收录的 secid 在响应里**整行缺席**(不报错)→ `TRADING_SYMBOL_NOT_FOUND`;批量路径按契约由调用方回落单取。
46
+ - 错误映射:非 2xx → `TRADING_UPSTREAM_ERROR`,不可达 → `TRADING_NETWORK`,非 JSON → `TRADING_EXCHANGE_ERROR`。
22
47
 
23
- ## Token
48
+ ## 能力面
24
49
 
25
- `eastmoney`(slug)/ `Eastmoney`(title)/ `EASTMONEY`(ref 前缀)/
26
- `cn`(工具前缀)/ `Cn`(服务键 infix)。token 未展开也可编译;
27
- 生成器保证全部替换。
50
+ | 服务方法 | cn 实例 | hk 实例 |
51
+ |---|---|---|
52
+ | `getTicker` / `getTickers`(一次请求取多只) | 有 | 有 |
53
+ | `getKlines` | 有 | 有 |
54
+ | `listInstruments`(关键词检索) | 有 | 有 |
55
+ | `subscribeTicker`(快照轮询,最小 1000 ms) | 有 | 有 |
56
+ | `getSouthboundHoldings`(港股通持股历史) | **缺席** | 有 |
57
+ | 盘口 / 基本面 / 主动买盘 | 未实现 | 未实现 |
28
58
 
29
- ## 注意(勿改的坑)
59
+ ## 已知边界(实测)
30
60
 
31
- - 服务类用 TS 编译期 `private`(禁 ECMAScript `#`,realm 代理会炸)。
32
- - 工具名 = 市场前缀 + 语义词(不带交易所名)——base 闸门正则只认市场前缀。
33
- - 行 id = `dsh-trading-<market>-connector-<slug>`,全仓唯一(insert-only 铁律 #1)。
34
- - 凭证只进 ref,绝不内置/落日志(铁律 #3/#5)。
61
+ - **`/api/qt/stock/get` 路径级空回**(2026-10-14):同一时刻、同域、同 UA 的 `ulist.np/get`、`trends2`、
62
+ `kline/get` 全部正常,只有该路径传输层空回(`curl` 对照 `(52) Empty reply from server`)。
63
+ 报价因此改走 `ulist.np/get`。
64
+ - **对突发并发极敏感**:2026-10-14 实测在飞 4 就触发「TCP+TLS 建连成功、服务端约 70 ms 后直接断连、
65
+ 无 HTTP 响应」,且失效面会从路径级扩到 `push2` 域全路径、持续 ≥12 分钟;2026-09-08 另有整 IP
66
+ 断开约 20 分钟的先例。客户端轮询应保持 ≥60 s 量级,**不要拿它跑批量探测**。
67
+ - 横向对比(周期 / 品种 / 历史深度 / 时延并发)见 [docs/guides/cn-data-source-comparison.md](../../docs/guides/cn-data-source-comparison.md)。
68
+
69
+ ## 合规记录(README 铁律 #5)
70
+
71
+ **东方财富公开端点:公开、无 key、无官方授权;个人使用边界自负**,以东财服务条款为准。
72
+ 本仓不缓存、不再分发行情数据。
73
+
74
+ ## 出网验证
75
+
76
+ ```sh
77
+ pnpm --filter @dshtrading/connector-eastmoney build
78
+ node spikes/impl-eastmoney-quote-endpoint/p1-quote-endpoint-and-scale.mjs # 报价端点与按行 f1 取价
79
+ node spikes/impl-eastmoney-quote-endpoint/p2-getTickers-batch.mjs # 批量报价(请求次数由外层计数器给出)
80
+ ./node_modules/.bin/tsx spikes/impl-five-day-1m-fetch/fetch-five-day-1m.ts # 五日 1m 与开价重建
81
+ ```
@@ -2,6 +2,6 @@ import { Config } from "./index.js";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
3
  //#region src/dataplane.d.ts
4
4
  declare const inject: string[];
5
- declare function apply(ctx: Context, config: Config): void;
5
+ declare function apply(ctx: Context, config?: Config): void;
6
6
  //#endregion
7
- export { apply, inject };
7
+ export { Config, apply, inject };
package/lib/dataplane.js CHANGED
@@ -1,12 +1,13 @@
1
- import { EastmoneyMarketDataService, marketDataKey } from "./index.js";
1
+ import { Config, EastmoneyMarketDataService, ROUTER_PROVIDER, marketDataKey } from "./index.js";
2
2
  //#region src/dataplane.ts
3
3
  const inject = [];
4
+ /** schema 的运行时可调用形态(校验即补默认值);手工构造 ctx 不传 config 时按 schema 默认值处理。 */
5
+ const withDefaults = (value) => Config(value);
4
6
  function resolveMarketDataRegistry(ctx) {
5
7
  const candidate = ctx.get?.("tradingMarketDataRegistry", false);
6
8
  return candidate !== void 0 ? candidate : void 0;
7
9
  }
8
- const ROUTER_PROVIDER = "eastmoney";
9
- function apply(ctx, config) {
10
+ function apply(ctx, config = withDefaults(void 0)) {
10
11
  if (!config.enabled) return;
11
12
  const market = config.market ?? "cn";
12
13
  const key = marketDataKey(market);
@@ -20,4 +21,4 @@ function apply(ctx, config) {
20
21
  ctx.effect(() => registry.register(market, ROUTER_PROVIDER, service));
21
22
  }
22
23
  //#endregion
23
- export { apply, inject };
24
+ export { Config, apply, inject };
package/lib/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { EastmoneyMarket, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, Interval, Kline, Ticker, TradingServiceError, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, toEastmoneySecid, utc8WallTimeToEpochMs } from "./rest.js";
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";
2
2
  import { Context, Service } from "@deepseek-ai/cordis";
3
3
  import Schema from "@deepseek-ai/schemastery";
4
- import { Disposable, Interval as Interval$1, Kline as Kline$1, MarketDataService, Ticker as Ticker$1 } from "@dshtrading/api";
4
+ import { Disposable, Interval as Interval$1, Kline as Kline$1, KlineCapability, MarketDataService, SouthboundHoldingHistory, Ticker as Ticker$1 } from "@dshtrading/api";
5
5
  //#region src/index.d.ts
6
6
  declare const name = "dsh-trading-cn-connector-eastmoney";
7
7
  interface Config {
@@ -16,8 +16,20 @@ declare function marketDataKey(market: 'cn' | 'hk'): string;
16
16
  declare class EastmoneyMarketDataService extends Service implements MarketDataService {
17
17
  private readonly client;
18
18
  constructor(ctx: Context, options?: EastmoneyRestOptions, serviceName?: string);
19
+ /** 港股通持股历史;仅 hk 实例存在(cn 实例为 undefined)。 */
20
+ getSouthboundHoldings?: (symbol: string) => Promise<SouthboundHoldingHistory>;
19
21
  getTicker(symbol: string): Promise<Ticker$1>;
22
+ /**
23
+ * 批量最新报价(自选栏每拍取价用):一次上游请求取多只标的。东财 ulist.np/get 原生支持
24
+ * 多 secid 逗号列表,cn/hk 实例都实现,故与 getSouthboundHoldings 不同、直接声明在类体上。
25
+ */
26
+ getTickers(symbols: string[]): Promise<Record<string, Ticker$1>>;
20
27
  getKlines(symbol: string, interval?: Interval$1, limit?: number): Promise<Kline$1[]>;
28
+ /**
29
+ * K 线能力声明(api 可选契约,2026-10-15 跨源 K 线路由):cn/hk 实例同集——1m 走 trends2
30
+ * 五日窗口、5m 及以上走 kline/get,两条通道覆盖同一个周期面(口径见 rest.ts)。
31
+ */
32
+ klineCapabilities(): readonly KlineCapability[];
21
33
  listInstruments(query?: string): Promise<Array<{
22
34
  symbol: string;
23
35
  name: string;
@@ -30,4 +42,4 @@ declare const ROUTER_PROVIDER = "eastmoney";
30
42
  declare function routeAllows(ctx: Context, config: Config, market: 'cn' | 'hk'): boolean;
31
43
  declare function apply(ctx: Context, config: Config): void;
32
44
  //#endregion
33
- export { Config, 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, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, 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, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, parseReportDateToUtcMs, routeAllows, toEastmoneySecid, utc8WallTimeToEpochMs };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, toEastmoneySecid, utc8WallTimeToEpochMs } from "./rest.js";
1
+ import { EASTMONEY_KLINE_MAX_BARS, EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyKlineCapabilities, 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";
@@ -18,13 +18,28 @@ var EastmoneyMarketDataService = class extends Service {
18
18
  constructor(ctx, options = {}, serviceName = TRADING_CN_MARKET_DATA_KEY) {
19
19
  super(ctx, serviceName);
20
20
  this.client = new EastmoneyRestClient(options);
21
+ if (this.client.market === "hk") this.getSouthboundHoldings = (symbol) => this.client.getSouthboundHoldings(symbol);
21
22
  }
22
23
  async getTicker(symbol) {
23
24
  return this.client.getTicker(symbol);
24
25
  }
26
+ /**
27
+ * 批量最新报价(自选栏每拍取价用):一次上游请求取多只标的。东财 ulist.np/get 原生支持
28
+ * 多 secid 逗号列表,cn/hk 实例都实现,故与 getSouthboundHoldings 不同、直接声明在类体上。
29
+ */
30
+ async getTickers(symbols) {
31
+ return this.client.getTickers(symbols);
32
+ }
25
33
  async getKlines(symbol, interval = "1d", limit = 100) {
26
34
  return this.client.getKlines(symbol, interval, limit);
27
35
  }
36
+ /**
37
+ * K 线能力声明(api 可选契约,2026-10-15 跨源 K 线路由):cn/hk 实例同集——1m 走 trends2
38
+ * 五日窗口、5m 及以上走 kline/get,两条通道覆盖同一个周期面(口径见 rest.ts)。
39
+ */
40
+ klineCapabilities() {
41
+ return this.client.klineCapabilities();
42
+ }
28
43
  async listInstruments(query) {
29
44
  return this.client.listInstruments(query);
30
45
  }
@@ -110,4 +125,4 @@ function apply(ctx, config) {
110
125
  });
111
126
  }
112
127
  //#endregion
113
- export { Config, EastmoneyMarketDataService, EastmoneyRestClient, INTERVAL_VOCABULARY, ROUTER_PROVIDER, TRADING_CN_MARKET_DATA_KEY, TRADING_HK_MARKET_DATA_KEY, TradingServiceError, apply, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, 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, eastmoneyPriceScale, mapIntervalToKlt, marketDataKey, name, parseIntervalMs, parseReportDateToUtcMs, routeAllows, toEastmoneySecid, utc8WallTimeToEpochMs };
package/lib/rest.d.ts CHANGED
@@ -1,10 +1,23 @@
1
- import { Interval as Interval$1, Kline as Kline$1, Ticker as Ticker$1, TradingErrorCode } from "@dshtrading/api";
1
+ import { Interval as Interval$1, Kline as Kline$1, KlineCapability, SouthboundHoldingHistory, Ticker as Ticker$1, TradingErrorCode } from "@dshtrading/api";
2
2
  //#region src/rest.d.ts
3
3
  declare class TradingServiceError extends Error {
4
4
  readonly code: TradingErrorCode;
5
5
  constructor(code: TradingErrorCode, message: string, cause?: unknown);
6
6
  }
7
7
  declare const INTERVAL_VOCABULARY: readonly ["1m", "5m", "15m", "30m", "1h", "1d", "1w", "1M"];
8
+ /**
9
+ * 能承接的请求深度上限 = 桥的 K 线参数闸门(packages/bot-api MAX_KLINE_LIMIT = 2500)。
10
+ * 5m 及以上走 kline/get,lmt 直通上游且历史深度远超闸门(2026-10-14 实测 klt=5&lmt=1000
11
+ * → 1000 根,spikes/impl-five-day-1m-probe/EVIDENCE.md §3);1m 走 trends2 窗口型取数
12
+ * (见 getIntradayTrends),同样承接 2500 的请求——返回根数以最近 5 个交易日窗口为界。
13
+ */
14
+ declare const EASTMONEY_KLINE_MAX_BARS = 2500;
15
+ /**
16
+ * K 线能力声明(api 可选契约 klineCapabilities):cn/hk 实例同一集(两条通道的周期面相同)。
17
+ * 1m 的取证 = trends2 ndays=5 是上游唯一的多日 1m 通道(ndays=10 → rc:1),一次给最近
18
+ * 5 个交易日(cn ≈1205 根 / hk ≈1655 根,实测每交易日 cn 241 / hk 331)。
19
+ */
20
+ declare function eastmoneyKlineCapabilities(): KlineCapability[];
8
21
  declare function mapIntervalToKlt(interval: Interval$1): string;
9
22
  declare function parseIntervalMs(interval: Interval$1): number;
10
23
  /** 连接器服务的市场(cn=A 股;hk=港股,2026-09-08 扩展,实证见 spikes/impl-eastmoney-hk/)。 */
@@ -16,8 +29,19 @@ type EastmoneyMarket = 'cn' | 'hk';
16
29
  * 与同市场 1m 序列相差数小时)。
17
30
  */
18
31
  declare function utc8WallTimeToEpochMs(value: string): number;
19
- /** 东财价格字段的分精度倍率:cn ×100(2 位小数),hk ×1000(3 位小数,响应 decimal=3)。 */
32
+ /**
33
+ * 东财价格字段的**市场级**分精度倍率:cn ×100,hk ×1000(响应 decimal=3)。
34
+ * 它只是 f1 缺失时的兜底——报价行自带 f1 = 该标的小数位数,且股票与 ETF 不同档
35
+ * (2026-10-14 实测:cn 股票 f1=2 而 cn ETF / hk 均 f1=3),故取值一律走按行的
36
+ * quotePriceScale(),市场级常数对 A 股 ETF 会差 10 倍。
37
+ */
20
38
  declare function eastmoneyPriceScale(market: EastmoneyMarket): number;
39
+ /**
40
+ * `YYYY-MM-DD[ ...]`(报表日期列为 UTC+8 零点的墙钟串,如 `2026-10-09 00:00:00`)
41
+ * → **UTC 当日 00:00** 的 epoch ms(与日 K openTime 同口径,便于前向对齐)。
42
+ * 只取日期段,用 Date.UTC 构造,不引入运行机器的本地时区偏移。
43
+ */
44
+ declare function parseReportDateToUtcMs(value: unknown): number | undefined;
21
45
  /**
22
46
  * 将标准代码转为东财 secid。**按连接器市场分流**(2026-09-08 审查 H3/L1):同一个
23
47
  * 东财服务实例可能服务 cn 或 hk,形态解析必须知道自己是哪个市场——否则港股宽容
@@ -38,6 +62,8 @@ interface EastmoneyRestOptions {
38
62
  baseUrl?: string;
39
63
  historyBaseUrl?: string;
40
64
  searchBaseUrl?: string;
65
+ /** 东财数据中心域(datacenter-web,报表类端点;与 push2 行情域分离)。 */
66
+ dataCenterBaseUrl?: string;
41
67
  fetchImpl?: typeof fetch;
42
68
  /** 本实例服务的市场(形态解析与价格倍率都按它分流;缺省 cn)。 */
43
69
  market?: EastmoneyMarket;
@@ -46,22 +72,97 @@ declare class EastmoneyRestClient {
46
72
  readonly baseUrl: string;
47
73
  readonly historyBaseUrl: string;
48
74
  readonly searchBaseUrl: string;
75
+ /** 东财数据中心域(港股通持股等报表端点)。 */
76
+ readonly dataCenterBaseUrl: string;
49
77
  /** 本实例的市场(cn/hk 单包双实例;形态解析必须与注册市场一致)。 */
50
78
  readonly market: EastmoneyMarket;
51
79
  private readonly fetchImpl;
80
+ /** 降级派生快照的 60s TTL 缓存(键=secid;只在降级分支读写,见 DERIVED_QUOTE_TTL_MS)。 */
81
+ private readonly derivedQuoteCache;
52
82
  constructor(options?: EastmoneyRestOptions);
53
83
  private requestJson;
54
84
  getTicker(symbol: string): Promise<Ticker$1>;
85
+ /**
86
+ * 批量最新报价:一次 `ulist.np/get` 取多只标的(契约见 @dshtrading/api 的
87
+ * `MarketDataService.getTickers`),把自选栏「N 只标的 N 次请求」压成 1 次。本连接器
88
+ * **不分片**——单次请求的标的数由调用方裁剪(桥侧 MAX_SYMBOLS)。
89
+ *
90
+ * 三个语义要点:
91
+ * - 键是**入参原字符串**:同一 secid 的多种写法(`600519` / `600519.SH`)各占一个键,
92
+ * 共享同一份快照;`Ticker.symbol` 仍是规范形;
93
+ * - 行→符号靠响应行的 f13+f12 拼回 secid 再查请求表,**与上游返回顺序无关**;
94
+ * - 只返回确实拿到的标的:转不成 secid 的符号、以及 diff 里缺行的符号都不出现在结果里
95
+ * (调用方按缺失回落单取,保住各符号自己的错误码);整批 HTTP/网络失败才抛出。
96
+ * - 整批失败时若有「降级派生」可用(见 derivedQuote),逐只派生而不是整批报废;一只都派生
97
+ * 不出来仍抛原始错误(2026-10-14 东财封报价路径后的活口)。
98
+ */
99
+ getTickers(symbols: string[]): Promise<Record<string, Ticker$1>>;
100
+ /**
101
+ * 批量报价路径不可用时的逐只降级:对**请求表里的每个 secid** 各取一次派生快照(TTL 内命中缓存
102
+ * 的不发请求);转不成 secid 的符号根本没进请求表,仍按契约跳过、整批不失败。
103
+ *
104
+ * 一只都派生不出来时抛回**原始**错误——空表在 getTickers 契约里是「一个都没收录」,与「整批
105
+ * 取数失败」语义完全不同,降级不得把后者改写成前者。只要拿到了一部分,就按既有语义只返回拿到
106
+ * 的那些(缺的由调用方回落 `getTicker`,拿它们各自的错误码)。
107
+ */
108
+ private deriveTickers;
109
+ /**
110
+ * 降级取价:60s TTL 缓存(键=secid,故 `002714` 与 `002714.SZ` 共享同一份)+ 失败兜底。
111
+ * 派生本身失败(trends2 也失败 / 没有行情行)说明这个源眼下整个不可用——抛回**原始**错误码,
112
+ * 绝不吞成 not-found、也绝不交出一条空快照。返回浅拷贝,不把缓存对象交给消费方改写。
113
+ */
114
+ private derivedQuote;
115
+ /**
116
+ * 报价路径不可用时的降级快照:走**同源**(push2his,与 kline/trends2 同域;2026-10-14 实测
117
+ * 报价域被封时它仍 200)的当日分钟序列,末根收盘价即最新价。
118
+ *
119
+ * 2026-10-14 实测(spikes/impl-eastmoney-quote-endpoint/p3):
120
+ * - 该响应的价格**已是十进制值**——cn 0.002714 末根 `2026-10-14 15:00,41.07,40.89,41.07,
121
+ * 40.89,5411,22125210.00,41.081` → 40.89,与同刻腾讯报价逐位一致,`data.preClose`=40.9
122
+ * = 腾讯昨收;故**不再**除 10^decimal(×10^f1 是报价端点 ulist.np/get 的整数口径,不是这条的);
123
+ * - `data` 顶层自带 name/preClose,名称、昨收与价格取自同一条响应;
124
+ * - changePercent 由同一对 price/prevClose 推出(序列里没有涨跌幅字段,也不另取端点),
125
+ * 保证「派生值自洽」;
126
+ * - **不给 volume**:窗口第 6 列之和在 cn 等于当日累计(196239 = 日 K 196239),但 hk 低于
127
+ * 当日累计(18,418,300 vs 19,933,548,缺 16:00 后收市竞价)——拿不到的量宁可缺省
128
+ * (`Ticker.volume` 本就可选),不用窗口合计之类的近似值冒充当日累计。
129
+ */
130
+ private deriveQuoteFromTrends;
131
+ /** K 线能力声明(api 可选契约):cn/hk 实例同一集,口径见 eastmoneyKlineCapabilities。 */
132
+ klineCapabilities(): readonly KlineCapability[];
55
133
  getKlines(symbol: string, interval?: Interval$1, limit?: number): Promise<Kline$1[]>;
56
134
  /**
57
- * 港股当日分时(trends2,ndays=1 = 最近一个交易日全天分钟序列,非交易日自然回落)。
58
- * 行格式同 kline/get:`时间,开,收,高,低,量,额,均价`;时间 'YYYY-MM-DD HH:MM' 为 UTC+8 墙钟。
135
+ * trends2 分时序列(最近 TRENDS_NDAYS 个交易日的全天分钟序列,非交易日自然回落到
136
+ * 最近交易日;cn/hk 共用)。行格式同 kline/get:`时间,开,收,高,低,量,额,均价`;
137
+ * 时间为 'YYYY-MM-DD HH:MM' 的 UTC+8 墙钟,序列按时间升序。
138
+ *
139
+ * 开价的派生语义(2026-10-14 实测,spikes/impl-five-day-1m-probe/):ndays>1 时上游
140
+ * 只给「当日」那一段真实开价,历史交易日每根的第 2 列恒为 0.00(cn 241/241、hk 331/331
141
+ * 全为 0),close 与腾讯 m1 逐根一致、high/low 有 ≤0.5 的舍入差。因此:
142
+ * - 上游 open > 0:用上游值(当日、以及非当日偶发的真实值),不做任何改写;
143
+ * - 上游 open = 0:重建 + clamp——候选值是同日前一根的 close(1m 粒度下,前一根收盘
144
+ * 是该分钟开盘价最佳可得近似;每个交易日首根没有同日前一根,取该根自身 close,不把
145
+ * 上游未给出的跨日缺口算进来,午后首根用的是午休前最后一根的 close,午休不移动价格),
146
+ * 再 clamp 进**当根** [low, high]:真实分钟 bar 恒有 low <= open <= high,该分钟真实
147
+ * 开价必然落在自身高低区间内,clamp 比原候选更接近真值,也避免实体戳出影线。
148
+ * low/high 非有限值或 low > high 时保留候选值(上游坏行不强行改写)。
149
+ * 重建后的 open 仍是派生值而非取数,但已保证自洽:2026-10-14 五日窗口实测,clamp 前
150
+ * 有 cn 242/1007、hk 28/1367 根越界(多数是最小变动价位量级,单根最大 cn 2.99 元,来自
151
+ * 2026-10-09 09:31 的分钟边界跳空),clamp 后越界 0 根(spikes/impl-five-day-1m-fetch/)。
59
152
  */
60
- private getHkIntradayTrends;
153
+ private getIntradayTrends;
61
154
  listInstruments(query?: string): Promise<Array<{
62
155
  symbol: string;
63
156
  name: string;
64
157
  }>>;
158
+ /**
159
+ * 港股通持股历史(东财数据中心 RPT_MUTUAL_STOCK_HOLDRANKS,2026-10-13)。
160
+ *
161
+ * 只覆盖港股通标的:标的无该序列时返回空 points(**空 ≠ 0**,消费方隐藏副图
162
+ * 而不是画一条贴底的假线);上游网络/协议失败抛错。仅 hk 实例可用——该报表以
163
+ * 5 位港股代码为主键,cn 实例调用即协议误用。
164
+ */
165
+ getSouthboundHoldings(symbol: string): Promise<SouthboundHoldingHistory>;
65
166
  }
66
167
  //#endregion
67
- export { EastmoneyMarket, EastmoneyRestClient, EastmoneyRestOptions, INTERVAL_VOCABULARY, type Interval$1 as Interval, type Kline$1 as Kline, type Ticker$1 as Ticker, TradingServiceError, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, toEastmoneySecid, utc8WallTimeToEpochMs };
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 };
package/lib/rest.js CHANGED
@@ -18,6 +18,24 @@ const INTERVAL_VOCABULARY = [
18
18
  "1w",
19
19
  "1M"
20
20
  ];
21
+ /**
22
+ * 能承接的请求深度上限 = 桥的 K 线参数闸门(packages/bot-api MAX_KLINE_LIMIT = 2500)。
23
+ * 5m 及以上走 kline/get,lmt 直通上游且历史深度远超闸门(2026-10-14 实测 klt=5&lmt=1000
24
+ * → 1000 根,spikes/impl-five-day-1m-probe/EVIDENCE.md §3);1m 走 trends2 窗口型取数
25
+ * (见 getIntradayTrends),同样承接 2500 的请求——返回根数以最近 5 个交易日窗口为界。
26
+ */
27
+ const EASTMONEY_KLINE_MAX_BARS = 2500;
28
+ /**
29
+ * K 线能力声明(api 可选契约 klineCapabilities):cn/hk 实例同一集(两条通道的周期面相同)。
30
+ * 1m 的取证 = trends2 ndays=5 是上游唯一的多日 1m 通道(ndays=10 → rc:1),一次给最近
31
+ * 5 个交易日(cn ≈1205 根 / hk ≈1655 根,实测每交易日 cn 241 / hk 331)。
32
+ */
33
+ function eastmoneyKlineCapabilities() {
34
+ return INTERVAL_VOCABULARY.map((interval) => ({
35
+ interval,
36
+ maxBars: EASTMONEY_KLINE_MAX_BARS
37
+ }));
38
+ }
21
39
  function mapIntervalToKlt(interval) {
22
40
  switch (interval) {
23
41
  case "1m": return "1";
@@ -77,11 +95,82 @@ function utc8WallTimeToEpochMs(value) {
77
95
  if (!m) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `invalid eastmoney wall time ${JSON.stringify(value)}`);
78
96
  return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)) - UTC8_MS;
79
97
  }
80
- /** 东财价格字段的分精度倍率:cn ×100(2 位小数),hk ×1000(3 位小数,响应 decimal=3)。 */
98
+ /**
99
+ * 东财价格字段的**市场级**分精度倍率:cn ×100,hk ×1000(响应 decimal=3)。
100
+ * 它只是 f1 缺失时的兜底——报价行自带 f1 = 该标的小数位数,且股票与 ETF 不同档
101
+ * (2026-10-14 实测:cn 股票 f1=2 而 cn ETF / hk 均 f1=3),故取值一律走按行的
102
+ * quotePriceScale(),市场级常数对 A 股 ETF 会差 10 倍。
103
+ */
81
104
  function eastmoneyPriceScale(market) {
82
105
  return market === "hk" ? 1e3 : 100;
83
106
  }
84
107
  /**
108
+ * `YYYY-MM-DD[ ...]`(报表日期列为 UTC+8 零点的墙钟串,如 `2026-10-09 00:00:00`)
109
+ * → **UTC 当日 00:00** 的 epoch ms(与日 K openTime 同口径,便于前向对齐)。
110
+ * 只取日期段,用 Date.UTC 构造,不引入运行机器的本地时区偏移。
111
+ */
112
+ function parseReportDateToUtcMs(value) {
113
+ if (typeof value !== "string") return void 0;
114
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
115
+ if (m === null) return void 0;
116
+ const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
117
+ return Number.isFinite(ms) ? ms : void 0;
118
+ }
119
+ /** 报表数值列 → 有限数;null/空串/非数值返回 undefined(调用方决定跳过还是兜底)。 */
120
+ function toFiniteNumber(value) {
121
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
122
+ if (typeof value === "string" && value.trim() !== "") {
123
+ const parsed = Number(value);
124
+ return Number.isFinite(parsed) ? parsed : void 0;
125
+ }
126
+ }
127
+ /**
128
+ * 报价行的价格除数 = 10^f1(f1 = 上游给该标的声明的小数位数)。按行取值才同时对
129
+ * 股票与 ETF 成立:2026-10-14 逐只与腾讯 qt.gtimg.cn 交叉验证——游戏ETF华夏 f1=3 →
130
+ * f2=1058 即 1.058 元(腾讯 1.058)、东瑞股份 f1=2 → f2=1413 即 14.13 元(腾讯 14.13)、
131
+ * 腾讯控股 f1=3 → f2=426200 即 426.2 港元。f1 缺失或超出 0..6 时回落到市场级常数。
132
+ */
133
+ function quotePriceScale(decimalDigits, market) {
134
+ const digits = toFiniteNumber(decimalDigits);
135
+ if (digits === void 0 || !Number.isInteger(digits) || digits < 0 || digits > 6) return eastmoneyPriceScale(market);
136
+ return 10 ** digits;
137
+ }
138
+ /**
139
+ * 报价行 → Ticker 快照(单标的 `getTicker` 与批量 `getTickers` 共用同一个解析器,避免养出
140
+ * 第二套取价逻辑):价格与昨收都按**该行**的 f1 取除数,涨跌幅走 ×100;f14 缺失或空白时不带 name。
141
+ */
142
+ function parseQuoteRow(row, canonical, market) {
143
+ const scale = quotePriceScale(row.f1, market);
144
+ const rawPrice = parseScaled(row.f2, scale) ?? 0;
145
+ const price = rawPrice > 0 ? rawPrice : 0;
146
+ const volume = toFiniteNumber(row.f5) ?? 0;
147
+ const quoteTime = toFiniteNumber(row.f124);
148
+ const timestamp = quoteTime !== void 0 && quoteTime > 0 ? quoteTime * 1e3 : Date.now();
149
+ const prevClose = parseScaled(row.f18, scale);
150
+ const changePercent = parseScaledHundred(row.f3);
151
+ const name = typeof row.f14 === "string" && row.f14.trim() ? row.f14.trim() : void 0;
152
+ return {
153
+ symbol: canonical,
154
+ ...name ? { name } : {},
155
+ price,
156
+ volume: volume > 0 ? volume : 0,
157
+ timestamp,
158
+ ...prevClose !== void 0 && prevClose > 0 ? { prevClose } : {},
159
+ ...changePercent !== void 0 ? { changePercent } : {}
160
+ };
161
+ }
162
+ /**
163
+ * 报价行 → 它自己的 secid(f13=市场 + f12=代码)。批量路径靠它把响应行还原成请求时算出的
164
+ * secid,**不依赖上游返回顺序**;两列任一缺失就弃掉这一行(宁可少一只标的,也不把行错配给
165
+ * 别的符号)。f12 正常是已补零的字符串(cn `002714` / hk `00700`),数值形照 String 处理。
166
+ */
167
+ function quoteRowSecid(row) {
168
+ const market = toFiniteNumber(row.f13);
169
+ const code = typeof row.f12 === "string" ? row.f12.trim() : typeof row.f12 === "number" ? String(row.f12) : "";
170
+ if (market === void 0 || !Number.isInteger(market) || code === "") return void 0;
171
+ return `${market}.${code}`;
172
+ }
173
+ /**
85
174
  * 将标准代码转为东财 secid。**按连接器市场分流**(2026-09-08 审查 H3/L1):同一个
86
175
  * 东财服务实例可能服务 cn 或 hk,形态解析必须知道自己是哪个市场——否则港股宽容
87
176
  * 形 `700` 会落进 CN 兜底(secid 0.700 / canonical 700.SZ),查询到另一个市场
@@ -127,25 +216,75 @@ function toEastmoneySecid(symbol, market = "cn") {
127
216
  market: "cn"
128
217
  };
129
218
  }
219
+ /**
220
+ * 东财数据中心「返回数据为空」业务码(2026-10-13 实测):非港股通标的与
221
+ * pageNumber 越界都返回 `{ result: null, success: false, code: 9201,
222
+ * message: "返回数据为空" }`,HTTP 200。它不是故障,是「该标的无此序列」,
223
+ * 必须与故障区分——其余非零码(实测 9501:报表配置不存在 / 列不存在)是
224
+ * 上游协议错误,要抛错而不是当成空序列。
225
+ */
226
+ const DATACENTER_EMPTY_CODE = 9201;
227
+ /** 数据中心报表端点的 Referer(该域校验 data.eastmoney.com,与 push2 的 quote 域不同)。 */
228
+ const DATACENTER_REFERER = "https://data.eastmoney.com/";
229
+ /**
230
+ * 1m 的 trends2 取数窗口 = 最近 5 个交易日(2026-10-14 实测,spikes/impl-five-day-1m-probe/):
231
+ * ndays=5 是上游上限(ndays=10 → rc:1),单次返回 cn 约 1205 根 / hk 约 1655 根
232
+ * (实测每个交易日 cn 241 根、hk 331 根)。
233
+ */
234
+ const TRENDS_NDAYS = 5;
235
+ /**
236
+ * 把重建出的开价收敛进当根 [low, high]:真实分钟 bar 恒有 low <= open <= high,
237
+ * 故越界的候选值一定偏离真值,夹到最近边界比放它出去更接近真值(也避免实体戳出影线)。
238
+ * low/high 非有限值或 low > high(上游坏行)时原样返回,不做改写。
239
+ */
240
+ function clampToBarRange(value, low, high) {
241
+ if (!Number.isFinite(low) || !Number.isFinite(high) || low > high) return value;
242
+ return Math.min(Math.max(value, low), high);
243
+ }
244
+ /**
245
+ * 报价行字段(ulist.np/get):f1=小数位数 f2=最新价 f3=涨跌幅(%) f5=成交量(手)
246
+ * f12=代码 f13=市场 f14=名称 f18=昨收 f124=行情时间(秒)。价格类字段是 ×10^f1 的整数。
247
+ */
248
+ const QUOTE_FIELDS = "f1,f2,f3,f5,f12,f13,f14,f18,f124";
249
+ /**
250
+ * 降级派生快照的缓存 TTL(2026-10-14):侧栏每 8s 一拍、每拍对每只自选取一次价;报价路径不可用
251
+ * 时若每拍都派生,就是把每只标的每分钟 7+ 次请求压到**同源**的 push2his 上——那是把仅存的那条
252
+ * 路也打穿。60s 内同一 secid 只允许一次 trends2。缓存**只在降级分支读写**:报价路径恢复即返回
253
+ * 直取的新鲜值,绝不用陈旧派生值顶替它。
254
+ */
255
+ const DERIVED_QUOTE_TTL_MS = 6e4;
256
+ /**
257
+ * 「首选取价路径不可用」判定:只有网络故障与上游错误才降级派生。`TRADING_SYMBOL_NOT_FOUND` /
258
+ * `TRADING_UNSUPPORTED_SYMBOL` 是关于**标的**的事实、不是取价路径的故障,一律照实上报
259
+ * (@dshtrading/api 的 Ticker 契约明令降级不得掩盖它们)。
260
+ */
261
+ function isQuotePathUnavailable(err) {
262
+ return err instanceof TradingServiceError && (err.code === "TRADING_NETWORK" || err.code === "TRADING_UPSTREAM_ERROR");
263
+ }
130
264
  var EastmoneyRestClient = class {
131
265
  baseUrl;
132
266
  historyBaseUrl;
133
267
  searchBaseUrl;
268
+ /** 东财数据中心域(港股通持股等报表端点)。 */
269
+ dataCenterBaseUrl;
134
270
  /** 本实例的市场(cn/hk 单包双实例;形态解析必须与注册市场一致)。 */
135
271
  market;
136
272
  fetchImpl;
273
+ /** 降级派生快照的 60s TTL 缓存(键=secid;只在降级分支读写,见 DERIVED_QUOTE_TTL_MS)。 */
274
+ derivedQuoteCache = /* @__PURE__ */ new Map();
137
275
  constructor(options = {}) {
138
276
  this.baseUrl = options.baseUrl ?? "https://push2.eastmoney.com";
139
277
  this.historyBaseUrl = options.historyBaseUrl ?? "https://push2his.eastmoney.com";
140
278
  this.searchBaseUrl = options.searchBaseUrl ?? "https://searchapi.eastmoney.com";
279
+ this.dataCenterBaseUrl = options.dataCenterBaseUrl ?? "https://datacenter-web.eastmoney.com";
141
280
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
142
281
  this.market = options.market ?? "cn";
143
282
  }
144
- async requestJson(url) {
283
+ async requestJson(url, referer = "https://quote.eastmoney.com/") {
145
284
  try {
146
285
  const res = await this.fetchImpl(url, { headers: {
147
286
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
148
- "Referer": "https://quote.eastmoney.com/"
287
+ "Referer": referer
149
288
  } });
150
289
  if (!res.ok) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney HTTP request failed: status ${res.status} ${res.statusText}`);
151
290
  return await res.json();
@@ -156,36 +295,156 @@ var EastmoneyRestClient = class {
156
295
  }
157
296
  async getTicker(symbol) {
158
297
  const { secid, canonical, market } = toEastmoneySecid(symbol, this.market);
159
- const scale = eastmoneyPriceScale(market);
160
- const url = `${this.baseUrl}/api/qt/stock/get?secid=${secid}&fields=f43,f44,f45,f46,f47,f48,f57,f58,f60,f86,f169,f170`;
161
- const res = await this.requestJson(url);
162
- if (!res.data) throw new TradingServiceError("TRADING_SYMBOL_NOT_FOUND", `Eastmoney symbol not found: ${symbol}`);
163
- const d = res.data;
164
- let rawPrice = 0;
165
- if (typeof d.f43 === "number") rawPrice = d.f43 / scale;
166
- else if (typeof d.f43 === "string" && d.f43 !== "-" && d.f43 !== "−") {
167
- const parsed = parseFloat(d.f43);
168
- rawPrice = Number.isNaN(parsed) ? 0 : parsed / scale;
298
+ const url = `${this.baseUrl}/api/qt/ulist.np/get?secids=${secid}&fields=${QUOTE_FIELDS}`;
299
+ try {
300
+ const row = (await this.requestJson(url)).data?.diff?.[0];
301
+ if (row === void 0) throw new TradingServiceError("TRADING_SYMBOL_NOT_FOUND", `Eastmoney symbol not found: ${symbol}`);
302
+ return parseQuoteRow(row, canonical, market);
303
+ } catch (err) {
304
+ if (!isQuotePathUnavailable(err)) throw err;
305
+ return this.derivedQuote(secid, canonical, err);
169
306
  }
170
- const price = rawPrice > 0 ? rawPrice : 0;
171
- const volume = typeof d.f47 === "number" ? d.f47 : typeof d.f47 === "string" ? parseFloat(d.f47) : 0;
172
- const timestamp = typeof d.f86 === "number" ? d.f86 * 1e3 : Date.now();
173
- const prevClose = market === "hk" ? parseScaled(d.f60, scale) : parseScaledHundred(d.f60);
174
- const changePercent = parseScaledHundred(d.f170);
175
- const name = typeof d.f58 === "string" && d.f58.trim() ? d.f58.trim() : void 0;
307
+ }
308
+ /**
309
+ * 批量最新报价:一次 `ulist.np/get` 取多只标的(契约见 @dshtrading/api 的
310
+ * `MarketDataService.getTickers`),把自选栏「N 只标的 N 次请求」压成 1 次。本连接器
311
+ * **不分片**——单次请求的标的数由调用方裁剪(桥侧 MAX_SYMBOLS)。
312
+ *
313
+ * 三个语义要点:
314
+ * - 键是**入参原字符串**:同一 secid 的多种写法(`600519` / `600519.SH`)各占一个键,
315
+ * 共享同一份快照;`Ticker.symbol` 仍是规范形;
316
+ * - 行→符号靠响应行的 f13+f12 拼回 secid 再查请求表,**与上游返回顺序无关**;
317
+ * - 只返回确实拿到的标的:转不成 secid 的符号、以及 diff 里缺行的符号都不出现在结果里
318
+ * (调用方按缺失回落单取,保住各符号自己的错误码);整批 HTTP/网络失败才抛出。
319
+ * - 整批失败时若有「降级派生」可用(见 derivedQuote),逐只派生而不是整批报废;一只都派生
320
+ * 不出来仍抛原始错误(2026-10-14 东财封报价路径后的活口)。
321
+ */
322
+ async getTickers(symbols) {
323
+ if (symbols.length === 0) return {};
324
+ const requested = /* @__PURE__ */ new Map();
325
+ for (const symbol of symbols) {
326
+ let mapped;
327
+ try {
328
+ mapped = toEastmoneySecid(symbol, this.market);
329
+ } catch {
330
+ continue;
331
+ }
332
+ const hit = requested.get(mapped.secid);
333
+ if (hit === void 0) requested.set(mapped.secid, {
334
+ canonical: mapped.canonical,
335
+ originals: [symbol]
336
+ });
337
+ else hit.originals.push(symbol);
338
+ }
339
+ if (requested.size === 0) return {};
340
+ const url = `${this.baseUrl}/api/qt/ulist.np/get?secids=${[...requested.keys()].join(",")}&fields=${QUOTE_FIELDS}`;
341
+ let res;
342
+ try {
343
+ res = await this.requestJson(url);
344
+ } catch (err) {
345
+ if (!isQuotePathUnavailable(err)) throw err;
346
+ return this.deriveTickers(requested, err);
347
+ }
348
+ const rows = Array.isArray(res.data?.diff) ? res.data?.diff : [];
349
+ const out = {};
350
+ for (const raw of rows) {
351
+ if (raw === null || typeof raw !== "object") continue;
352
+ const row = raw;
353
+ const secid = quoteRowSecid(row);
354
+ if (secid === void 0) continue;
355
+ const hit = requested.get(secid);
356
+ if (hit === void 0) continue;
357
+ const ticker = parseQuoteRow(row, hit.canonical, this.market);
358
+ for (const original of hit.originals) out[original] = ticker;
359
+ }
360
+ return out;
361
+ }
362
+ /**
363
+ * 批量报价路径不可用时的逐只降级:对**请求表里的每个 secid** 各取一次派生快照(TTL 内命中缓存
364
+ * 的不发请求);转不成 secid 的符号根本没进请求表,仍按契约跳过、整批不失败。
365
+ *
366
+ * 一只都派生不出来时抛回**原始**错误——空表在 getTickers 契约里是「一个都没收录」,与「整批
367
+ * 取数失败」语义完全不同,降级不得把后者改写成前者。只要拿到了一部分,就按既有语义只返回拿到
368
+ * 的那些(缺的由调用方回落 `getTicker`,拿它们各自的错误码)。
369
+ */
370
+ async deriveTickers(requested, cause) {
371
+ const out = {};
372
+ for (const [secid, hit] of requested) {
373
+ let ticker;
374
+ try {
375
+ ticker = await this.derivedQuote(secid, hit.canonical, cause);
376
+ } catch {
377
+ continue;
378
+ }
379
+ for (const original of hit.originals) out[original] = ticker;
380
+ }
381
+ if (Object.keys(out).length === 0) throw cause;
382
+ return out;
383
+ }
384
+ /**
385
+ * 降级取价:60s TTL 缓存(键=secid,故 `002714` 与 `002714.SZ` 共享同一份)+ 失败兜底。
386
+ * 派生本身失败(trends2 也失败 / 没有行情行)说明这个源眼下整个不可用——抛回**原始**错误码,
387
+ * 绝不吞成 not-found、也绝不交出一条空快照。返回浅拷贝,不把缓存对象交给消费方改写。
388
+ */
389
+ async derivedQuote(secid, canonical, cause) {
390
+ const hit = this.derivedQuoteCache.get(secid);
391
+ if (hit !== void 0 && hit.expiresAt > Date.now()) return { ...hit.ticker };
392
+ let ticker;
393
+ try {
394
+ ticker = await this.deriveQuoteFromTrends(secid, canonical);
395
+ } catch {
396
+ throw cause;
397
+ }
398
+ this.derivedQuoteCache.set(secid, {
399
+ ticker,
400
+ expiresAt: Date.now() + DERIVED_QUOTE_TTL_MS
401
+ });
402
+ return ticker;
403
+ }
404
+ /**
405
+ * 报价路径不可用时的降级快照:走**同源**(push2his,与 kline/trends2 同域;2026-10-14 实测
406
+ * 报价域被封时它仍 200)的当日分钟序列,末根收盘价即最新价。
407
+ *
408
+ * 2026-10-14 实测(spikes/impl-eastmoney-quote-endpoint/p3):
409
+ * - 该响应的价格**已是十进制值**——cn 0.002714 末根 `2026-10-14 15:00,41.07,40.89,41.07,
410
+ * 40.89,5411,22125210.00,41.081` → 40.89,与同刻腾讯报价逐位一致,`data.preClose`=40.9
411
+ * = 腾讯昨收;故**不再**除 10^decimal(×10^f1 是报价端点 ulist.np/get 的整数口径,不是这条的);
412
+ * - `data` 顶层自带 name/preClose,名称、昨收与价格取自同一条响应;
413
+ * - changePercent 由同一对 price/prevClose 推出(序列里没有涨跌幅字段,也不另取端点),
414
+ * 保证「派生值自洽」;
415
+ * - **不给 volume**:窗口第 6 列之和在 cn 等于当日累计(196239 = 日 K 196239),但 hk 低于
416
+ * 当日累计(18,418,300 vs 19,933,548,缺 16:00 后收市竞价)——拿不到的量宁可缺省
417
+ * (`Ticker.volume` 本就可选),不用窗口合计之类的近似值冒充当日累计。
418
+ */
419
+ async deriveQuoteFromTrends(secid, canonical) {
420
+ const url = `${this.historyBaseUrl}/api/qt/stock/trends2/get?secid=${secid}&fields1=f1,f2,f3,f4,f5,f6,f7,f8&fields2=f51,f52,f53,f54,f55,f56,f57,f58&ndays=1&iscr=0`;
421
+ const data = (await this.requestJson(url)).data;
422
+ const trends = data?.trends;
423
+ const last = Array.isArray(trends) ? trends[trends.length - 1] : void 0;
424
+ if (typeof last !== "string") throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney trends2 returned no intraday rows for ${secid}`);
425
+ const parts = last.split(",");
426
+ const price = parseFloat(parts[2] ?? "");
427
+ if (!Number.isFinite(price) || price <= 0) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney trends2 last row has no usable close for ${secid}`);
428
+ const prevClose = toFiniteNumber(data?.preClose);
429
+ const rawName = data?.name;
430
+ const name = typeof rawName === "string" && rawName.trim() ? rawName.trim() : void 0;
431
+ const changePercent = prevClose !== void 0 && prevClose > 0 ? (price - prevClose) / prevClose * 100 : void 0;
176
432
  return {
177
433
  symbol: canonical,
178
434
  ...name ? { name } : {},
179
435
  price,
180
- volume: volume > 0 ? volume : 0,
181
- timestamp,
436
+ timestamp: utc8WallTimeToEpochMs(parts[0] ?? ""),
182
437
  ...prevClose !== void 0 && prevClose > 0 ? { prevClose } : {},
183
438
  ...changePercent !== void 0 ? { changePercent } : {}
184
439
  };
185
440
  }
441
+ /** K 线能力声明(api 可选契约):cn/hk 实例同一集,口径见 eastmoneyKlineCapabilities。 */
442
+ klineCapabilities() {
443
+ return eastmoneyKlineCapabilities();
444
+ }
186
445
  async getKlines(symbol, interval = "1d", limit = 100) {
187
- const { secid, market } = toEastmoneySecid(symbol, this.market);
188
- if (market === "hk" && interval === "1m") return this.getHkIntradayTrends(secid, limit);
446
+ const { secid } = toEastmoneySecid(symbol, this.market);
447
+ if (interval === "1m") return this.getIntradayTrends(secid, limit);
189
448
  const klt = mapIntervalToKlt(interval);
190
449
  const stepMs = parseIntervalMs(interval);
191
450
  const url = `${this.historyBaseUrl}/api/qt/stock/kline/get?secid=${secid}&klt=${klt}&fqt=1&lmt=${limit}&end=20500101&fields1=f1,f2,f3,f4,f5,f6&fields2=f51,f52,f53,f54,f55,f56,f57,f58`;
@@ -220,28 +479,59 @@ var EastmoneyRestClient = class {
220
479
  return klines;
221
480
  }
222
481
  /**
223
- * 港股当日分时(trends2,ndays=1 = 最近一个交易日全天分钟序列,非交易日自然回落)。
224
- * 行格式同 kline/get:`时间,开,收,高,低,量,额,均价`;时间 'YYYY-MM-DD HH:MM' 为 UTC+8 墙钟。
482
+ * trends2 分时序列(最近 TRENDS_NDAYS 个交易日的全天分钟序列,非交易日自然回落到
483
+ * 最近交易日;cn/hk 共用)。行格式同 kline/get:`时间,开,收,高,低,量,额,均价`;
484
+ * 时间为 'YYYY-MM-DD HH:MM' 的 UTC+8 墙钟,序列按时间升序。
485
+ *
486
+ * 开价的派生语义(2026-10-14 实测,spikes/impl-five-day-1m-probe/):ndays>1 时上游
487
+ * 只给「当日」那一段真实开价,历史交易日每根的第 2 列恒为 0.00(cn 241/241、hk 331/331
488
+ * 全为 0),close 与腾讯 m1 逐根一致、high/low 有 ≤0.5 的舍入差。因此:
489
+ * - 上游 open > 0:用上游值(当日、以及非当日偶发的真实值),不做任何改写;
490
+ * - 上游 open = 0:重建 + clamp——候选值是同日前一根的 close(1m 粒度下,前一根收盘
491
+ * 是该分钟开盘价最佳可得近似;每个交易日首根没有同日前一根,取该根自身 close,不把
492
+ * 上游未给出的跨日缺口算进来,午后首根用的是午休前最后一根的 close,午休不移动价格),
493
+ * 再 clamp 进**当根** [low, high]:真实分钟 bar 恒有 low <= open <= high,该分钟真实
494
+ * 开价必然落在自身高低区间内,clamp 比原候选更接近真值,也避免实体戳出影线。
495
+ * low/high 非有限值或 low > high 时保留候选值(上游坏行不强行改写)。
496
+ * 重建后的 open 仍是派生值而非取数,但已保证自洽:2026-10-14 五日窗口实测,clamp 前
497
+ * 有 cn 242/1007、hk 28/1367 根越界(多数是最小变动价位量级,单根最大 cn 2.99 元,来自
498
+ * 2026-10-09 09:31 的分钟边界跳空),clamp 后越界 0 根(spikes/impl-five-day-1m-fetch/)。
225
499
  */
226
- async getHkIntradayTrends(secid, limit) {
227
- const url = `${this.historyBaseUrl}/api/qt/stock/trends2/get?secid=${secid}&fields1=f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11&fields2=f51,f52,f53,f54,f55,f56,f57,f58&ndays=1&iscr=0`;
500
+ async getIntradayTrends(secid, limit) {
501
+ const url = `${this.historyBaseUrl}/api/qt/stock/trends2/get?secid=${secid}&fields1=f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11&fields2=f51,f52,f53,f54,f55,f56,f57,f58&ndays=${TRENDS_NDAYS}&iscr=0`;
228
502
  const trends = (await this.requestJson(url)).data?.trends;
229
503
  if (!Array.isArray(trends)) return [];
230
504
  const stepMs = parseIntervalMs("1m");
231
505
  const klines = [];
506
+ let prevDay = "";
507
+ let prevClose = 0;
232
508
  for (const line of trends) {
233
509
  const parts = line.split(",");
234
510
  if (parts.length < 6) continue;
235
- const openTime = utc8WallTimeToEpochMs(parts[0] ?? "");
511
+ const timeStr = parts[0] ?? "";
512
+ const day = timeStr.slice(0, 10);
513
+ const close = parseFloat(parts[2] ?? "");
514
+ const high = parseFloat(parts[3] ?? "");
515
+ const low = parseFloat(parts[4] ?? "");
516
+ const upstreamOpen = parseFloat(parts[1] ?? "");
517
+ const open = upstreamOpen > 0 ? upstreamOpen : clampToBarRange(day === prevDay && prevClose > 0 ? prevClose : close, low, high);
518
+ let openTime;
519
+ try {
520
+ openTime = utc8WallTimeToEpochMs(timeStr);
521
+ } catch {
522
+ continue;
523
+ }
236
524
  klines.push({
237
525
  openTime,
238
- open: parseFloat(parts[1] ?? ""),
239
- high: parseFloat(parts[3] ?? ""),
240
- low: parseFloat(parts[4] ?? ""),
241
- close: parseFloat(parts[2] ?? ""),
526
+ open,
527
+ high,
528
+ low,
529
+ close,
242
530
  volume: parseFloat(parts[5] ?? ""),
243
531
  closeTime: openTime + stepMs - 1
244
532
  });
533
+ prevDay = day;
534
+ prevClose = close;
245
535
  }
246
536
  return klines.slice(-Math.max(limit, 1));
247
537
  }
@@ -261,6 +551,53 @@ var EastmoneyRestClient = class {
261
551
  }
262
552
  return out;
263
553
  }
554
+ /**
555
+ * 港股通持股历史(东财数据中心 RPT_MUTUAL_STOCK_HOLDRANKS,2026-10-13)。
556
+ *
557
+ * 只覆盖港股通标的:标的无该序列时返回空 points(**空 ≠ 0**,消费方隐藏副图
558
+ * 而不是画一条贴底的假线);上游网络/协议失败抛错。仅 hk 实例可用——该报表以
559
+ * 5 位港股代码为主键,cn 实例调用即协议误用。
560
+ */
561
+ async getSouthboundHoldings(symbol) {
562
+ if (this.market !== "hk") throw new TradingServiceError("TRADING_NOT_IMPLEMENTED", `Eastmoney southbound holdings is an hk-only capability (instance market: ${this.market})`);
563
+ const { canonical } = toEastmoneySecid(symbol, this.market);
564
+ const filter = `(SECURITY_CODE="${canonical.slice(0, canonical.indexOf("."))}")(MUTUAL_TYPE="002")`;
565
+ const url = `${this.dataCenterBaseUrl}/api/data/v1/get?reportName=RPT_MUTUAL_STOCK_HOLDRANKS&columns=ALL&filter=${encodeURIComponent(filter)}&pageSize=1000&pageNumber=1&sortColumns=HOLD_DATE&sortTypes=1&source=WEB&client=WEB`;
566
+ const res = await this.requestJson(url, DATACENTER_REFERER);
567
+ if (res.success === false && res.code !== DATACENTER_EMPTY_CODE) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney datacenter error: code ${res.code ?? "unknown"} ${res.message ?? ""}`.trim());
568
+ const result = res.result;
569
+ if (result === null || result === void 0) return {
570
+ symbol: canonical,
571
+ points: []
572
+ };
573
+ const rows = result.data;
574
+ if (rows === null || rows === void 0) return {
575
+ symbol: canonical,
576
+ points: []
577
+ };
578
+ if (!Array.isArray(rows)) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney datacenter malformed payload: result.data is ${typeof rows}`);
579
+ if (typeof result.pages === "number" && result.pages > 1) throw new TradingServiceError("TRADING_UPSTREAM_ERROR", `Eastmoney datacenter pagination overflow: ${rows.length} rows in page 1 of ${result.pages}`);
580
+ const byDate = /* @__PURE__ */ new Map();
581
+ for (const row of rows) {
582
+ if (row === null || typeof row !== "object") continue;
583
+ const record = row;
584
+ const ratio = toFiniteNumber(record.HOLD_SHARES_RATIO);
585
+ if (ratio === void 0) continue;
586
+ const date = parseReportDateToUtcMs(record.HOLD_DATE);
587
+ if (date === void 0) continue;
588
+ if (byDate.has(date)) continue;
589
+ byDate.set(date, {
590
+ date,
591
+ holdRatio: ratio,
592
+ holdShares: toFiniteNumber(record.HOLD_SHARES) ?? 0,
593
+ holdMarketCap: toFiniteNumber(record.HOLD_MARKET_CAP) ?? 0
594
+ });
595
+ }
596
+ return {
597
+ symbol: canonical,
598
+ points: [...byDate.values()].sort((a, b) => a.date - b.date)
599
+ };
600
+ }
264
601
  };
265
602
  //#endregion
266
- export { EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, toEastmoneySecid, utc8WallTimeToEpochMs };
603
+ export { EASTMONEY_KLINE_MAX_BARS, EastmoneyRestClient, INTERVAL_VOCABULARY, TradingServiceError, eastmoneyKlineCapabilities, eastmoneyPriceScale, mapIntervalToKlt, parseIntervalMs, parseReportDateToUtcMs, toEastmoneySecid, utc8WallTimeToEpochMs };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/connector-eastmoney",
3
- "description": "【模板】交易所连接器插件脚手架:Eastmoney(eastmoney)v2 插件骨架 —— 市场数据服务 + 交易服务 + 三态闸门 + BYOK 凭证 ref。生成器:node scripts/new-connector.mjs --slug <exchange>",
4
- "version": "0.6.0",
3
+ "description": "东方财富公开行情连接器(cn + hk 单包双市场):经 push2 / push2his 公共 REST 提供 tradingCnMarketData / tradingHkMarketData 与 <market>_get_ticker / <market>_get_klines 工具,免密、无官方授权,纯行情无交易通道。",
4
+ "version": "0.6.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -21,11 +21,11 @@
21
21
  ],
22
22
  "license": "PolyForm-Noncommercial-1.0.0",
23
23
  "dependencies": {
24
- "@dshtrading/api": "^0.6.0"
24
+ "@dshtrading/api": "^0.6.2"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@deepseek-ai/cordis": ">=4.0.0",
28
- "@deepseek-ai/dsh-tools": ">=0.2.0-rc.2",
28
+ "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2",
29
29
  "@deepseek-ai/schemastery": ">=3.18.0"
30
30
  },
31
31
  "devDependencies": {