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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
portfolio or ticker, the first two to compare; `.holdings` only its buttons), the buttons kept under it and every
press re-editing the answer from `shown` (an item's number plus one; compare's tickers, `pick.pack`), 32 items a page
(`pick.layout`; fewer on Discord) turned by `<` `>` under the tickers menu, its list of items checked by checksum,
`.compare`'s ticks a bit each in the data's mask, a seventh refused by an alert toast (`buttons.alert`); a pie or
`.compare`'s ticks (up to `buttons.MAX_PICKED`, 8) a bit each in the data's mask, one more refused by an alert toast (`buttons.alert`); a pie or
treemap primes its other charts in a thread (`pick.prime`, started by `worker.prime_in_background`); every chart
with period buttons primes its other periods (`charts.prime`, `worker.prime_periods` after `.compare`, `.price`,
`.performance`, `chart_reply` and the pickers; never for a scheduled run).
Expand Down
2 changes: 1 addition & 1 deletion doc/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ The bot answers commands in every chat it is in. By default, it fetches its upda
* A watch list per chat, which anyone in it can change, picked up by the reports there
* Listing of portfolios and portfolio performance
* A pie or a treemap of a portfolio by industry or sector (`/pie portfolio [sector]`, `/treemap portfolio [sector]`): the treemap tiles each holding by value, grouped and coloured by industry (the default) or sector
* Buttons to pick from where a command needs a portfolio or tickers and is typed without them: `/pie`, `/treemap` (the first portfolio alphabetically, by industry or by `sector`), `/history` (the first ticker) and `/compare` (the first two tickers, over 7D unless a period is typed) answer at once, with the buttons under the chart: portfolios four to a row and `/treemap`'s Sector/Industry toggle after them; for tickers a period row and a menu button (`Tickers ▶`; `Select... (2)` for `/compare`, with the number ticked) (those held, else the chat's watchlist) with `<` `>` pages, up to 6 ticked to compare. `/holdings` shows its portfolio buttons alone. Another portfolio or ticker, the toggle or a period redoes the chart in place, and a `/pie` or `/treemap` draws its other charts in the background so the next press is quick. `/compare T1 T2 [period]` typed with tickers gets the same period row and `Select...` menu when the tickers are among those held (else watched) and the period is a button one. Every chart with a period row, from any command, draws its other periods in the background once it is sent, so a period press is quick. Thirty-two items make a page (fewer on Discord, which holds five rows of five buttons). A job, or a chat with no buttons, gets the usage instead
* Buttons to pick from where a command needs a portfolio or tickers and is typed without them: `/pie`, `/treemap` (the first portfolio alphabetically, by industry or by `sector`), `/history` (the first ticker) and `/compare` (the first two tickers, over 7D unless a period is typed) answer at once, with the buttons under the chart: portfolios four to a row and `/treemap`'s Sector/Industry toggle after them; for tickers a period row and a menu button (`Tickers ▶`; `Select... (2)` for `/compare`, with the number ticked) (those held, else the chat's watchlist) with `<` `>` pages, up to 8 ticked to compare. `/holdings` shows its portfolio buttons alone. Another portfolio or ticker, the toggle or a period redoes the chart in place, and a `/pie` or `/treemap` draws its other charts in the background so the next press is quick. `/compare T1 T2 [period]` typed with tickers gets the same period row and `Select...` menu when the tickers are among those held (else watched) and the period is a button one. Every chart with a period row, from any command, draws its other periods in the background once it is sent, so a period press is quick. Thirty-two items make a page (fewer on Discord, which holds five rows of five buttons). A job, or a chat with no buttons, gets the usage instead
* Listing of holdings sorted by various attributes
* Running the other stock reports on demand

Expand Down
5 changes: 3 additions & 2 deletions lib/buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class PickPress(typing.NamedTuple):


PICKERS = {'p': 'pie', 't': 'treemap', 'h': 'holdings', 'y': 'history', 'c': 'compare'} # command key -> its name
MAX_PICKED = 6 # tickers .compare takes
MAX_PICKED = 8 # tickers .compare takes
TOO_MANY = f"⚠️ Max {MAX_PICKED} tickers: untick one first"

