chartpipe 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.
chartpipe/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """ChartPipe - Chart visualization toolkit for quantitative trading analysis.
2
+
3
+ Designed to work seamlessly with seamflux CLI and quantpipe.
4
+ """
5
+
6
+ __version__ = '0.1.0'
chartpipe/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """Entry point for python -m chartpipe."""
3
+
4
+ from .cli import cli
5
+
6
+ if __name__ == '__main__':
7
+ cli()
chartpipe/cli.py ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python3
2
+ """CLI entry point for ChartPipe."""
3
+
4
+ import logging
5
+ import sys
6
+
7
+ import click
8
+
9
+ from .cli_exit import EXIT_CODE_HELP
10
+ from .commands.ohlcv import ohlcv_command
11
+ from .commands.backtest import backtest_chart_command
12
+ from .commands.indicators import indicators_command
13
+ from .commands.stats import stats_command
14
+
15
+
16
+ def _configure_verbose(verbose: bool) -> None:
17
+ if verbose:
18
+ logging.basicConfig(
19
+ level=logging.DEBUG,
20
+ format="%(levelname)s %(name)s: %(message)s",
21
+ stream=sys.stderr,
22
+ )
23
+ else:
24
+ logging.basicConfig(level=logging.WARNING, handlers=[logging.NullHandler()])
25
+
26
+
27
+ @click.group(epilog=EXIT_CODE_HELP)
28
+ @click.version_option(version="0.1.0", prog_name="chartpipe")
29
+ @click.option(
30
+ "--json",
31
+ "json_output",
32
+ is_flag=True,
33
+ default=False,
34
+ help="Output results as JSON for pipeline integration",
35
+ )
36
+ @click.option(
37
+ "--output-dir",
38
+ "-o",
39
+ default="./charts",
40
+ help="Directory to save generated charts",
41
+ )
42
+ @click.option("--verbose", "-v", is_flag=True, help="Debug logs on stderr")
43
+ @click.option(
44
+ "--dry-run",
45
+ is_flag=True,
46
+ help="Validate input only; do not write chart files (subcommands may still parse data)",
47
+ )
48
+ @click.pass_context
49
+ def cli(ctx, json_output, output_dir, verbose, dry_run):
50
+ """ChartPipe - Chart visualization toolkit for quantitative trading.
51
+
52
+ Designed to work seamlessly with seamflux CLI and quantpipe via Unix pipes.
53
+
54
+ Auto-detects data formats: Binance, Polymarket, Uniswap, generic OHLCV.
55
+
56
+ See repository rule.md for pipeline JSON contract (schemaVersion, artifacts, exit codes).
57
+
58
+ Examples:
59
+
60
+ seamflux service invoke binance fetchOhlcv --params symbol=BTCUSDT | chartpipe ohlcv --stdin
61
+
62
+ quantpipe backtest ... --run-dir runs/id --json
63
+ chartpipe backtest --run-dir runs/id --json
64
+
65
+ chartpipe ohlcv --stdin --json
66
+ """
67
+ _configure_verbose(verbose)
68
+ ctx.ensure_object(dict)
69
+ ctx.obj["json_output"] = json_output
70
+ ctx.obj["output_dir"] = output_dir
71
+ ctx.obj["verbose"] = verbose
72
+ ctx.obj["dry_run"] = dry_run
73
+
74
+
75
+ # Register commands
76
+ cli.add_command(ohlcv_command)
77
+ cli.add_command(backtest_chart_command)
78
+ cli.add_command(indicators_command)
79
+ cli.add_command(stats_command)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ cli()
chartpipe/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. missing optional package)
15
+ """
@@ -0,0 +1 @@
1
+ """CLI commands for chartpipe."""
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Backtest visualization command."""
3
+
4
+ import logging
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+ from ..cli_exit import EXIT_GENERAL, EXIT_SCHEMA
11
+ from ..core.backtest_input import (
12
+ load_from_run_dir,
13
+ parse_backtest_payload,
14
+ title_from_summary,
15
+ )
16
+ from ..core.charts import ChartEngine
17
+ from ..core.pipeline_json import emit_json_line, failure_envelope, success_envelope, utc_now_iso
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ @click.command(name="backtest")
23
+ @click.option("--data", "-d", type=click.Path(exists=True), help="Path to backtest result file (JSON)")
24
+ @click.option("--input", "input_file", type=click.Path(exists=True), help="Alias for --data")
25
+ @click.option("--stdin", "use_stdin", is_flag=True, help="Read data from stdin")
26
+ @click.option(
27
+ "--run-dir",
28
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=str),
29
+ default=None,
30
+ help="Load equity.csv and optional trades.csv from a runs/<id>/ directory",
31
+ )
32
+ @click.option("--title", "-t", default=None, help="Chart title (default: from summary.json or generic)")
33
+ @click.option("--theme", default="dark", type=click.Choice(["dark", "light"]), help="Chart theme")
34
+ @click.option(
35
+ "--output-dir",
36
+ "-o",
37
+ "output_dir_flag",
38
+ default=None,
39
+ help="Same as global chartpipe --output-dir (directory to save charts)",
40
+ )
41
+ @click.option(
42
+ "--json",
43
+ "json_flag",
44
+ is_flag=True,
45
+ default=False,
46
+ help="Same as global chartpipe --json (machine-readable stdout)",
47
+ )
48
+ @click.option(
49
+ "--dry-run",
50
+ "dry_run_flag",
51
+ is_flag=True,
52
+ default=False,
53
+ help="Same as global chartpipe --dry-run (validate only, no chart file)",
54
+ )
55
+ @click.pass_context
56
+ def backtest_chart_command(
57
+ ctx,
58
+ data,
59
+ input_file,
60
+ use_stdin,
61
+ run_dir,
62
+ title,
63
+ theme,
64
+ output_dir_flag,
65
+ json_flag,
66
+ dry_run_flag,
67
+ ):
68
+ """Generate backtest visualization with equity curve, drawdown, and trades."""
69
+ json_output = bool(ctx.obj.get("json_output", False) or json_flag)
70
+ output_dir = output_dir_flag or ctx.obj.get("output_dir", "./charts")
71
+ dry_run = bool(ctx.obj.get("dry_run", False) or dry_run_flag)
72
+ command = "backtest"
73
+ path = input_file or data
74
+ cwd = os.getcwd()
75
+
76
+ def fail_json(code: str, msg: str, exit_code: int) -> None:
77
+ click.echo(msg, err=True)
78
+ if json_output:
79
+ emit_json_line(failure_envelope(command, code, msg))
80
+ ctx.exit(exit_code)
81
+
82
+ started = utc_now_iso()
83
+ df = None
84
+ trades = []
85
+ summary_block = None
86
+
87
+ try:
88
+ if run_dir:
89
+ df, trades, meta = load_from_run_dir(run_dir)
90
+ summary_block = meta.get("summary")
91
+ log.debug("Loaded backtest data from run-dir %s", run_dir)
92
+ elif use_stdin or not path:
93
+ input_text = sys.stdin.read()
94
+ if not input_text.strip():
95
+ fail_json(
96
+ "INVALID_INPUT",
97
+ "Error: No input data provided. Use --data, --run-dir, or pipe JSON via stdin.",
98
+ EXIT_GENERAL,
99
+ )
100
+ df, trades, _meta = parse_backtest_payload(input_text, cwd=cwd)
101
+ else:
102
+ with open(path, encoding="utf-8") as f:
103
+ df, trades, _meta = parse_backtest_payload(f.read(), cwd=cwd)
104
+ except FileNotFoundError as e:
105
+ fail_json("MISSING_FILE", str(e), EXIT_GENERAL)
106
+ except ValueError as e:
107
+ fail_json("INVALID_INPUT", str(e), EXIT_SCHEMA)
108
+ except Exception as e:
109
+ log.exception("backtest input failed")
110
+ fail_json("INVALID_INPUT", str(e), EXIT_SCHEMA)
111
+
112
+ if df.empty or "equity" not in df.columns:
113
+ fail_json(
114
+ "INVALID_INPUT",
115
+ "Error: No valid equity series found (need 'equity' column).",
116
+ EXIT_SCHEMA,
117
+ )
118
+
119
+ chart_title = title or title_from_summary(summary_block, "Backtest Results")
120
+
121
+ if dry_run:
122
+ msg = f"Dry run OK: would plot backtest chart ({len(df)} points, {len(trades)} trades)"
123
+ click.echo(msg, err=True)
124
+ finished = utc_now_iso()
125
+ if json_output:
126
+ emit_json_line(
127
+ success_envelope(
128
+ command,
129
+ artifacts={},
130
+ meta={"startedAt": started, "finishedAt": finished, "dryRun": True},
131
+ data={"rows": len(df), "trades": len(trades)},
132
+ )
133
+ )
134
+ return
135
+
136
+ engine = ChartEngine(output_dir=output_dir, theme=theme)
137
+ try:
138
+ chart_path = engine.plot_backtest(df, trades=trades, title=chart_title)
139
+ except Exception as e:
140
+ log.exception("plot_backtest failed")
141
+ fail_json("PLOT_ERROR", str(e), EXIT_GENERAL)
142
+
143
+ finished = utc_now_iso()
144
+ rel = os.path.relpath(chart_path, cwd)
145
+ if json_output:
146
+ emit_json_line(
147
+ success_envelope(
148
+ command,
149
+ artifacts={"chart": rel},
150
+ meta={"startedAt": started, "finishedAt": finished},
151
+ )
152
+ )
153
+ else:
154
+ click.echo(f"Chart saved: {chart_path}")
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Technical indicators chart command."""
3
+
4
+ import logging
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+ from ..cli_exit import EXIT_GENERAL, EXIT_SCHEMA, EXIT_UPSTREAM
11
+ from ..core.charts import ChartEngine
12
+ from ..core.input_data import (
13
+ json_payload_to_dataframe,
14
+ normalize_ohlcv_columns,
15
+ normalize_timestamp_column_to_index,
16
+ parse_input_text,
17
+ )
18
+ from ..core.pipeline_json import emit_json_line, failure_envelope, success_envelope, utc_now_iso
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ def parse_input_data(input_data):
24
+ """Parse input data from various formats."""
25
+ data = parse_input_text(input_data)
26
+ df = json_payload_to_dataframe(data)
27
+ df = normalize_ohlcv_columns(df)
28
+ return normalize_timestamp_column_to_index(df)
29
+
30
+
31
+ def calculate_rsi(prices, period=14):
32
+ """Calculate RSI indicator."""
33
+ delta = prices.diff()
34
+ gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
35
+ loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
36
+ rs = gain / loss
37
+ rsi = 100 - (100 / (1 + rs))
38
+ return rsi
39
+
40
+
41
+ def calculate_macd(prices, fast=12, slow=26, signal=9):
42
+ """Calculate MACD indicator."""
43
+ ema_fast = prices.ewm(span=fast).mean()
44
+ ema_slow = prices.ewm(span=slow).mean()
45
+ macd = ema_fast - ema_slow
46
+ macd_signal = macd.ewm(span=signal).mean()
47
+ histogram = macd - macd_signal
48
+ return macd, macd_signal, histogram
49
+
50
+
51
+ def calculate_bollinger(prices, period=20, std_dev=2):
52
+ """Calculate Bollinger Bands."""
53
+ middle = prices.rolling(window=period).mean()
54
+ std = prices.rolling(window=period).std()
55
+ upper = middle + (std * std_dev)
56
+ lower = middle - (std * std_dev)
57
+ return upper, middle, lower
58
+
59
+
60
+ @click.command(name="indicators")
61
+ @click.option("--data", "-d", type=click.Path(exists=True), help="Path to OHLCV data file (JSON)")
62
+ @click.option("--input", "input_file", type=click.Path(exists=True), help="Alias for --data")
63
+ @click.option("--stdin", "use_stdin", is_flag=True, help="Read data from standard input")
64
+ @click.option(
65
+ "--indicator",
66
+ "-i",
67
+ "indicator_type",
68
+ type=click.Choice(["rsi", "macd", "bb", "all"]),
69
+ default="rsi",
70
+ help="Indicator type to plot",
71
+ )
72
+ @click.option("--title", "-t", help="Chart title (auto-generated if not provided)")
73
+ @click.option("--period", "-p", default=14, help="Indicator period (for RSI)")
74
+ @click.option("--theme", default="dark", type=click.Choice(["dark", "light"]), help="Chart theme")
75
+ @click.option(
76
+ "--output-dir",
77
+ "-o",
78
+ "output_dir_flag",
79
+ default=None,
80
+ help="Same as global chartpipe --output-dir (directory to save charts)",
81
+ )
82
+ @click.option(
83
+ "--json",
84
+ "json_flag",
85
+ is_flag=True,
86
+ default=False,
87
+ help="Same as global chartpipe --json (machine-readable stdout)",
88
+ )
89
+ @click.option(
90
+ "--dry-run",
91
+ "dry_run_flag",
92
+ is_flag=True,
93
+ default=False,
94
+ help="Same as global chartpipe --dry-run (validate only, no chart file)",
95
+ )
96
+ @click.pass_context
97
+ def indicators_command(
98
+ ctx,
99
+ data,
100
+ input_file,
101
+ use_stdin,
102
+ indicator_type,
103
+ title,
104
+ period,
105
+ theme,
106
+ output_dir_flag,
107
+ json_flag,
108
+ dry_run_flag,
109
+ ):
110
+ """Generate technical indicator charts."""
111
+ json_output = bool(ctx.obj.get("json_output", False) or json_flag)
112
+ output_dir = output_dir_flag or ctx.obj.get("output_dir", "./charts")
113
+ dry_run = bool(ctx.obj.get("dry_run", False) or dry_run_flag)
114
+ command = "indicators"
115
+ path = input_file or data
116
+ cwd = os.getcwd()
117
+
118
+ def fail_json(code: str, msg: str, exit_code: int) -> None:
119
+ click.echo(msg, err=True)
120
+ if json_output:
121
+ emit_json_line(failure_envelope(command, code, msg))
122
+ ctx.exit(exit_code)
123
+
124
+ started = utc_now_iso()
125
+
126
+ try:
127
+ if use_stdin or not path:
128
+ input_text = sys.stdin.read()
129
+ if not input_text.strip():
130
+ fail_json(
131
+ "INVALID_INPUT",
132
+ "Error: No input data provided. Use --data or pipe data via stdin.",
133
+ EXIT_GENERAL,
134
+ )
135
+ df = parse_input_data(input_text)
136
+ else:
137
+ with open(path, encoding="utf-8") as f:
138
+ df = parse_input_data(f.read())
139
+ except (ValueError, OSError) as e:
140
+ fail_json("INVALID_INPUT", str(e), EXIT_SCHEMA)
141
+
142
+ if df.empty:
143
+ fail_json("INVALID_INPUT", "Error: No valid data found.", EXIT_SCHEMA)
144
+
145
+ if "close" in df.columns:
146
+ if indicator_type == "rsi" or indicator_type == "all":
147
+ if "rsi" not in df.columns:
148
+ df["rsi"] = calculate_rsi(df["close"], period)
149
+
150
+ if indicator_type == "macd" or indicator_type == "all":
151
+ if "macd" not in df.columns:
152
+ df["macd"], df["signal"], df["histogram"] = calculate_macd(df["close"])
153
+
154
+ if indicator_type == "bb" or indicator_type == "all":
155
+ if "bb_upper" not in df.columns:
156
+ df["bb_upper"], df["bb_middle"], df["bb_lower"] = calculate_bollinger(df["close"])
157
+
158
+ if dry_run:
159
+ click.echo(
160
+ f"Dry run OK: would plot indicator chart(s) type={indicator_type} ({len(df)} rows)",
161
+ err=True,
162
+ )
163
+ finished = utc_now_iso()
164
+ if json_output:
165
+ emit_json_line(
166
+ success_envelope(
167
+ command,
168
+ artifacts={},
169
+ meta={"startedAt": started, "finishedAt": finished, "dryRun": True},
170
+ data={"rows": len(df), "indicator": indicator_type},
171
+ )
172
+ )
173
+ return
174
+
175
+ engine = ChartEngine(output_dir=output_dir, theme=theme)
176
+
177
+ try:
178
+ if indicator_type == "all":
179
+ results = {}
180
+ for ind in ["rsi", "macd", "bb"]:
181
+ chart_title = title or f"{ind.upper()} Indicator"
182
+ results[ind] = engine.plot_indicator(df, indicator=ind, title=chart_title)
183
+ finished = utc_now_iso()
184
+ artifacts = {k: os.path.relpath(v, cwd) for k, v in results.items()}
185
+ if json_output:
186
+ emit_json_line(
187
+ success_envelope(
188
+ command,
189
+ artifacts=artifacts,
190
+ meta={"startedAt": started, "finishedAt": finished},
191
+ )
192
+ )
193
+ else:
194
+ for ind, pth in results.items():
195
+ click.echo(f"{ind.upper()} chart saved: {pth}")
196
+ else:
197
+ chart_title = title or f"{indicator_type.upper()} Indicator"
198
+ chart_path = engine.plot_indicator(df, indicator=indicator_type, title=chart_title)
199
+ finished = utc_now_iso()
200
+ if json_output:
201
+ emit_json_line(
202
+ success_envelope(
203
+ command,
204
+ artifacts={"chart": os.path.relpath(chart_path, cwd)},
205
+ meta={"startedAt": started, "finishedAt": finished},
206
+ )
207
+ )
208
+ else:
209
+ click.echo(f"Chart saved: {chart_path}")
210
+ except ImportError as e:
211
+ fail_json("MISSING_DEPENDENCY", str(e), EXIT_UPSTREAM)
212
+ except Exception as e:
213
+ log.exception("plot_indicator failed")
214
+ fail_json("PLOT_ERROR", str(e), EXIT_GENERAL)
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """OHLCV candlestick chart command."""
3
+
4
+ import logging
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+ from ..cli_exit import EXIT_GENERAL, EXIT_SCHEMA, EXIT_UPSTREAM
11
+ from ..core.charts import ChartEngine
12
+ from ..core.input_data import (
13
+ json_payload_to_dataframe,
14
+ normalize_ohlcv_columns,
15
+ parse_input_text,
16
+ )
17
+ from ..core.pipeline_json import emit_json_line, failure_envelope, success_envelope, utc_now_iso
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ def parse_input_data(input_data):
23
+ """Parse input data from various formats."""
24
+ data = parse_input_text(input_data)
25
+ df = json_payload_to_dataframe(data)
26
+ return normalize_ohlcv_columns(df)
27
+
28
+
29
+ @click.command(name="ohlcv")
30
+ @click.option("--data", "-d", type=click.Path(exists=True), help="Path to OHLCV data file (JSON)")
31
+ @click.option("--input", "input_file", type=click.Path(exists=True), help="Alias for --data")
32
+ @click.option("--stdin", "use_stdin", is_flag=True, help="Read data from standard input")
33
+ @click.option("--title", "-t", default="Price Chart", help="Chart title")
34
+ @click.option("--volume/--no-volume", default=True, help="Show volume subplot")
35
+ @click.option(
36
+ "--ma",
37
+ "ma_periods",
38
+ multiple=True,
39
+ type=int,
40
+ default=[20, 50],
41
+ help="Moving average periods (can be used multiple times)",
42
+ )
43
+ @click.option("--theme", default="dark", type=click.Choice(["dark", "light"]), help="Chart theme")
44
+ @click.option(
45
+ "--output-dir",
46
+ "-o",
47
+ "output_dir_flag",
48
+ default=None,
49
+ help="Same as global chartpipe --output-dir (directory to save charts)",
50
+ )
51
+ @click.option(
52
+ "--json",
53
+ "json_flag",
54
+ is_flag=True,
55
+ default=False,
56
+ help="Same as global chartpipe --json (machine-readable stdout)",
57
+ )
58
+ @click.option(
59
+ "--dry-run",
60
+ "dry_run_flag",
61
+ is_flag=True,
62
+ default=False,
63
+ help="Same as global chartpipe --dry-run (validate only, no chart file)",
64
+ )
65
+ @click.pass_context
66
+ def ohlcv_command(
67
+ ctx,
68
+ data,
69
+ input_file,
70
+ use_stdin,
71
+ title,
72
+ volume,
73
+ ma_periods,
74
+ theme,
75
+ output_dir_flag,
76
+ json_flag,
77
+ dry_run_flag,
78
+ ):
79
+ """Generate OHLCV candlestick chart."""
80
+ json_output = bool(ctx.obj.get("json_output", False) or json_flag)
81
+ output_dir = output_dir_flag or ctx.obj.get("output_dir", "./charts")
82
+ dry_run = bool(ctx.obj.get("dry_run", False) or dry_run_flag)
83
+ command = "ohlcv"
84
+ path = input_file or data
85
+ cwd = os.getcwd()
86
+
87
+ def fail_json(code: str, msg: str, exit_code: int) -> None:
88
+ click.echo(msg, err=True)
89
+ if json_output:
90
+ emit_json_line(failure_envelope(command, code, msg))
91
+ ctx.exit(exit_code)
92
+
93
+ started = utc_now_iso()
94
+
95
+ try:
96
+ if use_stdin or not path:
97
+ input_text = sys.stdin.read()
98
+ if not input_text.strip():
99
+ fail_json(
100
+ "INVALID_INPUT",
101
+ "Error: No input data provided. Use --data or pipe data via stdin.",
102
+ EXIT_GENERAL,
103
+ )
104
+ df = parse_input_data(input_text)
105
+ else:
106
+ with open(path, encoding="utf-8") as f:
107
+ df = parse_input_data(f.read())
108
+ except (ValueError, OSError) as e:
109
+ fail_json("INVALID_INPUT", str(e), EXIT_SCHEMA)
110
+
111
+ if df.empty:
112
+ fail_json("INVALID_INPUT", "Error: No valid data found.", EXIT_SCHEMA)
113
+
114
+ if dry_run:
115
+ click.echo(
116
+ f"Dry run OK: would plot OHLCV chart ({len(df)} rows)", err=True
117
+ )
118
+ finished = utc_now_iso()
119
+ if json_output:
120
+ emit_json_line(
121
+ success_envelope(
122
+ command,
123
+ artifacts={},
124
+ meta={"startedAt": started, "finishedAt": finished, "dryRun": True},
125
+ data={"rows": len(df)},
126
+ )
127
+ )
128
+ return
129
+
130
+ engine = ChartEngine(output_dir=output_dir, theme=theme)
131
+ try:
132
+ chart_path = engine.plot_ohlcv(
133
+ df,
134
+ title=title,
135
+ volume=volume,
136
+ ma_periods=list(ma_periods) if ma_periods else None,
137
+ )
138
+ except ImportError as e:
139
+ fail_json("MISSING_DEPENDENCY", str(e), EXIT_UPSTREAM)
140
+ except Exception as e:
141
+ log.exception("plot_ohlcv failed")
142
+ fail_json("PLOT_ERROR", str(e), EXIT_GENERAL)
143
+
144
+ finished = utc_now_iso()
145
+ rel = os.path.relpath(chart_path, cwd)
146
+ if json_output:
147
+ emit_json_line(
148
+ success_envelope(
149
+ command,
150
+ artifacts={"chart": rel},
151
+ meta={"startedAt": started, "finishedAt": finished},
152
+ )
153
+ )
154
+ else:
155
+ click.echo(f"Chart saved: {chart_path}")