pandora-cli-skills 1.1.42 → 1.1.43
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/SKILL.md +8 -2
- package/cli/lib/arb_command_service.cjs +70 -14
- package/cli/lib/command_executor_service.cjs +6 -1
- package/cli/lib/error_recovery_service.cjs +70 -0
- package/cli/lib/lifecycle_command_service.cjs +42 -4
- package/cli/lib/mcp_tool_registry.cjs +6 -0
- package/cli/lib/odds_command_service.cjs +168 -0
- package/cli/lib/parsers/odds_flags.cjs +19 -0
- package/cli/lib/schema_command_service.cjs +142 -0
- package/cli/lib/sports_model_input_service.cjs +29 -0
- package/cli/pandora.cjs +48 -140
- package/package.json +1 -1
- package/tests/cli/cli.integration.test.cjs +124 -0
- package/tests/cli/mcp.integration.test.cjs +76 -0
- package/tests/unit/new-features.test.cjs +82 -0
package/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pandora-cli-skills
|
|
3
3
|
summary: Canonical skill and operator guide for Pandora CLI including mirror, polymarket, resolve, and LP flows.
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.43
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Pandora CLI & Skills
|
|
@@ -93,7 +93,7 @@ pandora [--output table|json] markets list [--limit <n>] [--after <cursor>] [--b
|
|
|
93
93
|
pandora [--output table|json] markets get [--id <id> ...] [--stdin]
|
|
94
94
|
pandora [--output table|json] sports books list|events list|events live|odds snapshot|odds bulk|consensus|create plan|create run|sync once|sync run|sync start|sync stop|sync status|resolve plan [flags]
|
|
95
95
|
pandora [--output table|json] lifecycle start --config <path>|status --id <id>|resolve --id <id> --confirm
|
|
96
|
-
pandora arb scan --markets <csv> --output ndjson [--min-net-spread-pct <n>] [--fee-pct-per-leg <n>] [--amount-usdc <n>] [--iterations <n>] [--interval-ms <ms>]
|
|
96
|
+
pandora arb scan --markets <csv> --output ndjson|json [--min-net-spread-pct <n>] [--fee-pct-per-leg <n>] [--amount-usdc <n>] [--iterations <n>] [--interval-ms <ms>]
|
|
97
97
|
pandora [--output table|json] odds record --competition <id> --interval <sec> [--max-samples <n>] [--event-id <id>] [--venues pandora_amm,polymarket]
|
|
98
98
|
pandora [--output table|json] odds history --event-id <id> --output csv|json [--limit <n>]
|
|
99
99
|
pandora [--output table|json] polls list [--limit <n>] [--after <cursor>] [--before <cursor>] [--order-by <field>] [--order-direction asc|desc] [--chain-id <id>] [--creator <address>] [--status <int>] [--category <int>] [--question-contains <text>] [--where-json <json>]
|
|
@@ -443,6 +443,12 @@ Common structured error codes for automation:
|
|
|
443
443
|
- `INDEXER_HTTP_ERROR` / `INDEXER_TIMEOUT`: indexer transport failure.
|
|
444
444
|
- `MIRROR_*`: mirror pipeline/service failures (plan/deploy/verify/sync/go/status).
|
|
445
445
|
- `POLYMARKET_*`: Polymarket resolution/auth/order/preflight failures.
|
|
446
|
+
- `RISK_*`: risk state/guardrail/panic failures (`RISK_PANIC_ACTIVE`, `RISK_GUARDRAIL_BLOCKED`, etc.).
|
|
447
|
+
- `LIFECYCLE_*`: lifecycle state-machine file operations (`LIFECYCLE_EXISTS`, `LIFECYCLE_NOT_FOUND`).
|
|
448
|
+
- `ODDS_*`: odds record/history storage and connector failures.
|
|
449
|
+
- `ARB_*`: arb scan parsing/execution output-mode failures.
|
|
450
|
+
- `CONFIG_*`: config read/parse failures (for example `CONFIG_FILE_NOT_FOUND`).
|
|
451
|
+
- `MCP_FILE_ACCESS_BLOCKED`: MCP-mode file path denied outside workspace root.
|
|
446
452
|
- `WEBHOOK_DELIVERY_FAILED`: webhook hard-fail when `--fail-on-webhook-error` is set.
|
|
447
453
|
|
|
448
454
|
Error envelope:
|
|
@@ -14,7 +14,7 @@ const ARB_MARKET_FIELDS = [
|
|
|
14
14
|
];
|
|
15
15
|
|
|
16
16
|
const ARB_USAGE =
|
|
17
|
-
'pandora arb scan --markets <csv> --output ndjson [--min-net-spread-pct <n>] [--fee-pct-per-leg <n>] [--amount-usdc <n>] [--interval-ms <ms>] [--iterations <n>] [--indexer-url <url>] [--timeout-ms <ms>]';
|
|
17
|
+
'pandora arb scan --markets <csv> --output ndjson|json [--min-net-spread-pct <n>] [--fee-pct-per-leg <n>] [--amount-usdc <n>] [--interval-ms <ms>] [--iterations <n>] [--indexer-url <url>] [--timeout-ms <ms>]';
|
|
18
18
|
|
|
19
19
|
function requireDep(deps, name) {
|
|
20
20
|
if (!deps || typeof deps[name] !== 'function') {
|
|
@@ -210,8 +210,8 @@ function parseArbScanFlags(args, deps) {
|
|
|
210
210
|
throw new CliError('MISSING_REQUIRED_FLAG', 'arb scan requires at least two markets via --markets <csv>.');
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
if (options.output
|
|
214
|
-
throw new CliError('INVALID_FLAG_VALUE', 'arb scan
|
|
213
|
+
if (!['ndjson', 'json'].includes(options.output)) {
|
|
214
|
+
throw new CliError('INVALID_FLAG_VALUE', 'arb scan supports --output ndjson|json.');
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
if (options.minNetSpreadPct < 0) {
|
|
@@ -291,8 +291,29 @@ function createRunArbCommand(deps) {
|
|
|
291
291
|
parsePositiveInteger,
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
+
const mcpMode = String(process.env.PANDORA_MCP_MODE || '').trim() === '1';
|
|
295
|
+
if (mcpMode && options.output !== 'json') {
|
|
296
|
+
throw new CliError('MCP_LONG_RUNNING_MODE_BLOCKED', 'arb scan via MCP requires --output json and a bounded iteration count.', {
|
|
297
|
+
toolName: 'arb.scan',
|
|
298
|
+
hints: ['Use arb.scan with --output json --iterations 1 when calling via MCP.'],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (options.output === 'json' && !Number.isInteger(options.iterations)) {
|
|
303
|
+
options.iterations = 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (options.output === 'json' && Number.isInteger(options.iterations) && options.iterations > 1) {
|
|
307
|
+
const code = mcpMode ? 'MCP_LONG_RUNNING_MODE_BLOCKED' : 'INVALID_FLAG_VALUE';
|
|
308
|
+
throw new CliError(code, 'arb scan --output json supports only --iterations 1.', {
|
|
309
|
+
toolName: 'arb.scan',
|
|
310
|
+
hints: ['Use --output ndjson for streaming multi-iteration scans.'],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
294
314
|
const maxIterations = Number.isInteger(options.iterations) ? options.iterations : Number.POSITIVE_INFINITY;
|
|
295
315
|
let iteration = 0;
|
|
316
|
+
const snapshots = [];
|
|
296
317
|
|
|
297
318
|
while (iteration < maxIterations) {
|
|
298
319
|
iteration += 1;
|
|
@@ -308,23 +329,58 @@ function createRunArbCommand(deps) {
|
|
|
308
329
|
amountUsdc: options.amountUsdc,
|
|
309
330
|
});
|
|
310
331
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
332
|
+
if (options.output === 'ndjson') {
|
|
333
|
+
for (const opportunity of opportunities) {
|
|
334
|
+
// eslint-disable-next-line no-console
|
|
335
|
+
console.log(
|
|
336
|
+
JSON.stringify({
|
|
337
|
+
type: 'arb.scan.opportunity',
|
|
338
|
+
timestamp: new Date().toISOString(),
|
|
339
|
+
iteration,
|
|
340
|
+
indexerUrl,
|
|
341
|
+
...opportunity,
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
snapshots.push({
|
|
347
|
+
iteration,
|
|
348
|
+
observedAt: new Date().toISOString(),
|
|
349
|
+
count: opportunities.length,
|
|
350
|
+
opportunities,
|
|
351
|
+
});
|
|
322
352
|
}
|
|
323
353
|
|
|
324
354
|
if (iteration < maxIterations) {
|
|
325
355
|
await sleepMs(options.intervalMs);
|
|
326
356
|
}
|
|
327
357
|
}
|
|
358
|
+
|
|
359
|
+
if (options.output === 'json') {
|
|
360
|
+
const payload = {
|
|
361
|
+
action: 'scan',
|
|
362
|
+
indexerUrl,
|
|
363
|
+
iterationsCompleted: iteration,
|
|
364
|
+
requestedIterations: options.iterations,
|
|
365
|
+
intervalMs: options.intervalMs,
|
|
366
|
+
filters: {
|
|
367
|
+
markets: options.markets,
|
|
368
|
+
minNetSpreadPct: options.minNetSpreadPct,
|
|
369
|
+
feePctPerLeg: options.feePctPerLeg,
|
|
370
|
+
amountUsdc: options.amountUsdc,
|
|
371
|
+
},
|
|
372
|
+
opportunities: snapshots.flatMap((row) => row.opportunities),
|
|
373
|
+
snapshots,
|
|
374
|
+
diagnostics: [],
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (context.outputMode === 'json') {
|
|
378
|
+
emitSuccess(context.outputMode, 'arb.scan', payload);
|
|
379
|
+
} else {
|
|
380
|
+
// eslint-disable-next-line no-console
|
|
381
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
328
384
|
};
|
|
329
385
|
}
|
|
330
386
|
|
|
@@ -149,11 +149,16 @@ function createCommandExecutorService(options = {}) {
|
|
|
149
149
|
? Math.max(1_000, Math.trunc(runtime.timeoutMs))
|
|
150
150
|
: defaultTimeoutMs;
|
|
151
151
|
const env = runtime.env && typeof runtime.env === 'object' ? runtime.env : baseEnv;
|
|
152
|
+
const childEnv = {
|
|
153
|
+
...env,
|
|
154
|
+
// Mark child CLI invocations as MCP-controlled to enable stricter file/path safety guards.
|
|
155
|
+
PANDORA_MCP_MODE: '1',
|
|
156
|
+
};
|
|
152
157
|
const argv = ['--output', 'json', ...commandArgs];
|
|
153
158
|
|
|
154
159
|
const result = spawnSync(process.execPath, [cliPath, ...argv], {
|
|
155
160
|
encoding: 'utf8',
|
|
156
|
-
env,
|
|
161
|
+
env: childEnv,
|
|
157
162
|
timeout: timeoutMs,
|
|
158
163
|
killSignal: 'SIGKILL',
|
|
159
164
|
maxBuffer: 10 * 1024 * 1024,
|
|
@@ -99,6 +99,30 @@ function buildSportsResolvePlanRetryCommand(cliName, details) {
|
|
|
99
99
|
return `${cliName} sports resolve plan --event-id ${eventId}`;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
function buildLifecycleStatusCommand(cliName, details) {
|
|
103
|
+
const id = cleanToken(details && details.id, '<lifecycle-id>');
|
|
104
|
+
return `${cliName} lifecycle status --id ${id}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildLifecycleStartCommand(cliName, details) {
|
|
108
|
+
const configPath = cleanToken(details && details.configPath, '<config-file>');
|
|
109
|
+
return `${cliName} lifecycle start --config ${configPath}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildOddsRecordCommand(cliName, details) {
|
|
113
|
+
const competition = cleanToken(details && details.competition, '<competition>');
|
|
114
|
+
return `${cliName} odds record --competition ${competition} --interval 60 --max-samples 1`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildOddsHistoryCommand(cliName, details) {
|
|
118
|
+
const eventId = cleanToken(details && details.eventId, '<event-id>');
|
|
119
|
+
return `${cliName} odds history --event-id ${eventId} --output json`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildArbScanCommand(cliName) {
|
|
123
|
+
return `${cliName} arb scan --markets <market-a>,<market-b> --output json --iterations 1`;
|
|
124
|
+
}
|
|
125
|
+
|
|
102
126
|
/**
|
|
103
127
|
* Build deterministic Next-Best-Action recovery hints for JSON errors.
|
|
104
128
|
* @param {{cliName?: string}} [options]
|
|
@@ -264,6 +288,52 @@ function createErrorRecoveryService(options = {}) {
|
|
|
264
288
|
command: buildSportsResolvePlanRetryCommand(cliName, details),
|
|
265
289
|
retryable: true,
|
|
266
290
|
};
|
|
291
|
+
case 'LIFECYCLE_EXISTS':
|
|
292
|
+
return {
|
|
293
|
+
action: 'Inspect existing lifecycle state before creating another run',
|
|
294
|
+
command: buildLifecycleStatusCommand(cliName, details),
|
|
295
|
+
retryable: true,
|
|
296
|
+
};
|
|
297
|
+
case 'CONFIG_FILE_NOT_FOUND':
|
|
298
|
+
return {
|
|
299
|
+
action: 'Create/fix lifecycle config file and retry start',
|
|
300
|
+
command: buildLifecycleStartCommand(cliName, details),
|
|
301
|
+
retryable: true,
|
|
302
|
+
};
|
|
303
|
+
case 'LIFECYCLE_NOT_FOUND':
|
|
304
|
+
return {
|
|
305
|
+
action: 'Verify lifecycle id and inspect currently tracked runs',
|
|
306
|
+
command: `${cliName} lifecycle status --id <lifecycle-id>`,
|
|
307
|
+
retryable: true,
|
|
308
|
+
};
|
|
309
|
+
case 'ODDS_RECORD_CONNECTOR_FAILED':
|
|
310
|
+
case 'ODDS_RECORD_FAILED':
|
|
311
|
+
case 'ODDS_RECORD_WRITE_FAILED':
|
|
312
|
+
return {
|
|
313
|
+
action: 'Run one bounded odds capture sample to isolate connector/storage errors',
|
|
314
|
+
command: buildOddsRecordCommand(cliName, details),
|
|
315
|
+
retryable: true,
|
|
316
|
+
};
|
|
317
|
+
case 'ODDS_HISTORY_READ_FAILED':
|
|
318
|
+
case 'ODDS_HISTORY_FAILED':
|
|
319
|
+
return {
|
|
320
|
+
action: 'Retry odds history read with explicit event id and JSON output',
|
|
321
|
+
command: buildOddsHistoryCommand(cliName, details),
|
|
322
|
+
retryable: true,
|
|
323
|
+
};
|
|
324
|
+
case 'ARB_SCAN_FAILED':
|
|
325
|
+
case 'ARB_SCAN_INVALID_OUTPUT':
|
|
326
|
+
return {
|
|
327
|
+
action: 'Run a bounded arb scan iteration for deterministic diagnostics',
|
|
328
|
+
command: buildArbScanCommand(cliName),
|
|
329
|
+
retryable: true,
|
|
330
|
+
};
|
|
331
|
+
case 'MCP_FILE_ACCESS_BLOCKED':
|
|
332
|
+
return {
|
|
333
|
+
action: 'Use a workspace-relative file path when invoking tools via MCP',
|
|
334
|
+
command: `${cliName} help`,
|
|
335
|
+
retryable: true,
|
|
336
|
+
};
|
|
267
337
|
case 'MISSING_REQUIRED_FLAG':
|
|
268
338
|
case 'MISSING_FLAG_VALUE':
|
|
269
339
|
case 'INVALID_FLAG_VALUE':
|
|
@@ -59,7 +59,10 @@ function readJsonFile(filePath, CliError) {
|
|
|
59
59
|
raw = fs.readFileSync(filePath, 'utf8');
|
|
60
60
|
} catch (err) {
|
|
61
61
|
if (err && err.code === 'ENOENT') {
|
|
62
|
-
throw new CliError('
|
|
62
|
+
throw new CliError('LIFECYCLE_NOT_FOUND', `Lifecycle not found for id: ${path.basename(filePath, '.json')}`, {
|
|
63
|
+
id: path.basename(filePath, '.json'),
|
|
64
|
+
filePath,
|
|
65
|
+
});
|
|
63
66
|
}
|
|
64
67
|
throw err;
|
|
65
68
|
}
|
|
@@ -84,6 +87,15 @@ function writeJsonFileAtomic(filePath, payload) {
|
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
function isMcpMode() {
|
|
91
|
+
return String(process.env.PANDORA_MCP_MODE || '').trim() === '1';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isPathInside(baseDir, candidatePath) {
|
|
95
|
+
const relative = path.relative(baseDir, candidatePath);
|
|
96
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
function renderLifecycleTable(payload) {
|
|
88
100
|
// eslint-disable-next-line no-console
|
|
89
101
|
console.log(`Lifecycle: ${payload.id}`);
|
|
@@ -111,6 +123,24 @@ function createRunLifecycleCommand(deps) {
|
|
|
111
123
|
const commandHelpPayload = requireDep(deps, 'commandHelpPayload');
|
|
112
124
|
const parseLifecycleFlags = requireDep(deps, 'parseLifecycleFlags');
|
|
113
125
|
|
|
126
|
+
function assertMcpReadablePathAllowed(targetPath, flagName) {
|
|
127
|
+
if (!isMcpMode()) return;
|
|
128
|
+
const workspaceRoot = path.resolve(process.cwd());
|
|
129
|
+
const resolvedPath = path.resolve(targetPath);
|
|
130
|
+
if (!isPathInside(workspaceRoot, resolvedPath)) {
|
|
131
|
+
throw new CliError(
|
|
132
|
+
'MCP_FILE_ACCESS_BLOCKED',
|
|
133
|
+
`${flagName} must point to a file within the current workspace when running via MCP.`,
|
|
134
|
+
{
|
|
135
|
+
flag: flagName,
|
|
136
|
+
requestedPath: targetPath,
|
|
137
|
+
resolvedPath,
|
|
138
|
+
workspaceRoot,
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
114
144
|
return async function runLifecycleCommand(args, context) {
|
|
115
145
|
const action = args[0];
|
|
116
146
|
const actionArgs = args.slice(1);
|
|
@@ -165,12 +195,15 @@ function createRunLifecycleCommand(deps) {
|
|
|
165
195
|
|
|
166
196
|
if (options.action === 'start') {
|
|
167
197
|
const configPath = path.resolve(process.cwd(), options.configPath);
|
|
198
|
+
assertMcpReadablePathAllowed(configPath, '--config');
|
|
168
199
|
let configRaw;
|
|
169
200
|
try {
|
|
170
201
|
configRaw = fs.readFileSync(configPath, 'utf8');
|
|
171
202
|
} catch (err) {
|
|
172
203
|
if (err && err.code === 'ENOENT') {
|
|
173
|
-
throw new CliError('CONFIG_FILE_NOT_FOUND', `Lifecycle config file not found: ${configPath}
|
|
204
|
+
throw new CliError('CONFIG_FILE_NOT_FOUND', `Lifecycle config file not found: ${configPath}`, {
|
|
205
|
+
configPath,
|
|
206
|
+
});
|
|
174
207
|
}
|
|
175
208
|
throw err;
|
|
176
209
|
}
|
|
@@ -179,7 +212,9 @@ function createRunLifecycleCommand(deps) {
|
|
|
179
212
|
try {
|
|
180
213
|
config = JSON.parse(configRaw);
|
|
181
214
|
} catch {
|
|
182
|
-
throw new CliError('INVALID_JSON', `Lifecycle config must be valid JSON: ${configPath}
|
|
215
|
+
throw new CliError('INVALID_JSON', `Lifecycle config must be valid JSON: ${configPath}`, {
|
|
216
|
+
configPath,
|
|
217
|
+
});
|
|
183
218
|
}
|
|
184
219
|
|
|
185
220
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
@@ -197,7 +232,10 @@ function createRunLifecycleCommand(deps) {
|
|
|
197
232
|
|
|
198
233
|
const filePath = lifecycleFilePath(lifecycleDir, id);
|
|
199
234
|
if (fs.existsSync(filePath)) {
|
|
200
|
-
throw new CliError('LIFECYCLE_EXISTS', `Lifecycle already exists for id: ${id}
|
|
235
|
+
throw new CliError('LIFECYCLE_EXISTS', `Lifecycle already exists for id: ${id}`, {
|
|
236
|
+
id,
|
|
237
|
+
filePath,
|
|
238
|
+
});
|
|
201
239
|
}
|
|
202
240
|
|
|
203
241
|
const nowIso = new Date().toISOString();
|
|
@@ -243,8 +243,14 @@ const TOOL_DEFINITIONS = [
|
|
|
243
243
|
name: 'odds.record',
|
|
244
244
|
command: ['odds', 'record'],
|
|
245
245
|
description: 'Record venue odds snapshots into local history storage.',
|
|
246
|
+
longRunningBlocked: true,
|
|
246
247
|
mutating: true,
|
|
247
248
|
},
|
|
249
|
+
{
|
|
250
|
+
name: 'arb.scan',
|
|
251
|
+
command: ['arb', 'scan', '--output', 'json', '--iterations', '1'],
|
|
252
|
+
description: 'Run one bounded arb scan iteration and return a structured payload.',
|
|
253
|
+
},
|
|
248
254
|
{ name: 'lifecycle.status', command: ['lifecycle', 'status'], description: 'Inspect lifecycle state by id.' },
|
|
249
255
|
{
|
|
250
256
|
name: 'lifecycle.start',
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
function requireDep(deps, name) {
|
|
2
|
+
if (!deps || typeof deps[name] !== 'function') {
|
|
3
|
+
throw new Error(`createRunOddsCommand requires deps.${name}()`);
|
|
4
|
+
}
|
|
5
|
+
return deps[name];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const ODDS_RECORD_USAGE =
|
|
9
|
+
'pandora [--output table|json] odds record --competition <id> --interval <sec> [--max-samples <n>] [--event-id <id>] [--venues pandora_amm,polymarket] [--indexer-url <url>] [--polymarket-host <url>] [--polymarket-mock-url <url>] [--timeout-ms <ms>]';
|
|
10
|
+
const ODDS_HISTORY_USAGE =
|
|
11
|
+
'pandora [--output table|json] odds history --event-id <id> --output csv|json [--limit <n>]';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create runner for `pandora odds` commands.
|
|
15
|
+
* @param {object} deps
|
|
16
|
+
* @returns {(args: string[], context: {outputMode: 'table'|'json'}) => Promise<void>}
|
|
17
|
+
*/
|
|
18
|
+
function createRunOddsCommand(deps) {
|
|
19
|
+
const parseIndexerSharedFlags = requireDep(deps, 'parseIndexerSharedFlags');
|
|
20
|
+
const includesHelpFlag = requireDep(deps, 'includesHelpFlag');
|
|
21
|
+
const maybeLoadIndexerEnv = requireDep(deps, 'maybeLoadIndexerEnv');
|
|
22
|
+
const resolveIndexerUrl = requireDep(deps, 'resolveIndexerUrl');
|
|
23
|
+
const parseOddsFlags = requireDep(deps, 'parseOddsFlags');
|
|
24
|
+
const createOddsHistoryService = requireDep(deps, 'createOddsHistoryService');
|
|
25
|
+
const createVenueConnectorFactory = requireDep(deps, 'createVenueConnectorFactory');
|
|
26
|
+
const sleepMs = requireDep(deps, 'sleepMs');
|
|
27
|
+
const emitSuccess = requireDep(deps, 'emitSuccess');
|
|
28
|
+
const renderSingleEntityTable = requireDep(deps, 'renderSingleEntityTable');
|
|
29
|
+
|
|
30
|
+
return async function runOddsCommand(args, context) {
|
|
31
|
+
const shared = parseIndexerSharedFlags(args);
|
|
32
|
+
if (!shared.rest.length || includesHelpFlag(shared.rest)) {
|
|
33
|
+
if (context.outputMode === 'json') {
|
|
34
|
+
emitSuccess(context.outputMode, 'odds.help', {
|
|
35
|
+
usage: ODDS_RECORD_USAGE,
|
|
36
|
+
historyUsage: ODDS_HISTORY_USAGE,
|
|
37
|
+
});
|
|
38
|
+
} else {
|
|
39
|
+
// eslint-disable-next-line no-console
|
|
40
|
+
console.log(`Usage: ${ODDS_RECORD_USAGE}`);
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
console.log(` ${ODDS_HISTORY_USAGE}`);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
maybeLoadIndexerEnv(shared);
|
|
48
|
+
const indexerUrl = resolveIndexerUrl(shared.indexerUrl);
|
|
49
|
+
const parsed = parseOddsFlags(shared.rest);
|
|
50
|
+
const options = parsed.options || {};
|
|
51
|
+
|
|
52
|
+
const historyService = createOddsHistoryService();
|
|
53
|
+
const connectorFactory = createVenueConnectorFactory();
|
|
54
|
+
|
|
55
|
+
if (parsed.action === 'record') {
|
|
56
|
+
const intervalMs = Number(options.intervalSec) * 1000;
|
|
57
|
+
const maxSamples = Number.isInteger(options.maxSamples) && options.maxSamples > 0 ? options.maxSamples : 1;
|
|
58
|
+
const sampleResults = [];
|
|
59
|
+
let insertedTotal = 0;
|
|
60
|
+
|
|
61
|
+
for (let sample = 1; sample <= maxSamples; sample += 1) {
|
|
62
|
+
const rows = [];
|
|
63
|
+
const diagnostics = [];
|
|
64
|
+
for (const venue of options.venues) {
|
|
65
|
+
try {
|
|
66
|
+
const connector = connectorFactory.createConnector(venue, {
|
|
67
|
+
indexerUrl,
|
|
68
|
+
host: options.polymarketHost || null,
|
|
69
|
+
mockUrl: options.polymarketMockUrl || null,
|
|
70
|
+
timeoutMs: options.timeoutMs || shared.timeoutMs,
|
|
71
|
+
});
|
|
72
|
+
const pricePayload = await connector.getPrice({
|
|
73
|
+
competition: options.competition,
|
|
74
|
+
eventId: options.eventId,
|
|
75
|
+
indexerUrl,
|
|
76
|
+
host: options.polymarketHost || null,
|
|
77
|
+
mockUrl: options.polymarketMockUrl || null,
|
|
78
|
+
timeoutMs: options.timeoutMs || shared.timeoutMs,
|
|
79
|
+
});
|
|
80
|
+
if (pricePayload && Array.isArray(pricePayload.items)) {
|
|
81
|
+
rows.push(...pricePayload.items);
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
diagnostics.push({
|
|
85
|
+
venue,
|
|
86
|
+
code: err && err.code ? String(err.code) : 'ODDS_RECORD_CONNECTOR_FAILED',
|
|
87
|
+
message: err && err.message ? err.message : String(err),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const writeResult = historyService.recordEntries(rows);
|
|
93
|
+
insertedTotal += writeResult.inserted;
|
|
94
|
+
sampleResults.push({
|
|
95
|
+
sample,
|
|
96
|
+
observedAt: new Date().toISOString(),
|
|
97
|
+
inserted: writeResult.inserted,
|
|
98
|
+
diagnostics,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (sample < maxSamples) {
|
|
102
|
+
await sleepMs(intervalMs);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
emitSuccess(context.outputMode, 'odds.record', {
|
|
107
|
+
schemaVersion: '1.0.0',
|
|
108
|
+
generatedAt: new Date().toISOString(),
|
|
109
|
+
action: 'record',
|
|
110
|
+
competition: options.competition,
|
|
111
|
+
eventId: options.eventId || null,
|
|
112
|
+
intervalSec: options.intervalSec,
|
|
113
|
+
maxSamples,
|
|
114
|
+
venues: options.venues,
|
|
115
|
+
backend: historyService.backend,
|
|
116
|
+
storage: historyService.paths,
|
|
117
|
+
insertedTotal,
|
|
118
|
+
samples: sampleResults,
|
|
119
|
+
}, renderSingleEntityTable);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rows = historyService.queryByEventId(options.eventId, {
|
|
124
|
+
limit: options.limit,
|
|
125
|
+
});
|
|
126
|
+
const basePayload = {
|
|
127
|
+
schemaVersion: '1.0.0',
|
|
128
|
+
generatedAt: new Date().toISOString(),
|
|
129
|
+
action: 'history',
|
|
130
|
+
eventId: options.eventId,
|
|
131
|
+
backend: historyService.backend,
|
|
132
|
+
storage: historyService.paths,
|
|
133
|
+
count: rows.length,
|
|
134
|
+
items: rows,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (options.output === 'csv') {
|
|
138
|
+
const csv = historyService.formatRows(rows, 'csv');
|
|
139
|
+
if (context.outputMode === 'json') {
|
|
140
|
+
emitSuccess(context.outputMode, 'odds.history', {
|
|
141
|
+
...basePayload,
|
|
142
|
+
output: 'csv',
|
|
143
|
+
csv,
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
// eslint-disable-next-line no-console
|
|
147
|
+
console.log(csv);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (context.outputMode === 'json') {
|
|
153
|
+
emitSuccess(context.outputMode, 'odds.history', {
|
|
154
|
+
...basePayload,
|
|
155
|
+
output: 'json',
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.log(JSON.stringify(basePayload, null, 2));
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
createRunOddsCommand,
|
|
166
|
+
ODDS_RECORD_USAGE,
|
|
167
|
+
ODDS_HISTORY_USAGE,
|
|
168
|
+
};
|
|
@@ -29,6 +29,7 @@ function createParseOddsFlags(deps) {
|
|
|
29
29
|
const requireFlagValue = requireDep(deps, 'requireFlagValue');
|
|
30
30
|
const parsePositiveInteger = requireDep(deps, 'parsePositiveInteger');
|
|
31
31
|
const parseCsvList = requireDep(deps, 'parseCsvList');
|
|
32
|
+
const isSecureHttpUrlOrLocal = requireDep(deps, 'isSecureHttpUrlOrLocal');
|
|
32
33
|
|
|
33
34
|
return function parseOddsFlags(args) {
|
|
34
35
|
const action = String((args && args[0]) || '').trim().toLowerCase();
|
|
@@ -79,16 +80,34 @@ function createParseOddsFlags(deps) {
|
|
|
79
80
|
}
|
|
80
81
|
if (token === '--indexer-url') {
|
|
81
82
|
options.indexerUrl = requireFlagValue(rest, i, '--indexer-url');
|
|
83
|
+
if (!isSecureHttpUrlOrLocal(options.indexerUrl)) {
|
|
84
|
+
throw new CliError(
|
|
85
|
+
'INVALID_FLAG_VALUE',
|
|
86
|
+
`--indexer-url must use https:// (or http://localhost/127.0.0.1). Received: "${options.indexerUrl}"`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
82
89
|
i += 1;
|
|
83
90
|
continue;
|
|
84
91
|
}
|
|
85
92
|
if (token === '--polymarket-host') {
|
|
86
93
|
options.polymarketHost = requireFlagValue(rest, i, '--polymarket-host');
|
|
94
|
+
if (!isSecureHttpUrlOrLocal(options.polymarketHost)) {
|
|
95
|
+
throw new CliError(
|
|
96
|
+
'INVALID_FLAG_VALUE',
|
|
97
|
+
`--polymarket-host must use https:// (or http://localhost/127.0.0.1). Received: "${options.polymarketHost}"`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
87
100
|
i += 1;
|
|
88
101
|
continue;
|
|
89
102
|
}
|
|
90
103
|
if (token === '--polymarket-mock-url') {
|
|
91
104
|
options.polymarketMockUrl = requireFlagValue(rest, i, '--polymarket-mock-url');
|
|
105
|
+
if (!isSecureHttpUrlOrLocal(options.polymarketMockUrl)) {
|
|
106
|
+
throw new CliError(
|
|
107
|
+
'INVALID_FLAG_VALUE',
|
|
108
|
+
`--polymarket-mock-url must use https:// (or http://localhost/127.0.0.1). Received: "${options.polymarketMockUrl}"`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
92
111
|
i += 1;
|
|
93
112
|
continue;
|
|
94
113
|
}
|