diff --git a/lib/render.py b/lib/render.py index 16743d6..edcbb86 100644 --- a/lib/render.py +++ b/lib/render.py @@ -35,6 +35,7 @@ class Chart: red_rows: tuple = () # bars: the indices of rows drawn in red though they are not below zero (the sells) threshold: float = None # shorts: the outrageous line change: float = None # line: the change shown, whose sign colours the line and its subtitle + source: str = '' # where the data comes from, cited at the foot of the image ('Yahoo Finance') @property def drawable(self): @@ -85,6 +86,7 @@ def rise_fall_flat(): ASCENT, DESCENT = 0.76, 0.21 # its tallest letters and descenders, in ems: a line of text sits as matplotlib set it SLANT = 0.2 # italic: the upright face sheared by 11 degrees, as its oblique is (not in fonts-dejavu-core) +_source = contextvars.ContextVar('source', default='') # draw() cites the chart's source on its canvas _drawing = threading.RLock() # one chart at a time: Pillow's fonts are shared by every thread def preload(): @@ -174,6 +176,8 @@ def png(self): from PIL import Image, ImageDraw image = self.image.reduce(self.scale) if self.scale > 1 else self.image pen = ImageDraw.Draw(image) + if cited := _source.get(): + self.text(0.03 * W, self.height - 0.012 * H, f"Source: {cited}", 6.5, palette()[2], va='bottom') for x, baseline, line, size, color, bold, italic in self.writing: if italic: _slanted(image, x, baseline, line, size, color) @@ -739,7 +743,8 @@ def draw_treemap(chart): canvas = _Canvas(W, H, bg, scale=1) # upright edges alone: nothing to smooth total = sum(value for _, held in rows for _, value in held) or 1.0 top = _plot_top(chart.subtitle) # below the heading, as the other charts are - box = _squarify([sum(value for _, value in held) for _, held in rows], 0, top, W, H - top) # 2px edges, with gaps + foot = 0.04 * H if chart.source else 0 # a strip below the tiles for the source + box = _squarify([sum(value for _, value in held) for _, held in rows], 0, top, W, H - top - foot) # 2px edges, gaps n = 0 for (label, held), (x, y, w, h) in zip(rows, box): color = '#6b7280' if label == 'Other' else CATEGORIES[n % len(CATEGORIES)] @@ -798,11 +803,12 @@ def draw(chart): name = f"finbot_chart_{hashlib.sha1(key.encode()).hexdigest()[:20]}.png" if config.settings.cache and (image := cache.read_bytes(name, math.inf)): # kept until cleanup: it never goes stale return image - token = _dark_now.set(dark) + token, cited = _dark_now.set(dark), _source.set(chart.source) try: image = RENDERERS[chart.kind](trimmed(chart)) finally: _dark_now.reset(token) + _source.reset(cited) if config.settings.cache: cache.write_bytes(name, image) image.seek(0) diff --git a/lib/reports/history.py b/lib/reports/history.py index 4f12f7d..89c7cd1 100644 --- a/lib/reports/history.py +++ b/lib/reports/history.py @@ -40,7 +40,7 @@ def prepare_compare(service, tickers, period_days=None): subtitle = f"% change over {period_label}, {formats.date_span(start, end)}" + note lines = tuple((formats.short_ticker(t), tuple((f"{day:%Y-%m-%d}", float(close)) for day, close in rows)) for t, rows in cropped) - chart = render.Chart('compare', title, subtitle, lines) + chart = render.Chart('compare', title, subtitle, lines, source='Yahoo Finance') caption = [service.bold(f"{period_label}, since {start:%-d %b %Y}") + note] results = sorted(((growth(rows) - 1) * 100, t) for t, rows in cropped) for pct, t in reversed(results): @@ -63,7 +63,8 @@ def line_chart(ticker, changes, line, days=None): price = ' '.join(filter(None, (formats.price(line.price), line.currency))) shown = formats.date_span(*(datetime.date.fromisoformat(line.closes[end][0]) for end in (0, -1))) # its dates subtitle = f"{price} {arrow} {abs(change):,g}% {shown}" - return render.Chart('line', f"{line.name} ({ticker}) {span}", subtitle, line.closes, change=float(change)) + return render.Chart('line', f"{line.name} ({ticker}) {span}", subtitle, line.closes, change=float(change), + source='Yahoo Finance') def price_line(ticker, days=None, quote=None): """(its changes, its line chart or None) of a ticker over `days`, else over its whole history, to the price of its diff --git a/lib/reports/holdings.py b/lib/reports/holdings.py index 683b6ba..468a826 100644 --- a/lib/reports/holdings.py +++ b/lib/reports/holdings.py @@ -96,7 +96,8 @@ def prepare_treemap(portfolio_name, by, service, max_groups=8): count = sum(len(held) for held in groups.values()) subtitle = f"{_noun(by, len(groups))}, {count} {'holding' if count == 1 else 'holdings'}, " + \ formats.date_short(formats.today(), True) - return payload, render.Chart('treemap', title, subtitle, tuple((label, tuple(held)) for label, held in rows)) + return payload, render.Chart('treemap', title, subtitle, tuple((label, tuple(held)) for label, held in rows), + source='Sharesight') def prepare_watchlist(service, chat, user, action=None, ticker=None, removing=()): """The .watchlist reply in a chat (by its key): the companies it watches, those of `removing` struck through, or one diff --git a/lib/reports/movers.py b/lib/reports/movers.py index 79dd4bd..b2a1560 100644 --- a/lib/reports/movers.py +++ b/lib/reports/movers.py @@ -139,7 +139,8 @@ def report(service, found, mode='interday', threshold=None, days=None, top=None, if not top: heading = lambda when: HEADINGS[mode].format(threshold=threshold, when=when, where=", ".join(found.exchanges)) session = mode if mode in ('midsession', 'premarket') else None - chart = render.Chart('bars', heading(''), subtitle, tuple(label(found.rows)), session=session) + chart = render.Chart('bars', heading(''), subtitle, tuple(label(found.rows)), session=session, + source='Yahoo Finance') return [service.bold(heading(f', {span}'))] + lines, chart worst = [i for i in reversed(range(len(found.rows))) if i >= top][:top] # the worst, worst first, none of the best payload = [service.bold(f'Top {top} movers {span}')] + lines[:top] @@ -148,7 +149,7 @@ def report(service, found, mode='interday', threshold=None, days=None, top=None, bars = label(found.rows[:top]) # the best, then after a gap the worst, the worst last if worst: bars += [("", None)] + label(found.rows[i] for i in reversed(worst)) - return payload, render.Chart('bars', f"Top & bottom {top}", subtitle, tuple(bars)) + return payload, render.Chart('bars', f"Top & bottom {top}", subtitle, tuple(bars), source='Yahoo Finance') def nothing(mode, threshold, user, ticker, in_session): """What a chat is told when nothing meets the threshold, or the one ticker asked for (.session, .premarket) has no diff --git a/lib/reports/performance.py b/lib/reports/performance.py index c2c5cc9..d02b653 100644 --- a/lib/reports/performance.py +++ b/lib/reports/performance.py @@ -43,7 +43,8 @@ def report(service, past_days, portfolio_select=None): payload = [service.bold(f"Performance over {period}")] + [line for _, line, _ in rows] chart_rows = [(label, percent) for percent, _, label in rows if percent is not None] return payload, render.Chart('bars', "Performance", formats.change_span(past_days, formats.today()), - tuple(chart_rows), value_format='percent2') + tuple(chart_rows), value_format='percent2', + source='Sharesight, Yahoo Finance') def run(chat, *, past_days=None, portfolio_select=None): """Post each portfolio's performance over past_days (the past_days setting when None), or the one named, beside diff --git a/lib/reports/rankings.py b/lib/reports/rankings.py index 74a15f1..6d6bbba 100644 --- a/lib/reports/rankings.py +++ b/lib/reports/rankings.py @@ -89,7 +89,8 @@ def prepare_rating_payload(service, chat, actions, length=None, asked=True): # zoom the axis onto the ratings so the bars fill the panel (ratings only run 1-5) chart = render.Chart('bars', title, f"Mean rating of {analysts}: 1 = strong buy, 5 = strong sell (axis zoomed), " + formats.date_short(formats.today(), True), tuple(rows), value_format='number', - xlim=(max(0.9, low - spread * 0.9), min(5.1, high + spread * 0.25)), red_rows=tuple(red)) + xlim=(max(0.9, low - spread * 0.9), min(5.1, high + spread * 0.25)), red_rows=tuple(red), + source='Yahoo Finance') return payload, chart RATIOS = {'forward pe': 'price_to_earnings_forward', 'trailing pe': 'price_to_earnings_trailing', @@ -146,7 +147,7 @@ def ratio_list(service, chat, kind, mode='top', length=LENGTH): heading = f"{'Bottom' if mode == 'bottom' else 'Top'} {length} tracked stocks by {trail} ratio" subtitle = ', '.join(filter(None, (analysts, formats.date_short(formats.today(), True)))) chart = render.Chart('bars', heading, subtitle, tuple((label, float(ratio)) for ratio, label, _ in rows), - value_format='number') + value_format='number', source='Yahoo Finance') return [service.bold(heading + (f" ({analysts})" if analysts else ''))] + [line for _, _, line in rows], chart def prepare_beta_payload(service, chat): @@ -165,5 +166,5 @@ def prepare_beta_payload(service, chat): found.sort(key=lambda row: row[0], reverse=True) heading = 'Beta over 1.5 and mkt cap under US$2B' chart = render.Chart('bars', heading, formats.date_short(formats.today(), True), tuple(row for _, _, row in found), - value_format='number') + value_format='number', source='Yahoo Finance') return [service.bold(heading)] + [line for _, line, _ in found], chart diff --git a/lib/reports/shorts.py b/lib/reports/shorts.py index 89ea47a..4a732c2 100644 --- a/lib/reports/shorts.py +++ b/lib/reports/shorts.py @@ -53,4 +53,4 @@ def report(service, tickers, market_data, threshold, specific_stock): return lines, None return [service.bold(f'Tracked stocks shorted over {threshold:g}%')] + lines, render.Chart('shorts', subtitle=formats.date_short(formats.today(), True), rows=tuple((code, percent) for percent, _, code in found), - threshold=threshold) + threshold=threshold, source='Short Man') diff --git a/tests/test_charts.py b/tests/test_charts.py index ddd8b0e..d530069 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -138,6 +138,16 @@ def test_each_chart_type(self): else: self.check_image(make(), name) + def test_a_chart_cites_its_source(self): + """A chart with a source has it written at the foot, and still draws a sane image.""" + plain = uncached(render.Chart('bars', 'T', 'sub', tuple(MIXED))) + cited = uncached(render.Chart('bars', 'T', 'sub', tuple(MIXED), source='Yahoo Finance')) + foot = (0, 760, 400, 800) + self.assertIsNotNone(ImageChops.difference(decode(plain).convert('RGB').crop(foot), + decode(cited).convert('RGB').crop(foot)).getbbox()) + cited.seek(0) + self.check_image(cited, 'cited') + def test_edge_case_rows(self): """Rows that broke charts before (one, all zero, forty, long labels, huge or tiny values) draw sane images.""" for name, rows in EDGE_ROWS.items():