kabuchart-cli 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.
kabuchart/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Terminal-based stock chart CLI for Japanese stocks."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,351 @@
1
+ """Terminal chart rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from kabuchart._internal.models import StockData
8
+
9
+
10
+ def _format_market_cap(value: float | None) -> str:
11
+ """Format market cap like kabutan: 54兆6,822億円 for ≥10000億, else 9,999億円."""
12
+ if value is None:
13
+ return "-"
14
+ if value >= 10000:
15
+ cho = int(value) // 10000
16
+ oku = int(value) % 10000
17
+ return f"{cho}兆{oku:,}億円"
18
+ return f"{value:,.0f}億円"
19
+
20
+
21
+ def _source_line(data: StockData) -> str:
22
+ """Build source log line: [kabutan] etc."""
23
+ return f"[{data.source}]"
24
+
25
+
26
+ def _display_width(s: str) -> int:
27
+ """Calculate display width accounting for East Asian wide characters."""
28
+ import unicodedata
29
+
30
+ w = 0
31
+ for ch in s:
32
+ eaw = unicodedata.east_asian_width(ch)
33
+ w += 2 if eaw in ("F", "W") else 1
34
+ return w
35
+
36
+
37
+ def _pad_to_width(s: str, width: int) -> str:
38
+ """Pad string with spaces to reach target display width."""
39
+ current = _display_width(s)
40
+ if current >= width:
41
+ return s
42
+ return s + " " * (width - current)
43
+
44
+
45
+ def _wrap_text(text: str, width: int) -> list[str]:
46
+ """Wrap text to fit within display width, respecting East Asian characters."""
47
+ lines: list[str] = []
48
+ current = ""
49
+ current_w = 0
50
+ for ch in text:
51
+ ch_w = 2 if _display_width(ch) == 2 else 1
52
+ if current_w + ch_w > width:
53
+ lines.append(current)
54
+ current = ch
55
+ current_w = ch_w
56
+ else:
57
+ current += ch
58
+ current_w += ch_w
59
+ if current:
60
+ lines.append(current)
61
+ return lines
62
+
63
+
64
+ def _header(data: StockData, min_box_width: int = 80, *, verbose: bool = False) -> str:
65
+ """Build kabutan-style two-panel header info box.
66
+
67
+ Left panel: stock info + price (2 rows).
68
+ Right panel: metric columns with header/value rows.
69
+ Bottom row: market cap spanning full width.
70
+
71
+ ┌──────────────────────────────────────┬──────────┬───────┬───────┬──────┬────────┐
72
+ │ 7203 トヨタ自動車 東証P 13:43 │ 業種 │ PER │ PBR │利回り│信用倍率│
73
+ │ 3,462.0円 前日比 +69.0 (+2.03%) │ 輸送用機器│12.6倍 │1.16倍 │2.74% │2.07倍 │
74
+ ├──────────────────────────────────────┴──────────┴───────┴───────┴──────┴────────┤
75
+ │ 時価総額 547,138億円 │
76
+ └────────────────────────────────────────────────────────────────────────────────┘
77
+ """
78
+ i = data.info
79
+
80
+ # Right-side metric columns: (header, value) pairs
81
+ right_cols: list[tuple[str, str]] = []
82
+ if i.industry:
83
+ right_cols.append(("業種", i.industry))
84
+ if i.per is not None:
85
+ right_cols.append(("PER", f"{i.per:.1f}倍"))
86
+ if i.pbr is not None:
87
+ right_cols.append(("PBR", f"{i.pbr:.2f}倍"))
88
+ if i.dividend_yield is not None:
89
+ right_cols.append(("利回り", f"{i.dividend_yield:.2f}%"))
90
+ if i.margin_ratio is not None:
91
+ right_cols.append(("信用倍率", f"{i.margin_ratio:.2f}倍"))
92
+ if i.market_cap is not None:
93
+ right_cols.append(("時価総額", _format_market_cap(i.market_cap)))
94
+
95
+ if not right_cols:
96
+ return _header_simple(data, min_box_width)
97
+
98
+ # Column widths: max(header, value) display width + 2 padding
99
+ col_widths = [
100
+ max(_display_width(h), _display_width(v)) + 2
101
+ for h, v in right_cols
102
+ ]
103
+ n = len(right_cols)
104
+ right_total = sum(col_widths) + n # n separators
105
+
106
+ # Left panel content
107
+ left_parts = [f"{data.ticker} {data.name}"]
108
+ if i.market:
109
+ left_parts.append(i.market)
110
+ left1_text = " ".join(left_parts)
111
+ time_str = data.live.time if data.live and data.live.time else ""
112
+ line2_text = _price_line(data)
113
+
114
+ # Compute minimum left panel width from content
115
+ if time_str:
116
+ left_min = _display_width(left1_text) + 4 + len(time_str) # " text time"
117
+ else:
118
+ left_min = _display_width(left1_text) + 2 # " text "
119
+ left_min = max(left_min, _display_width(line2_text) + 2) # " price_line "
120
+
121
+ # Determine box width: fit content or min_box_width, whichever is larger
122
+ inner = max(left_min + right_total, min_box_width - 2)
123
+ left_width = inner - right_total
124
+
125
+ # Build left panel content
126
+ if time_str:
127
+ gap = left_width - _display_width(left1_text) - len(time_str) - 1
128
+ line1_left = " " + left1_text + " " * max(gap, 2) + time_str
129
+ else:
130
+ line1_left = " " + left1_text
131
+ line2_left = " " + line2_text
132
+
133
+ # Build right-side cells
134
+ hdr_cells = []
135
+ val_cells = []
136
+ for (h, v), w in zip(right_cols, col_widths):
137
+ hdr_cells.append(" " + _pad_to_width(h, w - 1))
138
+ val_cells.append(" " + _pad_to_width(v, w - 1))
139
+
140
+ # Assemble rows
141
+ row1 = ("│" + _pad_to_width(line1_left, left_width)
142
+ + "│" + "│".join(hdr_cells) + "│")
143
+ row2 = ("│" + _pad_to_width(line2_left, left_width)
144
+ + "│" + "│".join(val_cells) + "│")
145
+
146
+ # Borders
147
+ top = ("┌" + "─" * left_width
148
+ + "┬" + "┬".join("─" * w for w in col_widths) + "┐")
149
+
150
+ lines = [top, row1, row2]
151
+
152
+ # Bottom section: description only (verbose mode)
153
+ if verbose and i.description:
154
+ mid = ("├" + "─" * left_width
155
+ + "┴" + "┴".join("─" * w for w in col_widths) + "┤")
156
+ lines.append(mid)
157
+
158
+ desc_width = inner - 2 # 1 space padding each side
159
+ for dline in _wrap_text(i.description, desc_width):
160
+ lines.append("│ " + _pad_to_width(dline, inner - 1) + "│")
161
+
162
+ bot = "└" + "─" * inner + "┘"
163
+ lines.append(bot)
164
+ else:
165
+ bot = ("└" + "─" * left_width
166
+ + "┴" + "┴".join("─" * w for w in col_widths) + "┘")
167
+ lines.append(bot)
168
+
169
+ return "\n".join(lines)
170
+
171
+
172
+ def _header_simple(data: StockData, box_width: int = 80) -> str:
173
+ """Fallback simple header when no metric columns available."""
174
+ inner = box_width - 2
175
+ left_parts = [f"{data.ticker} {data.name}"]
176
+ if data.info.market:
177
+ left_parts.append(data.info.market)
178
+ if data.info.industry:
179
+ left_parts.append(data.info.industry)
180
+ line1 = " ".join(left_parts)
181
+ time_str = data.live.time if data.live and data.live.time else ""
182
+ if time_str:
183
+ gap = inner - _display_width(line1) - len(time_str)
184
+ line1 = line1 + " " * max(gap, 2) + time_str
185
+
186
+ line2 = _price_line(data)
187
+
188
+ top = "┌" + "─" * inner + "┐"
189
+ row1 = "│ " + _pad_to_width(line1, inner - 1) + "│"
190
+ row2 = "│ " + _pad_to_width(line2, inner - 1) + "│"
191
+ bot = "└" + "─" * inner + "┘"
192
+ return "\n".join([top, row1, row2, bot])
193
+
194
+
195
+ def _price_line(data: StockData) -> str:
196
+ """Build price line using live price if available, else OHLCV close."""
197
+ if data.live:
198
+ lp = data.live
199
+ sign = "+" if lp.change >= 0 else ""
200
+ return f"{lp.price:,.1f}円 前日比 {sign}{lp.change:,.1f} ({sign}{lp.change_pct:.2f}%)"
201
+
202
+ # Fallback: use latest OHLCV close
203
+ if not data.records:
204
+ return ""
205
+ if len(data.records) < 2:
206
+ return f"{data.records[-1].close:,.1f}円"
207
+ latest = data.records[-1]
208
+ prev = data.records[-2]
209
+ diff = latest.close - prev.close
210
+ pct = (diff / prev.close) * 100
211
+ sign = "+" if diff >= 0 else ""
212
+ return f"{latest.close:,.1f}円 前日比 {sign}{diff:,.1f} ({sign}{pct:.2f}%)"
213
+
214
+
215
+ def _date_axis(data: StockData, chart_width: int, n_visible: int) -> str:
216
+ """Build date axis labels like kabutan: 26/1, 2, 3."""
217
+ if not data.records:
218
+ return ""
219
+
220
+ dates = data.dates[-n_visible:]
221
+ n = len(dates)
222
+ if n == 0:
223
+ return ""
224
+
225
+ y_axis_width = chart_width - n
226
+ if y_axis_width < 0:
227
+ y_axis_width = 0
228
+
229
+ labels: list[tuple[int, str]] = []
230
+ prev_month = None
231
+ for i, d in enumerate(dates):
232
+ ym = (d.year, d.month)
233
+ if ym != prev_month:
234
+ if prev_month is None or d.year != prev_month[0]:
235
+ label = f"{d.year % 100}/{d.month}"
236
+ else:
237
+ label = str(d.month)
238
+ labels.append((i, label))
239
+ prev_month = ym
240
+
241
+ axis = [" "] * n
242
+ for pos, label in labels:
243
+ for j, ch in enumerate(label):
244
+ idx = pos + j
245
+ if idx < n:
246
+ axis[idx] = ch
247
+
248
+ return " " * y_axis_width + "".join(axis)
249
+
250
+
251
+ def _nice_step(price_range: float, target_ticks: int = 5) -> float:
252
+ """Compute a nice round step size for y-axis ticks.
253
+
254
+ Picks the largest "nice" number (1, 2, 5, 10 × magnitude) that yields
255
+ at least *target_ticks* ticks across the price range.
256
+ """
257
+ if price_range <= 0:
258
+ return 1.0
259
+ raw = price_range / target_ticks
260
+ magnitude = 10 ** math.floor(math.log10(raw))
261
+ # Pick the largest nice step that is <= raw (so we get enough ticks).
262
+ for nice in (10, 5, 2, 1):
263
+ candidate = nice * magnitude
264
+ if candidate <= raw:
265
+ return candidate
266
+ return magnitude
267
+
268
+
269
+ def _configure_y_axis(data: StockData, chart_height: int) -> None:
270
+ """Set candlestick-chart y-axis constants for nice round tick labels."""
271
+ from candlestick_chart import constants as cc
272
+
273
+ prices = data.highs + data.lows
274
+ if not prices:
275
+ return
276
+ price_range = max(prices) - min(prices)
277
+
278
+ # Target enough ticks so labels appear every ~5 rows.
279
+ target_ticks = max(chart_height // 5, 4)
280
+ step = _nice_step(price_range, target_ticks)
281
+
282
+ cc.Y_AXIS_ROUND_MULTIPLIER = 1.0 / step
283
+ cc.Y_AXIS_ROUND_DIR = "down"
284
+ # Show integer ticks for prices >= 100
285
+ if step >= 1:
286
+ cc.PRECISION = 0
287
+ else:
288
+ cc.PRECISION = 2
289
+
290
+ # Set Y_AXIS_SPACING so each tick shows a distinct value.
291
+ if price_range > 0:
292
+ num_ticks = max(price_range / step, 1)
293
+ cc.Y_AXIS_SPACING = max(3, math.ceil(chart_height / num_ticks))
294
+ else:
295
+ cc.Y_AXIS_SPACING = 4
296
+
297
+
298
+ def render(data: StockData, *, quiet: bool = False, verbose: bool = False) -> None:
299
+ """Render stock data as a terminal candlestick chart."""
300
+ from shutil import get_terminal_size
301
+
302
+ from candlestick_chart import Candle, Chart
303
+
304
+ # Source log (can be suppressed with --quiet)
305
+ if not quiet:
306
+ print(_source_line(data))
307
+
308
+ # Kabutan-style header
309
+ print(_header(data, verbose=verbose))
310
+
311
+ # Compact chart height: fit header + chart + date axis in one screen.
312
+ # Reserve rows for: header box (~6) + source line (1) + date axis (1) + margin (2).
313
+ term_w, term_h = get_terminal_size()
314
+ chart_total_h = min(term_h - 10, 32) # total chart widget height
315
+ chart_total_h = max(chart_total_h, 20) # floor at 20
316
+
317
+ candles = [
318
+ Candle(
319
+ open=r.open,
320
+ close=r.close,
321
+ high=r.high,
322
+ low=r.low,
323
+ volume=r.volume,
324
+ )
325
+ for r in data.records
326
+ ]
327
+
328
+ chart = Chart(candles, title="", height=chart_total_h)
329
+ chart.set_volume_pane_enabled(True)
330
+
331
+ # Configure y-axis using actual chart height after library computes it
332
+ _configure_y_axis(data, chart.chart_data.height)
333
+
334
+ # Get rendered chart, strip bottom info bar
335
+ rendered = chart._render() # noqa: SLF001
336
+ lines = rendered.split("\n")
337
+
338
+ while lines and not lines[-1].strip():
339
+ lines.pop()
340
+ if lines and not lines[-1].startswith("─"):
341
+ lines.pop()
342
+ while lines and not lines[-1].strip():
343
+ lines.pop()
344
+
345
+ # Date axis
346
+ n_visible = len(chart.chart_data.visible_candle_set.candles)
347
+ date_line = _date_axis(data, chart.chart_data.width, n_visible)
348
+ if date_line:
349
+ lines.append(date_line)
350
+
351
+ print("\n".join(lines))
@@ -0,0 +1,49 @@
1
+ """Configuration loading with layered precedence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ try:
10
+ import tomllib
11
+ except ModuleNotFoundError:
12
+ import tomli as tomllib # type: ignore[no-redef]
13
+
14
+
15
+ CONFIG_DIR = Path.home() / ".config" / "kabuchart"
16
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
17
+
18
+
19
+ @dataclass
20
+ class Config:
21
+ """Application configuration."""
22
+
23
+ jquants_api_key: str | None = None
24
+ data_source_priority: list[str] = field(
25
+ default_factory=lambda: ["pyjquants", "pykabutan", "yfinance"]
26
+ )
27
+
28
+ @classmethod
29
+ def load(cls) -> Config:
30
+ """Load config: env vars override config.toml."""
31
+ file_config = _load_toml()
32
+ return cls(
33
+ jquants_api_key=(
34
+ os.environ.get("JQUANTS_API_KEY")
35
+ or file_config.get("jquants_api_key")
36
+ ),
37
+ data_source_priority=file_config.get(
38
+ "data_source_priority",
39
+ ["pyjquants", "pykabutan", "yfinance"],
40
+ ),
41
+ )
42
+
43
+
44
+ def _load_toml() -> dict:
45
+ """Load config from TOML file. Returns empty dict if not found."""
46
+ if not CONFIG_FILE.exists():
47
+ return {}
48
+ with open(CONFIG_FILE, "rb") as f:
49
+ return tomllib.load(f)
@@ -0,0 +1,194 @@
1
+ """Cascading data fetch: pyjquants → yfinance → pykabutan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from kabuchart._internal.config import Config
8
+ from kabuchart._internal.models import OHLCV, LivePrice, StockData, StockInfo
9
+
10
+
11
+ def fetch(ticker: str, period: str, config: Config | None = None) -> StockData:
12
+ """Fetch OHLCV data using cascading data sources."""
13
+ if config is None:
14
+ config = Config.load()
15
+
16
+ errors: list[str] = []
17
+ for source in config.data_source_priority:
18
+ fetcher = _FETCHERS.get(source)
19
+ if fetcher is None:
20
+ continue
21
+ try:
22
+ return fetcher(ticker, period, config)
23
+ except Exception as e: # noqa: BLE001
24
+ errors.append(f"{source}: {e}")
25
+
26
+ raise RuntimeError(
27
+ f"All data sources failed for {ticker}:\n" + "\n".join(errors)
28
+ )
29
+
30
+
31
+ def _parse_live_price(soup) -> LivePrice | None:
32
+ """Extract live/delayed price from kabutan main page soup."""
33
+ try:
34
+ # Scope to the stock info container to avoid matching index data
35
+ container = soup.select_one("div#stockinfo_i1")
36
+ if not container:
37
+ return None
38
+
39
+ kabuka = container.select_one("span.kabuka")
40
+ if not kabuka:
41
+ return None
42
+ price_text = kabuka.get_text(strip=True).replace(",", "").replace("円", "")
43
+ price = float(price_text)
44
+
45
+ # Change: look for span.up or span.down within the container
46
+ change_el = container.select_one("span.up") or container.select_one("span.down")
47
+ change = 0.0
48
+ if change_el:
49
+ change_text = change_el.get_text(strip=True).replace(",", "")
50
+ change = float(change_text)
51
+
52
+ # Percent change
53
+ change_pct = (change / (price - change)) * 100 if price != change else 0.0
54
+
55
+ # Time from si_i1_1_rbox
56
+ time_str = ""
57
+ time_el = container.select_one("div.si_i1_1_rbox")
58
+ if time_el:
59
+ text = time_el.get_text(strip=True)
60
+ m = re.search(r"(\d{1,2}:\d{2})", text)
61
+ if m:
62
+ time_str = m.group(1)
63
+
64
+ return LivePrice(price=price, change=change, change_pct=change_pct, time=time_str)
65
+ except Exception: # noqa: BLE001
66
+ return None
67
+
68
+
69
+ def _get_kabutan_profile(ticker: str):
70
+ """Get pykabutan Ticker and profile. Returns (name, StockInfo, LivePrice|None) or ("", StockInfo(), None)."""
71
+ try:
72
+ import pykabutan as pk
73
+
74
+ t = pk.Ticker(ticker)
75
+ p = t.profile
76
+ info = StockInfo(
77
+ market=p.market or "",
78
+ industry=p.industry or "",
79
+ per=p.per,
80
+ pbr=p.pbr,
81
+ dividend_yield=p.dividend_yield,
82
+ market_cap=p.market_cap,
83
+ margin_ratio=p.margin_ratio,
84
+ description=p.description or "",
85
+ )
86
+ live = _parse_live_price(t._soup) # noqa: SLF001
87
+ return p.name, info, live
88
+ except Exception: # noqa: BLE001
89
+ return "", StockInfo(), None
90
+
91
+
92
+ def _fetch_yfinance(ticker: str, period: str, _config: Config) -> StockData:
93
+ import yfinance as yf
94
+
95
+ yf_ticker = f"{ticker}.T"
96
+ df = yf.Ticker(yf_ticker).history(period=period)
97
+ if df.empty:
98
+ raise ValueError(f"No data from yfinance for {yf_ticker}")
99
+
100
+ name, info, live = _get_kabutan_profile(ticker)
101
+ records = [
102
+ OHLCV(
103
+ date=idx.date(),
104
+ open=float(row["Open"]),
105
+ high=float(row["High"]),
106
+ low=float(row["Low"]),
107
+ close=float(row["Close"]),
108
+ volume=int(row["Volume"]),
109
+ )
110
+ for idx, row in df.iterrows()
111
+ ]
112
+ return StockData(
113
+ ticker=ticker, name=name, source="yfinance",
114
+ records=records, info=info, live=live,
115
+ )
116
+
117
+
118
+ def _fetch_pykabutan(ticker: str, period: str, _config: Config) -> StockData:
119
+ import pykabutan as pk
120
+
121
+ t = pk.Ticker(ticker)
122
+ p = t.profile
123
+ info = StockInfo(
124
+ market=p.market or "",
125
+ industry=p.industry or "",
126
+ per=p.per,
127
+ pbr=p.pbr,
128
+ dividend_yield=p.dividend_yield,
129
+ market_cap=p.market_cap,
130
+ margin_ratio=p.margin_ratio,
131
+ description=p.description or "",
132
+ )
133
+ live = _parse_live_price(t._soup) # noqa: SLF001
134
+ df = t.history(period=period)
135
+ if df.empty:
136
+ raise ValueError(f"No data from pykabutan for {ticker}")
137
+
138
+ df = df.sort_values("date").reset_index(drop=True)
139
+ records = [
140
+ OHLCV(
141
+ date=row["date"].date() if hasattr(row["date"], "date") else row["date"],
142
+ open=float(row["open"]),
143
+ high=float(row["high"]),
144
+ low=float(row["low"]),
145
+ close=float(row["close"]),
146
+ volume=int(row["volume"]),
147
+ )
148
+ for _, row in df.iterrows()
149
+ ]
150
+ return StockData(
151
+ ticker=ticker, name=p.name, source="kabutan",
152
+ records=records, info=info, live=live,
153
+ )
154
+
155
+
156
+ def _fetch_pyjquants(ticker: str, period: str, config: Config) -> StockData:
157
+ import os
158
+
159
+ if not config.jquants_api_key and not os.environ.get("JQUANTS_API_KEY"):
160
+ raise RuntimeError("J-Quants API key not configured")
161
+
162
+ try:
163
+ import pyjquants as pjq
164
+ except ImportError:
165
+ raise RuntimeError("pyjquants not installed (pip install kabuchart-cli[jquants])")
166
+
167
+ t = pjq.Ticker(ticker)
168
+ df = t.history(period=period)
169
+ if df.empty:
170
+ raise ValueError(f"No data from pyjquants for {ticker}")
171
+
172
+ name, info, live = _get_kabutan_profile(ticker)
173
+ records = [
174
+ OHLCV(
175
+ date=row["date"].date() if hasattr(row["date"], "date") else row["date"],
176
+ open=float(row["open"]),
177
+ high=float(row["high"]),
178
+ low=float(row["low"]),
179
+ close=float(row["close"]),
180
+ volume=int(row["volume"]),
181
+ )
182
+ for _, row in df.iterrows()
183
+ ]
184
+ return StockData(
185
+ ticker=ticker, name=name, source="jquants",
186
+ records=records, info=info, live=live,
187
+ )
188
+
189
+
190
+ _FETCHERS = {
191
+ "pyjquants": _fetch_pyjquants,
192
+ "yfinance": _fetch_yfinance,
193
+ "pykabutan": _fetch_pykabutan,
194
+ }
@@ -0,0 +1,78 @@
1
+ """Core data models for kabuchart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class OHLCV:
11
+ """Single day of stock price data."""
12
+
13
+ date: date
14
+ open: float
15
+ high: float
16
+ low: float
17
+ close: float
18
+ volume: int
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class StockInfo:
23
+ """Stock profile data from kabutan."""
24
+
25
+ market: str = ""
26
+ industry: str = ""
27
+ per: float | None = None
28
+ pbr: float | None = None
29
+ dividend_yield: float | None = None
30
+ market_cap: float | None = None
31
+ margin_ratio: float | None = None
32
+ description: str = ""
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class LivePrice:
37
+ """Live/delayed price from kabutan main page."""
38
+
39
+ price: float
40
+ change: float
41
+ change_pct: float
42
+ time: str # e.g. "13:25"
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class StockData:
47
+ """Collection of OHLCV data for a ticker."""
48
+
49
+ ticker: str
50
+ name: str
51
+ source: str
52
+ records: list[OHLCV]
53
+ info: StockInfo = StockInfo()
54
+ live: LivePrice | None = None
55
+
56
+ @property
57
+ def dates(self) -> list[date]:
58
+ return [r.date for r in self.records]
59
+
60
+ @property
61
+ def opens(self) -> list[float]:
62
+ return [r.open for r in self.records]
63
+
64
+ @property
65
+ def highs(self) -> list[float]:
66
+ return [r.high for r in self.records]
67
+
68
+ @property
69
+ def lows(self) -> list[float]:
70
+ return [r.low for r in self.records]
71
+
72
+ @property
73
+ def closes(self) -> list[float]:
74
+ return [r.close for r in self.records]
75
+
76
+ @property
77
+ def volumes(self) -> list[int]:
78
+ return [r.volume for r in self.records]
kabuchart/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ """CLI entry point for kabuchart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from kabuchart._internal.chart import render
10
+ from kabuchart._internal.data import fetch
11
+
12
+
13
+ def _period_from_flags(days: Optional[str], months: Optional[str]) -> str:
14
+ """Resolve period from -d or -m flags, defaulting to 3mo."""
15
+ if days:
16
+ return days.lstrip("-")
17
+ if months:
18
+ return months.lstrip("-")
19
+ return "3mo"
20
+
21
+
22
+ def main(
23
+ ticker: str = typer.Argument(help="Stock ticker code (e.g. 7203, 285A)"),
24
+ days: Optional[str] = typer.Option(
25
+ None, "-d", help="Date range in days (e.g. -d 30d, -d 5d)"
26
+ ),
27
+ months: Optional[str] = typer.Option(
28
+ None, "-m", help="Date range in months/years (e.g. -m 3mo, -m 1y)"
29
+ ),
30
+ quiet: bool = typer.Option(
31
+ True, "-q/ ", "--quiet/--no-quiet", help="Suppress source log line"
32
+ ),
33
+ verbose: bool = typer.Option(
34
+ True, "-v/ ", "--verbose/--no-verbose", help="Show stock description in header"
35
+ ),
36
+ ) -> None:
37
+ """Terminal stock charts for Japanese stocks."""
38
+ period = _period_from_flags(days, months)
39
+ data = fetch(ticker, period)
40
+ render(data, quiet=quiet, verbose=verbose)
41
+
42
+
43
+ app = typer.Typer(invoke_without_command=True, no_args_is_help=True)
44
+ app.command()(main)
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: kabuchart-cli
3
+ Version: 0.1.0
4
+ Summary: Terminal-based stock chart CLI for Japanese stocks
5
+ Project-URL: Homepage, https://github.com/obichan117/kabuchart-cli
6
+ Project-URL: Documentation, https://obichan117.github.io/kabuchart-cli/
7
+ Project-URL: Repository, https://github.com/obichan117/kabuchart-cli
8
+ Author: obichan117
9
+ License: MIT
10
+ Keywords: candlestick,chart,japanese,stock,terminal
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: candlestick-chart>=3.1.0
23
+ Requires-Dist: pykabutan>=0.1
24
+ Requires-Dist: typer>=0.9
25
+ Requires-Dist: yfinance>=0.2
26
+ Provides-Extra: dev
27
+ Requires-Dist: mkdocs-material>=9.0; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.4; extra == 'dev'
31
+ Requires-Dist: twine>=5.0; extra == 'dev'
32
+ Provides-Extra: jquants
33
+ Requires-Dist: pyjquants>=0.1; extra == 'jquants'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # kabuchart-cli
37
+
38
+ Terminal-based stock chart CLI for Japanese stocks.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install kabuchart-cli
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ chart 285A # candlestick chart (default: 3 months)
50
+ chart 285A -d 30d # last 30 days
51
+ chart 285A -m 6mo # last 6 months
52
+ chart 285A -m 1y # last 1 year
53
+ chart 285A --no-quiet # show data source info
54
+ chart 285A --no-verbose # hide stock description
55
+ ```
@@ -0,0 +1,11 @@
1
+ kabuchart/__init__.py,sha256=ph5Qi0q1eZwzoBaTHo6FRF7X64Mf_0Aofh1xVH4QeW8,81
2
+ kabuchart/cli.py,sha256=ZAK8Ubih2qBH_WSN9-cFpKXZ6IcK67VBYf45SmZLlUE,1311
3
+ kabuchart/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ kabuchart/_internal/chart.py,sha256=R52NV9_METobubVJX8R2NWhgvvHKXbmzoRcE-0_mZE8,12174
5
+ kabuchart/_internal/config.py,sha256=GrHv3mKCvNoPfipJlg19qm5jwZFute9H-LHmYfPnlxg,1293
6
+ kabuchart/_internal/data.py,sha256=sNnrP8De3ctwFt_H_yUFgxKVAGjjgAnBnwZk-W4LPYg,6075
7
+ kabuchart/_internal/models.py,sha256=-EEhoy2q7s5lAvIXfFA-8Xno2GM-S_ZNpuXkjpgppnk,1688
8
+ kabuchart_cli-0.1.0.dist-info/METADATA,sha256=gRCvN070cK4vc7IW4V9ftysgAwm0mT5TDm2mtLWtu5g,1837
9
+ kabuchart_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ kabuchart_cli-0.1.0.dist-info/entry_points.txt,sha256=mnLxfwQDa8tFgMJZE5o4s21Ej0KH0K7X4hoZmG4qodc,44
11
+ kabuchart_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chart = kabuchart.cli:app