@chongqingliangyunzhijing/ssquant-cli 0.1.0 → 0.2.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/README.md CHANGED
@@ -5,7 +5,10 @@
5
5
 
6
6
  ## 发给 AI
7
7
 
8
+ 开户完成后,账户列表显示柜台分配的实际交易账号,可直接用于 `account use` 和 `account delete`。开户中的请求可按操作号查询进度,已有内部编号仍可用于选择账户。
9
+
8
10
  > 请完整读取 https://www.pandaaiquant.com/ssquant/openapi/v1/agent-install-guide/cli ,按指引帮我安装 SSQUANT 期货仿真交易 CLI,使用我的 PandaAI 账号登录。请完成安装和环境检查,需要浏览器登录、验证码或授权时提醒我。登录后列出账户和剩余名额。如果我需要开户,问我初始资金(万元,1 至 10000),换算成人民币元,等我确认再创建。最后告诉我账户和就绪状态。本次仅做接入和开户。
9
11
 
10
12
  运行 `ssquant --help` 查看交易命令。查询、开户、交易计划和操作状态支持 `--json`。
13
+ 交易模式默认逐笔确认,适用于同一 PandaAI 用户下的全部 SSQUANT 仿真账户。用 `ssquant trade-mode show` 查询,`ssquant trade-mode set direct` 开启直接交易,`ssquant trade-mode set confirm` 恢复逐笔确认。AI 应按用户的明确指令更改设置。直接交易执行计划时省略 `--yes`。
11
14
  删除使用 `ssquant account delete <账户号>`。确认后释放本地名额,柜台已有委托仍可能成交。
package/bin/ssquant.js CHANGED
@@ -6,6 +6,7 @@ import * as acct from '../src/commands/account.js';
6
6
  import * as trade from '../src/commands/trade.js';
7
7
  import * as mandate from '../src/commands/mandate.js';
8
8
  import * as plans from '../src/commands/plan.js';
9
+ import * as tradeMode from '../src/commands/trade-mode.js';
9
10
  import { CLIENT_VERSION } from '../src/version.js';
10
11
  import * as sim from '../src/commands/sim.js';
11
12
 
@@ -39,6 +40,9 @@ program.command('login')
39
40
  program.command('logout').description('登出并清除本地凭证').action(run(async () => sys.logout()));
40
41
  program.command('whoami').description('查看登录状态与绑定账户').action(run(sys.whoami));
41
42
  program.command('doctor').description('连通性与状态自检').action(run(sys.doctor));
43
+ const mode = program.command('trade-mode').description('当前 PandaAI 用户的 SSQUANT 交易确认设置');
44
+ mode.command('show').description('查看交易模式').action(run(tradeMode.show));
45
+ mode.command('set <mode>').description('confirm 逐笔确认,direct 直接交易').action(run(tradeMode.set));
42
46
  const plan = program.command('plan').description('冻结交易计划');
43
47
  plan.command('create')
44
48
  .requiredOption('--operation <name>', 'place_order/cancel_order/cancel_all/close_all/rollover/target_position')
@@ -48,7 +52,7 @@ plan.command('create')
48
52
  plan.command('show').argument('<planId>').description('查看计划').action(run(plans.show));
49
53
  plan.command('execute').argument('<planId>').description('确认并执行计划')
50
54
  .option('--client-request-id <id>', '网络重试复用的幂等号')
51
- .requiredOption('-y, --yes', '确认执行该冻结计划')
55
+ .option('-y, --yes', '用户已确认执行该冻结计划')
52
56
  .action(run(plans.execute));
53
57
 
54
58
  const operation = program.command('operation').description('交易操作状态');
@@ -132,6 +136,7 @@ program.command('order')
132
136
  program.command('cancel')
133
137
  .argument('<orderId>', '委托号')
134
138
  .description('撤单')
139
+ .option('-y, --yes', '用户已确认撤销该委托')
135
140
  .action(run(trade.cancel));
136
141
 
137
142
  program.command('cancel-all')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chongqingliangyunzhijing/ssquant-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "SSQUANT 期货仿真交易命令行工具",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.js CHANGED