PERIODS = {'d': ('1D', 1), 'w': ('7D', 7), 'm': ('1M', 30), 'y': ('1Y', 365), 'x': ('Max', None)}
Expand Down Expand Up @@ -127,7 +127,8 @@ def parse(data):
if kind == 'k':
parts = rest.split('|')
if (len(parts) == 7 and parts[0] in PICKERS and parts[1] in ('0', 'i', 's') and (parts[2] in PERIODS or
parts[2] == '0') and len(parts[3]) <= 12 and len(parts[4]) <= 25 and parts[5][:1] in 'pmfrgh' and
parts[2] == '0') and len(parts[3]) <= 2 * MAX_PICKED and len(parts[4]) <= 25 and
parts[5][:1] in 'pmfrgh' and
parts[5][1:].isdigit() and all(part.isascii() and part.isalnum() for part in parts) and
len(data.encode()) <= MAX_CALLBACK_BYTES):
try:
Expand Down
9 changes: 5 additions & 4 deletions lib/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Chart:
"""A chart not yet drawn, for draw(). kind 'bars': rows of (label, value), first on top, a (label, None) row a gap;
'pie': rows of (label, value); 'treemap': rows of (group, ((label, value), ...)); 'shorts': rows of (label,
percent), and the threshold; 'line': rows of (date, close), a price line, and the change it shows; 'compare':
rows of (label, ((date, close), ...)), two to six price lines."""
rows of (label, ((date, close), ...)), two to eight price lines."""
kind: str
title: str = ''
subtitle: str = ''
Expand Down Expand Up @@ -56,6 +56,7 @@ def style_date_axis(ax, start, end, ink):
CATEGORIES = ('#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767') # the dark
# categorical slots, in fixed order; the green and red ones mean up and down, so lines that are neither leave them out
NEITHER_UP_NOR_DOWN = tuple(color for color in CATEGORIES if color not in ('#008300', '#e66767'))
COMPARE = (*NEITHER_UP_NOR_DOWN, '#2bb5d6', '#d9dde3') # .compare's eight lines: those slots, then a cyan, a pale gray
UP, DOWN, FLAT = '#34d399', '#fb7185', PALETTE[2] # a rise, a fall, neither: a price line's colour by its change
BAR_UP = CATEGORIES[0] # bars rise in blue; they fall in DOWN

Expand Down Expand Up @@ -201,11 +202,11 @@ def annotate(i, name, above):

@_drawn
def draw_compare(chart):
"""Rebased % change lines of two to six price series (rows of (label, ((date, close), ...)), already cropped to the
period), each labelled at its end: a 1280x800 PNG."""
"""Rebased % change lines of two to eight price series (rows of (label, ((date, close), ...)), already cropped to
the period), each labelled at its end: a 1280x800 PNG."""
from matplotlib.ticker import FuncFormatter
bg, ink, ink2, grid = PALETTE
colors = NEITHER_UP_NOR_DOWN
colors = COMPARE
fig, _ = _figure(bg)
ax = _time_axes(fig, bg, 0.72)
rebased = [] # (label, dates, % change since the first close)
Expand Down
2 changes: 1 addition & 1 deletion lib/reports/history.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Price history: .history (one stock over every period) and .compare (two to six stocks, rebased)."""
"""Price history: .history (one stock over every period) and .compare (two to eight stocks, rebased)."""
import bisect
import datetime
from lib import answer
Expand Down
4 changes: 2 additions & 2 deletions lib/reports/pick.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ def button(label, act, active=False, **changes):
nav.append(button('Compare', f"g{page}"))
if nav:
rows.append(nav)
heading = f"{service.bold(name.capitalize())}: pick " + (
'a portfolio' if noun(cmd) == 'portfolio' else 'up to 6 tickers' if cmd == 'c' else 'a ticker')
picks = f'up to {buttons.MAX_PICKED} tickers' if cmd == 'c' else 'a ticker'
heading = f"{service.bold(name.capitalize())}: pick " + ('a portfolio' if noun(cmd) == 'portfolio' else picks)
lines = ([note] if note else []) + [heading + (f" (page {page + 1} of {pages})" if pages > 1 else '')]
if ticked:
lines.append(f"Ticked: {', '.join(ticked)}")
Expand Down
4 changes: 2 additions & 2 deletions lib/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ def on_compare(request, args):
no symbols, buttons to pick them."""
if not args.words:
return picker(request, 'c', args)
if not 2 <= len(args.words) <= 6:
if not 2 <= len(args.words) <= buttons.MAX_PICKED:
raise Usage
tickers = list(dict.fromkeys(yahoo.symbols(args.words))) # each once, in the order asked
if len(tickers) < 2:
Expand Down Expand Up @@ -705,7 +705,7 @@ class Command:
Command(('marketcap', 'maletas', 'marketer'), on_marketcap, ('.marketcap [SYMBOL|top|bottom]',), COMPANY,
keywords=TOP),
Command(('beta',), on_beta, ('.beta',), PRICE),
Command(('compare',), on_compare, ('.compare SYMBOL SYMBOL [... up to 6] [period]',), PRICE, ('.compare',),
Command(('compare',), on_compare, ('.compare SYMBOL SYMBOL [... up to 8] [period]',), PRICE, ('.compare',),
period=True, keywords={'max': 'max', 'all': 'max'}), # max: as the default, 10 years
Command(('history', 'hospital', 'visual', 'hosiery'), on_history, ('.history SYMBOL',), PRICE),
Command(('performance', 'performanc'), on_performance, ('.performance [period] [portfolio]',), PRICE,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def test_removing_a_ticker_yahoo_no_longer_knows(self):
class BadArguments(FinbotCase):
def test_compare_argument_errors_give_usage(self):
""".compare with too few or too many tickers, or one twice, gets its usage."""
for cmd in ('.compare T1', '.compare T1 T2 T3 T4 T5 T6 T7', '.compare T1 T1'):
for cmd in ('.compare T1', '.compare T1 T2 T3 T4 T5 T6 T7 T8 T9', '.compare T1 T1'):
with self.subTest(cmd=cmd):
self.assertIn('Usage', str(self.command(cmd)))

Expand Down
28 changes: 14 additions & 14 deletions tests/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ def test_plans_are_kept_per_chat(self):

def test_compare_usage(self):
""".compare with one ticker gets its usage, from the command table."""
usage = 'Usage: .compare SYMBOL SYMBOL [... up to 6] [period]'
usage = 'Usage: .compare SYMBOL SYMBOL [... up to 8] [period]'
self.assertEqual(self.command('.compare T1'), [('text', [usage])])


Expand Down Expand Up @@ -1008,21 +1008,21 @@ def test_compare_answers_with_the_first_two_tickers(self):
self.assertEqual((self.toasts[-1], self.labels(rows)), (('Loading…', False), [['7D', '1M', '● 1Y', 'Max'],
['Select... (2)']])) # back at the top, with the period row

def test_compare_ticks_up_to_six_and_refuses_a_seventh(self):
""".compare: ticks toggle, a seventh is refused with an alert and a note."""
def test_compare_ticks_up_to_eight_and_refuses_a_ninth(self):
""".compare: ticks toggle, a ninth is refused with an alert and a note."""
self.command('.compare')
self.tap('Select... (2)')
for ticker in ('T10', 'T11', 'T12', 'T13'):
for ticker in ('T10', 'T11', 'T12', 'T13', 'T14', 'T15'):
kind, lines, rows = self.tap(ticker)
self.assertEqual([t for row in self.labels(rows) for t in row if t.startswith('✅')],
['✅ T0', '✅ T1', '✅ T10', '✅ T11', '✅ T12', '✅ T13'])
['✅ T0', '✅ T1', '✅ T10', '✅ T11', '✅ T12', '✅ T13', '✅ T14', '✅ T15'])
self.assertEqual(self.toasts[-1], ('Updating…', False))
kind, lines, rows = self.tap('T14')
kind, lines, rows = self.tap('T16')
self.assertEqual((self.toasts[-1], lines[0]), ((buttons.TOO_MANY, True), buttons.TOO_MANY))
self.assertNotIn('✅ T14', [t for row in self.labels(rows) for t in row])
self.assertNotIn('✅ T16', [t for row in self.labels(rows) for t in row])
kind, lines, rows = self.tap('✅ T13') # unticking makes room
self.assertNotIn(buttons.TOO_MANY, lines)
self.assertEqual(len([t for row in self.labels(rows) for t in row if t.startswith('✅')]), 5)
self.assertEqual(len([t for row in self.labels(rows) for t in row if t.startswith('✅')]), 7)

def test_buttons_of_another_list_start_again(self):
"""A button drawn for other tickers than there are now starts the picker again, at its first answer, saying
Expand Down Expand Up @@ -1085,12 +1085,12 @@ def test_tickers_are_paged_32_to_a_page_under_the_tickers_menu(self):
kind, lines, rows = self.tap('◀ Back')
self.assertEqual(self.labels(rows)[1], ['Select... (3)', 'Compare'])

