seolpyo-mplchart 0.1.3.1__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.
- seolpyo_mplchart/__init__.py +120 -0
- seolpyo_mplchart/base.py +111 -0
- seolpyo_mplchart/cursor.py +448 -0
- seolpyo_mplchart/data/apple.txt +64187 -0
- seolpyo_mplchart/data/samsung.txt +120002 -0
- seolpyo_mplchart/draw.py +371 -0
- seolpyo_mplchart/slider.py +550 -0
- seolpyo_mplchart/test.py +38 -0
- seolpyo_mplchart/utils.py +45 -0
- seolpyo_mplchart-0.1.3.1.dist-info/METADATA +49 -0
- seolpyo_mplchart-0.1.3.1.dist-info/RECORD +13 -0
- seolpyo_mplchart-0.1.3.1.dist-info/WHEEL +5 -0
- seolpyo_mplchart-0.1.3.1.dist-info/top_level.txt +1 -0
seolpyo_mplchart/draw.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
from matplotlib.backend_bases import PickEvent
|
|
2
|
+
from matplotlib.collections import LineCollection
|
|
3
|
+
from matplotlib.lines import Line2D
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from .base import Base
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Mixin:
|
|
12
|
+
def on_draw(self, e):
|
|
13
|
+
"This function works if draw event active."
|
|
14
|
+
return
|
|
15
|
+
def on_pick(self, e):
|
|
16
|
+
"This function works if pick event active."
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_set_key = {'zero', 'x', 'left', 'right', 'top', 'bottom',}
|
|
21
|
+
|
|
22
|
+
class DataMixin(Base):
|
|
23
|
+
df: pd.DataFrame
|
|
24
|
+
date = 'date'
|
|
25
|
+
Open, high, low, close = ('open', 'high', 'low', 'close')
|
|
26
|
+
volume = 'volume'
|
|
27
|
+
|
|
28
|
+
_visible_ma = set()
|
|
29
|
+
label_ma = '{}일선'
|
|
30
|
+
list_ma = (5, 20, 60, 120, 240)
|
|
31
|
+
# https://matplotlib.org/stable/gallery/color/named_colors.html
|
|
32
|
+
list_macolor = ('darkred', 'fuchsia', 'olive', 'orange', 'navy', 'darkmagenta', 'limegreen', 'darkcyan',)
|
|
33
|
+
|
|
34
|
+
color_up, color_down = ('#fe3032', '#0095ff')
|
|
35
|
+
color_flat = 'k'
|
|
36
|
+
color_up_down, color_down_up = ('w', 'w')
|
|
37
|
+
colors_volume = '#1f77b4'
|
|
38
|
+
|
|
39
|
+
candlewidth_half, volumewidth_half = (0.3, 0.36)
|
|
40
|
+
|
|
41
|
+
def _generate_data(self, df: pd.DataFrame, sort_df=True, calc_ma=True, **_):
|
|
42
|
+
for i in ('date', 'Open', 'high', 'low', 'close', 'volume'):
|
|
43
|
+
k: str = getattr(self, i)
|
|
44
|
+
if k in _set_key: raise Exception(f'you can not set "self.{i}" value in {_set_key}.\nself.{i}={k!r}')
|
|
45
|
+
if i != 'date':
|
|
46
|
+
dtype = df[k].dtype
|
|
47
|
+
if not isinstance(dtype, (np.dtypes.Float64DType, np.dtypes.Int64DType, np.dtypes.Float32DType, np.dtypes.Int32DType)):
|
|
48
|
+
raise TypeError(f'column dtype must be one of "float64" or "int64" or "float32" or "int32".(excluding "date" column)\ndf[{k!r}].dtype={dtype!r}')
|
|
49
|
+
|
|
50
|
+
# DataFrame 정렬
|
|
51
|
+
if sort_df:
|
|
52
|
+
df = df.sort_values([self.date]).reset_index()
|
|
53
|
+
|
|
54
|
+
if not self.list_ma: self.list_ma = tuple()
|
|
55
|
+
if calc_ma:
|
|
56
|
+
for i in self.list_ma: df[f'ma{i}'] = df[self.close].rolling(i).mean()
|
|
57
|
+
else:
|
|
58
|
+
keys = set(df.keys())
|
|
59
|
+
for i in self.list_ma:
|
|
60
|
+
if f'ma{i}' not in keys:
|
|
61
|
+
raise Exception(f'"ma{i}" column not in DataFrame.\nadd column or set calc_ma=True.')
|
|
62
|
+
|
|
63
|
+
df['x'] = df.index + 0.5
|
|
64
|
+
df['left'] = df['x'] - self.candlewidth_half
|
|
65
|
+
df['right'] = df['x'] + self.candlewidth_half
|
|
66
|
+
df['vleft'] = df['x'] - self.volumewidth_half
|
|
67
|
+
df['vright'] = df['x'] + self.volumewidth_half
|
|
68
|
+
|
|
69
|
+
df['top'] = np.where(df[self.Open] <= df[self.close], df[self.close], df[self.Open])
|
|
70
|
+
df['top'] = np.where(df[self.close] < df[self.Open], df[self.Open], df[self.close])
|
|
71
|
+
df['bottom'] = np.where(df[self.Open] <= df[self.close], df[self.Open], df[self.close])
|
|
72
|
+
df['bottom'] = np.where(df[self.close] < df[self.Open], df[self.close], df[self.Open])
|
|
73
|
+
|
|
74
|
+
# 양봉
|
|
75
|
+
df.loc[:, ['zero', 'facecolor', 'edgecolor']] = (0, self.color_up, self.color_up)
|
|
76
|
+
if self.color_up != self.color_down:
|
|
77
|
+
# 음봉
|
|
78
|
+
df.loc[df[self.close] < df[self.Open], ['facecolor', 'edgecolor']] = (self.color_down, self.color_down)
|
|
79
|
+
if self.color_up != self.color_flat:
|
|
80
|
+
# 보합
|
|
81
|
+
df.loc[df[self.close] == df[self.Open], ['facecolor', 'edgecolor']] = (self.color_flat, self.color_flat)
|
|
82
|
+
if self.color_up != self.color_up_down:
|
|
83
|
+
# 양봉(비우기)
|
|
84
|
+
df.loc[(df['facecolor'] == self.color_up) & (df[self.close] < df[self.close].shift(1)), 'facecolor'] = self.color_up_down
|
|
85
|
+
if self.color_down != self.color_down_up:
|
|
86
|
+
# 음봉(비우기)
|
|
87
|
+
df.loc[(df['facecolor'] == self.color_down) & (df[self.close].shift(1) < df[self.close]), ['facecolor']] = self.color_down_up
|
|
88
|
+
|
|
89
|
+
self.df = df
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
class CollectionMixin(DataMixin):
|
|
93
|
+
color_sliderline = 'k'
|
|
94
|
+
|
|
95
|
+
_masegment, _macolors = ({}, {})
|
|
96
|
+
|
|
97
|
+
def __init__(self, *args, **kwargs):
|
|
98
|
+
super().__init__(*args, **kwargs)
|
|
99
|
+
|
|
100
|
+
self._connect_event()
|
|
101
|
+
self._add_collection()
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
def _connect_event(self):
|
|
105
|
+
self.canvas.mpl_connect('pick_event', lambda x: self._on_pick(x))
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
def _add_collection(self):
|
|
109
|
+
self.macollection = LineCollection([], animated=True, antialiased=True, linewidth=1)
|
|
110
|
+
self.ax_price.add_collection(self.macollection)
|
|
111
|
+
|
|
112
|
+
self.slidercollection = LineCollection([], animated=True, antialiased=True)
|
|
113
|
+
self.ax_slider.add_collection(self.slidercollection)
|
|
114
|
+
|
|
115
|
+
# https://white.seolpyo.com/entry/145/
|
|
116
|
+
self.candlecollection = LineCollection([], animated=True, antialiased=True, linewidths=1)
|
|
117
|
+
self.ax_price.add_collection(self.candlecollection)
|
|
118
|
+
|
|
119
|
+
# https://white.seolpyo.com/entry/145/
|
|
120
|
+
self.volumecollection = LineCollection([], animated=True, antialiased=True, facecolors=self.colors_volume, linewidths=1, edgecolors='k')
|
|
121
|
+
self.ax_volume.add_collection(self.volumecollection)
|
|
122
|
+
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
def _set_collection(self):
|
|
126
|
+
candleseg = self.df[[
|
|
127
|
+
'x', self.high,
|
|
128
|
+
'x', 'top',
|
|
129
|
+
'left', 'top',
|
|
130
|
+
'left', 'bottom',
|
|
131
|
+
'x', 'bottom',
|
|
132
|
+
'x', self.low,
|
|
133
|
+
'x', 'bottom',
|
|
134
|
+
'right', 'bottom',
|
|
135
|
+
'right', 'top',
|
|
136
|
+
'x', 'top',
|
|
137
|
+
]].values
|
|
138
|
+
candleseg = candleseg.reshape(candleseg.shape[0], 10, 2)
|
|
139
|
+
|
|
140
|
+
self.candlecollection.set_segments(candleseg)
|
|
141
|
+
self.candlecollection.set_facecolor(self.df['facecolor'].values)
|
|
142
|
+
self.candlecollection.set_edgecolor(self.df['edgecolor'].values)
|
|
143
|
+
|
|
144
|
+
volseg = self.df[[
|
|
145
|
+
'left', 'zero',
|
|
146
|
+
'left', self.volume,
|
|
147
|
+
'right', self.volume,
|
|
148
|
+
'right', 'zero',
|
|
149
|
+
]].values
|
|
150
|
+
volseg = volseg.reshape(volseg.shape[0], 4, 2)
|
|
151
|
+
|
|
152
|
+
self.volumecollection.set_segments(volseg)
|
|
153
|
+
|
|
154
|
+
self._set_macollection()
|
|
155
|
+
|
|
156
|
+
# 가격이동평균선
|
|
157
|
+
maseg = reversed(self._masegment.values())
|
|
158
|
+
colors, widths = ([], [])
|
|
159
|
+
for i in reversed(self._macolors.values()): (colors.append(i), widths.append(1))
|
|
160
|
+
self.macollection.set_segments(maseg)
|
|
161
|
+
self.macollection.set_edgecolor(colors)
|
|
162
|
+
|
|
163
|
+
# 슬라이더 선형차트
|
|
164
|
+
keys = []
|
|
165
|
+
for i in reversed(self.list_ma):
|
|
166
|
+
keys.append('x')
|
|
167
|
+
keys.append(f'ma{i}')
|
|
168
|
+
sliderseg = self.df[keys + ['x', self.close]].values
|
|
169
|
+
sliderseg = sliderseg.reshape(sliderseg.shape[0], self.list_ma.__len__()+1, 2).swapaxes(0, 1)
|
|
170
|
+
(colors.append(self.color_sliderline), widths.append(1.8))
|
|
171
|
+
self.slidercollection.set_segments(sliderseg)
|
|
172
|
+
self.slidercollection.set_edgecolor(colors)
|
|
173
|
+
self.slidercollection.set_linewidth(widths)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
def _set_macollection(self):
|
|
177
|
+
# 기존 legend 제거
|
|
178
|
+
legends = self.ax_legend.get_legend()
|
|
179
|
+
if legends: legends.remove()
|
|
180
|
+
|
|
181
|
+
self._masegment.clear(), self._macolors.clear()
|
|
182
|
+
handles, labels = ([], [])
|
|
183
|
+
self._visible_ma.clear()
|
|
184
|
+
for n, i in enumerate(self.list_ma):
|
|
185
|
+
try: c = self.list_macolor[n]
|
|
186
|
+
except: c = self.color_sliderline
|
|
187
|
+
self._macolors[i] = c
|
|
188
|
+
# seg = self.df['x', f'ma{i}'].values
|
|
189
|
+
seg = self.df.loc[self.df[f'ma{i}'] != np.nan, ['x', f'ma{i}']].values
|
|
190
|
+
# print(f'{seg[:5]=}')
|
|
191
|
+
self._masegment[i] = seg
|
|
192
|
+
|
|
193
|
+
handles.append(Line2D([0, 1], [0, 1], color=c, linewidth=5, label=i))
|
|
194
|
+
labels.append(self.label_ma.format(i))
|
|
195
|
+
|
|
196
|
+
self._visible_ma.add(i)
|
|
197
|
+
|
|
198
|
+
# 가격이동평균선 legend 생성
|
|
199
|
+
if handles:
|
|
200
|
+
legends = self.ax_legend.legend(handles, labels, loc='lower left', ncol=10)
|
|
201
|
+
|
|
202
|
+
for i in legends.legend_handles:
|
|
203
|
+
i.set_picker(5)
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
def _on_pick(self, e):
|
|
207
|
+
self._pick_ma_action(e)
|
|
208
|
+
|
|
209
|
+
return self._draw()
|
|
210
|
+
|
|
211
|
+
def _pick_ma_action(self, e: PickEvent):
|
|
212
|
+
handle = e.artist
|
|
213
|
+
if e.artist.get_alpha() == 0.2:
|
|
214
|
+
visible = True
|
|
215
|
+
handle.set_alpha(1.0)
|
|
216
|
+
else:
|
|
217
|
+
visible = False
|
|
218
|
+
handle.set_alpha(0.2)
|
|
219
|
+
|
|
220
|
+
n = int(handle.get_label())
|
|
221
|
+
|
|
222
|
+
if visible: self._visible_ma = {i for i in self.list_ma if i in self._visible_ma or i == n}
|
|
223
|
+
else: self._visible_ma = {i for i in self._visible_ma if i != n}
|
|
224
|
+
|
|
225
|
+
self.macollection.set_segments([self._masegment[i] for i in reversed(self._masegment) if i in self._visible_ma])
|
|
226
|
+
colors = [self._macolors[i] for i in reversed(self._macolors) if i in self._visible_ma]
|
|
227
|
+
self.macollection.set_colors(colors)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
def _draw(self):
|
|
231
|
+
if self.fig.canvas is not self.canvas:
|
|
232
|
+
self.canvas = self.fig.canvas
|
|
233
|
+
self.canvas.draw()
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class BackgroundMixin(CollectionMixin):
|
|
238
|
+
background = None
|
|
239
|
+
candle_on_ma = True
|
|
240
|
+
|
|
241
|
+
_creating_background = False
|
|
242
|
+
|
|
243
|
+
def __init__(self, *args, **kwargs):
|
|
244
|
+
super().__init__(*args, **kwargs)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
def _connect_event(self):
|
|
248
|
+
super()._connect_event()
|
|
249
|
+
self.canvas.mpl_connect('draw_event', lambda x: self._on_draw(x))
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
def _create_background(self):
|
|
253
|
+
if self._creating_background: return
|
|
254
|
+
|
|
255
|
+
if self.fig.canvas is not self.canvas:
|
|
256
|
+
self.canvas = self.fig.canvas
|
|
257
|
+
|
|
258
|
+
self._creating_background = True
|
|
259
|
+
self._copy_bbox()
|
|
260
|
+
self._creating_background = False
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
def _copy_bbox(self):
|
|
264
|
+
self._draw_artist()
|
|
265
|
+
self.background = self.canvas.renderer.copy_from_bbox(self.fig.bbox)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
def _draw_artist(self):
|
|
269
|
+
renderer = self.canvas.renderer
|
|
270
|
+
|
|
271
|
+
self.ax_slider.xaxis.draw(renderer)
|
|
272
|
+
self.ax_slider.yaxis.draw(renderer)
|
|
273
|
+
|
|
274
|
+
self.slidercollection.draw(renderer)
|
|
275
|
+
|
|
276
|
+
self.ax_price.xaxis.draw(renderer)
|
|
277
|
+
self.ax_price.yaxis.draw(renderer)
|
|
278
|
+
|
|
279
|
+
if self.candle_on_ma:
|
|
280
|
+
self.macollection.draw(renderer)
|
|
281
|
+
self.candlecollection.draw(renderer)
|
|
282
|
+
else:
|
|
283
|
+
self.candlecollection.draw(renderer)
|
|
284
|
+
self.macollection.draw(renderer)
|
|
285
|
+
|
|
286
|
+
self.ax_volume.xaxis.draw(renderer)
|
|
287
|
+
self.ax_volume.yaxis.draw(renderer)
|
|
288
|
+
|
|
289
|
+
self.volumecollection.draw(renderer)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
def _on_draw(self, e):
|
|
293
|
+
self.background = None
|
|
294
|
+
self._restore_region()
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
def _restore_region(self):
|
|
298
|
+
if not self.background: self._create_background()
|
|
299
|
+
|
|
300
|
+
self.canvas.renderer.restore_region(self.background)
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
class DrawMixin(BackgroundMixin):
|
|
304
|
+
def set_data(self, df: pd.DataFrame, sort_df=True, calc_ma=True, change_lim=True, *_, **kwargs):
|
|
305
|
+
self._set_data(df, sort_df, calc_ma, change_lim, **kwargs)
|
|
306
|
+
return self.df
|
|
307
|
+
|
|
308
|
+
def _set_data(self, df: pd.DataFrame, sort_df=True, calc_ma=True, change_lim=True, *_, **kwargs):
|
|
309
|
+
self._generate_data(df, sort_df, calc_ma, **kwargs)
|
|
310
|
+
self._set_collection()
|
|
311
|
+
self._draw_collection(change_lim)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
def _draw_collection(self, change_lim=True):
|
|
315
|
+
xmax = self.df['x'].values[-1] + 1
|
|
316
|
+
|
|
317
|
+
xspace = xmax / 40
|
|
318
|
+
self.xmin, self.xmax = (-xspace, xmax+xspace)
|
|
319
|
+
# 슬라이더 xlim
|
|
320
|
+
self.ax_slider.set_xlim(self.xmin, self.xmax)
|
|
321
|
+
if change_lim:
|
|
322
|
+
# 주가 xlim
|
|
323
|
+
self.ax_price.set_xlim(self.xmin, self.xmax)
|
|
324
|
+
# 거래량 xlim
|
|
325
|
+
self.ax_volume.set_xlim(self.xmin, self.xmax)
|
|
326
|
+
|
|
327
|
+
ymin, ymax = (self.df[self.low].min(), self.df[self.high].max())
|
|
328
|
+
ysub = (ymax - ymin) / 15
|
|
329
|
+
|
|
330
|
+
# 슬라이더 ylim
|
|
331
|
+
self._slider_ymin, self._slider_ymax = (ymin-ysub, ymax+ysub)
|
|
332
|
+
self.ax_slider.set_ylim(self._slider_ymin, self._slider_ymax)
|
|
333
|
+
|
|
334
|
+
# 주가 ylim
|
|
335
|
+
self._price_ymin, self._price_ymax = (ymin-ysub, ymax+ysub)
|
|
336
|
+
if change_lim: self.ax_price.set_ylim(self._price_ymin, self._price_ymax)
|
|
337
|
+
|
|
338
|
+
# 거래량 ylim
|
|
339
|
+
self._vol_ymax = self.df[self.volume].max() * 1.2
|
|
340
|
+
if change_lim: self.ax_volume.set_ylim(0, self._vol_ymax)
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class Chart(DrawMixin, Mixin):
|
|
345
|
+
def _on_draw(self, e):
|
|
346
|
+
super()._on_draw(e)
|
|
347
|
+
return self.on_draw(e)
|
|
348
|
+
|
|
349
|
+
def _on_pick(self, e):
|
|
350
|
+
self.on_pick(e)
|
|
351
|
+
return super()._on_pick(e)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == '__main__':
|
|
355
|
+
import json
|
|
356
|
+
from time import time
|
|
357
|
+
|
|
358
|
+
import matplotlib.pyplot as plt
|
|
359
|
+
from pathlib import Path
|
|
360
|
+
|
|
361
|
+
with open(Path(__file__).parent / 'data/samsung.txt', 'r', encoding='utf-8') as txt:
|
|
362
|
+
data = json.load(txt)
|
|
363
|
+
print(f'{len(data)=}')
|
|
364
|
+
# data = data[:200]
|
|
365
|
+
df = pd.DataFrame(data)
|
|
366
|
+
|
|
367
|
+
t = time()
|
|
368
|
+
DrawMixin().set_data(df)
|
|
369
|
+
t2 = time() - t
|
|
370
|
+
print(f'{t2=}')
|
|
371
|
+
plt.show()
|