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 +6 -0
- chartpipe/__main__.py +7 -0
- chartpipe/cli.py +83 -0
- chartpipe/cli_exit.py +15 -0
- chartpipe/commands/__init__.py +1 -0
- chartpipe/commands/backtest.py +154 -0
- chartpipe/commands/indicators.py +214 -0
- chartpipe/commands/ohlcv.py +155 -0
- chartpipe/commands/stats.py +169 -0
- chartpipe/core/__init__.py +6 -0
- chartpipe/core/backtest_input.py +185 -0
- chartpipe/core/charts.py +591 -0
- chartpipe/core/input_data.py +151 -0
- chartpipe/core/pipeline_json.py +79 -0
- chartpipe/core/styles.py +65 -0
- chartpipe-0.1.0.dist-info/METADATA +376 -0
- chartpipe-0.1.0.dist-info/RECORD +20 -0
- chartpipe-0.1.0.dist-info/WHEEL +5 -0
- chartpipe-0.1.0.dist-info/entry_points.txt +2 -0
- chartpipe-0.1.0.dist-info/top_level.txt +1 -0
chartpipe/__init__.py
ADDED
chartpipe/__main__.py
ADDED
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}")
|