@dshtrading/connector-stooq 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 +16 -0
- package/lib/index.d.ts +83 -0
- package/lib/index.js +235 -0
- package/lib/rest.d.ts +36 -0
- package/lib/rest.js +191 -0
- package/package.json +35 -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,16 @@
|
|
|
1
|
+
# @dshtrading/connector-stooq
|
|
2
|
+
|
|
3
|
+
> **状态:未实证(本出口被反爬拒止)**。2026-08-31 实测:Stooq 对无浏览器特征的客户端先下发 JS proof-of-work 挑战页(`/__verify`),清挑战后 CSV 下载仍返回 `Access denied`(stooq.pl:`Odmowa dostępu`),疑似按出口 IP/账户策略拒绝匿名下载——本开发出口**无任何一次成功数据返回**,证据 `spikes/impl-us/REPORT.md`。代码完整、单测绿(夹具为 Stooq 长期稳定的 CSV 格式),**其他出口可能可用**,保留在仓备查。
|
|
4
|
+
>
|
|
5
|
+
> **us 切片已切换到 Yahoo Finance**(`packages/connector-yahoo`,本出口已实证,2026-08-29),本包不再是 us bundle 依赖。换出口复测通过前,不要据此包跑真数据验收。
|
|
6
|
+
|
|
7
|
+
dsh-trading us 切片市场连接器(原数据源):经 Stooq 公共 CSV 端点实现 `@dshtrading/api` 的 `MarketDataService` 契约(ctx 键 `tradingUsMarketData`),并提供 `us_get_ticker` / `us_get_klines` / `us_place_order` 三工具(下单三段闸门与 connector-binance 同构)。
|
|
8
|
+
|
|
9
|
+
## 已知局限(实现时已内置)
|
|
10
|
+
|
|
11
|
+
- 手册引用的报价端点 `/q/l/` 已 404(stooq.com 与 stooq.pl 均实测),`getTicker` 以最新日 K 收盘价近似,不反映盘前盘后;
|
|
12
|
+
- 反爬挑战页 → `TRADING_RATE_LIMITED`(不做挑战求解/伪装);`Access denied` → `TRADING_AUTH_FAILED`。
|
|
13
|
+
|
|
14
|
+
## 合规记录(README 铁律 #5)
|
|
15
|
+
|
|
16
|
+
Stooq 免费公开 CSV 端点、无 key;个人/非商业使用边界以 stooq.com 条款为准;本仓不缓存、不批量抓取、不再分发行情数据。
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { INTERVAL_VOCABULARY, StooqRestClient, StooqRestOptions, TradingServiceError, easternWallTimeToEpochMs, isSupportedInterval, normalizeStooqSymbol } from "./rest.js";
|
|
2
|
+
import { Context, Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
4
|
+
import { Disposable, Interval, Kline, MarketDataService, Ticker } from "@dshtrading/api";
|
|
5
|
+
//#region src/index.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Cordis 插件名 = patch 行 id(TEMPLATES §8):`dsh-trading-us-*` 市场命名空间,
|
|
8
|
+
* 全仓唯一(insert-only 铁律 #1)。
|
|
9
|
+
*/
|
|
10
|
+
declare const name = "dsh-trading-us-connector-stooq";
|
|
11
|
+
interface Config {
|
|
12
|
+
/** 交易安全闸门(铁律 #3):true 时下单类工具强制 dry-run。 */
|
|
13
|
+
dryRun: boolean;
|
|
14
|
+
/** 实盘总闸门:默认 false;false 时无论 dryRun 与否都拒绝实盘下单 [S4]。 */
|
|
15
|
+
liveTrading: boolean;
|
|
16
|
+
}
|
|
17
|
+
declare const Config: Schema<Config>;
|
|
18
|
+
/** 需要宿主提供的 Cordis 服务。 */
|
|
19
|
+
declare const inject: string[];
|
|
20
|
+
/** ctx 服务键:与 @dshtrading/api 的 Context 模块增强一致(市场命名空间)。 */
|
|
21
|
+
declare const TRADING_US_MARKET_DATA_KEY = "tradingUsMarketData";
|
|
22
|
+
interface SubscribeTickerOptions {
|
|
23
|
+
/** 轮询间隔(ms)。subscribeTicker 以日 K 轮询实现(Stooq 无推送面)。 */
|
|
24
|
+
readonly intervalMs?: number;
|
|
25
|
+
}
|
|
26
|
+
declare class StooqMarketDataService extends Service implements MarketDataService {
|
|
27
|
+
private readonly client;
|
|
28
|
+
constructor(ctx: Context, options?: StooqRestOptions, name?: string);
|
|
29
|
+
getTicker(symbol: string): Promise<Ticker>;
|
|
30
|
+
getKlines(symbol: string, interval: Interval, limit?: number): Promise<Kline[]>;
|
|
31
|
+
subscribeTicker(symbol: string, cb: (ticker: Ticker) => void, options?: SubscribeTickerOptions): Disposable;
|
|
32
|
+
}
|
|
33
|
+
/** us_place_order 参数契约(美股词汇,dryRun 缺省 true)。 */
|
|
34
|
+
interface PlaceOrderArgs {
|
|
35
|
+
/** 股票代码,如 `AAPL` 或 `aapl.us`(执行前归一化为 Stooq 小写形态)。 */
|
|
36
|
+
readonly symbol: string;
|
|
37
|
+
readonly side: 'BUY' | 'SELL';
|
|
38
|
+
readonly type: 'MARKET' | 'LIMIT';
|
|
39
|
+
/** 股数(整股),必须 > 0。 */
|
|
40
|
+
readonly quantity: number;
|
|
41
|
+
/** LIMIT 单必填(schema 无法条件必填,execute 内校验),必须 > 0。 */
|
|
42
|
+
readonly price?: number;
|
|
43
|
+
/** 缺省视为 true:仅模拟。显式 false 即实盘意图,进入闸门 ①/③。 */
|
|
44
|
+
readonly dryRun?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 闸门判定结果(三条路径与裁决顺序与 crypto 同构,见 connector-binance evaluateOrderGate)。
|
|
48
|
+
*/
|
|
49
|
+
type OrderGateVerdict = {
|
|
50
|
+
action: 'reject';
|
|
51
|
+
code: 'TRADING_LIVE_TRADING_DISABLED';
|
|
52
|
+
message: string;
|
|
53
|
+
} | {
|
|
54
|
+
action: 'simulate';
|
|
55
|
+
} | {
|
|
56
|
+
action: 'live';
|
|
57
|
+
};
|
|
58
|
+
declare function evaluateOrderGate(config: Config, args: PlaceOrderArgs): OrderGateVerdict;
|
|
59
|
+
/** DRY-RUN 回执:模拟成交 + Stooq 最新收盘参照(参照取不到不阻断模拟本身)。 */
|
|
60
|
+
interface DryRunReference {
|
|
61
|
+
source: 'stooq-daily-close';
|
|
62
|
+
price?: number;
|
|
63
|
+
volume?: number;
|
|
64
|
+
timestamp?: number;
|
|
65
|
+
unavailable?: string;
|
|
66
|
+
}
|
|
67
|
+
declare function buildDryRunReceipt(args: PlaceOrderArgs, marketData: Pick<MarketDataService, 'getTicker'>): Promise<string>;
|
|
68
|
+
interface PlaceOrderToolDeps {
|
|
69
|
+
/** 行情服务(dry-run 回执的市价参照),按接口取用,不直连 HTTP。 */
|
|
70
|
+
readonly marketData: Pick<MarketDataService, 'getTicker'>;
|
|
71
|
+
/** 插件配置(dryRun 强制模拟 / liveTrading 总闸门)。 */
|
|
72
|
+
readonly config: Config;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* us_place_order 工具工厂(独立导出便于单测三条闸门路径)。
|
|
76
|
+
*
|
|
77
|
+
* 审批不在这里做:dryRun!==true 的调用由 @dshtrading/base 的 gate 插件在
|
|
78
|
+
* `tools/pre-execute` waterfall 统一 ask(S4:headless 下 ask=deny,fail-closed)。
|
|
79
|
+
*/
|
|
80
|
+
declare function createPlaceOrderTool(deps: PlaceOrderToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
81
|
+
declare function apply(ctx: Context, config: Config): void;
|
|
82
|
+
//#endregion
|
|
83
|
+
export { Config, DryRunReference, INTERVAL_VOCABULARY, OrderGateVerdict, PlaceOrderArgs, PlaceOrderToolDeps, StooqMarketDataService, StooqRestClient, StooqRestOptions, SubscribeTickerOptions, TRADING_US_MARKET_DATA_KEY, TradingServiceError, apply, buildDryRunReceipt, createPlaceOrderTool, easternWallTimeToEpochMs, evaluateOrderGate, inject, isSupportedInterval, name, normalizeStooqSymbol };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { INTERVAL_VOCABULARY, StooqRestClient, TradingServiceError, easternWallTimeToEpochMs, isSupportedInterval, normalizeStooqSymbol } 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 插件名 = patch 行 id(TEMPLATES §8):`dsh-trading-us-*` 市场命名空间,
|
|
8
|
+
* 全仓唯一(insert-only 铁律 #1)。
|
|
9
|
+
*/
|
|
10
|
+
const name = "dsh-trading-us-connector-stooq";
|
|
11
|
+
const Config = Schema.object({
|
|
12
|
+
dryRun: Schema.boolean().default(true),
|
|
13
|
+
liveTrading: Schema.boolean().default(false)
|
|
14
|
+
});
|
|
15
|
+
/** 需要宿主提供的 Cordis 服务。 */
|
|
16
|
+
const inject = ["tools"];
|
|
17
|
+
/** ctx 服务键:与 @dshtrading/api 的 Context 模块增强一致(市场命名空间)。 */
|
|
18
|
+
const TRADING_US_MARKET_DATA_KEY = "tradingUsMarketData";
|
|
19
|
+
const SUBSCRIBE_MIN_MS = 250;
|
|
20
|
+
const SUBSCRIBE_DEFAULT_MS = 5e3;
|
|
21
|
+
var StooqMarketDataService = class extends Service {
|
|
22
|
+
client;
|
|
23
|
+
constructor(ctx, options = {}, name = TRADING_US_MARKET_DATA_KEY) {
|
|
24
|
+
super(ctx, name);
|
|
25
|
+
this.client = new StooqRestClient(options);
|
|
26
|
+
}
|
|
27
|
+
getTicker(symbol) {
|
|
28
|
+
return this.client.getTicker(symbol);
|
|
29
|
+
}
|
|
30
|
+
getKlines(symbol, interval, limit) {
|
|
31
|
+
return this.client.getHistorical(symbol, interval).then((all) => typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? all.slice(-limit) : all);
|
|
32
|
+
}
|
|
33
|
+
subscribeTicker(symbol, cb, options) {
|
|
34
|
+
const ms = Math.max(options?.intervalMs ?? SUBSCRIBE_DEFAULT_MS, SUBSCRIBE_MIN_MS);
|
|
35
|
+
const tick = () => {
|
|
36
|
+
this.client.getTicker(symbol).then(cb, () => {});
|
|
37
|
+
};
|
|
38
|
+
tick();
|
|
39
|
+
const timer = setInterval(tick, ms);
|
|
40
|
+
return { dispose: () => clearInterval(timer) };
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function evaluateOrderGate(config, args) {
|
|
44
|
+
const requestedDryRun = args.dryRun ?? true;
|
|
45
|
+
if (!requestedDryRun && !config.liveTrading) return {
|
|
46
|
+
action: "reject",
|
|
47
|
+
code: "TRADING_LIVE_TRADING_DISABLED",
|
|
48
|
+
message: `us_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.`
|
|
49
|
+
};
|
|
50
|
+
if (requestedDryRun || config.dryRun) return { action: "simulate" };
|
|
51
|
+
return { action: "live" };
|
|
52
|
+
}
|
|
53
|
+
/** 参数校验(模型调用问题抛普通 Error,与 crypto 先例一致;服务故障才用错误词汇)。 */
|
|
54
|
+
function validatePlaceOrderArgs(args) {
|
|
55
|
+
if (typeof args.symbol !== "string" || !args.symbol.trim()) throw new Error("us_place_order: invalid symbol — expected a US ticker like AAPL or aapl.us");
|
|
56
|
+
try {
|
|
57
|
+
normalizeStooqSymbol(args.symbol);
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
throw new Error(`us_place_order: invalid symbol ${JSON.stringify(args.symbol)} — expected a US ticker like AAPL`, { cause });
|
|
60
|
+
}
|
|
61
|
+
if (args.side !== "BUY" && args.side !== "SELL") throw new Error(`us_place_order: invalid side ${JSON.stringify(args.side)} — expected BUY or SELL`);
|
|
62
|
+
if (args.type !== "MARKET" && args.type !== "LIMIT") throw new Error(`us_place_order: invalid type ${JSON.stringify(args.type)} — expected MARKET or LIMIT`);
|
|
63
|
+
if (typeof args.quantity !== "number" || !Number.isFinite(args.quantity) || args.quantity <= 0) throw new Error(`us_place_order: invalid quantity ${JSON.stringify(args.quantity)} — expected a positive number`);
|
|
64
|
+
if (args.type === "LIMIT" && (typeof args.price !== "number" || !Number.isFinite(args.price) || args.price <= 0)) throw new Error("us_place_order: LIMIT orders require a positive price");
|
|
65
|
+
}
|
|
66
|
+
function normalizePlaceOrderArgs(raw) {
|
|
67
|
+
const args = raw ?? {};
|
|
68
|
+
const symbol = typeof args.symbol === "string" ? args.symbol.trim() : void 0;
|
|
69
|
+
return {
|
|
70
|
+
...args,
|
|
71
|
+
symbol
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function buildDryRunReceipt(args, marketData) {
|
|
75
|
+
const symbol = normalizeStooqSymbol(args.symbol);
|
|
76
|
+
let reference;
|
|
77
|
+
try {
|
|
78
|
+
const ticker = await marketData.getTicker(symbol);
|
|
79
|
+
reference = {
|
|
80
|
+
source: "stooq-daily-close",
|
|
81
|
+
price: ticker.price,
|
|
82
|
+
volume: ticker.volume,
|
|
83
|
+
timestamp: ticker.timestamp
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
reference = {
|
|
87
|
+
source: "stooq-daily-close",
|
|
88
|
+
unavailable: error instanceof Error ? error.message : String(error)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return JSON.stringify({
|
|
92
|
+
status: "filled",
|
|
93
|
+
dryRun: true,
|
|
94
|
+
note: "DRY-RUN — simulated fill; no order was sent to any broker. Stooq is a data source only (no trading API); the reference price is the latest daily close, which does not reflect pre/post-market moves.",
|
|
95
|
+
id: `dry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
96
|
+
symbol: symbol.toUpperCase(),
|
|
97
|
+
side: args.side.toLowerCase(),
|
|
98
|
+
type: args.type.toLowerCase(),
|
|
99
|
+
quantity: args.quantity,
|
|
100
|
+
...args.type === "LIMIT" ? { price: args.price } : {},
|
|
101
|
+
reference,
|
|
102
|
+
timestamp: Date.now()
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* us_place_order 工具工厂(独立导出便于单测三条闸门路径)。
|
|
107
|
+
*
|
|
108
|
+
* 审批不在这里做:dryRun!==true 的调用由 @dshtrading/base 的 gate 插件在
|
|
109
|
+
* `tools/pre-execute` waterfall 统一 ask(S4:headless 下 ask=deny,fail-closed)。
|
|
110
|
+
*/
|
|
111
|
+
function createPlaceOrderTool(deps) {
|
|
112
|
+
return defineTool({
|
|
113
|
+
name: "us_place_order",
|
|
114
|
+
description: "Place a US stock order, or simulate one. dryRun defaults to true and returns a DRY-RUN simulated fill receipt referencing the latest Stooq daily close. Real execution (dryRun=false) requires the plugin liveTrading switch plus user approval, and is not implemented yet in this slice (Stooq has no trading API).",
|
|
115
|
+
parameters: {
|
|
116
|
+
symbol: {
|
|
117
|
+
type: "string",
|
|
118
|
+
required: true,
|
|
119
|
+
description: "US stock ticker, e.g. AAPL or aapl.us"
|
|
120
|
+
},
|
|
121
|
+
side: {
|
|
122
|
+
type: "string",
|
|
123
|
+
enum: ["BUY", "SELL"],
|
|
124
|
+
required: true,
|
|
125
|
+
description: "Order side"
|
|
126
|
+
},
|
|
127
|
+
type: {
|
|
128
|
+
type: "string",
|
|
129
|
+
enum: ["MARKET", "LIMIT"],
|
|
130
|
+
required: true,
|
|
131
|
+
description: "Order type"
|
|
132
|
+
},
|
|
133
|
+
quantity: {
|
|
134
|
+
type: "number",
|
|
135
|
+
required: true,
|
|
136
|
+
description: "Share quantity (whole shares), must be > 0"
|
|
137
|
+
},
|
|
138
|
+
price: {
|
|
139
|
+
type: "number",
|
|
140
|
+
description: "Limit price; required when type=LIMIT"
|
|
141
|
+
},
|
|
142
|
+
dryRun: {
|
|
143
|
+
type: "boolean",
|
|
144
|
+
description: "true (default) = simulate only and return a DRY-RUN receipt; false = request real execution (gated by liveTrading and user approval)",
|
|
145
|
+
default: true
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
output: {
|
|
149
|
+
schema: { type: "string" },
|
|
150
|
+
render: (_args, value) => [{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: value
|
|
153
|
+
}]
|
|
154
|
+
},
|
|
155
|
+
async execute(raw) {
|
|
156
|
+
const args = normalizePlaceOrderArgs(raw);
|
|
157
|
+
validatePlaceOrderArgs(args);
|
|
158
|
+
const verdict = evaluateOrderGate(deps.config, args);
|
|
159
|
+
if (verdict.action === "reject") return JSON.stringify({
|
|
160
|
+
status: "rejected",
|
|
161
|
+
code: verdict.code,
|
|
162
|
+
message: verdict.message
|
|
163
|
+
});
|
|
164
|
+
if (verdict.action === "simulate") return buildDryRunReceipt(args, deps.marketData);
|
|
165
|
+
throw new TradingServiceError("TRADING_NOT_IMPLEMENTED", "us_place_order: live order execution is not implemented in this slice — Stooq provides market data only; a broker API (e.g. Alpaca/IBKR) is a follow-up. Keep dryRun=true for simulated fills.");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function apply(ctx, config) {
|
|
170
|
+
new StooqMarketDataService(ctx);
|
|
171
|
+
ctx.inject(["tradingUsMarketData"], (ctx) => {
|
|
172
|
+
const marketData = ctx.tradingUsMarketData;
|
|
173
|
+
ctx.tools.register(defineTool({
|
|
174
|
+
name: "us_get_ticker",
|
|
175
|
+
description: "Get the latest market snapshot for a US stock via the Stooq public CSV endpoint. NOTE: the original Stooq quote endpoint is gone, so this returns the latest DAILY close (price, volume, close time) — it does not reflect intraday or pre/post-market moves. No credentials required; personal/non-commercial use per stooq.com terms.",
|
|
176
|
+
parameters: { symbol: {
|
|
177
|
+
type: "string",
|
|
178
|
+
required: true,
|
|
179
|
+
description: "US stock ticker, e.g. AAPL or aapl.us (normalized internally)"
|
|
180
|
+
} },
|
|
181
|
+
output: {
|
|
182
|
+
schema: { type: "string" },
|
|
183
|
+
render: (_args, value) => [{
|
|
184
|
+
type: "text",
|
|
185
|
+
text: value
|
|
186
|
+
}]
|
|
187
|
+
},
|
|
188
|
+
async execute(args) {
|
|
189
|
+
const ticker = await marketData.getTicker(args.symbol);
|
|
190
|
+
return JSON.stringify(ticker);
|
|
191
|
+
}
|
|
192
|
+
}));
|
|
193
|
+
ctx.tools.register(defineTool({
|
|
194
|
+
name: "us_get_klines",
|
|
195
|
+
description: "Get recent OHLCV candles for a US stock via the Stooq public CSV endpoint. Supported intervals: 1/5/15/30 minute (intraday availability depends on Stooq policy), 1h, 1d, 1w, 1M. No credentials required; personal/non-commercial use per stooq.com terms.",
|
|
196
|
+
parameters: {
|
|
197
|
+
symbol: {
|
|
198
|
+
type: "string",
|
|
199
|
+
required: true,
|
|
200
|
+
description: "US stock ticker, e.g. AAPL or aapl.us (normalized internally)"
|
|
201
|
+
},
|
|
202
|
+
interval: {
|
|
203
|
+
type: "string",
|
|
204
|
+
enum: INTERVAL_VOCABULARY,
|
|
205
|
+
description: "Kline interval (Stooq-supported subset)",
|
|
206
|
+
default: "1d"
|
|
207
|
+
},
|
|
208
|
+
limit: {
|
|
209
|
+
type: "integer",
|
|
210
|
+
description: "Number of most recent candles to return (1-1000; Stooq returns full history and the tool trims)",
|
|
211
|
+
default: 100
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
output: {
|
|
215
|
+
schema: { type: "string" },
|
|
216
|
+
render: (_args, value) => [{
|
|
217
|
+
type: "text",
|
|
218
|
+
text: value
|
|
219
|
+
}]
|
|
220
|
+
},
|
|
221
|
+
async execute(args) {
|
|
222
|
+
const interval = args.interval ?? "1d";
|
|
223
|
+
const limit = args.limit ?? 100;
|
|
224
|
+
const klines = await marketData.getKlines(args.symbol, interval, limit);
|
|
225
|
+
return JSON.stringify(klines);
|
|
226
|
+
}
|
|
227
|
+
}));
|
|
228
|
+
ctx.tools.register(createPlaceOrderTool({
|
|
229
|
+
marketData,
|
|
230
|
+
config
|
|
231
|
+
}));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
235
|
+
export { Config, INTERVAL_VOCABULARY, StooqMarketDataService, StooqRestClient, TRADING_US_MARKET_DATA_KEY, TradingServiceError, apply, buildDryRunReceipt, createPlaceOrderTool, easternWallTimeToEpochMs, evaluateOrderGate, inject, isSupportedInterval, name, normalizeStooqSymbol };
|
package/lib/rest.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Interval, Kline, 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
|
+
/** 规范化并校验符号:接受 `AAPL` / `aapl.us`,统一为小写;无后缀补 `.us`。 */
|
|
9
|
+
declare function normalizeStooqSymbol(symbol: string): string;
|
|
10
|
+
/** 工具 parameters enum 用:受支持 interval 词汇(api Interval 的 Stooq 子集)。 */
|
|
11
|
+
declare const INTERVAL_VOCABULARY: readonly string[];
|
|
12
|
+
declare function isSupportedInterval(value: Interval): boolean;
|
|
13
|
+
/** 美东墙钟时间 `YYYY-MM-DD HH:MM` → epoch ms(Stooq 分钟级时间戳为交易所当地时区)。 */
|
|
14
|
+
declare function easternWallTimeToEpochMs(value: string): number;
|
|
15
|
+
interface StooqRestOptions {
|
|
16
|
+
/** 覆盖站点 base(测试/反代用;stooq.pl 镜像同路径),末尾不带斜杠。 */
|
|
17
|
+
readonly baseUrl?: string;
|
|
18
|
+
/** 单请求超时(ms),默认 10s。 */
|
|
19
|
+
readonly timeoutMs?: number;
|
|
20
|
+
/** 注入 fetch 实现;缺省用全局 fetch(Node 22+ 内置)。 */
|
|
21
|
+
readonly fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
declare class StooqRestClient {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(options?: StooqRestOptions);
|
|
26
|
+
/** 拉取并解析 K 线 CSV(全量历史/可用窗口),行序为旧→新。 */
|
|
27
|
+
getHistorical(symbol: string, interval: Interval): Promise<Kline[]>;
|
|
28
|
+
/**
|
|
29
|
+
* 最新行情快照。原报价端点 /q/l/ 已 404(手册引用失真,2026-08-31 实测)——
|
|
30
|
+
* 以最新日 K 收盘价近似:price=close、volume=当日量;timestamp=该日收盘时刻。
|
|
31
|
+
* 盘前盘后不反映,工具描述必须向模型说明该局限。
|
|
32
|
+
*/
|
|
33
|
+
getTicker(symbol: string): Promise<Ticker>;
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { INTERVAL_VOCABULARY, StooqRestClient, StooqRestOptions, TradingServiceError, easternWallTimeToEpochMs, isSupportedInterval, normalizeStooqSymbol };
|
package/lib/rest.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
/** Stooq 符号:小写字母/数字,可选 `.xx` 国家/交易所后缀(美股固定 `.us`;jp/uk/de/hk… 亦存在)。 */
|
|
13
|
+
const STOOQ_SYMBOL_PATTERN = /^[a-z0-9]{1,10}(?:\.[a-z]{1,3})?$/;
|
|
14
|
+
/** 规范化并校验符号:接受 `AAPL` / `aapl.us`,统一为小写;无后缀补 `.us`。 */
|
|
15
|
+
function normalizeStooqSymbol(symbol) {
|
|
16
|
+
if (typeof symbol !== "string" || !symbol.trim()) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", "Symbol must be a non-empty string, e.g. AAPL or aapl.us");
|
|
17
|
+
const lower = symbol.trim().toLowerCase();
|
|
18
|
+
const normalized = lower.includes(".") ? lower : `${lower}.us`;
|
|
19
|
+
if (!STOOQ_SYMBOL_PATTERN.test(normalized)) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `Symbol ${JSON.stringify(symbol)} is not a valid Stooq symbol (expected like AAPL / aapl.us)`);
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
/** api Interval 词汇的受支持子集 → Stooq `i=` 值(分钟级数字;d/w/m 日/周/月)。 */
|
|
23
|
+
const INTERVAL_TO_STOOQ = /* @__PURE__ */ new Map([
|
|
24
|
+
["1m", "1"],
|
|
25
|
+
["5m", "5"],
|
|
26
|
+
["15m", "15"],
|
|
27
|
+
["30m", "30"],
|
|
28
|
+
["1h", "60"],
|
|
29
|
+
["1d", "d"],
|
|
30
|
+
["1w", "w"],
|
|
31
|
+
["1M", "m"]
|
|
32
|
+
]);
|
|
33
|
+
/** 工具 parameters enum 用:受支持 interval 词汇(api Interval 的 Stooq 子集)。 */
|
|
34
|
+
const INTERVAL_VOCABULARY = [...INTERVAL_TO_STOOQ.keys()];
|
|
35
|
+
function isSupportedInterval(value) {
|
|
36
|
+
return INTERVAL_TO_STOOQ.has(value);
|
|
37
|
+
}
|
|
38
|
+
/** 日线行 `YYYY-MM-DD` → epoch ms(UTC 当日零点,行情日界语义,非交易时段语义)。 */
|
|
39
|
+
function dailyDateToEpochMs(date) {
|
|
40
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
41
|
+
if (!m) throw new Error(`invalid stooq daily date ${JSON.stringify(date)}`);
|
|
42
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 日线收盘时刻 = 当日 UTC 零点 + 1 天 − 1ms(K 线 closeTime 语义,与 Binance 映射同构)。
|
|
46
|
+
*/
|
|
47
|
+
const DAY_MS = 864e5;
|
|
48
|
+
/** 美东墙钟时间 `YYYY-MM-DD HH:MM` → epoch ms(Stooq 分钟级时间戳为交易所当地时区)。 */
|
|
49
|
+
function easternWallTimeToEpochMs(value) {
|
|
50
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
|
|
51
|
+
if (!m) throw new Error(`invalid stooq intraday timestamp ${JSON.stringify(value)}`);
|
|
52
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
53
|
+
const guess = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? 0));
|
|
54
|
+
const offsetMs = (date) => {
|
|
55
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
56
|
+
timeZone: "America/New_York",
|
|
57
|
+
year: "numeric",
|
|
58
|
+
month: "2-digit",
|
|
59
|
+
day: "2-digit",
|
|
60
|
+
hour: "2-digit",
|
|
61
|
+
minute: "2-digit",
|
|
62
|
+
second: "2-digit",
|
|
63
|
+
hour12: false
|
|
64
|
+
}).formatToParts(new Date(date));
|
|
65
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
|
66
|
+
const hour = get("hour") % 24;
|
|
67
|
+
return Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second")) - date;
|
|
68
|
+
};
|
|
69
|
+
return guess - offsetMs(guess);
|
|
70
|
+
}
|
|
71
|
+
/** 解析单行 CSV(RFC4180 子集:双引号包裹 + `""` 转义)。 */
|
|
72
|
+
function splitCsvLine(line) {
|
|
73
|
+
const fields = [];
|
|
74
|
+
let current = "";
|
|
75
|
+
let inQuotes = false;
|
|
76
|
+
for (let i = 0; i < line.length; i++) {
|
|
77
|
+
const ch = line[i];
|
|
78
|
+
if (inQuotes) {
|
|
79
|
+
if (ch === "\"") {
|
|
80
|
+
if (line[i + 1] === "\"") {
|
|
81
|
+
current += "\"";
|
|
82
|
+
i++;
|
|
83
|
+
} else inQuotes = false;
|
|
84
|
+
} else current += ch;
|
|
85
|
+
} else if (ch === "\"") inQuotes = true;
|
|
86
|
+
else if (ch === ",") {
|
|
87
|
+
fields.push(current);
|
|
88
|
+
current = "";
|
|
89
|
+
} else current += ch;
|
|
90
|
+
}
|
|
91
|
+
fields.push(current);
|
|
92
|
+
return fields;
|
|
93
|
+
}
|
|
94
|
+
/** 宽松转 number(非有限值返回 undefined)。 */
|
|
95
|
+
function num(value) {
|
|
96
|
+
const n = Number(value);
|
|
97
|
+
return Number.isFinite(n) ? n : void 0;
|
|
98
|
+
}
|
|
99
|
+
const DEFAULT_BASE_URL = "https://stooq.com";
|
|
100
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
101
|
+
var StooqRestClient = class {
|
|
102
|
+
#baseUrl;
|
|
103
|
+
#timeoutMs;
|
|
104
|
+
#fetchImpl;
|
|
105
|
+
constructor(options = {}) {
|
|
106
|
+
this.#baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
107
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
108
|
+
this.#fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
109
|
+
}
|
|
110
|
+
async #requestCsv(path, params) {
|
|
111
|
+
const query = new URLSearchParams(params).toString();
|
|
112
|
+
const target = `${this.#baseUrl}${path}?${query}`;
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
const timer = setTimeout(() => controller.abort(new DOMException(`request timed out after ${this.#timeoutMs}ms`, "TimeoutError")), this.#timeoutMs);
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await this.#fetchImpl(target, {
|
|
118
|
+
signal: controller.signal,
|
|
119
|
+
headers: { accept: "text/csv,text/html,*/*" }
|
|
120
|
+
});
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
const timedOut = controller.signal.aborted;
|
|
123
|
+
throw new TradingServiceError("TRADING_NETWORK", timedOut ? `Stooq ${path}: request timed out after ${this.#timeoutMs}ms` : `Stooq ${path}: network error`, cause);
|
|
124
|
+
} finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
const text = await res.text().catch(() => "");
|
|
128
|
+
if (text.includes("__verify") || text.includes("requires JavaScript to verify")) throw new TradingServiceError("TRADING_RATE_LIMITED", `Stooq ${path}: the endpoint served an anti-bot JavaScript challenge instead of CSV data. Open stooq.com once in a normal browser (or use a different egress) so the session clears its check, then retry.`);
|
|
129
|
+
const trimmed = text.trim();
|
|
130
|
+
if (trimmed === "Access denied" || trimmed === "Odmowa dostępu" || trimmed === "Przekroczony dzienny limit wywołań") throw new TradingServiceError("TRADING_AUTH_FAILED", `Stooq ${path}: upstream denied anonymous data access ("${trimmed}"). Stooq restricts CSV downloads by egress/account policy — personal non-commercial use boundary is governed by stooq.com terms.`);
|
|
131
|
+
if (!res.ok) throw new TradingServiceError(res.status === 429 ? "TRADING_RATE_LIMITED" : "TRADING_EXCHANGE_ERROR", `Stooq ${path}: HTTP ${res.status} ${res.statusText}`);
|
|
132
|
+
if (!trimmed) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Stooq ${path}: empty response`);
|
|
133
|
+
return text;
|
|
134
|
+
}
|
|
135
|
+
/** 拉取并解析 K 线 CSV(全量历史/可用窗口),行序为旧→新。 */
|
|
136
|
+
async getHistorical(symbol, interval) {
|
|
137
|
+
const sym = normalizeStooqSymbol(symbol);
|
|
138
|
+
const i = INTERVAL_TO_STOOQ.get(interval);
|
|
139
|
+
if (i === void 0) throw new TradingServiceError("TRADING_UNSUPPORTED_INTERVAL", `Stooq klines: unsupported interval ${String(interval)} — supported: ${INTERVAL_VOCABULARY.join("/")}`);
|
|
140
|
+
const lines = (await this.#requestCsv("/q/d/l/", {
|
|
141
|
+
s: sym,
|
|
142
|
+
i
|
|
143
|
+
})).trim().split(/\r?\n/);
|
|
144
|
+
if (lines[0] !== "Date,Open,High,Low,Close,Volume") throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Stooq klines for ${sym}: unexpected payload (expected "Date,Open,High,Low,Close,Volume" header)`);
|
|
145
|
+
const intraday = i !== "d" && i !== "w" && i !== "m";
|
|
146
|
+
const klines = [];
|
|
147
|
+
for (const line of lines.slice(1)) {
|
|
148
|
+
if (!line.trim()) continue;
|
|
149
|
+
const f = splitCsvLine(line);
|
|
150
|
+
if (f.length < 6) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Stooq klines for ${sym}: malformed row ${JSON.stringify(line)}`);
|
|
151
|
+
const [dateField, o, h, l, c, v] = f;
|
|
152
|
+
const open = num(o);
|
|
153
|
+
const high = num(h);
|
|
154
|
+
const low = num(l);
|
|
155
|
+
const close = num(c);
|
|
156
|
+
const volume = num(v);
|
|
157
|
+
if (open === void 0 || high === void 0 || low === void 0 || close === void 0) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Stooq klines for ${sym}: malformed row values ${JSON.stringify(line)}`);
|
|
158
|
+
const openTime = intraday ? easternWallTimeToEpochMs(dateField) : dailyDateToEpochMs(dateField);
|
|
159
|
+
klines.push({
|
|
160
|
+
openTime,
|
|
161
|
+
open,
|
|
162
|
+
high,
|
|
163
|
+
low,
|
|
164
|
+
close,
|
|
165
|
+
volume: volume ?? 0,
|
|
166
|
+
closeTime: intraday ? openTime + 6e4 * Number(i) - 1 : openTime + DAY_MS - 1
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (klines.length === 0) throw new TradingServiceError("TRADING_UNSUPPORTED_SYMBOL", `Stooq klines for ${sym}: no data rows (unknown/delisted symbol?)`);
|
|
170
|
+
return klines;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 最新行情快照。原报价端点 /q/l/ 已 404(手册引用失真,2026-08-31 实测)——
|
|
174
|
+
* 以最新日 K 收盘价近似:price=close、volume=当日量;timestamp=该日收盘时刻。
|
|
175
|
+
* 盘前盘后不反映,工具描述必须向模型说明该局限。
|
|
176
|
+
*/
|
|
177
|
+
async getTicker(symbol) {
|
|
178
|
+
const sym = normalizeStooqSymbol(symbol);
|
|
179
|
+
const klines = await this.getHistorical(sym, "1d");
|
|
180
|
+
const last = klines[klines.length - 1];
|
|
181
|
+
if (!last) throw new TradingServiceError("TRADING_EXCHANGE_ERROR", `Stooq ticker for ${sym}: no data rows`);
|
|
182
|
+
return {
|
|
183
|
+
symbol: sym.toUpperCase().replace(/\.US$/, ""),
|
|
184
|
+
price: last.close,
|
|
185
|
+
volume: last.volume,
|
|
186
|
+
timestamp: last.closeTime
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
//#endregion
|
|
191
|
+
export { INTERVAL_VOCABULARY, StooqRestClient, TradingServiceError, easternWallTimeToEpochMs, isSupportedInterval, normalizeStooqSymbol };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dshtrading/connector-stooq",
|
|
3
|
+
"description": "US market connector plugin for dsh-trading: public market data from Stooq CSV endpoints implementing the @dshtrading/api contracts (no key; personal/non-commercial use per stooq.com terms)",
|
|
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
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib"
|
|
17
|
+
],
|
|
18
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@dshtrading/api": "^0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@deepseek-ai/cordis": ">=4.0.0",
|
|
24
|
+
"@deepseek-ai/dsh-tools": ">=0.1.2-alpha.1",
|
|
25
|
+
"@deepseek-ai/schemastery": ">=3.18.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"tsdown": "^0.22.0",
|
|
29
|
+
"vitest": "^3.0.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
}
|
|
35
|
+
}
|