@@ -61,7 +61,7 @@ export async function loginBrowser({ scope = 'futures:read futures:trade', timeo
61
61
  const { verifier, challenge } = makePkce();
62
62
  const state = b64url(crypto.randomBytes(16));
63
63
 
64
- const { server, port, waitForCode } = await startCallbackServer(timeout);
64
+ const { server, port, waitForCode, finish } = await startCallbackServer(timeout, state);
65
65
  const redirectUri = `http://127.0.0.1:${port}/callback`;
66
66
 
67
67
  const url = apiUrl('/oauth/authorize') + '?' + new URLSearchParams({
@@ -93,42 +93,49 @@ export async function loginBrowser({ scope = 'futures:read futures:trade', timeo
93
93
  client_id: clientId(),
94
94
  },
95
95
  });
96
- return persist(data);
96
+ const saved = persist(data);
97
+ finish('success');
98
+ return saved;
99
+ } catch (error) {
100
+ finish('error');
101
+ throw error;
97
102
  } finally {
98
103
  server.close();
99
104
  }
100
105
  }
101
106
 
102
- function startCallbackServer(timeoutSec) {
107
+ export function startCallbackServer(timeoutSec, expectedState) {
103
108
  return new Promise((resolve, reject) => {
104
109
  let settle;
105
110
  const waitForCode = new Promise((res, rej) => {
106
111
  settle = { res, rej };
107
112
  });
108
113
 
114
+ let browserResponse;
115
+ const finish = (result) => {
116
+ if (!browserResponse || browserResponse.writableEnded) return;
117
+ browserResponse.writeHead(303, {
118
+ Location: LOGIN_HOME_URL + '?result=' + result,
119
+ 'Cache-Control': 'no-store',
120
+ 'Referrer-Policy': 'no-referrer',
121
+ }).end();
122
+ };
109
123
  const server = http.createServer((req, res) => {
110
124
  const u = new URL(req.url, 'http://127.0.0.1');
111
- if (!u.pathname.startsWith('/callback')) {
125
+ if (req.method !== 'GET' || u.pathname !== '/callback') {
112
126
  res.writeHead(404).end();
113
127
  return;
114
128
  }
115
- const err = u.searchParams.get('error');
116
- const html = (msg) =>
117
- `<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;padding:40px">
118
- <h2>${msg}</h2><p>可以关闭本页面,回到命令行继续。</p></body>`;
119
- if (err) {
120
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
121
- .end(html('✗ 授权被拒绝'));
129
+ if (u.searchParams.get('state') !== expectedState || browserResponse ||
130
+ (!u.searchParams.get('code') && !u.searchParams.get('error'))) {
131
+ res.writeHead(400, { 'Cache-Control': 'no-store' }).end();
132
+ return;
133
+ }
134
+ browserResponse = res;
135
+ if (u.searchParams.get('error')) {
122
136
  settle.rej(new ApiError('access_denied', '你在浏览器中拒绝了授权'));
123
137
  return;
124
138
  }
125
- // 回调只负责交付一次性 code;交付后离开 localhost 页面,避免把 code
126
- // 留在地址栏或让用户看到一个简陋的本地确认页。
127
- res.writeHead(303, {
128
- Location: LOGIN_HOME_URL,
129
- 'Cache-Control': 'no-store',
130
- 'Referrer-Policy': 'no-referrer',
131
- }).end();
132
139
  settle.res({
133
140
  code: u.searchParams.get('code'),
134
141
  state: u.searchParams.get('state'),
@@ -144,7 +151,7 @@ function startCallbackServer(timeoutSec) {
144
151
 
145
152
  // 端口交给系统分配:固定端口会在多开时冲突
146
153
  server.listen(0, '127.0.0.1', () => {
147
- resolve({ server, port: server.address().port, waitForCode });
154
+ resolve({ server, port: server.address().port, waitForCode, finish });
148
155
  });
149
156
  server.on('error', reject);
150
157
  });
@@ -158,6 +165,7 @@ export function persist(d) {
158
165
  expiresAt: Date.now() + (d.expires_in || 2592000) * 1000,
159
166
  scope: d.scope,
160
167
  accountId: d.account_id,
168
+ tradingAccount: d.trading_account ?? null,
161
169
  market: d.market,
162
170
  clientId: clientId(),
163
171
  savedAt: new Date().toISOString(),
@@ -22,7 +22,9 @@ export async function create(opts) {
22
22
  leg.contractCode || leg.orderId || '-', `${leg.side || ''}/${leg.offset || ''}`,
23
23
  leg.volume ?? leg.quantity ?? '-',
24
24
  ]));
25
- say(' 这是预演计划,尚未真实执行。取得用户明确确认后使用 plan execute。');
25
+ if (data.status === 'prepared') say(data.tradeMode === 'direct'
26
+ ? '当前为直接交易模式,可用 plan execute 执行本计划。'
27
+ : '请展示本计划,用户明确确认后用 plan execute --yes 执行。');
26
28
  }
27
29
 
28
30
  export async function show(planId) {
@@ -33,12 +35,8 @@ export async function show(planId) {
33
35
  }
34
36
 
35
37
  export async function execute(planId, opts) {
36
- if (!opts.yes) {
37
- const err = new Error('执行冻结计划必须显式传 --yes,表示用户已经确认该 planId');
38
- err.code = 'confirmation_required'; throw err;
39
- }
40
38
  const { data } = await request(`/futures/plans/${encodeURIComponent(planId)}/execute`, {
41
- method: 'POST', body: { confirmed: true, clientRequestId: opts.clientRequestId },
39
+ method: 'POST', body: { confirmed: !!opts.yes, clientRequestId: opts.clientRequestId },
42
40
  });
43
41
  emit(data);
44
42
  kv([['操作号', data.operationId], ['计划号', data.planId], ['状态', data.status],
@@ -34,7 +34,7 @@ export async function create(opts) {
34
34
  export async function use(id) {
35
35
  const { data } = await request('/sim/accounts/' + encodeURIComponent(id) + '/select', { method:'POST' });
36
36
  persist(data);
37
- show({ accountId:id });
37
+ show({ accountId: data.trading_account ?? null, ...(!data.trading_account && { accountRef: data.account_id }) });
38
38
  }
39
39
  export async function remove(id, opts) {
40
40
  if (!opts.yes && !/^y(es)?$/i.test(await ask(`确认删除 ${id} 并释放名额?柜台已有委托仍可能成交。[y/N] `))) return;
@@ -5,7 +5,7 @@ import { emit, say } from '../output.js';
5
5
 
6
6
  export async function login() {
7
7
  const info = await loginBrowser();
8
- emit({ authenticated: true, accountId: info.accountId || null });
8
+ emit({ authenticated: true, accountId: info.tradingAccount || null });
9
9
  say('已登录 PandaAI。运行 ssquant account list 查看账户和名额。');
10
10
  }
11
11
  export function logout() {
@@ -0,0 +1,31 @@
1
+ import { request } from '../http.js';
2
+ import { emit, isJson, say } from '../output.js';
3
+
4
+ const path = '/settings/trade-mode';
5
+
6
+ function display(data) {
7
+ emit(data);
8
+ say(data.mode === 'direct' ? '当前为直接交易模式。' : '当前为逐笔确认模式。');
9
+ }
10
+
11
+ export async function show() {
12
+ display((await request(path)).data);
13
+ }
14
+
15
+ export async function set(mode) {
16
+ if (!['confirm', 'direct'].includes(mode)) throw Error('模式必须为 confirm 或 direct');
17
+ display((await request(path, { method: 'PUT', body: { mode } })).data);
18
+ }
19
+
20
+ // 跳过提示不等同于用户确认,直接模式执行仍向服务端发送 confirmed=false。
21
+ export async function needsPrompt(opts, mode) {
22
+ if (opts.yes) return false;
23
+ const current = mode ?? (await request(path)).data.mode;
24
+ if (current === 'direct') return false;
25
+ if (!process.stdin.isTTY || isJson()) {
26
+ const error = new Error('当前为逐笔确认模式,请在用户确认本次交易后使用 --yes');
27
+ error.code = 'confirmation_required';
28
+ throw error;
29
+ }
30
+ return true;
31
+ }
@@ -3,12 +3,13 @@
3
3
  *
4
4
  * ★ 开仓允许传中文品种名/品种码,服务端会解析为当前主力实际合约;平仓仍必须传持仓实际合约。
5
5
  * ① 传 --price 自动按 GFD 限价单;不传价格自动按 IOC 市价单
6
- * ② 真实下单前若未加 --yes,交互确认(Agent 场景用 --yes 跳过)
6
+ * ② 按用户当前的交易模式确认后执行或直接执行
7
7
  */
8
8
  import readline from 'node:readline/promises';
9
9
  import { request } from '../http.js';
10
10
  import { emit, kv, num, say, table } from '../output.js';
11
11
  import { showQuote, showContractSpec } from './account.js';
12
+ import { needsPrompt } from './trade-mode.js';
12
13
 
13
14
  export async function order(opts) {
14
15
  const code = opts.symbol;
@@ -30,13 +31,12 @@ export async function order(opts) {
30
31
  orderType,
31
32
  volume: Number(opts.volume),
32
33
  dryRun: !!opts.dryRun,
34
+ confirmed: !!opts.yes,
33
35
  };
34
36
  if (opts.price !== undefined && opts.price !== null) body.price = Number(opts.price);
35
37
  if (opts.clientOrderId) body.clientOrderId = opts.clientOrderId;
36
38
 
37
- if (!opts.dryRun && !opts.yes && !process.stdin.isTTY) throw Error('非交互下单需要 --dry-run 预演或 --yes 明确确认');
38
- // 预演永远直接执行;真实下单在交互模式下要确认
39
- if (!opts.dryRun && !opts.yes && process.stdin.isTTY) {
39
+ if (!opts.dryRun && await needsPrompt(opts)) {
40
40
  const { data: preview } = await request('/futures/orders', {
41
41
  method: 'POST', body: { ...body, dryRun: true },
42
42
  });
@@ -61,6 +61,7 @@ export async function order(opts) {
61
61
  say('已取消,未下单。');
62
62
  return;
63
63
  }
64
+ body.confirmed = true;
64
65
  }
65
66
 
66
67
  const { data } = await request('/futures/orders', { method: 'POST', body });
@@ -101,8 +102,16 @@ export function showOrderPreview(data) {
101
102
  if (data.suggestion) say(` 建议:${data.suggestion}`);
102
103
  }
103
104
 
104
- export async function cancel(orderId) {
105
- const { data } = await request(`/futures/orders/${encodeURIComponent(orderId)}`,
105
+ export async function cancel(orderId, opts) {
106
+ let confirmed = !!opts.yes;
107
+ if (await needsPrompt(opts)) {
108
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
109
+ const answer = await rl.question(`确认撤销委托 ${orderId}?(yes/N) `);
110
+ rl.close();
111
+ if (!/^y(es)?$/i.test(answer.trim())) { say('已取消。'); return; }
112
+ confirmed = true;
113
+ }
114
+ const { data } = await request(`/futures/orders/${encodeURIComponent(orderId)}?confirmed=${confirmed}`,
106
115
  { method: 'DELETE' });
107
116
  emit(data);
108
117
  kv([['委托号', data.orderId || orderId], ['状态', data.statusText || data.status || '-']]);
@@ -124,14 +133,16 @@ export async function cancelAll(opts) {
124
133
  return;
125
134
  }
126
135
  if (!rows.length) { emit(plan); say('当前没有活动挂单。'); return; }
127
- if (!opts.yes && process.stdin.isTTY) {
136
+ let confirmed = !!opts.yes;
137
+ if (await needsPrompt(opts)) {
128
138
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
129
139
  const ans = await rl.question(`即将撤销 ${rows.length} 笔挂单,确认?(yes/N) `);
130
140
  rl.close();
131
141
  if (!/^y(es)?$/i.test(ans.trim())) { say('已取消。'); return; }
142
+ confirmed = true;
132
143
  }
133
144
  const { data } = await request('/futures/orders/cancel-all', {
134
- method: 'POST', body: { ...body, dryRun: false },
145
+ method: 'POST', body: { ...body, dryRun: false, confirmed },
135
146
  });
136
147
  emit(data);
137
148
  table(['委托号', '结果'], (data.results || []).map((r) => [
@@ -166,11 +177,8 @@ export async function closeAll(opts) {
166
177
  say(' 没有可平的持仓。');
167
178
  return;
168
179
  }
169
- if (!opts.yes && !process.stdin.isTTY) {
170
- say(' 非交互环境执行移仓必须显式传入 --yes。');
171
- return;
172
- }
173
- if (!opts.yes) {
180
+ body.confirmed = !!opts.yes;
181
+ if (await needsPrompt(opts)) {
174
182
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
175
183
  say('即将由服务端提交以下平仓腿:');
176
184
  table(['合约', '方向', '手数'], plan.legs.map((p) => [
@@ -181,6 +189,7 @@ export async function closeAll(opts) {
181
189
  say('已取消。');
182
190
  return;
183
191
  }
192
+ body.confirmed = true;
184
193
  }
185
194
  const { data } = await request('/futures/positions/close-all', {
186
195
  method: 'POST', body,
@@ -196,29 +205,27 @@ async function frozenBatch(operation, parameters, opts) {
196
205
  const { data: plan } = await request('/futures/plans', {
197
206
  method: 'POST', body: { operation, parameters },
198
207
  });
199
- emit(plan);
200
208
  say(plan.summary || '');
201
209
  table(['合约', '方向', '手数'], (plan.legs || []).map(leg =>
202
210
  [leg.contractCode, `${leg.side}/${leg.offset}`, leg.volume]));
203
- if (plan.status !== 'prepared' || !plan.requiresConfirmation) {
211
+ if (plan.status !== 'prepared') {
212
+ emit(plan);
204
213
  if (plan.preview?.message) say(plan.preview.message);
205
214
  if (plan.preview?.candidates?.length) table(['旧合约', '方向', '可平手数'],
206
215
  plan.preview.candidates.map(item => [item.contractCode, item.direction, item.closable]));
207
216
  say('预演未通过或需要补充参数,未发送真实订单。');
208
217
  return;
209
218
  }
210
- if (!opts.yes) {
211
- if (!process.stdin.isTTY) {
212
- const err = new Error('非交互执行需要用户明确确认后传 --yes');
213
- err.code = 'confirmation_required'; throw err;
214
- }
219
+ let confirmed = !!opts.yes;
220
+ if (await needsPrompt(opts, plan.tradeMode)) {
215
221
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
216
222
  const answer = await rl.question(`确认执行以上冻结计划 ${plan.planId}?(yes/N) `);
217
223
  rl.close();
218
224
  if (!/^y(es)?$/i.test(answer.trim())) { say('已取消。'); return; }
225
+ confirmed = true;
219
226
  }
220
227
  const { data: operationResult } = await request(`/futures/plans/${encodeURIComponent(plan.planId)}/execute`, {
221
- method: 'POST', body: { confirmed: true, clientRequestId: opts.clientRequestId },
228
+ method: 'POST', body: { confirmed, clientRequestId: opts.clientRequestId },
222
229
  });
223
230
  const data = { status: operationResult.status, ...operationResult.result,
224
231
  planId: plan.planId, operationId: operationResult.operationId };
package/src/http.js CHANGED
@@ -89,10 +89,23 @@ async function rawFetch(url, {
89
89
  return { res, json };
90
90
  }
91
91
 
92
+ // API 保留稳定的内部关联键,CLI 的账户号始终用柜台分配的交易账号。
93
+ function forDisplay(value, selectedAccount) {
94
+ if (Array.isArray(value)) return value.map(item => forDisplay(item, selectedAccount));
95
+ if (!value || typeof value !== 'object') return value;
96
+ const result = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, forDisplay(item, selectedAccount)]));
97
+ if (typeof result.accountId === 'string' && result.accountId.startsWith('sq_')) {
98
+ const number = result.tradingAccount ?? selectedAccount ?? null;
99
+ if (!number) result.accountRef = result.accountId;
100
+ result.accountId = number;
101
+ }
102
+ return result;
103
+ }
104
+
92
105
  /** 服务端信封 → 结果或抛错 */
93
106
  function unwrap({ res, json }) {
94
107
  if (res.ok && json && json.code === '0') {
95
- return { data: json.data, meta: json.meta, requestId: json.request_id };
108
+ return { data: forDisplay(json.data, res.headers.get('X-SSQUANT-Trading-Account')), meta: json.meta, requestId: json.request_id };
96
109
  }
97
110
  const code = (json && json.code) || `http_${res.status}`;
98
111
  const message = (json && json.message) || `HTTP ${res.status}`;