diff --git a/CLAUDE.md b/CLAUDE.md index cf77e0a9..bbe08ba3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/doc/commands.md b/doc/commands.md index 855090ff..7a3707f2 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -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 diff --git a/lib/buttons.py b/lib/buttons.py index 3a3d6037..57de4b7c 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -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)} @@ -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: diff --git a/lib/render.py b/lib/render.py index 0430c76f..ba17dec0 100644 --- a/lib/render.py +++ b/lib/render.py @@ -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 = '' @@ -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 @@ -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) diff --git a/lib/reports/history.py b/lib/reports/history.py index 6f7f7c8a..d91ff4d7 100644 --- a/lib/reports/history.py +++ b/lib/reports/history.py @@ -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 diff --git a/lib/reports/pick.py b/lib/reports/pick.py index c304ae86..c13b9a54 100644 --- a/lib/reports/pick.py +++ b/lib/reports/pick.py @@ -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)}") diff --git a/lib/worker.py b/lib/worker.py index b7ca03fb..4c2e46ba 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -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: @@ -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, diff --git a/tests/test_edges.py b/tests/test_edges.py index 486e68bb..cad3f4d8 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -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))) diff --git a/tests/test_reports.py b/tests/test_reports.py index f8f58ebb..43e95574 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -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])]) @@ -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 @@ -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') @@ -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)) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 179dc9f4..e13188f3 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -586,7 +586,7 @@ def test_add_alone_lists_the_reports_a_job_can_run(self): self.assertEqual(lines[0], 'What a job can run:') 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))