@liquiditytech/rapidx-cli 1.0.34 → 1.0.36
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 +8 -0
- package/dist/cli/commands/automation.js +36 -0
- package/dist/cli/commands/index.js +4 -0
- package/dist/cli/commands/order.js +54 -6
- package/dist/cli/help.js +2 -1
- package/dist/cli/parser.js +1 -1
- package/dist/core/automation/session.js +455 -0
- package/dist/core/contracts/capabilities.js +5 -0
- package/dist/core/contracts/compatibility.js +1 -1
- package/dist/core/contracts/input-schema.js +35 -0
- package/dist/core/index.js +1 -0
- package/dist/core/safety/policy.js +6 -1
- package/dist/core/trading/preview-preflight.js +10 -2
- package/dist/core/trading/preview.js +69 -40
- package/dist/core/version.js +1 -1
- package/dist/mcp/tool-registry.js +8 -3
- package/dist/mcp/tool-runner.js +91 -8
- package/package.json +1 -1
- package/packages/distribution/docs/cli.md +3 -4
- package/packages/distribution/docs/mcp.md +2 -2
- package/packages/distribution/docs/quickstart.md +7 -1
- package/packages/distribution/docs/tools.md +9 -2
- package/packages/distribution/manifests/offline-manifest.json +4 -4
- package/packages/distribution/registry/rapidx.mcp.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,14 @@ rapidx order place-preview --input '{"symbol":"BINANCE_PERP_BTC_USDT","side":"BU
|
|
|
38
38
|
|
|
39
39
|
Submit write operations only after reviewing the preview result and providing the returned `previewId` plus `confirmation.submitToken` as `continueConsentId`.
|
|
40
40
|
|
|
41
|
+
For bounded agent automation, create a local session first, then pass `automationSessionId` to preview:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
rapidx automation start --input '{"symbols":["BINANCE_PERP_BTC_USDT"],"maxNotionalPerOrder":"100","maxTotalNotional":"1000","expiresInSeconds":3600,"allowedActions":["order.place"],"allowedOrderTypes":["MARKET","LIMIT"],"explicitUserConsent":true,"acceptedRiskText":"I authorize RapidX automation for BINANCE_PERP_BTC_USDT with maxNotionalPerOrder 100 and maxTotalNotional 1000."}' --json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Automation still uses preview-first trading; the session only replaces per-order chat confirmation within the authorized scope.
|
|
48
|
+
|
|
41
49
|
## Use As MCP
|
|
42
50
|
|
|
43
51
|
Configure the agent host to start the MCP server with:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { extendAutomationSession, getAutomationSession, listAutomationSessions, loadAutomationSessionStoreFromFile, normalizeUnknownError, publicErrorDetails, resolveAutomationSessionStoreFile, saveAutomationSessionStoreToFile, startAutomationSession, stopAutomationSession, withAutomationSessionStoreLock } from "../../core/index.js";
|
|
2
|
+
import { fail, ok } from "../envelope.js";
|
|
3
|
+
export function runAutomationCommand(action, input) {
|
|
4
|
+
const filePath = resolveAutomationSessionStoreFile();
|
|
5
|
+
try {
|
|
6
|
+
return withAutomationSessionStoreLock(filePath, () => {
|
|
7
|
+
const store = loadAutomationSessionStoreFromFile(filePath);
|
|
8
|
+
if (action === "start") {
|
|
9
|
+
const session = startAutomationSession(store, input);
|
|
10
|
+
saveAutomationSessionStoreToFile(filePath, store);
|
|
11
|
+
return ok(session, "rapidx automation start");
|
|
12
|
+
}
|
|
13
|
+
if (action === "list") {
|
|
14
|
+
return ok({ sessions: listAutomationSessions(store) }, "rapidx automation list");
|
|
15
|
+
}
|
|
16
|
+
if (action === "status") {
|
|
17
|
+
return ok(getAutomationSession(store, input), "rapidx automation status");
|
|
18
|
+
}
|
|
19
|
+
if (action === "extend") {
|
|
20
|
+
const session = extendAutomationSession(store, input);
|
|
21
|
+
saveAutomationSessionStoreToFile(filePath, store);
|
|
22
|
+
return ok(session, "rapidx automation extend");
|
|
23
|
+
}
|
|
24
|
+
if (action === "stop") {
|
|
25
|
+
const session = stopAutomationSession(store, input);
|
|
26
|
+
saveAutomationSessionStoreToFile(filePath, store);
|
|
27
|
+
return ok(session, "rapidx automation stop");
|
|
28
|
+
}
|
|
29
|
+
return fail("RCLI12001", `Unknown automation command: ${action}`);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const productError = normalizeUnknownError(error, "RCLI26000");
|
|
34
|
+
return fail(productError.code.replace(/^RCORE/, "RCLI"), productError.message, productError.status, `rapidx automation ${action}`, publicErrorDetails(productError));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -2,6 +2,7 @@ import { checkInvocationMode } from "../invocation-checker.js";
|
|
|
2
2
|
import { fail, ok } from "../envelope.js";
|
|
3
3
|
import { runPortfolioCommand } from "./account.js";
|
|
4
4
|
import { runAlgoCommand } from "./algo.js";
|
|
5
|
+
import { runAutomationCommand } from "./automation.js";
|
|
5
6
|
import { runAuthCheck } from "./auth.js";
|
|
6
7
|
import { runConfigCommand } from "./config.js";
|
|
7
8
|
import { runDoctorCommand } from "./doctor.js";
|
|
@@ -42,6 +43,9 @@ export async function dispatchCli(parsed) {
|
|
|
42
43
|
if (domain === "update") {
|
|
43
44
|
return runUpdateCommand(action ?? "check", parsed.input);
|
|
44
45
|
}
|
|
46
|
+
if (domain === "automation") {
|
|
47
|
+
return runAutomationCommand(action ?? "list", parsed.input);
|
|
48
|
+
}
|
|
45
49
|
if (domain === "invocation" && action === "check") {
|
|
46
50
|
const invocationInput = {};
|
|
47
51
|
if (typeof parsed.input.commandLine === "string") {
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import { consumePreview, createTargetedTradePreview, createTradePreview, defaultSafetyPolicy, executeRapidXCapability, findCapabilityById, loadPreviewStoreFromFile, makeSafetyState, normalizeUnknownError, publicErrorDetails, resolvePreviewStoreFile, runPreviewPreflight, savePreviewStoreToFile, verifyPreview } from "../../core/index.js";
|
|
1
|
+
import { consumePreview, createTargetedTradePreview, createTradePreview, defaultSafetyPolicy, executeRapidXCapability, findCapabilityById, assertAutomationSessionStillAllowsSubmit, loadAutomationSessionStoreFromFile, loadPreviewStoreFromFile, makeSafetyState, normalizeUnknownError, publicErrorDetails, ProductError, assertTargetedTradePreviewInput, recordAutomationSessionSubmit, resolveAutomationSessionForPreview, resolveAutomationSessionStoreFile, resolvePreviewStoreFile, runPreviewPreflight, saveAutomationSessionStoreToFile, savePreviewStoreToFile, verifyPreview, withAutomationSessionStoreLock } from "../../core/index.js";
|
|
2
2
|
import { fail, ok } from "../envelope.js";
|
|
3
3
|
import { writeCliAudit } from "../audit.js";
|
|
4
4
|
const safetyState = makeSafetyState();
|
|
5
5
|
export async function runOrderCommand(action, input) {
|
|
6
6
|
const previewStoreFile = resolvePreviewStoreFile();
|
|
7
7
|
const previewStore = loadPreviewStoreFromFile(previewStoreFile);
|
|
8
|
+
const automationStoreFile = resolveAutomationSessionStoreFile();
|
|
9
|
+
const automationStore = loadAutomationSessionStoreFromFile(automationStoreFile);
|
|
8
10
|
if (action === "place-preview") {
|
|
9
11
|
const capability = findCapabilityById("order.place-preview");
|
|
10
12
|
if (!capability) {
|
|
11
13
|
return fail("RCLI30001", "order.place-preview capability missing.");
|
|
12
14
|
}
|
|
13
15
|
try {
|
|
14
|
-
const
|
|
16
|
+
const automation = optionalAutomationSession(automationStore, "order.place", input);
|
|
17
|
+
const preview = createTradePreview(capability, input, { ...defaultSafetyPolicy(), tradingEnabled: true, readOnly: false }, safetyState, previewStore, 300, automation ? { automationSession: automation.session } : {});
|
|
15
18
|
Object.assign(preview, await runPreviewPreflight("order.place", input));
|
|
16
19
|
savePreviewStoreToFile(previewStoreFile, previewStore);
|
|
17
20
|
return ok(preview, `rapidx order ${action}`);
|
|
@@ -28,8 +31,11 @@ export async function runOrderCommand(action, input) {
|
|
|
28
31
|
return fail("RCLI30001", `${previewTarget} capability missing.`);
|
|
29
32
|
}
|
|
30
33
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
assertTargetedTradePreviewInput(targetCapability, input);
|
|
35
|
+
const preflight = await runPreviewPreflight(previewTarget, input);
|
|
36
|
+
const automation = optionalAutomationSession(automationStore, previewTarget, { ...input, ...preflight });
|
|
37
|
+
const preview = createTargetedTradePreview(targetCapability, input, { ...defaultSafetyPolicy(), tradingEnabled: true, readOnly: false }, safetyState, previewStore, 300, automation ? { automationSession: automation.session } : {});
|
|
38
|
+
Object.assign(preview, preflight);
|
|
33
39
|
savePreviewStoreToFile(previewStoreFile, previewStore);
|
|
34
40
|
return ok(preview, `rapidx order ${action}`);
|
|
35
41
|
}
|
|
@@ -42,9 +48,12 @@ export async function runOrderCommand(action, input) {
|
|
|
42
48
|
if (!capability) {
|
|
43
49
|
return fail("RCLI12001", `Unknown order command: ${action}`);
|
|
44
50
|
}
|
|
51
|
+
let consumedPreview;
|
|
45
52
|
if (capability.previewRequired) {
|
|
53
|
+
let previewRecord;
|
|
46
54
|
try {
|
|
47
|
-
verifyPreview(previewStore, typeof input.previewId === "string" ? input.previewId : undefined, capability, input);
|
|
55
|
+
previewRecord = verifyPreview(previewStore, typeof input.previewId === "string" ? input.previewId : undefined, capability, input);
|
|
56
|
+
assertAutomationSessionStillAllowsSubmit(automationStore, previewRecord, capability.capabilityId);
|
|
48
57
|
}
|
|
49
58
|
catch (error) {
|
|
50
59
|
return fail("RCLI20002", error instanceof Error ? error.message : String(error), "BLOCKED", `rapidx order ${action}`);
|
|
@@ -52,8 +61,32 @@ export async function runOrderCommand(action, input) {
|
|
|
52
61
|
if (typeof input.continueConsentId !== "string" || input.continueConsentId.length === 0) {
|
|
53
62
|
return fail("RCLI20001", "continueConsentId is required for trade writes.", "BLOCKED", `rapidx order ${action}`);
|
|
54
63
|
}
|
|
64
|
+
if (previewRecord.automationSession) {
|
|
65
|
+
try {
|
|
66
|
+
const data = await withAutomationSessionStoreLock(automationStoreFile, async () => {
|
|
67
|
+
const lockedAutomationStore = loadAutomationSessionStoreFromFile(automationStoreFile);
|
|
68
|
+
const lockedPreviewStore = loadPreviewStoreFromFile(previewStoreFile);
|
|
69
|
+
const lockedPreviewRecord = verifyPreview(lockedPreviewStore, typeof input.previewId === "string" ? input.previewId : undefined, capability, input);
|
|
70
|
+
assertAutomationSessionStillAllowsSubmit(lockedAutomationStore, lockedPreviewRecord, capability.capabilityId);
|
|
71
|
+
const lockedConsumedPreview = consumePreview(lockedPreviewStore, typeof input.previewId === "string" ? input.previewId : undefined, capability, input);
|
|
72
|
+
savePreviewStoreToFile(previewStoreFile, lockedPreviewStore);
|
|
73
|
+
const result = await executeRapidXCapability(capability.capabilityId, input);
|
|
74
|
+
recordAutomationSessionSubmit(lockedAutomationStore, lockedConsumedPreview, new Date(), capability.capabilityId);
|
|
75
|
+
saveAutomationSessionStoreToFile(automationStoreFile, lockedAutomationStore);
|
|
76
|
+
return result;
|
|
77
|
+
});
|
|
78
|
+
const auditId = capability.operationType === "TRADE_WRITE"
|
|
79
|
+
? writeCliAudit("trade-write", "PASS", { command: `rapidx order ${action}`, capabilityId: capability.capabilityId, clientOrderId: input.clientOrderId })
|
|
80
|
+
: undefined;
|
|
81
|
+
return ok(data, `rapidx order ${action}`, "PASS", "real_tool_call", auditId);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const productError = normalizeUnknownError(error, "RCLI12001");
|
|
85
|
+
return fail(productError.code.replace(/^RCORE/, "RCLI"), productError.message, productError.status, `rapidx order ${action}`, publicErrorDetails(productError));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
55
88
|
try {
|
|
56
|
-
consumePreview(previewStore, typeof input.previewId === "string" ? input.previewId : undefined, capability, input);
|
|
89
|
+
consumedPreview = consumePreview(previewStore, typeof input.previewId === "string" ? input.previewId : undefined, capability, input);
|
|
57
90
|
savePreviewStoreToFile(previewStoreFile, previewStore);
|
|
58
91
|
}
|
|
59
92
|
catch (error) {
|
|
@@ -62,6 +95,10 @@ export async function runOrderCommand(action, input) {
|
|
|
62
95
|
}
|
|
63
96
|
try {
|
|
64
97
|
const data = await executeRapidXCapability(capability.capabilityId, input);
|
|
98
|
+
if (consumedPreview?.automationSession) {
|
|
99
|
+
recordAutomationSessionSubmit(automationStore, consumedPreview, new Date(), capability.capabilityId);
|
|
100
|
+
saveAutomationSessionStoreToFile(automationStoreFile, automationStore);
|
|
101
|
+
}
|
|
65
102
|
const auditId = capability.operationType === "TRADE_WRITE"
|
|
66
103
|
? writeCliAudit("trade-write", "PASS", { command: `rapidx order ${action}`, capabilityId: capability.capabilityId, clientOrderId: input.clientOrderId })
|
|
67
104
|
: undefined;
|
|
@@ -72,6 +109,17 @@ export async function runOrderCommand(action, input) {
|
|
|
72
109
|
return fail(productError.code.replace(/^RCORE/, "RCLI"), productError.message, productError.status, `rapidx order ${action}`, publicErrorDetails(productError));
|
|
73
110
|
}
|
|
74
111
|
}
|
|
112
|
+
function optionalAutomationSession(store, targetCapabilityId, input) {
|
|
113
|
+
try {
|
|
114
|
+
return resolveAutomationSessionForPreview(store, targetCapabilityId, input);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error instanceof ProductError && error.code === "RCORE26001") {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
75
123
|
function previewTargetForAction(action) {
|
|
76
124
|
if (action === "replace-preview") {
|
|
77
125
|
return "order.replace";
|
package/dist/cli/help.js
CHANGED
|
@@ -11,6 +11,7 @@ export function formatCliHelp() {
|
|
|
11
11
|
" rapidx self-check --input '{\"scope\":\"deep\"}' --json",
|
|
12
12
|
" rapidx market get-ticker --input '{\"symbol\":\"BINANCE_PERP_BTC_USDT\"}' --json",
|
|
13
13
|
" rapidx market get-ticker --symbol BINANCE_PERP_BTC_USDT --json",
|
|
14
|
+
" rapidx automation start --input '{\"symbols\":[\"BINANCE_PERP_BTC_USDT\"],\"maxNotionalPerOrder\":\"100\",\"maxTotalNotional\":\"1000\",\"expiresInSeconds\":3600,\"allowedActions\":[\"order.place\",\"order.replace\",\"order.cancel\"],\"allowedOrderTypes\":[\"MARKET\",\"LIMIT\"]}' --json",
|
|
14
15
|
" rapidx order place-preview --input '{\"symbol\":\"BINANCE_PERP_BTC_USDT\",\"side\":\"BUY\",\"orderType\":\"LIMIT\",\"price\":\"1\",\"quantity\":\"0.001\",\"maxNotional\":\"1\",\"clientOrderId\":\"example\"}' --json",
|
|
15
16
|
" rapidx order cancel-preview --input '{\"orderId\":\"<order-id>\"}' --json",
|
|
16
17
|
" rapidx trade verify-live --input '{\"symbol\":\"BINANCE_PERP_BTC_USDT\",\"side\":\"BUY\",\"maxNotional\":\"10\",\"clientOrderId\":\"verify-001\",\"explicitUserConsent\":true,\"acceptedRiskText\":\"I authorize a real verification order for BINANCE_PERP_BTC_USDT BUY maxNotional 10 with cancel cleanup.\"}' --json",
|
|
@@ -18,7 +19,7 @@ export function formatCliHelp() {
|
|
|
18
19
|
"",
|
|
19
20
|
"Domains:",
|
|
20
21
|
" schema, doctor, auth, config, update, self-check, invocation",
|
|
21
|
-
" market, portfolio, order, transaction, trade, position, algo",
|
|
22
|
+
" market, portfolio, automation, order, transaction, trade, position, algo",
|
|
22
23
|
"",
|
|
23
24
|
"Use --input for structured JSON. Named options such as --symbol and --depth are also accepted for simple inputs."
|
|
24
25
|
].join("\n");
|
package/dist/cli/parser.js
CHANGED
|
@@ -62,7 +62,7 @@ function readInput(value) {
|
|
|
62
62
|
return parsed;
|
|
63
63
|
}
|
|
64
64
|
const RESERVED_OPTIONS = new Set(["help", "version", "json", "input"]);
|
|
65
|
-
const NUMBER_OPTIONS = new Set(["depth", "limit", "pageSize", "leverage", "maxCacheAgeSeconds"]);
|
|
65
|
+
const NUMBER_OPTIONS = new Set(["depth", "limit", "pageSize", "leverage", "maxCacheAgeSeconds", "expiresInSeconds"]);
|
|
66
66
|
const BOOLEAN_OPTIONS = new Set(["postOnly", "reduceOnly", "readOnly", "explicitUserConsent", "force", "checkUpdates"]);
|
|
67
67
|
function normalizeOptionName(name) {
|
|
68
68
|
return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { ProductError } from "../errors/product-error.js";
|
|
5
|
+
import { parseRapidXSymbol } from "../client/symbol.js";
|
|
6
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 24 * 60 * 60;
|
|
7
|
+
const MAX_EXPIRES_IN_SECONDS = 30 * 24 * 60 * 60;
|
|
8
|
+
const DEFAULT_ALLOWED_ACTIONS = ["order.place", "order.replace", "order.cancel"];
|
|
9
|
+
const DEFAULT_ALLOWED_ORDER_TYPES = ["LIMIT", "MARKET"];
|
|
10
|
+
export function makeAutomationSessionStore() {
|
|
11
|
+
return { sessions: new Map() };
|
|
12
|
+
}
|
|
13
|
+
export function resolveAutomationSessionStoreFile(env = process.env, cwd = process.cwd()) {
|
|
14
|
+
const stateDir = env.RAPIDX_STATE_DIR ? resolve(env.RAPIDX_STATE_DIR) : resolve(cwd, ".rapidx");
|
|
15
|
+
return join(stateDir, "automation-sessions.json");
|
|
16
|
+
}
|
|
17
|
+
export function loadAutomationSessionStoreFromFile(filePath, now = new Date()) {
|
|
18
|
+
const store = makeAutomationSessionStore();
|
|
19
|
+
if (!existsSync(filePath)) {
|
|
20
|
+
return store;
|
|
21
|
+
}
|
|
22
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
23
|
+
if (!Array.isArray(parsed)) {
|
|
24
|
+
return store;
|
|
25
|
+
}
|
|
26
|
+
for (const item of parsed) {
|
|
27
|
+
if (isAutomationSession(item)) {
|
|
28
|
+
const session = item.expiresAt && new Date(item.expiresAt).getTime() < now.getTime() && item.status === "ACTIVE"
|
|
29
|
+
? { ...item, status: "STOPPED" }
|
|
30
|
+
: item;
|
|
31
|
+
store.sessions.set(session.automationSessionId, session);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return store;
|
|
35
|
+
}
|
|
36
|
+
export function saveAutomationSessionStoreToFile(filePath, store) {
|
|
37
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
38
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
39
|
+
writeFileSync(tmp, `${JSON.stringify([...store.sessions.values()], null, 2)}\n`, { mode: 0o600 });
|
|
40
|
+
renameSync(tmp, filePath);
|
|
41
|
+
}
|
|
42
|
+
export function withAutomationSessionStoreLock(filePath, fn) {
|
|
43
|
+
const lockPath = `${filePath}.lock`;
|
|
44
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new ProductError({
|
|
50
|
+
code: "RCORE26006",
|
|
51
|
+
status: "BLOCKED",
|
|
52
|
+
message: "Automation session store is busy. Retry shortly."
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
let removeSynchronously = true;
|
|
56
|
+
try {
|
|
57
|
+
const result = fn();
|
|
58
|
+
if (isPromiseLike(result)) {
|
|
59
|
+
removeSynchronously = false;
|
|
60
|
+
return result.finally(() => {
|
|
61
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
// Promise-returning callbacks remove the lock in finally() after they settle.
|
|
69
|
+
if (removeSynchronously && existsSync(lockPath)) {
|
|
70
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function startAutomationSession(store, input, now = new Date()) {
|
|
75
|
+
const acceptedRiskText = requireAutomationConsent(input, "automation.start");
|
|
76
|
+
const expiresInSeconds = input.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS;
|
|
77
|
+
validateExpiresInSeconds(expiresInSeconds);
|
|
78
|
+
const symbols = normalizeSymbols(input.symbols);
|
|
79
|
+
const session = {
|
|
80
|
+
automationSessionId: `ras_${randomUUID()}`,
|
|
81
|
+
status: "ACTIVE",
|
|
82
|
+
symbols,
|
|
83
|
+
maxNotionalPerOrder: validatePositiveDecimal(input.maxNotionalPerOrder, "maxNotionalPerOrder"),
|
|
84
|
+
maxTotalNotional: validatePositiveDecimal(input.maxTotalNotional, "maxTotalNotional"),
|
|
85
|
+
usedNotional: "0",
|
|
86
|
+
allowedActions: normalizeStringList(input.allowedActions, DEFAULT_ALLOWED_ACTIONS, "allowedActions"),
|
|
87
|
+
allowedOrderTypes: normalizeStringList(input.allowedOrderTypes, DEFAULT_ALLOWED_ORDER_TYPES, "allowedOrderTypes").map((value) => value.toUpperCase()),
|
|
88
|
+
createdAt: now.toISOString(),
|
|
89
|
+
expiresAt: new Date(now.getTime() + expiresInSeconds * 1000).toISOString(),
|
|
90
|
+
acceptedRiskText,
|
|
91
|
+
...(input.name ? { name: input.name } : {})
|
|
92
|
+
};
|
|
93
|
+
store.sessions.set(session.automationSessionId, session);
|
|
94
|
+
return session;
|
|
95
|
+
}
|
|
96
|
+
export function extendAutomationSession(store, input, now = new Date()) {
|
|
97
|
+
const acceptedRiskText = requireAutomationConsent(input, "automation.extend");
|
|
98
|
+
validateExpiresInSeconds(input.expiresInSeconds);
|
|
99
|
+
const session = requireSession(store, input.automationSessionId, now);
|
|
100
|
+
const updated = {
|
|
101
|
+
...session,
|
|
102
|
+
expiresAt: new Date(now.getTime() + input.expiresInSeconds * 1000).toISOString(),
|
|
103
|
+
lastExtensionAcceptedRiskText: acceptedRiskText
|
|
104
|
+
};
|
|
105
|
+
store.sessions.set(updated.automationSessionId, updated);
|
|
106
|
+
return updated;
|
|
107
|
+
}
|
|
108
|
+
export function stopAutomationSession(store, input, now = new Date()) {
|
|
109
|
+
const session = requireKnownSession(store, input.automationSessionId);
|
|
110
|
+
const updated = {
|
|
111
|
+
...session,
|
|
112
|
+
status: "STOPPED",
|
|
113
|
+
stoppedAt: now.toISOString()
|
|
114
|
+
};
|
|
115
|
+
store.sessions.set(updated.automationSessionId, updated);
|
|
116
|
+
return updated;
|
|
117
|
+
}
|
|
118
|
+
export function getAutomationSession(store, input, now = new Date()) {
|
|
119
|
+
return requireSession(store, input.automationSessionId, now);
|
|
120
|
+
}
|
|
121
|
+
export function listAutomationSessions(store, now = new Date()) {
|
|
122
|
+
return [...store.sessions.values()].map((session) => {
|
|
123
|
+
if (session.status === "ACTIVE" && new Date(session.expiresAt).getTime() < now.getTime()) {
|
|
124
|
+
return { ...session, status: "STOPPED" };
|
|
125
|
+
}
|
|
126
|
+
return session;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export function resolveAutomationSessionForPreview(store, targetCapabilityId, input, now = new Date()) {
|
|
130
|
+
const explicit = typeof input.automationSessionId === "string" && input.automationSessionId.length > 0
|
|
131
|
+
? input.automationSessionId
|
|
132
|
+
: undefined;
|
|
133
|
+
if (explicit) {
|
|
134
|
+
const session = requireSession(store, explicit, now);
|
|
135
|
+
assertSessionAllowsPreview(session, targetCapabilityId, input, now);
|
|
136
|
+
return { session, matchedBy: "explicit" };
|
|
137
|
+
}
|
|
138
|
+
const matches = [...store.sessions.values()].filter((session) => {
|
|
139
|
+
try {
|
|
140
|
+
assertSessionAllowsPreview(session, targetCapabilityId, input, now);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (matches.length === 1) {
|
|
148
|
+
return { session: matches[0], matchedBy: "auto" };
|
|
149
|
+
}
|
|
150
|
+
if (matches.length > 1) {
|
|
151
|
+
throw new ProductError({
|
|
152
|
+
code: "RCORE26003",
|
|
153
|
+
status: "BLOCKED",
|
|
154
|
+
message: "multiple automation sessions match this preview; pass automationSessionId explicitly."
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
throw new ProductError({
|
|
158
|
+
code: "RCORE26001",
|
|
159
|
+
status: "BLOCKED",
|
|
160
|
+
message: "automation session required for automated submit."
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
export function automationPreviewDetails(session) {
|
|
164
|
+
return {
|
|
165
|
+
automationSessionId: session.automationSessionId,
|
|
166
|
+
confirmationMode: "automation-session",
|
|
167
|
+
expiresAt: session.expiresAt,
|
|
168
|
+
maxNotionalPerOrder: session.maxNotionalPerOrder,
|
|
169
|
+
maxTotalNotional: session.maxTotalNotional,
|
|
170
|
+
usedNotional: session.usedNotional
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function assertAutomationSessionStillAllowsSubmit(store, preview, targetCapabilityId, now = new Date()) {
|
|
174
|
+
const sessionId = preview.automationSession?.automationSessionId;
|
|
175
|
+
if (!sessionId) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const session = requireSession(store, sessionId, now);
|
|
179
|
+
assertSessionAllowsPreview(session, targetCapabilityId, {
|
|
180
|
+
...preview.businessParams,
|
|
181
|
+
...(preview.orderReadback ? { orderReadback: preview.orderReadback } : {})
|
|
182
|
+
}, now);
|
|
183
|
+
return session;
|
|
184
|
+
}
|
|
185
|
+
export function recordAutomationSessionSubmit(store, preview, now = new Date(), targetCapabilityId) {
|
|
186
|
+
const sessionId = preview.automationSession?.automationSessionId;
|
|
187
|
+
if (!sessionId) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
const session = requireSession(store, sessionId, now);
|
|
191
|
+
const action = normalizePreviewCapabilityId(targetCapabilityId ?? preview.capabilityId);
|
|
192
|
+
const notional = automationNotionalForAction(action, {
|
|
193
|
+
...preview.businessParams,
|
|
194
|
+
...(preview.orderReadback ? { orderReadback: preview.orderReadback } : {})
|
|
195
|
+
});
|
|
196
|
+
if (!notional) {
|
|
197
|
+
return session;
|
|
198
|
+
}
|
|
199
|
+
const usedNotional = addDecimalStrings(session.usedNotional, notional);
|
|
200
|
+
const updated = { ...session, usedNotional };
|
|
201
|
+
store.sessions.set(session.automationSessionId, updated);
|
|
202
|
+
return updated;
|
|
203
|
+
}
|
|
204
|
+
function assertSessionAllowsPreview(session, targetCapabilityId, input, now = new Date()) {
|
|
205
|
+
requireActiveSession(session, now);
|
|
206
|
+
if (!session.allowedActions.includes(targetCapabilityId)) {
|
|
207
|
+
throw new ProductError({
|
|
208
|
+
code: "RCORE26004",
|
|
209
|
+
status: "BLOCKED",
|
|
210
|
+
message: `automation session does not allow ${targetCapabilityId}.`
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const symbol = symbolForAutomationInput(input);
|
|
214
|
+
if (isOrderLifecycleAction(targetCapabilityId) && !symbol) {
|
|
215
|
+
throw new ProductError({ code: "RCORE26004", status: "BLOCKED", message: `automation ${targetCapabilityId} requires symbol or orderReadback symbol.` });
|
|
216
|
+
}
|
|
217
|
+
if (symbol && !session.symbols.includes(symbol)) {
|
|
218
|
+
throw new ProductError({ code: "RCORE26004", status: "BLOCKED", message: "automation session does not allow this symbol." });
|
|
219
|
+
}
|
|
220
|
+
const orderType = typeof input.orderType === "string" ? input.orderType.toUpperCase() : undefined;
|
|
221
|
+
if (targetCapabilityId === "order.place" && orderType && !session.allowedOrderTypes.includes(orderType)) {
|
|
222
|
+
throw new ProductError({ code: "RCORE26004", status: "BLOCKED", message: "automation session does not allow this orderType." });
|
|
223
|
+
}
|
|
224
|
+
const notional = automationNotionalForAction(targetCapabilityId, input);
|
|
225
|
+
if (notional) {
|
|
226
|
+
if (compareDecimalStrings(notional, session.maxNotionalPerOrder) > 0) {
|
|
227
|
+
throw new ProductError({
|
|
228
|
+
code: "RCORE26005",
|
|
229
|
+
status: "BLOCKED",
|
|
230
|
+
message: targetCapabilityId === "order.place"
|
|
231
|
+
? "maxNotional exceeds automation maxNotionalPerOrder."
|
|
232
|
+
: `${targetCapabilityId} notional exceeds automation maxNotionalPerOrder.`
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (compareDecimalStrings(addDecimalStrings(session.usedNotional, notional), session.maxTotalNotional) > 0) {
|
|
236
|
+
throw new ProductError({
|
|
237
|
+
code: "RCORE26005",
|
|
238
|
+
status: "BLOCKED",
|
|
239
|
+
message: "automation maxTotalNotional exceeded."
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function isOrderLifecycleAction(targetCapabilityId) {
|
|
245
|
+
return targetCapabilityId === "order.place" || targetCapabilityId === "order.replace" || targetCapabilityId === "order.cancel";
|
|
246
|
+
}
|
|
247
|
+
function normalizePreviewCapabilityId(capabilityId) {
|
|
248
|
+
if (capabilityId === "order.place-preview") {
|
|
249
|
+
return "order.place";
|
|
250
|
+
}
|
|
251
|
+
if (capabilityId === "order.replace-preview") {
|
|
252
|
+
return "order.replace";
|
|
253
|
+
}
|
|
254
|
+
if (capabilityId === "order.cancel-preview") {
|
|
255
|
+
return "order.cancel";
|
|
256
|
+
}
|
|
257
|
+
return capabilityId ?? "";
|
|
258
|
+
}
|
|
259
|
+
function automationNotionalForAction(targetCapabilityId, input) {
|
|
260
|
+
if (targetCapabilityId === "order.place") {
|
|
261
|
+
return validatePositiveDecimal(String(input.maxNotional ?? ""), "maxNotional");
|
|
262
|
+
}
|
|
263
|
+
if (targetCapabilityId === "order.replace") {
|
|
264
|
+
return replacementNotional(input);
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
function replacementNotional(input) {
|
|
269
|
+
const orderReadback = isRecord(input.orderReadback) ? input.orderReadback : {};
|
|
270
|
+
const price = firstNonEmptyString(input.price, orderReadback.limitPrice, orderReadback.price, orderReadback.orderPrice, orderReadback.origPrice);
|
|
271
|
+
const quantity = firstNonEmptyString(input.quantity, input.qty, orderReadback.orderQty, orderReadback.quantity, orderReadback.qty, orderReadback.origQty);
|
|
272
|
+
if (!price || !quantity) {
|
|
273
|
+
throw new ProductError({
|
|
274
|
+
code: "RCORE26007",
|
|
275
|
+
status: "BLOCKED",
|
|
276
|
+
message: "automation order.replace requires readable replacement price and quantity."
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return multiplyDecimalStrings(validatePositiveDecimal(price, "price"), validatePositiveDecimal(quantity, "quantity"));
|
|
280
|
+
}
|
|
281
|
+
function symbolForAutomationInput(input) {
|
|
282
|
+
if (typeof input.symbol === "string" && input.symbol.length > 0) {
|
|
283
|
+
return canonicalSymbol(input.symbol);
|
|
284
|
+
}
|
|
285
|
+
const orderReadback = isRecord(input.orderReadback) ? input.orderReadback : undefined;
|
|
286
|
+
const readbackSymbol = orderReadback
|
|
287
|
+
? firstNonEmptyString(orderReadback.sym, orderReadback.symbol, orderReadback.instrumentId, orderReadback.instrument)
|
|
288
|
+
: undefined;
|
|
289
|
+
return readbackSymbol ? canonicalSymbol(readbackSymbol) : undefined;
|
|
290
|
+
}
|
|
291
|
+
function requireSession(store, automationSessionId, now) {
|
|
292
|
+
const session = requireKnownSession(store, automationSessionId);
|
|
293
|
+
requireActiveSession(session, now);
|
|
294
|
+
return session;
|
|
295
|
+
}
|
|
296
|
+
function requireKnownSession(store, automationSessionId) {
|
|
297
|
+
const session = store.sessions.get(automationSessionId);
|
|
298
|
+
if (!session) {
|
|
299
|
+
throw new ProductError({
|
|
300
|
+
code: "RCORE26002",
|
|
301
|
+
status: "NOT_FOUND",
|
|
302
|
+
message: "automation session not found."
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return session;
|
|
306
|
+
}
|
|
307
|
+
function requireActiveSession(session, now) {
|
|
308
|
+
if (session.status !== "ACTIVE") {
|
|
309
|
+
throw new ProductError({
|
|
310
|
+
code: "RCORE26002",
|
|
311
|
+
status: "BLOCKED",
|
|
312
|
+
message: "automation session is not active."
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (new Date(session.expiresAt).getTime() < now.getTime()) {
|
|
316
|
+
throw new ProductError({
|
|
317
|
+
code: "RCORE26002",
|
|
318
|
+
status: "BLOCKED",
|
|
319
|
+
message: "automation session has expired."
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function validateExpiresInSeconds(value) {
|
|
324
|
+
if (!Number.isInteger(value) || value <= 0 || value > MAX_EXPIRES_IN_SECONDS) {
|
|
325
|
+
throw new ProductError({
|
|
326
|
+
code: "RCORE26000",
|
|
327
|
+
status: "INVALID_INPUT",
|
|
328
|
+
message: "expiresInSeconds must be an integer from 1 to 2592000."
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function requireAutomationConsent(input, action) {
|
|
333
|
+
if (input.explicitUserConsent !== true) {
|
|
334
|
+
throw new ProductError({
|
|
335
|
+
code: "RCORE26000",
|
|
336
|
+
status: "BLOCKED",
|
|
337
|
+
message: `${action} requires explicitUserConsent=true.`
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const acceptedRiskText = typeof input.acceptedRiskText === "string" ? input.acceptedRiskText.trim() : "";
|
|
341
|
+
if (!acceptedRiskText) {
|
|
342
|
+
throw new ProductError({
|
|
343
|
+
code: "RCORE26000",
|
|
344
|
+
status: "BLOCKED",
|
|
345
|
+
message: `${action} requires acceptedRiskText from the user's authorization.`
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return acceptedRiskText;
|
|
349
|
+
}
|
|
350
|
+
function normalizeSymbols(symbols) {
|
|
351
|
+
if (!Array.isArray(symbols) || symbols.length === 0) {
|
|
352
|
+
throw new ProductError({ code: "RCORE26000", status: "INVALID_INPUT", message: "symbols must contain at least one symbol." });
|
|
353
|
+
}
|
|
354
|
+
return [...new Set(symbols.map((symbol) => canonicalSymbol(symbol)))];
|
|
355
|
+
}
|
|
356
|
+
function normalizeStringList(input, defaults, field) {
|
|
357
|
+
const values = input ?? [...defaults];
|
|
358
|
+
if (!Array.isArray(values) || values.length === 0 || values.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
359
|
+
throw new ProductError({ code: "RCORE26000", status: "INVALID_INPUT", message: `${field} must contain at least one string.` });
|
|
360
|
+
}
|
|
361
|
+
return [...new Set(values)];
|
|
362
|
+
}
|
|
363
|
+
function canonicalSymbol(value) {
|
|
364
|
+
try {
|
|
365
|
+
return parseRapidXSymbol(value).canonicalSymbol;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function validatePositiveDecimal(value, field) {
|
|
372
|
+
if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value) || Number(value) <= 0) {
|
|
373
|
+
throw new ProductError({ code: "RCORE26000", status: "INVALID_INPUT", message: `${field} must be a positive decimal string.` });
|
|
374
|
+
}
|
|
375
|
+
return trimDecimal(value);
|
|
376
|
+
}
|
|
377
|
+
function compareDecimalStrings(left, right) {
|
|
378
|
+
const [leftWhole = "0", leftFraction = ""] = trimDecimal(left).split(".");
|
|
379
|
+
const [rightWhole = "0", rightFraction = ""] = trimDecimal(right).split(".");
|
|
380
|
+
if (leftWhole.length !== rightWhole.length) {
|
|
381
|
+
return leftWhole.length > rightWhole.length ? 1 : -1;
|
|
382
|
+
}
|
|
383
|
+
if (leftWhole !== rightWhole) {
|
|
384
|
+
return leftWhole > rightWhole ? 1 : -1;
|
|
385
|
+
}
|
|
386
|
+
const width = Math.max(leftFraction.length, rightFraction.length);
|
|
387
|
+
const paddedLeft = leftFraction.padEnd(width, "0");
|
|
388
|
+
const paddedRight = rightFraction.padEnd(width, "0");
|
|
389
|
+
if (paddedLeft === paddedRight) {
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
return paddedLeft > paddedRight ? 1 : -1;
|
|
393
|
+
}
|
|
394
|
+
function addDecimalStrings(left, right) {
|
|
395
|
+
const leftParsed = parseDecimal(left);
|
|
396
|
+
const rightParsed = parseDecimal(right);
|
|
397
|
+
const scale = Math.max(leftParsed.scale, rightParsed.scale);
|
|
398
|
+
const sum = leftParsed.value * 10n ** BigInt(scale - leftParsed.scale)
|
|
399
|
+
+ rightParsed.value * 10n ** BigInt(scale - rightParsed.scale);
|
|
400
|
+
return formatDecimal(sum, scale);
|
|
401
|
+
}
|
|
402
|
+
function multiplyDecimalStrings(left, right) {
|
|
403
|
+
const leftParsed = parseDecimal(left);
|
|
404
|
+
const rightParsed = parseDecimal(right);
|
|
405
|
+
const scale = leftParsed.scale + rightParsed.scale;
|
|
406
|
+
return formatDecimal(leftParsed.value * rightParsed.value, scale);
|
|
407
|
+
}
|
|
408
|
+
function parseDecimal(value) {
|
|
409
|
+
const [whole, fraction = ""] = trimDecimal(value).split(".");
|
|
410
|
+
return {
|
|
411
|
+
value: BigInt(`${whole}${fraction}`),
|
|
412
|
+
scale: fraction.length
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function formatDecimal(value, scale) {
|
|
416
|
+
const raw = value.toString().padStart(scale + 1, "0");
|
|
417
|
+
if (scale === 0) {
|
|
418
|
+
return raw;
|
|
419
|
+
}
|
|
420
|
+
const whole = raw.slice(0, -scale);
|
|
421
|
+
const fraction = raw.slice(-scale).replace(/0+$/, "");
|
|
422
|
+
return fraction ? `${whole}.${fraction}` : whole;
|
|
423
|
+
}
|
|
424
|
+
function trimDecimal(value) {
|
|
425
|
+
return value.replace(/^0+(?=\d)/, "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
|
|
426
|
+
}
|
|
427
|
+
function firstNonEmptyString(...values) {
|
|
428
|
+
const value = values.find((candidate) => candidate !== undefined && candidate !== null && candidate !== "");
|
|
429
|
+
return value === undefined ? undefined : String(value);
|
|
430
|
+
}
|
|
431
|
+
function isRecord(value) {
|
|
432
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
433
|
+
}
|
|
434
|
+
function isPromiseLike(value) {
|
|
435
|
+
if (!value || typeof value !== "object" || !("then" in value)) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
return typeof value.then === "function";
|
|
439
|
+
}
|
|
440
|
+
function isAutomationSession(value) {
|
|
441
|
+
if (!value || typeof value !== "object") {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
const candidate = value;
|
|
445
|
+
return typeof candidate.automationSessionId === "string"
|
|
446
|
+
&& (candidate.status === "ACTIVE" || candidate.status === "STOPPED")
|
|
447
|
+
&& Array.isArray(candidate.symbols)
|
|
448
|
+
&& typeof candidate.maxNotionalPerOrder === "string"
|
|
449
|
+
&& typeof candidate.maxTotalNotional === "string"
|
|
450
|
+
&& typeof candidate.usedNotional === "string"
|
|
451
|
+
&& Array.isArray(candidate.allowedActions)
|
|
452
|
+
&& Array.isArray(candidate.allowedOrderTypes)
|
|
453
|
+
&& typeof candidate.createdAt === "string"
|
|
454
|
+
&& typeof candidate.expiresAt === "string";
|
|
455
|
+
}
|