@liquiditytech/rapidx-cli 1.0.27 → 1.0.29
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/dist/cli/commands/order.js +3 -1
- package/dist/cli/commands/trade.js +2 -1
- package/dist/cli/help.js +1 -1
- package/dist/core/client/capability-executor.js +81 -13
- package/dist/core/client/rapid-x-client.js +41 -8
- package/dist/core/client/symbol.js +6 -2
- package/dist/core/contracts/capabilities.js +6 -3
- package/dist/core/contracts/compatibility.js +1 -1
- package/dist/core/contracts/input-schema.js +66 -13
- package/dist/core/errors/product-error.js +2 -0
- package/dist/core/index.js +1 -0
- package/dist/core/safety/policy.js +6 -3
- package/dist/core/self-check/live-trading-verification-probes.js +12 -2
- package/dist/core/self-check/run-self-check.js +4 -2
- package/dist/core/trading/preview-preflight.js +338 -0
- package/dist/core/trading/preview.js +88 -14
- package/dist/core/trading/trading-verification.js +156 -26
- package/dist/core/version.js +1 -1
- package/dist/mcp/tool-registry.js +8 -1
- package/dist/mcp/tool-runner.js +29 -3
- package/package.json +1 -1
- package/packages/distribution/docs/cli.md +9 -1
- package/packages/distribution/docs/mcp.md +7 -2
- package/packages/distribution/docs/quickstart.md +9 -1
- package/packages/distribution/docs/tools.md +36 -2
- package/packages/distribution/manifests/offline-manifest.json +4 -4
- package/packages/distribution/registry/rapidx.mcp.json +1 -1
|
@@ -13,6 +13,7 @@ export function buildLiveTradingVerificationProbes(input = {}) {
|
|
|
13
13
|
orderType: "LIMIT",
|
|
14
14
|
price: orderInput.price,
|
|
15
15
|
quantity: orderInput.quantity,
|
|
16
|
+
positionSide: orderInput.positionSide,
|
|
16
17
|
maxNotional: orderInput.maxNotional,
|
|
17
18
|
clientOrderId: orderInput.clientOrderId,
|
|
18
19
|
postOnly: true,
|
|
@@ -34,7 +35,7 @@ export function buildLiveTradingVerificationProbes(input = {}) {
|
|
|
34
35
|
previewId: `internal-${order.orderId}`,
|
|
35
36
|
continueConsentId: continueConsentId(order.orderId, "cancel")
|
|
36
37
|
});
|
|
37
|
-
const data = extractDataObject(result);
|
|
38
|
+
const data = extractDataObject(extractWrappedResponse(result));
|
|
38
39
|
const status = normalizeOrderStatus(stringField(data, "orderState") ?? stringField(data, "action") ?? "");
|
|
39
40
|
if (status === "CANCELED") {
|
|
40
41
|
return { orderId: order.orderId, status };
|
|
@@ -97,7 +98,7 @@ async function cleanupCheck(input) {
|
|
|
97
98
|
openOrders: orders.filter((order) => {
|
|
98
99
|
const state = normalizeOrderStatus(stringField(order, "orderState") ?? "OPEN");
|
|
99
100
|
const orderSymbol = stringField(order, "sym") ?? stringField(order, "symbol") ?? symbol;
|
|
100
|
-
return orderSymbol === symbol && state !== "CANCELED" && state !== "REJECTED" && state !== "FILLED";
|
|
101
|
+
return orderSymbol === symbol && state !== "CANCELED" && state !== "REJECTED" && state !== "EXPIRED" && state !== "FILLED";
|
|
101
102
|
}).length,
|
|
102
103
|
unexpectedPositions: positions.filter((position) => {
|
|
103
104
|
const positionSymbol = stringField(position, "sym") ?? stringField(position, "symbol");
|
|
@@ -186,6 +187,12 @@ function extractDataObject(result) {
|
|
|
186
187
|
}
|
|
187
188
|
return {};
|
|
188
189
|
}
|
|
190
|
+
function extractWrappedResponse(result) {
|
|
191
|
+
if (result && typeof result === "object" && !Array.isArray(result) && Object.prototype.hasOwnProperty.call(result, "response")) {
|
|
192
|
+
return result.response;
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
189
196
|
function extractArrayData(result) {
|
|
190
197
|
const data = extractData(result);
|
|
191
198
|
if (Array.isArray(data)) {
|
|
@@ -244,6 +251,9 @@ function normalizeOrderStatus(state) {
|
|
|
244
251
|
case "CANCELED":
|
|
245
252
|
case "CANCEL_COMPLETE":
|
|
246
253
|
return "CANCELED";
|
|
254
|
+
case "EXPIRED":
|
|
255
|
+
case "EXPIRE":
|
|
256
|
+
return "EXPIRED";
|
|
247
257
|
case "REJECTED":
|
|
248
258
|
return "REJECTED";
|
|
249
259
|
default:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getSchemaResult } from "../contracts/capabilities.js";
|
|
1
|
+
import { getSchemaResult, listMcpCapabilities } from "../contracts/capabilities.js";
|
|
2
2
|
import { ProductError } from "../errors/product-error.js";
|
|
3
3
|
import { checkForUpdate } from "../update/check-update.js";
|
|
4
4
|
export async function runSelfCheck(options = {}) {
|
|
@@ -14,7 +14,9 @@ export async function runSelfCheck(options = {}) {
|
|
|
14
14
|
timestamp: new Date().toISOString()
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
const canonicalCapabilityCount = getSchemaResult().capabilities.length;
|
|
18
|
+
const mcpToolCount = new Set(listMcpCapabilities().map((capability) => capability.mcpTool)).size;
|
|
19
|
+
add({ name: "discovery", status: "PASS", source: "local_check", message: `${canonicalCapabilityCount} canonical capabilities loaded; ${mcpToolCount} MCP tools available.` });
|
|
18
20
|
add({ name: "schema-compatibility", status: "PASS", source: "local_check", message: "Canonical schema is available." });
|
|
19
21
|
let update;
|
|
20
22
|
if (options.input?.checkUpdates) {
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { RapidXClient } from "../client/rapid-x-client.js";
|
|
2
|
+
import { parseRapidXSymbol } from "../client/symbol.js";
|
|
3
|
+
import { ProductError } from "../errors/product-error.js";
|
|
4
|
+
export async function runPreviewPreflight(capabilityId, input, options = {}) {
|
|
5
|
+
switch (capabilityId) {
|
|
6
|
+
case "order.preview":
|
|
7
|
+
case "order.place-preview":
|
|
8
|
+
case "order.place":
|
|
9
|
+
case "algo.place":
|
|
10
|
+
return validateOrderPlacementPreflight(input, options);
|
|
11
|
+
case "order.cancel":
|
|
12
|
+
return validateOrderMutationPreflight(input, "cancel", options);
|
|
13
|
+
case "order.amend":
|
|
14
|
+
return validateOrderMutationPreflight(input, "amend", options);
|
|
15
|
+
case "position.close":
|
|
16
|
+
return validatePositionClosePreflight(input, options);
|
|
17
|
+
default:
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function validateOrderPlacementPreflight(input, options) {
|
|
22
|
+
const symbolInput = requiredString(input.symbol, "symbol");
|
|
23
|
+
const parsed = parseRapidXSymbol(symbolInput);
|
|
24
|
+
const client = options.client ?? new RapidXClient();
|
|
25
|
+
const response = await client.get("/api/v1/trading/sym/info", { sym: parsed.canonicalSymbol });
|
|
26
|
+
const rules = extractSymbolRules(response, parsed.canonicalSymbol);
|
|
27
|
+
validateQuantity(input, rules);
|
|
28
|
+
validatePrice(input, rules);
|
|
29
|
+
validateNotional(input, rules);
|
|
30
|
+
return {
|
|
31
|
+
marketRules: compactObject({
|
|
32
|
+
symbol: parsed.canonicalSymbol,
|
|
33
|
+
inputSymbol: parsed.inputSymbol !== parsed.canonicalSymbol ? parsed.inputSymbol : undefined,
|
|
34
|
+
minNotional: rules.minNotional,
|
|
35
|
+
minSize: rules.minSize,
|
|
36
|
+
lotSize: rules.lotSize,
|
|
37
|
+
qtyPrecision: rules.qtyPrecision,
|
|
38
|
+
tickSize: rules.tickSize,
|
|
39
|
+
pricePrecision: rules.pricePrecision
|
|
40
|
+
})
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function validateOrderMutationPreflight(input, action, options) {
|
|
44
|
+
const client = options.client ?? new RapidXClient();
|
|
45
|
+
const params = orderLookupParams(input);
|
|
46
|
+
const response = await client.get("/api/v1/trading/order", params);
|
|
47
|
+
const order = extractOrderReadback(response);
|
|
48
|
+
if (!order) {
|
|
49
|
+
throw new ProductError({
|
|
50
|
+
code: "RCORE22004",
|
|
51
|
+
status: "BLOCKED",
|
|
52
|
+
message: "ORDER_NOT_FOUND: order.get did not return an order for preview preflight."
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const state = normalizeOrderState(order.orderState ?? order.state ?? order.status);
|
|
56
|
+
if (!isOpenOrderState(state)) {
|
|
57
|
+
throw new ProductError({
|
|
58
|
+
code: "RCORE22005",
|
|
59
|
+
status: "BLOCKED",
|
|
60
|
+
message: `ORDER_NOT_OPEN: order is ${state || "UNKNOWN"} and cannot be ${action}ed.`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
orderReadback: {
|
|
65
|
+
...order,
|
|
66
|
+
orderState: state || order.orderState
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function validatePositionClosePreflight(input, options) {
|
|
71
|
+
const symbolInput = requiredString(input.symbol, "symbol");
|
|
72
|
+
const parsed = parseRapidXSymbol(symbolInput);
|
|
73
|
+
const client = options.client ?? new RapidXClient();
|
|
74
|
+
const response = await client.get("/api/v1/trading/position", { sym: parsed.canonicalSymbol });
|
|
75
|
+
const positions = extractPositions(response);
|
|
76
|
+
const positionSide = typeof input.positionSide === "string" && input.positionSide.length > 0 ? input.positionSide.toUpperCase() : undefined;
|
|
77
|
+
const position = positions.find((item) => {
|
|
78
|
+
const itemSymbol = stringField(item, "sym") ?? stringField(item, "symbol");
|
|
79
|
+
const itemSide = stringField(item, "positionSide")?.toUpperCase();
|
|
80
|
+
const qty = Number(stringField(item, "positionQty") ?? stringField(item, "quantity") ?? stringField(item, "qty") ?? "0");
|
|
81
|
+
return itemSymbol === parsed.canonicalSymbol && Math.abs(qty) > 0 && (!positionSide || !itemSide || itemSide === positionSide);
|
|
82
|
+
});
|
|
83
|
+
if (!position) {
|
|
84
|
+
throw new ProductError({
|
|
85
|
+
code: "RCORE24003",
|
|
86
|
+
status: "BLOCKED",
|
|
87
|
+
message: `NO_POSITION_TO_CLOSE: no non-zero position found for ${parsed.canonicalSymbol}.`
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
positionReadback: {
|
|
92
|
+
...position,
|
|
93
|
+
sym: stringField(position, "sym") ?? parsed.canonicalSymbol
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function extractSymbolRules(response, symbol) {
|
|
98
|
+
const data = unwrapData(response);
|
|
99
|
+
const candidates = [
|
|
100
|
+
isRecord(data) ? data[symbol] : undefined,
|
|
101
|
+
isRecord(data) && isRecord(data.data) ? data.data[symbol] : undefined,
|
|
102
|
+
isRecord(data) && Array.isArray(data.list) ? data.list.find((item) => isRecord(item) && (item.sym === symbol || item.symbol === symbol)) : undefined,
|
|
103
|
+
data
|
|
104
|
+
];
|
|
105
|
+
const record = candidates.find((item) => isSymbolRuleRecord(item, symbol));
|
|
106
|
+
if (!record) {
|
|
107
|
+
throw new ProductError({
|
|
108
|
+
code: "RCORE12001",
|
|
109
|
+
status: "BLOCKED",
|
|
110
|
+
message: `INVALID_SYMBOL: ${symbol} was not found in RapidX symbol-info.`
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const rules = { symbol };
|
|
114
|
+
assignIfDefined(rules, "minNotional", stringField(record, "minNotional"));
|
|
115
|
+
assignIfDefined(rules, "minSize", stringField(record, "minSize") ?? stringField(record, "minQty") ?? stringField(record, "minOrderQty"));
|
|
116
|
+
assignIfDefined(rules, "lotSize", stringField(record, "lotSize") ?? stringField(record, "qtyStep") ?? stringField(record, "stepSize"));
|
|
117
|
+
assignIfDefined(rules, "qtyPrecision", stringField(record, "qtyPrecision") ?? stringField(record, "quantityPrecision"));
|
|
118
|
+
assignIfDefined(rules, "tickSize", stringField(record, "tickSize") ?? stringField(record, "priceTick") ?? stringField(record, "priceStep"));
|
|
119
|
+
assignIfDefined(rules, "pricePrecision", stringField(record, "pricePrecision"));
|
|
120
|
+
return rules;
|
|
121
|
+
}
|
|
122
|
+
function assignIfDefined(target, key, value) {
|
|
123
|
+
if (value !== undefined) {
|
|
124
|
+
target[key] = value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function isSymbolRuleRecord(value, symbol) {
|
|
128
|
+
if (!isRecord(value) || Object.keys(value).length === 0) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const recordSymbol = stringField(value, "sym") ?? stringField(value, "symbol");
|
|
132
|
+
if (recordSymbol !== undefined) {
|
|
133
|
+
return recordSymbol === symbol;
|
|
134
|
+
}
|
|
135
|
+
return [
|
|
136
|
+
"minNotional",
|
|
137
|
+
"minSize",
|
|
138
|
+
"minQty",
|
|
139
|
+
"minOrderQty",
|
|
140
|
+
"lotSize",
|
|
141
|
+
"qtyStep",
|
|
142
|
+
"stepSize",
|
|
143
|
+
"qtyPrecision",
|
|
144
|
+
"quantityPrecision",
|
|
145
|
+
"tickSize",
|
|
146
|
+
"priceTick",
|
|
147
|
+
"priceStep",
|
|
148
|
+
"pricePrecision"
|
|
149
|
+
].some((field) => stringField(value, field) !== undefined);
|
|
150
|
+
}
|
|
151
|
+
function validateNotional(input, rules) {
|
|
152
|
+
const minNotional = positiveNumber(rules.minNotional);
|
|
153
|
+
if (minNotional === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const notional = estimateNotional(input);
|
|
157
|
+
if (notional !== undefined && notional < minNotional - 1e-12) {
|
|
158
|
+
throw new ProductError({
|
|
159
|
+
code: "RCORE24005",
|
|
160
|
+
status: "BLOCKED",
|
|
161
|
+
message: `MIN_NOTIONAL_NOT_MET: estimatedNotional ${formatNumber(notional)} is below minNotional ${rules.minNotional}.`
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function validateQuantity(input, rules) {
|
|
166
|
+
const quantityValue = input.quantity ?? input.qty;
|
|
167
|
+
if (quantityValue === undefined || quantityValue === null || quantityValue === "") {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const quantityText = String(quantityValue);
|
|
171
|
+
const quantity = positiveNumber(quantityText);
|
|
172
|
+
if (quantity === undefined) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const minSize = positiveNumber(rules.minSize);
|
|
176
|
+
if (minSize !== undefined && quantity < minSize - 1e-12) {
|
|
177
|
+
throw new ProductError({
|
|
178
|
+
code: "RCORE24006",
|
|
179
|
+
status: "BLOCKED",
|
|
180
|
+
message: `INVALID_QUANTITY: quantity ${quantityText} is below minSize ${rules.minSize}.`
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const lotSize = positiveNumber(rules.lotSize);
|
|
184
|
+
if (lotSize !== undefined && !isAlignedToStep(quantity, lotSize)) {
|
|
185
|
+
throw new ProductError({
|
|
186
|
+
code: "RCORE24006",
|
|
187
|
+
status: "BLOCKED",
|
|
188
|
+
message: `INVALID_QUANTITY: quantity ${quantityText} is not aligned to lotSize ${rules.lotSize}.`
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const qtyPrecision = integerValue(rules.qtyPrecision);
|
|
192
|
+
if (qtyPrecision !== undefined && decimalPlaces(quantityText) > qtyPrecision) {
|
|
193
|
+
throw new ProductError({
|
|
194
|
+
code: "RCORE24006",
|
|
195
|
+
status: "BLOCKED",
|
|
196
|
+
message: `INVALID_QUANTITY: quantity ${quantityText} exceeds qtyPrecision ${rules.qtyPrecision}.`
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function validatePrice(input, rules) {
|
|
201
|
+
if (input.price === undefined || input.price === null || input.price === "") {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const priceText = String(input.price);
|
|
205
|
+
const price = positiveNumber(priceText);
|
|
206
|
+
if (price === undefined) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const tickSize = positiveNumber(rules.tickSize);
|
|
210
|
+
if (tickSize !== undefined && !isAlignedToStep(price, tickSize)) {
|
|
211
|
+
throw new ProductError({
|
|
212
|
+
code: "RCORE24007",
|
|
213
|
+
status: "BLOCKED",
|
|
214
|
+
message: `INVALID_PRICE_TICK: price ${priceText} is not aligned to tickSize ${rules.tickSize}.`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const pricePrecision = integerValue(rules.pricePrecision);
|
|
218
|
+
if (pricePrecision !== undefined && decimalPlaces(priceText) > pricePrecision) {
|
|
219
|
+
throw new ProductError({
|
|
220
|
+
code: "RCORE24007",
|
|
221
|
+
status: "BLOCKED",
|
|
222
|
+
message: `INVALID_PRICE_TICK: price ${priceText} exceeds pricePrecision ${rules.pricePrecision}.`
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function estimateNotional(input) {
|
|
227
|
+
const direct = positiveNumber(input.amount ?? input.notional);
|
|
228
|
+
if (direct !== undefined) {
|
|
229
|
+
return direct;
|
|
230
|
+
}
|
|
231
|
+
const price = positiveNumber(input.price);
|
|
232
|
+
const quantity = positiveNumber(input.quantity ?? input.qty);
|
|
233
|
+
if (price === undefined || quantity === undefined) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
return price * quantity;
|
|
237
|
+
}
|
|
238
|
+
function orderLookupParams(input) {
|
|
239
|
+
const params = compactObject({
|
|
240
|
+
orderId: input.orderId,
|
|
241
|
+
clientOrderId: input.clientOrderId,
|
|
242
|
+
symbol: input.symbol
|
|
243
|
+
});
|
|
244
|
+
if (!params.orderId && !params.clientOrderId) {
|
|
245
|
+
throw new ProductError({
|
|
246
|
+
code: "RCORE00001",
|
|
247
|
+
status: "FAIL",
|
|
248
|
+
message: "orderId or clientOrderId is required."
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return params;
|
|
252
|
+
}
|
|
253
|
+
function extractOrderReadback(response) {
|
|
254
|
+
const data = unwrapData(response);
|
|
255
|
+
if (isRecord(data) && Array.isArray(data.list)) {
|
|
256
|
+
return data.list.find(isRecord);
|
|
257
|
+
}
|
|
258
|
+
if (isRecord(data) && Array.isArray(data.orders)) {
|
|
259
|
+
return data.orders.find(isRecord);
|
|
260
|
+
}
|
|
261
|
+
return isRecord(data) && Object.keys(data).length > 0 ? data : undefined;
|
|
262
|
+
}
|
|
263
|
+
function extractPositions(response) {
|
|
264
|
+
const data = unwrapData(response);
|
|
265
|
+
if (Array.isArray(data)) {
|
|
266
|
+
return data.filter(isRecord);
|
|
267
|
+
}
|
|
268
|
+
if (isRecord(data) && Array.isArray(data.list)) {
|
|
269
|
+
return data.list.filter(isRecord);
|
|
270
|
+
}
|
|
271
|
+
if (isRecord(data) && Array.isArray(data.positions)) {
|
|
272
|
+
return data.positions.filter(isRecord);
|
|
273
|
+
}
|
|
274
|
+
return isRecord(data) ? [data] : [];
|
|
275
|
+
}
|
|
276
|
+
function unwrapData(response) {
|
|
277
|
+
if (isRecord(response) && "data" in response) {
|
|
278
|
+
return response.data;
|
|
279
|
+
}
|
|
280
|
+
return response;
|
|
281
|
+
}
|
|
282
|
+
function normalizeOrderState(value) {
|
|
283
|
+
return value === undefined || value === null ? "" : String(value).toUpperCase();
|
|
284
|
+
}
|
|
285
|
+
function isOpenOrderState(state) {
|
|
286
|
+
return ["NEW", "OPEN", "ACCEPTED", "PARTIALLY_FILLED"].includes(state);
|
|
287
|
+
}
|
|
288
|
+
function requiredString(value, field) {
|
|
289
|
+
if (value === undefined || value === null || value === "") {
|
|
290
|
+
throw new ProductError({
|
|
291
|
+
code: "RCORE00001",
|
|
292
|
+
status: "FAIL",
|
|
293
|
+
message: `${field} is required.`
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return String(value);
|
|
297
|
+
}
|
|
298
|
+
function stringField(record, field) {
|
|
299
|
+
const value = record[field];
|
|
300
|
+
return value === undefined || value === null || value === "" ? undefined : String(value);
|
|
301
|
+
}
|
|
302
|
+
function positiveNumber(value) {
|
|
303
|
+
if (value === undefined || value === null || value === "") {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
const number = Number(value);
|
|
307
|
+
return Number.isFinite(number) && number > 0 ? number : undefined;
|
|
308
|
+
}
|
|
309
|
+
function integerValue(value) {
|
|
310
|
+
if (value === undefined || value === null || value === "") {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const number = Number(value);
|
|
314
|
+
return Number.isInteger(number) && number >= 0 ? number : undefined;
|
|
315
|
+
}
|
|
316
|
+
function isAlignedToStep(value, step) {
|
|
317
|
+
const ratio = value / step;
|
|
318
|
+
return Math.abs(ratio - Math.round(ratio)) < 1e-9;
|
|
319
|
+
}
|
|
320
|
+
function decimalPlaces(value) {
|
|
321
|
+
const normalized = value.toLowerCase();
|
|
322
|
+
if (normalized.includes("e")) {
|
|
323
|
+
const [mantissa, exponentRaw] = normalized.split("e");
|
|
324
|
+
const exponent = Number(exponentRaw);
|
|
325
|
+
return Math.max(0, decimalPlaces(mantissa ?? "0") - exponent);
|
|
326
|
+
}
|
|
327
|
+
const [, fractional = ""] = normalized.split(".");
|
|
328
|
+
return fractional.replace(/0+$/, "").length;
|
|
329
|
+
}
|
|
330
|
+
function formatNumber(value) {
|
|
331
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(12)));
|
|
332
|
+
}
|
|
333
|
+
function compactObject(input) {
|
|
334
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null && value !== ""));
|
|
335
|
+
}
|
|
336
|
+
function isRecord(value) {
|
|
337
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
338
|
+
}
|
|
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { assertInputMatchesSchema } from "../contracts/input-schema.js";
|
|
5
5
|
import { evaluateSafety, stableParamsHash } from "../safety/policy.js";
|
|
6
6
|
import { ProductError } from "../errors/product-error.js";
|
|
7
|
+
import { parseRapidXSymbol } from "../client/symbol.js";
|
|
7
8
|
export function makePreviewStore() {
|
|
8
9
|
return { records: new Map() };
|
|
9
10
|
}
|
|
@@ -38,10 +39,11 @@ export function savePreviewStoreToFile(filePath, store) {
|
|
|
38
39
|
}
|
|
39
40
|
export function createTradePreview(capability, input, policy, state, store, ttlSeconds = 300) {
|
|
40
41
|
assertInputMatchesSchema(capability.inputSchema, input, {
|
|
41
|
-
allowedExtraFields: ["explicitUserConsent", "acceptedRiskText"]
|
|
42
|
+
allowedExtraFields: ["explicitUserConsent", "acceptedRiskText", "automationMode", "automationConsentText", "automationScope"]
|
|
42
43
|
});
|
|
43
44
|
const tradeParams = normalizeTradeParams(input);
|
|
44
|
-
const
|
|
45
|
+
const automation = makeAutomationDetails(input, tradeParams);
|
|
46
|
+
const previewDetails = makePreviewDetails(capability, tradeParams, automation);
|
|
45
47
|
const decision = evaluateSafety(capability, input, policy, state);
|
|
46
48
|
if (!decision.allowed) {
|
|
47
49
|
throw new ProductError({
|
|
@@ -59,7 +61,8 @@ export function createTradePreview(capability, input, policy, state, store, ttlS
|
|
|
59
61
|
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
|
60
62
|
submitted: false,
|
|
61
63
|
status: "PASS",
|
|
62
|
-
confirmation: makeSubmitConfirmation(previewId)
|
|
64
|
+
confirmation: makeSubmitConfirmation(previewId, automation),
|
|
65
|
+
...(automation ? { automation } : {})
|
|
63
66
|
};
|
|
64
67
|
store.records.set(previewId, record);
|
|
65
68
|
return record;
|
|
@@ -73,7 +76,10 @@ export function createTargetedTradePreview(targetCapability, input, policy, stat
|
|
|
73
76
|
"acceptedRiskText",
|
|
74
77
|
"mcpSchemaVersion",
|
|
75
78
|
"skillsVersion",
|
|
76
|
-
"skillsSchemaVersion"
|
|
79
|
+
"skillsSchemaVersion",
|
|
80
|
+
"automationMode",
|
|
81
|
+
"automationConsentText",
|
|
82
|
+
"automationScope"
|
|
77
83
|
],
|
|
78
84
|
ignoredRequiredFields: ["previewId", "continueConsentId"]
|
|
79
85
|
});
|
|
@@ -92,7 +98,8 @@ export function createTargetedTradePreview(targetCapability, input, policy, stat
|
|
|
92
98
|
});
|
|
93
99
|
}
|
|
94
100
|
const tradeParams = normalizeTradeParams(input);
|
|
95
|
-
const
|
|
101
|
+
const automation = makeAutomationDetails(input, tradeParams);
|
|
102
|
+
const previewDetails = makePreviewDetails(targetCapability, tradeParams, automation);
|
|
96
103
|
const decision = evaluateSafety(targetCapability, input, policy, state);
|
|
97
104
|
if (!decision.allowed) {
|
|
98
105
|
throw new ProductError({
|
|
@@ -110,16 +117,19 @@ export function createTargetedTradePreview(targetCapability, input, policy, stat
|
|
|
110
117
|
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
|
111
118
|
submitted: false,
|
|
112
119
|
status: "PASS",
|
|
113
|
-
confirmation: makeSubmitConfirmation(previewId)
|
|
120
|
+
confirmation: makeSubmitConfirmation(previewId, automation),
|
|
121
|
+
...(automation ? { automation } : {})
|
|
114
122
|
};
|
|
115
123
|
store.records.set(previewId, record);
|
|
116
124
|
return record;
|
|
117
125
|
}
|
|
118
|
-
function makeSubmitConfirmation(previewId) {
|
|
126
|
+
function makeSubmitConfirmation(previewId, automation) {
|
|
119
127
|
return {
|
|
120
128
|
submitToken: `confirm_${previewId}`,
|
|
121
129
|
requiredFields: ["previewId", "continueConsentId"],
|
|
122
|
-
instruction:
|
|
130
|
+
instruction: automation
|
|
131
|
+
? "Automation mode is enabled for this preview. The agent may pass submitToken as continueConsentId without asking for another per-order chat confirmation, within the authorized scope."
|
|
132
|
+
: "Pass submitToken as continueConsentId only after the user has confirmed this exact preview."
|
|
123
133
|
};
|
|
124
134
|
}
|
|
125
135
|
export function verifyPreview(store, previewId, capability, input, now = new Date()) {
|
|
@@ -135,7 +145,7 @@ export function verifyPreview(store, previewId, capability, input, now = new Dat
|
|
|
135
145
|
throw new ProductError({
|
|
136
146
|
code: "RCORE20002",
|
|
137
147
|
status: "BLOCKED",
|
|
138
|
-
message: "previewId was not found."
|
|
148
|
+
message: "previewId was not found or has expired. Re-run preview and submit within 5 minutes."
|
|
139
149
|
});
|
|
140
150
|
}
|
|
141
151
|
if (new Date(record.expiresAt).getTime() < now.getTime()) {
|
|
@@ -172,16 +182,24 @@ function normalizeTradeParams(input) {
|
|
|
172
182
|
const normalized = {};
|
|
173
183
|
for (const [key, value] of Object.entries(input)) {
|
|
174
184
|
if (!NON_TRADE_HASH_FIELDS.has(key) && value !== undefined) {
|
|
175
|
-
normalized[key] = value;
|
|
185
|
+
normalized[key] = key === "symbol" && typeof value === "string" ? canonicalSymbolOrOriginal(value) : value;
|
|
176
186
|
}
|
|
177
187
|
}
|
|
178
188
|
return normalized;
|
|
179
189
|
}
|
|
180
|
-
function
|
|
190
|
+
function canonicalSymbolOrOriginal(value) {
|
|
191
|
+
try {
|
|
192
|
+
return parseRapidXSymbol(value).canonicalSymbol;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function makePreviewDetails(capability, tradeParams, automation) {
|
|
181
199
|
return {
|
|
182
200
|
businessParams: { ...tradeParams },
|
|
183
201
|
requestSummary: makeRequestSummary(capability.capabilityId, tradeParams),
|
|
184
|
-
riskNotes: makeRiskNotes(capability.capabilityId, tradeParams)
|
|
202
|
+
riskNotes: makeRiskNotes(capability.capabilityId, tradeParams, automation)
|
|
185
203
|
};
|
|
186
204
|
}
|
|
187
205
|
function makeRequestSummary(capabilityId, params) {
|
|
@@ -190,6 +208,7 @@ function makeRequestSummary(capabilityId, params) {
|
|
|
190
208
|
action: "place_order",
|
|
191
209
|
symbol: params.symbol,
|
|
192
210
|
side: params.side,
|
|
211
|
+
positionSide: params.positionSide,
|
|
193
212
|
orderType: params.orderType,
|
|
194
213
|
price: params.price,
|
|
195
214
|
quantity: params.quantity,
|
|
@@ -240,11 +259,18 @@ function makeRequestSummary(capabilityId, params) {
|
|
|
240
259
|
}
|
|
241
260
|
return compactRecord({ action: capabilityId, ...params });
|
|
242
261
|
}
|
|
243
|
-
function makeRiskNotes(capabilityId, params) {
|
|
262
|
+
function makeRiskNotes(capabilityId, params, automation) {
|
|
244
263
|
const notes = [];
|
|
264
|
+
if (automation) {
|
|
265
|
+
notes.push("Automation mode allows the agent to submit this preview with submitToken without asking for another per-order chat confirmation.");
|
|
266
|
+
}
|
|
245
267
|
if (params.maxNotional !== undefined) {
|
|
246
268
|
notes.push("maxNotional is a safety upper bound, not the target order size.");
|
|
247
269
|
}
|
|
270
|
+
if (String(params.orderType ?? "").toUpperCase() === "MARKET") {
|
|
271
|
+
notes.push("MARKET orders may execute immediately.");
|
|
272
|
+
notes.push("MARKET order fill price is not guaranteed and may include slippage.");
|
|
273
|
+
}
|
|
248
274
|
if (capabilityId === "position.close") {
|
|
249
275
|
notes.push("Do not pass side or quantity for position.close; RapidX determines BUY or SELL from the current position and closes the target symbol/positionSide.");
|
|
250
276
|
notes.push("position.close uses the RapidX close-position API and may execute as a market close with slippage.");
|
|
@@ -252,6 +278,51 @@ function makeRiskNotes(capabilityId, params) {
|
|
|
252
278
|
}
|
|
253
279
|
return notes;
|
|
254
280
|
}
|
|
281
|
+
function makeAutomationDetails(input, tradeParams) {
|
|
282
|
+
if (input.automationMode !== true) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
const acceptedRiskText = typeof input.automationConsentText === "string" ? input.automationConsentText.trim() : "";
|
|
286
|
+
const missing = automationConsentMissingFields(acceptedRiskText, tradeParams, input);
|
|
287
|
+
if (missing.length > 0) {
|
|
288
|
+
throw new ProductError({
|
|
289
|
+
code: "RCORE20003",
|
|
290
|
+
status: "BLOCKED",
|
|
291
|
+
message: `automationConsentText must include ${missing.join(", ")} for this exact automation scope.`
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
enabled: true,
|
|
296
|
+
confirmationMode: "automation-preview",
|
|
297
|
+
scope: typeof input.automationScope === "string" && input.automationScope.trim().length > 0 ? input.automationScope.trim() : "single-preview",
|
|
298
|
+
...(tradeParams.maxNotional !== undefined ? { maxNotional: String(tradeParams.maxNotional) } : {}),
|
|
299
|
+
acceptedRiskText
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function automationConsentMissingFields(text, tradeParams, input) {
|
|
303
|
+
const missing = [];
|
|
304
|
+
const upperText = text.toUpperCase();
|
|
305
|
+
const lowerText = text.toLowerCase();
|
|
306
|
+
if (text.length < 20) {
|
|
307
|
+
missing.push("automationConsentText");
|
|
308
|
+
}
|
|
309
|
+
const symbol = typeof tradeParams.symbol === "string" ? tradeParams.symbol : undefined;
|
|
310
|
+
if (symbol && !upperText.includes(symbol.toUpperCase())) {
|
|
311
|
+
missing.push("symbol");
|
|
312
|
+
}
|
|
313
|
+
const maxNotional = tradeParams.maxNotional;
|
|
314
|
+
if (maxNotional !== undefined && !text.includes(String(maxNotional))) {
|
|
315
|
+
missing.push("maxNotional");
|
|
316
|
+
}
|
|
317
|
+
if (!lowerText.includes("automation") && !lowerText.includes("automated") && !text.includes("自动")) {
|
|
318
|
+
missing.push("automation intent");
|
|
319
|
+
}
|
|
320
|
+
const scope = typeof input.automationScope === "string" ? input.automationScope.trim() : "";
|
|
321
|
+
if (scope && scope !== "single-preview" && !lowerText.includes(scope.toLowerCase())) {
|
|
322
|
+
missing.push("automationScope");
|
|
323
|
+
}
|
|
324
|
+
return missing;
|
|
325
|
+
}
|
|
255
326
|
function estimateNotional(params) {
|
|
256
327
|
if (params.amount !== undefined && params.amount !== null && params.amount !== "") {
|
|
257
328
|
return String(params.amount);
|
|
@@ -314,7 +385,10 @@ const NON_TRADE_HASH_FIELDS = new Set([
|
|
|
314
385
|
"acceptedRiskText",
|
|
315
386
|
"mcpSchemaVersion",
|
|
316
387
|
"skillsVersion",
|
|
317
|
-
"skillsSchemaVersion"
|
|
388
|
+
"skillsSchemaVersion",
|
|
389
|
+
"automationMode",
|
|
390
|
+
"automationConsentText",
|
|
391
|
+
"automationScope"
|
|
318
392
|
]);
|
|
319
393
|
function isTradePreviewRecord(value) {
|
|
320
394
|
if (!value || typeof value !== "object") {
|