Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions lib/reports/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/reports/holdings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/reports/movers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/reports/performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions lib/reports/rankings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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):
Expand All @@ -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
2 changes: 1 addition & 1 deletion lib/reports/shorts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
10 changes: 10 additions & 0 deletions tests/test_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down