├── .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 | ![feature1](https://raw.githubusercontent.com/highfestiva/finplot/master/feature1.png) 23 | 24 | ![feature2](https://raw.githubusercontent.com/highfestiva/finplot/master/feature2.jpg) 25 | 26 | ![feature3](https://raw.githubusercontent.com/highfestiva/finplot/master/feature3.jpg) 27 | 28 | ![feature3](https://raw.githubusercontent.com/highfestiva/finplot/master/feature-nuts.jpg) 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 | --------------------------------------------------------------------------------