@chongqingliangyunzhijing/ssquant-cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 交易授权边界。
3
+ *
4
+ * OAuth 登录后服务端会自动启用默认交易授权:全部合约、双向交易,
5
+ * 不要求用户填写白名单或额度。show 仅用于查看状态;create/revoke 保留为
6
+ * 兼容和紧急运维命令,正常安装和交易不需要用户执行。
7
+ */
8
+ import readline from 'node:readline/promises';
9
+ import { request } from '../http.js';
10
+ import { emit, kv, num, pct, say, table } from '../output.js';
11
+
12
+ export async function show() {
13
+ const { data } = await request('/mandate');
14
+ emit(data);
15
+ if (data.mode === 'automatic') {
16
+ say(' ✓ 安装流程已自动启用交易授权');
17
+ say(' 范围:全部合约、双向交易;不设用户侧手数、日委托和持仓白名单');
18
+ say(` 失效时间:${data.expiresAt}`);
19
+ return;
20
+ }
21
+ if (!data.active && !data.expired && data.message) {
22
+ say(` ✗ ${data.message}`);
23
+ if (data.suggestion) say(` 建议:${data.suggestion}`);
24
+ return;
25
+ }
26
+ kv([
27
+ ['状态', data.frozen ? `已冻结(${data.frozenReason})`
28
+ : data.expired ? '已过期' : (data.active ? '✓ 生效中' : '未生效')],
29
+ ['合约白名单', (data.symbols || []).join(', ')],
30
+ ['方向', { long: '只做多', short: '只做空', both: '双向' }[data.direction] || data.direction],
31
+ ['单笔上限', `${data.maxVolumePerOrder} 手`],
32
+ ['日委托上限', `${data.maxOrdersPerDay} 笔(今日已用 ${data.usedOrdersToday},剩 ${data.remainingOrdersToday ?? '-'})`],
33
+ ['持仓上限', `${data.maxPosition} 手`],
34
+ ['保证金占用上限', data.maxMarginRatio ? pct(data.maxMarginRatio) : '不限'],
35
+ ['止损线', data.stopLossEquity ? `回撤 ${pct(data.stopLossEquity)} 冻结授权` : '未设'],
36
+ ['失效时间', data.expiresAt],
37
+ ]);
38
+ }
39
+
40
+ export async function create(opts) {
41
+ const symbols = String(opts.symbols || '').split(',').map((s) => s.trim()).filter(Boolean);
42
+ if (!symbols.length) {
43
+ const e = new Error('必须用 --symbols 指定允许交易的合约');
44
+ e.code = 'invalid_request';
45
+ e.suggestion = '例:--symbols rb2610,au2512。请使用SSQUANT 提供的实际可交易合约代码';
46
+ throw e;
47
+ }
48
+
49
+ const body = {
50
+ symbols,
51
+ direction: opts.direction || 'both',
52
+ maxVolumePerOrder: Number(opts.maxVolumePerOrder),
53
+ maxOrdersPerDay: Number(opts.maxOrdersPerDay),
54
+ maxPosition: Number(opts.maxPosition),
55
+ maxMarginRatio: opts.maxMarginRatio === undefined ? null : Number(opts.maxMarginRatio),
56
+ stopLossEquity: opts.stopLoss === undefined ? null : Number(opts.stopLoss),
57
+ expiresAt: opts.expires,
58
+ };
59
+
60
+ if (!opts.yes && process.stdin.isTTY) {
61
+ say('即将声明以下交易授权边界(Agent 将在此范围内自主下单):');
62
+ kv([
63
+ ['合约白名单', symbols.join(', ')],
64
+ ['方向', body.direction],
65
+ ['单笔上限', `${body.maxVolumePerOrder} 手`],
66
+ ['日委托上限', `${body.maxOrdersPerDay} 笔`],
67
+ ['持仓上限', `${body.maxPosition} 手`],
68
+ ['保证金占用上限', body.maxMarginRatio ? pct(body.maxMarginRatio) : '不限'],
69
+ ['止损线', body.stopLossEquity ? pct(body.stopLossEquity) : '未设'],
70
+ ['失效时间', body.expiresAt],
71
+ ]);
72
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
73
+ const ans = await rl.question('确认声明?(yes/N) ');
74
+ rl.close();
75
+ if (!/^y(es)?$/i.test(ans.trim())) {
76
+ say('已取消,授权未变更。');
77
+ return;
78
+ }
79
+ }
80
+
81
+ const { data } = await request('/mandate', { method: 'POST', body });
82
+ emit(data);
83
+ say(' ✓ 授权已生效');
84
+ kv([['合约白名单', (data.symbols || []).join(', ')],
85
+ ['失效时间', data.expiresAt]]);
86
+ }
87
+
88
+ export async function revoke() {
89
+ const { data } = await request('/mandate', { method: 'DELETE' });
90
+ emit(data);
91
+ say(` ${data.revoked ? '✓' : 'ℹ'} ${data.message}`);
92
+ }
@@ -0,0 +1,66 @@
1
+ /** 冻结交易计划与操作状态。 */
2
+ import { request } from '../http.js';
3
+ import { emit, kv, say, table } from '../output.js';
4
+ import { showQuote } from './account.js';
5
+
6
+ function parseJson(text, label) {
7
+ try { return JSON.parse(text || '{}'); } catch {
8
+ const err = new Error(`${label} 必须是合法 JSON`); err.code = 'invalid_request'; throw err;
9
+ }
10
+ }
11
+
12
+ export async function create(opts) {
13
+ const parameters = parseJson(opts.parameters, '--parameters');
14
+ const { data } = await request('/futures/plans', {
15
+ method: 'POST', body: { operation: opts.operation, parameters },
16
+ });
17
+ emit(data);
18
+ kv([['计划号', data.planId], ['操作', data.operation], ['摘要', data.summary],
19
+ ['状态', data.status], ['有效期', data.expiresAt]]);
20
+ if (data.marketQuote) showQuote(data.marketQuote);
21
+ table(['合约/订单', '方向', '手数'], (data.legs || []).map((leg) => [
22
+ leg.contractCode || leg.orderId || '-', `${leg.side || ''}/${leg.offset || ''}`,
23
+ leg.volume ?? leg.quantity ?? '-',
24
+ ]));
25
+ if (data.status === 'prepared') say(data.tradeMode === 'direct'
26
+ ? '当前为直接交易模式,可用 plan execute 执行本计划。'
27
+ : '请展示本计划,用户明确确认后用 plan execute --yes 执行。');
28
+ }
29
+
30
+ export async function show(planId) {
31
+ const { data } = await request(`/futures/plans/${encodeURIComponent(planId)}`);
32
+ emit(data);
33
+ kv([['计划号', data.planId], ['操作', data.operation], ['摘要', data.summary],
34
+ ['状态', data.status], ['操作号', data.operationId || '-']]);
35
+ }
36
+
37
+ export async function execute(planId, opts) {
38
+ const { data } = await request(`/futures/plans/${encodeURIComponent(planId)}/execute`, {
39
+ method: 'POST', body: { confirmed: !!opts.yes, clientRequestId: opts.clientRequestId },
40
+ });
41
+ emit(data);
42
+ kv([['操作号', data.operationId], ['计划号', data.planId], ['状态', data.status],
43
+ ['下一步', data.nextAction?.code || 'none']]);
44
+ }
45
+
46
+ export async function operationShow(operationId) {
47
+ const { data } = await request(`/futures/operations/${encodeURIComponent(operationId)}`);
48
+ emit(data);
49
+ kv([['操作号', data.operationId], ['计划号', data.planId], ['操作', data.operation],
50
+ ['状态', data.status], ['下一步', data.nextAction?.code || 'none']]);
51
+ }
52
+
53
+ export async function operationWatch(operationId, opts) {
54
+ const interval = Number(opts.interval || 2);
55
+ if (interval < 1) {
56
+ const err = new Error('操作轮询间隔不能小于 1 秒'); err.code = 'invalid_request'; throw err;
57
+ }
58
+ const maxPolls = Number(opts.maxPolls || 10);
59
+ for (let i = 0; i < maxPolls; i += 1) {
60
+ const { data } = await request(`/futures/operations/${encodeURIComponent(operationId)}`);
61
+ emit(data);
62
+ kv([['操作号', data.operationId], ['状态', data.status], ['下一步', data.nextAction?.code || 'none']]);
63
+ if (!data || ['completed', 'partial', 'failed', 'expired', 'unknown'].includes(data.status)) return;
64
+ if (i + 1 < maxPolls) await new Promise((resolve) => setTimeout(resolve, interval * 1000));
65
+ }
66
+ }
@@ -0,0 +1,45 @@
1
+ import readline from 'node:readline/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { request } from '../http.js';
4
+ import { emit, say, isJson } from '../output.js';
5
+ import { persist } from '../auth.js';
6
+
7
+ function show(data) { emit(data); say(JSON.stringify(data, null, 2)); }
8
+
9
+ async function ask(message) {
10
+ if (!process.stdin.isTTY || isJson()) throw Error('非交互模式请提供金额和 --yes 确认参数');
11
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
12
+ try { return (await rl.question(message)).trim(); } finally { rl.close(); }
13
+ }
14
+ export function amount(text) {
15
+ if (!/^\d{1,5}(?:\.\d{1,6})?$/.test(text)) throw Error('请输入 1 至 10000 万元,精确到人民币分');
16
+ const [whole, fraction = ''] = text.split('.');
17
+ const fen = BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, '0'));
18
+ if (fen < 1000000n || fen > 10000000000n) throw Error('初始资金须在 1 至 10000 万元之间');
19
+ return (fen / 100n).toString() + '.' + (fen % 100n).toString().padStart(2, '0');
20
+ }
21
+ export async function list() { show((await request('/sim/accounts')).data); }
22
+ export async function create(opts) {
23
+ const value = opts.capitalWan ?? await ask('请输入初始资金(万元):');
24
+ const yuan = amount(value);
25
+ if (!opts.yes && !/^y(es)?$/i.test(await ask(`初始资金 ${value} 万元(${yuan} 元),确认开户?[y/N] `))) return;
26
+ const key = opts.requestId || randomUUID();
27
+ try {
28
+ show((await request('/sim/accounts', { method:'POST', body:{ initialCapitalWan:value, clientRequestId:key } })).data);
29
+ } catch (error) {
30
+ error.suggestion = `若开户结果未知,请复用 --request-id ${key} 及原金额查询或重试。`;
31
+ throw error;
32
+ }
33
+ }
34
+ export async function use(id) {
35
+ const { data } = await request('/sim/accounts/' + encodeURIComponent(id) + '/select', { method:'POST' });
36
+ persist(data);
37
+ show({ accountId:id });
38
+ }
39
+ export async function remove(id, opts) {
40
+ if (!opts.yes && !/^y(es)?$/i.test(await ask(`确认删除 ${id} 并释放名额?柜台已有委托仍可能成交。[y/N] `))) return;
41
+ show((await request('/sim/accounts/' + encodeURIComponent(id), { method:'DELETE' })).data);
42
+ }
43
+ export async function status(id) { show((await request('/sim/operations/' + encodeURIComponent(id))).data); }
44
+ export async function equity() { show((await request('/futures/equity')).data); }
45
+ export async function settlement(opts) { show((await request('/futures/settlement' + (opts.date ? '?tradeDate=' + encodeURIComponent(opts.date) : ''))).data); }
@@ -0,0 +1,25 @@
1
+ import { loginBrowser } from '../auth.js';
2
+ import { clearCredentials } from '../config.js';
3
+ import { request, requestAnon } from '../http.js';
4
+ import { emit, say } from '../output.js';
5
+
6
+ export async function login() {
7
+ const info = await loginBrowser();
8
+ emit({ authenticated: true, accountId: info.accountId || null });
9
+ say('已登录 PandaAI。运行 ssquant account list 查看账户和名额。');
10
+ }
11
+ export function logout() {
12
+ clearCredentials();
13
+ emit({ authenticated: false });
14
+ say('已清除本机 SSQUANT 凭证。');
15
+ }
16
+ export async function whoami() {
17
+ const { data } = await request('/whoami');
18
+ emit(data);
19
+ say(JSON.stringify(data));
20
+ }
21
+ export async function doctor() {
22
+ const { data } = await requestAnon('/health', { method: 'GET' });
23
+ emit(data);
24
+ say('SSQUANT OpenAPI 已就绪。');
25
+ }
@@ -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
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * 下单 / 撤单 / 一键平仓。
3
+ *
4
+ * ★ 开仓允许传中文品种名/品种码,服务端会解析为当前主力实际合约;平仓仍必须传持仓实际合约。
5
+ * ① 传 --price 自动按 GFD 限价单;不传价格自动按 IOC 市价单
6
+ * ② 按用户当前的交易模式确认后执行或直接执行
7
+ */
8
+ import readline from 'node:readline/promises';
9
+ import { request } from '../http.js';
10
+ import { emit, kv, num, say, table } from '../output.js';
11
+ import { showQuote, showContractSpec } from './account.js';
12
+ import { needsPrompt } from './trade-mode.js';
13
+
14
+ export async function order(opts) {
15
+ const code = opts.symbol;
16
+
17
+ const hasPrice = opts.price !== undefined && opts.price !== null;
18
+ const orderType = hasPrice ? 'limit' : 'market';
19
+ if (opts.type && opts.type !== orderType) {
20
+ const err = new Error(hasPrice
21
+ ? '已指定 --price,订单自动按 GFD 限价单处理,不要再传 --type market'
22
+ : '未指定 --price,订单自动按 IOC 市价单处理,不要再传 --type limit');
23
+ err.code = 'invalid_request';
24
+ throw err;
25
+ }
26
+
27
+ const body = {
28
+ contractCode: code,
29
+ side: opts.direction,
30
+ offset: opts.offset,
31
+ orderType,
32
+ volume: Number(opts.volume),
33
+ dryRun: !!opts.dryRun,
34
+ confirmed: !!opts.yes,
35
+ };
36
+ if (opts.price !== undefined && opts.price !== null) body.price = Number(opts.price);
37
+ if (opts.clientOrderId) body.clientOrderId = opts.clientOrderId;
38
+
39
+ if (!opts.dryRun && await needsPrompt(opts)) {
40
+ const { data: preview } = await request('/futures/orders', {
41
+ method: 'POST', body: { ...body, dryRun: true },
42
+ });
43
+ showOrderPreview(preview);
44
+ if (preview.wouldSucceed !== true) {
45
+ say('预演未通过,未下单。');
46
+ return;
47
+ }
48
+ const actual = preview.detail?.contractCode;
49
+ if (!actual) {
50
+ const err = new Error('预演未返回实际合约,不能继续确认下单');
51
+ err.code = 'contract_resolution_unavailable'; throw err;
52
+ }
53
+ body.contractCode = actual;
54
+ const desc = `${actual} ${opts.direction === 'buy' ? '买' : '卖'}` +
55
+ `${opts.offset === 'open' ? '开' : '平'} ${opts.volume} 手 ` +
56
+ `${orderType === 'market' ? '市价 IOC' : `限价 ${opts.price} GFD`}`;
57
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
58
+ const ans = await rl.question(`即将下单:${desc}\n确认?(yes/N) `);
59
+ rl.close();
60
+ if (!/^y(es)?$/i.test(ans.trim())) {
61
+ say('已取消,未下单。');
62
+ return;
63
+ }
64
+ body.confirmed = true;
65
+ }
66
+
67
+ const { data } = await request('/futures/orders', { method: 'POST', body });
68
+ emit(data);
69
+ if (data.marketQuote) showQuote(data.marketQuote);
70
+
71
+ if (body.dryRun) {
72
+ showOrderPreview(data);
73
+ return;
74
+ }
75
+
76
+ kv([
77
+ ['委托号', data.orderId || '-'],
78
+ ['操作号', data.operationId || data.requestId || '-'],
79
+ ['状态', data.statusText || data.status || '-'],
80
+ ['已成交', `${data.filledVolume ?? 0} / ${body.volume} 手`],
81
+ ['成交均价', num(data.avgPrice)],
82
+ ['幂等号', data.clientOrderId || '-'],
83
+ ]);
84
+ if (data.status === 'unknown') {
85
+ say(' ⚠ 回执超时,柜台可能已受理。**不要重复下单**,' +
86
+ `请用 ssquant orders --status active 或按幂等号 ${data.clientOrderId} 查证。`);
87
+ }
88
+ if (data.status === 'queued') {
89
+ say(' ⏳ 请求已排队,尚不代表柜台受理或成交。' +
90
+ `请用 ssquant operation show ${data.operationId || data.requestId} 查询;不要重复下单。`);
91
+ }
92
+ if (data.message) say(` 说明:${data.message}`);
93
+ }
94
+
95
+ export function showOrderPreview(data) {
96
+ say(data.wouldSucceed ? ' ✓ 预演通过(尚未下单)' : ` ✗ 预演不通过:${data.message || data.reason}`);
97
+ if (data.detail) {
98
+ kv(Object.entries(data.detail).filter(([, v]) => v === null || typeof v !== 'object')
99
+ .map(([k, v]) => [k, typeof v === 'number' ? num(v) : String(v)]));
100
+ if (data.detail.contract?.margin) showContractSpec(data.detail.contract);
101
+ }
102
+ if (data.suggestion) say(` 建议:${data.suggestion}`);
103
+ }
104
+
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}`,
115
+ { method: 'DELETE' });
116
+ emit(data);
117
+ kv([['委托号', data.orderId || orderId], ['状态', data.statusText || data.status || '-']]);
118
+ }
119
+
120
+ export async function cancelAll(opts) {
121
+ const body = { contractCode: opts.symbol, clientRequestId: opts.clientRequestId };
122
+ const preview = await request('/futures/orders/cancel-all', {
123
+ method: 'POST', body: { ...body, dryRun: true },
124
+ });
125
+ const plan = preview.data || {};
126
+ const rows = plan.orders || [];
127
+ if (opts.dryRun) {
128
+ emit(plan);
129
+ table(['委托号', '合约', '方向', '未成手数', '状态'], rows.map((o) => [
130
+ o.orderId, o.contractCode, `${o.sideText || o.side}${o.offsetText || o.offset}`,
131
+ o.unfilledQuantity ?? 0, o.statusText || o.status,
132
+ ]));
133
+ return;
134
+ }
135
+ if (!rows.length) { emit(plan); say('当前没有活动挂单。'); return; }
136
+ let confirmed = !!opts.yes;
137
+ if (await needsPrompt(opts)) {
138
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
139
+ const ans = await rl.question(`即将撤销 ${rows.length} 笔挂单,确认?(yes/N) `);
140
+ rl.close();
141
+ if (!/^y(es)?$/i.test(ans.trim())) { say('已取消。'); return; }
142
+ confirmed = true;
143
+ }
144
+ const { data } = await request('/futures/orders/cancel-all', {
145
+ method: 'POST', body: { ...body, dryRun: false, confirmed },
146
+ });
147
+ emit(data);
148
+ table(['委托号', '结果'], (data.results || []).map((r) => [
149
+ r.orderId || r.order?.orderId || '-', r.ok ? (r.result?.statusText || '已受理') : `失败:${r.error?.message || '-'}`,
150
+ ]));
151
+ }
152
+
153
+ export async function closeAll(opts) {
154
+ const body = {
155
+ contractCode: opts.symbol,
156
+ dryRun: !!opts.dryRun,
157
+ clientRequestId: opts.clientRequestId,
158
+ };
159
+ const preview = await request('/futures/positions/close-all', {
160
+ method: 'POST', body: { ...body, dryRun: true },
161
+ });
162
+ const plan = preview.data || {};
163
+ if (opts.dryRun) {
164
+ emit(plan);
165
+ table(['合约', '方向', '手数'], (plan.legs || []).map((p) => [
166
+ p.contractCode, p.direction === 'long' ? '卖平' : '买平', p.volume]));
167
+ return;
168
+ }
169
+
170
+ if (plan.activeOrders?.length) {
171
+ emit(plan);
172
+ say(' 当前存在活动委托,服务端拒绝真实清仓,请先撤单。');
173
+ return;
174
+ }
175
+ if (!plan.legs?.length) {
176
+ emit(plan);
177
+ say(' 没有可平的持仓。');
178
+ return;
179
+ }
180
+ body.confirmed = !!opts.yes;
181
+ if (await needsPrompt(opts)) {
182
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
183
+ say('即将由服务端提交以下平仓腿:');
184
+ table(['合约', '方向', '手数'], plan.legs.map((p) => [
185
+ p.contractCode, p.direction === 'long' ? '卖平' : '买平', p.volume]));
186
+ const ans = await rl.question('确认全部市价平仓?(yes/N) ');
187
+ rl.close();
188
+ if (!/^y(es)?$/i.test(ans.trim())) {
189
+ say('已取消。');
190
+ return;
191
+ }
192
+ body.confirmed = true;
193
+ }
194
+ const { data } = await request('/futures/positions/close-all', {
195
+ method: 'POST', body,
196
+ });
197
+ emit(data);
198
+ table(['合约', '方向', '手数', '结果'], (data.legs || []).map((r) => [
199
+ r.contractCode, r.side === 'sell' ? '卖平' : '买平', r.volume,
200
+ r.ok ? (r.result?.statusText || r.result?.status || '已提交') : `失败:${r.error?.message || '-'}`,
201
+ ]));
202
+ }
203
+
204
+ async function frozenBatch(operation, parameters, opts) {
205
+ const { data: plan } = await request('/futures/plans', {
206
+ method: 'POST', body: { operation, parameters },
207
+ });
208
+ say(plan.summary || '');
209
+ table(['合约', '方向', '手数'], (plan.legs || []).map(leg =>
210
+ [leg.contractCode, `${leg.side}/${leg.offset}`, leg.volume]));
211
+ if (plan.status !== 'prepared') {
212
+ emit(plan);
213
+ if (plan.preview?.message) say(plan.preview.message);
214
+ if (plan.preview?.candidates?.length) table(['旧合约', '方向', '可平手数'],
215
+ plan.preview.candidates.map(item => [item.contractCode, item.direction, item.closable]));
216
+ say('预演未通过或需要补充参数,未发送真实订单。');
217
+ return;
218
+ }
219
+ let confirmed = !!opts.yes;
220
+ if (await needsPrompt(opts, plan.tradeMode)) {
221
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
222
+ const answer = await rl.question(`确认执行以上冻结计划 ${plan.planId}?(yes/N) `);
223
+ rl.close();
224
+ if (!/^y(es)?$/i.test(answer.trim())) { say('已取消。'); return; }
225
+ confirmed = true;
226
+ }
227
+ const { data: operationResult } = await request(`/futures/plans/${encodeURIComponent(plan.planId)}/execute`, {
228
+ method: 'POST', body: { confirmed, clientRequestId: opts.clientRequestId },
229
+ });
230
+ const data = { status: operationResult.status, ...operationResult.result,
231
+ planId: plan.planId, operationId: operationResult.operationId };
232
+ emit(data);
233
+ if (data.message) say(data.message);
234
+ table(['阶段', '合约', '手数', '结果'], (data.legs || []).map(leg => [
235
+ leg.phase, leg.contractCode, leg.volume,
236
+ leg.ok ? (leg.result?.statusText || leg.result?.status || '成功') : `失败:${leg.error?.message || '-'}`,
237
+ ]));
238
+ }
239
+
240
+ export async function rollover(opts) {
241
+ const body = {
242
+ symbol: opts.symbol, volume: opts.volume === undefined ? undefined : Number(opts.volume),
243
+ direction: opts.direction, fromContract: opts.fromContract, clientRequestId: opts.clientRequestId,
244
+ };
245
+ if (!opts.dryRun) return frozenBatch('rollover', body, opts);
246
+ const { data: plan } = await request('/futures/positions/rollover', {
247
+ method: 'POST', body: { ...body, dryRun: true },
248
+ });
249
+ emit(plan);
250
+ if (plan.message) say(plan.message);
251
+ if (plan.candidates?.length) table(['旧合约', '方向', '可平手数'],
252
+ plan.candidates.map(item => [item.contractCode, item.direction, item.closable]));
253
+ table(['阶段', '合约', '方向', '手数', '预演'], (plan.legs || []).map(leg => [
254
+ leg.phase, leg.contractCode, leg.direction, leg.volume, leg.preview?.wouldSucceed ? '通过' : '失败',
255
+ ]));
256
+ }
257
+
258
+ export async function targetPosition(opts) {
259
+ let targets;
260
+ try { targets = JSON.parse(opts.targets); } catch (e) {
261
+ const err = new Error('--targets 必须是合法 JSON 数组'); err.code = 'invalid_request'; throw err;
262
+ }
263
+ if (!Array.isArray(targets) || !targets.length) {
264
+ const err = new Error('--targets 必须是非空 JSON 数组'); err.code = 'invalid_request'; throw err;
265
+ }
266
+ const body = { targets, clientRequestId: opts.clientRequestId };
267
+ if (!opts.dryRun) return frozenBatch('target_position', body, opts);
268
+ const preview = await request('/futures/positions/target', { method: 'POST', body: { ...body, dryRun: true } });
269
+ const plan = preview.data || {};
270
+ emit(plan);
271
+ table(['阶段', '合约', '方向', '手数', '预演'], (plan.legs || []).map((leg) => [
272
+ leg.phase, leg.contractCode, leg.direction, leg.volume,
273
+ leg.preview?.wouldSucceed === false ? `失败:${leg.preview?.message || leg.preview?.reason || '-'}` : '通过',
274
+ ]));
275
+ }
package/src/config.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 配置与凭证存取。
3
+ *
4
+ * 凭证存 ~/.panda/credentials.json,权限 0600。
5
+ * ★ 只存本服务签发的 pt_ token,**从不存官网账号密码** ——
6
+ * 身份始终在浏览器里完成,CLI 全程不接触密码。
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+
12
+ export const DEFAULT_BASE = 'https://www.pandaaiquant.com';
13
+ export const API_PREFIX = '/ssquant/openapi/v1';
14
+
15
+ /** 公开客户端 id:CLI 是公共客户端(无 client_secret),靠 PKCE 防截获 */
16
+ export const DEFAULT_CLIENT_ID = 'ssquant-cli';
17
+
18
+ export function homeDir() {
19
+ return process.env.SSQUANT_HOME || path.join(os.homedir(), '.ssquant');
20
+ }
21
+
22
+ export function credPath() {
23
+ return path.join(homeDir(), 'credentials.json');
24
+ }
25
+
26
+ export function credentialLockPath() {
27
+ return path.join(homeDir(), 'credentials.lock');
28
+ }
29
+
30
+ export function apiBase() {
31
+ const raw = process.env.SSQUANT_API_BASE || DEFAULT_BASE;
32
+ return raw.replace(/\/+$/, '');
33
+ }
34
+
35
+ export function apiUrl(p) {
36
+ const suffix = p.startsWith('/') ? p : `/${p}`;
37
+ return `${apiBase()}${API_PREFIX}${suffix}`;
38
+ }
39
+
40
+ export function clientId() {
41
+ return process.env.SSQUANT_CLIENT_ID || DEFAULT_CLIENT_ID;
42
+ }
43
+
44
+ export function loadCredentials() {
45
+ try {
46
+ const raw = fs.readFileSync(credPath(), 'utf8');
47
+ return JSON.parse(raw);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function saveCredentials(data) {
54
+ const dir = homeDir();
55
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
56
+ const file = credPath();
57
+ // 先写再改权限会有一瞬间是默认权限;用 mode 参数让它一出生就是 0600
58
+ const tmp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
59
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
60
+ try {
61
+ fs.chmodSync(tmp, 0o600);
62
+ } catch {
63
+ // Windows 上 chmod 基本无效,忽略;该平台靠用户目录 ACL 保护
64
+ }
65
+ fs.renameSync(tmp, file);
66
+ try {
67
+ fs.chmodSync(file, 0o600);
68
+ } catch {
69
+ // Windows 上 chmod 基本无效,忽略;该平台靠用户目录 ACL 保护
70
+ }
71
+ return file;
72
+ }
73
+
74
+ function delay(ms) {
75
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
+ }
77
+
78
+ /** 多进程刷新凭据时使用的跨平台独占文件锁。 */
79
+ export async function withCredentialLock(fn, { timeoutMs = 35_000, staleMs = 120_000 } = {}) {
80
+ const dir = homeDir();
81
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ const lock = credentialLockPath();
83
+ const deadline = Date.now() + timeoutMs;
84
+ while (Date.now() < deadline) {
85
+ let fd;
86
+ try {
87
+ fd = fs.openSync(lock, 'wx', 0o600);
88
+ } catch (e) {
89
+ if (!e || e.code !== 'EEXIST') throw e;
90
+ try {
91
+ const age = Date.now() - fs.statSync(lock).mtimeMs;
92
+ if (age > staleMs) fs.unlinkSync(lock);
93
+ } catch { /* 锁可能刚被持有者释放 */ }
94
+ await delay(50);
95
+ continue;
96
+ }
97
+ try {
98
+ fs.writeFileSync(fd, `${process.pid}\n`, { encoding: 'utf8' });
99
+ return await fn();
100
+ } finally {
101
+ try { fs.closeSync(fd); } catch { /* noop */ }
102
+ try { fs.unlinkSync(lock); } catch { /* 锁已由超时清理时忽略 */ }
103
+ }
104
+ }
105
+ throw new Error('凭据正在由另一个进程刷新,请稍后重试');
106
+ }
107
+
108
+ export function clearCredentials() {
109
+ try {
110
+ fs.unlinkSync(credPath());
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }