quantpipe 0.1.0__py3-none-any.whl
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.
- quantpipe/__init__.py +3 -0
- quantpipe/__main__.py +7 -0
- quantpipe/cli.py +49 -0
- quantpipe/cli_exit.py +15 -0
- quantpipe/commands/__init__.py +1 -0
- quantpipe/commands/backtest.py +285 -0
- quantpipe/commands/io_util.py +60 -0
- quantpipe/commands/paper.py +371 -0
- quantpipe/commands/scan.py +345 -0
- quantpipe/commands/signal.py +200 -0
- quantpipe/core/__init__.py +26 -0
- quantpipe/core/adapter.py +689 -0
- quantpipe/core/backtest.py +229 -0
- quantpipe/core/data.py +132 -0
- quantpipe/core/indicators.py +89 -0
- quantpipe/core/pipeline_json.py +83 -0
- quantpipe/core/strategy.py +100 -0
- quantpipe/strategies/__init__.py +13 -0
- quantpipe/strategies/ma_cross.py +97 -0
- quantpipe/strategies/macd.py +96 -0
- quantpipe/strategies/rsi.py +82 -0
- quantpipe/types.py +104 -0
- quantpipe-0.1.0.dist-info/METADATA +311 -0
- quantpipe-0.1.0.dist-info/RECORD +27 -0
- quantpipe-0.1.0.dist-info/WHEEL +5 -0
- quantpipe-0.1.0.dist-info/entry_points.txt +2 -0
- quantpipe-0.1.0.dist-info/top_level.txt +1 -0
quantpipe/__init__.py
ADDED
quantpipe/__main__.py
ADDED
quantpipe/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI entry point for QuantPipe."""
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from .cli_exit import EXIT_CODE_HELP
|
|
7
|
+
from .commands.backtest import backtest_command
|
|
8
|
+
from .commands.signal import signal_command
|
|
9
|
+
from .commands.scan import scan_command
|
|
10
|
+
from .commands.paper import paper_command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group(epilog=EXIT_CODE_HELP)
|
|
14
|
+
@click.version_option(version='0.1.0', prog_name='quantpipe')
|
|
15
|
+
def cli():
|
|
16
|
+
"""QuantPipe - Quantitative trading toolkit for LLM AI.
|
|
17
|
+
|
|
18
|
+
Designed to work seamlessly with seamflux CLI for market data via Unix pipes.
|
|
19
|
+
|
|
20
|
+
Auto-detects data formats: Binance, Polymarket, Uniswap, generic OHLCV.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
# Generate signal from Binance
|
|
24
|
+
seamflux invoke binance fetchOhlcv -p symbol=BTC/USDT -p timeframe=1h | quantpipe signal --strategy ma_cross --stdin
|
|
25
|
+
|
|
26
|
+
# Generate signal from Polymarket
|
|
27
|
+
seamflux invoke polymarket getPriceHistory -p market=<token_id> -p interval=1h | quantpipe signal --strategy rsi --stdin
|
|
28
|
+
|
|
29
|
+
# Run backtest
|
|
30
|
+
quantpipe backtest --data btc_history.json --strategy rsi --json
|
|
31
|
+
|
|
32
|
+
# Scan multiple symbols
|
|
33
|
+
seamflux invoke binance fetchTickers | quantpipe scan --strategy ma_cross --filter-signal BUY
|
|
34
|
+
|
|
35
|
+
# Paper trading with Polymarket
|
|
36
|
+
quantpipe paper --exec "seamflux invoke polymarket getMarket -p slug=<market_slug>" --strategy ma_cross
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Register commands
|
|
42
|
+
cli.add_command(backtest_command)
|
|
43
|
+
cli.add_command(signal_command)
|
|
44
|
+
cli.add_command(scan_command)
|
|
45
|
+
cli.add_command(paper_command)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == '__main__':
|
|
49
|
+
cli()
|
quantpipe/cli_exit.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Process exit codes aligned with repo rule.md (§3.3)."""
|
|
2
|
+
|
|
3
|
+
# 0 success; 1 generic (args, IO); 2 input/schema mismatch; 3 upstream unavailable
|
|
4
|
+
EXIT_OK = 0
|
|
5
|
+
EXIT_GENERAL = 1
|
|
6
|
+
EXIT_SCHEMA = 2
|
|
7
|
+
EXIT_UPSTREAM = 3
|
|
8
|
+
|
|
9
|
+
EXIT_CODE_HELP = """
|
|
10
|
+
Exit codes:
|
|
11
|
+
0 Success
|
|
12
|
+
1 General failure (invalid arguments, I/O error)
|
|
13
|
+
2 Input format / schema mismatch
|
|
14
|
+
3 Upstream dependency unavailable (e.g. subprocess failed, missing external tool)
|
|
15
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands for QuantPipe."""
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Backtest command for QuantPipe."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from ..cli_exit import EXIT_GENERAL, EXIT_SCHEMA
|
|
13
|
+
from ..core.backtest import BacktestEngine
|
|
14
|
+
from ..core.data import prepare_data
|
|
15
|
+
from ..core.pipeline_json import emit_json_line, failure_envelope, success_envelope, utc_now_iso
|
|
16
|
+
from ..strategies import STRATEGIES
|
|
17
|
+
from .io_util import QuantpipeCliError, configure_verbose, load_cli_input_dict
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_strategy_params(params: tuple) -> dict:
|
|
23
|
+
strategy_params = {}
|
|
24
|
+
for param in params:
|
|
25
|
+
if "=" not in param:
|
|
26
|
+
raise QuantpipeCliError(EXIT_GENERAL, f"Invalid param format '{param}'. Use key=value", "INVALID_PARAM")
|
|
27
|
+
key, value = param.split("=", 1)
|
|
28
|
+
try:
|
|
29
|
+
if "." in value:
|
|
30
|
+
value = float(value)
|
|
31
|
+
else:
|
|
32
|
+
value = int(value)
|
|
33
|
+
except ValueError:
|
|
34
|
+
pass
|
|
35
|
+
strategy_params[key] = value
|
|
36
|
+
return strategy_params
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_run_output_dir(run_dir: Optional[str], run_id: Optional[str]) -> Optional[str]:
|
|
40
|
+
if run_dir:
|
|
41
|
+
return os.path.abspath(run_dir)
|
|
42
|
+
if run_id:
|
|
43
|
+
return os.path.abspath(os.path.join(os.getcwd(), "runs", run_id))
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _artifact_rel_paths(files: Dict[str, str]) -> Dict[str, str]:
|
|
48
|
+
cwd = os.getcwd()
|
|
49
|
+
out: Dict[str, str] = {}
|
|
50
|
+
for k, p in files.items():
|
|
51
|
+
try:
|
|
52
|
+
out[k] = os.path.relpath(p, cwd)
|
|
53
|
+
except ValueError:
|
|
54
|
+
out[k] = p
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_equity_csv(engine: BacktestEngine, path: str) -> None:
|
|
59
|
+
idx = engine.df.index
|
|
60
|
+
eq = engine.equity_curve
|
|
61
|
+
if len(idx) == 0:
|
|
62
|
+
pd.DataFrame(columns=["timestamp", "equity"]).to_csv(path, index=False)
|
|
63
|
+
return
|
|
64
|
+
if len(eq) != len(idx):
|
|
65
|
+
n = min(len(idx), len(eq))
|
|
66
|
+
idx = idx[:n]
|
|
67
|
+
eq = eq[:n]
|
|
68
|
+
df = pd.DataFrame({"timestamp": idx, "equity": eq})
|
|
69
|
+
df.to_csv(path, index=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_trades_csv(engine: BacktestEngine, path: str) -> None:
|
|
73
|
+
rows = []
|
|
74
|
+
for t in engine.trades:
|
|
75
|
+
rows.append(
|
|
76
|
+
{
|
|
77
|
+
"timestamp": t.exit_time.isoformat(),
|
|
78
|
+
"entry_time": t.entry_time.isoformat(),
|
|
79
|
+
"side": t.side,
|
|
80
|
+
"price": t.exit_price,
|
|
81
|
+
"entry_price": t.entry_price,
|
|
82
|
+
"exit_price": t.exit_price,
|
|
83
|
+
"size": t.size,
|
|
84
|
+
"pnl": t.pnl,
|
|
85
|
+
"pnl_pct": t.pnl_pct,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
pd.DataFrame(rows).to_csv(path, index=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _package_version() -> str:
|
|
92
|
+
try:
|
|
93
|
+
import importlib.metadata as im
|
|
94
|
+
|
|
95
|
+
return im.version("quantpipe")
|
|
96
|
+
except Exception:
|
|
97
|
+
return "0.1.0"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@click.command(name="backtest")
|
|
101
|
+
@click.option("--data", "-d", "data_path", type=str, default=None, help="Data file path (JSON)")
|
|
102
|
+
@click.option("--input", "-i", "input_path", type=str, default=None, help="Alias for --data")
|
|
103
|
+
@click.option("--stdin", "use_stdin", is_flag=True, help="Read data from stdin")
|
|
104
|
+
@click.option("--strategy", "-s", required=True, help="Strategy name")
|
|
105
|
+
@click.option("--param", "params", multiple=True, help="Strategy parameters (key=value)")
|
|
106
|
+
@click.option("--json", "output_json", is_flag=True, help="Machine-readable JSON envelope on stdout")
|
|
107
|
+
@click.option("--pretty", is_flag=True, help="Pretty-print JSON (not for pipes)")
|
|
108
|
+
@click.option("--verbose", "-v", is_flag=True, help="Debug logs on stderr")
|
|
109
|
+
@click.option("--dry-run", is_flag=True, help="Validate input and strategy; do not run backtest or write files")
|
|
110
|
+
@click.option(
|
|
111
|
+
"--run-dir",
|
|
112
|
+
type=click.Path(file_okay=False, dir_okay=True, writable=True, path_type=str),
|
|
113
|
+
default=None,
|
|
114
|
+
help="Write trades.csv, equity.csv, summary.json, manifest.json under this directory",
|
|
115
|
+
)
|
|
116
|
+
@click.option("--run-id", type=str, default=None, help="Write artifacts to runs/<run-id>/ (ignored if --run-dir set)")
|
|
117
|
+
def backtest_command(
|
|
118
|
+
data_path: Optional[str],
|
|
119
|
+
input_path: Optional[str],
|
|
120
|
+
use_stdin: bool,
|
|
121
|
+
strategy: str,
|
|
122
|
+
params: tuple,
|
|
123
|
+
output_json: bool,
|
|
124
|
+
pretty: bool,
|
|
125
|
+
verbose: bool,
|
|
126
|
+
dry_run: bool,
|
|
127
|
+
run_dir: Optional[str],
|
|
128
|
+
run_id: Optional[str],
|
|
129
|
+
):
|
|
130
|
+
"""Run backtest on historical data."""
|
|
131
|
+
configure_verbose(verbose)
|
|
132
|
+
command = "backtest"
|
|
133
|
+
path = input_path or data_path
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
strategy_params = _parse_strategy_params(params)
|
|
137
|
+
except QuantpipeCliError as e:
|
|
138
|
+
click.echo(e.message, err=True)
|
|
139
|
+
if output_json or pretty:
|
|
140
|
+
emit_json_line(failure_envelope(command, e.error_code, e.message), pretty=pretty)
|
|
141
|
+
sys.exit(e.exit_code)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
input_data = load_cli_input_dict(use_stdin=use_stdin, path=path)
|
|
145
|
+
except QuantpipeCliError as e:
|
|
146
|
+
click.echo(e.message, err=True)
|
|
147
|
+
if output_json or pretty:
|
|
148
|
+
emit_json_line(failure_envelope(command, e.error_code, e.message), pretty=pretty)
|
|
149
|
+
sys.exit(e.exit_code)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
df, metadata = prepare_data(input_data)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
click.echo(f"Error preparing data: {e}", err=True)
|
|
155
|
+
if output_json or pretty:
|
|
156
|
+
emit_json_line(failure_envelope(command, "INVALID_INPUT", str(e)), pretty=pretty)
|
|
157
|
+
sys.exit(EXIT_SCHEMA)
|
|
158
|
+
|
|
159
|
+
if strategy not in STRATEGIES:
|
|
160
|
+
msg = f"Unknown strategy '{strategy}'. Available: {', '.join(STRATEGIES.keys())}"
|
|
161
|
+
click.echo(f"Error: {msg}", err=True)
|
|
162
|
+
if output_json or pretty:
|
|
163
|
+
emit_json_line(failure_envelope(command, "INVALID_INPUT", msg), pretty=pretty)
|
|
164
|
+
sys.exit(EXIT_GENERAL)
|
|
165
|
+
|
|
166
|
+
StrategyClass = STRATEGIES[strategy]
|
|
167
|
+
strat = StrategyClass(params=strategy_params)
|
|
168
|
+
|
|
169
|
+
started = utc_now_iso()
|
|
170
|
+
out_dir = _resolve_run_output_dir(run_dir, run_id)
|
|
171
|
+
effective_run_id = run_id if run_id else None
|
|
172
|
+
|
|
173
|
+
data_body: Dict[str, Any] = {
|
|
174
|
+
"source": {
|
|
175
|
+
"symbol": metadata.get("symbol"),
|
|
176
|
+
"market_type": metadata.get("market_type", "unknown"),
|
|
177
|
+
"type": metadata.get("type"),
|
|
178
|
+
"bars": len(df),
|
|
179
|
+
"period": {
|
|
180
|
+
"start": metadata.get("start"),
|
|
181
|
+
"end": metadata.get("end"),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
"strategy": {"name": strategy, "params": strat.params},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if dry_run:
|
|
188
|
+
data_body["dryRun"] = True
|
|
189
|
+
if output_json or pretty:
|
|
190
|
+
emit_json_line(
|
|
191
|
+
success_envelope(
|
|
192
|
+
command,
|
|
193
|
+
data_body,
|
|
194
|
+
run_id=effective_run_id,
|
|
195
|
+
meta={"startedAt": started, "finishedAt": utc_now_iso()},
|
|
196
|
+
),
|
|
197
|
+
pretty=pretty,
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
click.echo("\nDry run OK")
|
|
201
|
+
click.echo(f"Strategy: {strategy} {strat.params}")
|
|
202
|
+
click.echo(f"Bars: {len(df)}")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
engine = BacktestEngine(strat, df)
|
|
206
|
+
metrics = engine.run()
|
|
207
|
+
data_body["performance"] = metrics
|
|
208
|
+
|
|
209
|
+
artifacts_abs: Dict[str, str] = {}
|
|
210
|
+
if out_dir:
|
|
211
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
212
|
+
trades_p = os.path.join(out_dir, "trades.csv")
|
|
213
|
+
equity_p = os.path.join(out_dir, "equity.csv")
|
|
214
|
+
summary_p = os.path.join(out_dir, "summary.json")
|
|
215
|
+
manifest_p = os.path.join(out_dir, "manifest.json")
|
|
216
|
+
|
|
217
|
+
_write_trades_csv(engine, trades_p)
|
|
218
|
+
_write_equity_csv(engine, equity_p)
|
|
219
|
+
artifacts_abs["trades"] = trades_p
|
|
220
|
+
artifacts_abs["equity"] = equity_p
|
|
221
|
+
|
|
222
|
+
summary_payload = dict(data_body)
|
|
223
|
+
with open(summary_p, "w", encoding="utf-8") as f:
|
|
224
|
+
json.dump(summary_payload, f, indent=2, default=str)
|
|
225
|
+
artifacts_abs["summary"] = summary_p
|
|
226
|
+
|
|
227
|
+
manifest = {
|
|
228
|
+
"schemaVersion": 1,
|
|
229
|
+
"tool": "quantpipe",
|
|
230
|
+
"command": command,
|
|
231
|
+
"quantpipeVersion": _package_version(),
|
|
232
|
+
"strategy": strategy,
|
|
233
|
+
"strategyParams": strat.params,
|
|
234
|
+
"input": {"stdin": use_stdin, "path": path},
|
|
235
|
+
"runId": effective_run_id,
|
|
236
|
+
}
|
|
237
|
+
with open(manifest_p, "w", encoding="utf-8") as f:
|
|
238
|
+
json.dump(manifest, f, indent=2, default=str)
|
|
239
|
+
artifacts_abs["manifest"] = manifest_p
|
|
240
|
+
log.debug("Wrote artifacts under %s", out_dir)
|
|
241
|
+
|
|
242
|
+
finished = utc_now_iso()
|
|
243
|
+
artifacts_rel = _artifact_rel_paths(artifacts_abs) if artifacts_abs else None
|
|
244
|
+
|
|
245
|
+
if output_json or pretty:
|
|
246
|
+
emit_json_line(
|
|
247
|
+
success_envelope(
|
|
248
|
+
command,
|
|
249
|
+
data_body,
|
|
250
|
+
run_id=effective_run_id,
|
|
251
|
+
artifacts=artifacts_rel,
|
|
252
|
+
meta={"startedAt": started, "finishedAt": finished},
|
|
253
|
+
),
|
|
254
|
+
pretty=pretty,
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
click.echo(f"\nBacktest Results")
|
|
258
|
+
click.echo(f"================")
|
|
259
|
+
click.echo(f"Strategy: {strategy} {strat.params}")
|
|
260
|
+
click.echo(f"Data Format: {metadata.get('format', 'unknown')}")
|
|
261
|
+
click.echo(f"Market Type: {metadata.get('market_type', 'unknown')}")
|
|
262
|
+
click.echo(f"Symbol: {metadata.get('symbol', 'N/A')}")
|
|
263
|
+
click.echo(f"Period: {metadata.get('start')} to {metadata.get('end')}")
|
|
264
|
+
click.echo(f"Bars: {len(df)}")
|
|
265
|
+
if metadata.get("synthesized"):
|
|
266
|
+
click.echo(
|
|
267
|
+
f"Synthesized: {metadata.get('original_points')} price points -> OHLCV ({metadata.get('interval')})"
|
|
268
|
+
)
|
|
269
|
+
click.echo(f"")
|
|
270
|
+
click.echo(f"Performance:")
|
|
271
|
+
click.echo(f" Total Trades: {metrics['total_trades']}")
|
|
272
|
+
click.echo(f" Win Rate: {metrics['win_rate']:.2%}")
|
|
273
|
+
click.echo(f" Total Return: {metrics['total_return']:.2%}")
|
|
274
|
+
click.echo(f" Max Drawdown: {metrics['max_drawdown']:.2%}")
|
|
275
|
+
click.echo(f" Sharpe Ratio: {metrics['sharpe_ratio']:.2f}")
|
|
276
|
+
pf = metrics.get("profit_factor")
|
|
277
|
+
if pf is not None:
|
|
278
|
+
click.echo(f" Profit Factor: {pf:.2f}")
|
|
279
|
+
else:
|
|
280
|
+
click.echo(f" Profit Factor: N/A (no losing trades)")
|
|
281
|
+
if artifacts_abs:
|
|
282
|
+
click.echo("")
|
|
283
|
+
click.echo("Artifacts:")
|
|
284
|
+
for k, v in _artifact_rel_paths(artifacts_abs).items():
|
|
285
|
+
click.echo(f" {k}: {v}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared CLI input loading and logging setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from ..cli_exit import EXIT_GENERAL, EXIT_SCHEMA
|
|
11
|
+
from ..core.pipeline_json import unwrap_upstream_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QuantpipeCliError(Exception):
|
|
15
|
+
"""Raised for predictable CLI failures (carry exit code for rule.md)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, exit_code: int, message: str, error_code: str = "ERROR") -> None:
|
|
18
|
+
self.exit_code = exit_code
|
|
19
|
+
self.message = message
|
|
20
|
+
self.error_code = error_code
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def configure_verbose(verbose: bool) -> None:
|
|
25
|
+
"""Route debug logs to stderr; avoid polluting stdout JSON."""
|
|
26
|
+
if verbose:
|
|
27
|
+
logging.basicConfig(
|
|
28
|
+
level=logging.DEBUG,
|
|
29
|
+
format="%(levelname)s %(message)s",
|
|
30
|
+
stream=sys.stderr,
|
|
31
|
+
force=True,
|
|
32
|
+
)
|
|
33
|
+
else:
|
|
34
|
+
logging.basicConfig(level=logging.WARNING, stream=sys.stderr, force=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_cli_input_dict(*, use_stdin: bool, path: Optional[str]) -> Dict[str, Any]:
|
|
38
|
+
"""
|
|
39
|
+
Load JSON object from stdin or file; unwrap seamflux ``{"result": {...}}`` once.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
if use_stdin or path is None:
|
|
43
|
+
raw: Any = json.load(sys.stdin)
|
|
44
|
+
else:
|
|
45
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
46
|
+
raw = json.load(f)
|
|
47
|
+
except FileNotFoundError as e:
|
|
48
|
+
raise QuantpipeCliError(EXIT_GENERAL, str(e), "IO_ERROR") from e
|
|
49
|
+
except json.JSONDecodeError as e:
|
|
50
|
+
raise QuantpipeCliError(EXIT_SCHEMA, f"Invalid JSON: {e}", "INVALID_JSON") from e
|
|
51
|
+
except OSError as e:
|
|
52
|
+
raise QuantpipeCliError(EXIT_GENERAL, f"I/O error: {e}", "IO_ERROR") from e
|
|
53
|
+
|
|
54
|
+
if not isinstance(raw, dict):
|
|
55
|
+
raise QuantpipeCliError(EXIT_SCHEMA, "Top-level JSON must be an object", "INVALID_INPUT")
|
|
56
|
+
|
|
57
|
+
unwrapped = unwrap_upstream_json(raw)
|
|
58
|
+
if not isinstance(unwrapped, dict):
|
|
59
|
+
raise QuantpipeCliError(EXIT_SCHEMA, "Unwrapped payload must be an object", "INVALID_INPUT")
|
|
60
|
+
return unwrapped
|