@whyshu/ba 0.0.0-stage → 0.1.1

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/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Binance account panel",
4
+ "description": "Adds a left sidebar entry that opens a read-only panel with Binance spot assets, futures positions, and market board. The API key stays in the dsh host process."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "币安账户面板",
4
+ "description": "在左侧栏增加一个入口,点击后在主视图展示币安现货资产、合约持仓与行情看板(只读)。API Key 只保存在 dsh 宿主进程,不下发浏览器。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@whyshu/ba",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "DSH 左侧栏币安面板:主视图展示现货资产、合约持仓与行情看板(只读,API Key 仅宿主侧)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24.2.0",
9
+ "dsh": ">=0.2.0-0 <0.3.0-0"
10
+ },
11
+ "keywords": [
12
+ "dsh",
13
+ "deepseek-harness",
14
+ "dsh-plugin",
15
+ "cordis",
16
+ "binance",
17
+ "spot",
18
+ "futures",
19
+ "portfolio"
20
+ ],
21
+ "exports": {
22
+ ".": "./src/index.js",
23
+ "./client": "./dist/client.js",
24
+ "./locale/*.json": "./locale/*.json",
25
+ "./icon": "./icon.svg"
26
+ },
27
+ "files": [
28
+ "src/",
29
+ "dist/",
30
+ "locale/",
31
+ "icon.svg",
32
+ "cordis.patch.yml",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test test/*.test.mjs"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dsh": {
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "platform": "web",
47
+ "immediately": true
48
+ },
49
+ "plugin": {
50
+ "displayName": "币安账户面板"
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * whyshu-ba · 币安只读数据层(宿主侧)
3
+ *
4
+ * 设计红线:
5
+ * - API Key / Secret **只在宿主进程**读取与签名,永不回传浏览器;
6
+ * - 只读:只调用 GET 行情/账户/持仓接口,不调用任何下单/划转/提现接口;
7
+ * - 每次请求都重新读 `~/.dsh/argent/ba.env`,改 Key / 代理无需重启 dsh;
8
+ * - 代理可配(默认读 BINANCE_PROXY,其次 HTTPS_PROXY),未配置时直连。
9
+ *
10
+ * 配置(~/.dsh/argent/ba.env,权限 600):
11
+ * BINANCE_API_KEY=...
12
+ * BINANCE_API_SECRET=...
13
+ * BINANCE_PROXY=http://127.0.0.1:7890 # 可选;本机直连 api.binance.com 不通时必须配
14
+ * BINANCE_SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT # 可选,行情看板自选币对
15
+ */
16
+ import { createHmac } from 'node:crypto'
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import http from 'node:http'
21
+ import https from 'node:https'
22
+ import tls from 'node:tls'
23
+
24
+ export const SPOT_BASE = 'https://api.binance.com'
25
+ export const FUTURES_BASE = 'https://fapi.binance.com'
26
+ export const ENV_FILE = join(homedir(), '.dsh', 'argent', 'ba.env')
27
+ export const DEFAULT_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT']
28
+ const TIMEOUT_MS = 12000
29
+ const RECV_WINDOW = 5000
30
+
31
+ /** 币安接口返回的业务错误(code/msg);与网络错误分开,便于前端提示。 */
32
+ export class BinanceError extends Error {
33
+ constructor(message, { status = 0, code = 0 } = {}) {
34
+ super(message)
35
+ this.name = 'BinanceError'
36
+ this.status = status
37
+ this.code = code
38
+ }
39
+ }
40
+
41
+ /** 极简 KEY=VALUE 解析(只支持注释、空行、单/双引号),不引依赖。 */
42
+ export function parseEnvText(text) {
43
+ const out = {}
44
+ for (const raw of String(text || '').split(/\r?\n/)) {
45
+ const line = raw.trim()
46
+ if (!line || line.startsWith('#')) continue
47
+ const eq = line.indexOf('=')
48
+ if (eq <= 0) continue
49
+ const key = line.slice(0, eq).trim()
50
+ let value = line.slice(eq + 1).trim()
51
+ if (value.length >= 2) {
52
+ const q = value[0]
53
+ if ((q === '"' || q === "'") && value[value.length - 1] === q) value = value.slice(1, -1)
54
+ }
55
+ if (key) out[key] = value
56
+ }
57
+ return out
58
+ }
59
+
60
+ /** 读设置:进程环境变量优先,其次 env 文件。envFile 可传(测试用隔离路径)。 */
61
+ export function loadSettings(envFile = ENV_FILE) {
62
+ let file = {}
63
+ try {
64
+ if (existsSync(envFile)) file = parseEnvText(readFileSync(envFile, 'utf8'))
65
+ } catch { /* 读不到就按未配置处理 */ }
66
+ const pick = (name) => {
67
+ const raw = process.env[name] !== undefined ? process.env[name] : file[name]
68
+ return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
69
+ }
70
+ const symbols = pick('BINANCE_SYMBOLS')
71
+ .split(',')
72
+ .map((s) => s.trim().toUpperCase())
73
+ .filter((s) => /^[A-Z0-9]{5,20}$/.test(s))
74
+ const proxy = pick('BINANCE_PROXY')
75
+ || process.env.HTTPS_PROXY || process.env.https_proxy
76
+ || process.env.HTTP_PROXY || process.env.http_proxy
77
+ || ''
78
+ return {
79
+ apiKey: pick('BINANCE_API_KEY'),
80
+ apiSecret: pick('BINANCE_API_SECRET'),
81
+ proxy,
82
+ symbols: symbols.length ? symbols : DEFAULT_SYMBOLS,
83
+ envFile,
84
+ }
85
+ }
86
+
87
+ export function hasCredentials(settings) {
88
+ return Boolean(settings && settings.apiKey && settings.apiSecret)
89
+ }
90
+
91
+ /**
92
+ * 通过 HTTP 代理建立到目标主机的 CONNECT 隧道。
93
+ * @param {string} proxy 代理 URL(http://host:port)
94
+ * @param {URL} target 目标 URL
95
+ * @param {number} timeoutMs 超时
96
+ * @returns {Promise<import('node:net').Socket>} 已连通的裸 TCP socket
97
+ */
98
+ function openTunnel(proxy, target, timeoutMs) {
99
+ return new Promise((resolve, reject) => {
100
+ let proxyUrl
101
+ try { proxyUrl = new URL(proxy) } catch { reject(new Error('BINANCE_PROXY 不是合法 URL: ' + proxy)); return }
102
+ if (proxyUrl.protocol !== 'http:') { reject(new Error('只支持 http:// 代理,收到: ' + proxyUrl.protocol)); return }
103
+ const port = target.port || '443'
104
+ const host = target.hostname
105
+ const req = http.request({
106
+ host: proxyUrl.hostname,
107
+ port: proxyUrl.port || 80,
108
+ method: 'CONNECT',
109
+ path: host + ':' + port,
110
+ headers: { host: host + ':' + port },
111
+ agent: false,
112
+ timeout: timeoutMs,
113
+ })
114
+ const fail = (e) => { try { req.destroy(e) } catch { reject(e) } }
115
+ req.on('timeout', () => fail(new Error('代理连接超时: ' + proxyUrl.host)))
116
+ req.on('error', (e) => reject(new Error('代理连接失败 ' + proxyUrl.host + ': ' + e.message)))
117
+ req.on('connect', (res, socket) => {
118
+ if (res.statusCode !== 200) {
119
+ socket.destroy()
120
+ reject(new Error('代理 CONNECT 返回 ' + res.statusCode))
121
+ return
122
+ }
123
+ resolve(socket)
124
+ })
125
+ req.end()
126
+ })
127
+ }
128
+
129
+ /**
130
+ * GET 一个 JSON(带代理/超时);HTTP 非 2xx 或币安 {code,msg} 抛 BinanceError。
131
+ * 超时是**整段硬超时**:DNS / CONNECT / TLS / 响应任一环节卡住都会按时失败。
132
+ * @param {string} url
133
+ * @param {{headers?: Record<string,string>, proxy?: string, timeoutMs?: number}} [opts]
134
+ */
135
+ export async function requestJson(url, opts = {}) {
136
+ const target = new URL(url)
137
+ const timeoutMs = opts.timeoutMs || TIMEOUT_MS
138
+ const headers = { accept: 'application/json', 'user-agent': 'whyshu-ba/0.1 (dsh plugin)', ...(opts.headers || {}) }
139
+
140
+ let tunnel = null
141
+ let agent = null
142
+ let req = null
143
+ let timer = null
144
+ let settled = false
145
+
146
+ const cleanup = () => {
147
+ if (timer) { clearTimeout(timer); timer = null }
148
+ if (agent) { try { agent.destroy() } catch { /* noop */ } }
149
+ if (tunnel && tunnel.destroyed === false) { try { tunnel.destroy() } catch { /* noop */ } }
150
+ if (req && req.destroyed === false) { try { req.destroy() } catch { /* noop */ } }
151
+ }
152
+
153
+ return new Promise((resolve, reject) => {
154
+ const finish = (fn, value) => {
155
+ if (settled) return
156
+ settled = true
157
+ cleanup()
158
+ fn(value)
159
+ }
160
+ const fail = (e) => finish(reject, e instanceof BinanceError ? e : new Error((e && e.message) || String(e)))
161
+
162
+ timer = setTimeout(() => {
163
+ fail(new Error('请求超时(' + timeoutMs + 'ms)' + (opts.proxy ? ',代理 ' + opts.proxy : '')))
164
+ }, timeoutMs)
165
+
166
+ ;(async () => {
167
+ try {
168
+ if (opts.proxy) tunnel = await openTunnel(opts.proxy, target, timeoutMs)
169
+ if (settled) return
170
+ if (tunnel) {
171
+ // 走代理:自定义 Agent 把 CONNECT 后的裸 socket 包成 TLS。
172
+ agent = new https.Agent({ keepAlive: false, maxSockets: 1 })
173
+ agent.createConnection = (options) => tls.connect({ socket: tunnel, servername: options.host })
174
+ }
175
+ if (settled) return
176
+ req = https.request({
177
+ host: target.hostname,
178
+ port: target.port || 443,
179
+ path: target.pathname + target.search,
180
+ method: 'GET',
181
+ headers: { ...headers, host: target.hostname, connection: 'close' },
182
+ agent: agent || false,
183
+ }, (res) => {
184
+ const chunks = []
185
+ res.on('data', (c) => chunks.push(c))
186
+ res.on('end', () => {
187
+ const text = Buffer.concat(chunks).toString('utf8')
188
+ let body
189
+ try { body = text ? JSON.parse(text) : {} } catch { body = undefined }
190
+ if (res.statusCode >= 200 && res.statusCode < 300) {
191
+ finish(resolve, body)
192
+ return
193
+ }
194
+ const msg = body && (body.msg || body.message) ? String(body.msg || body.message) : text.slice(0, 200)
195
+ finish(reject, new BinanceError('HTTP ' + res.statusCode + ': ' + msg, { status: res.statusCode, code: body && typeof body.code === 'number' ? body.code : 0 }))
196
+ })
197
+ res.on('error', fail)
198
+ })
199
+ req.on('error', fail)
200
+ req.end()
201
+ } catch (e) {
202
+ fail(e)
203
+ }
204
+ })()
205
+ })
206
+ }
207
+
208
+ /**
209
+ * 批量 symbols 参数:币安**要求原样**的 JSON 数组(`["A","B"]`),
210
+ * 百分号编码会被 `Illegal characters found in parameter 'symbols'` 拒绝。
211
+ */
212
+ function symbolsParam(symbols) {
213
+ return JSON.stringify([...new Set(symbols)])
214
+ }
215
+
216
+ /** 公开行情:24h ticker 批量查询(无需鉴权);整批失败时逐个降级。 */
217
+ export async function fetchTickers24h(symbols, settings) {
218
+ if (!symbols || !symbols.length) return []
219
+ const normalize = (r) => ({
220
+ symbol: String(r.symbol || ''),
221
+ lastPrice: Number(r.lastPrice || 0),
222
+ priceChangePercent: Number(r.priceChangePercent || 0),
223
+ highPrice: Number(r.highPrice || 0),
224
+ lowPrice: Number(r.lowPrice || 0),
225
+ quoteVolume: Number(r.quoteVolume || 0),
226
+ })
227
+ try {
228
+ const rows = await requestJson(SPOT_BASE + '/api/v3/ticker/24hr?symbols=' + symbolsParam(symbols), { proxy: settings.proxy })
229
+ if (!Array.isArray(rows)) throw new BinanceError('行情返回格式异常')
230
+ return rows.map(normalize)
231
+ } catch (batchError) {
232
+ // 自选里混了不存在的币对时整批会失败:逐个取,成功的留下。
233
+ const settled = await Promise.all(symbols.map(async (symbol) => {
234
+ try {
235
+ const one = await requestJson(SPOT_BASE + '/api/v3/ticker/24hr?symbol=' + encodeURIComponent(symbol), { proxy: settings.proxy })
236
+ return one && one.symbol ? normalize(one) : null
237
+ } catch { return null }
238
+ }))
239
+ const rows = settled.filter(Boolean)
240
+ if (!rows.length) throw batchError
241
+ return rows
242
+ }
243
+ }
244
+
245
+ /** 公开行情:批量现价(用于资产折 USDT);整批失败时逐个降级。 */
246
+ export async function fetchPrices(symbols, settings) {
247
+ if (!symbols || !symbols.length) return new Map()
248
+ try {
249
+ const rows = await requestJson(SPOT_BASE + '/api/v3/ticker/price?symbols=' + symbolsParam(symbols), { proxy: settings.proxy })
250
+ const map = new Map()
251
+ if (Array.isArray(rows)) for (const r of rows) map.set(String(r.symbol || ''), Number(r.price || 0))
252
+ return map
253
+ } catch {
254
+ // 某些资产没有 USDT 现货对(或已下线)会让整批失败:逐个取,失败的跳过。
255
+ const map = new Map()
256
+ await Promise.all([...new Set(symbols)].map(async (symbol) => {
257
+ try {
258
+ const one = await requestJson(SPOT_BASE + '/api/v3/ticker/price?symbol=' + encodeURIComponent(symbol), { proxy: settings.proxy })
259
+ if (one && one.price) map.set(String(one.symbol || symbol), Number(one.price))
260
+ } catch { /* 无对应交易对:该资产估值留空 */ }
261
+ }))
262
+ return map
263
+ }
264
+ }
265
+
266
+ /** 签名 GET(现货/合约共用)。 */
267
+ async function signedGet(base, path, params, settings) {
268
+ const search = new URLSearchParams({
269
+ ...params,
270
+ recvWindow: String(RECV_WINDOW),
271
+ timestamp: String(Date.now()),
272
+ }).toString()
273
+ const signature = createHmac('sha256', settings.apiSecret).update(search).digest('hex')
274
+ return requestJson(base + path + '?' + search + '&signature=' + signature, {
275
+ proxy: settings.proxy,
276
+ headers: { 'X-MBX-APIKEY': settings.apiKey },
277
+ })
278
+ }
279
+
280
+ /** 现货账户:余额 + 现价折 USDT。 */
281
+ export async function fetchSpot(settings) {
282
+ const account = await signedGet(SPOT_BASE, '/api/v3/account', {}, settings)
283
+ const balances = Array.isArray(account.balances) ? account.balances : []
284
+ const rows = balances
285
+ .map((b) => {
286
+ const free = Number(b.free || 0)
287
+ const locked = Number(b.locked || 0)
288
+ return { asset: String(b.asset || ''), free, locked, total: free + locked }
289
+ })
290
+ .filter((b) => b.asset && b.total > 0 && b.total > 1e-12)
291
+
292
+ const pairs = rows
293
+ .filter((b) => b.asset !== 'USDT')
294
+ .map((b) => b.asset + 'USDT')
295
+ let prices = new Map()
296
+ if (pairs.length) {
297
+ try { prices = await fetchPrices(pairs, settings) } catch { /* 无对应现货对的币种不估值 */ }
298
+ }
299
+ const valued = rows.map((b) => {
300
+ const priceUSDT = b.asset === 'USDT' ? 1 : (prices.get(b.asset + 'USDT') || 0)
301
+ return { ...b, priceUSDT, valueUSDT: priceUSDT ? b.total * priceUSDT : null }
302
+ }).sort((a, b) => (b.valueUSDT || 0) - (a.valueUSDT || 0))
303
+ const totalUSDT = valued.reduce((sum, b) => sum + (b.valueUSDT || 0), 0)
304
+ return {
305
+ totalUSDT,
306
+ canTrade: Boolean(account.canTrade),
307
+ accountType: String(account.accountType || ''),
308
+ rows: valued,
309
+ }
310
+ }
311
+
312
+ /** U 本位合约:账户余额 + 非零持仓。 */
313
+ export async function fetchFutures(settings) {
314
+ const [account, positions] = await Promise.all([
315
+ signedGet(FUTURES_BASE, '/fapi/v2/account', {}, settings),
316
+ signedGet(FUTURES_BASE, '/fapi/v2/positionRisk', {}, settings),
317
+ ])
318
+ const rows = (Array.isArray(positions) ? positions : [])
319
+ .map((p) => {
320
+ const amt = Number(p.positionAmt || 0)
321
+ return {
322
+ symbol: String(p.symbol || ''),
323
+ side: amt > 0 ? 'LONG' : amt < 0 ? 'SHORT' : 'FLAT',
324
+ positionAmt: amt,
325
+ entryPrice: Number(p.entryPrice || 0),
326
+ markPrice: Number(p.markPrice || 0),
327
+ unrealizedProfit: Number(p.unRealizedProfit || p.unrealizedProfit || 0),
328
+ leverage: Number(p.leverage || 0),
329
+ liquidationPrice: Number(p.liquidationPrice || 0),
330
+ marginType: String(p.marginType || ''),
331
+ }
332
+ })
333
+ .filter((p) => p.symbol && p.positionAmt !== 0)
334
+ return {
335
+ totalWalletBalance: Number(account.totalWalletBalance || 0),
336
+ totalUnrealizedProfit: Number(account.totalUnrealizedProfit || 0),
337
+ totalMarginBalance: Number(account.totalMarginBalance || 0),
338
+ availableBalance: Number(account.availableBalance || 0),
339
+ positions: rows,
340
+ }
341
+ }
342
+
343
+ /**
344
+ * 面板总览:行情总是有(公开接口);账户/持仓在配好 Key 后才有。
345
+ * 各段独立失败:一段报错不影响其他段。
346
+ * @param {ReturnType<typeof loadSettings>} [settingsIn]
347
+ */
348
+ export async function fetchOverview(settingsIn) {
349
+ const settings = settingsIn || loadSettings()
350
+ const configured = hasCredentials(settings)
351
+ const errors = []
352
+ const mark = (section, e) => {
353
+ const message = e instanceof BinanceError ? e.message : String((e && e.message) || e)
354
+ errors.push({ section, message })
355
+ }
356
+
357
+ let tickers = []
358
+ try {
359
+ tickers = await fetchTickers24h(settings.symbols, settings)
360
+ } catch (e) {
361
+ mark('market', e)
362
+ }
363
+
364
+ let spot = null
365
+ let futures = null
366
+ if (configured) {
367
+ try { spot = await fetchSpot(settings) } catch (e) { mark('spot', e) }
368
+ try { futures = await fetchFutures(settings) } catch (e) { mark('futures', e) }
369
+ }
370
+
371
+ // 行情按自选顺序返回(币安接口不保证顺序)
372
+ const bySymbol = new Map(tickers.map((k) => [k.symbol, k]))
373
+ const ordered = settings.symbols.map((s) => bySymbol.get(s)).filter(Boolean)
374
+
375
+ return {
376
+ at: new Date().toISOString(),
377
+ configured,
378
+ proxy: Boolean(settings.proxy),
379
+ symbols: settings.symbols,
380
+ tickers: ordered.length ? ordered : tickers,
381
+ spot,
382
+ futures,
383
+ errors,
384
+ }
385
+ }