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 +3 -0
- kabuchart/_internal/__init__.py +0 -0
- kabuchart/_internal/chart.py +351 -0
- kabuchart/_internal/config.py +49 -0
- kabuchart/_internal/data.py +194 -0
- kabuchart/_internal/models.py +78 -0
- kabuchart/cli.py +44 -0
- kabuchart_cli-0.1.0.dist-info/METADATA +55 -0
- kabuchart_cli-0.1.0.dist-info/RECORD +11 -0
- kabuchart_cli-0.1.0.dist-info/WHEEL +4 -0
- kabuchart_cli-0.1.0.dist-info/entry_points.txt +2 -0
kabuchart/__init__.py
ADDED
|
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,,
|