├── .github
├── FUNDING.yml
└── ISSUE_TEMPLATE
│ └── bug_report.md
├── .gitignore
├── .gitmodules
├── LICENSE.txt
├── README.md
├── build-distribute.sh
├── feature-nuts.jpg
├── feature1.png
├── feature2.jpg
├── feature3.jpg
├── finplot
├── __init__.py
├── _version.py
├── examples
│ ├── analyze-2.py
│ ├── analyze.py
│ ├── animate.py
│ ├── bfx.py
│ ├── bitmex-ws.py
│ ├── btc-long-term.py
│ ├── bubble-table.py
│ ├── complicated.py
│ ├── dockable.py
│ ├── embed.py
│ ├── heatmap.py
│ ├── line.py
│ ├── overlay-correlate.py
│ ├── pandas-df-plot.py
│ ├── renko-dark-mode.py
│ ├── requirements.txt
│ ├── snp500.py
│ └── volume-profile.py
├── live.py
└── pdplot.py
├── nuts.xcf
├── run-all.py
├── screenshot.jpg
└── setup.py
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [highfestiva]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to improve finplot
4 | title: "[BUG]"
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | ### Requirements (place an `x` in each of the `[ ]`)**
11 | * [ ] I realize finplot is not a web lib. (Hint: it's native!)
12 | * [ ] I've read the [snippets](https://github.com/highfestiva/finplot/wiki/Snippets) and not found what I'm looking for.
13 | * [ ] I've searched for any related issues and avoided creating a duplicate issue.
14 | * [ ] I've updated finplot (`pip install -U finplot`).
15 | * [ ] I've supplied the required data to run my code below.
16 |
17 | ### Code to reproduce
18 |
21 | ```python
22 | import finplot as fplt
23 | import pandas as pd
24 | ```
25 |
26 | ### Describe the bug
27 | A clear and concise description of what the bug is.
28 |
29 | ### Expected behavior
30 | A clear and concise description of what you expected to happen instead.
31 |
32 | #### Screenshots
33 | If applicable, add screenshots to help explain your problem.
34 |
35 | #### Reproducible in:
36 |
37 | *OS*:
38 | *finplot version*:
39 | *pyqtgraph version*:
40 | *pyqt version*:
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | dist/
3 | build/
4 | dumb/
5 | *.egg-info/
6 | screenshot.png
7 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "finplot.wiki"]
2 | path = finplot.wiki
3 | url = https://github.com/highfestiva/finplot.wiki.git
4 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Jonas Byström
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Finance Plot
2 |
3 | Finance Plotter, or finplot, is a performant library with a clean api to help you with your backtesting. It's
4 | optionated with good defaults, so you can start doing your work without having to setup plots, colors, scales,
5 | autoscaling, keybindings, handle panning+vertical zooming (which all non-finance libraries have problems with).
6 | And best of all: it can show hundreds of thousands of datapoints without batting an eye.
7 |
8 |
9 |
10 |
11 | ## Features
12 |
13 | * Great performance compared to mpl_finance, plotly and Bokeh
14 | * Clean api
15 | * Works with both stocks as well as cryptocurrencies on any time resolution
16 | * Show as many charts as you want on the same time axis, zoom on all of them at once
17 | * Auto-reload position where you were looking last run
18 | * Overlays, fill between, value bands, symbols, labels, legend, volume profile, heatmaps, etc.
19 | * Can show real-time updates, including orderbook. Save screenshot.
20 | * Comes with a [dozen](https://github.com/highfestiva/finplot/blob/master/finplot/examples) great examples.
21 |
22 | 
23 |
24 | 
25 |
26 | 
27 |
28 | 
29 |
30 |
31 | ## What it is not
32 |
33 | finplot *is not a web app*. It does not help you create an homebrew exchange. It does not work with Jupyter Labs.
34 |
35 | It is only intended for you to do backtesting in. That is not to say that you can't create a ticker or a trade
36 | widget yourself. The library is based on the eminent pyqtgraph, which is fast and flexible, so feel free to hack
37 | away if that's what you want.
38 |
39 |
40 | ## Easy installation
41 |
42 | ```bash
43 | $ pip install finplot
44 | ```
45 |
46 |
47 | ## Example
48 |
49 | It's straight-forward to start using. This shows every daily candle of Apple since the 80'ies:
50 |
51 | ```python
52 | import finplot as fplt
53 | import yfinance
54 |
55 | df = yfinance.download('AAPL')
56 | fplt.candlestick_ochl(df[['Open', 'Close', 'High', 'Low']])
57 | fplt.show()
58 | ```
59 |
60 | For more examples and a bunch of snippets, see the [examples](https://github.com/highfestiva/finplot/blob/master/finplot/examples/)
61 | directory or the [wiki](https://github.com/highfestiva/finplot/wiki). There you'll find how to plot MACD, Parabolic SAR, RSI,
62 | volume profile and much more.
63 |
64 |
65 | ## Coffee
66 |
67 | For future support and features, consider a small donation.
68 |
69 | BTC: bc1qk8m8yh86l2pz4eypflchr0tkn5aeud6cmt426m
70 |
71 | ETH: 0x684d7d4C52ed428AE9a36B2407ba909D896cDB67
72 |
--------------------------------------------------------------------------------
/build-distribute.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | rm -Rf build/ dist/ finplot.egg-info/
4 | python3 setup.py sdist bdist_wheel
5 |
6 | echo "Deploying in 10..."
7 | sleep 10
8 | python3 -m twine upload dist/*
9 |
--------------------------------------------------------------------------------
/feature-nuts.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/highfestiva/finplot/d2786a4daf5ac6f69273fe91b3965d5be3f2caf8/feature-nuts.jpg
--------------------------------------------------------------------------------
/feature1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/highfestiva/finplot/d2786a4daf5ac6f69273fe91b3965d5be3f2caf8/feature1.png
--------------------------------------------------------------------------------
/feature2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/highfestiva/finplot/d2786a4daf5ac6f69273fe91b3965d5be3f2caf8/feature2.jpg
--------------------------------------------------------------------------------
/feature3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/highfestiva/finplot/d2786a4daf5ac6f69273fe91b3965d5be3f2caf8/feature3.jpg
--------------------------------------------------------------------------------
/finplot/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | Financial data plotter with better defaults, api, behavior and performance than
4 | mpl_finance and plotly.
5 |
6 | Lines up your time-series with a shared X-axis; ideal for volume, RSI, etc.
7 |
8 | Zoom does something similar to what you'd normally expect for financial data,
9 | where the Y-axis is auto-scaled to highest high and lowest low in the active
10 | region.
11 | '''
12 |
13 | from ._version import __version__
14 |
15 | from ast import literal_eval
16 | from collections import OrderedDict, defaultdict
17 | from datetime import datetime, timezone
18 | from dateutil.tz import tzlocal
19 | from decimal import Decimal
20 | from functools import partial, partialmethod
21 | from finplot.live import Live
22 | from math import ceil, floor, fmod
23 | import numpy as np
24 | import os.path
25 | import pandas as pd
26 | import pyqtgraph as pg
27 | from pyqtgraph import QtCore, QtGui
28 |
29 |
30 |
31 | # appropriate types
32 | ColorMap = pg.ColorMap
33 |
34 | # module definitions, mostly colors
35 | legend_border_color = '#777'
36 | legend_fill_color = '#666a'
37 | legend_text_color = '#ddd6'
38 | soft_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
39 | hard_colors = ['#000000', '#772211', '#000066', '#555555', '#0022cc', '#ffcc00']
40 | colmap_clash = ColorMap([0.0, 0.2, 0.6, 1.0], [[127, 127, 255, 51], [0, 0, 127, 51], [255, 51, 102, 51], [255, 178, 76, 51]])
41 | foreground = '#000'
42 | background = '#fff'
43 | odd_plot_background = '#eaeaea'
44 | grid_alpha = 0.2
45 | crosshair_right_margin = 200
46 | crosshair_bottom_margin = 50
47 | candle_bull_color = '#26a69a'
48 | candle_bear_color = '#ef5350'
49 | candle_bull_body_color = background
50 | candle_bear_body_color = candle_bear_color
51 | candle_shadow_width = 1
52 | volume_bull_color = '#92d2cc'
53 | volume_bear_color = '#f7a9a7'
54 | volume_bull_body_color = volume_bull_color
55 | volume_neutral_color = '#bbb'
56 | poc_color = '#006'
57 | band_color = '#d2dfe6'
58 | draw_band_color = '#a0c0e0a0'
59 | cross_hair_color = '#0007'
60 | draw_line_color = '#000'
61 | draw_done_color = '#555'
62 | significant_decimals = 8
63 | significant_eps = 1e-8
64 | max_decimals = 10
65 | max_zoom_points = 20 # number of visible candles when maximum zoomed in
66 | axis_height_factor = {0: 2}
67 | clamp_grid = True
68 | right_margin_candles = 5 # whitespace at the right-hand side
69 | side_margin = 0.5
70 | lod_candles = 3000
71 | lod_labels = 700
72 | cache_candle_factor = 3 # factor extra candles rendered to buffer
73 | y_pad = 0.03 # 3% padding at top and bottom of autozoom plots
74 | y_label_width = 65
75 | timestamp_format = '%Y-%m-%d %H:%M:%S.%f'
76 | display_timezone = tzlocal() # default to local
77 | truncate_timestamp = True
78 | winx,winy,winw,winh = 300,150,800,400
79 | win_recreate_delta = 30
80 | log_plot_offset = -2.2222222e-16 # I could file a bug report, probably in PyQt, but this is more fun
81 | # format: mode, min-duration, pd-freq-fmt, tick-str-len
82 | time_splits = [('years', 2*365*24*60*60, 'YS', 4), ('months', 3*30*24*60*60, 'MS', 10), ('weeks', 3*7*24*60*60, 'W-MON', 10),
83 | ('days', 3*24*60*60, 'D', 10), ('hours', 9*60*60, '3h', 16), ('hours', 3*60*60, 'h', 16),
84 | ('minutes', 45*60, '15min', 16), ('minutes', 15*60, '5min', 16), ('minutes', 3*60, 'min', 16),
85 | ('seconds', 45, '15s', 19), ('seconds', 15, '5s', 19), ('seconds', 3, 's', 19),
86 | ('milliseconds', 0, 'ms', 23)]
87 |
88 | app = None
89 | windows = [] # no gc
90 | timers = [] # no gc
91 | sounds = {} # no gc
92 | epoch_period = 1e30
93 | last_ax = None # always assume we want to plot in the last axis, unless explicitly specified
94 | overlay_axs = [] # for keeping track of candlesticks in overlays
95 | viewrestore = False
96 | key_esc_close = True # ESC key closes window
97 | master_data = {}
98 |
99 |
100 |
101 | lerp = lambda t,a,b: t*b+(1-t)*a
102 |
103 |
104 |
105 | class EpochAxisItem(pg.AxisItem):
106 | def __init__(self, vb, *args, **kwargs):
107 | super().__init__(*args, **kwargs)
108 | self.vb = vb
109 |
110 | def tickStrings(self, values, scale, spacing):
111 | if self.mode == 'num':
112 | return ['%g'%v for v in values]
113 | conv = _x2year if self.mode=='years' else _x2local_t
114 | strs = [conv(self.vb.datasrc, value)[0] for value in values]
115 | if all(_is_str_midnight(s) for s in strs if s): # all at midnight -> round to days
116 | strs = [s.partition(' ')[0] for s in strs]
117 | return strs
118 |
119 | def tickValues(self, minVal, maxVal, size):
120 | self.mode = 'num'
121 | ax = self.vb.parent()
122 | datasrc = _get_datasrc(ax, require=False)
123 | if datasrc is None or not self.vb.x_indexed:
124 | return super().tickValues(minVal, maxVal, size)
125 | # calculate if we use years, days, etc.
126 | t0,t1,_,_,_ = datasrc.hilo(minVal, maxVal)
127 | t0,t1 = pd.to_datetime(t0), pd.to_datetime(t1)
128 | dts = (t1-t0).total_seconds()
129 | gfx_width = int(size)
130 | for mode, dtt, freq, ticklen in time_splits:
131 | if dts > dtt:
132 | self.mode = mode
133 | desired_ticks = gfx_width / ((ticklen+2) * 10) - 1 # an approximation is fine
134 | if self.vb.datasrc is not None and not self.vb.datasrc.is_smooth_time():
135 | desired_ticks -= 1 # leave more space for unevenly spaced ticks
136 | desired_ticks = max(desired_ticks, 4)
137 | to_midnight = freq in ('YS', 'MS', 'W-MON', 'D')
138 | tz = display_timezone if to_midnight else None # for shorter timeframes, timezone seems buggy
139 | rng = pd.date_range(t0, t1, tz=tz, normalize=to_midnight, freq=freq)
140 | steps = len(rng) if len(rng)&1==0 else len(rng)+1 # reduce jitter between e.g. 5<-->10 ticks for resolution close to limit
141 | step = int(steps/desired_ticks) or 1
142 | rng = rng[::step]
143 | if not to_midnight:
144 | try: rng = rng.round(freq=freq)
145 | except: pass
146 | ax = self.vb.parent()
147 | rng = _pdtime2index(ax=ax, ts=pd.Series(rng), require_time=True)
148 | indices = [ceil(i) for i in rng if i>-1e200]
149 | return [(0, indices)]
150 | return [(0,[])]
151 |
152 | def generateDrawSpecs(self, p):
153 | specs = super().generateDrawSpecs(p)
154 | if specs:
155 | if not self.style['showValues']:
156 | pen,p0,p1 = specs[0] # axis specs
157 | specs = [(_makepen('#fff0'),p0,p1)] + list(specs[1:]) # don't draw axis if hiding values
158 | else:
159 | # throw out ticks that are out of bounds
160 | text_specs = specs[2]
161 | if len(text_specs) >= 4:
162 | rect,flags,text = text_specs[0]
163 | if rect.left() < 0:
164 | del text_specs[0]
165 | rect,flags,text = text_specs[-1]
166 | if rect.right() > self.geometry().width():
167 | del text_specs[-1]
168 | # ... and those that overlap
169 | x = 1e6
170 | for i,(rect,flags,text) in reversed(list(enumerate(text_specs))):
171 | if rect.right() >= x:
172 | del text_specs[i]
173 | else:
174 | x = rect.left()
175 | return specs
176 |
177 |
178 |
179 | class YAxisItem(pg.AxisItem):
180 | def __init__(self, vb, *args, **kwargs):
181 | super().__init__(*args, **kwargs)
182 | self.vb = vb
183 | self.hide_strings = False
184 | self.style['autoExpandTextSpace'] = False
185 | self.style['autoReduceTextSpace'] = False
186 | self.next_fmt = '%g'
187 |
188 | def tickValues(self, minVal, maxVal, size):
189 | vs = super().tickValues(minVal, maxVal, size)
190 | if len(vs) < 3:
191 | return vs
192 | return self.fmt_values(vs)
193 |
194 | def logTickValues(self, minVal, maxVal, size, stdTicks):
195 | v1 = int(floor(minVal))
196 | v2 = int(ceil(maxVal))
197 | minor = []
198 | for v in range(v1, v2):
199 | minor.extend([v+l for l in np.log10(np.linspace(1, 9.9, 90))])
200 | minor = [x for x in minor if x>minVal and x 10:
204 | minor = minor[::len(minor)//5]
205 | vs = [(None, minor)]
206 | return self.fmt_values(vs)
207 |
208 | def tickStrings(self, values, scale, spacing):
209 | if self.hide_strings:
210 | return []
211 | xform = self.vb.yscale.xform
212 | return [self.next_fmt%xform(value) for value in values]
213 |
214 | def fmt_values(self, vs):
215 | xform = self.vb.yscale.xform
216 | gs = ['%g'%xform(v) for v in vs[-1][1]]
217 | if not gs:
218 | return vs
219 | if any(['e' in g for g in gs]):
220 | maxdec = max([len((g).partition('.')[2].partition('e')[0]) for g in gs if 'e' in g])
221 | self.next_fmt = '%%.%ie' % maxdec
222 | elif gs:
223 | maxdec = max([len((g).partition('.')[2]) for g in gs])
224 | self.next_fmt = '%%.%if' % maxdec
225 | else:
226 | self.next_fmt = '%g'
227 | return vs
228 |
229 |
230 |
231 | class YScale:
232 | def __init__(self, scaletype, scalef):
233 | self.scaletype = scaletype
234 | self.set_scale(scalef)
235 |
236 | def set_scale(self, scale):
237 | self.scalef = scale
238 |
239 | def xform(self, y):
240 | if self.scaletype == 'log':
241 | y = 10**y
242 | y = y * self.scalef
243 | return y
244 |
245 | def invxform(self, y, verify=False):
246 | y /= self.scalef
247 | if self.scaletype == 'log':
248 | if verify and y <= 0:
249 | return -1e6 / self.scalef
250 | y = np.log10(y)
251 | return y
252 |
253 |
254 |
255 | class PandasDataSource:
256 | '''Candle sticks: create with five columns: time, open, close, hi, lo - in that order.
257 | Volume bars: create with three columns: time, open, close, volume - in that order.
258 | For all other types, time needs to be first, usually followed by one or more Y-columns.'''
259 | def __init__(self, df):
260 | if type(df.index) == pd.DatetimeIndex or df.index[-1]>1e8 or '.RangeIndex' not in str(type(df.index)):
261 | df = df.reset_index()
262 | self.df = df.copy()
263 | # manage time column
264 | if _has_timecol(self.df):
265 | timecol = self.df.columns[0]
266 | dtype = str(df[timecol].dtype)
267 | isnum = ('int' in dtype or 'float' in dtype) and df[timecol].iloc[-1] < 1e7
268 | if not isnum:
269 | self.df[timecol] = _pdtime2epoch(df[timecol])
270 | self.standalone = _is_standalone(self.df[timecol])
271 | self.col_data_offset = 1 # no. of preceeding columns for other plots and time column
272 | else:
273 | self.standalone = False
274 | self.col_data_offset = 0 # no. of preceeding columns for other plots and time column
275 | # setup data for joining data sources and zooming
276 | self.scale_cols = [i for i in range(self.col_data_offset,len(self.df.columns)) if self.df.iloc[:,i].dtype!=object]
277 | self.cache_hilo = OrderedDict()
278 | self.renames = {}
279 | newcols = []
280 | for col in self.df.columns:
281 | oldcol = col
282 | while col in newcols:
283 | col = str(col)+'+'
284 | newcols.append(col)
285 | if oldcol != col:
286 | self.renames[oldcol] = col
287 | self.df.columns = newcols
288 | self.pre_update = lambda df: df
289 | self.post_update = lambda df: df
290 | self._period = None
291 | self._smooth_time = None
292 | self.is_sparse = self.df[self.df.columns[self.col_data_offset]].isnull().sum().max() > len(self.df)//2
293 |
294 | @property
295 | def period_ns(self):
296 | if len(self.df) <= 1:
297 | return 1
298 | if not self._period:
299 | self._period = self.calc_period_ns()
300 | return self._period
301 |
302 | def calc_period_ns(self, n=100, delta=lambda dt: int(dt.median())):
303 | timecol = self.df.columns[0]
304 | dtimes = self.df[timecol].iloc[0:n].diff()
305 | dtimes = dtimes[dtimes!=0]
306 | return delta(dtimes) if len(dtimes)>1 else 1
307 |
308 | @property
309 | def index(self):
310 | return self.df.index
311 |
312 | @property
313 | def x(self):
314 | timecol = self.df.columns[0]
315 | return self.df[timecol]
316 |
317 | @property
318 | def y(self):
319 | col = self.df.columns[self.col_data_offset]
320 | return self.df[col]
321 |
322 | @property
323 | def z(self):
324 | col = self.df.columns[self.col_data_offset+1]
325 | return self.df[col]
326 |
327 | @property
328 | def xlen(self):
329 | return len(self.df)
330 |
331 | def calc_significant_decimals(self, full):
332 | def float_round(f):
333 | return float('%.3e'%f) # 0.00999748 -> 0.01
334 | def remainder_ok(a, b):
335 | c = a % b
336 | if c / b > 0.98: # remainder almost same as denominator
337 | c = abs(c-b)
338 | return c < b*0.6 # half is fine
339 | def calc_sd(ser):
340 | ser = ser.iloc[:1000]
341 | absdiff = ser.diff().abs()
342 | absdiff[absdiff<1e-30] = np.float32(1e30)
343 | smallest_diff = absdiff.min()
344 | if smallest_diff > 1e29: # just 0s?
345 | return 0
346 | smallest_diff = float_round(smallest_diff)
347 | absser = ser.iloc[:100].abs()
348 | for _ in range(2): # check if we have a remainder that is a better epsilon
349 | remainder = [fmod(v,smallest_diff) for v in absser]
350 | remainder = [v for v in remainder if v>smallest_diff/20]
351 | if not remainder:
352 | break
353 | smallest_diff_r = min(remainder)
354 | if smallest_diff*0.05 < smallest_diff_r < smallest_diff * 0.7 and remainder_ok(smallest_diff, smallest_diff_r):
355 | smallest_diff = smallest_diff_r
356 | else:
357 | break
358 | return smallest_diff
359 | def calc_dec(ser, smallest_diff):
360 | if not full: # line plots usually have extreme resolution
361 | absmax = ser.iloc[:300].abs().max()
362 | s = '%.3e' % absmax
363 | else: # candles
364 | s = '%.2e' % smallest_diff
365 | base,_,exp = s.partition('e')
366 | base = base.rstrip('0')
367 | exp = -int(exp)
368 | max_base_decimals = min(5, -exp+2) if exp < 0 else 3
369 | base_decimals = max(0, min(max_base_decimals, len(base)-2))
370 | decimals = exp + base_decimals
371 | decimals = max(0, min(max_decimals, decimals))
372 | if not full: # apply grid for line plots only
373 | smallest_diff = max(10**(-decimals), smallest_diff)
374 | return decimals, smallest_diff
375 | # first calculate EPS for series 0&1, then do decimals
376 | sds = [calc_sd(self.y)] # might be all zeros for bar charts
377 | if len(self.scale_cols) > 1:
378 | sds.append(calc_sd(self.z)) # if first is open, this might be close
379 | sds = [sd for sd in sds if sd>0]
380 | big_diff = max(sds)
381 | smallest_diff = min([sd for sd in sds if sd>big_diff/100]) # filter out extremely small epsilons
382 | ser = self.z if len(self.scale_cols) > 1 else self.y
383 | return calc_dec(ser, smallest_diff)
384 |
385 | def update_init_x(self, init_steps):
386 | self.init_x0, self.init_x1 = _xminmax(self, x_indexed=True, init_steps=init_steps)
387 |
388 | def closest_time(self, x):
389 | timecol = self.df.columns[0]
390 | return self.df.loc[int(x), timecol]
391 |
392 | def timebased(self):
393 | return self.df.iloc[-1,0] > 1e7
394 |
395 | def is_smooth_time(self):
396 | if self._smooth_time is None:
397 | # less than 1% time delta is smooth
398 | self._smooth_time = self.timebased() and (np.abs(np.diff(self.x.values[1:100])[1:]//(self.period_ns//1000)-1000) < 10).all()
399 | return self._smooth_time
400 |
401 | def addcols(self, datasrc):
402 | new_scale_cols = [c+len(self.df.columns)-datasrc.col_data_offset for c in datasrc.scale_cols]
403 | self.scale_cols += new_scale_cols
404 | orig_col_data_cnt = len(self.df.columns)
405 | if _has_timecol(datasrc.df):
406 | timecol = self.df.columns[0]
407 | df = self.df.set_index(timecol)
408 | timecol = timecol if timecol in datasrc.df.columns else datasrc.df.columns[0]
409 | newcols = datasrc.df.set_index(timecol)
410 | else:
411 | df = self.df
412 | newcols = datasrc.df
413 | cols = list(newcols.columns)
414 | for i,col in enumerate(cols):
415 | old_col = col
416 | while col in self.df.columns:
417 | cols[i] = col = str(col)+'+'
418 | if old_col != col:
419 | datasrc.renames[old_col] = col
420 | newcols.columns = cols
421 | self.df = df.join(newcols, how='outer')
422 | if _has_timecol(datasrc.df):
423 | self.df.reset_index(inplace=True)
424 | datasrc.df = self.df # they are the same now
425 | datasrc.init_x0 = self.init_x0
426 | datasrc.init_x1 = self.init_x1
427 | datasrc.col_data_offset = orig_col_data_cnt
428 | datasrc.scale_cols = new_scale_cols
429 | self.cache_hilo = OrderedDict()
430 | self._period = self._smooth_time = None
431 | datasrc._period = datasrc._smooth_time = None
432 | ldf2 = len(self.df) // 2
433 | self.is_sparse = self.is_sparse or self.df[self.df.columns[self.col_data_offset]].isnull().sum().max() > ldf2
434 | datasrc.is_sparse = datasrc.is_sparse or datasrc.df[datasrc.df.columns[datasrc.col_data_offset]].isnull().sum().max() > ldf2
435 |
436 | def update(self, datasrc):
437 | df = self.pre_update(self.df)
438 | orig_cols = list(df.columns)
439 | timecol,orig_cols = orig_cols[0],orig_cols[1:]
440 | df = df.set_index(timecol)
441 | input_df = datasrc.df.set_index(datasrc.df.columns[0])
442 | input_df.columns = [self.renames.get(col, col) for col in input_df.columns]
443 | # pad index if the input data is a sub-set
444 | if len(input_df) > 0 and len(df) > 0 and (len(df) != len(input_df) or input_df.index[-1] != df.index[-1]):
445 | output_df = pd.merge(input_df, df[[]], how='outer', left_index=True, right_index=True)
446 | else:
447 | output_df = input_df
448 | for col in df.columns:
449 | if col not in output_df.columns:
450 | output_df[col] = df[col]
451 | # if neccessary, cut out unwanted data
452 | if len(input_df) > 0 and len(df) > 0:
453 | start_idx = end_idx = None
454 | if input_df.index[0] > df.index[0]:
455 | start_idx = 0
456 | if input_df.index[-1] < df.index[-1]:
457 | end_idx = -1
458 | if start_idx is not None or end_idx is not None:
459 | end_idx = None if end_idx == -1 else end_idx
460 | output_df = output_df.loc[input_df.index[start_idx:end_idx], :]
461 | output_df = self.post_update(output_df)
462 | output_df = output_df.reset_index()
463 | self.df = output_df[[output_df.columns[0]]+orig_cols] if orig_cols else output_df
464 | self.init_x1 = self.xlen + right_margin_candles - side_margin
465 | self.cache_hilo = OrderedDict()
466 | self._period = self._smooth_time = None
467 |
468 | def set_df(self, df):
469 | self.df = df
470 | self.cache_hilo = OrderedDict()
471 | self._period = self._smooth_time = None
472 |
473 | def hilo(self, x0, x1):
474 | '''Return five values in time range: t0, t1, highest, lowest, number of rows.'''
475 | if x0 == x1:
476 | x0 = x1 = int(x1)
477 | else:
478 | x0,x1 = int(x0+0.5),int(x1)
479 | query = '%i,%i' % (x0,x1)
480 | if query not in self.cache_hilo:
481 | v = self.cache_hilo[query] = self._hilo(x0, x1)
482 | else:
483 | # re-insert to raise prio
484 | v = self.cache_hilo[query] = self.cache_hilo.pop(query)
485 | if len(self.cache_hilo) > 100: # drop if too many
486 | del self.cache_hilo[next(iter(self.cache_hilo))]
487 | return v
488 |
489 | def _hilo(self, x0, x1):
490 | df = self.df.loc[x0:x1, :]
491 | if not len(df):
492 | return 0,0,0,0,0
493 | timecol = df.columns[0]
494 | t0 = df[timecol].iloc[0]
495 | t1 = df[timecol].iloc[-1]
496 | valcols = df.columns[self.scale_cols]
497 | hi = df[valcols].max().max()
498 | lo = df[valcols].min().min()
499 | return t0,t1,hi,lo,len(df)
500 |
501 | def rows(self, colcnt, x0, x1, yscale, lod=True, resamp=None):
502 | df = self.df.loc[x0:x1, :]
503 | if self.is_sparse:
504 | df = df.loc[df.iloc[:,self.col_data_offset].notna(), :]
505 | origlen = len(df)
506 | return self._rows(df, colcnt, yscale=yscale, lod=lod, resamp=resamp), origlen
507 |
508 | def _rows(self, df, colcnt, yscale, lod, resamp):
509 | colcnt -= 1 # time is always implied
510 | colidxs = [0] + list(range(self.col_data_offset, self.col_data_offset+colcnt))
511 | if lod and len(df) > lod_candles:
512 | if resamp:
513 | df = self._resample(df, colcnt, resamp)
514 | colidxs = None
515 | else:
516 | df = df.iloc[::len(df)//lod_candles]
517 | dfr = df.iloc[:,colidxs] if colidxs else df
518 | if yscale.scaletype == 'log' or yscale.scalef != 1:
519 | dfr = dfr.copy()
520 | for i in range(1, colcnt+1):
521 | colname = dfr.columns[i]
522 | if dfr[colname].dtype != object:
523 | dfr[colname] = yscale.invxform(dfr.iloc[:,i])
524 | return dfr
525 |
526 | def _resample(self, df, colcnt, resamp):
527 | cdo = self.col_data_offset
528 | sample_rate = len(df) * 5 // lod_candles
529 | offset = len(df) % sample_rate
530 | dfd = df[[df.columns[0]]+[df.columns[cdo]]].iloc[offset::sample_rate]
531 | c = df[df.columns[cdo+1]].iloc[offset+sample_rate-1::sample_rate]
532 | c.index -= sample_rate - 1
533 | dfd[df.columns[cdo+1]] = c
534 | if resamp == 'hilo':
535 | dfd[df.columns[cdo+2]] = df[df.columns[cdo+2]].rolling(sample_rate).max().shift(-sample_rate+1)
536 | dfd[df.columns[cdo+3]] = df[df.columns[cdo+3]].rolling(sample_rate).min().shift(-sample_rate+1)
537 | else:
538 | dfd[df.columns[cdo+2]] = df[df.columns[cdo+2]].rolling(sample_rate).sum().shift(-sample_rate+1)
539 | dfd[df.columns[cdo+3]] = df[df.columns[cdo+3]].rolling(sample_rate).sum().shift(-sample_rate+1)
540 | # append trailing columns
541 | trailing_colidx = cdo + 4
542 | for i in range(trailing_colidx, colcnt):
543 | col = df.columns[i]
544 | dfd[col] = df[col].iloc[offset::sample_rate]
545 | return dfd
546 |
547 | def __eq__(self, other):
548 | return id(self) == id(other) or id(self.df) == id(other.df)
549 |
550 | def __hash__(self):
551 | return id(self)
552 |
553 |
554 | class FinWindow(pg.GraphicsLayoutWidget):
555 | def __init__(self, title, **kwargs):
556 | global winx, winy
557 | self.title = title
558 | pg.mkQApp()
559 | super().__init__(**kwargs)
560 | self.setWindowTitle(title)
561 | self.setGeometry(winx, winy, winw, winh)
562 | winx = (winx+win_recreate_delta) % 800
563 | winy = (winy+win_recreate_delta) % 500
564 | self.centralWidget.installEventFilter(self)
565 | self.ci.setContentsMargins(0, 0, 0, 0)
566 | self.ci.setSpacing(-1)
567 | self.closing = False
568 |
569 | @property
570 | def axs(self):
571 | return [ax for ax in self.ci.items if isinstance(ax, pg.PlotItem)]
572 |
573 | def autoRangeEnabled(self):
574 | return [True, True]
575 |
576 | def close(self):
577 | self.closing = True
578 | _savewindata(self)
579 | _clear_timers()
580 | return super().close()
581 |
582 | def eventFilter(self, obj, ev):
583 | if ev.type()== QtCore.QEvent.Type.WindowDeactivate:
584 | _savewindata(self)
585 | return False
586 |
587 | def resizeEvent(self, ev):
588 | '''We resize and set the top Y axis larger according to the axis_height_factor.
589 | No point in trying to use the "row stretch factor" in Qt which is broken
590 | beyond repair.'''
591 | if ev and not self.closing:
592 | axs = self.axs
593 | new_win_height = ev.size().height()
594 | old_win_height = ev.oldSize().height() if ev.oldSize().height() > 0 else new_win_height
595 | client_borders = old_win_height - sum(ax.vb.size().height() for ax in axs)
596 | client_borders = min(max(client_borders, 0), 30) # hrm
597 | new_height = new_win_height - client_borders
598 | for i,ax in enumerate(axs):
599 | j = axis_height_factor.get(i, 1)
600 | f = j / (len(axs)+sum(axis_height_factor.values())-len(axis_height_factor))
601 | ax.setMinimumSize(100 if j>1 else 50, new_height*f)
602 | return super().resizeEvent(ev)
603 |
604 | def leaveEvent(self, ev):
605 | if not self.closing:
606 | super().leaveEvent(ev)
607 |
608 |
609 | class FinCrossHair:
610 | def __init__(self, ax, color):
611 | self.ax = ax
612 | self.x = 0
613 | self.y = 0
614 | self.clamp_x = 0
615 | self.clamp_y = 0
616 | self.infos = []
617 | pen = pg.mkPen(color=color, style=QtCore.Qt.PenStyle.CustomDashLine, dash=[7, 7])
618 | self.vline = pg.InfiniteLine(angle=90, movable=False, pen=pen)
619 | self.hline = pg.InfiniteLine(angle=0, movable=False, pen=pen)
620 | self.xtext = pg.TextItem(color=color, anchor=(0,1))
621 | self.ytext = pg.TextItem(color=color, anchor=(0,0))
622 | self.vline.setZValue(50)
623 | self.hline.setZValue(50)
624 | self.xtext.setZValue(50)
625 | self.ytext.setZValue(50)
626 | self.show()
627 |
628 | def update(self, point=None):
629 | if point is not None:
630 | self.x,self.y = x,y = point.x(),point.y()
631 | else:
632 | x,y = self.x,self.y
633 | x,y = _clamp_xy(self.ax, x,y)
634 | if x == self.clamp_x and y == self.clamp_y:
635 | return
636 | self.clamp_x,self.clamp_y = x,y
637 | self.vline.setPos(x)
638 | self.hline.setPos(y)
639 | self.xtext.setPos(x, y)
640 | self.ytext.setPos(x, y)
641 | rng = self.ax.vb.y_max - self.ax.vb.y_min
642 | rngmax = abs(self.ax.vb.y_min) + rng # any approximation is fine
643 | sd,se = (self.ax.significant_decimals,self.ax.significant_eps) if clamp_grid else (significant_decimals,significant_eps)
644 | timebased = False
645 | if self.ax.vb.x_indexed:
646 | xtext,timebased = _x2local_t(self.ax.vb.datasrc, x)
647 | else:
648 | xtext = _round_to_significant(rng, rngmax, x, sd, se)
649 | linear_y = y
650 | y = self.ax.vb.yscale.xform(y)
651 | ytext = _round_to_significant(rng, rngmax, y, sd, se)
652 | if not timebased:
653 | if xtext:
654 | xtext = 'x ' + xtext
655 | ytext = 'y ' + ytext
656 | screen_pos = self.ax.mapFromView(pg.Point(x, linear_y))
657 | far_right = self.ax.boundingRect().right() - crosshair_right_margin
658 | far_bottom = self.ax.boundingRect().bottom() - crosshair_bottom_margin
659 | close2right = screen_pos.x() > far_right
660 | close2bottom = screen_pos.y() > far_bottom
661 | try:
662 | for info in self.infos:
663 | xtext,ytext = info(x,y,xtext,ytext)
664 | except Exception as e:
665 | print('Crosshair error:', type(e), e)
666 | space = ' '
667 | if close2right:
668 | xtext = xtext + space
669 | ytext = ytext + space
670 | xanchor = [1,1]
671 | yanchor = [1,0]
672 | else:
673 | xtext = space + xtext
674 | ytext = space + ytext
675 | xanchor = [0,1]
676 | yanchor = [0,0]
677 | if close2bottom:
678 | yanchor = [1,1]
679 | if close2right:
680 | xanchor = [1,2]
681 | else:
682 | ytext = ytext + space
683 | self.xtext.setAnchor(xanchor)
684 | self.ytext.setAnchor(yanchor)
685 | self.xtext.setText(xtext)
686 | self.ytext.setText(ytext)
687 |
688 | def show(self):
689 | self.ax.addItem(self.vline, ignoreBounds=True)
690 | self.ax.addItem(self.hline, ignoreBounds=True)
691 | self.ax.addItem(self.xtext, ignoreBounds=True)
692 | self.ax.addItem(self.ytext, ignoreBounds=True)
693 |
694 | def hide(self):
695 | self.ax.removeItem(self.xtext)
696 | self.ax.removeItem(self.ytext)
697 | self.ax.removeItem(self.vline)
698 | self.ax.removeItem(self.hline)
699 |
700 |
701 |
702 | class FinLegendItem(pg.LegendItem):
703 | def __init__(self, border_color, fill_color, **kwargs):
704 | super().__init__(**kwargs)
705 | self.layout.setVerticalSpacing(2)
706 | self.layout.setHorizontalSpacing(20)
707 | self.layout.setContentsMargins(2, 2, 10, 2)
708 | self.border_color = border_color
709 | self.fill_color = fill_color
710 |
711 | def paint(self, p, *args):
712 | p.setPen(pg.mkPen(self.border_color))
713 | p.setBrush(pg.mkBrush(self.fill_color))
714 | p.drawRect(self.boundingRect())
715 |
716 |
717 |
718 | class FinPolyLine(pg.PolyLineROI):
719 | def __init__(self, vb, *args, **kwargs):
720 | self.vb = vb # init before parent constructor
721 | self.texts = []
722 | super().__init__(*args, **kwargs)
723 |
724 | def addSegment(self, h1, h2, index=None):
725 | super().addSegment(h1, h2, index)
726 | text = pg.TextItem(color=draw_line_color)
727 | text.setZValue(50)
728 | text.segment = self.segments[-1 if index is None else index]
729 | if index is None:
730 | self.texts.append(text)
731 | else:
732 | self.texts.insert(index, text)
733 | self.update_text(text)
734 | self.vb.addItem(text, ignoreBounds=True)
735 |
736 | def removeSegment(self, seg):
737 | super().removeSegment(seg)
738 | for text in list(self.texts):
739 | if text.segment == seg:
740 | self.vb.removeItem(text)
741 | self.texts.remove(text)
742 |
743 | def update_text(self, text):
744 | h0 = text.segment.handles[0]['item']
745 | h1 = text.segment.handles[1]['item']
746 | diff = h1.pos() - h0.pos()
747 | if diff.y() < 0:
748 | text.setAnchor((0.5,0))
749 | else:
750 | text.setAnchor((0.5,1))
751 | text.setPos(h1.pos())
752 | text.setText(_draw_line_segment_text(self, text.segment, h0.pos(), h1.pos()))
753 |
754 | def update_texts(self):
755 | for text in self.texts:
756 | self.update_text(text)
757 |
758 | def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier, finish=True, coords='parent'):
759 | super().movePoint(handle, pos, modifiers, finish, coords)
760 | self.update_texts()
761 |
762 | def segmentClicked(self, segment, ev=None, pos=None):
763 | pos = segment.mapToParent(ev.pos())
764 | pos = _clamp_point(self.vb.parent(), pos)
765 | super().segmentClicked(segment, pos=pos)
766 | self.update_texts()
767 |
768 | def addHandle(self, info, index=None):
769 | handle = super().addHandle(info, index)
770 | handle.movePoint = partial(_roihandle_move_snap, self.vb, handle.movePoint)
771 | return handle
772 |
773 |
774 | class FinLine(pg.GraphicsObject):
775 | def __init__(self, points, pen):
776 | super().__init__()
777 | self.points = points
778 | self.pen = pen
779 |
780 | def paint(self, p, *args):
781 | p.setPen(self.pen)
782 | p.drawPath(self.shape())
783 |
784 | def shape(self):
785 | p = QtGui.QPainterPath()
786 | p.moveTo(*self.points[0])
787 | p.lineTo(*self.points[1])
788 | return p
789 |
790 | def boundingRect(self):
791 | return self.shape().boundingRect()
792 |
793 |
794 | class FinEllipse(pg.EllipseROI):
795 | def addRotateHandle(self, *args, **kwargs):
796 | pass
797 |
798 |
799 | class FinRect(pg.RectROI):
800 | def __init__(self, ax, brush, *args, **kwargs):
801 | self.ax = ax
802 | self.brush = brush
803 | super().__init__(*args, **kwargs)
804 |
805 | def paint(self, p, *args):
806 | r = QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized()
807 | p.setPen(self.currentPen)
808 | p.setBrush(self.brush)
809 | p.translate(r.left(), r.top())
810 | p.scale(r.width(), r.height())
811 | p.drawRect(0, 0, 1, 1)
812 |
813 | def addScaleHandle(self, *args, **kwargs):
814 | if self.resizable:
815 | super().addScaleHandle(*args, **kwargs)
816 |
817 |
818 | class FinViewBox(pg.ViewBox):
819 | def __init__(self, win, init_steps=300, yscale=YScale('linear', 1), v_zoom_scale=1, *args, **kwargs):
820 | super().__init__(*args, **kwargs)
821 | self.win = win
822 | self.init_steps = init_steps
823 | self.yscale = yscale
824 | self.v_zoom_scale = v_zoom_scale
825 | self.master_viewbox = None
826 | self.rois = []
827 | self.vband = None
828 | self.win._isMouseLeftDrag = False
829 | self.zoom_listeners = set()
830 | self.reset()
831 |
832 | def reset(self):
833 | self.v_zoom_baseline = 0.5
834 | self.v_autozoom = True
835 | self.max_zoom_points_f = 1
836 | self.y_max = 1000
837 | self.y_min = 0
838 | self.y_positive = True
839 | self.x_indexed = True
840 | self.force_range_update = 0
841 | while self.rois:
842 | self.remove_last_roi()
843 | self.draw_line = None
844 | self.drawing = False
845 | self.standalones = set()
846 | self.updating_linked = False
847 | self.set_datasrc(None)
848 | self.setMouseEnabled(x=True, y=False)
849 | self.setRange(QtCore.QRectF(pg.Point(0, 0), pg.Point(1, 1)))
850 |
851 | def set_datasrc(self, datasrc):
852 | self.datasrc = datasrc
853 | if not self.datasrc:
854 | return
855 | datasrc.update_init_x(self.init_steps)
856 |
857 | def pre_process_data(self):
858 | if self.datasrc and self.datasrc.scale_cols:
859 | df = self.datasrc.df.iloc[:, self.datasrc.scale_cols]
860 | self.y_max = df.max().max()
861 | self.y_min = df.min().min()
862 | if self.y_min <= 0:
863 | self.y_positive = False
864 | if self.y_min < 0:
865 | self.v_zoom_baseline = 0.5
866 |
867 | @property
868 | def datasrc_or_standalone(self):
869 | ds = self.datasrc
870 | if not ds and self.standalones:
871 | ds = next(iter(self.standalones))
872 | return ds
873 |
874 | def wheelEvent(self, ev, axis=None):
875 | if self.master_viewbox:
876 | return self.master_viewbox.wheelEvent(ev, axis=axis)
877 | if ev.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
878 | scale_fact = 1
879 | self.v_zoom_scale /= 1.02 ** (ev.delta() * self.state['wheelScaleFactor'])
880 | else:
881 | scale_fact = 1.02 ** (ev.delta() * self.state['wheelScaleFactor'])
882 | vr = self.targetRect()
883 | center = self.mapToView(ev.scenePos())
884 | pct_x = (center.x()-vr.left()) / vr.width()
885 | if pct_x < 0.05: # zoom to far left => all the way left
886 | center = pg.Point(vr.left(), center.y())
887 | elif pct_x > 0.95: # zoom to far right => all the way right
888 | center = pg.Point(vr.right(), center.y())
889 | self.zoom_rect(vr, scale_fact, center)
890 | # update crosshair
891 | _mouse_moved(self.win, self, None)
892 | ev.accept()
893 |
894 | def mouseDragEvent(self, ev, axis=None):
895 | axis = 0 # don't constrain drag direction
896 | if self.master_viewbox:
897 | return self.master_viewbox.mouseDragEvent(ev, axis=axis)
898 | if not self.datasrc:
899 | return
900 | if ev.button() == QtCore.Qt.MouseButton.LeftButton:
901 | self.mouseLeftDrag(ev, axis)
902 | elif ev.button() == QtCore.Qt.MouseButton.MiddleButton:
903 | self.mouseMiddleDrag(ev, axis)
904 | elif ev.button() == QtCore.Qt.MouseButton.RightButton:
905 | self.mouseRightDrag(ev, axis)
906 | else:
907 | super().mouseDragEvent(ev, axis)
908 |
909 | def mouseLeftDrag(self, ev, axis):
910 | '''
911 | LButton drag pans.
912 | Shift+LButton drag draws vertical bars ("selections").
913 | Ctrl+LButton drag draw lines.
914 | '''
915 | pan_drag = ev.modifiers() == QtCore.Qt.KeyboardModifier.NoModifier
916 | select_band_drag = not self.drawing and (self.vband is not None or ev.modifiers() == QtCore.Qt.KeyboardModifier.ShiftModifier)
917 | draw_drag = self.vband is None and (self.drawing or ev.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier)
918 |
919 | if pan_drag:
920 | super().mouseDragEvent(ev, axis)
921 | if ev.isFinish():
922 | self.win._isMouseLeftDrag = False
923 | else:
924 | self.win._isMouseLeftDrag = True
925 | if ev.isFinish() or draw_drag or select_band_drag:
926 | self.refresh_all_y_zoom()
927 |
928 | if select_band_drag:
929 | p = self.mapToView(ev.pos())
930 | p = _clamp_point(self.parent(), p)
931 | if self.vband is None:
932 | p0 = self.mapToView(ev.buttonDownPos())
933 | p0 = _clamp_point(self.parent(), p0)
934 | x = self.datasrc.x
935 | x0, x1 = x[int(p0.x())], x[min(len(x)-1, int(p.x())+1)]
936 | self.vband = add_vertical_band(x0, x1, color=draw_band_color, ax=self.parent())
937 | self.vband.setMovable(True)
938 | _set_clamp_pos(self.vband.lines[0])
939 | _set_clamp_pos(self.vband.lines[1])
940 | else:
941 | rgn = (self.vband.lines[0].value(), int(p.x()))
942 | self.vband.setRegion(rgn)
943 | if ev.isFinish():
944 | self.rois += [self.vband]
945 | self.vband = None
946 |
947 | if draw_drag:
948 | if self.draw_line and not self.drawing:
949 | self.set_draw_line_color(draw_done_color)
950 | p1 = self.mapToView(ev.pos())
951 | p1 = _clamp_point(self.parent(), p1)
952 | if not self.drawing:
953 | # add new line
954 | p0 = self.mapToView(ev.lastPos())
955 | p0 = _clamp_point(self.parent(), p0)
956 | self.draw_line = _create_poly_line(self, [p0, p1], closed=False, pen=pg.mkPen(draw_line_color), movable=False)
957 | self.draw_line.setZValue(40)
958 | self.rois.append(self.draw_line)
959 | self.addItem(self.draw_line)
960 | self.drawing = True
961 | else:
962 | # draw placed point at end of poly-line
963 | self.draw_line.movePoint(-1, p1)
964 | if ev.isFinish():
965 | self.drawing = False
966 |
967 | ev.accept()
968 |
969 | def mouseMiddleDrag(self, ev, axis):
970 | '''Ctrl+MButton draw ellipses.'''
971 | if ev.modifiers() != QtCore.Qt.KeyboardModifier.ControlModifier:
972 | return super().mouseDragEvent(ev, axis)
973 | p1 = self.mapToView(ev.pos())
974 | p1 = _clamp_point(self.parent(), p1)
975 | def nonzerosize(a, b):
976 | c = b-a
977 | return pg.Point(abs(c.x()) or 1, abs(c.y()) or 1e-3)
978 | if not self.drawing:
979 | # add new ellipse
980 | p0 = self.mapToView(ev.lastPos())
981 | p0 = _clamp_point(self.parent(), p0)
982 | s = nonzerosize(p0, p1)
983 | p0 = QtCore.QPointF(p0.x()-s.x()/2, p0.y()-s.y()/2)
984 | self.draw_ellipse = FinEllipse(p0, s, pen=pg.mkPen(draw_line_color), movable=True)
985 | self.draw_ellipse.setZValue(80)
986 | self.rois.append(self.draw_ellipse)
987 | self.addItem(self.draw_ellipse)
988 | self.drawing = True
989 | else:
990 | c = self.draw_ellipse.pos() + self.draw_ellipse.size()*0.5
991 | s = nonzerosize(c, p1)
992 | self.draw_ellipse.setSize(s*2, update=False)
993 | self.draw_ellipse.setPos(c-s)
994 | if ev.isFinish():
995 | self.drawing = False
996 | ev.accept()
997 |
998 | def mouseRightDrag(self, ev, axis):
999 | '''RButton drag is box zoom. At least for now.'''
1000 | ev.accept()
1001 | if not ev.isFinish():
1002 | self.updateScaleBox(ev.buttonDownPos(), ev.pos())
1003 | else:
1004 | self.rbScaleBox.hide()
1005 | ax = QtCore.QRectF(pg.Point(ev.buttonDownPos(ev.button())), pg.Point(ev.pos()))
1006 | ax = self.childGroup.mapRectFromParent(ax)
1007 | if ax.width() < 2: # zooming this narrow is probably a mistake
1008 | ax.adjust(-1, 0, +1, 0)
1009 | self.showAxRect(ax)
1010 | self.axHistoryPointer += 1
1011 | self.axHistory = self.axHistory[:self.axHistoryPointer] + [ax]
1012 |
1013 | def mouseClickEvent(self, ev):
1014 | if self.master_viewbox:
1015 | return self.master_viewbox.mouseClickEvent(ev)
1016 | if _mouse_clicked(self, ev):
1017 | ev.accept()
1018 | return
1019 | if ev.button() != QtCore.Qt.MouseButton.LeftButton or ev.modifiers() != QtCore.Qt.KeyboardModifier.ControlModifier or not self.draw_line:
1020 | return super().mouseClickEvent(ev)
1021 | # add another segment to the currently drawn line
1022 | p = self.mapClickToView(ev.pos())
1023 | p = _clamp_point(self.parent(), p)
1024 | self.append_draw_segment(p)
1025 | self.drawing = False
1026 | ev.accept()
1027 |
1028 | def mapClickToView(self, pos):
1029 | '''mapToView() does not do grids properly in embedded widgets. Strangely, only affect clicks, not drags.'''
1030 | if self.win.parent() is not None:
1031 | ax = self.parent()
1032 | if ax.getAxis('right').grid:
1033 | pos.setX(pos.x() + self.width())
1034 | elif ax.getAxis('bottom').grid:
1035 | pos.setY(pos.y() + self.height())
1036 | return super().mapToView(pos)
1037 |
1038 | def keyPressEvent(self, ev):
1039 | if self.master_viewbox:
1040 | return self.master_viewbox.keyPressEvent(ev)
1041 | if _key_pressed(self, ev):
1042 | ev.accept()
1043 | return
1044 | super().keyPressEvent(ev)
1045 |
1046 | def linkedViewChanged(self, view, axis):
1047 | if not self.datasrc or self.updating_linked:
1048 | return
1049 | if view and self.datasrc and view.datasrc:
1050 | self.updating_linked = True
1051 | tr = self.targetRect()
1052 | vr = view.targetRect()
1053 | is_dirty = view.force_range_update > 0
1054 | is_same_scale = self.datasrc.xlen == view.datasrc.xlen
1055 | if is_same_scale: # stable zoom based on index
1056 | if is_dirty or abs(vr.left()-tr.left()) >= 1 or abs(vr.right()-tr.right()) >= 1:
1057 | if is_dirty:
1058 | view.force_range_update -= 1
1059 | self.update_y_zoom(vr.left(), vr.right())
1060 | else: # sloppy one based on time stamps
1061 | tt0,tt1,_,_,_ = self.datasrc.hilo(tr.left(), tr.right())
1062 | vt0,vt1,_,_,_ = view.datasrc.hilo(vr.left(), vr.right())
1063 | period2 = self.datasrc.period_ns * 0.5
1064 | if is_dirty or abs(vt0-tt0) >= period2 or abs(vt1-tt1) >= period2:
1065 | if is_dirty:
1066 | view.force_range_update -= 1
1067 | if self.parent():
1068 | x0,x1 = _pdtime2index(self.parent(), pd.Series([vt0,vt1]), any_end=True)
1069 | self.update_y_zoom(x0, x1)
1070 | self.updating_linked = False
1071 |
1072 | def zoom_rect(self, vr, scale_fact, center):
1073 | if not self.datasrc:
1074 | return
1075 | x0 = center.x() + (vr.left()-center.x()) * scale_fact
1076 | x1 = center.x() + (vr.right()-center.x()) * scale_fact
1077 | self.update_y_zoom(x0, x1)
1078 |
1079 | def pan_x(self, steps=None, percent=None):
1080 | if self.datasrc is None:
1081 | return
1082 | if steps is None:
1083 | steps = int(percent/100*self.targetRect().width())
1084 | tr = self.targetRect()
1085 | x1 = tr.right() + steps
1086 | startx = -side_margin
1087 | endx = self.datasrc.xlen + right_margin_candles - side_margin
1088 | if x1 > endx:
1089 | x1 = endx
1090 | x0 = x1 - tr.width()
1091 | if x0 < startx:
1092 | x0 = startx
1093 | x1 = x0 + tr.width()
1094 | self.update_y_zoom(x0, x1)
1095 |
1096 | def refresh_all_y_zoom(self):
1097 | '''This updates Y zoom on all views, such as when a mouse drag is completed.'''
1098 | main_vb = self
1099 | if self.linkedView(0):
1100 | self.force_range_update = 1 # main need to update only once to us
1101 | main_vb = list(self.win.axs)[0].vb
1102 | main_vb.force_range_update = len(self.win.axs)-1 # update main as many times as there are other rows
1103 | self.update_y_zoom()
1104 | # refresh crosshair when done
1105 | _mouse_moved(self.win, self, None)
1106 |
1107 | def update_y_zoom(self, x0=None, x1=None):
1108 | datasrc = self.datasrc_or_standalone
1109 | if datasrc is None:
1110 | return
1111 | if x0 is None or x1 is None:
1112 | tr = self.targetRect()
1113 | x0 = tr.left()
1114 | x1 = tr.right()
1115 | if x1-x0 <= 1:
1116 | return
1117 | # make edges rigid
1118 | xl = max(_round(x0-side_margin)+side_margin, -side_margin)
1119 | xr = min(_round(x1-side_margin)+side_margin, datasrc.xlen+right_margin_candles-side_margin)
1120 | dxl = xl-x0
1121 | dxr = xr-x1
1122 | if dxl > 0:
1123 | x1 += dxl
1124 | if dxr < 0:
1125 | x0 += dxr
1126 | x0 = max(_round(x0-side_margin)+side_margin, -side_margin)
1127 | x1 = min(_round(x1-side_margin)+side_margin, datasrc.xlen+right_margin_candles-side_margin)
1128 | # fetch hi-lo and set range
1129 | _,_,hi,lo,cnt = datasrc.hilo(x0, x1)
1130 | vr = self.viewRect()
1131 | minlen = int((max_zoom_points-0.5) * self.max_zoom_points_f + 0.51)
1132 | if (x1-x0) < vr.width() and cnt < minlen:
1133 | return
1134 | if not self.v_autozoom:
1135 | hi = vr.bottom()
1136 | lo = vr.top()
1137 | if self.yscale.scaletype == 'log':
1138 | if lo < 0:
1139 | lo = 0.05 * self.yscale.scalef # strange QT log scale rendering, which I'm unable to compensate for
1140 | else:
1141 | lo = max(1e-100, lo)
1142 | rng = (hi / lo) ** (1/self.v_zoom_scale)
1143 | rng = min(rng, 1e200) # avoid float overflow
1144 | base = (hi*lo) ** self.v_zoom_baseline
1145 | y0 = base / rng**self.v_zoom_baseline
1146 | y1 = base * rng**(1-self.v_zoom_baseline)
1147 | else:
1148 | rng = (hi-lo) / self.v_zoom_scale
1149 | rng = max(rng, 2e-7) # some very weird bug where high/low exponents stops rendering
1150 | base = (hi+lo) * self.v_zoom_baseline
1151 | y0 = base - rng*self.v_zoom_baseline
1152 | y1 = base + rng*(1-self.v_zoom_baseline)
1153 | if not self.x_indexed:
1154 | x0,x1 = _xminmax(datasrc, x_indexed=False, extra_margin=0)
1155 | return self.set_range(x0, y0, x1, y1)
1156 |
1157 | def set_range(self, x0, y0, x1, y1):
1158 | if x0 is None or x1 is None:
1159 | tr = self.targetRect()
1160 | x0 = tr.left()
1161 | x1 = tr.right()
1162 | if np.isnan(y0) or np.isnan(y1):
1163 | return
1164 | _y0 = self.yscale.invxform(y0, verify=True)
1165 | _y1 = self.yscale.invxform(y1, verify=True)
1166 | self.setRange(QtCore.QRectF(pg.Point(x0, _y0), pg.Point(x1, _y1)), padding=0)
1167 | self.zoom_changed()
1168 | return True
1169 |
1170 | def remove_last_roi(self):
1171 | if self.rois:
1172 | if not isinstance(self.rois[-1], pg.PolyLineROI):
1173 | self.removeItem(self.rois[-1])
1174 | self.rois = self.rois[:-1]
1175 | else:
1176 | h = self.rois[-1].handles[-1]['item']
1177 | self.rois[-1].removeHandle(h)
1178 | if not self.rois[-1].segments:
1179 | self.removeItem(self.rois[-1])
1180 | self.rois = self.rois[:-1]
1181 | self.draw_line = None
1182 | if self.rois:
1183 | if isinstance(self.rois[-1], pg.PolyLineROI):
1184 | self.draw_line = self.rois[-1]
1185 | self.set_draw_line_color(draw_line_color)
1186 | return True
1187 |
1188 | def append_draw_segment(self, p):
1189 | h0 = self.draw_line.handles[-1]['item']
1190 | h1 = self.draw_line.addFreeHandle(p)
1191 | self.draw_line.addSegment(h0, h1)
1192 | self.drawing = True
1193 |
1194 | def set_draw_line_color(self, color):
1195 | if self.draw_line:
1196 | pen = pg.mkPen(color)
1197 | for segment in self.draw_line.segments:
1198 | segment.currentPen = segment.pen = pen
1199 | segment.update()
1200 |
1201 | def suggestPadding(self, axis):
1202 | return 0
1203 |
1204 | def zoom_changed(self):
1205 | for zl in self.zoom_listeners:
1206 | zl(self)
1207 |
1208 |
1209 |
1210 | class FinPlotItem(pg.GraphicsObject):
1211 | def __init__(self, ax, datasrc, lod):
1212 | super().__init__()
1213 | self.ax = ax
1214 | self.datasrc = datasrc
1215 | self.picture = QtGui.QPicture()
1216 | self.painter = QtGui.QPainter()
1217 | self.dirty = True
1218 | self.lod = lod
1219 | self.cachedRect = None
1220 |
1221 | def repaint(self):
1222 | self.dirty = True
1223 | self.paint(None)
1224 |
1225 | def paint(self, p, *args):
1226 | if self.datasrc.is_sparse:
1227 | self.dirty = True
1228 | self.update_dirty_picture(self.viewRect())
1229 | if p is not None:
1230 | p.drawPicture(0, 0, self.picture)
1231 |
1232 | def update_dirty_picture(self, visibleRect):
1233 | if self.dirty or \
1234 | (self.lod and # regenerate when zoom changes?
1235 | (visibleRect.left() < self.cachedRect.left() or \
1236 | visibleRect.right() > self.cachedRect.right() or \
1237 | visibleRect.width() < self.cachedRect.width() / cache_candle_factor)): # optimize when zooming in
1238 | self._generate_picture(visibleRect)
1239 |
1240 | def _generate_picture(self, boundingRect):
1241 | w = boundingRect.width()
1242 | self.cachedRect = QtCore.QRectF(boundingRect.left()-(cache_candle_factor-1)*0.5*w, 0, cache_candle_factor*w, 0)
1243 | self.painter.begin(self.picture)
1244 | self._generate_dummy_picture(self.viewRect())
1245 | self.generate_picture(self.cachedRect)
1246 | self.painter.end()
1247 | self.dirty = False
1248 |
1249 | def _generate_dummy_picture(self, boundingRect):
1250 | if self.datasrc.is_sparse:
1251 | # just draw something to ensure PyQt will paint us again
1252 | self.painter.setPen(pg.mkPen(background))
1253 | self.painter.setBrush(pg.mkBrush(background))
1254 | l,r = boundingRect.left(), boundingRect.right()
1255 | self.painter.drawRect(QtCore.QRectF(l, boundingRect.top(), 1e-3, boundingRect.height()*1e-5))
1256 | self.painter.drawRect(QtCore.QRectF(r, boundingRect.bottom(), -1e-3, -boundingRect.height()*1e-5))
1257 |
1258 | def boundingRect(self):
1259 | return QtCore.QRectF(self.picture.boundingRect())
1260 |
1261 |
1262 |
1263 | class CandlestickItem(FinPlotItem):
1264 | def __init__(self, ax, datasrc, draw_body, draw_shadow, candle_width, colorfunc, resamp=None):
1265 | self.colors = dict(bull_shadow = candle_bull_color,
1266 | bull_frame = candle_bull_color,
1267 | bull_body = candle_bull_body_color,
1268 | bear_shadow = candle_bear_color,
1269 | bear_frame = candle_bear_color,
1270 | bear_body = candle_bear_body_color,
1271 | weak_bull_shadow = brighten(candle_bull_color, 1.2),
1272 | weak_bull_frame = brighten(candle_bull_color, 1.2),
1273 | weak_bull_body = brighten(candle_bull_color, 1.2),
1274 | weak_bear_shadow = brighten(candle_bear_color, 1.5),
1275 | weak_bear_frame = brighten(candle_bear_color, 1.5),
1276 | weak_bear_body = brighten(candle_bear_color, 1.5))
1277 | self.draw_body = draw_body
1278 | self.draw_shadow = draw_shadow
1279 | self.candle_width = candle_width
1280 | self.shadow_width = candle_shadow_width
1281 | self.colorfunc = colorfunc
1282 | self.resamp = resamp
1283 | self.x_offset = 0
1284 | super().__init__(ax, datasrc, lod=True)
1285 |
1286 | def generate_picture(self, boundingRect):
1287 | left,right = boundingRect.left(), boundingRect.right()
1288 | p = self.painter
1289 | df,origlen = self.datasrc.rows(5, left, right, yscale=self.ax.vb.yscale, resamp=self.resamp)
1290 | f = origlen / len(df) if len(df) else 1
1291 | w = self.candle_width * f
1292 | w2 = w * 0.5
1293 | for shadow,frame,body,df_rows in self.colorfunc(self, self.datasrc, df):
1294 | idxs = df_rows.index
1295 | rows = df_rows.values
1296 | if self.x_offset:
1297 | idxs += self.x_offset
1298 | if self.draw_shadow:
1299 | p.setPen(pg.mkPen(shadow, width=self.shadow_width))
1300 | for x,(t,open,close,high,low) in zip(idxs, rows):
1301 | if high > low:
1302 | p.drawLine(QtCore.QPointF(x, low), QtCore.QPointF(x, high))
1303 | if self.draw_body:
1304 | p.setPen(pg.mkPen(frame))
1305 | p.setBrush(pg.mkBrush(body))
1306 | for x,(t,open,close,high,low) in zip(idxs, rows):
1307 | p.drawRect(QtCore.QRectF(x-w2, open, w, close-open))
1308 |
1309 | def rowcolors(self, prefix):
1310 | return [self.colors[prefix+'_shadow'], self.colors[prefix+'_frame'], self.colors[prefix+'_body']]
1311 |
1312 |
1313 |
1314 | class HeatmapItem(FinPlotItem):
1315 | def __init__(self, ax, datasrc, rect_size=0.9, filter_limit=0, colmap=colmap_clash, whiteout=0.0, colcurve=lambda x:pow(x,4)):
1316 | self.rect_size = rect_size
1317 | self.filter_limit = filter_limit
1318 | self.colmap = colmap
1319 | self.whiteout = whiteout
1320 | self.colcurve = colcurve
1321 | self.col_data_end = len(datasrc.df.columns)
1322 | super().__init__(ax, datasrc, lod=False)
1323 |
1324 | def generate_picture(self, boundingRect):
1325 | prices = self.datasrc.df.columns[self.datasrc.col_data_offset:self.col_data_end]
1326 | h0 = (prices[0] - prices[1]) * (1-self.rect_size)
1327 | h1 = (prices[0] - prices[1]) * (1-(1-self.rect_size)*2)
1328 | rect_size2 = 0.5 * self.rect_size
1329 | df = self.datasrc.df.iloc[:, self.datasrc.col_data_offset:self.col_data_end]
1330 | values = df.values
1331 | # normalize
1332 | values -= np.nanmin(values)
1333 | values = values / (np.nanmax(values) / (1+self.whiteout)) # overshoot for coloring
1334 | lim = self.filter_limit * (1+self.whiteout)
1335 | p = self.painter
1336 | for t,row in enumerate(values):
1337 | for ci,price in enumerate(prices):
1338 | v = row[ci]
1339 | if v >= lim:
1340 | v = 1 - self.colcurve(1 - (v-lim)/(1-lim))
1341 | color = self.colmap.map(v, mode='qcolor')
1342 | p.fillRect(QtCore.QRectF(t-rect_size2, self.ax.vb.yscale.invxform(price+h0), self.rect_size, self.ax.vb.yscale.invxform(h1)), color)
1343 |
1344 |
1345 |
1346 | class HorizontalTimeVolumeItem(CandlestickItem):
1347 | def __init__(self, ax, datasrc, candle_width=0.8, draw_va=0.0, draw_vaw=1.0, draw_body=0.4, draw_poc=0.0, colorfunc=None):
1348 | '''A negative draw_body does not mean that the candle is drawn in the opposite direction (use negative volume for that),
1349 | but instead that screen scale will be used instead of interval-relative scale.'''
1350 | self.draw_va = draw_va
1351 | self.draw_vaw = draw_vaw
1352 | self.draw_poc = draw_poc
1353 | ## self.col_data_end = len(datasrc.df.columns)
1354 | colorfunc = colorfunc or horizvol_colorfilter() # resolve function lower down in source code
1355 | super().__init__(ax, datasrc, draw_shadow=False, candle_width=candle_width, draw_body=draw_body, colorfunc=colorfunc)
1356 | self.lod = False
1357 | self.colors.update(dict(neutral_shadow = volume_neutral_color,
1358 | neutral_frame = volume_neutral_color,
1359 | neutral_body = volume_neutral_color,
1360 | bull_body = candle_bull_color))
1361 |
1362 | def generate_picture(self, boundingRect):
1363 | times = self.datasrc.df.iloc[:, 0]
1364 | vals = self.datasrc.df.values
1365 | prices = vals[:, self.datasrc.col_data_offset::2]
1366 | volumes = vals[:, self.datasrc.col_data_offset+1::2].T
1367 | # normalize
1368 | try:
1369 | index = _pdtime2index(self.ax, times, require_time=True)
1370 | index_steps = pd.Series(index).diff().shift(-1)
1371 | index_steps[index_steps.index[-1]] = index_steps.median()
1372 | except AssertionError:
1373 | index = times
1374 | index_steps = [1]*len(index)
1375 | draw_body = self.draw_body
1376 | wf = 1
1377 | if draw_body < 0:
1378 | wf = -draw_body * self.ax.vb.targetRect().width()
1379 | draw_body = 1
1380 | binc = len(volumes)
1381 | if not binc:
1382 | return
1383 | divvol = np.nanmax(np.abs(volumes), axis=0)
1384 | divvol[divvol==0] = 1
1385 | volumes = (volumes * wf / divvol).T
1386 | p = self.painter
1387 | h = 1e-10
1388 | for i in range(len(prices)):
1389 | f = index_steps[i] * wf
1390 | prcr = prices[i]
1391 | prv = prcr[~np.isnan(prcr)]
1392 | if len(prv) > 1:
1393 | h = np.diff(prv).min()
1394 | t = index[i]
1395 | volr = np.nan_to_num(volumes[i])
1396 |
1397 | # calc poc
1398 | pocidx = np.nanargmax(volr)
1399 |
1400 | # draw value area
1401 | if self.draw_va:
1402 | volrs = volr / np.nansum(volr)
1403 | v = volrs[pocidx]
1404 | a = b = pocidx
1405 | while True:
1406 | if v >= self.draw_va - 1e-5:
1407 | break
1408 | aa = a - 1
1409 | bb = b + 1
1410 | va = volrs[aa] if aa>=0 else 0
1411 | vb = volrs[bb] if bb= vb: # NOTE both == is also ok
1413 | a = max(0, aa)
1414 | v += va
1415 | if va <= vb: # NOTE both == is also ok
1416 | b = min(binc-1, bb)
1417 | v += vb
1418 | if a==0 and b==binc-1:
1419 | break
1420 | color = pg.mkColor(band_color)
1421 | p.fillRect(QtCore.QRectF(t, prcr[a], f*self.draw_vaw, prcr[b]-prcr[a]+h), color)
1422 |
1423 | # draw horizontal bars
1424 | if draw_body:
1425 | h0 = h * (1-self.candle_width)/2
1426 | h1 = h * self.candle_width
1427 | for shadow,frame,body,data in self.colorfunc(self, self.datasrc, np.array([prcr, volr])):
1428 | p.setPen(pg.mkPen(frame))
1429 | p.setBrush(pg.mkBrush(body))
1430 | prcr_,volr_ = data
1431 | for w,y in zip(volr_, prcr_):
1432 | if abs(w) > 1e-15:
1433 | p.drawRect(QtCore.QRectF(t, y+h0, w*f*draw_body, h1))
1434 |
1435 | # draw poc line
1436 | if self.draw_poc:
1437 | y = prcr[pocidx] + h / 2
1438 | p.setPen(pg.mkPen(poc_color))
1439 | p.drawLine(QtCore.QPointF(t, y), QtCore.QPointF(t+f*self.draw_poc, y))
1440 |
1441 |
1442 |
1443 | class ScatterLabelItem(FinPlotItem):
1444 | def __init__(self, ax, datasrc, color, anchor):
1445 | self.color = color
1446 | self.text_items = {}
1447 | self.anchor = anchor
1448 | self.show = False
1449 | super().__init__(ax, datasrc, lod=True)
1450 |
1451 | def generate_picture(self, bounding_rect):
1452 | rows = self.getrows(bounding_rect)
1453 | if len(rows) > lod_labels: # don't even generate when there's too many of them
1454 | self.clear_items(list(self.text_items.keys()))
1455 | return
1456 | drops = set(self.text_items.keys())
1457 | created = 0
1458 | for x,t,y,txt in rows:
1459 | txt = str(txt)
1460 | ishtml = '<' in txt and '>' in txt
1461 | key = '%s:%.8f' % (t, y)
1462 | if key in self.text_items:
1463 | item = self.text_items[key]
1464 | (item.setHtml if ishtml else item.setText)(txt)
1465 | item.setPos(x, y)
1466 | drops.remove(key)
1467 | else:
1468 | kws = {'html':txt} if ishtml else {'text':txt}
1469 | self.text_items[key] = item = pg.TextItem(color=self.color, anchor=self.anchor, **kws)
1470 | item.setPos(x, y)
1471 | item.setParentItem(self)
1472 | created += 1
1473 | if created > 0 or self.dirty: # only reduce cache if we've added some new or updated
1474 | self.clear_items(drops)
1475 |
1476 | def clear_items(self, drop_keys):
1477 | for key in drop_keys:
1478 | item = self.text_items[key]
1479 | item.scene().removeItem(item)
1480 | del self.text_items[key]
1481 |
1482 | def getrows(self, bounding_rect):
1483 | left,right = bounding_rect.left(), bounding_rect.right()
1484 | df,_ = self.datasrc.rows(3, left, right, yscale=self.ax.vb.yscale, lod=False)
1485 | rows = df.dropna()
1486 | idxs = rows.index
1487 | rows = rows.values
1488 | rows = [(i,t,y,txt) for i,(t,y,txt) in zip(idxs, rows) if txt]
1489 | return rows
1490 |
1491 | def boundingRect(self):
1492 | return self.viewRect()
1493 |
1494 |
1495 | def create_plot(title='Finance Plot', rows=1, init_zoom_periods=1e10, maximize=True, yscale='linear'):
1496 | pg.setConfigOptions(foreground=foreground, background=background)
1497 | win = FinWindow(title)
1498 | win.show_maximized = maximize
1499 | ax0 = axs = create_plot_widget(master=win, rows=rows, init_zoom_periods=init_zoom_periods, yscale=yscale)
1500 | axs = axs if type(axs) in (tuple,list) else [axs]
1501 | for ax in axs:
1502 | win.addItem(ax, col=1)
1503 | win.nextRow()
1504 | return ax0
1505 |
1506 |
1507 | def create_plot_widget(master, rows=1, init_zoom_periods=1e10, yscale='linear'):
1508 | pg.setConfigOptions(foreground=foreground, background=background)
1509 | global last_ax
1510 | if master not in windows:
1511 | windows.append(master)
1512 | axs = []
1513 | prev_ax = None
1514 | for n in range(rows):
1515 | ysc = yscale[n] if type(yscale) in (list,tuple) else yscale
1516 | ysc = YScale(ysc, 1) if type(ysc) == str else ysc
1517 | viewbox = FinViewBox(master, init_steps=init_zoom_periods, yscale=ysc, v_zoom_scale=1-y_pad, enableMenu=False)
1518 | ax = prev_ax = _add_timestamp_plot(master=master, prev_ax=prev_ax, viewbox=viewbox, index=n, yscale=ysc)
1519 | if axs:
1520 | ax.setXLink(axs[0].vb)
1521 | else:
1522 | viewbox.setFocus()
1523 | axs += [ax]
1524 | if master not in master_data:
1525 | master_data[master] = {}
1526 | if isinstance(master, pg.GraphicsLayoutWidget):
1527 | proxy = pg.SignalProxy(master.scene().sigMouseMoved, rateLimit=144, slot=partial(_mouse_moved, master, axs[0].vb))
1528 | master_data[master][axs[0].vb] = dict(proxymm=proxy, last_mouse_evs=None, last_mouse_y=0)
1529 | if 'default' not in master_data[master]:
1530 | master_data[master]['default'] = master_data[master][axs[0].vb]
1531 | else:
1532 | for ax in axs:
1533 | proxy = pg.SignalProxy(ax.ax_widget.scene().sigMouseMoved, rateLimit=144, slot=partial(_mouse_moved, master, ax.vb))
1534 | master_data[master][ax.vb] = dict(proxymm=proxy, last_mouse_evs=None, last_mouse_y=0)
1535 | last_ax = axs[0]
1536 | return axs[0] if len(axs) == 1 else axs
1537 |
1538 |
1539 | def close():
1540 | for win in windows:
1541 | try:
1542 | win.close()
1543 | except Exception as e:
1544 | print('Window closing error:', type(e), e)
1545 | global last_ax
1546 | windows.clear()
1547 | overlay_axs.clear()
1548 | _clear_timers()
1549 | sounds.clear()
1550 | master_data.clear()
1551 | last_ax = None
1552 |
1553 |
1554 | def price_colorfilter(item, datasrc, df):
1555 | opencol = df.columns[1]
1556 | closecol = df.columns[2]
1557 | is_up = df[opencol] <= df[closecol] # open lower than close = goes up
1558 | yield item.rowcolors('bull') + [df.loc[is_up, :]]
1559 | yield item.rowcolors('bear') + [df.loc[~is_up, :]]
1560 |
1561 |
1562 | def volume_colorfilter(item, datasrc, df):
1563 | opencol = df.columns[3]
1564 | closecol = df.columns[4]
1565 | is_up = df[opencol] <= df[closecol] # open lower than close = goes up
1566 | yield item.rowcolors('bull') + [df.loc[is_up, :]]
1567 | yield item.rowcolors('bear') + [df.loc[~is_up, :]]
1568 |
1569 |
1570 | def strength_colorfilter(item, datasrc, df):
1571 | opencol = df.columns[1]
1572 | closecol = df.columns[2]
1573 | startcol = df.columns[3]
1574 | endcol = df.columns[4]
1575 | is_up = df[opencol] <= df[closecol] # open lower than close = goes up
1576 | is_strong = df[startcol] <= df[endcol]
1577 | yield item.rowcolors('bull') + [df.loc[is_up&is_strong, :]]
1578 | yield item.rowcolors('weak_bull') + [df.loc[is_up&(~is_strong), :]]
1579 | yield item.rowcolors('weak_bear') + [df.loc[(~is_up)&is_strong, :]]
1580 | yield item.rowcolors('bear') + [df.loc[(~is_up)&(~is_strong), :]]
1581 |
1582 |
1583 | def volume_colorfilter_section(sections=[]):
1584 | '''The sections argument is a (starting_index, color_name) array.'''
1585 | def _colorfilter(sections, item, datasrc, df):
1586 | if not sections:
1587 | return volume_colorfilter(item, datasrc, df)
1588 | for (i0,colname),(i1,_) in zip(sections, sections[1:]+[(None,'neutral')]):
1589 | rows = df.iloc[i0:i1, :]
1590 | yield item.rowcolors(colname) + [rows]
1591 | return partial(_colorfilter, sections)
1592 |
1593 |
1594 | def horizvol_colorfilter(sections=[]):
1595 | '''The sections argument is a (starting_index, color_name) array.'''
1596 | def _colorfilter(sections, item, datasrc, data):
1597 | if not sections:
1598 | yield item.rowcolors('neutral') + [data]
1599 | for (i0,colname),(i1,_) in zip(sections, sections[1:]+[(None,'neutral')]):
1600 | rows = data[:, i0:i1]
1601 | yield item.rowcolors(colname) + [rows]
1602 | return partial(_colorfilter, sections)
1603 |
1604 |
1605 | def candlestick_ochl(datasrc, draw_body=True, draw_shadow=True, candle_width=0.6, ax=None, colorfunc=price_colorfilter):
1606 | ax = _create_plot(ax=ax, maximize=False)
1607 | datasrc = _create_datasrc(ax, datasrc, ncols=5)
1608 | datasrc.scale_cols = [3,4] # only hi+lo scales
1609 | _set_datasrc(ax, datasrc)
1610 | item = CandlestickItem(ax=ax, datasrc=datasrc, draw_body=draw_body, draw_shadow=draw_shadow, candle_width=candle_width, colorfunc=colorfunc, resamp='hilo')
1611 | _update_significants(ax, datasrc, force=True)
1612 | item.update_data = partial(_update_data, None, None, item)
1613 | item.update_gfx = partial(_update_gfx, item)
1614 | ax.addItem(item)
1615 | return item
1616 |
1617 |
1618 | def renko(x, y=None, bins=None, step=None, ax=None, colorfunc=price_colorfilter):
1619 | ax = _create_plot(ax=ax, maximize=False)
1620 | datasrc = _create_datasrc(ax, x, y, ncols=3)
1621 | origdf = datasrc.df
1622 | adj = _adjust_renko_log_datasrc if ax.vb.yscale.scaletype == 'log' else _adjust_renko_datasrc
1623 | step_adjust_renko_datasrc = partial(adj, bins, step)
1624 | step_adjust_renko_datasrc(datasrc)
1625 | ax.decouple()
1626 | item = candlestick_ochl(datasrc, draw_shadow=False, candle_width=1, ax=ax, colorfunc=colorfunc)
1627 | item.colors['bull_body'] = item.colors['bull_frame']
1628 | item.update_data = partial(_update_data, None, step_adjust_renko_datasrc, item)
1629 | item.update_gfx = partial(_update_gfx, item)
1630 | global epoch_period
1631 | epoch_period = (origdf.iloc[1,0] - origdf.iloc[0,0]) // int(1e9)
1632 | return item
1633 |
1634 |
1635 | def volume_ocv(datasrc, candle_width=0.8, ax=None, colorfunc=volume_colorfilter):
1636 | ax = _create_plot(ax=ax, maximize=False)
1637 | datasrc = _create_datasrc(ax, datasrc, ncols=4)
1638 | _adjust_volume_datasrc(datasrc)
1639 | _set_datasrc(ax, datasrc)
1640 | item = CandlestickItem(ax=ax, datasrc=datasrc, draw_body=True, draw_shadow=False, candle_width=candle_width, colorfunc=colorfunc, resamp='sum')
1641 | _update_significants(ax, datasrc, force=True)
1642 | item.colors['bull_body'] = item.colors['bull_frame']
1643 | if colorfunc == volume_colorfilter: # assume normal volume plot
1644 | item.colors['bull_frame'] = volume_bull_color
1645 | item.colors['bull_body'] = volume_bull_body_color
1646 | item.colors['bear_frame'] = volume_bear_color
1647 | item.colors['bear_body'] = volume_bear_color
1648 | ax.vb.v_zoom_baseline = 0
1649 | else:
1650 | item.colors['weak_bull_frame'] = brighten(volume_bull_color, 1.2)
1651 | item.colors['weak_bull_body'] = brighten(volume_bull_color, 1.2)
1652 | item.update_data = partial(_update_data, None, _adjust_volume_datasrc, item)
1653 | item.update_gfx = partial(_update_gfx, item)
1654 | ax.addItem(item)
1655 | item.setZValue(-20)
1656 | return item
1657 |
1658 |
1659 | def horiz_time_volume(datasrc, ax=None, **kwargs):
1660 | '''Draws multiple fixed horizontal volumes. The input format is:
1661 | [[time0, [(price0,volume0),(price1,volume1),...]], ...]
1662 |
1663 | This chart needs to be plot last, so it knows if it controls
1664 | what time periods are shown, or if its using time already in
1665 | place by another plot.'''
1666 | # update handling default if necessary
1667 | global max_zoom_points, right_margin_candles
1668 | if max_zoom_points > 15:
1669 | max_zoom_points = 4
1670 | if right_margin_candles > 3:
1671 | right_margin_candles = 1
1672 |
1673 | ax = _create_plot(ax=ax, maximize=False)
1674 | datasrc = _preadjust_horiz_datasrc(datasrc)
1675 | datasrc = _create_datasrc(ax, datasrc, allow_scaling=False)
1676 | _adjust_horiz_datasrc(datasrc)
1677 | if ax.vb.datasrc is not None:
1678 | datasrc.standalone = True # only force standalone if there is something on our charts already
1679 | datasrc.scale_cols = [datasrc.col_data_offset, len(datasrc.df.columns)-2] # first and last price columns
1680 | datasrc.pre_update = lambda df: df.loc[:, :df.columns[0]] # throw away previous data
1681 | datasrc.post_update = lambda df: df.dropna(how='all') # kill all-NaNs
1682 | _set_datasrc(ax, datasrc)
1683 | item = HorizontalTimeVolumeItem(ax=ax, datasrc=datasrc, **kwargs)
1684 | item.update_data = partial(_update_data, _preadjust_horiz_datasrc, _adjust_horiz_datasrc, item)
1685 | item.update_gfx = partial(_update_gfx, item)
1686 | item.setZValue(-10)
1687 | ax.addItem(item)
1688 | return item
1689 |
1690 |
1691 | def heatmap(datasrc, ax=None, **kwargs):
1692 | '''Expensive function. Only use on small data sets. See HeatmapItem for kwargs. Input datasrc
1693 | has x (time) in index or first column, y (price) as column names, and intensity (color) as
1694 | cell values.'''
1695 | ax = _create_plot(ax=ax, maximize=False)
1696 | if ax.vb.v_zoom_scale >= 0.9:
1697 | ax.vb.v_zoom_scale = 0.6
1698 | datasrc = _create_datasrc(ax, datasrc)
1699 | datasrc.scale_cols = [] # doesn't scale
1700 | _set_datasrc(ax, datasrc)
1701 | item = HeatmapItem(ax=ax, datasrc=datasrc, **kwargs)
1702 | item.update_data = partial(_update_data, None, None, item)
1703 | item.update_gfx = partial(_update_gfx, item)
1704 | item.setZValue(-30)
1705 | ax.addItem(item)
1706 | if ax.vb.datasrc is not None and not ax.vb.datasrc.timebased(): # manual zoom update
1707 | ax.setXLink(None)
1708 | if ax.prev_ax:
1709 | ax.prev_ax.set_visible(xaxis=True)
1710 | df = ax.vb.datasrc.df
1711 | prices = df.columns[ax.vb.datasrc.col_data_offset:item.col_data_end]
1712 | delta_price = abs(prices[0] - prices[1])
1713 | ax.vb.set_range(0, min(df.columns[1:]), len(df), max(df.columns[1:])+delta_price)
1714 | return item
1715 |
1716 |
1717 | def bar(x, y=None, width=0.8, ax=None, colorfunc=strength_colorfilter, **kwargs):
1718 | '''Bar plots are decoupled. Use volume_ocv() if you want a bar plot which relates to other time plots.'''
1719 | global right_margin_candles, max_zoom_points
1720 | right_margin_candles = 0
1721 | max_zoom_points = min(max_zoom_points, 8)
1722 | ax = _create_plot(ax=ax, maximize=False)
1723 | ax.decouple()
1724 | datasrc = _create_datasrc(ax, x, y, ncols=1)
1725 | _adjust_bar_datasrc(datasrc, order_cols=False) # don't rearrange columns, done for us in volume_ocv()
1726 | item = volume_ocv(datasrc, candle_width=width, ax=ax, colorfunc=colorfunc)
1727 | item.update_data = partial(_update_data, None, _adjust_bar_datasrc, item)
1728 | item.update_gfx = partial(_update_gfx, item)
1729 | ax.vb.pre_process_data()
1730 | if ax.vb.y_min >= 0:
1731 | ax.vb.v_zoom_baseline = 0
1732 | return item
1733 |
1734 |
1735 | def hist(x, bins, ax=None, **kwargs):
1736 | hist_data = pd.cut(x, bins=bins).value_counts()
1737 | data = [(i.mid,0,hist_data.loc[i],hist_data.loc[i]) for i in sorted(hist_data.index)]
1738 | df = pd.DataFrame(data, columns=['x','_op_','_cl_','bin'])
1739 | df.set_index('x', inplace=True)
1740 | item = bar(df, ax=ax)
1741 | del item.update_data
1742 | return item
1743 |
1744 |
1745 | def plot(x, y=None, color=None, width=1, ax=None, style=None, legend=None, zoomscale=True, **kwargs):
1746 | ax = _create_plot(ax=ax, maximize=False)
1747 | used_color = _get_color(ax, style, color)
1748 | datasrc = _create_datasrc(ax, x, y, ncols=1)
1749 | if not zoomscale:
1750 | datasrc.scale_cols = []
1751 | _set_datasrc(ax, datasrc)
1752 | if legend is not None:
1753 | _create_legend(ax)
1754 | x = datasrc.x if not ax.vb.x_indexed else datasrc.index
1755 | y = datasrc.y / ax.vb.yscale.scalef
1756 | if ax.vb.yscale.scaletype == 'log':
1757 | y = y + log_plot_offset
1758 | if style is None or any(ch in style for ch in '-_.'):
1759 | connect_dots = 'finite' # same as matplotlib; use datasrc.standalone=True if you want to keep separate intervals on a plot
1760 | item = ax.plot(x, y, pen=_makepen(color=used_color, style=style, width=width), name=legend, connect=connect_dots)
1761 | item.setZValue(5)
1762 | else:
1763 | symbol = {'v':'t', '^':'t1', '>':'t2', '<':'t3'}.get(style, style) # translate some similar styles
1764 | yfilter = y.notnull()
1765 | ser = y.loc[yfilter]
1766 | x = x.loc[yfilter].values if hasattr(x, 'loc') else x[yfilter]
1767 | item = ax.plot(x, ser.values, pen=None, symbol=symbol, symbolPen=None, symbolSize=7*width, symbolBrush=pg.mkBrush(used_color), name=legend)
1768 | if width < 1:
1769 | item.opts['antialias'] = True
1770 | item.scatter._dopaint = item.scatter.paint
1771 | item.scatter.paint = partial(_paint_scatter, item.scatter)
1772 | # optimize (when having large number of points) by ignoring scatter click detection
1773 | _dummy_mouse_click = lambda ev: 0
1774 | item.scatter.mouseClickEvent = _dummy_mouse_click
1775 | item.setZValue(10)
1776 | item.opts['handed_color'] = color
1777 | item.ax = ax
1778 | item.datasrc = datasrc
1779 | _update_significants(ax, datasrc, force=False)
1780 | item.update_data = partial(_update_data, None, None, item)
1781 | item.update_gfx = partial(_update_gfx, item)
1782 | # add legend to main ax, not to overlay
1783 | axm = ax.vb.master_viewbox.parent() if ax.vb.master_viewbox else ax
1784 | if axm.legend is not None:
1785 | if legend and axm != ax:
1786 | axm.legend.addItem(item, name=legend)
1787 | for _,label in axm.legend.items:
1788 | if label.text == legend:
1789 | label.setAttr('justify', 'left')
1790 | label.setText(label.text, color=legend_text_color)
1791 | return item
1792 |
1793 |
1794 | def labels(x, y=None, labels=None, color=None, ax=None, anchor=(0.5,1)):
1795 | ax = _create_plot(ax=ax, maximize=False)
1796 | used_color = _get_color(ax, '?', color)
1797 | datasrc = _create_datasrc(ax, x, y, labels, ncols=3)
1798 | datasrc.scale_cols = [] # don't use this for scaling
1799 | _set_datasrc(ax, datasrc)
1800 | item = ScatterLabelItem(ax=ax, datasrc=datasrc, color=used_color, anchor=anchor)
1801 | _update_significants(ax, datasrc, force=False)
1802 | item.update_data = partial(_update_data, None, None, item)
1803 | item.update_gfx = partial(_update_gfx, item)
1804 | ax.addItem(item)
1805 | if ax.vb.v_zoom_scale > 0.9: # adjust to make hi/lo text fit
1806 | ax.vb.v_zoom_scale = 0.9
1807 | return item
1808 |
1809 |
1810 | def live(plots=1):
1811 | if plots == 1:
1812 | return Live()
1813 | return [Live() for _ in range(plots)]
1814 |
1815 |
1816 | def add_legend(text, ax=None):
1817 | ax = _create_plot(ax=ax, maximize=False)
1818 | _create_legend(ax)
1819 | row = ax.legend.layout.rowCount()
1820 | label = pg.LabelItem(text, color=legend_text_color, justify='left')
1821 | ax.legend.layout.addItem(label, row, 0, 1, 2)
1822 | return label
1823 |
1824 |
1825 | def fill_between(plot0, plot1, color=None):
1826 | used_color = brighten(_get_color(plot0.ax, None, color), 1.3)
1827 | item = pg.FillBetweenItem(plot0, plot1, brush=pg.mkBrush(used_color))
1828 | item.ax = plot0.ax
1829 | item.setZValue(-40)
1830 | item.ax.addItem(item)
1831 | # Ugly bug fix for PyQtGraph bug where downsampled/clipped plots are used in conjunction
1832 | # with fill between. The reason is that the curves of the downsampled plots are only
1833 | # calculated when shown, but not when added to the axis. We fix by saying the plot is
1834 | # changed every time the zoom is changed - including initial load.
1835 | def update_fill(vb):
1836 | plot0.sigPlotChanged.emit(plot0)
1837 | plot0.ax.vb.zoom_listeners.add(update_fill)
1838 | return item
1839 |
1840 |
1841 | def set_x_pos(xmin, xmax, ax=None):
1842 | ax = _create_plot(ax=ax, maximize=False)
1843 | xidx0,xidx1 = _pdtime2index(ax, pd.Series([xmin, xmax]))
1844 | ax.vb.update_y_zoom(xidx0, xidx1)
1845 | _repaint_candles()
1846 |
1847 |
1848 | def set_y_range(ymin, ymax, ax=None):
1849 | ax = _create_plot(ax=ax, maximize=False)
1850 | ax.setLimits(yMin=ymin, yMax=ymax)
1851 | ax.vb.v_autozoom = False
1852 | ax.vb.set_range(None, ymin, None, ymax)
1853 |
1854 |
1855 | def set_y_scale(yscale='linear', ax=None):
1856 | ax = _create_plot(ax=ax, maximize=False)
1857 | ax.setLogMode(y=(yscale=='log'))
1858 | ax.vb.yscale = YScale(yscale, ax.vb.yscale.scalef)
1859 |
1860 |
1861 | def add_band(y0, y1, color=band_color, ax=None):
1862 | print('add_band() is deprecated, use add_horizontal_band() instead.')
1863 | return add_horizontal_band(y0, y1, color, ax)
1864 |
1865 |
1866 | def add_horizontal_band(y0, y1, color=band_color, ax=None):
1867 | ax = _create_plot(ax=ax, maximize=False)
1868 | color = _get_color(ax, None, color)
1869 | ix = ax.vb.yscale.invxform
1870 | lr = pg.LinearRegionItem([ix(y0),ix(y1)], orientation=pg.LinearRegionItem.Horizontal, brush=pg.mkBrush(color), movable=False)
1871 | lr.lines[0].setPen(pg.mkPen(None))
1872 | lr.lines[1].setPen(pg.mkPen(None))
1873 | lr.setZValue(-50)
1874 | lr.ax = ax
1875 | ax.addItem(lr)
1876 | return lr
1877 |
1878 |
1879 | def add_vertical_band(x0, x1, color=band_color, ax=None):
1880 | ax = _create_plot(ax=ax, maximize=False)
1881 | x_pts = _pdtime2index(ax, pd.Series([x0, x1]))
1882 | color = _get_color(ax, None, color)
1883 | lr = pg.LinearRegionItem([x_pts[0],x_pts[1]], orientation=pg.LinearRegionItem.Vertical, brush=pg.mkBrush(color), movable=False)
1884 | lr.lines[0].setPen(pg.mkPen(None))
1885 | lr.lines[1].setPen(pg.mkPen(None))
1886 | lr.setZValue(-50)
1887 | lr.ax = ax
1888 | ax.addItem(lr)
1889 | return lr
1890 |
1891 |
1892 | def add_rect(p0, p1, color=band_color, interactive=False, ax=None):
1893 | ax = _create_plot(ax=ax, maximize=False)
1894 | x_pts = _pdtime2index(ax, pd.Series([p0[0], p1[0]]))
1895 | ix = ax.vb.yscale.invxform
1896 | y0,y1 = sorted([p0[1], p1[1]])
1897 | pos = (x_pts[0], ix(y0))
1898 | size = (x_pts[1]-pos[0], ix(y1)-ix(y0))
1899 | rect = FinRect(ax=ax, brush=pg.mkBrush(color), pos=pos, size=size, movable=interactive, resizable=interactive, rotatable=False)
1900 | rect.setZValue(-40)
1901 | if interactive:
1902 | ax.vb.rois.append(rect)
1903 | rect.ax = ax
1904 | ax.addItem(rect)
1905 | return rect
1906 |
1907 |
1908 | def add_line(p0, p1, color=draw_line_color, width=1, style=None, interactive=False, ax=None):
1909 | ax = _create_plot(ax=ax, maximize=False)
1910 | used_color = _get_color(ax, style, color)
1911 | pen = _makepen(color=used_color, style=style, width=width)
1912 | x_pts = _pdtime2index(ax, pd.Series([p0[0], p1[0]]))
1913 | ix = ax.vb.yscale.invxform
1914 | pts = [(x_pts[0], ix(p0[1])), (x_pts[1], ix(p1[1]))]
1915 | if interactive:
1916 | line = _create_poly_line(ax.vb, pts, closed=False, pen=pen, movable=False)
1917 | ax.vb.rois.append(line)
1918 | else:
1919 | line = FinLine(pts, pen=pen)
1920 | line.ax = ax
1921 | ax.addItem(line)
1922 | return line
1923 |
1924 |
1925 | def add_text(pos, s, color=draw_line_color, anchor=(0,0), ax=None):
1926 | ax = _create_plot(ax=ax, maximize=False)
1927 | color = _get_color(ax, None, color)
1928 | text = pg.TextItem(s, color=color, anchor=anchor)
1929 | x = pos[0]
1930 | if ax.vb.datasrc is not None:
1931 | x = _pdtime2index(ax, pd.Series([pos[0]]))[0]
1932 | y = ax.vb.yscale.invxform(pos[1])
1933 | text.setPos(x, y)
1934 | text.setZValue(50)
1935 | text.ax = ax
1936 | ax.addItem(text, ignoreBounds=True)
1937 | return text
1938 |
1939 |
1940 | def remove_line(line):
1941 | print('remove_line() is deprecated, use remove_primitive() instead')
1942 | remove_primitive(line)
1943 |
1944 |
1945 | def remove_text(text):
1946 | print('remove_text() is deprecated, use remove_primitive() instead')
1947 | remove_primitive(text)
1948 |
1949 |
1950 | def remove_primitive(primitive):
1951 | ax = primitive.ax
1952 | ax.removeItem(primitive)
1953 | if primitive in ax.vb.rois:
1954 | ax.vb.rois.remove(primitive)
1955 | if hasattr(primitive, 'texts'):
1956 | for txt in primitive.texts:
1957 | ax.vb.removeItem(txt)
1958 |
1959 |
1960 | def set_mouse_callback(callback, ax=None, when='click'):
1961 | '''Callback when clicked like so: callback(x, y).'''
1962 | ax = ax if ax else last_ax
1963 | master = ax.ax_widget if hasattr(ax, 'ax_widget') else ax.vb.win
1964 | if when == 'hover':
1965 | master.proxy_hover = pg.SignalProxy(master.scene().sigMouseMoved, rateLimit=15, slot=partial(_mcallback_pos, ax, callback))
1966 | elif when in ('dclick', 'double-click'):
1967 | master.proxy_dclick = pg.SignalProxy(master.scene().sigMouseClicked, slot=partial(_mcallback_click, ax, callback, 'dclick'))
1968 | elif when in ('click', 'lclick'):
1969 | master.proxy_click = pg.SignalProxy(master.scene().sigMouseClicked, slot=partial(_mcallback_click, ax, callback, 'lclick'))
1970 | elif when in ('mclick',):
1971 | master.proxy_click = pg.SignalProxy(master.scene().sigMouseClicked, slot=partial(_mcallback_click, ax, callback, 'mclick'))
1972 | elif when in ('rclick',):
1973 | master.proxy_click = pg.SignalProxy(master.scene().sigMouseClicked, slot=partial(_mcallback_click, ax, callback, 'rclick'))
1974 | elif when in ('any',):
1975 | master.proxy_click = pg.SignalProxy(master.scene().sigMouseClicked, slot=partial(_mcallback_click, ax, callback, 'any'))
1976 | else:
1977 | print(f'Warning: unknown click "{when}" sent to set_mouse_callback()')
1978 |
1979 |
1980 | def set_time_inspector(callback, ax=None, when='click'):
1981 | print('Warning: set_time_inspector() is a misnomer from olden days. Please use set_mouse_callback() instead.')
1982 | set_mouse_callback(callback, ax, when)
1983 |
1984 |
1985 | def add_crosshair_info(infofunc, ax=None):
1986 | '''Callback when crosshair updated like so: info(ax,x,y,xtext,ytext); the info()
1987 | callback must return two values: xtext and ytext.'''
1988 | ax = _create_plot(ax=ax, maximize=False)
1989 | ax.crosshair.infos.append(infofunc)
1990 |
1991 |
1992 | def timer_callback(update_func, seconds, single_shot=False):
1993 | global timers
1994 | timer = QtCore.QTimer()
1995 | timer.timeout.connect(update_func)
1996 | if single_shot:
1997 | timer.setSingleShot(True)
1998 | timer.start(int(seconds*1000))
1999 | timers.append(timer)
2000 | return timer
2001 |
2002 |
2003 | def autoviewrestore(enable=True):
2004 | '''Restor functionality saves view zoom coordinates when closing a window, and
2005 | load them when creating the plot (with the same name) again.'''
2006 | global viewrestore
2007 | viewrestore = enable
2008 |
2009 |
2010 | def refresh():
2011 | for win in windows:
2012 | axs = win.axs + [ax for ax in overlay_axs if ax.vb.win==win]
2013 | for ax in axs:
2014 | _improve_significants(ax)
2015 | vbs = [ax.vb for ax in axs]
2016 | for vb in vbs:
2017 | vb.pre_process_data()
2018 | if viewrestore:
2019 | if _loadwindata(win):
2020 | continue
2021 | _set_max_zoom(vbs)
2022 | for vb in vbs:
2023 | datasrc = vb.datasrc_or_standalone
2024 | if datasrc and (vb.linkedView(0) is None or vb.linkedView(0).datasrc is None or vb.master_viewbox):
2025 | vb.update_y_zoom(datasrc.init_x0, datasrc.init_x1)
2026 | _repaint_candles()
2027 | for md in master_data.values():
2028 | for vb in md:
2029 | if type(vb) != str: # ignore 'default'
2030 | _mouse_moved(win, vb, None)
2031 |
2032 |
2033 | def show(qt_exec=True):
2034 | refresh()
2035 | for win in windows:
2036 | if isinstance(win, FinWindow) or qt_exec:
2037 | if win.show_maximized:
2038 | win.showMaximized()
2039 | else:
2040 | win.show()
2041 | if windows and qt_exec:
2042 | global last_ax, app
2043 | app = QtGui.QGuiApplication.instance()
2044 | app.exec()
2045 | windows.clear()
2046 | overlay_axs.clear()
2047 | _clear_timers()
2048 | sounds.clear()
2049 | master_data.clear()
2050 | last_ax = None
2051 |
2052 |
2053 | def play_sound(filename):
2054 | if filename not in sounds:
2055 | from PyQt6.QtMultimedia import QSoundEffect
2056 | s = sounds[filename] = QSoundEffect() # disallow gc
2057 | s.setSource(QtCore.QUrl.fromLocalFile(filename))
2058 | s = sounds[filename]
2059 | s.play()
2060 |
2061 |
2062 | def screenshot(file, fmt='png'):
2063 | if _internal_windows_only() and not app:
2064 | print('ERROR: screenshot must be callbacked from e.g. timer_callback()')
2065 | return False
2066 | try:
2067 | buffer = QtCore.QBuffer()
2068 | app.primaryScreen().grabWindow(windows[0].winId()).save(buffer, fmt)
2069 | file.write(buffer.data())
2070 | return True
2071 | except Exception as e:
2072 | print('Screenshot error:', type(e), e)
2073 | return False
2074 |
2075 |
2076 | def experiment(*args, **kwargs):
2077 | if 'opengl' in args or kwargs.get('opengl'):
2078 | try:
2079 | # pip install PyOpenGL PyOpenGL-accelerate to get this going
2080 | import OpenGL
2081 | pg.setConfigOptions(useOpenGL=True, enableExperimental=True)
2082 | except Exception as e:
2083 | print('WARNING: OpenGL init error.', type(e), e)
2084 |
2085 |
2086 | #################### INTERNALS ####################
2087 |
2088 |
2089 | def _openfile(*args):
2090 | return open(*args)
2091 |
2092 |
2093 | def _loadwindata(win):
2094 | try: os.mkdir(os.path.expanduser('~/.finplot'))
2095 | except: pass
2096 | try:
2097 | f = os.path.expanduser('~/.finplot/'+win.title.replace('/','-')+'.ini')
2098 | settings = [(k.strip(),literal_eval(v.strip())) for line in _openfile(f) for k,d,v in [line.partition('=')] if v]
2099 | if not settings:
2100 | return
2101 | except:
2102 | return
2103 | kvs = {k:v for k,v in settings}
2104 | vbs = set(ax.vb for ax in win.axs)
2105 | zoom_set = False
2106 | for vb in vbs:
2107 | ds = vb.datasrc
2108 | if ds and (vb.linkedView(0) is None or vb.linkedView(0).datasrc is None or vb.master_viewbox):
2109 | period_ns = ds.period_ns
2110 | if kvs['min_x'] >= ds.x.iloc[0]-period_ns and kvs['max_x'] <= ds.x.iloc[-1]+period_ns:
2111 | x0,x1 = ds.x.loc[ds.x>=kvs['min_x']].index[0], ds.x.loc[ds.x<=kvs['max_x']].index[-1]
2112 | if x1 == len(ds.x)-1:
2113 | x1 += right_margin_candles
2114 | x1 += 0.5
2115 | zoom_set = vb.update_y_zoom(x0, x1)
2116 | return zoom_set
2117 |
2118 |
2119 | def _savewindata(win):
2120 | if not viewrestore:
2121 | return
2122 | try:
2123 | min_x = int(1e100)
2124 | max_x = int(-1e100)
2125 | for ax in win.axs:
2126 | if ax.vb.targetRect().right() < 4: # ignore empty plots
2127 | continue
2128 | if ax.vb.datasrc is None:
2129 | continue
2130 | t0,t1,_,_,_ = ax.vb.datasrc.hilo(ax.vb.targetRect().left(), ax.vb.targetRect().right())
2131 | min_x = np.nanmin([min_x, t0])
2132 | max_x = np.nanmax([max_x, t1])
2133 | if np.max(np.abs([min_x, max_x])) < 1e99:
2134 | s = 'min_x = %s\nmax_x = %s\n' % (min_x, max_x)
2135 | f = os.path.expanduser('~/.finplot/'+win.title.replace('/','-')+'.ini')
2136 | try: changed = _openfile(f).read() != s
2137 | except: changed = True
2138 | if changed:
2139 | _openfile(f, 'wt').write(s)
2140 | ## print('%s saved' % win.title)
2141 | except Exception as e:
2142 | print('Error saving plot:', e)
2143 |
2144 |
2145 | def _internal_windows_only():
2146 | return all(isinstance(win,FinWindow) for win in windows)
2147 |
2148 |
2149 | def _create_plot(ax=None, **kwargs):
2150 | if ax:
2151 | return ax
2152 | if last_ax:
2153 | return last_ax
2154 | return create_plot(**kwargs)
2155 |
2156 |
2157 | def _create_axis(pos, **kwargs):
2158 | if pos == 'x':
2159 | return EpochAxisItem(**kwargs)
2160 | elif pos == 'y':
2161 | return YAxisItem(**kwargs)
2162 |
2163 |
2164 | def _clear_timers():
2165 | for timer in timers:
2166 | timer.timeout.disconnect()
2167 | timers.clear()
2168 |
2169 |
2170 | def _add_timestamp_plot(master, prev_ax, viewbox, index, yscale):
2171 | native_win = isinstance(master, pg.GraphicsLayoutWidget)
2172 | if native_win and prev_ax is not None:
2173 | prev_ax.set_visible(xaxis=False) # hide the whole previous axis
2174 | axes = {'bottom': _create_axis(pos='x', vb=viewbox, orientation='bottom'),
2175 | 'right': _create_axis(pos='y', vb=viewbox, orientation='right')}
2176 | if native_win:
2177 | ax = pg.PlotItem(viewBox=viewbox, axisItems=axes, name='plot-%i'%index, enableMenu=False)
2178 | else:
2179 | axw = pg.PlotWidget(viewBox=viewbox, axisItems=axes, name='plot-%i'%index, enableMenu=False)
2180 | ax = axw.plotItem
2181 | ax.ax_widget = axw
2182 | ax.setClipToView(True)
2183 | ax.setDownsampling(auto=True, mode='subsample')
2184 | ax.hideAxis('left')
2185 | if y_label_width:
2186 | ax.axes['right']['item'].setWidth(y_label_width) # this is to put all graphs on equal footing when texts vary from 0.4 to 2000000
2187 | ax.axes['right']['item'].setStyle(tickLength=-5) # some bug, totally unexplicable (why setting the default value again would fix repaint width as axis scale down)
2188 | ax.axes['right']['item'].setZValue(30) # put axis in front instead of behind data
2189 | ax.axes['bottom']['item'].setZValue(30)
2190 | ax.setLogMode(y=(yscale.scaletype=='log'))
2191 | ax.significant_forced = False
2192 | ax.significant_decimals = significant_decimals
2193 | ax.significant_eps = significant_eps
2194 | ax.inverted = False
2195 | ax.axos = []
2196 | ax.crosshair = FinCrossHair(ax, color=cross_hair_color)
2197 | ax.hideButtons()
2198 | ax.overlay = partial(_ax_overlay, ax)
2199 | ax.set_visible = partial(_ax_set_visible, ax)
2200 | ax.decouple = partial(_ax_decouple, ax)
2201 | ax.disable_x_index = partial(_ax_disable_x_index, ax)
2202 | ax.reset = partial(_ax_reset, ax)
2203 | ax.invert_y = partial(_ax_invert_y, ax)
2204 | ax.expand = partial(_ax_expand, ax)
2205 | ax.prev_ax = prev_ax
2206 | ax.win_index = index
2207 | if index%2:
2208 | viewbox.setBackgroundColor(odd_plot_background)
2209 | viewbox.setParent(ax)
2210 | return ax
2211 |
2212 |
2213 | def _ax_overlay(ax, scale=0.25, yaxis=False):
2214 | '''The scale parameter defines how "high up" on the initial plot this overlay will show.
2215 | The yaxis parameter can be one of [False, 'linear', 'log'].'''
2216 | yscale = yaxis if yaxis else 'linear'
2217 | viewbox = FinViewBox(ax.vb.win, init_steps=ax.vb.init_steps, yscale=YScale(yscale, 1), enableMenu=False)
2218 | viewbox.master_viewbox = ax.vb
2219 | viewbox.setZValue(-5)
2220 | viewbox.setBackgroundColor(ax.vb.state['background'])
2221 | ax.vb.setBackgroundColor(None)
2222 | viewbox.v_zoom_scale = scale
2223 | if hasattr(ax, 'ax_widget'):
2224 | ax.ax_widget.scene().addItem(viewbox)
2225 | else:
2226 | ax.vb.win.centralWidget.scene().addItem(viewbox)
2227 | viewbox.setXLink(ax.vb)
2228 | def updateView():
2229 | viewbox.setGeometry(ax.vb.sceneBoundingRect())
2230 | axo = pg.PlotItem(enableMenu=False)
2231 | axo.significant_forced = False
2232 | axo.significant_decimals = significant_decimals
2233 | axo.significant_eps = significant_eps
2234 | axo.prev_ax = None
2235 | axo.crosshair = None
2236 | axo.decouple = partial(_ax_decouple, axo)
2237 | axo.disable_x_index = partial(_ax_disable_x_index, axo)
2238 | axo.reset = partial(_ax_reset, axo)
2239 | axo.hideAxis('left')
2240 | axo.hideAxis('right')
2241 | axo.hideAxis('bottom')
2242 | axo.hideButtons()
2243 | viewbox.addItem(axo)
2244 | axo.vb = viewbox
2245 | if yaxis and isinstance(axo.vb.win, pg.GraphicsLayoutWidget):
2246 | axi = _create_axis(pos='y', vb=axo.vb, orientation='left')
2247 | axo.setAxisItems({'left': axi})
2248 | axo.vb.win.addItem(axi, row=0, col=0)
2249 | ax.vb.sigResized.connect(updateView)
2250 | overlay_axs.append(axo)
2251 | ax.axos.append(axo)
2252 | updateView()
2253 | return axo
2254 |
2255 |
2256 | def _ax_set_visible(ax, crosshair=None, xaxis=None, yaxis=None, xgrid=None, ygrid=None):
2257 | if crosshair == False:
2258 | ax.crosshair.hide()
2259 | if xaxis is not None:
2260 | ax.getAxis('bottom').setStyle(showValues=xaxis)
2261 | if yaxis is not None:
2262 | ax.getAxis('right').setStyle(showValues=yaxis)
2263 | if xgrid is not None or ygrid is not None:
2264 | ax.showGrid(x=xgrid, y=ygrid, alpha=grid_alpha)
2265 | if ax.getAxis('right'):
2266 | ax.getAxis('right').setEnabled(False)
2267 | if ax.getAxis('bottom'):
2268 | ax.getAxis('bottom').setEnabled(False)
2269 |
2270 |
2271 | def _ax_decouple(ax):
2272 | ax.setXLink(None)
2273 | if ax.prev_ax:
2274 | ax.prev_ax.set_visible(xaxis=True)
2275 |
2276 |
2277 | def _ax_disable_x_index(ax, decouple=True):
2278 | ax.vb.x_indexed = False
2279 | if decouple:
2280 | _ax_decouple(ax)
2281 |
2282 |
2283 | def _ax_reset(ax):
2284 | if ax.crosshair is not None:
2285 | ax.crosshair.hide()
2286 | for item in list(ax.items):
2287 | if any(isinstance(item, c) for c in [FinLine, FinPolyLine, pg.TextItem]):
2288 | try:
2289 | remove_primitive(item)
2290 | except:
2291 | pass
2292 | else:
2293 | ax.removeItem(item)
2294 | if ax.vb.master_viewbox and hasattr(item, 'name') and item.name():
2295 | legend = ax.vb.master_viewbox.parent().legend
2296 | if legend:
2297 | legend.removeItem(item)
2298 | if ax.legend:
2299 | ax.legend.opts['offset'] = None
2300 | ax.legend.setParentItem(None)
2301 | ax.legend = None
2302 | ax.vb.reset()
2303 | ax.vb.set_datasrc(None)
2304 | if ax.crosshair is not None:
2305 | ax.crosshair.show()
2306 | ax.significant_forced = False
2307 |
2308 |
2309 | def _ax_invert_y(ax):
2310 | ax.inverted = not ax.inverted
2311 | ax.setTransform(ax.transform().scale(1,-1).translate(0,-ax.height()))
2312 |
2313 |
2314 | def _ax_expand(ax):
2315 | vb = ax.vb
2316 | axs = vb.win.axs
2317 | for axx in axs:
2318 | if axx.inverted: # roll back inversion if changing expansion
2319 | axx.invert_y()
2320 | show_all = any(not ax.isVisible() for ax in axs)
2321 | for rowi,axx in enumerate(axs):
2322 | if axx != ax:
2323 | axx.setVisible(show_all)
2324 | for axo in axx.axos:
2325 | axo.vb.setVisible(show_all)
2326 | ax.set_visible(xaxis=not show_all)
2327 | axs[-1].set_visible(xaxis=True)
2328 |
2329 |
2330 | def _create_legend(ax):
2331 | if ax.vb.master_viewbox:
2332 | ax = ax.vb.master_viewbox.parent()
2333 | if ax.legend is None:
2334 | ax.legend = FinLegendItem(border_color=legend_border_color, fill_color=legend_fill_color, size=None, offset=(3,2))
2335 | ax.legend.setParentItem(ax.vb)
2336 |
2337 |
2338 | def _update_significants(ax, datasrc, force):
2339 | # check if no epsilon set yet
2340 | default_dec = 0.99 < ax.significant_decimals/significant_decimals < 1.01
2341 | default_eps = 0.99 < ax.significant_eps/significant_eps < 1.01
2342 | if force or (default_dec and default_eps):
2343 | try:
2344 | sd,se = datasrc.calc_significant_decimals(full=force)
2345 | if sd or se != significant_eps:
2346 | if (force and not ax.significant_forced) or default_dec or sd > ax.significant_decimals:
2347 | ax.significant_decimals = sd
2348 | ax.significant_forced |= force
2349 | if (force and not ax.significant_forced) or default_eps or se < ax.significant_eps:
2350 | ax.significant_eps = se
2351 | ax.significant_forced |= force
2352 | except:
2353 | pass # datasrc probably full av NaNs
2354 |
2355 |
2356 | def _improve_significants(ax):
2357 | '''Force update of the EPS if we both have no bars/candles AND a log scale.
2358 | This is intended to fix the lower part of the grid on line plots on a log scale.'''
2359 | if ax.vb.yscale.scaletype == 'log':
2360 | if not any(isinstance(item, CandlestickItem) for item in ax.items):
2361 | _update_significants(ax, ax.vb.datasrc, force=True)
2362 |
2363 |
2364 | def _is_standalone(timeser):
2365 | # more than N percent gaps or time reversals probably means this is a standalone plot
2366 | return timeser.isnull().sum() + (timeser.diff()<=0).sum() > len(timeser)*0.1
2367 |
2368 |
2369 | def _create_poly_line(*args, **kwargs):
2370 | return FinPolyLine(*args, **kwargs)
2371 |
2372 |
2373 | def _create_series(a):
2374 | return a if isinstance(a, pd.Series) else pd.Series(a)
2375 |
2376 |
2377 | def _create_datasrc(ax, *args, ncols=-1, allow_scaling=True):
2378 | def do_create(args):
2379 | if len(args) == 1 and type(args[0]) == PandasDataSource:
2380 | return args[0]
2381 | if len(args) == 1 and type(args[0]) in (list, tuple):
2382 | args = [np.array(args[0])]
2383 | if len(args) == 1 and type(args[0]) == np.ndarray:
2384 | args = [pd.DataFrame(args[0].T)]
2385 | if len(args) == 1 and type(args[0]) == pd.DataFrame:
2386 | return PandasDataSource(args[0])
2387 | args = [_create_series(a) for a in args]
2388 | return PandasDataSource(pd.concat(args, axis=1))
2389 | iargs = [a for a in args if a is not None]
2390 | datasrc = do_create(iargs)
2391 | # check if time column missing
2392 | if len(datasrc.df.columns) in (1, ncols-1):
2393 | # assume time data has already been added before
2394 | for a in ax.vb.win.axs:
2395 | if a.vb.datasrc and len(a.vb.datasrc.df.columns) >= 2:
2396 | col = a.vb.datasrc.df.columns[0]
2397 | if col in datasrc.df.columns:
2398 | # ensure column names are unique
2399 | datasrc.df.columns = a.vb.datasrc.df.columns[1:len(datasrc.df.columns)+1]
2400 | col = a.vb.datasrc.df.columns[0]
2401 | datasrc.df.insert(0, col, a.vb.datasrc.df[col])
2402 | datasrc = PandasDataSource(datasrc.df)
2403 | break
2404 | if len(datasrc.df.columns) in (1, ncols-1):
2405 | if ncols > 1:
2406 | print(f"WARNING: this type of plot wants %i columns/args, but you've only supplied %i" % (ncols, len(datasrc.df.columns)))
2407 | print(' - Assuming time column is missing and using index instead.')
2408 | datasrc = PandasDataSource(datasrc.df.reset_index())
2409 | elif len(iargs) >= 2 and len(datasrc.df.columns) == len(iargs)+1 and len(iargs) == len(args):
2410 | try:
2411 | if '.Int' in str(type(iargs[0].index)):
2412 | print('WARNING: performance penalty and crash may occur when using int64 instead of range indices.')
2413 | if (iargs[0].index == range(len(iargs[0]))).all():
2414 | print(' - Fix by .reset_index(drop=True)')
2415 | return _create_datasrc(ax, datasrc.df[datasrc.df.columns[1:]], ncols=ncols)
2416 | except:
2417 | print('WARNING: input data source may cause performance penalty and crash.')
2418 |
2419 | assert len(datasrc.df.columns) >= ncols, 'ERROR: too few columns/args supplied for this plot'
2420 |
2421 | if datasrc.period_ns < 0:
2422 | print('WARNING: input data source has time in descending order. Try sort_values() before calling.')
2423 |
2424 | # FIX: stupid QT bug causes rectangles larger than 2G to flicker, so scale rendering down some
2425 | # FIX: PyQt 5.15.2 lines >1e6 are being clipped to 1e6 during the first render pass, so scale down if >1e6
2426 | if ax.vb.yscale.scalef == 1 and allow_scaling and datasrc.df.iloc[:, 1:].max(numeric_only=True).max() > 1e6:
2427 | ax.vb.yscale.set_scale(int(1e6))
2428 | return datasrc
2429 |
2430 |
2431 | def _set_datasrc(ax, datasrc, addcols=True):
2432 | viewbox = ax.vb
2433 | if not datasrc.standalone:
2434 | if viewbox.datasrc is None:
2435 | viewbox.set_datasrc(datasrc) # for mwheel zoom-scaling
2436 | _set_x_limits(ax, datasrc)
2437 | else:
2438 | t0 = viewbox.datasrc.x.loc[0]
2439 | if addcols:
2440 | viewbox.datasrc.addcols(datasrc)
2441 | else:
2442 | viewbox.datasrc.df = datasrc.df
2443 | # check if we need to re-render previous plots due to changed indices
2444 | indices_updated = viewbox.datasrc.timebased() and t0 != viewbox.datasrc.x.loc[0]
2445 | for item in ax.items:
2446 | if hasattr(item, 'datasrc') and not item.datasrc.standalone:
2447 | item.datasrc.set_df(viewbox.datasrc.df) # every plot here now has the same time-frame
2448 | if indices_updated:
2449 | _start_visual_update(item)
2450 | _end_visual_update(item)
2451 | _set_x_limits(ax, datasrc)
2452 | viewbox.set_datasrc(viewbox.datasrc) # update zoom
2453 | else:
2454 | viewbox.standalones.add(datasrc)
2455 | datasrc.update_init_x(viewbox.init_steps)
2456 | if datasrc.timebased() and viewbox.datasrc is not None:
2457 | ## print('WARNING: re-indexing standalone, time-based plot')
2458 | vdf = viewbox.datasrc.df
2459 | d = {v:k for k,v in enumerate(vdf[vdf.columns[0]])}
2460 | datasrc.df.index = [d[i] for i in datasrc.df[datasrc.df.columns[0]]]
2461 | ## if not viewbox.x_indexed:
2462 | ## _set_x_limits(ax, datasrc)
2463 | # update period if this datasrc has higher time resolution
2464 | global epoch_period
2465 | if datasrc.timebased() and (epoch_period > 1e7 or not datasrc.standalone):
2466 | ep_secs = datasrc.period_ns / 1e9
2467 | epoch_period = ep_secs if ep_secs < epoch_period else epoch_period
2468 |
2469 |
2470 | def _has_timecol(df):
2471 | return len(df.columns) >= 2
2472 |
2473 |
2474 | def _set_max_zoom(vbs):
2475 | '''Set the relative allowed zoom level between axes groups, where the lowest-resolution
2476 | plot in each group uses max_zoom_points, while the others get a scale >=1 of their
2477 | respective highest zoom level.'''
2478 | groups = defaultdict(set)
2479 | for vb in vbs:
2480 | master_vb = vb.linkedView(0)
2481 | if vb.datasrc and master_vb and master_vb.datasrc:
2482 | groups[master_vb].add(vb)
2483 | groups[master_vb].add(master_vb)
2484 | for group in groups.values():
2485 | minlen = min(len(vb.datasrc.df) for vb in group)
2486 | for vb in group:
2487 | vb.max_zoom_points_f = len(vb.datasrc.df) / minlen
2488 |
2489 |
2490 | def _adjust_renko_datasrc(bins, step, datasrc):
2491 | if not bins and not step:
2492 | bins = 50
2493 | if not step:
2494 | step = (datasrc.y.max()-datasrc.y.min()) / bins
2495 | bricks = datasrc.y.diff() / step
2496 | bricks = (datasrc.y[bricks.isnull() | (bricks.abs()>=0.5)] / step).round().astype(int)
2497 | extras = datasrc.df.iloc[:, datasrc.col_data_offset+1:]
2498 | ts = datasrc.x[bricks.index]
2499 | up = bricks.iloc[0] + 1
2500 | dn = up - 2
2501 | data = []
2502 | for t,i,brick in zip(ts, bricks.index, bricks):
2503 | s = 0
2504 | if brick >= up:
2505 | x0,x1,s = up-1,brick,+1
2506 | up = brick+1
2507 | dn = brick-2
2508 | elif brick <= dn:
2509 | x0,x1,s = dn,brick-1,-1
2510 | up = brick+2
2511 | dn = brick-1
2512 | if s:
2513 | for x in range(x0, x1, s):
2514 | td = abs(x1-x)-1
2515 | ds = 0 if s>0 else step
2516 | y = x*step
2517 | z = list(extras.loc[i])
2518 | data.append([t-td, y+ds, y+step-ds, y+step, y] + z)
2519 | datasrc.set_df(pd.DataFrame(data, columns='time open close high low'.split()+list(extras.columns)))
2520 |
2521 |
2522 | def _adjust_renko_log_datasrc(bins, step, datasrc):
2523 | datasrc.df.loc[:,datasrc.df.columns[1]] = np.log10(datasrc.df.iloc[:,1])
2524 | _adjust_renko_datasrc(bins, step, datasrc)
2525 | datasrc.df.loc[:,datasrc.df.columns[1:5]] = 10**datasrc.df.iloc[:,1:5]
2526 |
2527 |
2528 | def _adjust_volume_datasrc(datasrc):
2529 | if len(datasrc.df.columns) <= 4:
2530 | _insert_col(datasrc, 3, '_zero_', [0]*len(datasrc.df)) # base of candles is always zero
2531 | datasrc.set_df(datasrc.df.iloc[:,[0,3,4,1,2]]) # re-arrange columns for rendering
2532 | datasrc.scale_cols = [1, 2] # scale by both baseline and volume
2533 |
2534 |
2535 | def _preadjust_horiz_datasrc(datasrc):
2536 | arrayify = lambda d: d.values if type(d) == pd.DataFrame else d
2537 | # create a dataframe from the input array
2538 | times = [t for t,row in datasrc]
2539 | if len(times) == 1: # add an empty time slot
2540 | times = [times[0], times[0]+1]
2541 | datasrc = datasrc + [[times[1], [(p,0) for p,v in arrayify(datasrc[0][1])]]]
2542 | data = [[e for v in arrayify(row) for e in v] for t,row in datasrc]
2543 | maxcols = max(len(row) for row in data)
2544 | return pd.DataFrame(columns=range(maxcols), data=data, index=times)
2545 |
2546 |
2547 | def _adjust_horiz_datasrc(datasrc):
2548 | # to be able to scale properly, move the last two values to the last two columns
2549 | values = datasrc.df.iloc[:, 1:].values
2550 | for i,nrow in enumerate(values):
2551 | orow = nrow[~np.isnan(nrow)]
2552 | if len(nrow) == len(orow) or len(orow) <= 2:
2553 | continue
2554 | nrow[-2:] = orow[-2:]
2555 | nrow[len(orow)-2:len(orow)] = np.nan
2556 | datasrc.df[datasrc.df.columns[1:]] = values
2557 |
2558 |
2559 | def _adjust_bar_datasrc(datasrc, order_cols=True):
2560 | if len(datasrc.df.columns) <= 2:
2561 | _insert_col(datasrc, 1, '_base_', [0]*len(datasrc.df)) # base
2562 | if len(datasrc.df.columns) <= 4:
2563 | _insert_col(datasrc, 1, '_open_', [0]*len(datasrc.df)) # "open" for color
2564 | _insert_col(datasrc, 2, '_close_', datasrc.df.iloc[:, 3]) # "close" (actual bar value) for color
2565 | if order_cols:
2566 | datasrc.set_df(datasrc.df.iloc[:,[0,3,4,1,2]]) # re-arrange columns for rendering
2567 | datasrc.scale_cols = [1, 2] # scale by both baseline and volume
2568 |
2569 |
2570 | def _update_data(preadjustfunc, adjustfunc, item, ds, gfx=True):
2571 | if preadjustfunc:
2572 | ds = preadjustfunc(ds)
2573 | ds = _create_datasrc(item.ax, ds)
2574 | if adjustfunc:
2575 | adjustfunc(ds)
2576 | cs = list(item.datasrc.df.columns[:1]) + list(item.datasrc.df.columns[item.datasrc.col_data_offset:])
2577 | if len(cs) >= len(ds.df.columns):
2578 | ds.df.columns = cs[:len(ds.df.columns)]
2579 | item.datasrc.update(ds)
2580 | _set_datasrc(item.ax, item.datasrc, addcols=False)
2581 | if gfx:
2582 | item.update_gfx()
2583 |
2584 |
2585 | def _update_gfx(item):
2586 | _start_visual_update(item)
2587 | for i in item.ax.items:
2588 | if i == item or not hasattr(i, 'datasrc'):
2589 | continue
2590 | lc = len(item.datasrc.df)
2591 | li = len(i.datasrc.df)
2592 | if lc and li and max(lc,li)/min(lc,li) > 100: # TODO: should be typed instead
2593 | continue
2594 | cc = item.datasrc.df.columns
2595 | ci = i.datasrc.df.columns
2596 | c0 = [c for c in ci if c in cc]
2597 | c1 = [c for c in ci if not c in cc]
2598 | df_clipped = item.datasrc.df[c0]
2599 | if c1:
2600 | df_clipped = df_clipped.copy()
2601 | for c in c1:
2602 | df_clipped[c] = i.datasrc.df[c]
2603 | i.datasrc.set_df(df_clipped)
2604 | break
2605 | update_sigdig = False
2606 | if not item.datasrc.standalone and not item.ax.vb.win._isMouseLeftDrag:
2607 | # new limits when extending/reducing amount of data
2608 | x_min,x1 = _set_x_limits(item.ax, item.datasrc)
2609 | # scroll all plots if we're at the far right
2610 | tr = item.ax.vb.targetRect()
2611 | x0 = x1 - tr.width()
2612 | if x0 < right_margin_candles + side_margin:
2613 | x0 = -side_margin
2614 | if tr.right() < x1 - 5 - 2*right_margin_candles:
2615 | x0 = x1 = None
2616 | prev_top = item.ax.vb.targetRect().top()
2617 | item.ax.vb.update_y_zoom(x0, x1)
2618 | this_top = item.ax.vb.targetRect().top()
2619 | if this_top and not (0.99 < abs(prev_top/this_top) < 1.01):
2620 | update_sigdig = True
2621 | if item.ax.axes['bottom']['item'].isVisible(): # update axes if visible
2622 | item.ax.axes['bottom']['item'].hide()
2623 | item.ax.axes['bottom']['item'].show()
2624 | _end_visual_update(item)
2625 | if update_sigdig:
2626 | _update_significants(item.ax, item.ax.vb.datasrc, force=True)
2627 |
2628 |
2629 | def _start_visual_update(item):
2630 | if isinstance(item, FinPlotItem):
2631 | item.ax.removeItem(item)
2632 | item.dirty = True
2633 | else:
2634 | y = item.datasrc.y / item.ax.vb.yscale.scalef
2635 | if item.ax.vb.yscale.scaletype == 'log':
2636 | y = y + log_plot_offset
2637 | x = item.datasrc.index if item.ax.vb.x_indexed else item.datasrc.x
2638 | item.setData(x, y)
2639 |
2640 |
2641 | def _end_visual_update(item):
2642 | if isinstance(item, FinPlotItem):
2643 | item.ax.addItem(item)
2644 | item.repaint()
2645 |
2646 |
2647 | def _set_x_limits(ax, datasrc):
2648 | x0,x1 = _xminmax(datasrc, x_indexed=ax.vb.x_indexed)
2649 | ax.setLimits(xMin=x0, xMax=x1)
2650 | return x0,x1
2651 |
2652 |
2653 | def _xminmax(datasrc, x_indexed, init_steps=None, extra_margin=0):
2654 | if x_indexed and init_steps:
2655 | # initial zoom
2656 | x0 = max(datasrc.xlen-init_steps, 0) - side_margin - extra_margin
2657 | x1 = datasrc.xlen + right_margin_candles + side_margin + extra_margin
2658 | elif x_indexed:
2659 | # total x size for indexed data
2660 | x0 = -side_margin - extra_margin
2661 | x1 = datasrc.xlen + right_margin_candles - 1 + side_margin + extra_margin # add another margin to get the "snap back" sensation
2662 | else:
2663 | # x size for plain Y-over-X data (i.e. not indexed)
2664 | x0 = datasrc.x.min()
2665 | x1 = datasrc.x.max()
2666 | # extend margin on decoupled plots
2667 | d = (x1-x0) * (0.2+extra_margin)
2668 | x0 -= d
2669 | x1 += d
2670 | return x0,x1
2671 |
2672 |
2673 | def _repaint_candles():
2674 | '''Candles are only partially drawn, and therefore needs manual dirty reminder whenever it goes off-screen.'''
2675 | axs = [ax for win in windows for ax in win.axs] + overlay_axs
2676 | for ax in axs:
2677 | for item in list(ax.items):
2678 | if isinstance(item, FinPlotItem):
2679 | _start_visual_update(item)
2680 | _end_visual_update(item)
2681 |
2682 |
2683 | def _paint_scatter(item, p, *args):
2684 | with np.errstate(invalid='ignore'): # make pg's mask creation calls to numpy shut up
2685 | item._dopaint(p, *args)
2686 |
2687 |
2688 | def _key_pressed(vb, ev):
2689 | if ev.text() == 'g': # grid
2690 | global clamp_grid
2691 | clamp_grid = not clamp_grid
2692 | for win in windows:
2693 | for ax in win.axs:
2694 | ax.crosshair.update()
2695 | elif ev.text() == 'i': # invert y-axes
2696 | for win in windows:
2697 | for ax in win.axs:
2698 | ax.invert_y()
2699 | elif ev.text() == 'f': # focus/expand active axis
2700 | vb.parent().expand()
2701 | elif ev.text() in ('\r', ' '): # enter, space
2702 | vb.set_draw_line_color(draw_done_color)
2703 | vb.draw_line = None
2704 | elif ev.text() in ('\x7f', '\b'): # del, backspace
2705 | if not vb.remove_last_roi():
2706 | return False
2707 | elif ev.key() == QtCore.Qt.Key.Key_Left:
2708 | vb.pan_x(percent=-15)
2709 | elif ev.key() == QtCore.Qt.Key.Key_Right:
2710 | vb.pan_x(percent=+15)
2711 | elif ev.key() == QtCore.Qt.Key.Key_Home:
2712 | vb.pan_x(steps=-1e10)
2713 | _repaint_candles()
2714 | elif ev.key() == QtCore.Qt.Key.Key_End:
2715 | vb.pan_x(steps=+1e10)
2716 | _repaint_candles()
2717 | elif ev.key() == QtCore.Qt.Key.Key_Escape and key_esc_close:
2718 | vb.win.close()
2719 | else:
2720 | return False
2721 | return True
2722 |
2723 |
2724 | def _mouse_clicked(vb, ev):
2725 | if ev.button() == 8: # back
2726 | vb.pan_x(percent=-30)
2727 | elif ev.button() == 16: # fwd
2728 | vb.pan_x(percent=+30)
2729 | else:
2730 | return False
2731 | return True
2732 |
2733 |
2734 | def _mouse_moved(master, vb, evs):
2735 | if hasattr(master, 'closing') and master.closing:
2736 | return
2737 | md = master_data[master].get(vb) or master_data[master].get('default')
2738 | if md is None:
2739 | return
2740 | if not evs:
2741 | evs = md['last_mouse_evs']
2742 | if not evs:
2743 | return
2744 | md['last_mouse_evs'] = evs
2745 | pos = evs[-1]
2746 | # allow inter-pixel moves if moving mouse slowly
2747 | y = pos.y()
2748 | dy = y - md['last_mouse_y']
2749 | if 0 < abs(dy) <= 1:
2750 | pos.setY(pos.y() - dy/2)
2751 | md['last_mouse_y'] = y
2752 | # apply to all crosshairs
2753 | for ax in master.axs:
2754 | if ax.isVisible() and ax.crosshair:
2755 | point = ax.vb.mapSceneToView(pos)
2756 | ax.crosshair.update(point)
2757 |
2758 |
2759 | def _get_link_group(master, vb):
2760 | '''return the viewboxes in the same linked group as the parameter one'''
2761 | vbs = [ax.vb for ax in master.axs]
2762 | vb_master_to_linked = defaultdict(set)
2763 | for vb0 in vbs:
2764 | vb_master = vb0.linkedView(0)
2765 | if vb_master:
2766 | vb_master_to_linked[vb_master].add(vb_master)
2767 | vb_master_to_linked[vb_master].add(vb0)
2768 | continue
2769 | vb_master_to_linked[vb0].add(vb0) # add master itself
2770 | affected_vbs = vb_master_to_linked[vb.linkedView(0) or vb]
2771 | return affected_vbs
2772 |
2773 |
2774 | def _wheel_event_wrapper(self, orig_func, ev):
2775 | # scrolling on the border is simply annoying, pop in a couple of pixels to make sure
2776 | d = QtCore.QPointF(-2,0)
2777 | ev = QtGui.QWheelEvent(ev.position()+d, ev.globalPosition()+d, ev.pixelDelta(), ev.angleDelta(), ev.buttons(), ev.modifiers(), ev.phase(), False)
2778 | orig_func(self, ev)
2779 |
2780 |
2781 | def _mcallback_click(ax, callback, when, evs):
2782 | if evs[-1].accepted:
2783 | return
2784 | if when == 'dclick' and evs[-1].double():
2785 | pass
2786 | elif when == 'lclick' and evs[-1].button() == QtCore.Qt.MouseButton.LeftButton:
2787 | pass
2788 | elif when == 'mclick' and evs[-1].button() == QtCore.Qt.MouseButton.MiddleButton:
2789 | pass
2790 | elif when == 'rclick' and evs[-1].button() == QtCore.Qt.MouseButton.RightButton:
2791 | pass
2792 | elif when == 'any':
2793 | pass
2794 | else:
2795 | return
2796 | pos = evs[-1].scenePos()
2797 | return _mcallback_pos(ax, callback, (pos,))
2798 |
2799 |
2800 | def _mcallback_pos(ax, callback, poss):
2801 | if not ax.vb.datasrc:
2802 | return
2803 | point = ax.vb.mapSceneToView(poss[-1])
2804 | t = point.x() + 0.5
2805 | try:
2806 | t = ax.vb.datasrc.closest_time(t)
2807 | except KeyError: # when clicking beyond right_margin_candles
2808 | if clamp_grid:
2809 | t = ax.vb.datasrc.x.iloc[-1 if t > 0 else 0]
2810 | try:
2811 | callback(t, point.y())
2812 | except OSError as e:
2813 | pass
2814 | except Exception as e:
2815 | print('Inspection error:', type(e), e)
2816 |
2817 |
2818 | def brighten(color, f):
2819 | if not color:
2820 | return color
2821 | return pg.mkColor(color).lighter(int(f*100))
2822 |
2823 |
2824 | def _get_color(ax, style, wanted_color):
2825 | if type(wanted_color) in (str, QtGui.QColor):
2826 | return wanted_color
2827 | index = wanted_color if type(wanted_color) == int else None
2828 | is_line = lambda style: style is None or any(ch in style for ch in '-_.')
2829 | get_handed_color = lambda item: item.opts.get('handed_color')
2830 | this_line = is_line(style)
2831 | if this_line:
2832 | colors = soft_colors
2833 | else:
2834 | colors = hard_colors
2835 | if index is None:
2836 | avoid = set(i.opts['handed_color'] for i in ax.items if isinstance(i,pg.PlotDataItem) and get_handed_color(i) is not None and this_line==is_line(i.opts['symbol']))
2837 | index = len([i for i in ax.items if isinstance(i,pg.PlotDataItem) and get_handed_color(i) is None and this_line==is_line(i.opts['symbol'])])
2838 | while index in avoid:
2839 | index += 1
2840 | return colors[index%len(colors)]
2841 |
2842 |
2843 | def _pdtime2epoch(t):
2844 | if isinstance(t, pd.Series):
2845 | if isinstance(t.iloc[0], pd.Timestamp):
2846 | dtype = str(t.dtype)
2847 | if '[s' in dtype:
2848 | return t.astype('int64') * int(1e9)
2849 | elif '[ms' in dtype:
2850 | return t.astype('int64') * int(1e6)
2851 | elif '[us' in dtype:
2852 | return t.astype('int64') * int(1e3)
2853 | return t.astype('int64')
2854 | h = np.nanmax(t.values)
2855 | if h < 1e10: # handle s epochs
2856 | return (t*1e9).astype('int64')
2857 | if h < 1e13: # handle ms epochs
2858 | return (t*1e6).astype('int64')
2859 | if h < 1e16: # handle us epochs
2860 | return (t*1e3).astype('int64')
2861 | return t.astype('int64')
2862 | return t
2863 |
2864 |
2865 | def _pdtime2index(ax, ts, any_end=False, require_time=False):
2866 | if isinstance(ts.iloc[0], pd.Timestamp):
2867 | ts = ts.astype('int64')
2868 | else:
2869 | h = np.nanmax(ts.values)
2870 | if h < 1e7:
2871 | if require_time:
2872 | assert False, 'not a time series'
2873 | return ts
2874 | if h < 1e10: # handle s epochs
2875 | ts = ts.astype('float64') * 1e9
2876 | elif h < 1e13: # handle ms epochs
2877 | ts = ts.astype('float64') * 1e6
2878 | elif h < 1e16: # handle us epochs
2879 | ts = ts.astype('float64') * 1e3
2880 |
2881 | datasrc = _get_datasrc(ax)
2882 | xs = datasrc.x
2883 |
2884 | # try exact match before approximate match
2885 | if all(xs.isin(ts)):
2886 | exact = datasrc.index[ts].to_list()
2887 | if len(exact) == len(ts):
2888 | return exact
2889 |
2890 | r = []
2891 | for i,t in enumerate(ts):
2892 | xss = xs.loc[xs>t]
2893 | if len(xss) == 0:
2894 | t0 = xs.iloc[-1]
2895 | if any_end or t0 == t:
2896 | r.append(len(xs)-1)
2897 | continue
2898 | if i > 0:
2899 | continue
2900 | assert t <= t0, 'must plot this primitive in prior time-range'
2901 | i1 = xss.index[0]
2902 | i0 = i1-1
2903 | if i0 < 0:
2904 | i0,i1 = 0,1
2905 | t0,t1 = xs.loc[i0], xs.loc[i1]
2906 | if t0 == t1:
2907 | r.append(i0)
2908 | else:
2909 | dt = (t-t0) / (t1-t0)
2910 | r.append(lerp(dt, i0, i1))
2911 | return r
2912 |
2913 |
2914 | def _is_str_midnight(s):
2915 | return s.endswith(' 00:00') or s.endswith(' 00:00:00')
2916 |
2917 |
2918 | def _get_datasrc(ax, require=True):
2919 | if ax.vb.datasrc is not None or not ax.vb.x_indexed:
2920 | return ax.vb.datasrc
2921 | vbs = [ax.vb for win in windows for ax in win.axs]
2922 | for vb in vbs:
2923 | if vb.datasrc:
2924 | return vb.datasrc
2925 | if require:
2926 | assert ax.vb.datasrc, 'not possible to plot this primitive without a prior time-range to compare to'
2927 |
2928 |
2929 | def _insert_col(datasrc, col_idx, col_name, data):
2930 | if col_name not in datasrc.df.columns:
2931 | datasrc.df.insert(col_idx, col_name, data)
2932 |
2933 |
2934 | def _millisecond_tz_wrap(s):
2935 | if len(s) > 6 and s[-6] in '+-' and s[-3] == ':': # +01:00 fmt timezone present?
2936 | s = s[:-6]
2937 | return (s+'.000000') if '.' not in s else s
2938 |
2939 |
2940 | def _x2local_t(datasrc, x):
2941 | if display_timezone == None:
2942 | return _x2utc(datasrc, x)
2943 | return _x2t(datasrc, x, lambda t: _millisecond_tz_wrap(datetime.fromtimestamp(t/1e9, tz=display_timezone).strftime(timestamp_format)))
2944 |
2945 |
2946 | def _x2utc(datasrc, x):
2947 | # using pd.to_datetime allow for pre-1970 dates
2948 | return _x2t(datasrc, x, lambda t: pd.to_datetime(t, unit='ns').strftime(timestamp_format))
2949 |
2950 |
2951 | def _x2t(datasrc, x, ts2str):
2952 | if not datasrc:
2953 | return '',False
2954 | try:
2955 | x += 0.5
2956 | t,_,_,_,cnt = datasrc.hilo(x, x)
2957 | if cnt:
2958 | if not datasrc.timebased():
2959 | return '%g' % t, False
2960 | s = ts2str(t)
2961 | if not truncate_timestamp:
2962 | return s,True
2963 | if epoch_period >= 23*60*60: # daylight savings, leap seconds, etc
2964 | i = s.index(' ')
2965 | elif epoch_period >= 59: # consider leap seconds
2966 | i = s.rindex(':')
2967 | elif epoch_period >= 1:
2968 | i = s.index('.') if '.' in s else len(s)
2969 | elif epoch_period >= 0.001:
2970 | i = -3
2971 | else:
2972 | i = len(s)
2973 | return s[:i],True
2974 | except Exception as e:
2975 | import traceback
2976 | traceback.print_exc()
2977 | return '',datasrc.timebased()
2978 |
2979 |
2980 | def _x2year(datasrc, x):
2981 | t,hasds = _x2local_t(datasrc, x)
2982 | return t[:4],hasds
2983 |
2984 |
2985 | def _round_to_significant(rng, rngmax, x, significant_decimals, significant_eps):
2986 | is_highres = (rng/significant_eps > 1e2 and rngmax<1e-2) or abs(rngmax) > 1e7 or rng < 1e-5
2987 | sd = significant_decimals
2988 | if is_highres and abs(x)>0:
2989 | exp10 = floor(np.log10(abs(x)))
2990 | x = x / (10**exp10)
2991 | rm = int(abs(np.log10(rngmax))) if rngmax>0 else 0
2992 | sd = min(3, sd+rm)
2993 | fmt = '%%%i.%ife%%i' % (sd, sd)
2994 | r = fmt % (x, exp10)
2995 | else:
2996 | eps = fmod(x, significant_eps)
2997 | if abs(eps) >= significant_eps/2:
2998 | # round up
2999 | eps -= np.sign(eps)*significant_eps
3000 | xx = x - eps
3001 | fmt = '%%%i.%if' % (sd, sd)
3002 | r = fmt % xx
3003 | if abs(x)>0 and rng<1e4 and r.startswith('0.0') and float(r[:-1]) == 0:
3004 | r = '%.2e' % x
3005 | return r
3006 |
3007 |
3008 | def _roihandle_move_snap(vb, orig_func, pos, modifiers=QtCore.Qt.KeyboardModifier, finish=True):
3009 | pos = vb.mapDeviceToView(pos)
3010 | pos = _clamp_point(vb.parent(), pos)
3011 | pos = vb.mapViewToDevice(pos)
3012 | orig_func(pos, modifiers=modifiers, finish=finish)
3013 |
3014 |
3015 | def _clamp_xy(ax, x, y):
3016 | if not clamp_grid:
3017 | return x, y
3018 | y = ax.vb.yscale.xform(y)
3019 | # scale x
3020 | if ax.vb.x_indexed:
3021 | ds = ax.vb.datasrc
3022 | if x < 0 or (ds and x > len(ds.df)-1):
3023 | x = 0 if x < 0 else len(ds.df)-1
3024 | x = _round(x)
3025 | # scale y
3026 | if y < 0.1 and ax.vb.yscale.scaletype == 'log':
3027 | magnitude = int(3 - np.log10(y)) # round log to N decimals
3028 | y = round(y, magnitude)
3029 | else: # linear
3030 | eps = ax.significant_eps
3031 | if eps > 1e-8:
3032 | eps2 = np.sign(y) * 0.5 * eps
3033 | y -= fmod(y+eps2, eps) - eps2
3034 | y = ax.vb.yscale.invxform(y, verify=True)
3035 | return x, y
3036 |
3037 |
3038 | def _clamp_point(ax, p):
3039 | if clamp_grid:
3040 | x,y = _clamp_xy(ax, p.x(), p.y())
3041 | return pg.Point(x, y)
3042 | return p
3043 |
3044 |
3045 | def _comp_set_clamp_pos(set_pos_func, pos):
3046 | if clamp_grid:
3047 | if isinstance(pos, QtCore.QPointF):
3048 | pos = pg.Point(int(pos.x()), pos.y())
3049 | else:
3050 | pos = int(pos)
3051 | set_pos_func(pos)
3052 |
3053 |
3054 | def _set_clamp_pos(comp):
3055 | comp.setPos = partial(_comp_set_clamp_pos, comp.setPos)
3056 |
3057 |
3058 | def _draw_line_segment_text(polyline, segment, pos0, pos1):
3059 | fsecs = None
3060 | datasrc = polyline.vb.datasrc
3061 | if datasrc and clamp_grid:
3062 | try:
3063 | x0,x1 = pos0.x()+0.5, pos1.x()+0.5
3064 | t0,_,_,_,cnt0 = datasrc.hilo(x0, x0)
3065 | t1,_,_,_,cnt1 = datasrc.hilo(x1, x1)
3066 | if cnt0 and cnt1:
3067 | fsecs = abs(t1 - t0) / 1e9
3068 | except:
3069 | pass
3070 | diff = pos1 - pos0
3071 | if fsecs is None:
3072 | fsecs = abs(diff.x()*epoch_period)
3073 | secs = int(fsecs)
3074 | mins = secs//60
3075 | hours = mins//60
3076 | mins = mins%60
3077 | secs = secs%60
3078 | if hours==0 and mins==0 and secs < 60 and epoch_period < 1:
3079 | msecs = int((fsecs-int(fsecs))*1000)
3080 | ts = '%0.2i:%0.2i.%0.3i' % (mins, secs, msecs)
3081 | elif hours==0 and mins < 60 and epoch_period < 60:
3082 | ts = '%0.2i:%0.2i:%0.2i' % (hours, mins, secs)
3083 | elif hours < 24:
3084 | ts = '%0.2i:%0.2i' % (hours, mins)
3085 | else:
3086 | days = hours // 24
3087 | hours %= 24
3088 | ts = '%id %0.2i:%0.2i' % (days, hours, mins)
3089 | if ts.endswith(' 00:00'):
3090 | ts = ts.partition(' ')[0]
3091 | ysc = polyline.vb.yscale
3092 | if polyline.vb.y_positive:
3093 | y0,y1 = ysc.xform(pos0.y()), ysc.xform(pos1.y())
3094 | if y0:
3095 | gain = y1 / y0 - 1
3096 | if gain > 10:
3097 | value = 'x%i' % gain
3098 | else:
3099 | value = '%+.2f %%' % (100 * gain)
3100 | elif not y1:
3101 | value = '0'
3102 | else:
3103 | value = '+∞' if y1>0 else '-∞'
3104 | else:
3105 | dy = ysc.xform(diff.y())
3106 | if dy and (abs(dy) >= 1e4 or abs(dy) <= 1e-2):
3107 | value = '%+3.3g' % dy
3108 | else:
3109 | value = '%+2.2f' % dy
3110 | extra = _draw_line_extra_text(polyline, segment, pos0, pos1)
3111 | return '%s %s (%s)' % (value, extra, ts)
3112 |
3113 |
3114 | def _draw_line_extra_text(polyline, segment, pos0, pos1):
3115 | '''Shows the proportions of this line height compared to the previous segment.'''
3116 | prev_text = None
3117 | for text in polyline.texts:
3118 | if prev_text is not None and text.segment == segment:
3119 | h0 = prev_text.segment.handles[0]['item']
3120 | h1 = prev_text.segment.handles[1]['item']
3121 | prev_change = h1.pos().y() - h0.pos().y()
3122 | this_change = pos1.y() - pos0.y()
3123 | if not abs(prev_change) > 1e-14:
3124 | break
3125 | change_part = abs(this_change / prev_change)
3126 | return ' = 1:%.2f ' % change_part
3127 | prev_text = text
3128 | return ''
3129 |
3130 |
3131 | def _makepen(color, style=None, width=1):
3132 | if style is None or style == '-':
3133 | return pg.mkPen(color=color, width=width)
3134 | dash = []
3135 | for ch in style:
3136 | if ch == '-':
3137 | dash += [4,2]
3138 | elif ch == '_':
3139 | dash += [10,2]
3140 | elif ch == '.':
3141 | dash += [1,2]
3142 | elif ch == ' ':
3143 | if dash:
3144 | dash[-1] += 2
3145 | return pg.mkPen(color=color, style=QtCore.Qt.PenStyle.CustomDashLine, dash=dash, width=width)
3146 |
3147 |
3148 | def _round(v):
3149 | return floor(v+0.5)
3150 |
3151 |
3152 | try:
3153 | qtver = '%d.%d' % (QtCore.QT_VERSION//256//256, QtCore.QT_VERSION//256%256)
3154 | if qtver not in ('5.9', '5.13') and [int(i) for i in pg.__version__.split('.')] <= [0,11,0]:
3155 | print('WARNING: your version of Qt may not plot curves containing NaNs and is not recommended.')
3156 | print('See https://github.com/pyqtgraph/pyqtgraph/issues/1057')
3157 | except:
3158 | pass
3159 |
3160 | import locale
3161 | code,_ = locale.getdefaultlocale()
3162 | if code is not None and \
3163 | (any(sanctioned in code.lower() for sanctioned in '_ru _by ru_ by_'.split()) or \
3164 | any(sanctioned in code.lower() for sanctioned in 'ru be'.split())):
3165 | import os
3166 | os._exit(1)
3167 | assert False
3168 |
3169 | # default to black-on-white
3170 | pg.widgets.GraphicsView.GraphicsView.wheelEvent = partialmethod(_wheel_event_wrapper, pg.widgets.GraphicsView.GraphicsView.wheelEvent)
3171 | # use finplot instead of matplotlib
3172 | pd.set_option('plotting.backend', 'finplot.pdplot')
3173 | # pick up win resolution
3174 | try:
3175 | import ctypes
3176 | user32 = ctypes.windll.user32
3177 | user32.SetProcessDPIAware()
3178 | lod_candles = int(user32.GetSystemMetrics(0) * 1.6)
3179 | candle_shadow_width = int(user32.GetSystemMetrics(0) // 2100 + 1) # 2560 and resolutions above -> wider shadows
3180 | except:
3181 | pass
3182 |
--------------------------------------------------------------------------------
/finplot/_version.py:
--------------------------------------------------------------------------------
1 | __version__ = '1.9.7'
2 |
--------------------------------------------------------------------------------
/finplot/examples/analyze-2.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | import numpy as np
5 | import pandas as pd
6 | import yfinance as yf
7 |
8 |
9 | btc = yf.download('BTC-USD', '2014-09-01')
10 |
11 | ax1,ax2,ax3,ax4,ax5 = fplt.create_plot('Bitcoin/Dollar long term analysis', rows=5, maximize=False)
12 | fplt.set_y_scale(ax=ax1, yscale='log')
13 |
14 | fplt.plot(btc.Close, color='#000', legend='Log price', ax=ax1)
15 | btc['ma200'] = btc.Close.rolling(200).mean()
16 | btc['ma50'] = btc.Close.rolling(50).mean()
17 | fplt.plot(btc.ma200, legend='MA200', ax=ax1)
18 | fplt.plot(btc.ma50, legend='MA50', ax=ax1)
19 | btc['one'] = 1
20 | fplt.volume_ocv(btc[['ma200','ma50','one']], candle_width=1, ax=ax1.overlay(scale=0.02))
21 |
22 | daily_ret = btc.Close.pct_change()*100
23 | fplt.plot(daily_ret, width=3, color='#000', legend='Daily returns %', ax=ax2)
24 |
25 | fplt.add_legend('Daily % returns histogram', ax=ax3)
26 | fplt.hist(daily_ret, bins=60, ax=ax3)
27 |
28 | fplt.add_legend('Yearly returns in %', ax=ax4)
29 | fplt.bar(btc.Close.resample('YE').last().pct_change().dropna()*100, ax=ax4)
30 |
31 | # calculate monthly returns, display as a 4x3 heatmap
32 | months = btc['Adj Close'].resample('ME').last().pct_change().dropna().to_frame() * 100
33 | months.index = mnames = months.index.month_name().to_list()
34 | mnames = mnames[mnames.index('January'):][:12]
35 | mrets = [months.loc[mname].mean().iloc[0] for mname in mnames]
36 | hmap = pd.DataFrame(columns=[2,1,0], data=np.array(mrets).reshape((3,4)).T)
37 | hmap = hmap.reset_index() # use the range index as X-coordinates (if no DateTimeIndex is found, the first column is used as X)
38 | colmap = fplt.ColorMap([0.3, 0.5, 0.7], [[255, 110, 90], [255, 247, 0], [60, 255, 50]]) # traffic light
39 | fplt.heatmap(hmap, rect_size=1, colmap=colmap, colcurve=lambda x: x, ax=ax5)
40 | for j,mrow in enumerate(np.array(mnames).reshape((3,4))):
41 | for i,month in enumerate(mrow):
42 | s = month+' %+.2f%%'%hmap.loc[i,2-j]
43 | fplt.add_text((i, 2.5-j), s, anchor=(0.5,0.5), ax=ax5)
44 | ax5.set_visible(crosshair=False, xaxis=False, yaxis=False) # hide junk for a more pleasing look
45 |
46 | fplt.show()
47 |
--------------------------------------------------------------------------------
/finplot/examples/analyze.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | from collections import defaultdict
4 | import dateutil.parser
5 | import finplot as fplt
6 | import numpy as np
7 | import pandas as pd
8 | import requests
9 |
10 |
11 | baseurl = 'https://www.bitmex.com/api'
12 | fplt.timestamp_format = '%m/%d/%Y %H:%M:%S.%f'
13 |
14 |
15 | def local2timestamp(s):
16 | return int(dateutil.parser.parse(s).timestamp())
17 |
18 |
19 | def download_price_history(symbol='XBTUSD', start_time='2023-01-01', end_time='2023-10-29', interval_mins=60):
20 | start_time = local2timestamp(start_time)
21 | end_time = local2timestamp(end_time)
22 | data = defaultdict(list)
23 | for start_t in range(start_time, end_time, 10000*60*interval_mins):
24 | end_t = start_t + 10000*60*interval_mins
25 | if end_t > end_time:
26 | end_t = end_time
27 | url = '%s/udf/history?symbol=%s&resolution=%s&from=%s&to=%s' % (baseurl, symbol, interval_mins, start_t, end_t)
28 | print(url)
29 | d = requests.get(url).json()
30 | del d['s'] # ignore status=ok
31 | for col in d:
32 | data[col] += d[col]
33 | df = pd.DataFrame(data)
34 | df = df.rename(columns={'t':'time', 'o':'open', 'c':'close', 'h':'high', 'l':'low', 'v':'volume'})
35 | return df.set_index('time')
36 |
37 |
38 | def plot_accumulation_distribution(df, ax):
39 | ad = (2*df.close-df.high-df.low) * df.volume / (df.high - df.low)
40 | ad.cumsum().ffill().plot(ax=ax, legend='Accum/Dist', color='#f00000')
41 |
42 |
43 | def plot_bollinger_bands(df, ax):
44 | mean = df.close.rolling(20).mean()
45 | stddev = df.close.rolling(20).std()
46 | df['boll_hi'] = mean + 2.5*stddev
47 | df['boll_lo'] = mean - 2.5*stddev
48 | p0 = df.boll_hi.plot(ax=ax, color='#808080', legend='BB')
49 | p1 = df.boll_lo.plot(ax=ax, color='#808080')
50 | fplt.fill_between(p0, p1, color='#bbb')
51 |
52 |
53 | def plot_ema(df, ax):
54 | df.close.ewm(span=9).mean().plot(ax=ax, legend='EMA')
55 |
56 |
57 | def plot_heikin_ashi(df, ax):
58 | df['h_close'] = (df.open+df.close+df.high+df.low) / 4
59 | ho = (df.open.iloc[0] + df.close.iloc[0]) / 2
60 | for i,hc in zip(df.index, df['h_close']):
61 | df.loc[i, 'h_open'] = ho
62 | ho = (ho + hc) / 2
63 | df['h_high'] = df[['high','h_open','h_close']].max(axis=1)
64 | df['h_low'] = df[['low','h_open','h_close']].min(axis=1)
65 | df[['h_open','h_close','h_high','h_low']].plot(ax=ax, kind='candle')
66 |
67 |
68 | def plot_heikin_ashi_volume(df, ax):
69 | df[['h_open','h_close','volume']].plot(ax=ax, kind='volume')
70 |
71 |
72 | def plot_on_balance_volume(df, ax):
73 | obv = df.volume.copy()
74 | obv[df.close < df.close.shift()] = -obv
75 | obv[df.close==df.close.shift()] = 0
76 | obv.cumsum().plot(ax=ax, legend='OBV', color='#008800')
77 |
78 |
79 | def plot_rsi(df, ax):
80 | diff = df.close.diff().values
81 | gains = diff
82 | losses = -diff
83 | with np.errstate(invalid='ignore'):
84 | gains[(gains<0)|np.isnan(gains)] = 0.0
85 | losses[(losses<=0)|np.isnan(losses)] = 1e-10 # we don't want divide by zero/NaN
86 | n = 14
87 | m = (n-1) / n
88 | ni = 1 / n
89 | g = gains[n] = np.nanmean(gains[:n])
90 | l = losses[n] = np.nanmean(losses[:n])
91 | gains[:n] = losses[:n] = np.nan
92 | for i,v in enumerate(gains[n:],n):
93 | g = gains[i] = ni*v + m*g
94 | for i,v in enumerate(losses[n:],n):
95 | l = losses[i] = ni*v + m*l
96 | rs = gains / losses
97 | df['rsi'] = 100 - (100/(1+rs))
98 | df.rsi.plot(ax=ax, legend='RSI')
99 | fplt.set_y_range(0, 100, ax=ax)
100 | fplt.add_horizontal_band(30, 70, ax=ax)
101 |
102 |
103 | def plot_vma(df, ax):
104 | df.volume.rolling(20).mean().plot(ax=ax, color='#c0c030')
105 |
106 |
107 | symbol = 'XBTUSD'
108 | df = download_price_history(symbol=symbol)
109 |
110 | ax,axv,ax2,ax3,ax4 = fplt.create_plot('BitMEX %s heikin-ashi price history' % symbol, rows=5)
111 | ax.set_visible(xgrid=True, ygrid=True)
112 |
113 | # price chart
114 | plot_heikin_ashi(df, ax)
115 | plot_bollinger_bands(df, ax)
116 | plot_ema(df, ax)
117 |
118 | # volume chart
119 | plot_heikin_ashi_volume(df, axv)
120 | plot_vma(df, ax=axv)
121 |
122 | # some more charts
123 | plot_accumulation_distribution(df, ax2)
124 | plot_on_balance_volume(df, ax3)
125 | plot_rsi(df, ax4)
126 |
127 | # restore view (X-position and zoom) when we run this example again
128 | fplt.autoviewrestore()
129 |
130 | fplt.show()
131 |
--------------------------------------------------------------------------------
/finplot/examples/animate.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | import numpy as np
5 | import pandas as pd
6 |
7 |
8 | FPS = 30
9 | anim_counter = 0
10 |
11 |
12 | def gen_dumb_price():
13 | # start with four random columns
14 | v = np.random.normal(size=(1000,4))
15 | df = pd.DataFrame(v, columns='low open close high'.split())
16 | # smooth out
17 | df = df.rolling(10).mean()
18 | # add a bit of push
19 | ma = df['low'].rolling(20).mean().diff()
20 | for col in df.columns:
21 | df[col] *= ma*100
22 | # arrange so low is lowest each period, high highest, open and close in between
23 | df.values.sort(axis=1)
24 | # add price variation over time and some amplitude
25 | df = (df.T + np.sin(df.index/87) * 3 + np.cos(df.index/201) * 5).T + 20
26 | # some green, some red candles
27 | flip = df['open'].shift(-1) <= df['open']
28 | df.loc[flip,'open'],df.loc[flip,'close'] = df['close'].copy(),df['open'].copy()
29 | # price action => volume
30 | df['volume'] = df['high'] - df['low']
31 | # epoch time
32 | df.index = np.linspace(1608332400-60*1000, 1608332400, 1000)
33 | return df['open close high low volume'.split()].dropna()
34 |
35 |
36 | def gen_spots(ax, df):
37 | spot_ser = df['low'] - 0.1
38 | spot_ser[(spot_ser.reset_index(drop=True).index - anim_counter) % 20 != 0] = np.nan
39 | spot_plot.plot(spot_ser, style='o', color=2, width=2, ax=ax, zoomscale=False)
40 |
41 |
42 | def gen_labels(ax, df):
43 | y_ser = df['volume'] - 0.1
44 | y_ser[(y_ser.reset_index(drop=True).index + anim_counter) % 50 != 0] = np.nan
45 | dft = y_ser.to_frame()
46 | dft.columns = ['y']
47 | dft['text'] = dft['y'].apply(lambda v: str(round(v, 1)) if v>0 else '')
48 | label_plot.labels(dft, ax=ax)
49 |
50 |
51 | def move_view(ax, df):
52 | global anim_counter
53 | x = -np.cos(anim_counter/100)*(len(df)/2-50) + len(df)/2
54 | w = np.sin(anim_counter/100)**4*50 + 50
55 | fplt.set_x_pos(df.index[int(x-w)], df.index[int(x+w)], ax=ax)
56 | anim_counter += 1
57 |
58 |
59 | def animate(ax, ax2, df):
60 | gen_spots(ax, df)
61 | gen_labels(ax2, df)
62 | move_view(ax, df)
63 |
64 |
65 | df = gen_dumb_price()
66 | ax,ax2 = fplt.create_plot('Things move', rows=2, init_zoom_periods=100, maximize=False)
67 | df.plot(kind='candle', ax=ax)
68 | df[['open','close','volume']].plot(kind='volume', ax=ax2)
69 | spot_plot,label_plot = fplt.live(2)
70 | fplt.timer_callback(lambda: animate(ax, ax2, df), 1/FPS)
71 | fplt.show()
72 |
--------------------------------------------------------------------------------
/finplot/examples/bfx.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import math
4 | import pandas as pd
5 | import finplot as fplt
6 | import requests
7 | import time
8 |
9 |
10 | def cumcnt_indices(v):
11 | v = v.astype('float64')
12 | v[v==0] = math.nan
13 | cumsum = v.cumsum().ffill()
14 | reset = -cumsum[v.isnull()].diff().fillna(cumsum)
15 | r = v.where(v.notnull(), reset).cumsum().fillna(0.0)
16 | return r.astype(int)
17 |
18 |
19 | def td_sequential(close):
20 | close4 = close.shift(4)
21 | td = cumcnt_indices(close > close4)
22 | ts = cumcnt_indices(close < close4)
23 | return td, ts
24 |
25 |
26 | def update():
27 | # load data
28 | limit = 500
29 | start = int(time.time()*1000) - (500-2)*60*1000
30 | url = 'https://api.bitfinex.com/v2/candles/trade:1m:tBTCUSD/hist?limit=%i&sort=1&start=%i' % (limit, start)
31 | table = requests.get(url).json()
32 | df = pd.DataFrame(table, columns='time open close high low volume'.split())
33 |
34 | # calculate indicator
35 | tdup,tddn = td_sequential(df['close'])
36 | df['tdup'] = [('%i'%i if 0 df['t'].iloc[-1]:
68 | # add new candle
69 | o = c = h = l = df['c'].iloc[-1]
70 | df1 = pd.DataFrame(dict(t=[t], o=[o], c=[c], h=[l], l=[l]))
71 | df = pd.concat([df, df1], ignore_index=True, sort=False)
72 | else:
73 | # update last candle
74 | i = df.index.max()
75 | df.loc[i,'c'] = c
76 | if c > df.loc[i,'h']:
77 | df.loc[i,'h'] = c
78 | if c < df.loc[i,'l']:
79 | df.loc[i,'l'] = c
80 |
81 |
82 | def update_orderbook_data(orderbook10):
83 | global orderbook
84 | ask = pd.DataFrame(orderbook10['asks'], columns=['price','volume'])
85 | bid = pd.DataFrame(orderbook10['bids'], columns=['price','volume'])
86 | bid['price'] -= 0.5 # bid below price
87 | ask['volume'] = -ask['volume'].cumsum() # negative volume means pointing left
88 | bid['volume'] = -bid['volume'].cumsum()
89 | orderbook = [[len(df)+0.5, pd.concat([bid.iloc[::-1], ask])]]
90 |
91 |
92 | if __name__ == '__main__':
93 | df = pd.DataFrame(price_history())
94 |
95 | # fix bug in BitMEX websocket lib
96 | def bugfix(self, *args, **kwargs):
97 | from pyee import EventEmitter
98 | EventEmitter.__init__(self)
99 | orig_init(self, *args, **kwargs)
100 | orig_init = Instrument.__init__
101 | Instrument.__init__ = bugfix
102 |
103 | ws = Instrument(channels=[InstrumentChannels.trade, InstrumentChannels.orderBook10])
104 | @ws.on('action')
105 | def action(message):
106 | if 'orderBook' in message['table']:
107 | for orderbook10 in message['data']:
108 | update_orderbook_data(orderbook10)
109 | else:
110 | for trade in message['data']:
111 | update_candlestick_data(trade)
112 | thread = Thread(target=ws.run_forever)
113 | thread.daemon = True
114 | thread.start()
115 | fplt.create_plot('Realtime Bitcoin/Dollar 1m (BitMEX websocket)', init_zoom_periods=75, maximize=False)
116 | plot_candles, plot_bb_hi, plot_bb_lo, plot_orderbook = fplt.live(4)
117 | # use bitmex colors
118 | plot_candles.colors.update(dict(
119 | bull_shadow = '#388d53',
120 | bull_frame = '#205536',
121 | bull_body = '#52b370',
122 | bear_shadow = '#d56161',
123 | bear_frame = '#5c1a10',
124 | bear_body = '#e8704f'))
125 | plot_orderbook.colors.update(dict(
126 | bull_frame = '#52b370',
127 | bull_body = '#bae1c6',
128 | bear_frame = '#e8704f',
129 | bear_body = '#f6c6b9'))
130 | update_plot()
131 | fplt.timer_callback(update_plot, 0.5) # update in 2 Hz
132 | fplt.show()
133 |
--------------------------------------------------------------------------------
/finplot/examples/btc-long-term.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | from datetime import date
4 | import finplot as fplt
5 | import requests
6 | import pandas as pd
7 |
8 | now = date.today().strftime('%Y-%m-%d')
9 | r = requests.get('https://www.bitstamp.net/api-internal/tradeview/price-history/BTC/USD/?step=86400&start_datetime=2011-08-18T00:00:00.000Z&end_datetime=%sT00:00:00.000Z' % now)
10 | df = pd.DataFrame(r.json()['data']).astype({'timestamp':int, 'open':float, 'close':float, 'high':float, 'low':float}).set_index('timestamp')
11 |
12 | # plot price
13 | fplt.create_plot('Bitcoin 2011-%s'%now.split('-')[0], yscale='log')
14 | fplt.candlestick_ochl(df['open close high low'.split()])
15 |
16 | # monthly separator lines
17 | months = pd.to_datetime(df.index, unit='s').strftime('%m')
18 | last_month = ''
19 | for x,(month,price) in enumerate(zip(months, df.close)):
20 | if month != last_month:
21 | fplt.add_line((x-0.5, price*0.5), (x-0.5, price*2), color='#bbb', style='--')
22 | last_month = month
23 |
24 | fplt.show()
25 |
--------------------------------------------------------------------------------
/finplot/examples/bubble-table.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | '''Plots quotes as bubbles and as a table on the second axis. The table
3 | is composed of labels (text) and a heatmap (background color).'''
4 |
5 | import dateutil.parser
6 | import finplot as fplt
7 | import numpy as np
8 | import pandas as pd
9 | import requests
10 |
11 |
12 | interval_mins = 1
13 | start_t = '2024-03-03T19:15Z'
14 | count = 35
15 | downsample = 5
16 | start_ts = int(dateutil.parser.parse(start_t).timestamp()) + 1*60
17 |
18 |
19 | def download_resample():
20 | end_ts = start_ts + (count*downsample-2)*60
21 | price_url = f'https://www.bitmex.com/api/udf/history?symbol=XBTUSD&resolution={interval_mins}&from={start_ts}&to={end_ts}'
22 | quote_url = f'https://www.bitmex.com/api/v1/quote/bucketed?symbol=XBTUSD&binSize={interval_mins}m&startTime={start_t}&count={count*downsample}'
23 |
24 | prices = pd.DataFrame(requests.get(price_url).json())
25 | quotes = pd.DataFrame(requests.get(quote_url).json())
26 | prices['timestamp'] = pd.to_datetime(prices.t, unit='s')
27 | quotes['timestamp'] = pd.to_datetime(quotes.timestamp)
28 | prices.set_index('timestamp', inplace=True)
29 | quotes.set_index('timestamp', inplace=True)
30 | prices, quotes = resample(prices, quotes)
31 | return prices, quotes
32 |
33 |
34 | def resample(prices, quotes):
35 | quotes.bidPrice = (quotes.bidPrice*quotes.bidSize).rolling(downsample).sum() / quotes.bidSize.rolling(downsample).sum()
36 | quotes.bidSize = quotes.bidSize.rolling(downsample).sum()
37 | quotes.askPrice = (quotes.askPrice*quotes.askSize).rolling(downsample).sum() / quotes.askSize.rolling(downsample).sum()
38 | quotes.askSize = quotes.askSize.rolling(downsample).sum()
39 | q = quotes.iloc[downsample-1::downsample]
40 | q.index = quotes.index[::downsample]
41 |
42 | p = prices.rename(columns={'o':'Open', 'c':'Close', 'h':'High', 'l':'Low', 'v':'Volume'})
43 | p.Open = p.Open.shift(downsample-1)
44 | p.High = p.High.rolling(downsample).max()
45 | p.Low = p.Low.rolling(downsample).min()
46 | p.Volume = p.Volume.rolling(downsample).sum()
47 | p = p.iloc[downsample-1::downsample]
48 | p.index = q.index
49 |
50 | return p,q
51 |
52 |
53 | def plot_bubble_pass(price, price_col, size_col, min_val, max_val, scale, color, ax):
54 | price = price.copy()
55 | price.loc[(price[size_col]max_val), price_col] = np.nan
56 | fplt.plot(price[price_col], style='o', width=scale, color=color, ax=ax)
57 |
58 |
59 | def plot_quote_bubbles(quotes, ax):
60 | quotes['bidSize2'] = np.sqrt(quotes.bidSize) # linearize by circle area
61 | quotes['askSize2'] = np.sqrt(quotes.askSize)
62 | size2 = pd.concat([quotes.bidSize2, quotes.askSize2])
63 | rng = np.linspace(size2.min(), size2.max(), 5)
64 | rng = list(zip(rng[:-1], rng[1:]))
65 | for a,b in reversed(rng):
66 | scale = (a+b) / rng[-1][1] + 0.2
67 | plot_bubble_pass(quotes, 'bidPrice', 'bidSize2', a, b, scale=scale, color='#0f0', ax=ax)
68 | plot_bubble_pass(quotes, 'askPrice', 'askSize2', a, b, scale=scale, color='#f00', ax=ax)
69 |
70 |
71 | def plot_quote_table(quotes, ax):
72 | '''Plot quote table (in millions). We're using lables on top of a heatmap to create sort of a table.'''
73 | ax.set_visible(yaxis=False) # Y axis is useless on our table
74 | def skip_y_crosshair_info(x, y, xt, yt): # we don't want any Y crosshair info on the table
75 | return xt, ''
76 | fplt.add_crosshair_info(skip_y_crosshair_info, ax=ax)
77 | fplt.set_y_range(0, 2, ax) # 0-1 for bid row, 1-2 for ask row
78 |
79 | # add two columns for table cell colors
80 | quotes[1] = -quotes['askSize'] * 0.5 / quotes['askSize'].max() + 0.5
81 | quotes[0] = +quotes['bidSize'] * 0.5 / quotes['bidSize'].max() + 0.5
82 |
83 | ts = [int(t.timestamp()) for t in quotes.index]
84 | colmap = fplt.ColorMap([0.0, 0.5, 1.0], [[200, 80, 60], [200, 190, 100], [40, 170, 30]]) # traffic light colors
85 | fplt.heatmap(quotes[[1, 0]], colmap=colmap, colcurve=lambda x: x, ax=ax) # linear color mapping
86 | maxSize = max(quotes.bidSize.max(), quotes.askSize.max()) / 100
87 | fplt.labels(ts, [1.5]*count, ['%i'%(v/maxSize) for v in quotes['askSize']], ax=ax2, anchor=(0.5, 0.5))
88 | fplt.labels(ts, [0.5]*count, ['%i'%(v/maxSize) for v in quotes['bidSize']], ax=ax2, anchor=(0.5, 0.5))
89 |
90 |
91 | prices, quotes = download_resample()
92 |
93 | fplt.max_zoom_points = 5
94 | fplt.right_margin_candles = 0
95 | ax,ax2 = fplt.create_plot(f'BitMEX {downsample}m quote bubble plot + quote table', rows=2, maximize=False)
96 | fplt.windows[0].ci.layout.setRowStretchFactor(0, 10) # make primary plot large, and implicitly table small
97 | candles = fplt.candlestick_ochl(prices[['Open','Close','High','Low']], ax=ax)
98 | candles.colors.update(dict(bear_body='#fa8')) # bright red, to make bubbles visible
99 | fplt.volume_ocv(prices[['Open','Close','Volume']], ax=ax.overlay())
100 | plot_quote_bubbles(quotes, ax=ax)
101 | plot_quote_table(quotes, ax=ax2)
102 | fplt.show()
103 |
--------------------------------------------------------------------------------
/finplot/examples/complicated.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | '''A lengthy example that shows some more complex uses of finplot:
3 | - control panel in PyQt
4 | - varying indicators, intervals and layout
5 | - toggle dark mode
6 | - price line
7 | - real-time updates via websocket
8 |
9 | This example includes dipping in to the internals of finplot and
10 | the underlying lib pyqtgraph, which is not part of the API per se,
11 | and may thus change in the. If so happens, this example will be
12 | updated to reflect such changes.
13 |
14 | Included is also some third-party libraries to make the example
15 | more realistic.
16 |
17 | You'll need to "pip install websocket-client" before running this
18 | to be able to see real-time price action.
19 | '''
20 |
21 |
22 | import finplot as fplt
23 | from functools import lru_cache
24 | import json
25 | from math import nan
26 | import pandas as pd
27 | from PyQt6.QtWidgets import QComboBox, QCheckBox, QWidget, QGridLayout
28 | import pyqtgraph as pg
29 | import requests
30 | from time import time as now, sleep
31 | from threading import Thread
32 | import websocket
33 |
34 |
35 | class BinanceWebsocket:
36 | def __init__(self):
37 | self.url = 'wss://stream.binance.com/stream'
38 | self.symbol = None
39 | self.interval = None
40 | self.ws = None
41 | self.df = None
42 |
43 | def reconnect(self, symbol, interval, df):
44 | '''Connect and subscribe, if not already done so.'''
45 | self.df = df
46 | if symbol.lower() == self.symbol and self.interval == interval:
47 | return
48 | self.symbol = symbol.lower()
49 | self.interval = interval
50 | self.thread_connect = Thread(target=self._thread_connect)
51 | self.thread_connect.daemon = True
52 | self.thread_connect.start()
53 |
54 | def close(self, reset_symbol=True):
55 | if reset_symbol:
56 | self.symbol = None
57 | if self.ws:
58 | self.ws.close()
59 | self.ws = None
60 |
61 | def _thread_connect(self):
62 | self.close(reset_symbol=False)
63 | print('websocket connecting to %s...' % self.url)
64 | self.ws = websocket.WebSocketApp(self.url, on_message=self.on_message, on_error=self.on_error)
65 | self.thread_io = Thread(target=self.ws.run_forever)
66 | self.thread_io.daemon = True
67 | self.thread_io.start()
68 | for _ in range(100):
69 | if self.ws.sock and self.ws.sock.connected:
70 | break
71 | sleep(0.1)
72 | else:
73 | self.close()
74 | raise websocket.WebSocketTimeoutException('websocket connection failed')
75 | self.subscribe(self.symbol, self.interval)
76 | print('websocket connected')
77 |
78 | def subscribe(self, symbol, interval):
79 | try:
80 | data = '{"method":"SUBSCRIBE","params":["%s@kline_%s"],"id":1}' % (symbol, interval)
81 | self.ws.send(data)
82 | except Exception as e:
83 | print('websocket subscribe error:', type(e), e)
84 | raise e
85 |
86 | def on_message(self, *args, **kwargs):
87 | df = self.df
88 | if df is None:
89 | return
90 | msg = json.loads(args[-1])
91 | if 'stream' not in msg:
92 | return
93 | stream = msg['stream']
94 | if '@kline_' in stream:
95 | k = msg['data']['k']
96 | t = k['t']
97 | t1 = int(df.index[-1].timestamp()) * 1000
98 | if t <= t1:
99 | # update last candle
100 | i = df.index[-1]
101 | df.loc[i, 'Close'] = float(k['c'])
102 | df.loc[i, 'High'] = max(df.loc[i, 'High'], float(k['h']))
103 | df.loc[i, 'Low'] = min(df.loc[i, 'Low'], float(k['l']))
104 | df.loc[i, 'Volume'] = float(k['v'])
105 | print(k)
106 | else:
107 | # create a new candle
108 | data = [t] + [float(k[i]) for i in ['o','c','h','l','v']]
109 | candle = pd.DataFrame([data], columns='Time Open Close High Low Volume'.split()).astype({'Time':'datetime64[ms]'})
110 | candle.set_index('Time', inplace=True)
111 | self.df = pd.concat([df, candle])
112 |
113 | def on_error(self, error, *args, **kwargs):
114 | print('websocket error: %s' % error)
115 |
116 |
117 | def do_load_price_history(symbol, interval):
118 | url = 'https://www.binance.com/api/v1/klines?symbol=%s&interval=%s&limit=%s' % (symbol, interval, 1000)
119 | print('loading binance %s %s' % (symbol, interval))
120 | d = requests.get(url).json()
121 | df = pd.DataFrame(d, columns='Time Open High Low Close Volume a b c d e f'.split())
122 | df = df.astype({'Time':'datetime64[ms]', 'Open':float, 'High':float, 'Low':float, 'Close':float, 'Volume':float})
123 | return df.set_index('Time')
124 |
125 |
126 | @lru_cache(maxsize=5)
127 | def cache_load_price_history(symbol, interval):
128 | '''Stupid caching, but works sometimes.'''
129 | return do_load_price_history(symbol, interval)
130 |
131 |
132 | def load_price_history(symbol, interval):
133 | '''Use memoized, and if too old simply load the data.'''
134 | df = cache_load_price_history(symbol, interval)
135 | # check if cache's newest candle is current
136 | t0 = df.index[-2].timestamp()
137 | t1 = df.index[-1].timestamp()
138 | t2 = t1 + (t1 - t0)
139 | if now() >= t2:
140 | df = do_load_price_history(symbol, interval)
141 | return df
142 |
143 |
144 | def calc_parabolic_sar(df, af=0.2, steps=10):
145 | up = True
146 | sars = [nan] * len(df)
147 | sar = ep_lo = df.Low.iloc[0]
148 | ep = ep_hi = df.High.iloc[0]
149 | aaf = af
150 | aaf_step = aaf / steps
151 | af = 0
152 | for i,(hi,lo) in enumerate(zip(df.High, df.Low)):
153 | # parabolic sar formula:
154 | sar = sar + af * (ep - sar)
155 | # handle new extreme points
156 | if hi > ep_hi:
157 | ep_hi = hi
158 | if up:
159 | ep = ep_hi
160 | af = min(aaf, af+aaf_step)
161 | elif lo < ep_lo:
162 | ep_lo = lo
163 | if not up:
164 | ep = ep_lo
165 | af = min(aaf, af+aaf_step)
166 | # handle switch
167 | if up:
168 | if lo < sar:
169 | up = not up
170 | sar = ep_hi
171 | ep = ep_lo = lo
172 | af = 0
173 | else:
174 | if hi > sar:
175 | up = not up
176 | sar = ep_lo
177 | ep = ep_hi = hi
178 | af = 0
179 | sars[i] = sar
180 | df['sar'] = sars
181 | return df['sar']
182 |
183 |
184 | def calc_rsi(price, n=14, ax=None):
185 | diff = price.diff().values
186 | gains = diff
187 | losses = -diff
188 | gains[~(gains>0)] = 0.0
189 | losses[~(losses>0)] = 1e-10 # we don't want divide by zero/NaN
190 | m = (n-1) / n
191 | ni = 1 / n
192 | g = gains[n] = gains[:n].mean()
193 | l = losses[n] = losses[:n].mean()
194 | gains[:n] = losses[:n] = nan
195 | for i,v in enumerate(gains[n:],n):
196 | g = gains[i] = ni*v + m*g
197 | for i,v in enumerate(losses[n:],n):
198 | l = losses[i] = ni*v + m*l
199 | rs = gains / losses
200 | rsi = 100 - (100/(1+rs))
201 | return rsi
202 |
203 |
204 | def calc_stochastic_oscillator(df, n=14, m=3, smooth=3):
205 | lo = df.Low.rolling(n).min()
206 | hi = df.High.rolling(n).max()
207 | k = 100 * (df.Close-lo) / (hi-lo)
208 | d = k.rolling(m).mean()
209 | return k, d
210 |
211 |
212 | def calc_plot_data(df, indicators):
213 | '''Returns data for all plots and for the price line.'''
214 | price = df['Open Close High Low'.split()]
215 | volume = df['Open Close Volume'.split()]
216 | ma50 = ma200 = vema24 = sar = rsi = stoch = stoch_s = None
217 | if 'few' in indicators or 'moar' in indicators:
218 | ma50 = price.Close.rolling(50).mean()
219 | ma200 = price.Close.rolling(200).mean()
220 | vema24 = volume.Volume.ewm(span=24).mean()
221 | if 'moar' in indicators:
222 | sar = calc_parabolic_sar(df)
223 | rsi = calc_rsi(df.Close)
224 | stoch,stoch_s = calc_stochastic_oscillator(df)
225 | plot_data = dict(price=price, volume=volume, ma50=ma50, ma200=ma200, vema24=vema24, sar=sar, rsi=rsi, \
226 | stoch=stoch, stoch_s=stoch_s)
227 | # for price line
228 | last_close = price.iloc[-1].Close
229 | last_col = fplt.candle_bull_color if last_close > price.iloc[-2].Close else fplt.candle_bear_color
230 | price_data = dict(last_close=last_close, last_col=last_col)
231 | return plot_data, price_data
232 |
233 |
234 | def realtime_update_plot():
235 | '''Called at regular intervals by a timer.'''
236 | if ws.df is None:
237 | return
238 |
239 | # calculate the new plot data
240 | indicators = ctrl_panel.indicators.currentText().lower()
241 | data,price_data = calc_plot_data(ws.df, indicators)
242 |
243 | # first update all data, then graphics (for zoom rigidity)
244 | for k in data:
245 | if data[k] is not None:
246 | plots[k].update_data(data[k], gfx=False)
247 | for k in data:
248 | if data[k] is not None:
249 | plots[k].update_gfx()
250 |
251 | # place and color price line
252 | ax.price_line.setPos(price_data['last_close'])
253 | ax.price_line.pen.setColor(pg.mkColor(price_data['last_col']))
254 |
255 |
256 | def change_asset(*args, **kwargs):
257 | '''Resets and recalculates everything, and plots for the first time.'''
258 | # save window zoom position before resetting
259 | fplt._savewindata(fplt.windows[0])
260 |
261 | symbol = ctrl_panel.symbol.currentText()
262 | interval = ctrl_panel.interval.currentText()
263 | ws.close()
264 | ws.df = None
265 | df = load_price_history(symbol, interval=interval)
266 | ws.reconnect(symbol, interval, df)
267 |
268 | # remove any previous plots
269 | ax.reset()
270 | axo.reset()
271 | ax_rsi.reset()
272 |
273 | # calculate plot data
274 | indicators = ctrl_panel.indicators.currentText().lower()
275 | data,price_data = calc_plot_data(df, indicators)
276 |
277 | # some space for legend
278 | ctrl_panel.move(100 if 'clean' in indicators else 200, 0)
279 |
280 | # plot data
281 | global plots
282 | plots = {}
283 | plots['price'] = fplt.candlestick_ochl(data['price'], ax=ax)
284 | plots['volume'] = fplt.volume_ocv(data['volume'], ax=axo)
285 | if data['ma50'] is not None:
286 | plots['ma50'] = fplt.plot(data['ma50'], legend='MA-50', ax=ax)
287 | plots['ma200'] = fplt.plot(data['ma200'], legend='MA-200', ax=ax)
288 | plots['vema24'] = fplt.plot(data['vema24'], color=4, legend='V-EMA-24', ax=axo)
289 | if data['rsi'] is not None:
290 | ax.set_visible(xaxis=False)
291 | ax_rsi.show()
292 | fplt.set_y_range(0, 100, ax=ax_rsi)
293 | fplt.add_horizontal_band(30, 70, color='#6335', ax=ax_rsi)
294 | plots['sar'] = fplt.plot(data['sar'], color='#55a', style='+', width=0.6, legend='SAR', ax=ax)
295 | plots['rsi'] = fplt.plot(data['rsi'], legend='RSI', ax=ax_rsi)
296 | plots['stoch'] = fplt.plot(data['stoch'], color='#880', legend='Stoch', ax=ax_rsi)
297 | plots['stoch_s'] = fplt.plot(data['stoch_s'], color='#650', ax=ax_rsi)
298 | else:
299 | ax.set_visible(xaxis=True)
300 | ax_rsi.hide()
301 |
302 | # price line
303 | ax.price_line = pg.InfiniteLine(angle=0, movable=False, pen=fplt._makepen(fplt.candle_bull_body_color, style='.'))
304 | ax.price_line.setPos(price_data['last_close'])
305 | ax.price_line.pen.setColor(pg.mkColor(price_data['last_col']))
306 | ax.addItem(ax.price_line, ignoreBounds=True)
307 |
308 | # restores saved zoom position, if in range
309 | fplt.refresh()
310 |
311 |
312 | def dark_mode_toggle(dark):
313 | '''Digs into the internals of finplot and pyqtgraph to change the colors of existing
314 | plots, axes, backgronds, etc.'''
315 | # first set the colors we'll be using
316 | if dark:
317 | fplt.foreground = '#777'
318 | fplt.background = '#090c0e'
319 | fplt.candle_bull_color = fplt.candle_bull_body_color = '#0b0'
320 | fplt.candle_bear_color = '#a23'
321 | volume_transparency = '6'
322 | else:
323 | fplt.foreground = '#444'
324 | fplt.background = fplt.candle_bull_body_color = '#fff'
325 | fplt.candle_bull_color = '#380'
326 | fplt.candle_bear_color = '#c50'
327 | volume_transparency = 'c'
328 | fplt.volume_bull_color = fplt.volume_bull_body_color = fplt.candle_bull_color + volume_transparency
329 | fplt.volume_bear_color = fplt.candle_bear_color + volume_transparency
330 | fplt.cross_hair_color = fplt.foreground+'8'
331 | fplt.draw_line_color = '#888'
332 | fplt.draw_done_color = '#555'
333 |
334 | pg.setConfigOptions(foreground=fplt.foreground, background=fplt.background)
335 | # control panel color
336 | if ctrl_panel is not None:
337 | p = ctrl_panel.palette()
338 | p.setColor(ctrl_panel.darkmode.foregroundRole(), pg.mkColor(fplt.foreground))
339 | ctrl_panel.darkmode.setPalette(p)
340 |
341 | # window background
342 | for win in fplt.windows:
343 | win.setBackground(fplt.background)
344 |
345 | # axis, crosshair, candlesticks, volumes
346 | axs = [ax for win in fplt.windows for ax in win.axs]
347 | vbs = set([ax.vb for ax in axs])
348 | axs += fplt.overlay_axs
349 | axis_pen = fplt._makepen(color=fplt.foreground)
350 | for ax in axs:
351 | ax.axes['right']['item'].setPen(axis_pen)
352 | ax.axes['right']['item'].setTextPen(axis_pen)
353 | ax.axes['bottom']['item'].setPen(axis_pen)
354 | ax.axes['bottom']['item'].setTextPen(axis_pen)
355 | if ax.crosshair is not None:
356 | ax.crosshair.vline.pen.setColor(pg.mkColor(fplt.foreground))
357 | ax.crosshair.hline.pen.setColor(pg.mkColor(fplt.foreground))
358 | ax.crosshair.xtext.setColor(fplt.foreground)
359 | ax.crosshair.ytext.setColor(fplt.foreground)
360 | for item in ax.items:
361 | if isinstance(item, fplt.FinPlotItem):
362 | isvolume = ax in fplt.overlay_axs
363 | if not isvolume:
364 | item.colors.update(
365 | dict(bull_shadow = fplt.candle_bull_color,
366 | bull_frame = fplt.candle_bull_color,
367 | bull_body = fplt.candle_bull_body_color,
368 | bear_shadow = fplt.candle_bear_color,
369 | bear_frame = fplt.candle_bear_color,
370 | bear_body = fplt.candle_bear_color))
371 | else:
372 | item.colors.update(
373 | dict(bull_frame = fplt.volume_bull_color,
374 | bull_body = fplt.volume_bull_body_color,
375 | bear_frame = fplt.volume_bear_color,
376 | bear_body = fplt.volume_bear_color))
377 | item.repaint()
378 |
379 |
380 | def create_ctrl_panel(win):
381 | panel = QWidget(win)
382 | panel.move(100, 0)
383 | win.scene().addWidget(panel)
384 | layout = QGridLayout(panel)
385 |
386 | panel.symbol = QComboBox(panel)
387 | [panel.symbol.addItem(i+'USDT') for i in 'BTC ETH XRP DOGE BNB SOL ADA LTC LINK DOT TRX BCH'.split()]
388 | panel.symbol.setCurrentIndex(1)
389 | layout.addWidget(panel.symbol, 0, 0)
390 | panel.symbol.currentTextChanged.connect(change_asset)
391 |
392 | layout.setColumnMinimumWidth(1, 30)
393 |
394 | panel.interval = QComboBox(panel)
395 | [panel.interval.addItem(i) for i in '1d 4h 1h 30m 15m 5m 1m 1s'.split()]
396 | panel.interval.setCurrentIndex(6)
397 | layout.addWidget(panel.interval, 0, 2)
398 | panel.interval.currentTextChanged.connect(change_asset)
399 |
400 | layout.setColumnMinimumWidth(3, 30)
401 |
402 | panel.indicators = QComboBox(panel)
403 | [panel.indicators.addItem(i) for i in 'Clean:Few indicators:Moar indicators'.split(':')]
404 | panel.indicators.setCurrentIndex(1)
405 | layout.addWidget(panel.indicators, 0, 4)
406 | panel.indicators.currentTextChanged.connect(change_asset)
407 |
408 | layout.setColumnMinimumWidth(5, 30)
409 |
410 | panel.darkmode = QCheckBox(panel)
411 | panel.darkmode.setText('Haxxor mode')
412 | panel.darkmode.setCheckState(pg.Qt.QtCore.Qt.CheckState.Checked)
413 | panel.darkmode.toggled.connect(dark_mode_toggle)
414 | layout.addWidget(panel.darkmode, 0, 6)
415 |
416 | return panel
417 |
418 |
419 | plots = {}
420 | fplt.y_pad = 0.07 # pad some extra (for control panel)
421 | fplt.max_zoom_points = 7
422 | fplt.autoviewrestore()
423 | ax,ax_rsi = fplt.create_plot('Complicated Binance Example', rows=2, init_zoom_periods=300)
424 | axo = ax.overlay()
425 |
426 | # use websocket for real-time
427 | ws = BinanceWebsocket()
428 |
429 | # hide rsi chart to begin with; show x-axis of top plot
430 | ax_rsi.hide()
431 | ax_rsi.vb.setBackgroundColor(None) # don't use odd background color
432 | ax.set_visible(xaxis=True)
433 |
434 | ctrl_panel = create_ctrl_panel(ax.vb.win)
435 | dark_mode_toggle(True)
436 | change_asset()
437 | fplt.timer_callback(realtime_update_plot, 0.5) # update twice every second
438 | fplt.show()
439 |
--------------------------------------------------------------------------------
/finplot/examples/dockable.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | from functools import lru_cache
5 | from PyQt6.QtWidgets import QApplication, QGridLayout, QMainWindow, QGraphicsView, QComboBox, QLabel
6 | from pyqtgraph.dockarea import DockArea, Dock
7 | from threading import Thread
8 | import yfinance as yf
9 |
10 | app = QApplication([])
11 | win = QMainWindow()
12 | area = DockArea()
13 | win.setCentralWidget(area)
14 | win.resize(1600,800)
15 | win.setWindowTitle("Docking charts example for finplot")
16 |
17 | # Set width/height of QSplitter
18 | win.setStyleSheet("QSplitter { width : 20px; height : 20px; }")
19 |
20 | # Create docks
21 | dock_0 = Dock("dock_0", size = (1000, 100), closable = True)
22 | dock_1 = Dock("dock_1", size = (1000, 100), closable = True)
23 | dock_2 = Dock("dock_2", size = (1000, 100), closable = True)
24 | area.addDock(dock_0)
25 | area.addDock(dock_1)
26 | area.addDock(dock_2)
27 |
28 | # Create example charts
29 | combo = QComboBox()
30 | combo.setEditable(True)
31 | [combo.addItem(i) for i in 'AMRK META REVG TSLA TWTR WMT CT=F GC=F ^GSPC ^FTSE ^N225 EURUSD=X ETH-USD'.split()]
32 | dock_0.addWidget(combo, 0, 0, 1, 1)
33 | info = QLabel()
34 | dock_0.addWidget(info, 0, 1, 1, 1)
35 |
36 | # Chart for dock_0
37 | ax0,ax1,ax2 = fplt.create_plot_widget(master=area, rows=3, init_zoom_periods=100)
38 | area.axs = [ax0, ax1, ax2]
39 | dock_0.addWidget(ax0.ax_widget, 1, 0, 1, 2)
40 | dock_1.addWidget(ax1.ax_widget, 1, 0, 1, 2)
41 | dock_2.addWidget(ax2.ax_widget, 1, 0, 1, 2)
42 |
43 | # Link x-axis
44 | ax1.setXLink(ax0)
45 | ax2.setXLink(ax0)
46 | win.axs = [ax0,ax1,ax2]
47 |
48 | @lru_cache(maxsize = 15)
49 | def download(symbol):
50 | return yf.download(symbol, "2020-01-01")
51 |
52 | @lru_cache(maxsize = 100)
53 | def get_name(symbol):
54 | return yf.Ticker(symbol).info.get("shortName") or symbol
55 |
56 | def update(txt):
57 | df = download(txt)
58 | if len(df) < 20: # symbol does not exist
59 | return
60 | info.setText("Loading symbol name...")
61 | price = df ["Open Close High Low".split()]
62 | ma20 = df.Close.rolling(20).mean()
63 | ma50 = df.Close.rolling(50).mean()
64 | volume = df ["Open Close Volume".split()]
65 | ax0.reset() # remove previous plots
66 | ax1.reset() # remove previous plots
67 | ax2.reset() # remove previous plots
68 | fplt.candlestick_ochl(price, ax = ax0)
69 | fplt.plot(ma20, legend = "MA-20", ax = ax1)
70 | fplt.plot(ma50, legend = "MA-50", ax = ax1)
71 | fplt.volume_ocv(volume, ax = ax2)
72 | fplt.refresh() # refresh autoscaling when all plots complete
73 | Thread(target=lambda: info.setText(get_name(txt))).start() # slow, so use thread
74 |
75 | combo.currentTextChanged.connect(update)
76 | update(combo.currentText())
77 |
78 | fplt.show(qt_exec = False) # prepares plots when they're all setup
79 | win.show()
80 | app.exec()
81 |
--------------------------------------------------------------------------------
/finplot/examples/embed.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | from functools import lru_cache
5 | from PyQt6.QtWidgets import QApplication, QGridLayout, QGraphicsView, QComboBox, QLabel
6 | from threading import Thread
7 | import yfinance as yf
8 |
9 |
10 | app = QApplication([])
11 | win = QGraphicsView()
12 | win.setWindowTitle('TradingView wannabe')
13 | layout = QGridLayout()
14 | win.setLayout(layout)
15 | win.resize(600, 500)
16 |
17 | combo = QComboBox()
18 | combo.setEditable(True)
19 | [combo.addItem(i) for i in 'AMRK META REVG TSLA WMT CT=F GC=F ^GSPC ^FTSE ^N225 EURUSD=X ETH-USD'.split()]
20 | layout.addWidget(combo, 0, 0, 1, 1)
21 | info = QLabel()
22 | layout.addWidget(info, 0, 1, 1, 1)
23 |
24 | ax = fplt.create_plot(init_zoom_periods=100)
25 | win.axs = [ax] # finplot requres this property
26 | axo = ax.overlay()
27 | layout.addWidget(ax.vb.win, 1, 0, 1, 2)
28 |
29 |
30 | @lru_cache(maxsize=15)
31 | def download(symbol):
32 | return yf.download(symbol, '2019-01-01')
33 |
34 | @lru_cache(maxsize=100)
35 | def get_name(symbol):
36 | return yf.Ticker(symbol).info.get('shortName') or symbol
37 |
38 | plots = []
39 | def update(txt):
40 | df = download(txt)
41 | if len(df) < 20: # symbol does not exist
42 | return
43 | info.setText('Loading symbol name...')
44 | price = df['Open Close High Low'.split()]
45 | ma20 = df.Close.rolling(20).mean()
46 | ma50 = df.Close.rolling(50).mean()
47 | volume = df['Open Close Volume'.split()]
48 | ax.reset() # remove previous plots
49 | axo.reset() # remove previous plots
50 | fplt.candlestick_ochl(price)
51 | fplt.plot(ma20, legend='MA-20')
52 | fplt.plot(ma50, legend='MA-50')
53 | fplt.volume_ocv(volume, ax=axo)
54 | fplt.refresh() # refresh autoscaling when all plots complete
55 | Thread(target=lambda: info.setText(get_name(txt))).start() # slow, so use thread
56 |
57 | combo.currentTextChanged.connect(update)
58 | update(combo.currentText())
59 |
60 |
61 | fplt.show(qt_exec=False) # prepares plots when they're all setup
62 | win.show()
63 | app.exec()
64 |
--------------------------------------------------------------------------------
/finplot/examples/heatmap.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | import pandas as pd
5 | import requests
6 |
7 |
8 | # download, extract times and candles
9 | url = 'https://www.tensorcharts.com/tensor/binance/BTCUSDT/heatmapCandles/5min'
10 | data = requests.get(url).json()
11 | times = pd.to_datetime([e['T'] for e in data])
12 | candles = [(e['open'],e['close'],e['high'],e['low']) for e in data]
13 | df_candles = pd.DataFrame(index=times, data=candles)
14 |
15 | # extract volume heatmap as a PRICE x VOLUME matrix
16 | orderbooks = [(e['heatmapOrderBook'] or []) for e in data]
17 | prices = sorted(set(prc for ob in orderbooks for prc in ob[::2]))
18 | vol_matrix = [[0]*len(prices) for _ in range(len(times))]
19 | for i,orderbook in enumerate(orderbooks):
20 | for price,volume in zip(orderbook[::2],orderbook[1::2]):
21 | j = prices.index(price)
22 | vol_matrix[i][j] = volume
23 | df_volume_heatmap = pd.DataFrame(index=times, columns=prices, data=vol_matrix)
24 |
25 | # plot
26 | fplt.create_plot('Binance BTC 15m orderbook heatmap')
27 | fplt.candlestick_ochl(df_candles)
28 | fplt.heatmap(df_volume_heatmap, filter_limit=0.1, whiteout=0.1)
29 | fplt.show()
30 |
--------------------------------------------------------------------------------
/finplot/examples/line.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | import numpy as np
5 | import pandas as pd
6 |
7 |
8 | dates = pd.date_range('01:00', '01:00:01.200', freq='1ms')
9 | prices = pd.Series(np.random.random(len(dates))).rolling(30).mean() + 4
10 | fplt.plot(dates, prices, width=3)
11 | line = fplt.add_line((dates[100], 4.4), (dates[len(dates)-10], 4.6), color='#9900ff', interactive=True)
12 | ## fplt.remove_primitive(line)
13 | text = fplt.add_text((dates[500], 4.6), "I'm here alright!", color='#bb7700')
14 | ## fplt.remove_primitive(text)
15 | rect = fplt.add_rect((dates[700], 4.5), (dates[850], 4.4), color='#8c8', interactive=True)
16 | ## fplt.remove_primitive(rect)
17 |
18 | def save():
19 | fplt.screenshot(open('screenshot.png', 'wb'))
20 | fplt.timer_callback(save, 0.5, single_shot=True) # wait some until we're rendered
21 |
22 | fplt.show()
23 |
--------------------------------------------------------------------------------
/finplot/examples/overlay-correlate.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | from datetime import date, timedelta
4 | import finplot as fplt
5 | import pandas as pd
6 | import scipy.optimize
7 | import yfinance as yf
8 |
9 | now = date.today()
10 | start_day = now - timedelta(days=55)
11 | df = yf.download('GOOG', start_day.isoformat(), now.isoformat(), interval='90m')
12 | dfms = yf.download('MSFT', start_day.isoformat(), now.isoformat(), interval='90m')
13 |
14 | # resample to daily candles, i.e. five 90-minute candles per business day
15 | dfd = df.Open.resample('D').first().to_frame()
16 | dfd['Close'] = df.Close.resample('D').last()
17 | dfd['High'] = df.High.resample('D').max()
18 | dfd['Low'] = df.Low.resample('D').min()
19 |
20 | ax,ax2 = fplt.create_plot('Alphabet Inc.', rows=2, maximize=False)
21 | ax2.disable_x_index() # second plot is not timebased
22 |
23 | # plot down-sampled daily candles first
24 | daily_plot = fplt.candlestick_ochl(dfd.dropna(), candle_width=5)
25 | daily_plot.colors.update(dict(bull_body='#bfb', bull_shadow='#ada', bear_body='#fbc', bear_shadow='#dab'))
26 | daily_plot.x_offset = 3.1 # resample() gets us start of day, offset +1.1 (gap+off center wick)
27 |
28 | # plot high resolution on top
29 | fplt.candlestick_ochl(df[['Open','Close','High','Low']])
30 |
31 | # scatter plot correlation between Google and Microsoft stock
32 | df['ret_alphabet'] = df.Close.pct_change()
33 | df['ret_microsoft'] = dfms.Close.pct_change()
34 | dfc = df.dropna().reset_index(drop=True)[['ret_alphabet', 'ret_microsoft']]
35 | fplt.plot(dfc, style='o', color=1, ax=ax2)
36 |
37 | # draw least-square line
38 | errfun = lambda arr: [y-arr[0]*x+arr[1] for x,y in zip(dfc.ret_alphabet, dfc.ret_microsoft)]
39 | line = scipy.optimize.least_squares(errfun, [0.01, 0.01]).x
40 | linex = [dfc.ret_alphabet.min(), dfc.ret_alphabet.max()]
41 | liney = [linex[0]*line[0]+line[1], linex[1]*line[0]+line[1]]
42 | fplt.add_line((linex[0],liney[0]), (linex[1],liney[1]), color='#993', ax=ax2)
43 | fplt.add_text((linex[1],liney[1]), 'k=%.2f'%line[0], color='#993', ax=ax2)
44 | fplt.add_legend('Alphabet vs. Microsft 90m correlation', ax=ax2)
45 |
46 | fplt.show()
47 |
--------------------------------------------------------------------------------
/finplot/examples/pandas-df-plot.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | import pandas as pd
5 |
6 | labels = ['A', 'B', 'C', 'D']
7 | df = pd.DataFrame({'A':[1,2,3,5], 'B':[2,4,5,3], 'C':[3,6,11,8], 'D':[1,1,2,2], 'labels':labels}, index=[1606086000, 1606086001, 1606086002, 1606086003])
8 |
9 | df.plot('A')
10 | df.plot('A', 'labels', kind='labels', color='#b90')
11 | df.plot('C', kind='scatter')
12 | (1-df.B).plot(kind='scatter')
13 | df.plot(kind='candle').setZValue(-100)
14 | (df.B-4).plot.bar()
15 |
16 | fplt.show()
17 |
--------------------------------------------------------------------------------
/finplot/examples/renko-dark-mode.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | import yfinance as yf
5 |
6 | df = yf.download('BNO', '2014-01-01')
7 |
8 | # setup dark mode, and red/green candles
9 | w = fplt.foreground = '#eef'
10 | b = fplt.background = fplt.odd_plot_background = '#242320'
11 | fplt.candle_bull_color = fplt.volume_bull_color = fplt.candle_bull_body_color = fplt.volume_bull_body_color = '#352'
12 | fplt.candle_bear_color = fplt.volume_bear_color = '#810'
13 | fplt.cross_hair_color = w+'a'
14 |
15 | # plot renko + renko-transformed volume
16 | ax,axv = fplt.create_plot('US Brent Oil Renko [dark mode]', rows=2, maximize=False)
17 | plot = fplt.renko(df[['Close','Volume']], ax=ax) # let renko transform volume by passing it in as an extra column
18 | df_renko = plot.datasrc.df # names of new renko columns are time, open, close, high, low and whatever extras you pass in
19 | fplt.volume_ocv(df_renko[['time','open','close','Volume']], ax=axv)
20 | fplt.show()
21 |
--------------------------------------------------------------------------------
/finplot/examples/requirements.txt:
--------------------------------------------------------------------------------
1 | bitmex_websocket==0.2.83
2 | numpy==1.26.4
3 | pandas==2.2.1
4 | pyee==9.0.4
5 | PyOpenGL==3.1.7
6 | PyQt6==6.6.1
7 | PyQt6_sip==13.4.0
8 | pyqtgraph==0.13.3
9 | python_dateutil==2.8.2
10 | pytz==2022.7
11 | Requests==2.32.0
12 | scipy==1.12.0
13 | websocket_client==0.53.0
14 | yfinance==0.2.37
15 |
--------------------------------------------------------------------------------
/finplot/examples/snp500.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import finplot as fplt
4 | import pandas as pd
5 | import yfinance as yf
6 |
7 |
8 | symbol = 'SPY'
9 | interval = '1d'
10 | df = yf.download(symbol, interval=interval)
11 |
12 | ax,ax2 = fplt.create_plot('S&P 500 MACD', rows=2)
13 |
14 | # plot macd with standard colors first
15 | macd = df.Close.ewm(span=12).mean() - df.Close.ewm(span=26).mean()
16 | signal = macd.ewm(span=9).mean()
17 | df['macd_diff'] = macd - signal
18 | fplt.volume_ocv(df[['Open','Close','macd_diff']], ax=ax2, colorfunc=fplt.strength_colorfilter)
19 | fplt.plot(macd, ax=ax2, legend='MACD')
20 | fplt.plot(signal, ax=ax2, legend='Signal')
21 |
22 | # change to b/w coloring templates for next plots
23 | fplt.candle_bull_color = fplt.candle_bear_color = fplt.candle_bear_body_color = '#000'
24 | fplt.volume_bull_color = fplt.volume_bear_color = '#333'
25 | fplt.candle_bull_body_color = fplt.volume_bull_body_color = '#fff'
26 |
27 | # plot price and volume
28 | fplt.candlestick_ochl(df[['Open','Close','High','Low']], ax=ax)
29 | hover_label = fplt.add_legend('', ax=ax)
30 | axo = ax.overlay()
31 | fplt.volume_ocv(df[['Open','Close','Volume']], ax=axo)
32 | fplt.plot(df.Volume.ewm(span=24).mean(), ax=axo, color=1)
33 |
34 | #######################################################
35 | ## update crosshair and legend when moving the mouse ##
36 |
37 | def update_legend_text(x, y):
38 | row = df.loc[pd.to_datetime(x, unit='ns')]
39 | # format html with the candle and set legend
40 | fmt = '%%.2f' % ('0b0' if (row.Open= end_time:
23 | end_t = end_time - interval_ms
24 | url = 'https://www.binance.com/fapi/v1/klines?interval=%s&limit=%s&symbol=%s&startTime=%s&endTime=%s' % (interval_str, 1000, symbol, start_t, end_t)
25 | print(url)
26 | d = requests.get(url).json()
27 | assert type(d)==list, d
28 | data += d
29 | df = pd.DataFrame(data, columns='time open high low close volume a b c d e f'.split())
30 | return df.astype({'time':'datetime64[ms]', 'open':float, 'high':float, 'low':float, 'close':float, 'volume':float})
31 |
32 |
33 | def calc_volume_profile(df, period, bins):
34 | '''Calculate a poor man's volume distribution/profile by "pinpointing" each kline volume to a certain
35 | price and placing them, into N buckets. (IRL volume would be something like "trade-bins" per candle.)
36 | The output format is a matrix, where each [period] time is a row index, and even columns contain
37 | start (low) price and odd columns contain volume (for that price and time interval). See
38 | finplot.horiz_time_volume() for more info.'''
39 | data = []
40 | df['hlc3'] = (df.high + df.low + df.close) / 3 # assume this is volume center per each 1m candle
41 | _,all_bins = pd.cut(df.hlc3, bins, right=False, retbins=True)
42 | for _,g in df.groupby(pd.Grouper(key='time', freq=period)):
43 | t = g.time.iloc[0]
44 | volbins = pd.cut(g.hlc3, all_bins, right=False)
45 | price2vol = defaultdict(float)
46 | for iv,vol in zip(volbins, g.volume):
47 | price2vol[iv.left] += vol
48 | data.append([t, sorted(price2vol.items())])
49 | return data
50 |
51 |
52 | def calc_vwap(period):
53 | vwap = pd.Series([], dtype = 'float64')
54 | df['hlc3v'] = df['hlc3'] * df.volume
55 | for _,g in df.groupby(pd.Grouper(key='time', freq=period)):
56 | i0,i1 = g.index[0],g.index[-1]
57 | vwap = pd.concat([vwap, g.hlc3v.loc[i0:i1].cumsum() / df.volume.loc[i0:i1].cumsum()])
58 | return vwap
59 |
60 |
61 | # download and calculate indicators
62 | df = download_price_history(interval_mins=30) # reduce to [15, 5, 1] minutes to increase accuracy
63 | time_volume_profile = calc_volume_profile(df, period='W', bins=100) # try fewer/more horizontal bars (graphical resolution only)
64 | vwap = calc_vwap(period='W') # try period='D'
65 |
66 | # plot
67 | fplt.create_plot('Binance BTC futures weekly volume profile')
68 | fplt.plot(df.time, df.close, legend='Price')
69 | fplt.plot(df.time, vwap, style='--', legend='VWAP')
70 | fplt.horiz_time_volume(time_volume_profile, draw_va=0.7, draw_poc=1.0)
71 | fplt.show()
72 |
--------------------------------------------------------------------------------
/finplot/live.py:
--------------------------------------------------------------------------------
1 | '''
2 | For simplifying plot updating.
3 | '''
4 |
5 | import finplot
6 |
7 |
8 | class Live:
9 | def __init__(self):
10 | self.colors = {}
11 | self.item = None
12 | self.item_create_func_name = ''
13 |
14 |
15 | def item_create_call(self, name):
16 | val = getattr(finplot, name)
17 | if not callable(val):
18 | return val
19 | def wrap_call(*args, **kwargs):
20 | if 'gfx' in kwargs: # only used in subsequent update calls
21 | del kwargs['gfx']
22 | item = val(*args, **kwargs)
23 | if isinstance(item, finplot.pg.GraphicsObject): # some kind of plot?
24 | setattr(self, 'item', item)
25 | setattr(self, 'item_create_func_name', val.__name__)
26 | # update to the "root" colors dict, if set
27 | if self.colors:
28 | item.colors.update(self.colors)
29 | # from hereon, use the item.colors instead, if present
30 | if hasattr(item, 'colors'):
31 | setattr(self, 'colors', item.colors)
32 | return self
33 | return item
34 | return wrap_call
35 |
36 |
37 | def item_update_call(self, name):
38 | if name == self.item_create_func_name:
39 | def wrap_call(*args, **kwargs):
40 | item = object.__getattribute__(self, 'item')
41 | ka = {'gfx':kwargs.get('gfx', True)} # only gfx parameter used
42 | assert len(args) == 1, 'only one unnamed argument allowed for live plots'
43 | item.update_data(*args, **ka)
44 | return wrap_call
45 | return getattr(self.item, name)
46 |
47 |
48 | def __getattribute__(self, name):
49 | try:
50 | return object.__getattribute__(self, name)
51 | except:
52 | pass
53 | # check if we're creating the plot, or updating data on an already created one
54 | if object.__getattribute__(self, 'item') is None:
55 | return self.item_create_call(name)
56 | return self.item_update_call(name)
57 |
--------------------------------------------------------------------------------
/finplot/pdplot.py:
--------------------------------------------------------------------------------
1 | '''
2 | Used as Pandas plotting backend.
3 | '''
4 |
5 | import finplot
6 |
7 |
8 | def plot(df, x, y, kind, **kwargs):
9 | _x = df.index if y is None else df[x]
10 | try:
11 | _y = df[x].reset_index(drop=True) if y is None else df[y]
12 | except:
13 | _y = df.reset_index(drop=True)
14 | kwargs = dict(kwargs)
15 | if 'by' in kwargs:
16 | del kwargs['by']
17 | if kind in ('candle', 'candle_ochl', 'candlestick', 'candlestick_ochl', 'volume', 'volume_ocv', 'renko'):
18 | if 'candle' in kind:
19 | return finplot.candlestick_ochl(df, **kwargs)
20 | elif 'volume' in kind:
21 | return finplot.volume_ocv(df, **kwargs)
22 | elif 'renko' in kind:
23 | return finplot.renko(df, **kwargs)
24 | elif kind == 'scatter':
25 | if 'style' not in kwargs:
26 | kwargs['style'] = 'o'
27 | if type(x) is str and type(y) is str and _x is not None and _y is not None:
28 | return finplot.plot(_x, _y, **kwargs)
29 | else:
30 | return finplot.plot(df, **kwargs)
31 | elif kind == 'bar':
32 | if type(x) is str and type(y) is str and _x is not None and _y is not None:
33 | return finplot.bar(_x, _y, **kwargs)
34 | else:
35 | return finplot.bar(df, **kwargs)
36 | elif kind in ('barh', 'horiz_time_volume'):
37 | return finplot.horiz_time_volume(df, **kwargs)
38 | elif kind in ('heatmap'):
39 | return finplot.heatmap(df, **kwargs)
40 | elif kind in ('labels'):
41 | if type(x) is str and type(y) is str and _x is not None and _y is not None:
42 | return finplot.labels(_x, _y, **kwargs)
43 | else:
44 | return finplot.labels(df, **kwargs)
45 | elif kind in ('hist', 'histogram'):
46 | return finplot.hist(df, **kwargs)
47 | else:
48 | if x is None:
49 | _x = df
50 | _y = None
51 | if 'style' not in kwargs:
52 | kwargs['style'] = None
53 | return finplot.plot(_x, _y, **kwargs)
54 |
--------------------------------------------------------------------------------
/nuts.xcf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/highfestiva/finplot/d2786a4daf5ac6f69273fe91b3965d5be3f2caf8/nuts.xcf
--------------------------------------------------------------------------------
/run-all.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import glob
4 | import os
5 | import sys
6 |
7 |
8 | pyexe = 'python3.exe' if 'win' in sys.platform else 'python3'
9 | pycode = open('README.md').read().split('```')[1::2]
10 | for pc in pycode:
11 | if not pc.startswith('python'):
12 | continue
13 | pc = pc.split(maxsplit=1)[1]
14 | open('.t.py', 'w').write('import finplot as fplt\n'+pc)
15 | print('markup example')
16 | os.system(f'{pyexe} .t.py')
17 | for fn in glob.glob('finplot/examples/*.py') + glob.glob('dumb/*.py'):
18 | print(fn)
19 | os.system(f'{pyexe} {fn}')
20 | os.remove('.t.py')
21 | os.remove('screenshot.png')
22 |
--------------------------------------------------------------------------------
/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/highfestiva/finplot/d2786a4daf5ac6f69273fe91b3965d5be3f2caf8/screenshot.jpg
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import setuptools
3 | from finplot._version import __version__
4 |
5 | with open('README.md', 'r') as fh:
6 | long_description = fh.read()
7 |
8 | setuptools.setup(
9 | name='finplot',
10 | version=__version__,
11 | author='Jonas Byström',
12 | author_email='highfestiva@gmail.com',
13 | description='Finance plotting',
14 | long_description=long_description,
15 | long_description_content_type='text/markdown',
16 | url='https://github.com/highfestiva/finplot',
17 | packages=['finplot'],
18 | install_requires=['numpy>=1.23.5', 'pandas>=1.5.2', 'PyQt6>=6.4.0', 'pyqtgraph>=0.13.1', 'python-dateutil'],
19 | classifiers=[
20 | 'Programming Language :: Python :: 3',
21 | 'License :: OSI Approved :: MIT License',
22 | 'Operating System :: OS Independent',
23 | ],
24 | )
25 |
--------------------------------------------------------------------------------