def test_a_seventh_tick_is_refused_on_a_later_page(self):
"""The limit of six counts every page's ticks, and the refusal stays on the page."""
def test_a_ninth_tick_is_refused_on_a_later_page(self):
"""The limit of eight counts every page's ticks, and the refusal stays on the page."""
self.many()
self.command('.compare')
self.tap('Select... (2)')
for ticker in ('S02', 'S03', 'S04', 'S05'):
for ticker in ('S02', 'S03', 'S04', 'S05', 'S06', 'S07'):
self.tap(ticker)
self.tap('>')
kind, lines, rows = self.tap('S33')
Expand Down Expand Up @@ -1134,12 +1134,12 @@ def test_discord_pages_hold_five_rows(self):
self.assertEqual(counted, items)

def test_pick_data_carries_the_page_and_what_is_shown(self):
"""A button's data says its page and what the message shows (compare's six tickers packed), within a button's
"""A button's data says its page and what the message shows (compare's eight tickers packed), within a button's
64 bytes with 100 items."""
press = buttons.PickPress('c', 's', 'x', pick.pack([0, 1, 2, 3, 98, 99]), (1 << 100) - 1, 'm2', 'a1b2')
press = buttons.PickPress('c', 's', 'x', pick.pack([0, 1, 2, 3, 4, 5, 98, 99]), (1 << 100) - 1, 'm2', 'a1b2')
button = buttons.pick_button('x', press)
self.assertEqual((buttons.parse(button.data), len(button.data) <= 64), (press, True))
self.assertEqual(pick.unpack(press.shown), [0, 1, 2, 3, 98, 99])
self.assertEqual(pick.unpack(press.shown), [0, 1, 2, 3, 4, 5, 98, 99])
for data in ('k|c|0|x|0|0|z1|a1b2', 'k|c|0|x|0|0|p|a1b2', 'k|c|0|x|0|0|r-1|a1b2', 'k|c|0|x|0|0|s1|a1b2'):
self.assertIsNone(buttons.parse(data))

Expand Down
2 changes: 1 addition & 1 deletion tests/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ def test_add_alone_lists_the_reports_a_job_can_run(self):
self.assertEqual(lines[0], '<b>What a job can run:</b>')
for line in ('cal earnings|ex-dividend', 'message "TEXT"', 'performance [DAYS]', 'podcasts FEED_URL',
'price midsession|interday|premarket|close|DAYS', 'trades', '.jobs add price weekly', 'beta',
'pe [SYMBOL|top|bottom]', 'compare SYMBOL SYMBOL [... up to 6] [period]', '.jobs add pe top weekly'):
'pe [SYMBOL|top|bottom]', 'compare SYMBOL SYMBOL [... up to 8] [period]', '.jobs add pe top weekly'):
self.assertIn(line, lines)
self.assertFalse(any(name in line for line in lines for name in jobs.SYSTEM_ACTIONS))

Expand Down