@dshtrading/connector-tencent 0.1.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/LICENSE +75 -0
- package/README.md +25 -0
- package/lib/dataplane.d.ts +7 -0
- package/lib/dataplane.js +24 -0
- package/lib/index.d.ts +86 -0
- package/lib/index.js +256 -0
- package/lib/rest.d.ts +129 -0
- package/lib/rest.js +449 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Copyright (c) 2026 zhu1090093659 (dsh-trading)
|
|
2
|
+
|
|
3
|
+
# PolyForm Noncommercial License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncommercial Purposes
|
|
34
|
+
|
|
35
|
+
Any noncommercial purpose is a permitted purpose.
|
|
36
|
+
|
|
37
|
+
## Personal Uses
|
|
38
|
+
|
|
39
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
40
|
+
|
|
41
|
+
## Noncommercial Organizations
|
|
42
|
+
|
|
43
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
44
|
+
|
|
45
|
+
## Fair Use
|
|
46
|
+
|
|
47
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
48
|
+
|
|
49
|
+
## No Other Rights
|
|
50
|
+
|
|
51
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
52
|
+
|
|
53
|
+
## Patent Defense
|
|
54
|
+
|
|
55
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
56
|
+
|
|
57
|
+
## Violations
|
|
58
|
+
|
|
59
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
60
|
+
|
|
61
|
+
## No Liability
|
|
62
|
+
|
|
63
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
64
|
+
|
|
65
|
+
## Definitions
|
|
66
|
+
|
|
67
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
68
|
+
|
|
69
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
70
|
+
|
|
71
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
72
|
+
|
|
73
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
74
|
+
|
|
75
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @dshtrading/connector-tencent
|
|
2
|
+
|
|
3
|
+
> **状态:已实证(本出口可用)**。2026-08-31 实测:报价(qt.gtimg.cn)与日/周/月 K(web.ifzq.gtimg.cn fqkline/hkfqkline)从本开发出口均可返回真实数据,cn(贵州茅台)+ hk(腾讯控股)真实网络验证 PASS 2/2,证据 `spikes/impl-cn-hk/REPORT.md` 与 `r1/r2/r3-*` 原始文件。
|
|
4
|
+
|
|
5
|
+
dsh-trading **cn+hk 双市场切片**市场连接器:经腾讯公共行情端点实现 `@dshtrading/api` 的 `MarketDataService` 契约,并提供 `<market>_get_ticker` / `<market>_get_klines` / `<market>_place_order` 三工具(下单三段闸门与 connector-binance/connector-stooq 同构)。
|
|
6
|
+
|
|
7
|
+
## 单包双市场(多实例模式)
|
|
8
|
+
|
|
9
|
+
本包是**一个插件、两个市场实例**(手册 §8,新模式):
|
|
10
|
+
|
|
11
|
+
- Config 首键 `market: 'cn' | 'hk'` 分流;插件名统一为 `dsh-trading-tencent`;
|
|
12
|
+
- cn-trader / hk-trader 两个 preset 用**不同行 id**(`dsh-trading-cn-connector` / `dsh-trading-hk-connector`)挂载**同一 bare 包名**,`config.market` 决定实例行为;
|
|
13
|
+
- cn 实例 provide `tradingCnMarketData` + 注册 `cn_get_ticker/cn_get_klines/cn_place_order`;hk 实例 provide `tradingHkMarketData` + `hk_` 三件;
|
|
14
|
+
- isolate 组键 = 对应服务名(preset 挂载硬规则)。
|
|
15
|
+
|
|
16
|
+
## 已知局限(实现时已内置)
|
|
17
|
+
|
|
18
|
+
- 报价响应为 **GBK 编码**,客户端用 `TextDecoder('gbk')` 解码(Node 全 ICU 内置);
|
|
19
|
+
- cn/hk 报价字段布局不同:cn 成交量单位是**手**(×100 归一到股)、hk 是**股**;hk 买卖档位字段全 0(`r_hk` 无实时档)→ bid/ask 缺省;
|
|
20
|
+
- hk K 线 wire 前缀是 `hk`(报价才是 `r_hk`);K 线行字段序是**开收高低量**,hk 行第 7 元素起为分红/回购附加对象(解析丢弃);
|
|
21
|
+
- 分钟线端点(kline/mkline)本出口不可达,未实现(待验证);支持 interval = 1d/1w/1M(前权 qfq)。
|
|
22
|
+
|
|
23
|
+
## 合规记录(README 铁律 #5)
|
|
24
|
+
|
|
25
|
+
**腾讯公共行情端点:公开、无 key、无官方授权;个人使用边界自负**,以腾讯服务条款为准。本仓不缓存、不批量抓取、不再分发行情数据。腾讯不提供交易 API——live 下单路径恒为 `TRADING_NOT_IMPLEMENTED`(券商 API 是后续任务)。
|
package/lib/dataplane.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { TencentMarketDataService, marketDataKey } from "./index.js";
|
|
2
|
+
//#region src/dataplane.ts
|
|
3
|
+
const inject = [];
|
|
4
|
+
/** 解析注册表服务;老部署(base/router 未升级)返回 undefined → 调用方回退旧的直接 provide 路径。 */
|
|
5
|
+
function resolveMarketDataRegistry(ctx) {
|
|
6
|
+
const candidate = ctx.get?.("tradingMarketDataRegistry", false);
|
|
7
|
+
return candidate !== void 0 ? candidate : void 0;
|
|
8
|
+
}
|
|
9
|
+
/** 本连接器的路由 provider slug(路由层词汇,docs/exchange-routing.md §2.2)。 */
|
|
10
|
+
const ROUTER_PROVIDER = "tencent";
|
|
11
|
+
function apply(ctx, config) {
|
|
12
|
+
const market = config.market;
|
|
13
|
+
const key = marketDataKey(market);
|
|
14
|
+
const registry = resolveMarketDataRegistry(ctx);
|
|
15
|
+
if (registry === void 0) {
|
|
16
|
+
new TencentMarketDataService(ctx, market, {}, key);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const inner = ctx.isolate(key);
|
|
20
|
+
const service = new TencentMarketDataService(inner, market, {}, key);
|
|
21
|
+
ctx.effect(() => registry.register(market, ROUTER_PROVIDER, service));
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { apply, inject };
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { CnTickerExtra, HkTickerExtra, INTERVAL_VOCABULARY, TencentFundamentals, TencentIntervalDef, TencentKlineType, TencentMarket, TencentRestClient, TencentRestOptions, TencentTicker, TradingServiceError, klineDateToEpochMs, minuteKlineTimeToEpochMs, normalizeCnSymbol, normalizeHkSymbol, normalizeSymbol, parseCnOrderbook, toCanonicalTencentSymbol, wallTimeToEpochMs } from "./rest.js";
|
|
2
|
+
import { Context, Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
4
|
+
import { Disposable, Interval, Kline, MarketDataService, Orderbook, StockFundamentals, Ticker } from "@dshtrading/api";
|
|
5
|
+
//#region src/index.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Cordis 插件名:`dsh-trading-*` 命名空间(TEMPLATES §8)。**两个 preset 实例共享此
|
|
8
|
+
* 插件名**——实例区分靠 preset 行 id(dsh-trading-cn-connector / dsh-trading-hk-connector)
|
|
9
|
+
* 与 config.market,不再依赖插件名后缀(单包双市场与 crypto/us 的「一市场一插件名」不同)。
|
|
10
|
+
*/
|
|
11
|
+
declare const name = "dsh-trading-tencent";
|
|
12
|
+
type MarketOption = TencentMarket;
|
|
13
|
+
interface Config {
|
|
14
|
+
/** 市场分流(单包双市场的核心开关):cn = A 股,hk = 港股。 */
|
|
15
|
+
market: MarketOption;
|
|
16
|
+
/** 交易安全闸门(铁律 #3):true 时下单类工具强制 dry-run。 */
|
|
17
|
+
dryRun: boolean;
|
|
18
|
+
/** 实盘总闸门:默认 false;false 时无论 dryRun 与否都拒绝实盘下单 [S4]。 */
|
|
19
|
+
liveTrading: boolean;
|
|
20
|
+
}
|
|
21
|
+
declare const Config: Schema<Config>;
|
|
22
|
+
/** 需要宿主提供的 Cordis 服务。 */
|
|
23
|
+
declare const inject: string[];
|
|
24
|
+
declare const TRADING_CN_MARKET_DATA_KEY = "tradingCnMarketData";
|
|
25
|
+
declare const TRADING_HK_MARKET_DATA_KEY = "tradingHkMarketData";
|
|
26
|
+
declare function marketDataKey(market: MarketOption): string;
|
|
27
|
+
interface SubscribeTickerOptions {
|
|
28
|
+
/** 轮询间隔(ms)。subscribeTicker 以快照轮询实现(腾讯公共端点无推送面)。 */
|
|
29
|
+
readonly intervalMs?: number;
|
|
30
|
+
}
|
|
31
|
+
declare class TencentMarketDataService extends Service implements MarketDataService {
|
|
32
|
+
private readonly client;
|
|
33
|
+
constructor(ctx: Context, market: MarketOption, options?: TencentRestOptions, name?: string);
|
|
34
|
+
getTicker(symbol: string): Promise<TencentTicker>;
|
|
35
|
+
/** 基本面快照(同一报价行解析,MarketDataService 可选契约 2026-09-02)。 */
|
|
36
|
+
getFundamentals(symbol: string): Promise<StockFundamentals>;
|
|
37
|
+
/**
|
|
38
|
+
* 盘口快照(MarketDataService 可选契约,issue #39):同一报价行五档字段。
|
|
39
|
+
* hk 实例结构性不支持(r_hk 行档位全 0,getOrderbook 内按 NOT_IMPLEMENTED 拒绝)。
|
|
40
|
+
*/
|
|
41
|
+
getOrderbook(symbol: string): Promise<Orderbook>;
|
|
42
|
+
getKlines(symbol: string, interval: Interval, limit?: number): Promise<Kline[]>;
|
|
43
|
+
subscribeTicker(symbol: string, cb: (ticker: Ticker) => void, options?: SubscribeTickerOptions): Disposable;
|
|
44
|
+
}
|
|
45
|
+
/** <market>_place_order 参数契约(dryRun 缺省 true)。 */
|
|
46
|
+
interface PlaceOrderArgs {
|
|
47
|
+
/** A 股 6 位代码(600519/SH600519)或港股 1-5 位代码(00700/700)。 */
|
|
48
|
+
readonly symbol: string;
|
|
49
|
+
readonly side: 'BUY' | 'SELL';
|
|
50
|
+
readonly type: 'MARKET' | 'LIMIT';
|
|
51
|
+
/** 股数(整股),必须 > 0。 */
|
|
52
|
+
readonly quantity: number;
|
|
53
|
+
/** LIMIT 单必填(schema 无法条件必填,execute 内校验),必须 > 0。 */
|
|
54
|
+
readonly price?: number;
|
|
55
|
+
/** 缺省视为 true:仅模拟。显式 false 即实盘意图,进入闸门 ①/③。 */
|
|
56
|
+
readonly dryRun?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** 闸门判定结果(三条路径与裁决顺序与 crypto/us 同构)。 */
|
|
59
|
+
type OrderGateVerdict = {
|
|
60
|
+
action: 'reject';
|
|
61
|
+
code: 'TRADING_LIVE_TRADING_DISABLED';
|
|
62
|
+
message: string;
|
|
63
|
+
} | {
|
|
64
|
+
action: 'simulate';
|
|
65
|
+
} | {
|
|
66
|
+
action: 'live';
|
|
67
|
+
};
|
|
68
|
+
declare function evaluateOrderGate(config: Config, args: PlaceOrderArgs): OrderGateVerdict;
|
|
69
|
+
/** DRY-RUN 回执:模拟成交 + 腾讯最新行情参照(参照取不到不阻断模拟本身)。 */
|
|
70
|
+
declare function buildDryRunReceipt(market: MarketOption, args: PlaceOrderArgs, marketData: Pick<MarketDataService, 'getTicker'>): Promise<string>;
|
|
71
|
+
interface PlaceOrderToolDeps {
|
|
72
|
+
/** 行情服务(dry-run 回执的市价参照),按接口取用,不直连 HTTP。 */
|
|
73
|
+
readonly marketData: Pick<MarketDataService, 'getTicker'>;
|
|
74
|
+
/** 插件配置(market 分流 + dryRun 强制模拟 / liveTrading 总闸门)。 */
|
|
75
|
+
readonly config: Config;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* <market>_place_order 工具工厂(独立导出便于单测三条闸门路径)。
|
|
79
|
+
*
|
|
80
|
+
* 审批不在这里做:dryRun!==true 的调用由 @dshtrading/base 的 gate 插件在
|
|
81
|
+
* `tools/pre-execute` waterfall 统一 ask(S4:headless 下 ask=deny,fail-closed)。
|
|
82
|
+
*/
|
|
83
|
+
declare function createPlaceOrderTool(deps: PlaceOrderToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
84
|
+
declare function apply(ctx: Context, config: Config): void;
|
|
85
|
+
//#endregion
|
|
86
|
+
export { CnTickerExtra, Config, HkTickerExtra, INTERVAL_VOCABULARY, MarketOption, OrderGateVerdict, PlaceOrderArgs, PlaceOrderToolDeps, SubscribeTickerOptions, TRADING_CN_MARKET_DATA_KEY, TRADING_HK_MARKET_DATA_KEY, TencentFundamentals, TencentIntervalDef, TencentKlineType, TencentMarket, TencentMarketDataService, TencentRestClient, TencentRestOptions, TencentTicker, TradingServiceError, apply, buildDryRunReceipt, createPlaceOrderTool, evaluateOrderGate, inject, klineDateToEpochMs, marketDataKey, minuteKlineTimeToEpochMs, name, normalizeCnSymbol, normalizeHkSymbol, normalizeSymbol, parseCnOrderbook, toCanonicalTencentSymbol, wallTimeToEpochMs };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { INTERVAL_VOCABULARY, TencentRestClient, TradingServiceError, klineDateToEpochMs, minuteKlineTimeToEpochMs, normalizeCnSymbol, normalizeHkSymbol, normalizeSymbol, parseCnOrderbook, toCanonicalTencentSymbol, wallTimeToEpochMs } from "./rest.js";
|
|
2
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
4
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
/**
|
|
7
|
+
* Cordis 插件名:`dsh-trading-*` 命名空间(TEMPLATES §8)。**两个 preset 实例共享此
|
|
8
|
+
* 插件名**——实例区分靠 preset 行 id(dsh-trading-cn-connector / dsh-trading-hk-connector)
|
|
9
|
+
* 与 config.market,不再依赖插件名后缀(单包双市场与 crypto/us 的「一市场一插件名」不同)。
|
|
10
|
+
*/
|
|
11
|
+
const name = "dsh-trading-tencent";
|
|
12
|
+
const Config = Schema.object({
|
|
13
|
+
market: Schema.union(["cn", "hk"]).required(),
|
|
14
|
+
dryRun: Schema.boolean().default(true),
|
|
15
|
+
liveTrading: Schema.boolean().default(false)
|
|
16
|
+
});
|
|
17
|
+
/** 需要宿主提供的 Cordis 服务。 */
|
|
18
|
+
const inject = ["tools"];
|
|
19
|
+
const TRADING_CN_MARKET_DATA_KEY = "tradingCnMarketData";
|
|
20
|
+
const TRADING_HK_MARKET_DATA_KEY = "tradingHkMarketData";
|
|
21
|
+
function marketDataKey(market) {
|
|
22
|
+
return market === "hk" ? TRADING_HK_MARKET_DATA_KEY : TRADING_CN_MARKET_DATA_KEY;
|
|
23
|
+
}
|
|
24
|
+
const SUBSCRIBE_MIN_MS = 250;
|
|
25
|
+
const SUBSCRIBE_DEFAULT_MS = 5e3;
|
|
26
|
+
var TencentMarketDataService = class extends Service {
|
|
27
|
+
client;
|
|
28
|
+
constructor(ctx, market, options = {}, name = TRADING_CN_MARKET_DATA_KEY) {
|
|
29
|
+
super(ctx, name);
|
|
30
|
+
this.client = new TencentRestClient(market, options);
|
|
31
|
+
}
|
|
32
|
+
getTicker(symbol) {
|
|
33
|
+
return this.client.getTicker(symbol);
|
|
34
|
+
}
|
|
35
|
+
/** 基本面快照(同一报价行解析,MarketDataService 可选契约 2026-09-02)。 */
|
|
36
|
+
getFundamentals(symbol) {
|
|
37
|
+
return this.client.getFundamentals(symbol);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 盘口快照(MarketDataService 可选契约,issue #39):同一报价行五档字段。
|
|
41
|
+
* hk 实例结构性不支持(r_hk 行档位全 0,getOrderbook 内按 NOT_IMPLEMENTED 拒绝)。
|
|
42
|
+
*/
|
|
43
|
+
getOrderbook(symbol) {
|
|
44
|
+
return this.client.getOrderbook(symbol);
|
|
45
|
+
}
|
|
46
|
+
getKlines(symbol, interval, limit) {
|
|
47
|
+
return this.client.getKlines(symbol, interval, limit);
|
|
48
|
+
}
|
|
49
|
+
subscribeTicker(symbol, cb, options) {
|
|
50
|
+
const ms = Math.max(options?.intervalMs ?? SUBSCRIBE_DEFAULT_MS, SUBSCRIBE_MIN_MS);
|
|
51
|
+
const tick = () => {
|
|
52
|
+
this.client.getTicker(symbol).then(cb, () => {});
|
|
53
|
+
};
|
|
54
|
+
tick();
|
|
55
|
+
const timer = setInterval(tick, ms);
|
|
56
|
+
return { dispose: () => clearInterval(timer) };
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
function evaluateOrderGate(config, args) {
|
|
60
|
+
const requestedDryRun = args.dryRun ?? true;
|
|
61
|
+
if (!requestedDryRun && !config.liveTrading) return {
|
|
62
|
+
action: "reject",
|
|
63
|
+
code: "TRADING_LIVE_TRADING_DISABLED",
|
|
64
|
+
message: `${config.market}_place_order rejected: the call requests real execution (dryRun=${String(args.dryRun)}) but live trading is disabled (liveTrading=false). Ask the user to enable liveTrading explicitly after confirmation, or keep dryRun=true for a simulated fill.`
|
|
65
|
+
};
|
|
66
|
+
if (requestedDryRun || config.dryRun) return { action: "simulate" };
|
|
67
|
+
return { action: "live" };
|
|
68
|
+
}
|
|
69
|
+
/** 参数校验(模型调用问题抛普通 Error;服务故障才用错误词汇)。 */
|
|
70
|
+
function validatePlaceOrderArgs(market, args) {
|
|
71
|
+
try {
|
|
72
|
+
normalizeSymbol(market, args.symbol);
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
const expected = market === "hk" ? "an HK code like 00700 or 700" : "a CN A-share code like 600519 or SH600519";
|
|
75
|
+
throw new Error(`${market}_place_order: invalid symbol ${JSON.stringify(args.symbol)} — expected ${expected}`, { cause });
|
|
76
|
+
}
|
|
77
|
+
if (args.side !== "BUY" && args.side !== "SELL") throw new Error(`${market}_place_order: invalid side ${JSON.stringify(args.side)} — expected BUY or SELL`);
|
|
78
|
+
if (args.type !== "MARKET" && args.type !== "LIMIT") throw new Error(`${market}_place_order: invalid type ${JSON.stringify(args.type)} — expected MARKET or LIMIT`);
|
|
79
|
+
if (typeof args.quantity !== "number" || !Number.isFinite(args.quantity) || args.quantity <= 0) throw new Error(`${market}_place_order: invalid quantity ${JSON.stringify(args.quantity)} — expected a positive number`);
|
|
80
|
+
if (args.type === "LIMIT" && (typeof args.price !== "number" || !Number.isFinite(args.price) || args.price <= 0)) throw new Error(`${market}_place_order: LIMIT orders require a positive price`);
|
|
81
|
+
}
|
|
82
|
+
function normalizePlaceOrderArgs(raw) {
|
|
83
|
+
const args = raw ?? {};
|
|
84
|
+
const symbol = typeof args.symbol === "string" ? args.symbol.trim() : void 0;
|
|
85
|
+
return {
|
|
86
|
+
...args,
|
|
87
|
+
symbol
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** DRY-RUN 回执:模拟成交 + 腾讯最新行情参照(参照取不到不阻断模拟本身)。 */
|
|
91
|
+
async function buildDryRunReceipt(market, args, marketData) {
|
|
92
|
+
const symbol = normalizeSymbol(market, args.symbol);
|
|
93
|
+
let reference;
|
|
94
|
+
try {
|
|
95
|
+
const ticker = await marketData.getTicker(symbol);
|
|
96
|
+
reference = {
|
|
97
|
+
source: "tencent-quote",
|
|
98
|
+
price: ticker.price,
|
|
99
|
+
volume: ticker.volume,
|
|
100
|
+
timestamp: ticker.timestamp
|
|
101
|
+
};
|
|
102
|
+
} catch (error) {
|
|
103
|
+
reference = {
|
|
104
|
+
source: "tencent-quote",
|
|
105
|
+
unavailable: error instanceof Error ? error.message : String(error)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify({
|
|
109
|
+
status: "filled",
|
|
110
|
+
dryRun: true,
|
|
111
|
+
note: "DRY-RUN — simulated fill; no order was sent to any broker. Tencent is a data source only (no trading API); the reference price is the latest public quote snapshot.",
|
|
112
|
+
id: `dry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
113
|
+
market,
|
|
114
|
+
symbol,
|
|
115
|
+
side: args.side.toLowerCase(),
|
|
116
|
+
type: args.type.toLowerCase(),
|
|
117
|
+
quantity: args.quantity,
|
|
118
|
+
...args.type === "LIMIT" ? { price: args.price } : {},
|
|
119
|
+
reference,
|
|
120
|
+
timestamp: Date.now()
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* <market>_place_order 工具工厂(独立导出便于单测三条闸门路径)。
|
|
125
|
+
*
|
|
126
|
+
* 审批不在这里做:dryRun!==true 的调用由 @dshtrading/base 的 gate 插件在
|
|
127
|
+
* `tools/pre-execute` waterfall 统一 ask(S4:headless 下 ask=deny,fail-closed)。
|
|
128
|
+
*/
|
|
129
|
+
function createPlaceOrderTool(deps) {
|
|
130
|
+
const market = deps.config.market;
|
|
131
|
+
return defineTool({
|
|
132
|
+
name: `${market}_place_order`,
|
|
133
|
+
description: `Place a ${market === "hk" ? "Hong Kong" : "China A-share"} stock order, or simulate one. dryRun defaults to true and returns a DRY-RUN simulated fill receipt referencing the latest Tencent public quote. Real execution (dryRun=false) requires the plugin liveTrading switch plus user approval, and is not implemented yet in this slice (Tencent has no trading API).`,
|
|
134
|
+
parameters: {
|
|
135
|
+
symbol: {
|
|
136
|
+
type: "string",
|
|
137
|
+
required: true,
|
|
138
|
+
description: market === "hk" ? "HK stock code, e.g. 00700 or 700 (normalized internally)" : "CN A-share code, e.g. 600519 / SH600519 / sz000001 (normalized internally)"
|
|
139
|
+
},
|
|
140
|
+
side: {
|
|
141
|
+
type: "string",
|
|
142
|
+
enum: ["BUY", "SELL"],
|
|
143
|
+
required: true,
|
|
144
|
+
description: "Order side"
|
|
145
|
+
},
|
|
146
|
+
type: {
|
|
147
|
+
type: "string",
|
|
148
|
+
enum: ["MARKET", "LIMIT"],
|
|
149
|
+
required: true,
|
|
150
|
+
description: "Order type"
|
|
151
|
+
},
|
|
152
|
+
quantity: {
|
|
153
|
+
type: "number",
|
|
154
|
+
required: true,
|
|
155
|
+
description: "Share quantity (whole shares), must be > 0"
|
|
156
|
+
},
|
|
157
|
+
price: {
|
|
158
|
+
type: "number",
|
|
159
|
+
description: "Limit price; required when type=LIMIT"
|
|
160
|
+
},
|
|
161
|
+
dryRun: {
|
|
162
|
+
type: "boolean",
|
|
163
|
+
description: "true (default) = simulate only and return a DRY-RUN receipt; false = request real execution (gated by liveTrading and user approval)",
|
|
164
|
+
default: true
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
output: {
|
|
168
|
+
schema: { type: "string" },
|
|
169
|
+
render: (_args, value) => [{
|
|
170
|
+
type: "text",
|
|
171
|
+
text: value
|
|
172
|
+
}]
|
|
173
|
+
},
|
|
174
|
+
async execute(raw) {
|
|
175
|
+
const args = normalizePlaceOrderArgs(raw);
|
|
176
|
+
validatePlaceOrderArgs(market, args);
|
|
177
|
+
const verdict = evaluateOrderGate(deps.config, args);
|
|
178
|
+
if (verdict.action === "reject") return JSON.stringify({
|
|
179
|
+
status: "rejected",
|
|
180
|
+
code: verdict.code,
|
|
181
|
+
message: verdict.message
|
|
182
|
+
});
|
|
183
|
+
if (verdict.action === "simulate") return buildDryRunReceipt(market, args, deps.marketData);
|
|
184
|
+
throw new TradingServiceError("TRADING_NOT_IMPLEMENTED", `${market}_place_order: live order execution is not implemented in this slice — Tencent provides market data only; a broker API is a follow-up. Keep dryRun=true for simulated fills.`);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function apply(ctx, config) {
|
|
189
|
+
const market = config.market;
|
|
190
|
+
const key = marketDataKey(market);
|
|
191
|
+
new TencentMarketDataService(ctx, market, {}, key);
|
|
192
|
+
ctx.inject([key], (ctx) => {
|
|
193
|
+
const marketData = ctx[key];
|
|
194
|
+
ctx.tools.register(defineTool({
|
|
195
|
+
name: `${market}_get_ticker`,
|
|
196
|
+
description: `Get the latest market snapshot for a ${market === "hk" ? "Hong Kong (HKEX)" : "China A-share"} stock via the Tencent public quote endpoint (qt.gtimg.cn). Returns price, open/high/low, prev close, volume (shares) and quote time. No credentials required; Tencent public endpoint, no official authorization — personal use at your own discretion per Tencent terms.`,
|
|
197
|
+
parameters: { symbol: {
|
|
198
|
+
type: "string",
|
|
199
|
+
required: true,
|
|
200
|
+
description: market === "hk" ? "HK stock code, e.g. 00700 or 700 (normalized internally)" : "CN A-share code, e.g. 600519 / SH600519 / sz000001 (normalized internally)"
|
|
201
|
+
} },
|
|
202
|
+
output: {
|
|
203
|
+
schema: { type: "string" },
|
|
204
|
+
render: (_args, value) => [{
|
|
205
|
+
type: "text",
|
|
206
|
+
text: value
|
|
207
|
+
}]
|
|
208
|
+
},
|
|
209
|
+
async execute(args) {
|
|
210
|
+
const ticker = await marketData.getTicker(args.symbol);
|
|
211
|
+
return JSON.stringify(ticker);
|
|
212
|
+
}
|
|
213
|
+
}));
|
|
214
|
+
ctx.tools.register(defineTool({
|
|
215
|
+
name: `${market}_get_klines`,
|
|
216
|
+
description: `Get recent OHLCV candles for a ${market === "hk" ? "Hong Kong (HKEX)" : "China A-share"} stock via the Tencent public kline endpoint (cn supports 5m/30m/1d/1w/1M; hk supports 1d/1w/1M forward-adjusted qfq). No credentials required; Tencent public endpoint, no official authorization — personal use at your own discretion per Tencent terms.`,
|
|
217
|
+
parameters: {
|
|
218
|
+
symbol: {
|
|
219
|
+
type: "string",
|
|
220
|
+
required: true,
|
|
221
|
+
description: market === "hk" ? "HK stock code, e.g. 00700 or 700 (normalized internally)" : "CN A-share code, e.g. 600519 / SH600519 / sz000001 (normalized internally)"
|
|
222
|
+
},
|
|
223
|
+
interval: {
|
|
224
|
+
type: "string",
|
|
225
|
+
enum: INTERVAL_VOCABULARY,
|
|
226
|
+
description: "Kline interval (Tencent-supported subset)",
|
|
227
|
+
default: "1d"
|
|
228
|
+
},
|
|
229
|
+
limit: {
|
|
230
|
+
type: "integer",
|
|
231
|
+
description: "Number of most recent candles to return (1-800)",
|
|
232
|
+
default: 100
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
output: {
|
|
236
|
+
schema: { type: "string" },
|
|
237
|
+
render: (_args, value) => [{
|
|
238
|
+
type: "text",
|
|
239
|
+
text: value
|
|
240
|
+
}]
|
|
241
|
+
},
|
|
242
|
+
async execute(args) {
|
|
243
|
+
const interval = args.interval ?? "1d";
|
|
244
|
+
const limit = args.limit ?? 100;
|
|
245
|
+
const klines = await marketData.getKlines(args.symbol, interval, limit);
|
|
246
|
+
return JSON.stringify(klines);
|
|
247
|
+
}
|
|
248
|
+
}));
|
|
249
|
+
ctx.tools.register(createPlaceOrderTool({
|
|
250
|
+
marketData,
|
|
251
|
+
config
|
|
252
|
+
}));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
export { Config, INTERVAL_VOCABULARY, TRADING_CN_MARKET_DATA_KEY, TRADING_HK_MARKET_DATA_KEY, TencentMarketDataService, TencentRestClient, TradingServiceError, apply, buildDryRunReceipt, createPlaceOrderTool, evaluateOrderGate, inject, klineDateToEpochMs, marketDataKey, minuteKlineTimeToEpochMs, name, normalizeCnSymbol, normalizeHkSymbol, normalizeSymbol, parseCnOrderbook, toCanonicalTencentSymbol, wallTimeToEpochMs };
|
package/lib/rest.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Interval, Kline, Orderbook, StockFundamentals, Ticker, TradingErrorCode } from "@dshtrading/api";
|
|
2
|
+
//#region src/rest.d.ts
|
|
3
|
+
/** api 包 TradingError 契约的运行时 Error 实现。 */
|
|
4
|
+
declare class TradingServiceError extends Error {
|
|
5
|
+
readonly code: TradingErrorCode;
|
|
6
|
+
constructor(code: TradingErrorCode, message: string, cause?: unknown);
|
|
7
|
+
}
|
|
8
|
+
type TencentMarket = 'cn' | 'hk';
|
|
9
|
+
/**
|
|
10
|
+
* 规范化 A 股符号:接受 `600519` / `SH600519` / `sh600519` / `sz000001`,统一为
|
|
11
|
+
* 腾讯 wire 形态小写 `<sh|sz><6位数字>`。6/9 开头→sh(沪,含科创板 688),0/3 开头→sz
|
|
12
|
+
* (深,含创业板 300)。4/8 开头(北交所)不在本切片支持范围。
|
|
13
|
+
*/
|
|
14
|
+
declare function normalizeCnSymbol(symbol: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* 规范化港股符号:接受 `00700` / `700`(1-5 位数字,不足 5 位左补零,统一为 5 位数字
|
|
17
|
+
* 形态 `00700`)及 `HSI` / `HSTECH` / `HSCEI` 等指数符号。wire 形态由客户端按端点再加前缀(报价 `r_hk` / K线 `hk`)。
|
|
18
|
+
*/
|
|
19
|
+
declare function normalizeHkSymbol(symbol: string): string;
|
|
20
|
+
/** 按市场规范化符号并返回市场。 */
|
|
21
|
+
declare function normalizeSymbol(market: TencentMarket, symbol: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* 输出归一 → 规范形(docs/symbol-vocabulary.md):cn wire 形 sh600519 → 600519.SH;
|
|
24
|
+
* hk 5 位形 00700 → 00700.HK,指数形 HSI → HSI.HK。下游永远看到市场规范词汇。
|
|
25
|
+
*/
|
|
26
|
+
declare function toCanonicalTencentSymbol(market: TencentMarket, wireOrCode: string): string;
|
|
27
|
+
type TencentKlineType = 'fqkline' | 'mkline';
|
|
28
|
+
interface TencentIntervalDef {
|
|
29
|
+
readonly type: TencentKlineType;
|
|
30
|
+
readonly tf: string;
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly durationMs: number;
|
|
33
|
+
}
|
|
34
|
+
/** 工具 parameters enum 用:受支持 interval 词汇。 */
|
|
35
|
+
declare const INTERVAL_VOCABULARY: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* 分钟 K 线时间 `YYYYMMDDHHmm` → epoch ms(Asia/Shanghai 墙钟,12 位紧凑格式)。
|
|
38
|
+
*/
|
|
39
|
+
declare function minuteKlineTimeToEpochMs(value: string, timeZone?: string): number;
|
|
40
|
+
/** K 线日期 `YYYY-MM-DD` → epoch ms(UTC 当日零点锚定,行情日界语义,与 stooq 映射同构)。 */
|
|
41
|
+
declare function klineDateToEpochMs(date: string): number;
|
|
42
|
+
/**
|
|
43
|
+
* 交易所当地墙钟 → epoch ms(Intl 求时区偏移,含夏令时;同 stooq easternWallTimeToEpochMs
|
|
44
|
+
* 的两轮逼近法,泛化时区参数)。cn 报价时间是 `YYYYMMDDHHMMSS`(Asia/Shanghai),
|
|
45
|
+
* hk 报价时间是 `YYYY/MM/DD HH:MM:SS`(Asia/Hong_Kong)。
|
|
46
|
+
*/
|
|
47
|
+
declare function wallTimeToEpochMs(value: string, timeZone: string): number;
|
|
48
|
+
/** cn 报价扩展字段(Ticker 契约的超集;风控 skill 用涨停/跌停价)。 */
|
|
49
|
+
interface CnTickerExtra {
|
|
50
|
+
readonly market: 'cn';
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly prevClose?: number;
|
|
53
|
+
readonly open?: number;
|
|
54
|
+
readonly high?: number;
|
|
55
|
+
readonly low?: number;
|
|
56
|
+
/** 涨停价(f47)。 */
|
|
57
|
+
readonly limitUp?: number;
|
|
58
|
+
/** 跌停价(f48)。 */
|
|
59
|
+
readonly limitDown?: number;
|
|
60
|
+
readonly change?: number;
|
|
61
|
+
readonly changePercent?: number;
|
|
62
|
+
readonly currency: 'CNY';
|
|
63
|
+
}
|
|
64
|
+
/** hk 报价扩展字段。 */
|
|
65
|
+
interface HkTickerExtra {
|
|
66
|
+
readonly market: 'hk';
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly prevClose?: number;
|
|
69
|
+
readonly open?: number;
|
|
70
|
+
readonly high?: number;
|
|
71
|
+
readonly low?: number;
|
|
72
|
+
readonly change?: number;
|
|
73
|
+
readonly changePercent?: number;
|
|
74
|
+
/** 52 周最高(f48)。 */
|
|
75
|
+
readonly week52High?: number;
|
|
76
|
+
/** 52 周最低(f49)。 */
|
|
77
|
+
readonly week52Low?: number;
|
|
78
|
+
readonly currency: 'HKD';
|
|
79
|
+
}
|
|
80
|
+
type TencentTicker = Ticker & (CnTickerExtra | HkTickerExtra);
|
|
81
|
+
/**
|
|
82
|
+
* cn 五档盘口解析(issue #39)。字段布局(v_sh600519,与 parseCnTicker 同一行):
|
|
83
|
+
* 9=买一价 10=买一量(**手**) … 17=买五价 18=买五量;19=卖一价 20=卖一量 … 27=卖五价
|
|
84
|
+
* 28=卖五量。档位全 0/缺省时该档丢弃——全部无效时返回空档位(消费方显示空态,
|
|
85
|
+
* 盘后/集合竞价外时段属正常),不抛错。
|
|
86
|
+
*/
|
|
87
|
+
declare function parseCnOrderbook(fields: string[], timestamp: number): Orderbook;
|
|
88
|
+
/** 基本面快照 = api 契约形(币种单位由市场决定:cn=CNY,hk=HKD,展示层标注)。 */
|
|
89
|
+
type TencentFundamentals = StockFundamentals;
|
|
90
|
+
interface TencentRestOptions {
|
|
91
|
+
/** 覆盖报价 base(测试/反代用),末尾不带斜杠。 */
|
|
92
|
+
readonly quoteBaseUrl?: string;
|
|
93
|
+
/** 覆盖 K 线 base(测试/反代用),末尾不带斜杠。 */
|
|
94
|
+
readonly klineBaseUrl?: string;
|
|
95
|
+
/** 覆盖分钟 K 线 base(测试/反代用),末尾不带斜杠。 */
|
|
96
|
+
readonly mklineBaseUrl?: string;
|
|
97
|
+
/** 单请求超时(ms),默认 10s。 */
|
|
98
|
+
readonly timeoutMs?: number;
|
|
99
|
+
/** 注入 fetch 实现;缺省用全局 fetch(Node 22+ 内置)。 */
|
|
100
|
+
readonly fetchImpl?: typeof fetch;
|
|
101
|
+
}
|
|
102
|
+
declare class TencentRestClient {
|
|
103
|
+
#private;
|
|
104
|
+
constructor(market: TencentMarket, options?: TencentRestOptions);
|
|
105
|
+
get market(): TencentMarket;
|
|
106
|
+
/**
|
|
107
|
+
* 最新行情快照(同一报价行含基本面字段,getFundamentals 零额外请求成本地解析)。
|
|
108
|
+
*/
|
|
109
|
+
getTicker(symbol: string): Promise<TencentTicker>;
|
|
110
|
+
/**
|
|
111
|
+
* 基本面与估值快照:与 getTicker 同一行报价(字段布局见 parseCnFundamentals /
|
|
112
|
+
* parseHkFundamentals 注释),无额外端点。估值字段缺省(指数/ETF)时整字段省略。
|
|
113
|
+
*/
|
|
114
|
+
getFundamentals(symbol: string): Promise<TencentFundamentals>;
|
|
115
|
+
/**
|
|
116
|
+
* 盘口快照(api 可选契约 getOrderbook,issue #39):与 getTicker 同一行报价的
|
|
117
|
+
* 五档字段(cn 布局 fields 9-28,见 parseCnOrderbook)。**hk 结构性不支持**——
|
|
118
|
+
* r_hk 行买卖档位全 0(2026-08-31 实测,parseHkTicker 注释),直接按
|
|
119
|
+
* TRADING_NOT_IMPLEMENTED 上报,桥层转 ok:false(前端显示「未提供」而非空盘口)。
|
|
120
|
+
*/
|
|
121
|
+
getOrderbook(symbol: string): Promise<Orderbook>;
|
|
122
|
+
/**
|
|
123
|
+
* K 线(日/周/月走 fqkline 前权 qfq;5m/30m 分钟线走 mkline 端点)。
|
|
124
|
+
* 港股 mkline 端点不支持分钟线,请求 5m/30m 会抛出 TRADING_UNSUPPORTED_INTERVAL。
|
|
125
|
+
*/
|
|
126
|
+
getKlines(symbol: string, interval: Interval, limit?: number): Promise<Kline[]>;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
export { CnTickerExtra, HkTickerExtra, INTERVAL_VOCABULARY, TencentFundamentals, TencentIntervalDef, TencentKlineType, TencentMarket, TencentRestClient, TencentRestOptions, TencentTicker, TradingServiceError, klineDateToEpochMs, minuteKlineTimeToEpochMs, normalizeCnSymbol, normalizeHkSymbol, normalizeSymbol, parseCnOrderbook, toCanonicalTencentSymbol, wallTimeToEpochMs };
|
package/lib/rest.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
//#region src/rest.ts
|
|
2
|
+
/** api 包 TradingError 契约的运行时 Error 实现。 */
|
|
3
|
+
var TradingServiceError = class extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message, cause) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "TradingServiceError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
if (cause !== void 0) this.cause = cause;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const CN_SYMBOL_PATTERN = /^(?:(sh|sz)(\d{6})|(\d{6})(?:\.(sh|sz))?)$/;
|
|
13
|
+
const HK_SYMBOL_PATTERN = /^(?:r_hk|hk)?([a-z0-9^]{1,10})(?:\.hk)?$/i;
|
|
14
|
+
/**
|
|
15
|
+
* 规范化 A 股符号:接受 `600519` / `SH600519` / `sh600519` / `sz000001`,统一为
|
|
16
|
+
* 腾讯 wire 形态小写 `<sh|sz><6位数字>`。6/9 开头→sh(沪,含科创板 688),0/3 开头→sz
|
|
17
|
+
* (深,含创业板 300)。4/8 开头(北交所)不在本切片支持范围。
|
|
18
|
+
*/
|
|
19
|
+
function normalizeCnSymbol(symbol) {
|
|
20
|
+
if (typeof symbol !== "string" || !symbol.trim()) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", "Symbol must be a non-empty string, e.g. 600519 / SH600519 / sz000001");
|
|
21
|
+
const raw = symbol.trim().toLowerCase();
|
|
22
|
+
const m = CN_SYMBOL_PATTERN.exec(raw);
|
|
23
|
+
if (!m) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `Symbol ${JSON.stringify(symbol)} is not a valid CN A-share symbol (expected 6-digit code, optionally SH/SZ prefixed)`);
|
|
24
|
+
if (m[1]) return `${m[1]}${m[2]}`;
|
|
25
|
+
const code = m[3];
|
|
26
|
+
if (m[4]) return `${m[4]}${code}`;
|
|
27
|
+
return `${code.startsWith("6") || code.startsWith("9") ? "sh" : "sz"}${code}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 规范化港股符号:接受 `00700` / `700`(1-5 位数字,不足 5 位左补零,统一为 5 位数字
|
|
31
|
+
* 形态 `00700`)及 `HSI` / `HSTECH` / `HSCEI` 等指数符号。wire 形态由客户端按端点再加前缀(报价 `r_hk` / K线 `hk`)。
|
|
32
|
+
*/
|
|
33
|
+
function normalizeHkSymbol(symbol) {
|
|
34
|
+
if (typeof symbol !== "string" || !symbol.trim()) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", "Symbol must be a non-empty string, e.g. 00700, 700 or HSI");
|
|
35
|
+
const m = HK_SYMBOL_PATTERN.exec(symbol.trim().toLowerCase());
|
|
36
|
+
if (!m) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `Symbol ${JSON.stringify(symbol)} is not a valid HK stock code or index (expected 1-5 digits or index symbol, e.g. 700 / 00700 / HSI)`);
|
|
37
|
+
const code = m[1] ?? "";
|
|
38
|
+
if (/^\d+$/.test(code)) return code.padStart(5, "0");
|
|
39
|
+
return code.toUpperCase();
|
|
40
|
+
}
|
|
41
|
+
/** 按市场规范化符号并返回市场。 */
|
|
42
|
+
function normalizeSymbol(market, symbol) {
|
|
43
|
+
return market === "hk" ? normalizeHkSymbol(symbol) : normalizeCnSymbol(symbol);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 输出归一 → 规范形(docs/symbol-vocabulary.md):cn wire 形 sh600519 → 600519.SH;
|
|
47
|
+
* hk 5 位形 00700 → 00700.HK,指数形 HSI → HSI.HK。下游永远看到市场规范词汇。
|
|
48
|
+
*/
|
|
49
|
+
function toCanonicalTencentSymbol(market, wireOrCode) {
|
|
50
|
+
if (market === "hk") return `${wireOrCode.replace(/^r_hk|^hk/i, "")}.HK`;
|
|
51
|
+
const m = /^(sh|sz)(\d{6})$/i.exec(wireOrCode);
|
|
52
|
+
return m ? `${m[2]}.${(m[1] ?? "").toUpperCase()}` : wireOrCode;
|
|
53
|
+
}
|
|
54
|
+
/** api Interval 词汇的受支持子集 → 腾讯 tf= 值与响应键。 */
|
|
55
|
+
const INTERVAL_TO_TENCENT = /* @__PURE__ */ new Map([
|
|
56
|
+
["5m", {
|
|
57
|
+
type: "mkline",
|
|
58
|
+
tf: "m5",
|
|
59
|
+
key: "m5",
|
|
60
|
+
durationMs: 3e5
|
|
61
|
+
}],
|
|
62
|
+
["30m", {
|
|
63
|
+
type: "mkline",
|
|
64
|
+
tf: "m30",
|
|
65
|
+
key: "m30",
|
|
66
|
+
durationMs: 18e5
|
|
67
|
+
}],
|
|
68
|
+
["1d", {
|
|
69
|
+
type: "fqkline",
|
|
70
|
+
tf: "day",
|
|
71
|
+
key: "qfqday",
|
|
72
|
+
durationMs: 864e5
|
|
73
|
+
}],
|
|
74
|
+
["1w", {
|
|
75
|
+
type: "fqkline",
|
|
76
|
+
tf: "week",
|
|
77
|
+
key: "qfqweek",
|
|
78
|
+
durationMs: 6048e5
|
|
79
|
+
}],
|
|
80
|
+
["1M", {
|
|
81
|
+
type: "fqkline",
|
|
82
|
+
tf: "month",
|
|
83
|
+
key: "qfqmonth",
|
|
84
|
+
durationMs: 2592e6
|
|
85
|
+
}]
|
|
86
|
+
]);
|
|
87
|
+
/** 工具 parameters enum 用:受支持 interval 词汇。 */
|
|
88
|
+
const INTERVAL_VOCABULARY = [...INTERVAL_TO_TENCENT.keys()];
|
|
89
|
+
/**
|
|
90
|
+
* 分钟 K 线时间 `YYYYMMDDHHmm` → epoch ms(Asia/Shanghai 墙钟,12 位紧凑格式)。
|
|
91
|
+
*/
|
|
92
|
+
function minuteKlineTimeToEpochMs(value, timeZone = "Asia/Shanghai") {
|
|
93
|
+
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(value);
|
|
94
|
+
if (!m) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `invalid tencent minute kline timestamp ${JSON.stringify(value)}`);
|
|
95
|
+
const [, y, mo, d, h, mi] = m;
|
|
96
|
+
const guess = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), 0);
|
|
97
|
+
const offsetMs = (date) => {
|
|
98
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
99
|
+
timeZone,
|
|
100
|
+
year: "numeric",
|
|
101
|
+
month: "2-digit",
|
|
102
|
+
day: "2-digit",
|
|
103
|
+
hour: "2-digit",
|
|
104
|
+
minute: "2-digit",
|
|
105
|
+
second: "2-digit",
|
|
106
|
+
hour12: false
|
|
107
|
+
}).formatToParts(new Date(date));
|
|
108
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
|
109
|
+
const hour = get("hour") % 24;
|
|
110
|
+
return Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second")) - date;
|
|
111
|
+
};
|
|
112
|
+
return guess - offsetMs(guess);
|
|
113
|
+
}
|
|
114
|
+
/** K 线日期 `YYYY-MM-DD` → epoch ms(UTC 当日零点锚定,行情日界语义,与 stooq 映射同构)。 */
|
|
115
|
+
function klineDateToEpochMs(date) {
|
|
116
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
117
|
+
if (!m) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `invalid tencent kline date ${JSON.stringify(date)}`);
|
|
118
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 交易所当地墙钟 → epoch ms(Intl 求时区偏移,含夏令时;同 stooq easternWallTimeToEpochMs
|
|
122
|
+
* 的两轮逼近法,泛化时区参数)。cn 报价时间是 `YYYYMMDDHHMMSS`(Asia/Shanghai),
|
|
123
|
+
* hk 报价时间是 `YYYY/MM/DD HH:MM:SS`(Asia/Hong_Kong)。
|
|
124
|
+
*/
|
|
125
|
+
function wallTimeToEpochMs(value, timeZone) {
|
|
126
|
+
const compact = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(value);
|
|
127
|
+
const normalized = (compact ? `${compact[1]}-${compact[2]}-${compact[3]}T${compact[4]}:${compact[5]}:${compact[6]}` : value.replace(/\//g, "-")).replace(/^(\d{4}-\d{2}-\d{2})$/, "$1T00:00:00");
|
|
128
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(normalized);
|
|
129
|
+
if (!m) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `invalid tencent quote timestamp ${JSON.stringify(value)}`);
|
|
130
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
131
|
+
const guess = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? 0));
|
|
132
|
+
const offsetMs = (date) => {
|
|
133
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
134
|
+
timeZone,
|
|
135
|
+
year: "numeric",
|
|
136
|
+
month: "2-digit",
|
|
137
|
+
day: "2-digit",
|
|
138
|
+
hour: "2-digit",
|
|
139
|
+
minute: "2-digit",
|
|
140
|
+
second: "2-digit",
|
|
141
|
+
hour12: false
|
|
142
|
+
}).formatToParts(new Date(date));
|
|
143
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
|
144
|
+
const hour = get("hour") % 24;
|
|
145
|
+
return Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second")) - date;
|
|
146
|
+
};
|
|
147
|
+
return guess - offsetMs(guess);
|
|
148
|
+
}
|
|
149
|
+
/** 宽松转 number(非有限值返回 undefined)。 */
|
|
150
|
+
function num(value) {
|
|
151
|
+
if (value === void 0) return void 0;
|
|
152
|
+
const n = Number(value);
|
|
153
|
+
return Number.isFinite(n) ? n : void 0;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* cn 报价字段布局(v_sh600519,2026-08-31 实测 sh600519,88 字段):
|
|
157
|
+
* 1=名称 2=代码 3=现价 4=昨收 5=今开 6=成交量(**手**) 9=买一价 19=卖一价
|
|
158
|
+
* 30=时间 YYYYMMDDHHMMSS(Asia/Shanghai) 31=涨跌 32=涨跌% 33=最高 34=最低
|
|
159
|
+
* 35="价/量(手)/额(元)" 47=涨停价 48=跌停价 82=币种(CNY)。
|
|
160
|
+
*/
|
|
161
|
+
function parseCnTicker(fields, timestamp) {
|
|
162
|
+
const price = num(fields[3]);
|
|
163
|
+
if (price === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", "Tencent CN ticker: missing/invalid price field");
|
|
164
|
+
const bid = num(fields[9]);
|
|
165
|
+
const ask = num(fields[19]);
|
|
166
|
+
const volumeLots = num(fields[6]);
|
|
167
|
+
return {
|
|
168
|
+
market: "cn",
|
|
169
|
+
currency: "CNY",
|
|
170
|
+
name: fields[1] ?? "",
|
|
171
|
+
symbol: String(fields[2] ?? ""),
|
|
172
|
+
price,
|
|
173
|
+
...bid !== void 0 && bid > 0 ? { bid } : {},
|
|
174
|
+
...ask !== void 0 && ask > 0 ? { ask } : {},
|
|
175
|
+
...volumeLots !== void 0 ? { volume: volumeLots * 100 } : {},
|
|
176
|
+
timestamp,
|
|
177
|
+
prevClose: num(fields[4]),
|
|
178
|
+
open: num(fields[5]),
|
|
179
|
+
high: num(fields[33]),
|
|
180
|
+
low: num(fields[34]),
|
|
181
|
+
limitUp: num(fields[47]),
|
|
182
|
+
limitDown: num(fields[48]),
|
|
183
|
+
change: num(fields[31]),
|
|
184
|
+
changePercent: num(fields[32])
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* hk 报价字段布局(v_r_hk00700,2026-08-31 实测腾讯控股,78 字段——与 cn 布局不同):
|
|
189
|
+
* 1=名称 2=代码 3=现价 4=昨收 5=今开 6=成交量(**股**,非手) 30=时间 YYYY/MM/DD
|
|
190
|
+
* HH:MM:SS(Asia/Hong_Kong) 31=涨跌 32=涨跌% 33=最高 34=最低 37=成交额(HKD)
|
|
191
|
+
* 46=英文名 48=52周高 49=52周低 75=币种(HKD)。买卖档位字段全 0(r_hk 实时档不可用),
|
|
192
|
+
* bid/ask 置缺省。
|
|
193
|
+
*/
|
|
194
|
+
function parseHkTicker(fields, timestamp) {
|
|
195
|
+
const price = num(fields[3]);
|
|
196
|
+
if (price === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", "Tencent HK ticker: missing/invalid price field");
|
|
197
|
+
return {
|
|
198
|
+
market: "hk",
|
|
199
|
+
currency: "HKD",
|
|
200
|
+
name: fields[1] ?? "",
|
|
201
|
+
symbol: String(fields[2] ?? ""),
|
|
202
|
+
price,
|
|
203
|
+
...num(fields[6]) !== void 0 ? { volume: num(fields[6]) } : {},
|
|
204
|
+
timestamp,
|
|
205
|
+
prevClose: num(fields[4]),
|
|
206
|
+
open: num(fields[5]),
|
|
207
|
+
high: num(fields[33]),
|
|
208
|
+
low: num(fields[34]),
|
|
209
|
+
change: num(fields[31]),
|
|
210
|
+
changePercent: num(fields[32]),
|
|
211
|
+
week52High: num(fields[48]),
|
|
212
|
+
week52Low: num(fields[49])
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* cn 五档盘口解析(issue #39)。字段布局(v_sh600519,与 parseCnTicker 同一行):
|
|
217
|
+
* 9=买一价 10=买一量(**手**) … 17=买五价 18=买五量;19=卖一价 20=卖一量 … 27=卖五价
|
|
218
|
+
* 28=卖五量。档位全 0/缺省时该档丢弃——全部无效时返回空档位(消费方显示空态,
|
|
219
|
+
* 盘后/集合竞价外时段属正常),不抛错。
|
|
220
|
+
*/
|
|
221
|
+
function parseCnOrderbook(fields, timestamp) {
|
|
222
|
+
const bids = [];
|
|
223
|
+
const asks = [];
|
|
224
|
+
for (let i = 0; i < 5; i++) {
|
|
225
|
+
const price = num(fields[9 + i * 2]);
|
|
226
|
+
const amountLots = num(fields[10 + i * 2]);
|
|
227
|
+
if (price !== void 0 && amountLots !== void 0 && price > 0 && amountLots > 0) bids.push({
|
|
228
|
+
price,
|
|
229
|
+
amount: amountLots * 100
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
for (let i = 0; i < 5; i++) {
|
|
233
|
+
const price = num(fields[19 + i * 2]);
|
|
234
|
+
const amountLots = num(fields[20 + i * 2]);
|
|
235
|
+
if (price !== void 0 && amountLots !== void 0 && price > 0 && amountLots > 0) asks.push({
|
|
236
|
+
price,
|
|
237
|
+
amount: amountLots * 100
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
symbol: "",
|
|
242
|
+
bids,
|
|
243
|
+
asks,
|
|
244
|
+
timestamp
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* cn 基本面字段布局(与 parseCnTicker 同一行报价;字段号 2026-09-02 实测
|
|
249
|
+
* spikes/impl-cn-hk/r4-fundamentals/ 核对):38=换手率% 39=动态PE 44=流通市值(亿)
|
|
250
|
+
* 45=总市值(亿) 46=PB 52=静态PE 53=PE(TTM) **67=52周高 68=52周低**。
|
|
251
|
+
* 注意:kit-cn/fundamentals 的 68/69 与其实测夹具自洽,但夹具比真实响应多一个空字段
|
|
252
|
+
* (真实行 53 之后只有两个空位,52 周高在 67)——本解析器以 r4 原始字节证据为准。
|
|
253
|
+
* 字段缺省(指数/ETF)时 num() 置 undefined 后整字段省略。
|
|
254
|
+
*/
|
|
255
|
+
function parseCnFundamentals(fields, timestamp) {
|
|
256
|
+
const peDynamic = num(fields[39]);
|
|
257
|
+
const peTtm = num(fields[53]) ?? peDynamic;
|
|
258
|
+
const totalMarketCapYi = num(fields[45]);
|
|
259
|
+
const floatMarketCapYi = num(fields[44]);
|
|
260
|
+
return {
|
|
261
|
+
symbol: "",
|
|
262
|
+
name: fields[1] ?? "",
|
|
263
|
+
...totalMarketCapYi !== void 0 ? { marketCap: totalMarketCapYi * 1e8 } : {},
|
|
264
|
+
...floatMarketCapYi !== void 0 ? { floatMarketCap: floatMarketCapYi * 1e8 } : {},
|
|
265
|
+
...peTtm !== void 0 ? { peTtm } : {},
|
|
266
|
+
...peDynamic !== void 0 ? { peDynamic } : {},
|
|
267
|
+
...num(fields[46]) !== void 0 ? { pb: num(fields[46]) } : {},
|
|
268
|
+
...num(fields[38]) !== void 0 ? { turnoverRate: num(fields[38]) } : {},
|
|
269
|
+
...num(fields[67]) !== void 0 ? { fiftyTwoWeekHigh: num(fields[67]) } : {},
|
|
270
|
+
...num(fields[68]) !== void 0 ? { fiftyTwoWeekLow: num(fields[68]) } : {},
|
|
271
|
+
timestamp
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* hk 基本面字段布局(与 parseHkTicker 同一行报价,字段号对齐 kit-hk/fundamentals 实测):
|
|
276
|
+
* 39=动态PE 44=总市值(亿港元) 45=流通市值(亿港元) 47=股息率(百分比数值) 48=52周高 49=52周低
|
|
277
|
+
* 57=PE(TTM) 58=PB 59=换手率%。
|
|
278
|
+
*/
|
|
279
|
+
function parseHkFundamentals(fields, timestamp) {
|
|
280
|
+
const peDynamic = num(fields[39]);
|
|
281
|
+
const peTtm = num(fields[57]) ?? peDynamic;
|
|
282
|
+
const totalMarketCapYi = num(fields[44]);
|
|
283
|
+
const floatMarketCapYi = num(fields[45]);
|
|
284
|
+
const dividendYieldPercent = num(fields[47]);
|
|
285
|
+
return {
|
|
286
|
+
symbol: "",
|
|
287
|
+
name: fields[1] ?? "",
|
|
288
|
+
...totalMarketCapYi !== void 0 ? { marketCap: totalMarketCapYi * 1e8 } : {},
|
|
289
|
+
...floatMarketCapYi !== void 0 ? { floatMarketCap: floatMarketCapYi * 1e8 } : {},
|
|
290
|
+
...peTtm !== void 0 ? { peTtm } : {},
|
|
291
|
+
...peDynamic !== void 0 ? { peDynamic } : {},
|
|
292
|
+
...num(fields[58]) !== void 0 ? { pb: num(fields[58]) } : {},
|
|
293
|
+
...dividendYieldPercent !== void 0 ? { dividendYield: dividendYieldPercent / 100 } : {},
|
|
294
|
+
...num(fields[59]) !== void 0 ? { turnoverRate: num(fields[59]) } : {},
|
|
295
|
+
...num(fields[48]) !== void 0 ? { fiftyTwoWeekHigh: num(fields[48]) } : {},
|
|
296
|
+
...num(fields[49]) !== void 0 ? { fiftyTwoWeekLow: num(fields[49]) } : {},
|
|
297
|
+
timestamp
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const DEFAULT_QUOTE_BASE_URL = "https://qt.gtimg.cn";
|
|
301
|
+
const DEFAULT_KLINE_BASE_URL = "https://web.ifzq.gtimg.cn";
|
|
302
|
+
const DEFAULT_MKLINE_BASE_URL = "https://ifzq.gtimg.cn";
|
|
303
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
304
|
+
var TencentRestClient = class {
|
|
305
|
+
#market;
|
|
306
|
+
#quoteBaseUrl;
|
|
307
|
+
#klineBaseUrl;
|
|
308
|
+
#mklineBaseUrl;
|
|
309
|
+
#timeoutMs;
|
|
310
|
+
#fetchImpl;
|
|
311
|
+
constructor(market, options = {}) {
|
|
312
|
+
this.#market = market;
|
|
313
|
+
this.#quoteBaseUrl = options.quoteBaseUrl ?? DEFAULT_QUOTE_BASE_URL;
|
|
314
|
+
this.#klineBaseUrl = options.klineBaseUrl ?? DEFAULT_KLINE_BASE_URL;
|
|
315
|
+
this.#mklineBaseUrl = options.mklineBaseUrl ?? DEFAULT_MKLINE_BASE_URL;
|
|
316
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
317
|
+
this.#fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
318
|
+
}
|
|
319
|
+
get market() {
|
|
320
|
+
return this.#market;
|
|
321
|
+
}
|
|
322
|
+
async #requestArrayBuffer(url) {
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
const timer = setTimeout(() => controller.abort(new DOMException(`request timed out after ${this.#timeoutMs}ms`, "TimeoutError")), this.#timeoutMs);
|
|
325
|
+
let res;
|
|
326
|
+
try {
|
|
327
|
+
res = await this.#fetchImpl(url, { signal: controller.signal });
|
|
328
|
+
} catch (cause) {
|
|
329
|
+
const timedOut = controller.signal.aborted;
|
|
330
|
+
throw new TradingServiceError("TRADING_NETWORK", timedOut ? `Tencent ${url}: request timed out after ${this.#timeoutMs}ms` : `Tencent ${url}: network error`, cause);
|
|
331
|
+
} finally {
|
|
332
|
+
clearTimeout(timer);
|
|
333
|
+
}
|
|
334
|
+
if (!res.ok) throw new TradingServiceError(res.status === 429 ? "TRADING_RATE_LIMITED" : "TRADING_EXCHANGE_ERROR", `Tencent ${url}: HTTP ${res.status} ${res.statusText}`);
|
|
335
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
336
|
+
}
|
|
337
|
+
/** 报价 wire 形态:cn=sh600519;hk=r_hk00700(hk K 线另用 hk 前缀,见文件头注)。 */
|
|
338
|
+
#quoteWireCode(symbol) {
|
|
339
|
+
return this.#market === "hk" ? `r_hk${symbol}` : symbol;
|
|
340
|
+
}
|
|
341
|
+
/** K 线 wire 形态:cn=sh600519;hk=hk00700(r_hk 打 K 线端点是 param error,实测)。 */
|
|
342
|
+
#klineWireCode(symbol) {
|
|
343
|
+
return this.#market === "hk" ? `hk${symbol}` : symbol;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* 报价行取数(getTicker/getFundamentals 共用):**GBK 解码**(响应 charset=GBK,
|
|
347
|
+
* UTF-8 直接乱码);未知代码返回 `v_pv_none="1"` 之类短 body,按
|
|
348
|
+
* TRADING_UNSUPPORTED_SYMBOL 上报。返回 `~` 分隔字段数组 + 行情时间戳。
|
|
349
|
+
*/
|
|
350
|
+
async #fetchQuoteFields(symbol) {
|
|
351
|
+
const sym = normalizeSymbol(this.#market, symbol);
|
|
352
|
+
const url = `${this.#quoteBaseUrl}/q=${this.#quoteWireCode(sym)}`;
|
|
353
|
+
const bytes = await this.#requestArrayBuffer(url);
|
|
354
|
+
const text = new TextDecoder("gbk").decode(bytes);
|
|
355
|
+
const m = /="([^"]*)"/.exec(text);
|
|
356
|
+
if (!m) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `Tencent ticker for ${sym}: unparseable payload ${JSON.stringify(text.slice(0, 80))} (unknown symbol?)`);
|
|
357
|
+
const fields = m[1].split("~");
|
|
358
|
+
if (fields.length < 35) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `Tencent ticker for ${sym}: payload has ${fields.length} fields, expected >= 35 (unknown/delisted symbol?)`);
|
|
359
|
+
return {
|
|
360
|
+
fields,
|
|
361
|
+
timestamp: this.#market === "hk" ? wallTimeToEpochMs(fields[30] ?? "", "Asia/Hong_Kong") : wallTimeToEpochMs(fields[30] ?? "", "Asia/Shanghai"),
|
|
362
|
+
sym
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* 最新行情快照(同一报价行含基本面字段,getFundamentals 零额外请求成本地解析)。
|
|
367
|
+
*/
|
|
368
|
+
async getTicker(symbol) {
|
|
369
|
+
const { fields, timestamp, sym } = await this.#fetchQuoteFields(symbol);
|
|
370
|
+
return {
|
|
371
|
+
...this.#market === "hk" ? parseHkTicker(fields, timestamp) : parseCnTicker(fields, timestamp),
|
|
372
|
+
symbol: toCanonicalTencentSymbol(this.#market, sym)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* 基本面与估值快照:与 getTicker 同一行报价(字段布局见 parseCnFundamentals /
|
|
377
|
+
* parseHkFundamentals 注释),无额外端点。估值字段缺省(指数/ETF)时整字段省略。
|
|
378
|
+
*/
|
|
379
|
+
async getFundamentals(symbol) {
|
|
380
|
+
const { fields, timestamp, sym } = await this.#fetchQuoteFields(symbol);
|
|
381
|
+
return {
|
|
382
|
+
...this.#market === "hk" ? parseHkFundamentals(fields, timestamp) : parseCnFundamentals(fields, timestamp),
|
|
383
|
+
symbol: toCanonicalTencentSymbol(this.#market, sym)
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* 盘口快照(api 可选契约 getOrderbook,issue #39):与 getTicker 同一行报价的
|
|
388
|
+
* 五档字段(cn 布局 fields 9-28,见 parseCnOrderbook)。**hk 结构性不支持**——
|
|
389
|
+
* r_hk 行买卖档位全 0(2026-08-31 实测,parseHkTicker 注释),直接按
|
|
390
|
+
* TRADING_NOT_IMPLEMENTED 上报,桥层转 ok:false(前端显示「未提供」而非空盘口)。
|
|
391
|
+
*/
|
|
392
|
+
async getOrderbook(symbol) {
|
|
393
|
+
if (this.#market === "hk") throw new TradingServiceError("TRADING_NOT_IMPLEMENTED", "Tencent HK orderbook: r_hk quote row carries no five-level depth (all-zero fields) — orderbook unavailable for hk");
|
|
394
|
+
const { fields, timestamp, sym } = await this.#fetchQuoteFields(symbol);
|
|
395
|
+
return {
|
|
396
|
+
...parseCnOrderbook(fields, timestamp),
|
|
397
|
+
symbol: toCanonicalTencentSymbol(this.#market, sym)
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* K 线(日/周/月走 fqkline 前权 qfq;5m/30m 分钟线走 mkline 端点)。
|
|
402
|
+
* 港股 mkline 端点不支持分钟线,请求 5m/30m 会抛出 TRADING_UNSUPPORTED_INTERVAL。
|
|
403
|
+
*/
|
|
404
|
+
async getKlines(symbol, interval, limit) {
|
|
405
|
+
const sym = normalizeSymbol(this.#market, symbol);
|
|
406
|
+
const mapping = INTERVAL_TO_TENCENT.get(interval);
|
|
407
|
+
if (!mapping) throw new TradingServiceError("TRADING_UNSUPPORTED_INTERVAL", `Tencent klines: unsupported interval ${String(interval)} — supported: ${INTERVAL_VOCABULARY.join("/")}`);
|
|
408
|
+
if (mapping.type === "mkline" && this.#market === "hk") throw new TradingServiceError("TRADING_UNSUPPORTED_INTERVAL", `Tencent klines: Hong Kong market (HKEX) does not support minute intervals (${interval}) via public endpoint`);
|
|
409
|
+
const count = typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? Math.min(limit, 800) : 100;
|
|
410
|
+
const wire = this.#klineWireCode(sym);
|
|
411
|
+
const isMinute = mapping.type === "mkline";
|
|
412
|
+
const url = isMinute ? `${this.#mklineBaseUrl}/appstock/app/kline/mkline?param=${wire},${mapping.tf},,${count}` : `${this.#klineBaseUrl}/${this.#market === "hk" ? "appstock/app/hkfqkline/get" : "appstock/app/fqkline/get"}?param=${wire},${mapping.tf},,,${count},qfq`;
|
|
413
|
+
const bytes = await this.#requestArrayBuffer(url);
|
|
414
|
+
let payload;
|
|
415
|
+
try {
|
|
416
|
+
payload = JSON.parse(new TextDecoder("utf-8").decode(bytes));
|
|
417
|
+
} catch (cause) {
|
|
418
|
+
throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Tencent klines for ${sym}: non-JSON payload`, cause);
|
|
419
|
+
}
|
|
420
|
+
if (payload.code !== 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Tencent klines for ${sym}: upstream code=${String(payload.code)} msg=${JSON.stringify(payload.msg ?? "")}`);
|
|
421
|
+
const market = payload.data?.[wire];
|
|
422
|
+
const raw = isMinute ? market?.[mapping.key] : market?.[mapping.key] ?? market?.[mapping.tf];
|
|
423
|
+
const rows = Array.isArray(raw) ? raw : void 0;
|
|
424
|
+
if (rows === void 0 || rows.length === 0) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `Tencent klines for ${sym}: no ${mapping.key}/${mapping.tf} rows (unknown/delisted symbol?)`);
|
|
425
|
+
const klines = [];
|
|
426
|
+
for (const row of rows) {
|
|
427
|
+
if (!Array.isArray(row) || typeof row[0] !== "string") throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Tencent klines for ${sym}: malformed row ${JSON.stringify(row)?.slice(0, 80)}`);
|
|
428
|
+
const open = num(String(row[1]));
|
|
429
|
+
const close = num(String(row[2]));
|
|
430
|
+
const high = num(String(row[3]));
|
|
431
|
+
const low = num(String(row[4]));
|
|
432
|
+
const volume = num(String(row[5]));
|
|
433
|
+
if (open === void 0 || close === void 0 || high === void 0 || low === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Tencent klines for ${sym}: malformed row values ${JSON.stringify(row).slice(0, 80)}`);
|
|
434
|
+
const openTime = isMinute ? minuteKlineTimeToEpochMs(row[0]) : klineDateToEpochMs(row[0]);
|
|
435
|
+
klines.push({
|
|
436
|
+
openTime,
|
|
437
|
+
open,
|
|
438
|
+
high,
|
|
439
|
+
low,
|
|
440
|
+
close,
|
|
441
|
+
volume: volume ?? 0,
|
|
442
|
+
closeTime: openTime + mapping.durationMs - 1
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return klines;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
//#endregion
|
|
449
|
+
export { INTERVAL_VOCABULARY, TencentRestClient, TradingServiceError, klineDateToEpochMs, minuteKlineTimeToEpochMs, normalizeCnSymbol, normalizeHkSymbol, normalizeSymbol, parseCnOrderbook, toCanonicalTencentSymbol, wallTimeToEpochMs };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dshtrading/connector-tencent",
|
|
3
|
+
"description": "CN + HK dual-market connector plugin for dsh-trading: public market data from Tencent quote/kline endpoints implementing the @dshtrading/api contracts (single package, per-instance market split; no key, no official authorization — personal use boundary at your own discretion)",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json",
|
|
14
|
+
"./dataplane": {
|
|
15
|
+
"types": "./lib/dataplane.d.ts",
|
|
16
|
+
"default": "./lib/dataplane.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib"
|
|
21
|
+
],
|
|
22
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@dshtrading/api": "^0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@deepseek-ai/cordis": ">=4.0.0",
|
|
28
|
+
"@deepseek-ai/dsh-tools": ">=0.1.2-alpha.1",
|
|
29
|
+
"@deepseek-ai/schemastery": ">=3.18.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"tsdown": "^0.22.0",
|
|
33
|
+
"vitest": "^3.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsdown",
|
|
37
|
+
"test": "vitest run"
|
|
38
|
+
}
|
|
39
|
+
}
|