From 8160a375038d5c590033c84c81a769ae0057a9ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 22:56:28 +0000 Subject: [PATCH 01/81] Slack replies without a bot token say so; user text is escaped; booleans take yes/no; graph=False means no charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slack: link previews are turned off in the request body, where Slack reads unfurl_links/unfurl_media (they went in the headers and never worked). A reply without slackBotToken logs "ERROR slack: a reply to channel … needs slackBotToken", slack_api raises the same instead of a TypeError, and the bot warns once at startup when Slack commands are configured without the token. - Text a user wrote is shown as text: a .plan entry with a < made Telegram refuse every .plan reply after it, and a first name is escaped where it arrives. Slack's already escaped text is not escaped twice. - A true/false setting takes true/false, yes/no, on/off or 1/0; anything else is a config error (scheduler=yes and graph=Ture used to mean False). A blank value means the default, as the README says of finbot.env. - graph=False turns off every chart, not only the price lines. - performance names each portfolio from the portfolio list: one with no holdings crashed the report. - Yahoo: the chart request sends the cookie of its crumb; cookie and crumb are fetched together under the lock; price_history no longer writes its PNG cache with the cache off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- README.md | 4 +-- bot.py | 9 ++++-- lib/config.py | 33 +++++++++++++++++---- lib/webhook.py | 25 +++++++++++----- lib/worker.py | 6 +++- lib/yahoo.py | 20 ++++++++----- performance.py | 9 +++--- tests/test_config.py | 19 ++++++++++++ tests/test_edges.py | 26 +++++++++++++++++ tests/test_providers.py | 65 +++++++++++++++++++++++++++++++++++++++++ tests/test_reports.py | 6 ++++ 11 files changed, 191 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 978cbb2a..614b376f 100644 --- a/README.md +++ b/README.md @@ -480,7 +480,7 @@ Copies preferences.conf and metadata files into the data directory (never overwr util/sync_defaults.py [-d DEST] --apply ``` -All 26 keys, read by the bot itself. A change takes effect on a reload. +All 26 keys, read by the bot itself. A change takes effect on a reload. A true/false preference takes `true`/`false`, `yes`/`no`, `on`/`off` or `1`/`0`; anything else is a mistake, reported like any other. A blank value means the default. | Key | Default | Description | |---|---|---| @@ -716,7 +716,7 @@ Visit https://api.slack.com/apps/ to create a new Slack app from scratch (if you * Adding scopes to an app that is already installed takes effect only after _Reinstall to Workspace_. * Scroll up to _OAuth Tokens for Your Workspace_: * If _Bot User OAuth Token_ is not visible, hit _Install to Workspace > Allow_ - * Copy _Bot User OAuth Token_ into `finbot.env` as `slackBotToken`. This allows finbot to authenticate with Slack so it can send replies. + * Copy _Bot User OAuth Token_ into `finbot.env` as `slackBotToken`. This allows finbot to authenticate with Slack so it can send replies. Without it Slack commands get no reply: the log says so at startup, and again for each reply. * Restart `bot.py` ### Daemonize (systemd) diff --git a/bot.py b/bot.py index 0b5cf865..d33dc9a1 100755 --- a/bot.py +++ b/bot.py @@ -5,7 +5,7 @@ signal.signal(signal.SIGHUP, signal.SIG_IGN) # ignored until startup is done (the reload handler is installed last): its default action kills the bot import gevent.monkey gevent.monkey.patch_all() -import json, os, re, sys, traceback +import html, json, os, re, sys, traceback from pathlib import Path from gevent import pywsgi import threading @@ -97,11 +97,12 @@ def reply(status, body=b''): sender = msg["from"] message_id = str(msg["message_id"]) chat_id = str(msg["chat"]["id"]) + first_name = html.escape(sender["first_name"], quote=False) # replies are Telegram HTML: a name is text, not markup if "username" in sender: user = '@' + sender["username"] - userRealName = sender["first_name"] or user # what to call them: the handle when the first name is blank + userRealName = first_name or user # what to call them: the handle when the first name is blank else: - user = userRealName = '@' + sender["first_name"] + user = userRealName = '@' + first_name if "text" in msg: message = msg["text"] print("[Telegram]:", user, message, file=sys.stderr) if debug else None @@ -205,6 +206,8 @@ def reload_all(*args): sys.exit(1) for line in config.startup_report(): print('config:', line, file=sys.stderr) + for line in config.startup_warnings(): + print('ERROR config:', line, file=sys.stderr) if os.getuid() == 0: print("Running as superuser. This is not recommended.", file=sys.stderr) if config_scheduler: # read before anything else happens: an impossible time in it stops the start diff --git a/lib/config.py b/lib/config.py index 45c2f8c1..3b73061d 100644 --- a/lib/config.py +++ b/lib/config.py @@ -110,20 +110,34 @@ def read_startup_file(path): return values +TRUE_WORDS = ('true', '1', 't', 'yes', 'y', 'on') +FALSE_WORDS = ('false', '0', 'f', 'no', 'n', 'off') + + class _Reader: - """Typed reads from a getenv(name, default); a bad value is remembered, the default used, and all problems raised together.""" + """Typed reads from a getenv(name, default); a bad value is remembered, the default used, and all problems raised together. + A blank value means unset: the default.""" def __init__(self, getenv): self.getenv = getenv self.errors = [] + def value(self, name, default): + value = self.getenv(name, default) + return default if value == '' else value + def str(self, name, default=None): - return self.getenv(name, default) + return self.value(name, default) def bool(self, name, default): - return str(self.getenv(name, default)).lower() in ('true', '1', 't') + value = self.value(name, default) + word = str(value).strip().lower() + if word in TRUE_WORDS or word in FALSE_WORDS: + return word in TRUE_WORDS + self.errors.append(f"{name}={value!r} is not true or false") + return default def number(self, kind, name, default): - value = self.getenv(name, default) + value = self.value(name, default) try: return kind(value) except ValueError: @@ -327,6 +341,14 @@ def startup_report(): return lines +def startup_warnings(): + """Problems in finbot.env that do not stop the start, a line each: a setting that leaves part of the bot unable to work.""" + warnings = [] + if config_slackOutgoingWebhook and not config_slackBotToken: + warnings.append('slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply') + return warnings + + adjectives = [ 'absolutely', 'amazingly', @@ -515,5 +537,6 @@ def startup_report(): # `from lib.config import *` is how the other modules get their config_* names; keep the loader machinery out of their namespaces _INTERNAL = {'difflib', 're', 'sys', 'ConfigError', 'key_problem', 'parse_assignments', 'read_text', 'read_preferences', 'read_startup_file', 'startup_values', 'preference_values', 'reload', 'child_environment', 'print_errors', 'check_startup_environment', 'startup_report', 'preferences_path', - 'STARTUP_KEYS', 'PREFERENCE_KEYS', 'SECRET_KEYS', 'PREFERENCES_FILE', 'PASSED', 'PREFERENCE_NAMES', 'OLD_ENV'} + 'startup_warnings', 'STARTUP_KEYS', 'PREFERENCE_KEYS', 'SECRET_KEYS', 'PREFERENCES_FILE', 'PASSED', 'PREFERENCE_NAMES', 'OLD_ENV', + 'TRUE_WORDS', 'FALSE_WORDS'} __all__ = [name for name in dir() if not name.startswith('_') and name not in _INTERNAL] diff --git a/lib/webhook.py b/lib/webhook.py index a612e596..52d82810 100644 --- a/lib/webhook.py +++ b/lib/webhook.py @@ -15,11 +15,11 @@ def write(service, url, payload_string, chat_id=None, message_id=None, reply_mar headers = {'Content-type': 'application/json'} payload = {'text': payload_string} if 'slack.com' in url: - headers['unfurl_links'] = 'false' - headers['unfurl_media'] = 'false' + payload['unfurl_links'] = False # no link previews: Slack reads these from the body, not the headers + payload['unfurl_media'] = False if chat_id: if not config_slackBotToken: - print("Slack: replying in a channel needs slackBotToken in finbot.env", file=sys.stderr) + print(f"ERROR slack: a reply to channel {chat_id} needs slackBotToken in finbot.env; not sent", file=sys.stderr) return None headers['Authorization'] = 'Bearer ' + config_slackBotToken payload['channel'] = chat_id @@ -154,6 +154,13 @@ def payload_wrapper(service, url, payload, chat_id=None, message_id=None, reply_ 'strike': {'telegram': ('', ''), 'slack': ('~', '~'), 'discord': ('~~', '~~')}, } +def escape(text, service): + """Text a user wrote (a .plan entry, a name) made safe to show: Telegram's HTML and Slack's markup both take & < >. + It is unescaped first because it may arrive escaped already (Slack escapes what it sends; bot.py escapes Telegram names): + escaping twice would show &amp;. Discord's markdown shows entities as they are, so it gets the plain text.""" + text = html.unescape(str(text)) + return text if service == 'discord' else html.escape(text, quote=False) + def _styled(style, message, service): if service not in MARKUP[style]: return message @@ -189,10 +196,10 @@ def visible(text): MAYBE_SENT = object() # a read timeout on the request that posts a chart: it may have arrived, so no text copy follows def takes_charts(service, reply=True): - """Whether a report to `service` can carry a chart image: on Telegram always; on Slack in a reply to a chat (`reply`), uploaded - into its channel with the bot token, but not in a scheduled report, which goes through the incoming webhook and takes text only; - never on Discord. Elsewhere a report goes out as text.""" - return service == 'telegram' or (service == 'slack' and reply and bool(config_slackBotToken)) + """Whether a report to `service` can carry a chart image: never with the graph preference off; on Telegram always; on Slack in + a reply to a chat (`reply`), uploaded into its channel with the bot token, but not in a scheduled report, which goes through the + incoming webhook and takes text only; never on Discord. Elsewhere a report goes out as text.""" + return config_graph and (service == 'telegram' or (service == 'slack' and reply and bool(config_slackBotToken))) def telegram_photo(chat_id, image_data, caption, message_id=None, reply_markup=None): """sendPhoto, the report as its caption (trimmed to fit). Telegram's answer, MAYBE_SENT, or None if it was refused.""" @@ -229,6 +236,8 @@ def telegram_photo(chat_id, image_data, caption, message_id=None, reply_markup=N def slack_api(method, **data): """POST a Slack Web API method as the bot, form-encoded, and return its answer. Raises RuntimeError with Slack's error if not ok.""" + if not config_slackBotToken: + raise RuntimeError(f"Slack {method}: needs slackBotToken in finbot.env") r = net.post('https://slack.com/api/' + method, headers={'Authorization': 'Bearer ' + config_slackBotToken}, data=data, timeout=config_http_timeout) try: output = r.json() @@ -270,7 +279,7 @@ def sendPhoto(chat_id, image_data, caption, service, message_id=None, reply_mark with its chart; but not after a read timeout on the request that posts it, when the chart may have arrived after all.""" output = None chartable = takes_charts(service, reply=bool(chat_id)) - if service == 'telegram': + if chartable and service == 'telegram': output = telegram_photo(chat_id, image_data, caption, message_id, reply_markup) elif chartable: output = slack_photo(chat_id, image_data, caption, message_id) diff --git a/lib/worker.py b/lib/worker.py index 02943c2b..acf456fe 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -350,7 +350,11 @@ def on_plan(request, m): if m.group(1) and not m.group(1).startswith('@'): plan[request.user] = m.group(1) util.json_write('finbot_plan.json', plan, persist=True) - request.say([f"{webhook.bold(k.removeprefix('@'), request.service)}: {webhook.italic(v, request.service)}\n" for k, v in plan.items()]) + def who(user): # a Slack user is a mention (<@U123>), which is markup to keep; elsewhere a handle is text + user = user.removeprefix('@') + return user if request.service == 'slack' else webhook.escape(user, request.service) + request.say([f"{webhook.bold(who(k), request.service)}: {webhook.italic(webhook.escape(v, request.service), request.service)}\n" + for k, v in plan.items()]) def on_who(request, m): if not m.group('ticker'): diff --git a/lib/yahoo.py b/lib/yahoo.py index 83720238..236532b9 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -56,6 +56,13 @@ def getCrumb(seconds=1209600): # 14 days print("Got a new Yahoo crumb", file=sys.stderr) return r.text +def credentials(): + """(cookie, crumb), taken together under the lock: a crumb is only good with the cookie it was issued for, and a renewal + between two separate calls could pair a new cookie with an old crumb.""" + with _auth: + crumb = getCrumb() # renews the cookie too, when due + return getCookie(), crumb + def rmCrumb(): """Set the cached crumb aside (as .old): it belongs to the cookie being replaced.""" crumb = Path(util.cache_path("finbot_yahoo_crumb.json")) @@ -141,8 +148,7 @@ def fetch(tickers): cacheFile = "finbot_yahoo_" + hashlib.sha256("_".join(tickers).encode()).hexdigest() + '.json' if config_cache and (cache := util.read_cache(cacheFile, 300)): return cache - cookie = getCookie() - crumb = getCrumb() + cookie, crumb = credentials() r = _get_first(_hosts('/v7/finance/quote?crumb=' + crumb + '&symbols=' + ','.join(tickers)), {**HEADERS, 'Content-type': 'application/json', 'Cookie': cookie}) if r is None: print("Exhausted Yahoo API attempts. Giving up", file=sys.stderr) @@ -226,8 +232,7 @@ def fetch_detail(ticker, seconds=UNSET): if seconds is UNSET: seconds = config_cache_seconds now = datetime.datetime.now() - cookie = getCookie() - crumb = getCrumb() + cookie, crumb = credentials() cacheFile = "finbot_yahoo_detail_" + ticker + '.json' data = util.read_cache(cacheFile, seconds) if config_cache else None if data: @@ -362,7 +367,8 @@ def price_history(ticker, days=None, seconds=UNSET, graph=UNSET, graphCache=True if config_cache and graphCache and (image := util.read_binary_cache(image_cache_file, seconds)): return percent_dict, image image = util.graph(df, title, stock.get('currency')) - util.write_binary_cache(image_cache_file, image) + if config_cache: + util.write_binary_cache(image_cache_file, image) return percent_dict, image def price_series(ticker): @@ -439,12 +445,12 @@ def fetch_chart_json(ticker, days=3665, seconds=UNSET, full=False): cacheFile = "finbot_yahoo_history_" + ticker + ("_max" if full else "") + ".json" if config_cache and (cache := util.read_cache(cacheFile, seconds)): return cache - crumb = getCrumb() # renews the cookie too, when due + cookie, crumb = credentials() start = '0' if full else str(int((now - datetime.timedelta(days=days)).timestamp())) url = f'https://query1.finance.yahoo.com/v8/finance/chart/{ticker}?period1={start}&period2={int(now.timestamp())}&interval=1d&crumb={crumb}' print("Fetching", net.redact(url), file=sys.stderr) if debug else None try: - r = net.get(url, headers={**HEADERS, 'Content-type': 'application/json'}, timeout=config_http_timeout) + r = net.get(url, headers={**HEADERS, 'Content-type': 'application/json', 'Cookie': cookie}, timeout=config_http_timeout) except Exception as e: raise RuntimeError(f"Yahoo unreachable for {ticker}: {net.redact(e)}") from e if r.status_code != 200: diff --git a/performance.py b/performance.py index 23864308..5fd25df4 100755 --- a/performance.py +++ b/performance.py @@ -12,10 +12,10 @@ BENCHMARKS = (('SPY', 'NYSEARCA', 'S&P 500'), ('QQQ', 'NasdaqGM', 'NASDAQ 100')) def gather(past_days, portfolio_select=None): - """{portfolio id: Sharesight's performance report over past_days} of every portfolio, or of the one named (in any case). Raises - sharesight.PortfolioNotFound for a name that is none of them, and RuntimeError when there is no report.""" + """{portfolio id: (its name, Sharesight's performance report over past_days)} of every portfolio, or of the one named (in any + case). Raises sharesight.PortfolioNotFound for a name that is none of them, and RuntimeError when there is no report.""" portfolios = sharesight.pick_portfolios(portfolio_select) - performance = {portfolio_id: sharesight.get_performance(portfolio_id, past_days) for portfolio_id in portfolios.values()} + performance = {portfolio_id: (name, sharesight.get_performance(portfolio_id, past_days)) for name, portfolio_id in portfolios.items()} if not performance: print("Error: no Sharesight data found", file=sys.stderr) raise RuntimeError("no Sharesight data found") @@ -40,8 +40,7 @@ def report(service, past_days=UNSET, portfolio_select=None, interactive=False, p performance = gather(past_days, portfolio_select) lines = [] # (percent, text), sorted into the payload below chart_rows = [] - for portfolio_id, data in performance.items(): - name = data['report']['holdings'][0]['portfolio']['name'] + for portfolio_id, (name, data) in performance.items(): # the name from the portfolio list: one with no holdings names none link = util.link("https://portfolio.sharesight.com/portfolios/" + str(portfolio_id), name, service) percent = float(data['report']['capital_gain_percent']) lines.append((percent, f"{util.get_emoji(percent)} {link} {util.signed_percent(percent, 2)}")) diff --git a/tests/test_config.py b/tests/test_config.py index ba4ade60..6f9703f8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -98,6 +98,18 @@ def test_invalid_values_are_named(self): self.assertEqual(len(caught.exception.errors), 2) self.assertIn("future_days='abc' is not a whole number", caught.exception.errors[0]) + def test_booleans_take_the_usual_words_and_refuse_a_typo(self): + for word, value in (('True', True), ('yes', True), ('ON', True), ('1', True), ('false', False), ('No', False), ('off', False), ('0', False)): + with self.subTest(word=word): + self.assertIs(config.preference_values(config._getenv_for({'graph': word}))['config_graph'], value) + with self.assertRaises(config.ConfigError) as caught: # it used to mean False without a word + config.preference_values(config._getenv_for({'graph': 'Ture'})) + self.assertIn("graph='Ture' is not true or false", caught.exception.errors[0]) + + def test_a_blank_value_means_the_default(self): + values = config.preference_values(config._getenv_for({'cache': '', 'future_days': '', 'country_code': ''})) + self.assertEqual((values['config_cache'], values['config_future_days'], values['config_country_code']), (True, 30, 'AU')) + def test_unreadable_file_is_an_error_naming_it(self): path = Path(tempfile.mkdtemp(), 'preferences.conf') path.mkdir() # reading a directory fails like a permission error would @@ -323,6 +335,13 @@ def test_a_leftover_env_in_the_checkout_refuses_to_start(self): self.assertIn(f'{old} is the old single config file, which finbot no longer reads', caught.exception.errors[0]) self.assertIn('/util/split_env.py, which writes finbot.env.new and preferences.conf.new', caught.exception.errors[1]) + def test_slack_commands_without_a_bot_token_are_warned_about(self): + with mock.patch.object(config, 'config_slackOutgoingWebhook', 'https://example.com/slack'): + with mock.patch.object(config, 'config_slackBotToken', False): + self.assertEqual(config.startup_warnings(), ['slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply']) + with mock.patch.object(config, 'config_slackBotToken', 'xoxb-1'): + self.assertEqual(config.startup_warnings(), []) + def test_startup_report_is_a_short_count_never_names_or_values(self): with mock.patch.dict(os.environ, {'slack_webhook': 'https://secret.example/abc', 'port': '5000'}, clear=True): lines = config.startup_report() diff --git a/tests/test_edges.py b/tests/test_edges.py index d6776e93..43edf3cd 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -264,6 +264,32 @@ def test_slack_does_not_change_the_telegram_bot_name(self): self.assertEqual(bot.botName, '@finbot_tg') +class UserTextIsEscaped(FinbotCase): + """Replies go out as Telegram HTML: text a user wrote is shown, never read as markup. One stray < in a .plan entry made Telegram + refuse that reply and every .plan reply after it.""" + + def test_a_plan_is_shown_as_text(self): + reply = self.command('.plan buy & hold')[0][1] + self.assertIn('buy <cheap> & hold', reply[0]) + self.assertEqual(self.command('.plan')[0][1], reply) # and the same way again + self.sent.clear() + worker.process_request('slack', 'C1', '<@U1>', '.plan sell <dear>', BOT, 'U', '1') # Slack escapes what it sends + text = '\n'.join(self.sent[0][1]) + self.assertIn('*<@U1>*', text) # a Slack mention stays markup + self.assertIn('_sell <dear>_', text) # escaped once, not twice + self.assertIn('_buy <cheap> & hold_', text) + + def test_a_telegram_name_is_escaped_where_it_arrives(self): + requests = [] + self.patch(worker, 'process_request', lambda *a: requests.append(a)) + body = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A&'}, 'text': '.hello'}} + env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(json.dumps(body).encode()), 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} + with contextlib.redirect_stderr(io.StringIO()): + bot.main(env, lambda *a, **k: None) + gevent.sleep(0.05) # the worker runs in a (gevent) thread + self.assertEqual((requests[0][2], requests[0][5]), ('@A<b>&', '@A<b>&')) # the handle and the name + + class SecretsStayOutOfLogs(FinbotCase): """A failed request's error message carries its URL: the Telegram token, a Slack or Discord webhook path, a Yahoo crumb.""" SECRET_URL = 'https://api.telegram.org/bot123456:SECRET-tok_en/sendMessage?chat_id=1' diff --git a/tests/test_providers.py b/tests/test_providers.py index fdbbf8f6..9f66cf30 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -201,6 +201,20 @@ def slack(text): # Slack: the reports come as text self.assertEqual(len(self.errors), 1) # the one ticker asked for: one line saying why self.assertTrue(self.errors[0].startswith('⚠️ .price: RuntimeError: 404 error for T3')) + def test_the_chart_request_carries_the_cookie_of_its_crumb(self): + headers = [] + self.patch(yahoo.net, 'get', lambda url, **kwargs: headers.append(kwargs.get('headers', {})) or Resp(self.chart([100.0] * 5))) + yahoo.fetch_chart_json('T1', full=True) + self.assertEqual(headers[0].get('Cookie'), 'A=B') # the crumb used to go without its cookie + + def test_with_the_cache_off_no_chart_is_written(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + self.patch(yahoo, 'config_cache', False) + self.http(yahoo, Resp(self.chart([100.0] * 30 + [110.0]))) + table, image = yahoo.price_history('T1', 7, graph=True) + self.assertIsNotNone(image) + self.assertEqual([name for name in os.listdir(util.config_cache_dir) if name.startswith('finbot_graph_')], []) + def test_price_series_raises_on_failure_and_on_empty_history(self): self.http(yahoo, Resp(status=500)) with self.assertRaises(RuntimeError): @@ -545,6 +559,21 @@ def refuse(*a, **k): self.assertTrue(self.sent) self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) + def test_the_graph_preference_off_sends_no_chart_anywhere(self): + """graph=False means no charts, as the README says; it used to hold back only the price-line charts.""" + self.patch(webhook, 'config_graph', False) + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + posts = [] + self.patch(webhook.net, 'post', lambda url, **k: posts.append(url) or Resp({'ok': True, 'result': True})) + for service, chat in (('telegram', '55'), ('slack', 'C1')): + for cmd in ('.price', '.price top', '.performance 7d', '.shorts', '.buy', '.peg', '.beta', '.compare T1 T2', '.pie Rob'): + with self.subTest(service=service, cmd=cmd): + self.sent.clear(); self.errors.clear() + worker.process_request(service, chat, '@u', cmd, BOT, 'U', '1') + self.assertEqual(self.errors, []) + self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) + self.assertEqual([url for url in posts if 'sendPhoto' in url or 'files.' in url], []) + def test_a_chart_telegram_refuses_goes_out_as_text(self): def post(outcome): def fake(url, **kwargs): @@ -565,6 +594,42 @@ def fake(url, **kwargs): self.assertEqual(self.sent, expect) +class SlackSending(FinbotCase): + """Slack replies go through chat.postMessage with the bot token; scheduled reports through the incoming webhook.""" + + def setUp(self): + super().setUp() + self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) + self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) + self.posts = [] + self.patch(webhook.net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or Resp({'ok': True})) + + def test_link_previews_are_turned_off_in_the_body(self): + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + webhook.write('slack', 'https://slack.com/api/chat.postMessage', 'hi', 'C1') + webhook.write('slack', 'https://hooks.slack.com/services/T/B/x', 'hi') + self.assertEqual(len(self.posts), 2) + for url, kwargs in self.posts: + with self.subTest(url=url): # they used to go in the headers, where Slack does not look + self.assertEqual((kwargs['json']['unfurl_links'], kwargs['json']['unfurl_media']), (False, False)) + self.assertNotIn('unfurl_links', kwargs['headers']) + + def test_a_reply_without_a_bot_token_is_logged_not_sent(self): + self.patch(webhook, 'config_slackBotToken', False) + err = io.StringIO() + with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): + for cmd in ('.help', '.price', '.compare T1 T2'): + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U', '1') + self.assertEqual((self.posts, self.errors), ([], [])) + self.assertEqual(err.getvalue().count('ERROR slack: a reply to channel C1 needs slackBotToken in finbot.env; not sent'), 3) + + def test_the_slack_api_says_what_is_missing(self): + self.patch(webhook, 'config_slackBotToken', False) + with self.assertRaisesRegex(RuntimeError, 'Slack files.getUploadURLExternal: needs slackBotToken in finbot.env'): + webhook.slack_api('files.getUploadURLExternal', filename='chart.png', length=1) # it used to be a TypeError + self.assertEqual(self.posts, []) + + class TelegramWebhook(FinbotCase): """bot.py registers the webhook once as it starts. Importing lib.telegram, as every job process does, touches nothing.""" diff --git a/tests/test_reports.py b/tests/test_reports.py index df019833..34869a2a 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -60,6 +60,12 @@ class ReportFunctions(FinbotCase): def test_prepare_help(self): self.assertTrue(reports.prepare_help('telegram', BOT)) + def test_a_portfolio_with_no_holdings_is_reported_by_name(self): + """Its name used to be read from its first holding: an empty portfolio crashed .performance.""" + self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': {'holdings': [], 'capital_gain_percent': '0.0'}}) + payload, _ = performance.report('telegram', 7) + self.assertTrue(any('>Rob' in line for line in payload), payload) + def test_prepare_holdings(self): self.assertTrue(reports.prepare_holdings_payload(None, 'telegram')) From d71c1b142206f593fb6e1dcc42a37a8f2968920a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:01:54 +0000 Subject: [PATCH 02/81] Dead plumbing removed, quiet tests, a chat-networks table and a code map for CLAUDE.md - Replies never quoted the asking message (the worker passed message_id=None even before the command table): the whole chain goes, from bot.py through process_request, the report scripts and write/sendPhoto, with Slack's thread_ts/reply_broadcast. So do the chat_id=telegramChatID defaults only an interactive run reads, the "make google cloud happy" returns, hbar_chart(threshold=) that only a test used, sharesight.warm_once (the tests call warm()[0]), a fontweight DejaVu Sans does not have, a dead emoji, an unreachable except, a stranded unittest.main() and a misnamed test. - A passing test run prints 0.4 KB instead of 23.7 KB: -b in CLAUDE.md and CI, bot imported before requests so gevent patches ssl first, a real job child's output sent to /dev/null, and the two chart warnings fixed at their cause (all-zero bars divided by zero; a 2-day span made the date locator complain). - README: a table of what each chat network can do, near the top; the command list as it is (.value and .rating never existed; .beta .buy .sell .compare .pe .fpe .peg .plan were missing); 12-hour cache default; no key counts to keep in step. - CLAUDE.md: a code map, a test map, conventions and token tips. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 36 +++++++++++++++++++++-- README.md | 63 ++++++++++++++++++++-------------------- bot.py | 4 +-- cal.py | 7 ++--- lib/reports.py | 50 +++++++++++++++---------------- lib/sharesight.py | 4 --- lib/util.py | 12 ++++---- lib/webhook.py | 35 +++++++++------------- lib/worker.py | 2 +- milestone.py | 4 --- performance.py | 4 +-- price.py | 2 +- rating.py | 5 +--- reminder.py | 3 -- shorts.py | 5 +--- tests/common.py | 4 +-- tests/test_charts.py | 3 +- tests/test_edges.py | 12 ++++---- tests/test_jobs.py | 4 ++- tests/test_providers.py | 34 +++++++++++----------- tests/test_reports.py | 10 +++---- trades.py | 7 ++--- 23 files changed, 152 insertions(+), 160 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9a0f38b..d171e059 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: run: | pip install -r requirements.txt python -m compileall -q *.py lib util - python -m unittest discover -s tests -v + python -m unittest discover -s tests -v -b notify: # Telegram alert when the weekly run fails. Needs repository secrets TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. diff --git a/CLAUDE.md b/CLAUDE.md index 9b1ea0ab..3089ef0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Iterating (auto mode) The owner chats back and forth; every message that asks for a change gets a change. -1. Make the change, run the tests (`.venv/bin/python -m unittest discover -s tests`), fix what breaks. +1. Make the change, run the tests (`.venv/bin/python -m unittest discover -s tests -b`), fix what breaks. 2. Commit (with the attribution trailer) and **push to the work branch** (`git push -u origin `). Do this every iteration, so the branch always holds the latest working state. If a PR from the work branch is open, update its title and description in the same iteration (see below). 3. Reply briefly: what changed, test result, and the pushed commit's short hash. @@ -20,5 +20,37 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm ## Facts - Python 3.13.5; tests are offline and need no network (`tests/`). CI runs them on the latest 3.x and 3.13.5, except for pull requests that change only README.md, CLAUDE.md or `img/*.png` (install, compile and tests are skipped; the allowlist in `.github/workflows/ci.yml` must never include a path a test reads). - Production runs the Debian system packages (including `python3-croniter`, for the scheduler in `lib/jobs.py`); `requirements.txt` is for pip/uv users and CI. -- A new preference goes in five places: `PREFERENCE_KEYS` and `preference_values` (`lib/config.py`), `defaults/preferences.conf`, the README's preferences table and its key count, and `DEFAULTS` plus the key count in `tests/test_config.py`. The tests catch all but the README. +- A new preference goes in five places: `PREFERENCE_KEYS` and `preference_values` (`lib/config.py`), `defaults/preferences.conf`, the README's preferences table, and `DEFAULTS` plus the key count in `tests/test_config.py`. The tests catch all but the README (no test may read README.md: CI skips the tests for a README-only change). - If Yahoo and Sharesight are unreachable (the cloud environment's network policy can block them), check behaviour against the offline fixtures in `tests/common.py`. For a refactor, compare old and new output: load `git show HEAD:` as a module beside the new one and diff what both produce for the same inputs. + +## Code map (open the one file you need) +- `bot.py`: WSGI entry; routes /telegram and /slack, authenticates, hands each message to `lib/worker.py` in a thread; SIGHUP reload. +- `lib/worker.py`: `COMMANDS` (regex → `on_*` handler, first match wins, `.SYMBOL` last); `Request`; the typing indicator. +- `lib/reports.py`: `prepare_*` builders for the chat-only reports (lines, plus a chart spec). +- `price.py performance.py trades.py shorts.py cal.py rating.py milestone.py reminder.py podcasts.py cleanup.py`: reports that also + run on a schedule; each has `lambda_handler()` and `job(argv)` (its schedule-line argument parser). +- `lib/webhook.py`: sending (chunking, photos, Slack upload), markup, escaping, error reporting. `lib/telegram.py`: Bot API calls. +- `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/util.py`: formatting, links, market tables, cache and + state files, all matplotlib rendering. +- `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources, cached in `DATA_DIR/cache`. +- `lib/config.py`: finbot.env + preferences.conf → `config_*` names (star-imported), reload. `lib/jobs.py` + `lib/jobrun.py`: the + scheduler; each run is a child process. +- `tests/common.py`: `FinbotCase` (offline fixtures). `self.command('.price')` returns what was sent: `('text', lines)` or + `('photo', size, caption)`. +- Which test file: commands and scheduled reports `test_reports`; failures, bad input, WSGI and Slack events `test_edges`; + Yahoo/Sharesight parsing, sending, Telegram API `test_providers`; scheduler `test_jobs`; config `test_config`; charts + `test_charts` (refresh goldens with `UPDATE_GOLDEN=1`); pure helpers `test_util`; data dir `test_datadir`; chunking, git_version + `test_finbot`. + +## Conventions +- A report is a list of lines. Markup through `webhook.bold/italic(text, service)`, user-written text through + `webhook.escape(text, service)`, links through `util.link`/`util.finance_link`. +- Tabs in code; in tests match the file. New and changed lines stay under 120 characters (an edit quotes whole lines). +- stdout is discarded under systemd (`StandardOutput=null`): anything worth logging goes to stderr. +- A new command: `COMMANDS`, `prepare_help`, the README command list, and `command_keyboard` if it gets a button. + +## Saving tokens +- Run the tests quietly: `.venv/bin/python -m unittest discover -s tests -b` prints only failures (about 0.4 KB when green). One + module: `.venv/bin/python -m unittest tests.test_reports -b`. +- README.md is 38 KB: grep for the section instead of reading it whole. `doc/` holds large API samples that the tests replay: + inspect them with python, don't read them. diff --git a/README.md b/README.md index 614b376f..7f2e802c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,20 @@ * Shared watch list * List stocks (holdings + watchlist) by P/E, Forward P/E, PEG, market cap, or Yahoo analyst consensus +### Chat networks +What finbot can do on each network. Telegram takes everything; the others take what their APIs allow. + +| Feature | Telegram | Slack | Discord | +|---|---|---|---| +| Chat commands | ✓ | ✓ | ✗ (a webhook only posts) | +| "typing…" while a command works | ✓ | ✗ (Slack has no typing notice for bots) | ✗ | +| Charts in replies | ✓ | ✓ with `slackBotToken` | n/a | +| Charts in scheduled reports | ✓ | ✗ (the incoming webhook takes text only) | ✗ | +| Period and job buttons | ✓ | ✗ | ✗ | +| No link previews | ✓ | ✓ | ✗ (set `hyperlink=False`) | +| Linked tickers | ✓ | ✓ | ✓ | +| Charts updated in place by their buttons | ✓ | ✗ | ✗ | + ![Screenshot of showing trade notifications on Slack](img/screenshot.png?raw=true "Screenshot showing trade notifications on Slack") ### Requirements @@ -37,53 +51,38 @@ croniter python-dateutil requests gevent pandas matplotlib numpy ### Supported commands Where: -* _percent_ is a number followed by '%' -* _period_ is a number followed by 'd' 'w' 'm' or 'y' +* _percent_ is a number, optionally followed by '%' +* _period_ is a number followed by 'd', 'w', 'm' or 'y', or 'ytd' * _portfolio_ is a Sharesight portfolio name * _symbol_ is a Yahoo Finance symbol ``` .symbol +.beta +.buy +.sell +.compare symbol symbol [symbol...] [period|max] .dividend [period|symbol] .earnings [period|symbol] +.forward pe [symbol|top|bottom|negative] .history symbol .holdings portfolio .jobs [run number|command] .marketcap [symbol|top|bottom] +.pe [symbol|top|bottom] +.peg [symbol|top|bottom|negative] .performance [period] [portfolio] .pie portfolio [sector] +.plan [your plan] .premarket [percent|symbol] -.price [percent|symbol] [period] -.rating [strong buy|buy|hold|underperform|sell] +.price [percent|symbol|top|bottom] [period] .session [percent|symbol] .shorts [percent|symbol] .trades [period] [portfolio] -.value [pe|forward pe|peg|bottom pe|bottom forward pe|bottom peg|negative forward pe|negative peg] .watchlist [add|del symbol] .who symbol ``` -Alternative syntax: - -``` -@botname -@botname dividend [period|symbol] -@botname earnings [period|symbol] -@botname history symbol -@botname holdings [portfolio] -@botname jobs [run number|command] -@botname marketcap [symbol|top|bottom] -@botname performance [period] [portfolio] -@botname pie portfolio [sector] -@botname premarket [percent|symbol] -@botname price [percent|symbol] [period] -@botname rating [buy|hold|underperform|sell] -@botname session [percent|symbol] -@botname shorts [percent|symbol] -@botname trades [period] [portfolio] -@botname value [pe|forward pe|peg] -@botname watchlist [add|del symbol] -@botname who symbol -``` +`!` or `/` work in place of the dot, and so does the bot's name: `@botname price 7d`, `@botname AAPL`. `.help` lists the commands in chat. Trade notifications are peformed by polling the Sharesight trades API on a schedule (see [Scheduling](#scheduling)), and notifying your configured chat networks of any new trades. Thus, it works best if your trades are auto-imported into Sharesight through its broker integrations, and if your environment has persistent storage so that the bot can keep track of known trade IDs between runs. @@ -176,7 +175,7 @@ You can also specify a portfolio name to get trades for just that portfolio: ![Price chart triggered from Slack](img/finbot_price.png?raw=true "Price chart triggered from Slack") ![Price alert triggered by Cron](img/price.png?raw=true "Price alert triggered by Cron") -`prices.py` sends premarket, midsession, and interday price movements if any holdings moved over a percentage threshold. This data is sourced from Yahoo! Finance and Sharesight. The default threshold is 5% but you can change it by setting `price_percent` in the `preferences.conf`, or by providing a number as argument when triggered through the chat bot. Decimal fractions are accepted. +`price.py` sends premarket, midsession, and interday price movements if any holdings moved over a percentage threshold. This data is sourced from Yahoo! Finance and Sharesight. The default threshold is 5% but you can change it by setting `price_percent` in the `preferences.conf`, or by providing a number as argument when triggered through the chat bot. Decimal fractions are accepted. If `demote_volatile` is `true`, stocks with a [market cap](https://www.investopedia.com/terms/m/marketcapitalization.asp) under US$150M, or a [beta](https://www.investopedia.com/terms/b/beta.asp) of > 1.5 and a market cap of under US$1B will have their alert threshold (`price_percent`) doubled. A market cap in another currency is converted at Yahoo's exchange rate. @@ -451,7 +450,7 @@ sudo cp ~/finbot/defaults/finbot.env /opt/finbot.env && sudo chmod 600 /opt/finb * **init.d / shell:** `util/check_config.py /opt/finbot.env && set -a && . /opt/finbot.env && set +a && ./bot.py` (`set -a` is what exports the values to the bot). * **Scheduled jobs:** they are started by `bot.py` (see [Scheduling](#scheduling)), so they run on the same settings. For a one-off run, see [Running a report by hand](#scheduling). -All 17 keys, read by your launcher. A change needs a restart. Blank means unset. +The startup keys, read by your launcher. A change needs a restart. Blank means unset. | Key | Default | Description | |---|---|---| @@ -480,7 +479,7 @@ Copies preferences.conf and metadata files into the data directory (never overwr util/sync_defaults.py [-d DEST] --apply ``` -All 26 keys, read by the bot itself. A change takes effect on a reload. A true/false preference takes `true`/`false`, `yes`/`no`, `on`/`off` or `1`/`0`; anything else is a mistake, reported like any other. A blank value means the default. +The preferences, read by the bot itself. A change takes effect on a reload. A true/false preference takes `true`/`false`, `yes`/`no`, `on`/`off` or `1`/`0`; anything else is a mistake, reported like any other. A blank value means the default. | Key | Default | Description | |---|---|---| @@ -583,10 +582,10 @@ include_portfolios=100001 100002 ``` ### Caching -Many objects are cached for just under one day by default. Cache is controlled by the settings below. Trades IDs are stored on disk, but trade queries generally don't result in a cache hit for functional reasons. +Many objects are cached for 12 hours by default (`cache_seconds`). Cache is controlled by the settings below. Trades IDs are stored on disk, but trade queries generally don't result in a cache hit for functional reasons. ``` cache=True -cache_seconds=82800 +cache_seconds=43200 performance_cache_seconds=1800 ``` diff --git a/bot.py b/bot.py index d33dc9a1..59cd22f3 100755 --- a/bot.py +++ b/bot.py @@ -95,7 +95,6 @@ def reply(status, body=b''): return reply('200 OK', b'Unsupported') msg = inbound["message"] sender = msg["from"] - message_id = str(msg["message_id"]) chat_id = str(msg["chat"]["id"]) first_name = html.escape(sender["first_name"], quote=False) # replies are Telegram HTML: a name is text, not markup if "username" in sender: @@ -141,7 +140,6 @@ def reply(status, body=b''): if event["type"] not in ('message', 'app_mention') or "text" not in event: print(f"[{service}]: unhandled event callback type", event["type"], file=sys.stderr) return reply('200 OK', b'

Unhandled

') - message_id = str(event["ts"]) message = event['text'] message = re.sub(r'', r'\g<1>', message) # becomes dub.ax message = re.sub(r'<(@[\w\.]+)>', r'\g<1>', message) # <@QWERTY> becomes @QWERTY @@ -155,7 +153,7 @@ def reply(status, body=b''): return reply('200 OK', b'

Unhandled

') # process in a background thread so we don't keep the requesting client waiting - threading.Thread(target=worker.process_request, args=(service, chat_id, user, message, bot_name, userRealName, message_id)).start() + threading.Thread(target=worker.process_request, args=(service, chat_id, user, message, bot_name, userRealName)).start() # Return an empty response to the client return reply('200 OK') diff --git a/cal.py b/cal.py index 1a63e487..10b9dd0f 100755 --- a/cal.py +++ b/cal.py @@ -11,7 +11,7 @@ EVENTS = {'earnings': ('earnings_date', 'Upcoming earnings'), 'ex-dividend': ('ex_dividend_date', 'Upcoming ex-dividends')} # -> (field, heading) -def lambda_handler(chat_id=config_telegramChatID, days=UNSET, service=None, specific_stock=None, message_id=None, interactive=False, event='earnings'): +def lambda_handler(chat_id=None, days=UNSET, service=None, specific_stock=None, interactive=False, event='earnings'): """The tracked stocks' (or one stock's) upcoming earnings or ex-dividend dates, as `event` says. A scheduled earnings report is pinned on Telegram.""" if days is UNSET: @@ -78,10 +78,7 @@ def pin_if_earnings(service, results): print("") for service, url, chat in webhook.destinations(interactive, service, chat_id): - pin_if_earnings(service, webhook.send(service, url, chat, prepare_payload(service, market_data), message_id=message_id)) - - # make google cloud happy - return True + pin_if_earnings(service, webhook.send(service, url, chat, prepare_payload(service, market_data))) def job(argv): """Arguments of a scheduled run: (label, lambda_handler kwargs). Raises ValueError with the usage on a bad argument.""" diff --git a/lib/reports.py b/lib/reports.py index fb6fe5a7..d42c06bd 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -423,36 +423,32 @@ def prepare_value_payload(service, action='pe', ticker_select=None, length=15): forward_ratio = None peg_ratio = None ratio = None - try: - if action in ('pe', 'bottom pe', 'forward pe', 'bottom forward pe'): - trailing_ratio = market_data.get(ticker).get('price_to_earnings_trailing') - forward_ratio = market_data.get(ticker).get('price_to_earnings_forward') - if action in ('forward pe', 'bottom forward pe'): - if not ticker_select and forward_ratio is not None and forward_ratio < 0: - continue - ratio = forward_ratio - else: - if not ticker_select and trailing_ratio is not None and trailing_ratio < 0: - continue - ratio = trailing_ratio - elif action == 'negative forward pe': - forward_ratio = market_data.get(ticker).get('price_to_earnings_forward') - if not ticker_select and (forward_ratio is None or forward_ratio >= 0): + if action in ('pe', 'bottom pe', 'forward pe', 'bottom forward pe'): + trailing_ratio = market_data.get(ticker).get('price_to_earnings_trailing') + forward_ratio = market_data.get(ticker).get('price_to_earnings_forward') + if action in ('forward pe', 'bottom forward pe'): + if not ticker_select and forward_ratio is not None and forward_ratio < 0: continue ratio = forward_ratio - elif action in ('peg', 'bottom peg'): - peg_ratio = market_data.get(ticker).get('price_to_earnings_peg') - if not ticker_select and peg_ratio is not None and peg_ratio < 0: - continue - ratio = peg_ratio - elif action == 'negative peg': - peg_ratio = market_data.get(ticker).get('price_to_earnings_peg') - if not ticker_select and (peg_ratio is None or peg_ratio >= 0): + else: + if not ticker_select and trailing_ratio is not None and trailing_ratio < 0: continue - ratio = peg_ratio - except KeyError: - print(ticker, action, "value not found", file=sys.stderr) - continue + ratio = trailing_ratio + elif action == 'negative forward pe': + forward_ratio = market_data.get(ticker).get('price_to_earnings_forward') + if not ticker_select and (forward_ratio is None or forward_ratio >= 0): + continue + ratio = forward_ratio + elif action in ('peg', 'bottom peg'): + peg_ratio = market_data.get(ticker).get('price_to_earnings_peg') + if not ticker_select and peg_ratio is not None and peg_ratio < 0: + continue + ratio = peg_ratio + elif action == 'negative peg': + peg_ratio = market_data.get(ticker).get('price_to_earnings_peg') + if not ticker_select and (peg_ratio is None or peg_ratio >= 0): + continue + ratio = peg_ratio if ratio is None: continue profile_title = market_data.get(ticker).get('profile_title') diff --git a/lib/sharesight.py b/lib/sharesight.py index d41a4895..824b9257 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -220,10 +220,6 @@ def warm(margin=0.9): fetched += 1 return fetched, portfolios -def warm_once(margin=0.9): - """warm(), returning only the number of fetches made.""" - return warm(margin)[0] - def next_due(margin=0.9, portfolios=None): """Seconds until the soonest warmed cache needs refreshing (0 if one already does). `portfolios` is the list warm() just used; without it the cached list is read.""" diff --git a/lib/util.py b/lib/util.py index 26c8f44c..22726183 100644 --- a/lib/util.py +++ b/lib/util.py @@ -353,11 +353,9 @@ def style_date_axis(ax, start, end, ink): import matplotlib.dates as mdates days = (end - start).days fmt = '%Y' if days > 1460 else '%b %Y' if days > 150 else '%d %b' - ax.xaxis.set_major_locator(mdates.AutoDateLocator(minticks=3, maxticks=5)) + ax.xaxis.set_major_locator(mdates.AutoDateLocator(minticks=2, maxticks=5)) # minticks=3 warns on a 2-day span ax.xaxis.set_major_formatter(mdates.DateFormatter(fmt)) ax.tick_params(axis='x', colors=ink, labelsize=10, length=3, width=0.8, pad=6) - for label in ax.get_xticklabels(): - label.set_fontweight('medium') PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # charts: dark surface, ink, muted ink, grid lines @@ -496,7 +494,7 @@ def tinted(bg, color, amount=0.07): mix = lambda i: round(int(bg[i:i + 2], 16) * (1 - amount) + int(color[i:i + 2], 16) * amount) return '#{:02x}{:02x}{:02x}'.format(mix(1), mix(3), mix(5)) -def hbar_chart(rows, title, subtitle='', value_fmt=None, threshold=None, xlim=None, session=None): +def hbar_chart(rows, title, subtitle='', value_fmt=None, xlim=None, session=None): """Horizontal bars that mirror the caption list, first row on top, filling the whole 1280x800 canvas. Blue for >= 0, red below. Each row's label sits at the bar's base and its value at the tip. rows: [(label, value)]; a (label, None) row is a blank spacer. xlim zooms the value axis (bars then start at its left edge). @@ -517,11 +515,11 @@ def hbar_chart(rows, title, subtitle='', value_fmt=None, threshold=None, xlim=No base = max(xlim[0], 0) if xlim else 0 if not xlim: ax.axvline(0, color=ink2, linewidth=1.0, zorder=4) - if threshold is not None: - ax.axvline(threshold, color=ink2, linewidth=0.9, linestyle=(0, (4, 3)), zorder=4) - top, bottom = max(max(values), 0, threshold or 0), min(min(values), 0) + top, bottom = max(max(values), 0), min(min(values), 0) lo0 = xlim[0] if xlim else bottom hi0 = xlim[1] if xlim else top + if hi0 <= lo0: # every value zero: give the axis a width, or the scale below divides by zero + hi0 = lo0 + 1 ax.set_xlim(lo0, hi0) ax.set_ylim(n - 0.4, -0.6) # first row on top ax.set_yticks([]) diff --git a/lib/webhook.py b/lib/webhook.py index 52d82810..3dc706ab 100644 --- a/lib/webhook.py +++ b/lib/webhook.py @@ -11,7 +11,7 @@ from lib import net from lib import telegram -def write(service, url, payload_string, chat_id=None, message_id=None, reply_markup=None): +def write(service, url, payload_string, chat_id=None, reply_markup=None): headers = {'Content-type': 'application/json'} payload = {'text': payload_string} if 'slack.com' in url: @@ -23,15 +23,10 @@ def write(service, url, payload_string, chat_id=None, message_id=None, reply_mar return None headers['Authorization'] = 'Bearer ' + config_slackBotToken payload['channel'] = chat_id - if message_id: - payload['thread_ts'] = message_id - payload['reply_broadcast'] = 'true' elif 'api.telegram.org' in url: payload['parse_mode'] = 'HTML' payload['disable_web_page_preview'] = 'true' payload['disable_notification'] = 'true' - payload['allow_sending_without_reply'] = 'true' - payload['reply_to_message_id'] = message_id if reply_markup: payload['reply_markup'] = reply_markup # keyboard (Telegram only) try: @@ -70,12 +65,12 @@ def destinations(interactive, service, chat_id): """Where a report goes: back to the chat that asked for it (interactive), else to the default chats.""" return [(service, chat_url(service, chat_id), chat_id)] if interactive else default_chats() -def send(service, url, chat_id, payload, image=None, reply_markup=None, message_id=None): +def send(service, url, chat_id, payload, image=None, reply_markup=None): """A report: its chart with the text as caption when there is one (sendPhoto falls back to the text where a chart cannot go), else the text. reply_markup is the chart's buttons. Returns what payload_wrapper or sendPhoto did.""" if image: - return sendPhoto(chat_id, image, '\n'.join(payload), service, message_id=message_id, reply_markup=reply_markup) - return payload_wrapper(service, url, payload, chat_id, message_id) + return sendPhoto(chat_id, image, '\n'.join(payload), service, reply_markup=reply_markup) + return payload_wrapper(service, url, payload, chat_id) def error_line(e, context=None, limit=200): """One-line, secret-free summary of an exception (or string).""" @@ -126,7 +121,7 @@ def cli(script, job, handler, argv=None): sys.exit(1) guarded(label, handler, **kwargs) -def payload_wrapper(service, url, payload, chat_id=None, message_id=None, reply_markup=None): +def payload_wrapper(service, url, payload, chat_id=None, reply_markup=None): """Returns a list with the response of each message sent (several if the payload was chunked).""" if not payload: print(service + ": Nothing to send") # informational @@ -137,7 +132,7 @@ def payload_wrapper(service, url, payload, chat_id=None, message_id=None, reply_ print("Payload: " + payload_string) if debug else None limit = 2000 if service == 'discord' else 4096 if len(payload_string) <= limit: - return [write(service, url, payload_string, chat_id, message_id, reply_markup)] + return [write(service, url, payload_string, chat_id, reply_markup)] print(service, f"payload is over {limit:,} bytes. Splitting.") chunks = list(batched(payload, config_chunk_maxlines)) results = [] @@ -201,7 +196,7 @@ def takes_charts(service, reply=True): incoming webhook and takes text only; never on Discord. Elsewhere a report goes out as text.""" return config_graph and (service == 'telegram' or (service == 'slack' and reply and bool(config_slackBotToken))) -def telegram_photo(chat_id, image_data, caption, message_id=None, reply_markup=None): +def telegram_photo(chat_id, image_data, caption, reply_markup=None): """sendPhoto, the report as its caption (trimmed to fit). Telegram's answer, MAYBE_SENT, or None if it was refused.""" url = webhooks['telegram'] + "sendPhoto?chat_id=" + str(chat_id) data = { @@ -209,9 +204,7 @@ def telegram_photo(chat_id, image_data, caption, message_id=None, reply_markup=N 'chat_id': chat_id, 'caption': fit_caption(caption), 'parse_mode': 'HTML', - 'disable_web_page_preview': True, - 'allow_sending_without_reply': True, - 'reply_to_message_id': message_id} + 'disable_web_page_preview': True} if reply_markup: data['reply_markup'] = json.dumps(reply_markup) # inline keyboard (Telegram only) if hasattr(image_data, 'seek'): @@ -247,7 +240,7 @@ def slack_api(method, **data): raise RuntimeError(f"Slack {method}: {output.get('error') or r.status_code}") return output -def slack_photo(chat_id, image_data, caption, message_id=None): +def slack_photo(chat_id, image_data, caption): """Upload the chart into a Slack channel with the report as its message, the way Slack takes files since it retired files.upload: ask for an upload URL, send the bytes there, then complete the upload into the channel (the bot needs the files:write and files:read scopes). Slack's answer, MAYBE_SENT, or None if a step failed.""" @@ -260,8 +253,6 @@ def slack_photo(chat_id, image_data, caption, message_id=None): if r.status_code != 200: raise RuntimeError(f"Slack upload: {r.status_code} {r.text[:200]}") share = {'files': json.dumps([{'id': ticket['file_id']}]), 'channel_id': chat_id, 'initial_comment': caption} - if message_id: - share['thread_ts'] = message_id try: output = slack_api('files.completeUploadExternal', **share) except requests.exceptions.ReadTimeout as e: @@ -273,23 +264,23 @@ def slack_photo(chat_id, image_data, caption, message_id=None): print("OK slack chart upload to", chat_id, file=sys.stderr) return output -def sendPhoto(chat_id, image_data, caption, service, message_id=None, reply_markup=None): +def sendPhoto(chat_id, image_data, caption, service, reply_markup=None): """The chart, with the report as its caption: on Telegram, or uploaded into the Slack channel of a reply. Where a chart cannot go (takes_charts), or the service refuses it, the report is sent as text instead, without the chart's buttons, so it does not vanish with its chart; but not after a read timeout on the request that posts it, when the chart may have arrived after all.""" output = None chartable = takes_charts(service, reply=bool(chat_id)) if chartable and service == 'telegram': - output = telegram_photo(chat_id, image_data, caption, message_id, reply_markup) + output = telegram_photo(chat_id, image_data, caption, reply_markup) elif chartable: - output = slack_photo(chat_id, image_data, caption, message_id) + output = slack_photo(chat_id, image_data, caption) if output is MAYBE_SENT: return None if output: return output if chartable: print("chart not sent, sending the report as text", file=sys.stderr) - return payload_wrapper(service, chat_url(service, chat_id) if chat_id else webhooks.get(service), caption.split('\n'), chat_id, message_id) + return payload_wrapper(service, chat_url(service, chat_id) if chat_id else webhooks.get(service), caption.split('\n'), chat_id) def editMessageText(chat_id, message_id, text, reply_markup=None): """Telegram: replace a text message's text and buttons in place. A failure is only logged.""" diff --git a/lib/worker.py b/lib/worker.py index acf456fe..193cf716 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -129,7 +129,7 @@ def process_callback(service, callback_id, chat_id, message_id, data): except Exception as e: webhook.report_error(e, service, chat_id, context='chart') -def process_request(service, chat_id, user, message, botName, userRealName, message_id): +def process_request(service, chat_id, user, message, botName, userRealName): """Entry point for inbound chat requests: the first command in COMMANDS that fits the message answers it (a message that fits none gets no answer). Any failure becomes a one-line reply in the originating chat, after the typing stops.""" request = Request(service, chat_id, user, userRealName, botName) diff --git a/milestone.py b/milestone.py index 4c778d5b..5ccbd3d6 100755 --- a/milestone.py +++ b/milestone.py @@ -13,7 +13,6 @@ def lambda_handler(): def prepare_milestone_payload(service, market_data): - emoji = '⭐' payload = [] payloadhigh = [] payloadlow = [] @@ -68,9 +67,6 @@ def prepare_milestone_payload(service, market_data): if records: util.json_write('finbot_milestone.json', records, persist=True) - # make google cloud happy - return True - def job(argv): return 'milestone.py', {} diff --git a/performance.py b/performance.py index 5fd25df4..dc684740 100755 --- a/performance.py +++ b/performance.py @@ -61,14 +61,14 @@ def report(service, past_days=UNSET, portfolio_select=None, interactive=False, p graph = util.rows_chart(chart_rows, "Performance", f"% change {'' if period == 'today' else 'over '}{period}", value_fmt=lambda v: util.signed_percent(v, 2)) return payload, graph -def lambda_handler(chat_id=config_telegramChatID, past_days=UNSET, service=None, user='', portfolio_select=None, message_id=None, interactive=False): +def lambda_handler(chat_id=None, past_days=UNSET, service=None, user='', portfolio_select=None, interactive=False): if past_days is UNSET: past_days = config_past_days performance = gather(past_days, portfolio_select) for service, url, chat in webhook.destinations(interactive, service, chat_id): payload, graph = report(service, past_days, portfolio_select, interactive, performance) buttons = charts.keyboard('f', [portfolio_select or ''], charts.period_for_days(past_days)) if graph and service == 'telegram' else None - webhook.send(service, url, chat, payload, graph, reply_markup=buttons, message_id=message_id) + webhook.send(service, url, chat, payload, graph, reply_markup=buttons) def job(argv): """Arguments of a scheduled run: (label, lambda_handler kwargs). Raises ValueError with the usage on a bad argument.""" diff --git a/price.py b/price.py index d1b27426..7fad0339 100755 --- a/price.py +++ b/price.py @@ -154,7 +154,7 @@ def report(service, threshold=UNSET, user='', specific_stock=None, interactive=F payload = [f"{user}, no price movements meet threshold {threshold:g}%"] return payload, graph -def lambda_handler(chat_id=config_telegramChatID, threshold=UNSET, service=None, user='', specific_stock=None, interactive=False, midsession=False, +def lambda_handler(chat_id=None, threshold=UNSET, service=None, user='', specific_stock=None, interactive=False, midsession=False, premarket=False, interday=False, days=None, close=False, top=None): if threshold is UNSET: threshold = config_price_percent diff --git a/rating.py b/rating.py index 28476edd..34c0877d 100755 --- a/rating.py +++ b/rating.py @@ -6,7 +6,7 @@ from lib import util from lib import yahoo -def lambda_handler(chat_id=config_telegramChatID, specific_stock=None, service=None, interactive=False): +def lambda_handler(chat_id=None, specific_stock=None, service=None, interactive=False): """Analysts' consensus ratings that changed since the last scheduled run, which records the ratings it saw.""" def prepare_rating_payload(service, market_data): def get_emoji(old, new): @@ -66,9 +66,6 @@ def get_emoji(old, new): if new and not interactive: # added to what is recorded: a ticker missing from this run keeps its last rating util.json_write('finbot_rating.json', old | new, persist=True) - # make google cloud happy - return True - def job(argv): return 'rating.py', {} diff --git a/reminder.py b/reminder.py index 09b043b2..7205c783 100755 --- a/reminder.py +++ b/reminder.py @@ -71,9 +71,6 @@ def prepare_finance_calendar_payload(service): for service, url, chat in webhook.default_chats(): webhook.send(service, url, chat, prepare_finance_calendar_payload(service)) - # make google cloud happy - return True - def job(argv): return 'reminder.py', {} diff --git a/shorts.py b/shorts.py index 674694ca..2e44772b 100755 --- a/shorts.py +++ b/shorts.py @@ -7,7 +7,7 @@ from lib import yahoo from lib import shortman -def lambda_handler(chat_id=config_telegramChatID, threshold=UNSET, specific_stock=None, service=None, interactive=False): +def lambda_handler(chat_id=None, threshold=UNSET, specific_stock=None, service=None, interactive=False): if threshold is UNSET: threshold = config_shorts_percent def prepare_shorts_payload(service, market_data): @@ -48,9 +48,6 @@ def prepare_shorts_payload(service, market_data): payload, graph = prepare_shorts_payload(service, market_data) webhook.send(service, url, chat, payload, graph) - # make google cloud happy - return True - def job(argv): return 'shorts.py', {} diff --git a/tests/common.py b/tests/common.py index d6a0e971..7df12171 100644 --- a/tests/common.py +++ b/tests/common.py @@ -9,9 +9,9 @@ os.chdir(ROOT) os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok', telegramChatID='1') +import bot # first, and once: it monkey-patches with gevent, which must happen before requests imports ssl and never mid-test from lib import config, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports import performance, shorts, price, reminder, cal, trades, milestone, rating -import bot # imported once up front: it monkey-patches with gevent, which must not happen mid-test REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper'))} @@ -103,6 +103,6 @@ def command(self, text, chat='55'): self.sent.clear() self.markups.clear() self.errors.clear() - worker.process_request('telegram', chat, '@u', text, BOT, 'U', '1') + worker.process_request('telegram', chat, '@u', text, BOT, 'U') self.assertEqual(self.errors, [], f'{text} crashed') # the worker reports crashes instead of raising return list(self.sent) diff --git a/tests/test_charts.py b/tests/test_charts.py index 44822e20..a9d33208 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -87,11 +87,10 @@ def test_edge_case_rows(self): self.check_image(util.hbar_chart(rows, 'Title', 'sub'), name) self.check_image(util.rows_chart(rows, 'Title'), name + '/rows_chart') - def test_zoomed_axis_and_threshold(self): + def test_zoomed_axis(self): self.check_image(util.hbar_chart([('AAA', 1.2), ('BBB', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed') self.check_image(util.hbar_chart([('WWWWWWWW', 1.2), ('MMM', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed wide labels') self.check_image(util.hbar_chart([(f'T{i}', 1 + i / 10) for i in range(15)], 'Ratings', xlim=(0.9, 3.0)), 'zoomed 15 rows') - self.check_image(util.hbar_chart(MIXED, 'Threshold', threshold=10), 'threshold') def test_shorts_chart_sizes_and_verdicts(self): for n in (1, 5, 45): diff --git a/tests/test_edges.py b/tests/test_edges.py index 43edf3cd..0635972a 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -19,7 +19,7 @@ def run_all(self): with self.subTest(cmd=cmd): if cmd in self.ONE_LINE_ERROR: self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', cmd, BOT, 'U', '1') + worker.process_request('telegram', '55', '@u', cmd, BOT, 'U') self.assertLessEqual(len(self.errors), 1, cmd) else: self.command(cmd) @@ -37,7 +37,7 @@ def down(*a, **k): for cmd in ('.price', '.peg', '.T1', '.compare T1 T2'): with self.subTest(cmd=cmd): self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', cmd, BOT, 'U', '1') + worker.process_request('telegram', '55', '@u', cmd, BOT, 'U') self.assertEqual(len(self.errors), 1, cmd) # exactly one one-line report, not a traceback to the user self.assertTrue(self.errors[0].startswith('⚠️')) @@ -48,7 +48,7 @@ def down(*a, **k): self.patch(sharesight, 'get_performance', down) self.patch(sharesight, 'get_portfolios', down) self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U', '1') + worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U') self.assertEqual(len(self.errors), 1) def test_a_failed_command_stops_typing_before_saying_why(self): @@ -57,7 +57,7 @@ def down(*a, **k): self.patch(sharesight, 'get_portfolios', down) typing_when_reported = [] self.patch(webhook, 'report_error', lambda e, *a, **k: typing_when_reported.append([t.is_active() for t in self.typing])) - worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U', '1') + worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U') self.assertEqual(typing_when_reported, [[False]]) # its one indicator had stopped when the error line went out def test_no_holdings_and_empty_watchlist(self): @@ -91,7 +91,7 @@ def no_history(t): raise RuntimeError(f'no price history for {t}') self.patch(yahoo, 'price_series', no_history) self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', '.compare T1 T2', BOT, 'U', '1') + worker.process_request('telegram', '55', '@u', '.compare T1 T2', BOT, 'U') self.assertEqual(len(self.errors), 1) def test_history_without_a_ticker_or_with_extra_argument_gives_usage(self): @@ -273,7 +273,7 @@ def test_a_plan_is_shown_as_text(self): self.assertIn('buy <cheap> & hold', reply[0]) self.assertEqual(self.command('.plan')[0][1], reply) # and the same way again self.sent.clear() - worker.process_request('slack', 'C1', '<@U1>', '.plan sell <dear>', BOT, 'U', '1') # Slack escapes what it sends + worker.process_request('slack', 'C1', '<@U1>', '.plan sell <dear>', BOT, 'U') # Slack escapes what it sends text = '\n'.join(self.sent[0][1]) self.assertIn('*<@U1>*', text) # a Slack mention stays markup self.assertIn('_sell <dear>_', text) # escaped once, not twice diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 072f3c10..4ae1cfe1 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -483,7 +483,7 @@ def test_telegram_gets_a_button_per_command_and_slack_does_not(self): def test_the_chat_command_passes_the_keyboard_on(self): seen = [] self.patch(webhook, 'payload_wrapper', lambda svc, url, payload, *a, **k: seen.append(k.get('reply_markup')) or []) - worker.process_request('telegram', '55', '@u', '.jobs', BOT, 'U', '1') + worker.process_request('telegram', '55', '@u', '.jobs', BOT, 'U') self.assertEqual(len(seen[0]['inline_keyboard'][0]), 2) def test_button_press_runs_that_command(self): @@ -580,6 +580,8 @@ def test_every_command_can_be_handed_to_a_child(self): self.assertEqual(json.loads(json.dumps(kwargs)), kwargs, label) def test_a_real_child_runs_the_handler_and_reports_its_exit_status(self): + real = jobs.subprocess.Popen + self.patch(jobs.subprocess, 'Popen', lambda args, **kw: real(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kw)) with tempfile.TemporaryDirectory() as data: env = {'DATA_DIR': data, 'telegramBotToken': '', 'slack_webhook': '', 'discord_webhook': ''} with mock.patch.dict(os.environ, env): diff --git a/tests/test_providers.py b/tests/test_providers.py index 9f66cf30..ddd3f8dd 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -183,7 +183,7 @@ def get(url, **kwargs): self.patch(yahoo.net, 'get', get) def slack(text): # Slack: the reports come as text self.sent.clear(); self.errors.clear() - worker.process_request('slack', 'C1', '<@U1>', text, BOT, 'U', '1') + worker.process_request('slack', 'C1', '<@U1>', text, BOT, 'U') self.assertEqual(self.errors, [], text) return '\n'.join(map(str, self.sent[-1][1])) price_list = slack('.price 7d') # T3 is left out, the rest are listed @@ -197,7 +197,7 @@ def slack(text): # Slack: the reports come as text self.assertIn('NASDAQ 100', report) self.command('.T3') # the profile goes out without its history lines self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', '.price T3 7d', BOT, 'U', '1') + worker.process_request('telegram', '55', '@u', '.price T3 7d', BOT, 'U') self.assertEqual(len(self.errors), 1) # the one ticker asked for: one line saying why self.assertTrue(self.errors[0].startswith('⚠️ .price: RuntimeError: 404 error for T3')) @@ -303,28 +303,28 @@ def age(self, cache_file, seconds): def test_cold_cache_is_filled_then_left_alone(self): calls = self.serve_sharesight() - self.assertEqual(sharesight.warm_once(), 3) # portfolio list + one holdings report per portfolio (2) + self.assertEqual(sharesight.warm()[0], 3) # portfolio list + one holdings report per portfolio (2) self.assertEqual(sum('/performance' in c for c in calls), 2) self.assertTrue(all('start_date=' + datetime.date.today().isoformat() in c for c in calls if '/performance' in c)) # days=0 - self.assertEqual(sharesight.warm_once(), 0) + self.assertEqual(sharesight.warm()[0], 0) self.assertEqual(len(calls), 3) # nothing refetched while fresh def test_refreshes_before_the_ttl_runs_out(self): calls = self.serve_sharesight() - sharesight.warm_once() + sharesight.warm()[0] calls.clear() self.age(sharesight.performance_cache_file(684141, 0), 0.95 * sharesight.config_performance_cache_seconds) # 95% of its lifetime - self.assertEqual(sharesight.warm_once(), 1) + self.assertEqual(sharesight.warm()[0], 1) self.assertEqual(len(calls), 1) self.assertIn('684141/performance', calls[0]) calls.clear() self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) - self.assertEqual(sharesight.warm_once(), 1) + self.assertEqual(sharesight.warm()[0], 1) self.assertTrue(calls[0].endswith('/portfolios')) def test_a_request_never_waits_on_a_cache_the_job_has_warmed(self): calls = self.serve_sharesight() - sharesight.warm_once() + sharesight.warm()[0] calls.clear() self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) self.assertEqual(len(sharesight.get_performance(684141, 0)['report']['holdings']), 18) @@ -332,7 +332,7 @@ def test_a_request_never_waits_on_a_cache_the_job_has_warmed(self): def test_holdings_wrapper_is_served_entirely_from_the_warmed_cache(self): calls = self.serve_sharesight() - sharesight.warm_once() + sharesight.warm()[0] calls.clear() tickers = sharesight.get_holdings_wrapper() # get_portfolios() + get_holdings() per portfolio, no cache of its own self.assertIn('ARM', tickers) @@ -340,11 +340,11 @@ def test_holdings_wrapper_is_served_entirely_from_the_warmed_cache(self): def test_failed_refresh_keeps_the_old_cache(self): self.serve_sharesight() - sharesight.warm_once() + sharesight.warm()[0] self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) self.http(sharesight, ConnectionError('down')) with self.assertRaises(RuntimeError): - sharesight.warm_once() + sharesight.warm()[0] self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) # still served from the cache def test_loop_survives_failures_and_backs_off(self): @@ -382,7 +382,7 @@ def wait(self, seconds): def test_a_warming_pass_reads_the_cached_portfolio_list_once(self): self.serve_sharesight() - sharesight.warm_once() # fill the caches + sharesight.warm()[0] # fill the caches reads = [] real = util.read_cache self.patch(util, 'read_cache', lambda name, *a, **k: (reads.append(name), real(name, *a, **k))[1]) @@ -395,7 +395,7 @@ def test_a_warming_pass_reads_the_cached_portfolio_list_once(self): def test_next_due_is_90_percent_of_the_ttl(self): self.serve_sharesight() self.assertEqual(sharesight.next_due(), 0) # cold cache - sharesight.warm_once() + sharesight.warm()[0] due = sharesight.next_due() self.assertAlmostEqual(due, 0.9 * min(sharesight.config_cache_seconds, sharesight.config_performance_cache_seconds), delta=5) @@ -504,7 +504,7 @@ def test_slack_replies_upload_their_charts(self): with self.subTest(cmd=cmd): calls = self.slack_answers(*self.upload_steps()) self.sent.clear(); self.errors.clear() - worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U', '1') + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') self.assertEqual(self.errors, []) self.assertEqual([url for url, _ in calls], ['https://slack.com/api/files.getUploadURLExternal', self.UPLOAD_URL, 'https://slack.com/api/files.completeUploadExternal']) @@ -554,7 +554,7 @@ def refuse(*a, **k): for cmd in ('.beta', '.pie Rob', '.buy', '.sell', '.peg', '.price', '.price top', '.price T1 7d', '.performance 7d', '.shorts', '.history T1', '.compare T1 T2'): with self.subTest(cmd=cmd): self.sent.clear(); self.errors.clear() - worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U', '1') + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') self.assertEqual(self.errors, []) self.assertTrue(self.sent) self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) @@ -569,7 +569,7 @@ def test_the_graph_preference_off_sends_no_chart_anywhere(self): for cmd in ('.price', '.price top', '.performance 7d', '.shorts', '.buy', '.peg', '.beta', '.compare T1 T2', '.pie Rob'): with self.subTest(service=service, cmd=cmd): self.sent.clear(); self.errors.clear() - worker.process_request(service, chat, '@u', cmd, BOT, 'U', '1') + worker.process_request(service, chat, '@u', cmd, BOT, 'U') self.assertEqual(self.errors, []) self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) self.assertEqual([url for url in posts if 'sendPhoto' in url or 'files.' in url], []) @@ -619,7 +619,7 @@ def test_a_reply_without_a_bot_token_is_logged_not_sent(self): err = io.StringIO() with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): for cmd in ('.help', '.price', '.compare T1 T2'): - worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U', '1') + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') self.assertEqual((self.posts, self.errors), ([], [])) self.assertEqual(err.getvalue().count('ERROR slack: a reply to channel C1 needs slackBotToken in finbot.env; not sent'), 3) diff --git a/tests/test_reports.py b/tests/test_reports.py index 34869a2a..516db938 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -47,7 +47,7 @@ def test_history_and_compare(self): with self.subTest(cmd=cmd): self.assertTrue(self.command(cmd), f'{cmd} sent nothing') - def test_trades_and_rating_report_when_nothing_is_stored(self): + def test_trades_and_watchlist_report_when_nothing_is_stored(self): # first run: no state files exist yet, which used to crash these for cmd in ('.trades', '.watchlist'): self.assertTrue(self.command(cmd), cmd) @@ -376,10 +376,6 @@ def item(n, date): self.assertEqual(self.sent, []) # all seen now -if __name__ == '__main__': - unittest.main() - - class PieCommand(FinbotCase): def setUp(self): super().setUp() @@ -417,3 +413,7 @@ def test_unknown_or_missing_portfolio_lists_known(self): def test_text_only_service(self): worker.Request('discord', '1', '@u', 'U', BOT).deliver(['x'], {'pie': True, 'rows': [('a', 1.0)], 'title': 't'}) self.assertEqual(self.sent[-1][0], 'text') + + +if __name__ == '__main__': + unittest.main() diff --git a/trades.py b/trades.py index fe5fa02a..7e0fdcd1 100755 --- a/trades.py +++ b/trades.py @@ -9,7 +9,7 @@ from lib import util from lib import yahoo -def lambda_handler(chat_id=config_telegramChatID, days=UNSET, service=None, user='', portfolio_select=None, message_id=None, interactive=False): +def lambda_handler(chat_id=None, days=UNSET, service=None, user='', portfolio_select=None, interactive=False): """The buys and sells of the past `days`, in every portfolio or the one named: on a scheduled run only those not reported before. Raises sharesight.PortfolioNotFound for a name that is none of them.""" if days is UNSET: @@ -95,16 +95,13 @@ def prepare_trade_payload(service, trades): payload = prepare_trade_payload(service, trades) if interactive and not payload: # no trades, or none of them a buy or a sell payload = [f"{user} No trades {util.days_english(days, 'in the past ')}. {random.choice(noTradesVerb)}"] - webhook.send(service, url, chat, payload, message_id=message_id) + webhook.send(service, url, chat, payload) # write state file if newtrades and not interactive: known_trades.update(newtrades) util.json_write(state_file, list(known_trades), persist=True) - # make google cloud happy - return True - def job(argv): return 'trades.py', {} From 40c51b7a60517897f539263ab1b7b0d22af51191 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:04:22 +0000 Subject: [PATCH 03/81] SIGUSR1 flushes the cache: systemctl kill -s USR1 finbot, or systemctl start finbot-flush - bot.py deletes its cache files on SIGUSR1 and keeps its state (watchlist, seen trades, ratings, schedule). Yahoo's cookie and crumb go together under their lock, and the Sharesight caches refill at once (keep_warm can now be woken). The signal is ignored until startup is done, like SIGHUP, and job processes ignore it, since systemctl kill signals every process of the unit. - finbot-flush.service, a oneshot unit, gives it a systemctl verb; finbot.service needs no change. README: how to flush under systemd, Docker or by hand, and cleanup.py 0 with the bot stopped. - cleanup leaves a temp file written in the last minute (an atomic write in flight) and still deletes a crash's old leftovers. - A cache file deleted between the age check and the read is a miss, not an error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 3 ++- README.md | 12 ++++++++++++ bot.py | 15 +++++++++++++++ cleanup.py | 8 ++++++-- finbot-flush.service | 7 +++++++ lib/jobrun.py | 1 + lib/sharesight.py | 9 ++++++++- lib/util.py | 16 ++++++++++++---- tests/test_edges.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_jobs.py | 10 ++++++++++ tests/test_providers.py | 13 +++++++++++++ tests/test_util.py | 11 +++++++++++ 12 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 finbot-flush.service diff --git a/CLAUDE.md b/CLAUDE.md index 3089ef0b..dd018293 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - If Yahoo and Sharesight are unreachable (the cloud environment's network policy can block them), check behaviour against the offline fixtures in `tests/common.py`. For a refactor, compare old and new output: load `git show HEAD:` as a module beside the new one and diff what both produce for the same inputs. ## Code map (open the one file you need) -- `bot.py`: WSGI entry; routes /telegram and /slack, authenticates, hands each message to `lib/worker.py` in a thread; SIGHUP reload. +- `bot.py`: WSGI entry; routes /telegram and /slack, authenticates, hands each message to `lib/worker.py` in a thread; SIGHUP reload, SIGUSR1 + cache flush. - `lib/worker.py`: `COMMANDS` (regex → `on_*` handler, first match wins, `.SYMBOL` last); `Request`; the typing indicator. - `lib/reports.py`: `prepare_*` builders for the chat-only reports (lines, plus a chart spec). - `price.py performance.py trades.py shorts.py cal.py rating.py milestone.py reminder.py podcasts.py cleanup.py`: reports that also diff --git a/README.md b/README.md index 7f2e802c..40eab733 100644 --- a/README.md +++ b/README.md @@ -591,6 +591,18 @@ performance_cache_seconds=1800 `performance_cache_seconds` (default 30 minutes) is the cache lifetime of Sharesight performance reports. +To flush the cache, so that everything is fetched afresh, signal the running bot: it deletes its cache files (Yahoo and Sharesight +answers, charts, the Yahoo cookie) and keeps its state (watchlist, seen trades, ratings, schedule), then refills the Sharesight caches +at once. There is no `systemctl flush`, since systemctl takes no custom verbs; any of these does it: +``` +sudo systemctl kill -s USR1 finbot # systemd +sudo systemctl start finbot-flush # the same, once finbot-flush.service is installed (see below) +docker kill -s USR1 # Docker +kill -USR1 # anything else +``` +The log says `cache: flushed N files`. To install the `finbot-flush` shortcut: `sudo cp -v finbot-flush.service /etc/systemd/system/ && +sudo systemctl daemon-reload`. `finbot.service` itself needs no change. With the bot stopped, `cleanup.py 0` deletes the same files. + While `bot.py` is running with Sharesight configured and `cache=True` (a reload that switches `cache` pauses or resumes it), a background job keeps the Sharesight portfolio list (`cache_seconds`) and each portfolio's holdings report (1 day, `performance_cache_seconds` cache) refreshed at 90% of their cache lifetime, so chat requests do not wait on Sharesight. If a refresh fails, the previous cache keeps being served and the job retries after 5 minutes. ### Hyperlinks diff --git a/bot.py b/bot.py index 59cd22f3..58a06fef 100755 --- a/bot.py +++ b/bot.py @@ -3,6 +3,7 @@ import signal if __name__ == '__main__': signal.signal(signal.SIGHUP, signal.SIG_IGN) # ignored until startup is done (the reload handler is installed last): its default action kills the bot + signal.signal(signal.SIGUSR1, signal.SIG_IGN) # likewise the cache flush import gevent.monkey gevent.monkey.patch_all() import html, json, os, re, sys, traceback @@ -20,6 +21,8 @@ from lib import net from lib import telegram from lib import webhook +from lib import yahoo +import cleanup botName = None # the Telegram bot's @username, from telegram.register() at start AUTHENTICATED = 'finbot.authenticated' # set in a request's WSGI environ once it has passed its service's check @@ -195,6 +198,17 @@ def reload_all(*args): if scheduler: scheduler.reload() +def flush_cache(*args): + """SIGUSR1 (`systemctl kill -s USR1 finbot`): delete the cache and keep the state (watchlist, seen trades, ratings, schedule). + Yahoo's cookie and crumb belong together, so they go while nothing is renewing them; the Sharesight caches refill at once.""" + try: + with yahoo._auth: + removed = cleanup.lambda_handler(days=0) + sharesight.wake() + print(f"cache: flushed {removed} file{'' if removed == 1 else 's'} from {config_cache_dir}", file=sys.stderr) + except Exception as e: + print('ERROR', webhook.error_line(e, 'cache flush'), file=sys.stderr) + if __name__ == '__main__': print(f'Starting finbot {git_version()}', file=sys.stderr) try: @@ -237,6 +251,7 @@ def reload_all(*args): jobs.current = scheduler # for the .jobs chat command threading.Thread(target=scheduler.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs gevent.signal_handler(signal.SIGHUP, reload_all) # startup is done. systemctl reload finbot: pick up edited preferences.conf and schedule.cron + gevent.signal_handler(signal.SIGUSR1, flush_cache) # systemctl kill -s USR1 finbot: delete the cache try: httpd.serve_forever() except OSError as e: diff --git a/cleanup.py b/cleanup.py index 95a6bf57..40bafe80 100755 --- a/cleanup.py +++ b/cleanup.py @@ -5,8 +5,10 @@ from lib import webhook def lambda_handler(days=30): - """Delete the cache files (finbot_*) that were last written more than `days` days ago.""" - cutoff = time.time() - days * 86400 + """Delete the cache files (finbot_*) that were last written more than `days` days ago (days=0: all of them, a flush), but not + a temp file written in the last minute: that is an atomic write in flight (util.write_file).""" + now = time.time() + cutoff = now - days * 86400 removed = 0 try: entries = list(os.scandir(config_cache_dir)) @@ -14,6 +16,8 @@ def lambda_handler(days=30): return 0 for entry in entries: if entry.is_file(follow_symlinks=False) and entry.name.startswith('finbot_') and entry.stat().st_mtime < cutoff: + if entry.name.endswith('.tmp') and entry.stat().st_mtime > now - 60: + continue try: os.unlink(entry.path) removed += 1 diff --git a/finbot-flush.service b/finbot-flush.service new file mode 100644 index 00000000..d79c3580 --- /dev/null +++ b/finbot-flush.service @@ -0,0 +1,7 @@ +[Unit] +Description=Flush finbot's cache (sudo systemctl start finbot-flush) + +[Service] +Type=oneshot +# finbot deletes its cache files on SIGUSR1 and keeps its state; its job processes ignore the signal +ExecStart=/bin/systemctl kill --signal=USR1 finbot.service diff --git a/lib/jobrun.py b/lib/jobrun.py index 1139f4d9..c3bc0d29 100644 --- a/lib/jobrun.py +++ b/lib/jobrun.py @@ -5,6 +5,7 @@ import signal if __name__ == '__main__': signal.signal(signal.SIGHUP, signal.SIG_IGN) # a reload is for the bot, but `systemctl kill -s HUP` signals every process of the unit + signal.signal(signal.SIGUSR1, signal.SIG_IGN) # likewise a flush (systemctl kill -s USR1); its default action would kill the job import importlib, json, re, sys from lib import webhook diff --git a/lib/sharesight.py b/lib/sharesight.py index 824b9257..a39caf34 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -231,6 +231,12 @@ def next_due(margin=0.9, portfolios=None): waits.append(0 if age is None else margin * config_performance_cache_seconds - age) return max(0, min(waits)) +_wake = threading.Event() # set by wake(): the caches were flushed, refill them now + +def wake(): + """Have keep_warm refill the caches now instead of when they would next be due.""" + _wake.set() + def keep_warm(min_wait=5, retry=300, idle=60, stop=None): """Background loop: keep the portfolio list and holdings report cached, sleeping until the next one is due (90% of its TTL). While the cache preference is off (a reload can switch it either way) it fetches nothing, since nothing would be written and @@ -248,4 +254,5 @@ def keep_warm(min_wait=5, retry=300, idle=60, stop=None): if stop: stop.wait(wait) else: - time.sleep(wait) + _wake.wait(wait) + _wake.clear() diff --git a/lib/util.py b/lib/util.py index 22726183..e2b5132b 100644 --- a/lib/util.py +++ b/lib/util.py @@ -119,10 +119,19 @@ def _fresh_cache(cacheFile, maxSeconds): print("cache hit:", path, "TTL:", td_to_human(datetime.timedelta(seconds=maxSeconds - age)), file=sys.stderr) if debug else None return path +def _read_fresh(cacheFile, maxSeconds, read): + """read(path) of the cache file if it is fresh, else None; also None if the file goes (cleanup, a flush) after the check.""" + path = _fresh_cache(cacheFile, maxSeconds) + if not path: + return None + try: + return read(Path(path)) + except FileNotFoundError: + return None + def read_cache(cacheFile, maxSeconds=UNSET): """A cache file's JSON, or None when it is missing or older than maxSeconds.""" - path = _fresh_cache(cacheFile, maxSeconds) - return json.loads(Path(path).read_text(encoding="utf-8")) if path else None + return _read_fresh(cacheFile, maxSeconds, lambda path: json.loads(path.read_text(encoding="utf-8"))) def cache_age(cacheFile): """Seconds since the cache file was written, or None when it does not exist.""" @@ -165,8 +174,7 @@ def json_load(filename, persist=False): def read_binary_cache(cacheFile, maxSeconds=UNSET): """A cache file's bytes (as a file object), or None when it is missing or older than maxSeconds.""" - path = _fresh_cache(cacheFile, maxSeconds) - return io.BytesIO(Path(path).read_bytes()) if path else None + return _read_fresh(cacheFile, maxSeconds, lambda path: io.BytesIO(path.read_bytes())) def write_binary_cache(cacheFile, data): data.seek(0) diff --git a/tests/test_edges.py b/tests/test_edges.py index 0635972a..7c9c28b3 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -366,6 +366,41 @@ def test_sighup_during_startup_is_ignored_and_works_afterwards(self): self.assertIsNone(proc.poll(), ''.join(lines)) self.assertIn('reload requested but', ''.join(lines)) + def test_usr1_flushes_the_cache_and_keeps_the_state(self): + skip_if_old_env(self) + with tempfile.TemporaryDirectory() as data: + cache = Path(data, 'cache') + (cache / 'matplotlib').mkdir(parents=True) + for name in ('finbot_yahoo_cookie.json', 'finbot_graph_T1_None.png', 'finbot_x.json.abc.tmp'): # the last: a write in flight + (cache / name).write_text('{}') + Path(data, 'finbot_watchlist.json').write_text('["T1"]') # state, not cache + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} + env.update(DATA_DIR=data, port='0', scheduler='False') + proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True) + self.addCleanup(proc.wait) + self.addCleanup(proc.kill) + deadline = time.monotonic() + 30 + while not int(Path(f'/proc/{proc.pid}/status').read_text().split('SigIgn:')[1].split()[0], 16) & (1 << (signal.SIGUSR1 - 1)): + self.assertLess(time.monotonic(), deadline) + time.sleep(0.001) + proc.send_signal(signal.SIGUSR1) # still starting: ignored, where its default action would kill the bot + lines = [] + for line in proc.stderr: + lines.append(line) + if line.startswith('Opening socket'): + break + self.assertIsNone(proc.poll(), ''.join(lines)) + self.assertEqual(len(os.listdir(cache)), 4) # nothing flushed yet + time.sleep(1) # the handler goes in just before serving + proc.send_signal(signal.SIGUSR1) + for line in proc.stderr: + lines.append(line) + if 'cache: flushed' in line: + break + self.assertIn('cache: flushed 2 files', ''.join(lines)) + self.assertEqual(sorted(os.listdir(cache)), ['finbot_x.json.abc.tmp', 'matplotlib']) + self.assertTrue(Path(data, 'finbot_watchlist.json').exists()) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 4ae1cfe1..7526ca57 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -161,6 +161,16 @@ def test_removes_only_old_finbot_cache_files(self): self.assertEqual(cleanup.lambda_handler(30), 2) self.assertEqual(sorted(os.listdir(folder)), ['finbot_dir', 'finbot_new.json', 'other_old.json']) + def test_a_temp_file_being_written_is_left_alone(self): + folder = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(folder, ignore_errors=True)) + self.patch(cleanup, 'config_cache_dir', folder) + for name, stamp in (('finbot_a.json.x1.tmp', time.time() - 5), ('finbot_b.json.x2.tmp', time.time() - 40 * 86400)): + Path(folder, name).write_text('x') + os.utime(Path(folder, name), (stamp, stamp)) + self.assertEqual(cleanup.lambda_handler(0), 1) # a flush: the stale one goes, the write in flight stays + self.assertEqual(os.listdir(folder), ['finbot_a.json.x1.tmp']) + def test_missing_cache_dir_is_fine(self): self.patch(cleanup, 'config_cache_dir', '/nonexistent/finbot-cache') self.assertEqual(cleanup.lambda_handler(30), 0) diff --git a/tests/test_providers.py b/tests/test_providers.py index ddd3f8dd..b7084738 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1,6 +1,7 @@ """Yahoo, Sharesight and Telegram parsing and failure handling, driven by the real API responses saved under doc/. HTTP is mocked at lib.net, so the production parsing code runs unchanged.""" import contextlib, copy, datetime, io, json, os, subprocess, unittest +import gevent from unittest import mock from tests.common import * @@ -380,6 +381,18 @@ def wait(self, seconds): sharesight.keep_warm(idle=60, stop=stop) self.assertEqual((stop.waits, len(passes)), ([60, 60, 3000, 3000], 2)) # idle while off: no Sharesight request every 5 seconds + def test_a_flush_wakes_the_loop_to_refill_at_once(self): + passes = [] + self.patch(sharesight, 'warm', lambda: (passes.append(1), (0, {}))[1]) + self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) + loop = gevent.spawn(sharesight.keep_warm) + self.addCleanup(loop.kill) + gevent.sleep(0.05) + self.assertEqual(len(passes), 1) # then it sleeps until the next cache is due, 50 minutes away + sharesight.wake() + gevent.sleep(0.05) + self.assertEqual(len(passes), 2) + def test_a_warming_pass_reads_the_cached_portfolio_list_once(self): self.serve_sharesight() sharesight.warm()[0] # fill the caches diff --git a/tests/test_util.py b/tests/test_util.py index f84e2b5c..4b7e03f7 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -119,6 +119,17 @@ def test_read_cache_missing_or_expired_is_falsy(self): util.json_write('old.json', {'a': 1}) self.assertFalse(util.read_cache('old.json', -1)) # already expired + def test_a_cache_file_that_goes_while_it_is_read_is_a_miss(self): + util.json_write('gone.json', {'a': 1}) + real = Path.read_text + def vanish(path, *a, **k): # cleanup or a flush deleted it between the age check and the read + if path.name == 'gone.json': + path.unlink() + raise FileNotFoundError(path) + return real(path, *a, **k) + with mock.patch.object(Path, 'read_text', vanish): + self.assertIsNone(util.read_cache('gone.json', 60)) + def test_binary_cache_roundtrip(self): self.assertFalse(util.read_binary_cache('absent.png')) util.write_binary_cache('img.png', io.BytesIO(b'png-bytes')) From 551caa792469011f8d8e9c13a305e97855b79dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:08:16 +0000 Subject: [PATCH 04/81] Charts drawn off the gevent hub; a retried update runs once; report scripts load less; cache-first Yahoo details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The bot draws charts in one native thread (util.render_off_hub, from bot.py only). Drawn in the hub, the shorts pictogram froze every chat and incoming webhook for 1.3 s; now the hub's worst pause is about 0.1 s. One thread, because several at once deadlock matplotlib under gevent's patched locks (tested). matplotlib is imported at start instead of by the first chart. - A Telegram update or a Slack event that arrives again (Slack resends after 3 s without an answer) runs once, by update_id and event_id. - reports imports pandas and the scheduler where it uses them: a price or performance job process no longer loads pandas and every script through charts → reports → jobs (import price: 0.53 s → 0.14 s). - fetch_detail reads its cache before taking Yahoo's auth lock; data files (the simplywall.st lists, 73 KB, read on every profile) are parsed again only when they change. - A scheduled price report fetches the betas of the small caps that moved together, not one at a time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 2 ++ bot.py | 24 +++++++++++++++++++++++- lib/config.py | 15 ++++++++++++--- lib/reports.py | 6 ++++-- lib/util.py | 28 ++++++++++++++++++++++++++++ lib/yahoo.py | 2 +- price.py | 19 +++++++++++++------ tests/common.py | 3 ++- tests/test_charts.py | 33 ++++++++++++++++++++++++++++++++- tests/test_datadir.py | 13 ++++++++++++- tests/test_edges.py | 19 +++++++++++++++++-- tests/test_jobs.py | 8 ++++++++ tests/test_providers.py | 8 ++++++++ tests/test_reports.py | 11 +++++++++++ 14 files changed, 173 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd018293..388aa1cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm `webhook.escape(text, service)`, links through `util.link`/`util.finance_link`. - Tabs in code; in tests match the file. New and changed lines stay under 120 characters (an edit quotes whole lines). - stdout is discarded under systemd (`StandardOutput=null`): anything worth logging goes to stderr. +- A chart renderer in `lib/util.py` gets `@_drawn` (the bot draws in one native thread); it must not call another `@_drawn` + function, or that thread waits on itself. - A new command: `COMMANDS`, `prepare_help`, the README command list, and `command_keyboard` if it gets a button. ## Saving tokens diff --git a/bot.py b/bot.py index 58a06fef..da1c69ee 100755 --- a/bot.py +++ b/bot.py @@ -6,7 +6,7 @@ signal.signal(signal.SIGUSR1, signal.SIG_IGN) # likewise the cache flush import gevent.monkey gevent.monkey.patch_all() -import html, json, os, re, sys, traceback +import collections, html, json, os, re, sys, traceback from pathlib import Path from gevent import pywsgi import threading @@ -22,11 +22,26 @@ from lib import telegram from lib import webhook from lib import yahoo +from lib import util import cleanup botName = None # the Telegram bot's @username, from telegram.register() at start AUTHENTICATED = 'finbot.authenticated' # set in a request's WSGI environ once it has passed its service's check SLACK_USER_SUBTYPES = (None, 'thread_broadcast') # Slack message subtypes a person writes; the others are bot posts, edits, joins and the like +_updates_seen = collections.OrderedDict() # (service, update id) of the last updates taken, oldest first + +def seen_before(service, update_id, keep=1000): + """Whether this update was taken already: Telegram's update_id, Slack's event_id. Slack resends an event it got no answer to + within 3 seconds and Telegram one that failed, and a command must not run twice. An update without an id is never a repeat.""" + if update_id is None: + return False + key = (service, update_id) + if key in _updates_seen: + return True + _updates_seen[key] = None + if len(_updates_seen) > keep: + _updates_seen.popitem(last=False) + return False def main(environ, start_response): """WSGI entry point. Never lets an exception escape. A failure handling an authenticated request is reported as one line to the @@ -84,6 +99,9 @@ def reply(status, body=b''): print(f"[{service}]: request body is not a JSON object", file=sys.stderr) return reply('400 Bad Request', b'

Bad Request

') if service == 'telegram': + if seen_before(service, inbound.get('update_id')): + print("[telegram]: update", inbound['update_id'], "seen already, not run again", file=sys.stderr) + return reply('200 OK') bot_name = botName if "callback_query" in inbound: # chart period buttons cq = inbound["callback_query"] @@ -136,6 +154,9 @@ def reply(status, body=b''): print("replying with", response, file=sys.stderr) return reply('200 OK', bytes(response, "utf-8")) if inbound['type'] == 'event_callback': + if seen_before(service, inbound.get('event_id')): + print("[slack]: event", inbound['event_id'], "seen already (a retry), not run again", file=sys.stderr) + return reply('200 OK') event = inbound['event'] bot_user = inbound['authorizations'][0]['user_id'] # QWERTY if event.get('bot_id') or event.get('subtype') not in SLACK_USER_SUBTYPES or not event.get('user') or event['user'] == bot_user: @@ -234,6 +255,7 @@ def flush_cache(*args): sys.exit(1) else: print('Scheduled reports are off (scheduler=False)', file=sys.stderr) + util.render_off_hub() # charts drawn in a thread of their own: no chat waits while another's chart is drawn httpd = pywsgi.WSGIServer((config_ip, config_port), main) httpd.secure_repr = False if debug else None print(f'Opening socket on http://{config_ip}:{config_port}', file=sys.stderr) diff --git a/lib/config.py b/lib/config.py index 3b73061d..3ec3e88b 100644 --- a/lib/config.py +++ b/lib/config.py @@ -275,13 +275,22 @@ def data_file(name): user = config_var_dir + '/' + name return user if os.path.isfile(user) else str(REPO_DIR / 'defaults' / name) +_data_files = {} # path -> (modification time, parsed JSON) + def data_json(name, default=None): - """A data file's JSON (the user's copy or the shipped default, as data_file has it), or `default` when there is neither.""" + """A data file's JSON (the user's copy or the shipped default, as data_file has it), or `default` when there is neither. Parsed + again only when the file changes; callers must not modify what they get.""" path = data_file(name) - if not os.path.isfile(path): + try: + mtime = os.path.getmtime(path) + except OSError: return default + if (cached := _data_files.get(path)) and cached[0] == mtime: + return cached[1] with open(path, encoding="utf-8") as f: - return json.load(f) + value = json.load(f) + _data_files[path] = (mtime, value) + return value def reload(): diff --git a/lib/reports.py b/lib/reports.py index d42c06bd..01f8dc45 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -2,9 +2,7 @@ import hashlib, re import datetime import sys -import pandas as pd from lib.config import * -from lib import jobs from lib import sharesight from lib import util from lib import webhook @@ -146,6 +144,7 @@ def profile_title_sort(e): # disregards markup in sort command def prepare_compare(service, args): """Compare 2-6 tickers as rebased % change. A trailing time period (3m/1y/90d/ytd) is optional; without one, the span is the earliest date covered by every ticker. Returns (caption lines, image, tickers, period days).""" + import pandas as pd usage = ['Usage: .compare SYMBOL SYMBOL [SYMBOL...up to 6] [period|max] (default: 10 years)'] period_days = None full_history = False @@ -219,6 +218,7 @@ def prepare_compare(service, args): def prepare_jobs_payload(service, args): """`.jobs` lists the scheduled commands, each once; `.jobs run ` runs one now, as cron would. Returns (payload, reply_markup): Telegram gets a button per command.""" + from lib import jobs # here: jobs imports every script, and some scripts import this module scheduler = jobs.current if scheduler is None: return ['Scheduled jobs are off (scheduler=False)'], None @@ -243,12 +243,14 @@ def prepare_jobs_payload(service, args): def run_job_line(scheduler, text): """Run a scheduled command now and say how it went in one line.""" + from lib import jobs # here: jobs imports every script, and some scripts import this module try: return job_line(scheduler, *scheduler.run_now(text)) except jobs.ScheduleError as e: return str(e) def job_line(scheduler, label, outcome): + from lib import jobs # here: jobs imports every script, and some scripts import this module number = [command[0] for command in scheduler.commands()].index(label) + 1 return f"{number}. {jobs.display(label)}: {outcome}" diff --git a/lib/util.py b/lib/util.py index e2b5132b..7a7f6d47 100644 --- a/lib/util.py +++ b/lib/util.py @@ -1,3 +1,4 @@ +import functools import os import io import datetime @@ -367,6 +368,28 @@ def style_date_axis(ax, start, end, ink): PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # charts: dark surface, ink, muted ink, grid lines +_render_pool = None # set by render_off_hub(): the bot draws its charts in one native thread + +def render_off_hub(): + """For the bot: draw charts in one native thread from now on, so that the gevent hub keeps serving every other chat while one + is drawn (the shorts pictogram takes over a second; in the hub that froze everything). One thread, because several at once + deadlock matplotlib under gevent's patched locks. matplotlib is imported here, at start, instead of by the first chart. Job + processes and the tests draw in place.""" + global _render_pool + from gevent.threadpool import ThreadPool + import matplotlib.backends.backend_agg, matplotlib.dates, matplotlib.figure, matplotlib.patches, matplotlib.path, matplotlib.ticker # noqa: F401 + _render_pool = ThreadPool(1) + +def _drawn(draw): + """A chart function that draws in the render pool when there is one. Only for a function that calls none of the others: the + pool's one thread waiting on itself would never finish.""" + @functools.wraps(draw) + def in_pool(*args, **kwargs): + if _render_pool is None: + return draw(*args, **kwargs) + return _render_pool.apply(draw, args, kwargs) + return in_pool + def _figure(facecolor): """A 1280x800 figure (Telegram's photo size, so it is never resampled) and its Agg canvas.""" from matplotlib.figure import Figure @@ -399,6 +422,7 @@ def _png(fig, facecolor): buf.seek(0) return buf +@_drawn def graph(df, title, ylabel): """Render a price chart as PNG bytes, sized for Telegram (1280px wide, no server-side resampling).""" from matplotlib.ticker import MaxNLocator, FuncFormatter @@ -449,6 +473,7 @@ def annotate(i, name, above): return _png(fig, bg) +@_drawn def compare_graph(series, title, subtitle=''): """Rebased % change lines for 2-6 tickers. series: list of (label, pandas Series of closes), already cropped to the period. Returns PNG bytes (1280x800, Telegram's photo size).""" @@ -502,6 +527,7 @@ def tinted(bg, color, amount=0.07): mix = lambda i: round(int(bg[i:i + 2], 16) * (1 - amount) + int(color[i:i + 2], 16) * amount) return '#{:02x}{:02x}{:02x}'.format(mix(1), mix(3), mix(5)) +@_drawn def hbar_chart(rows, title, subtitle='', value_fmt=None, xlim=None, session=None): """Horizontal bars that mirror the caption list, first row on top, filling the whole 1280x800 canvas. Blue for >= 0, red below. Each row's label sits at the bar's base and its value at the tip. @@ -596,6 +622,7 @@ def shorts_verdict(percent): """The more shorted, the shorter the shorts.""" return next(name for limit, name in SHORTS_VERDICTS if percent < limit) +@_drawn def shorts_chart(rows, threshold, step=5): """Short interest as a pictogram: each row is a run of cartoon shorts, one pair per `step` percent (the last pair cropped). rows: [(label, percent)], largest first. Loud on purpose and drawn from patches, since matplotlib cannot render colour emoji. @@ -711,6 +738,7 @@ def rows_chart(rows, title, subtitle='', **kw): rows = rows[:20] + rows[-20:] return hbar_chart(rows, title, subtitle, **kw) +@_drawn def pie_chart(rows, title, subtitle=''): """Pie of (label, value) rows, largest first, with a legend of name and share. At most 8 named slices (the dark categorical slots, in fixed order); a slice labelled 'Other' is gray. Returns PNG bytes (1280x800).""" diff --git a/lib/yahoo.py b/lib/yahoo.py index 236532b9..743a2978 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -232,13 +232,13 @@ def fetch_detail(ticker, seconds=UNSET): if seconds is UNSET: seconds = config_cache_seconds now = datetime.datetime.now() - cookie, crumb = credentials() cacheFile = "finbot_yahoo_detail_" + ticker + '.json' data = util.read_cache(cacheFile, seconds) if config_cache else None if data: print('.', sep=' ', end='', flush=True) else: print('↓', sep=' ', end='', flush=True) + cookie, crumb = credentials() # only on a miss: a cached detail needs neither, nor the lock modules = 'calendarEvents,defaultKeyStatistics,balanceSheetHistoryQuarterly,financialData,summaryProfile,summaryDetail,price,earnings,earningsTrend,insiderTransactions' r = _get_first(_hosts('/v10/finance/quoteSummary/' + ticker + '?modules=' + modules + '&crumb=' + crumb), {**HEADERS, 'Cookie': cookie}) if r is None: diff --git a/price.py b/price.py index 7fad0339..37be6dae 100755 --- a/price.py +++ b/price.py @@ -30,9 +30,15 @@ def gather(specific_stock=None, days=None): yahoo.prefetch_history([t for t in market_data if 'percent_change_period' not in market_data[t]]) return tickers, market_data -def trigger_bar(ticker, quote, threshold): +def needs_beta(quote): + """Whether trigger_bar must know this ticker's beta: a small cap between US$150M and US$1B, which counts as volatile when its + beta is over 1.5. The beta is only in Yahoo's details.""" + return config_demote_volatile and 1.5e8 <= quote.get('market_cap_usd', float('inf')) < 1e9 + +def trigger_bar(ticker, quote, threshold, details=None): """The move this ticker needs before it alone sends a scheduled report: more for leveraged ETFs (demote_leveraged) and volatile - small caps (demote_volatile; their market caps in US dollars), else the threshold itself.""" + small caps (demote_volatile; their market caps in US dollars), else the threshold itself. details: Yahoo's details of the + tickers that need_beta, fetched together; without it this ticker's are fetched here.""" title = quote['profile_title'].lower() if config_demote_leveraged and ('2x' in title or '3x' in title): return threshold * 1.3 @@ -42,7 +48,7 @@ def trigger_bar(ticker, quote, threshold): return threshold * config_volatility_multiplier * 1.3 if cap < 1.5e8: return threshold * config_volatility_multiplier - beta = yahoo.fetch_detail(ticker).get(ticker, {}).get('beta') + beta = (yahoo.fetch_detail(ticker) if details is None else details).get(ticker, {}).get('beta') if beta is None or beta > 1.5: return threshold * config_volatility_multiplier return threshold @@ -100,9 +106,10 @@ def report(service, threshold=UNSET, user='', specific_stock=None, interactive=F exchange = quote['profile_exchange'] exchanges.add(util.exchange_human(exchange)) rows.append((ticker, percent, f"{util.get_emoji(percent)} {quote['profile_title']} ({util.quote_link(ticker, exchange, service, days=days)})")) - triggered = triggered or abs(percent) >= trigger_bar(ticker, quote, threshold) - if not triggered: - rows = [] + if not triggered: # a scheduled report: is there a move big enough for its ticker? Small caps' betas are fetched together + details = yahoo.fetch_details([ticker for ticker, _, _ in rows if needs_beta(market_data[ticker])]) + if not any(abs(percent) >= trigger_bar(ticker, market_data[ticker], threshold, details) for ticker, percent, _ in rows): + rows = [] rows.sort(key=lambda row: row[1], reverse=True) payload = [f"{line} {util.signed_percent(round(percent))}" for _, percent, line in rows] chart_rows = [(ticker, percent) for ticker, percent, _ in rows] diff --git a/tests/common.py b/tests/common.py index 7df12171..39fc46a2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,6 +1,6 @@ """Shared fixture: points finbot at a temp cache dir and replaces every external service (Telegram, Yahoo, Sharesight, Short Man) with canned data. Nothing here touches the network.""" -import io, os, sys, tempfile, time, unittest +import collections, io, os, sys, tempfile, time, unittest import pandas as pd from unittest import mock @@ -61,6 +61,7 @@ def setUp(self): self.patch(util, 'config_cache_dir', tmp.name) self.patch(config, 'config_var_dir', tmp.name) self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) + self.patch(bot, '_updates_seen', collections.OrderedDict()) # each test starts with no update taken for m in (performance, shorts, price, cal, trades, milestone, rating, worker, reminder, telegram): self.patch(m, 'webhooks', webhook.webhooks) self.sent = [] diff --git a/tests/test_charts.py b/tests/test_charts.py index a9d33208..7c09862b 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -4,7 +4,8 @@ 3. one golden image per chart type, compared with a tolerance. Refresh after an intended restyle with: UPDATE_GOLDEN=1 python -m unittest tests.test_charts """ -import datetime, io, os, unittest +import datetime, io, os, time, unittest +import gevent from unittest import mock import numpy as np import pandas as pd @@ -112,6 +113,36 @@ def test_flat_and_tiny_price_series(self): self.check_image(util.graph(two, 'Two points', 'USD'), 'two points') +class RenderPool(unittest.TestCase): + """The bot draws charts in one native thread (util.render_off_hub), so the gevent hub keeps serving while a chart is drawn.""" + + def setUp(self): + util.render_off_hub() + self.addCleanup(setattr, util, '_render_pool', None) + + def test_charts_asked_for_at_once_all_finish(self): + jobs = [gevent.spawn(util.pie_chart, [('a', 1.0), ('b', 2.0)], f't{i}') for i in range(4)] + jobs += [gevent.spawn(util.hbar_chart, MIXED, 'T'), gevent.spawn(util.rows_chart, MIXED, 'T')] + gevent.joinall(jobs, timeout=120) + self.assertTrue(all(job.successful() for job in jobs), 'a chart did not finish (several native threads deadlock matplotlib)') + self.assertTrue(all(decode(job.value).size == (1280, 800) for job in jobs)) + + def test_the_hub_keeps_serving_while_a_chart_is_drawn(self): + gaps, done = [], [] + def ticker(): + last = time.monotonic() + while not done: + gevent.sleep(0.01) + now = time.monotonic() + gaps.append(now - last) + last = now + tick = gevent.spawn(ticker) + util.shorts_chart([(f'T{i}', 60.0 - 2 * i) for i in range(24)], 15) # about a second of drawing + done.append(True) + tick.join() + self.assertLess(max(gaps), 0.5) # drawn in the hub, the ticker stopped for the whole chart + + class Contracts(unittest.TestCase): def capture_bars(self, rows, **kwargs): calls = [] diff --git a/tests/test_datadir.py b/tests/test_datadir.py index 417b4b05..f59b65af 100644 --- a/tests/test_datadir.py +++ b/tests/test_datadir.py @@ -1,4 +1,5 @@ -import os, shutil, subprocess, sys, tempfile, unittest +import os, shutil, subprocess, sys, tempfile, time, unittest +from unittest import mock from pathlib import Path from lib import config from util import sync_defaults @@ -51,6 +52,16 @@ def test_data_file_override_and_fallback(self): Path(config.config_var_dir, name).write_text('{}') self.assertEqual(config.data_file(name), config.config_var_dir + '/' + name) + def test_a_data_file_is_parsed_again_only_when_it_changes(self): + path = Path(config.config_var_dir, 'finbot_adr.json') + path.write_text('{"A": "B"}') + self.assertEqual(config.data_json('finbot_adr.json'), {'A': 'B'}) + with mock.patch('json.load', side_effect=AssertionError('parsed again')): + self.assertEqual(config.data_json('finbot_adr.json'), {'A': 'B'}) + path.write_text('{"A": "C"}') + os.utime(path, (time.time() + 5, time.time() + 5)) # a later modification time + self.assertEqual(config.data_json('finbot_adr.json'), {'A': 'C'}) + def test_sync_defaults(self): self.assertIn(('finbot_adr.json', 'would copy'), sync_defaults.sync()) self.assertFalse(Path(config.config_var_dir, 'finbot_adr.json').exists()) # dry run diff --git a/tests/test_edges.py b/tests/test_edges.py index 7c9c28b3..76c06df8 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -212,6 +212,15 @@ def test_telegram_edge_updates(self): odd = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'new_chat_members': []}} self.assertIn(b'Unhandled', self.request(body=odd, headers=secret)[0]) + def test_a_telegram_update_sent_again_runs_once(self): + calls = [] + self.patch(worker, 'process_request', lambda *a: calls.append(a)) + body = {'update_id': 7, 'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.price'}} + for _ in range(2): # Telegram sends an update again when it got no answer + self.assertEqual(self.request(body=body, headers=self.SECRET)[1], '200 OK') + gevent.sleep(0.05) + self.assertEqual(len(calls), 1) + def test_button_presses_are_logged(self): calls = [] self.patch(worker, 'process_callback', lambda *a: calls.append(a)) @@ -234,8 +243,8 @@ def setUp(self): self.requests = [] self.patch(worker, 'process_request', lambda *a: self.requests.append(a)) - def event(self, **event): - body = json.dumps({'token': 'stok', 'type': 'event_callback', 'authorizations': [{'user_id': 'UBOT'}], + def event(self, event_id=None, **event): + body = json.dumps({'token': 'stok', 'type': 'event_callback', 'authorizations': [{'user_id': 'UBOT'}], 'event_id': event_id, 'event': dict({'type': 'message', 'channel': 'C1', 'ts': '1.2'}, **event)}).encode() status = [] with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): @@ -258,6 +267,12 @@ def test_only_people_are_answered(self): self.assertEqual([(r[0], r[1], r[2], r[3], r[4]) for r in self.requests], [('slack', 'C1', '<@U1>', '.price @UBOT', '@UBOT'), ('slack', 'C1', '<@U2>', '.trades', '@UBOT')]) + def test_a_retried_event_runs_once(self): + for _ in range(2): # Slack resends an event it got no answer to within 3 seconds, with the same event_id + self.assertEqual(self.event('Ev1', user='U1', text='.price'), '200 OK') + self.assertEqual(self.event('Ev2', user='U1', text='.price'), '200 OK') + self.assertEqual(len(self.requests), 2) + def test_slack_does_not_change_the_telegram_bot_name(self): self.patch(bot, 'botName', '@finbot_tg') self.event(user='U1', text='.price') diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 7526ca57..e95b1b7e 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -630,6 +630,14 @@ def test_scheduler_reports_a_child_that_ended_without_saying_why(self): self.assertEqual(self.errors, [f'⚠️ trades.py: RuntimeError: {reason}'] if reason else []) self.assertEqual(s.busy, set()) + def test_a_report_script_loads_neither_pandas_nor_the_scheduler(self): + """A job process imports only its own script: price used to pull in pandas and, through charts, reports and jobs, every + script there is.""" + code = 'import sys, price, performance, trades, shorts, cal; print(sorted(m for m in ("pandas", "lib.jobs", "matplotlib") if m in sys.modules))' + with tempfile.TemporaryDirectory() as data: + out = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, env={**os.environ, 'DATA_DIR': data}) + self.assertEqual(out.stdout.strip().splitlines()[-1:], ['[]'], out.stderr) + def test_the_event_loop_keeps_running_while_a_child_works(self): self.sleeper(1) ticks = [] diff --git a/tests/test_providers.py b/tests/test_providers.py index b7084738..5f718171 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -117,6 +117,14 @@ def test_parses_real_quote_summary(self): self.assertEqual(data['profile_title'], 'NVIDIA') self.assertTrue(data['profile_bio']) + def test_a_cached_detail_needs_no_cookie(self): + self.http(yahoo, Resp(self.combined())) + yahoo.fetch_detail('NVDA') + def refuse(): + raise AssertionError('a cached detail took the Yahoo auth lock') + self.patch(yahoo, 'credentials', refuse) + self.assertIn('NVDA', yahoo.fetch_detail('NVDA')) + def test_http_failure_gives_empty(self): for failure in (Resp(status=404), ConnectionError('down')): with self.subTest(failure=failure): diff --git a/tests/test_reports.py b/tests/test_reports.py index 516db938..d668ff3f 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -100,6 +100,17 @@ def test_a_small_cap_quoted_in_won_needs_the_bigger_move_too(self): self.assertAlmostEqual(quote['market_cap_usd'], 1e11 / 1380.0) # US$72M self.assertEqual(price.trigger_bar('T1', quote, 5), 5 * price.config_volatility_multiplier) + def test_the_small_caps_betas_are_fetched_together(self): + """A scheduled price report asks for the beta of each small cap that moved (US$150M to US$1B): together, not one by one.""" + calls = [] + self.patch(yahoo, 'fetch_details', lambda tickers, strict=False: calls.append(sorted(tickers)) or {t: market_entry(t) for t in tickers}) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: self.fail('a beta fetched on its own')) + self.patch(price, 'config_demote_volatile', True) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'market_cap': 5e8} for t in ts}) + price.lambda_handler() + self.assertEqual(len(calls), 1) + self.assertTrue(calls[0]) + def test_forward_pe_lists_need_as_many_analysts_as_buy(self): """A forward P/E is analysts' estimate: with few of them it says little, as a perfect 1.0 buy from two brokers does.""" analysts = {'T1': 7, 'T2': 6, 'T3': 12, 'T4': 2} From 8e0989333501379d423065cc15d62839d702d34c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:11:15 +0000 Subject: [PATCH 05/81] Discord: the native webhook, with charts, masked links and no link previews; reports split by size as well as lines - Discord reports go to the webhook's own endpoint instead of its Slack-compatible one (a /slack suffix in finbot.env is dropped): flags=4 turns link previews off (the README's "garbage link previews" limitation is gone), mentions ping nobody, and ?wait=true makes Discord answer 200 instead of an empty 204. - Scheduled reports to Discord carry their charts, uploaded with the report as the message (up to Discord's 2,000 characters). - Links on Discord are masked links, [text](), with the brackets and parentheses that would end one escaped. - A long report is split into messages of at most chunk_maxlines lines and the service's character limit, whichever comes first: twenty linked lines made a 3,000-character Discord message, which Discord refuses. - README: the Discord setup and the chat-networks table say what the webhook now does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- README.md | 14 ++++---- lib/config.py | 2 +- lib/util.py | 18 ++++++---- lib/webhook.py | 79 ++++++++++++++++++++++++++++++++++------- tests/test_config.py | 5 +++ tests/test_finbot.py | 7 ++++ tests/test_providers.py | 45 +++++++++++++++++++++-- tests/test_reports.py | 5 +-- 8 files changed, 143 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 40eab733..08764927 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ What finbot can do on each network. Telegram takes everything; the others take w | Chat commands | ✓ | ✓ | ✗ (a webhook only posts) | | "typing…" while a command works | ✓ | ✗ (Slack has no typing notice for bots) | ✗ | | Charts in replies | ✓ | ✓ with `slackBotToken` | n/a | -| Charts in scheduled reports | ✓ | ✗ (the incoming webhook takes text only) | ✗ | +| Charts in scheduled reports | ✓ | ✗ (the incoming webhook takes text only) | ✓ | | Period and job buttons | ✓ | ✗ | ✗ | -| No link previews | ✓ | ✓ | ✗ (set `hyperlink=False`) | +| No link previews | ✓ | ✓ | ✓ | | Linked tickers | ✓ | ✓ | ✓ | | Charts updated in place by their buttons | ✓ | ✗ | ✗ | @@ -541,12 +541,13 @@ sharesight_client_secret=01d692d4de7 samplefc64bc2e2f01d692d4de72986ea808f6e9981 ``` ### Discord -Discord support is currently only for trade notifications and scheduled reports. -The Discord webhook can be provisioned in Discord under _Server Settings > Integrations > Webhooks_. +A Discord webhook takes the trade notifications and scheduled reports, with their charts attached and without link previews. +Provision it in Discord under _Server Settings > Integrations > Webhooks_. Example: ``` discord_webhook=https://discord.com/api/webhooks/1009998000000000000/aaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbb ``` +A URL ending in `/slack`, from older setups, works as it is: finbot uses Discord's own endpoint either way. ### Slack A Slack webhook can be provisioned by creating a new app from scratch at https://api.slack.com/apps/ then navigating to _Incoming Webhooks_. Once the link is generated, add it to `finbot.env`. Example: @@ -608,7 +609,7 @@ While `bot.py` is running with Sharesight configured and `cache=True` (a reload ### Hyperlinks These `preferences.conf` options relate to hyperlinks: -* `hyperlink`, if set to `False`, disables all hyperlinks. Note: it can not disable syntax highlighting and false positives by chat clients that try to detect domains. Setting it to `False` can be useful for chat networks like Discord that can't easily disable link previews. Defaults to `True`. +* `hyperlink`, if set to `False`, disables all hyperlinks. Note: it can not disable syntax highlighting and false positives by chat clients that try to detect domains. Defaults to `True`. ``` hyperlink=True ``` @@ -750,9 +751,6 @@ You can now monitor the bot's stderr with : journalctl -fu finbot ``` -## Limitations -* Discord shows garbage link previews from Sharesight. Set `hyperlink=False`, or disable this for each Discord client under _Settings > Text & Images > Embeds and link previews_ (must be repeated for each participating Discord client). - ## Suggestions * Is my code is doing something the hard way? * Something important is missing from this README? diff --git a/lib/config.py b/lib/config.py index 3ec3e88b..e7e3259c 100644 --- a/lib/config.py +++ b/lib/config.py @@ -184,7 +184,7 @@ def startup_values(getenv): if getenv('slack_webhook'): hooks['slack'] = getenv('slack_webhook').rstrip('/') if getenv('discord_webhook'): - hooks['discord'] = getenv('discord_webhook').rstrip('/').removesuffix('/slack') + '/slack' + hooks['discord'] = getenv('discord_webhook').rstrip('/').removesuffix('/slack') # the native endpoint, not the Slack-compatible one if getenv('telegramBotToken'): hooks['telegram'] = 'https://api.telegram.org/bot' + getenv('telegramBotToken').rstrip('/') + '/' values['webhooks'] = hooks diff --git a/lib/util.py b/lib/util.py index 7a7f6d47..8a65736a 100644 --- a/lib/util.py +++ b/lib/util.py @@ -199,13 +199,17 @@ def security_line(flag, title, link): return f"{flag} {title} ({link})" if flag else f"{title} ({link})" def link(url, text, service='telegram'): - if service == 'telegram' and config_hyperlink: - hyperlink = '' + text + '' - elif service in {'discord', 'slack'} and config_hyperlink: - hyperlink = '<' + url + '|' + text + '>' - else: - hyperlink = text - return hyperlink + """text linking to url in the service's markup: HTML on Telegram, on Slack, a masked link on Discord (the angle + brackets keep its preview away; brackets in the text and parentheses in the url would end it early).""" + if not config_hyperlink: + return text + if service == 'telegram': + return '' + text + '' + if service == 'slack': + return '<' + url + '|' + text + '>' + if service == 'discord': + return '[' + text.replace('[', '\\[').replace(']', '\\]') + '](<' + url.replace('(', '%28').replace(')', '%29') + '>)' + return text def finance_link(symbol, exchange, service='telegram', days=1, brief=True, text=None): if config_hyperlinkProvider == 'google': diff --git a/lib/webhook.py b/lib/webhook.py index 3dc706ab..ba5ce1bc 100644 --- a/lib/webhook.py +++ b/lib/webhook.py @@ -3,7 +3,6 @@ import time import requests import re -from itertools import batched import sys import traceback @@ -11,10 +10,21 @@ from lib import net from lib import telegram +DISCORD_LIMIT = 2000 # characters in a Discord message +SUPPRESS_EMBEDS = 4 # Discord message flag: no link previews +NO_MENTIONS = {'parse': []} # Discord: text that looks like @everyone or a mention pings nobody + +def discord_url(url): + """A Discord webhook URL that answers with the message posted (200) instead of an empty 204.""" + return url + ('&' if '?' in url else '?') + 'wait=true' + def write(service, url, payload_string, chat_id=None, reply_markup=None): headers = {'Content-type': 'application/json'} payload = {'text': payload_string} - if 'slack.com' in url: + if service == 'discord': # the native webhook endpoint: Discord's Slack-compatible one takes no flags + payload = {'content': payload_string, 'flags': SUPPRESS_EMBEDS, 'allowed_mentions': NO_MENTIONS} + url = discord_url(url) + elif 'slack.com' in url: payload['unfurl_links'] = False # no link previews: Slack reads these from the body, not the headers payload['unfurl_media'] = False if chat_id: @@ -130,11 +140,11 @@ def payload_wrapper(service, url, payload, chat_id=None, reply_markup=None): payload_string = '\n'.join(payload) print("Preparing outbound to", service, str(len(payload_string)), "bytes") print("Payload: " + payload_string) if debug else None - limit = 2000 if service == 'discord' else 4096 + limit = DISCORD_LIMIT if service == 'discord' else 4096 if len(payload_string) <= limit: return [write(service, url, payload_string, chat_id, reply_markup)] print(service, f"payload is over {limit:,} bytes. Splitting.") - chunks = list(batched(payload, config_chunk_maxlines)) + chunks = chunked(payload, limit, config_chunk_maxlines) results = [] for idx, chunk in enumerate(chunks): if idx: @@ -143,6 +153,22 @@ def payload_wrapper(service, url, payload, chat_id=None, reply_markup=None): results.append(write(service, url, '\n'.join(chunk), chat_id, reply_markup=reply_markup if last else None)) return results +def chunked(lines, limit, max_lines): + """The lines in messages of at most max_lines lines and limit characters each, whichever comes first; a line longer than limit + on its own is cut short.""" + chunks, current, size = [], [], 0 + for line in lines: + if len(line) > limit: + line = line[:limit - 1] + '…' + if current and (len(current) >= max_lines or size + 1 + len(line) > limit): + chunks.append(current) + current, size = [], 0 + size += len(line) + (1 if current else 0) + current.append(line) + if current: + chunks.append(current) + return chunks + MARKUP = { # how each service marks text up: style -> service -> (before, after) 'bold': {'telegram': ('', ''), 'slack': ('*', '*'), 'discord': ('**', '**')}, 'italic': {'telegram': ('', ''), 'slack': ('_', '_'), 'discord': ('_', '_')}, @@ -172,10 +198,12 @@ def strike(message, service): return _styled('strike', message, service) -def fit_caption(caption, limit=1024): - """Telegram rejects photo captions over 1024 visible characters. Keep whole lines that fit and say how many were left out.""" - def visible(text): - return len(re.sub(r'<[^>]+>', '', text)) +def fit_caption(caption, limit=1024, visible=None): + """Telegram rejects photo captions over 1024 visible characters; Discord takes 2,000 characters, markup included + (visible=len). Keep whole lines that fit and say how many were left out.""" + if visible is None: + def visible(text): + return len(re.sub(r'<[^>]+>', '', text)) if visible(caption) <= limit: return caption lines = caption.split('\n') @@ -191,10 +219,10 @@ def visible(text): MAYBE_SENT = object() # a read timeout on the request that posts a chart: it may have arrived, so no text copy follows def takes_charts(service, reply=True): - """Whether a report to `service` can carry a chart image: never with the graph preference off; on Telegram always; on Slack in - a reply to a chat (`reply`), uploaded into its channel with the bot token, but not in a scheduled report, which goes through the - incoming webhook and takes text only; never on Discord. Elsewhere a report goes out as text.""" - return config_graph and (service == 'telegram' or (service == 'slack' and reply and bool(config_slackBotToken))) + """Whether a report to `service` can carry a chart image: never with the graph preference off; on Telegram and Discord always; + on Slack in a reply to a chat (`reply`), uploaded into its channel with the bot token, but not in a scheduled report, which goes + through the incoming webhook and takes text only. Elsewhere a report goes out as text.""" + return config_graph and (service in ('telegram', 'discord') or (service == 'slack' and reply and bool(config_slackBotToken))) def telegram_photo(chat_id, image_data, caption, reply_markup=None): """sendPhoto, the report as its caption (trimmed to fit). Telegram's answer, MAYBE_SENT, or None if it was refused.""" @@ -227,6 +255,31 @@ def telegram_photo(chat_id, image_data, caption, reply_markup=None): print(r.status_code, "error telegram sendPhoto", output.get('error_code'), output.get('description') or r.text[:300], file=sys.stderr) return None +def discord_photo(image_data, caption): + """The chart on the Discord webhook, with the report as its message (trimmed to Discord's 2,000 characters), no link previews. + Discord's answer, MAYBE_SENT, or None if it was refused.""" + if hasattr(image_data, 'seek'): + image_data.seek(0) + payload = {'content': fit_caption(caption, DISCORD_LIMIT, len), 'flags': SUPPRESS_EMBEDS, 'allowed_mentions': NO_MENTIONS, + 'attachments': [{'id': 0, 'filename': 'chart.png'}]} + try: + r = net.post(discord_url(webhooks['discord']), data={'payload_json': json.dumps(payload)}, + files={'files[0]': ('chart.png', image_data, 'image/png')}, timeout=config_http_timeout) + except requests.exceptions.ReadTimeout as e: + print("Discord chart timed out, it may still arrive:", net.redact(e), file=sys.stderr) + return MAYBE_SENT + except Exception as e: + print("Discord chart failed:", net.redact(e), file=sys.stderr) + return None + if r.status_code == 200: + print(r.status_code, "OK discord chart", file=sys.stderr) + try: + return r.json() or {} + except ValueError: + return {} + print(r.status_code, "error discord chart", net.redact(r.text[:300]), file=sys.stderr) + return None + def slack_api(method, **data): """POST a Slack Web API method as the bot, form-encoded, and return its answer. Raises RuntimeError with Slack's error if not ok.""" if not config_slackBotToken: @@ -272,6 +325,8 @@ def sendPhoto(chat_id, image_data, caption, service, reply_markup=None): chartable = takes_charts(service, reply=bool(chat_id)) if chartable and service == 'telegram': output = telegram_photo(chat_id, image_data, caption, reply_markup) + elif chartable and service == 'discord': + output = discord_photo(image_data, caption) elif chartable: output = slack_photo(chat_id, image_data, caption) if output is MAYBE_SENT: diff --git a/tests/test_config.py b/tests/test_config.py index 6f9703f8..431ab57a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -335,6 +335,11 @@ def test_a_leftover_env_in_the_checkout_refuses_to_start(self): self.assertIn(f'{old} is the old single config file, which finbot no longer reads', caught.exception.errors[0]) self.assertIn('/util/split_env.py, which writes finbot.env.new and preferences.conf.new', caught.exception.errors[1]) + def test_a_discord_webhook_uses_the_native_endpoint(self): + for url in ('https://discord.com/api/webhooks/1/abc', 'https://discord.com/api/webhooks/1/abc/slack/'): # the old setup's suffix + values = config.startup_values(lambda name, default=None: url if name == 'discord_webhook' else default) + self.assertEqual(values['webhooks']['discord'], 'https://discord.com/api/webhooks/1/abc') + def test_slack_commands_without_a_bot_token_are_warned_about(self): with mock.patch.object(config, 'config_slackOutgoingWebhook', 'https://example.com/slack'): with mock.patch.object(config, 'config_slackBotToken', False): diff --git a/tests/test_finbot.py b/tests/test_finbot.py index df4c8614..5eec41e3 100644 --- a/tests/test_finbot.py +++ b/tests/test_finbot.py @@ -25,6 +25,13 @@ def post(url, **kwargs): self.assertIn('reply_markup', posted[-1]) self.assertTrue(all('reply_markup' not in p for p in posted[:-1])) + def test_chunks_respect_both_the_line_count_and_the_size(self): + chunks = webhook.chunked(['a' * 30] * 10 + ['b' * 500], limit=100, max_lines=4) + self.assertEqual([len(chunk) for chunk in chunks], [3, 3, 3, 1, 1]) # 3 x 30 plus newlines is 92; a fourth would pass 100 + self.assertTrue(all(len('\n'.join(chunk)) <= 100 for chunk in chunks)) + self.assertEqual(chunks[-1], ['b' * 99 + '…']) # a line over the limit on its own is cut short + self.assertEqual(webhook.chunked(['x'] * 9, limit=4096, max_lines=4), [['x'] * 4, ['x'] * 4, ['x']]) + def test_zoneinfo_offset_is_not_lmt(self): tz = zoneinfo.ZoneInfo('Australia/Melbourne') self.assertEqual(datetime.datetime(2022, 8, 18, tzinfo=tz).utcoffset(), datetime.timedelta(hours=10)) diff --git a/tests/test_providers.py b/tests/test_providers.py index 5f718171..6af35bda 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -514,8 +514,8 @@ def upload_steps(self): def test_where_charts_can_go(self): self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - cases = (('telegram',), ('telegram', False), ('slack',), ('slack', False), ('discord',)) - self.assertEqual([webhook.takes_charts(*case) for case in cases], [True, True, True, False, False]) # Slack: replies, not scheduled reports + cases = (('telegram',), ('telegram', False), ('slack',), ('slack', False), ('discord', False)) + self.assertEqual([webhook.takes_charts(*case) for case in cases], [True, True, True, False, True]) # Slack: replies, not scheduled reports self.patch(webhook, 'config_slackBotToken', False) self.assertFalse(webhook.takes_charts('slack')) @@ -615,6 +615,47 @@ def fake(url, **kwargs): self.assertEqual(self.sent, expect) +class DiscordWebhook(FinbotCase): + """Discord's own webhook endpoint (the Slack-compatible one takes no flags): no link previews, no pings, charts attached.""" + URL = 'https://discord.com/api/webhooks/1/abc' + + def setUp(self): + super().setUp() + self.patch(webhook, 'webhooks', {'discord': self.URL}) + self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) + self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) + self.posts = [] + self.patch(webhook.net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or Resp({'id': '1'})) + + def test_text_goes_to_the_native_endpoint_without_previews_or_pings(self): + webhook.write('discord', self.URL, 'hello @everyone') + url, kwargs = self.posts[0] + self.assertEqual(url, self.URL + '?wait=true') # without wait Discord answers 204, which counted as a failure + self.assertEqual(kwargs['json'], {'content': 'hello @everyone', 'flags': 4, 'allowed_mentions': {'parse': []}}) + + def test_a_scheduled_report_carries_its_chart(self): + price.lambda_handler() + (url, kwargs), = self.posts + self.assertEqual(url, self.URL + '?wait=true') + payload = json.loads(kwargs['data']['payload_json']) + self.assertEqual((payload['flags'], payload['allowed_mentions'], payload['attachments'][0]['filename']), (4, {'parse': []}, 'chart.png')) + self.assertTrue(payload['content'].startswith('**5% at close'), payload['content'][:60]) + self.assertLessEqual(len(payload['content']), 2000) + name, image, kind = kwargs['files']['files[0]'] + self.assertEqual((name, kind), ('chart.png', 'image/png')) + self.assertTrue(image.read().startswith(b'\x89PNG')) + + def test_a_long_report_is_split_by_size(self): + line = 'x' * 150 + webhook.payload_wrapper('discord', self.URL, [line] * 30) + sizes = [len(kwargs['json']['content']) for _, kwargs in self.posts] + self.assertGreater(len(sizes), 2) + self.assertTrue(all(size <= 2000 for size in sizes), sizes) # 20 lines a chunk used to make 3,019 characters + + def test_links_are_masked_links(self): + self.assertEqual(util.link('https://x.example/a(b)', 'T[1]', 'discord'), '[T\\[1\\]]()') + + class SlackSending(FinbotCase): """Slack replies go through chat.postMessage with the bot token; scheduled reports through the incoming webhook.""" diff --git a/tests/test_reports.py b/tests/test_reports.py index d668ff3f..49c06a3d 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -421,8 +421,9 @@ def test_unknown_or_missing_portfolio_lists_known(self): self.assertTrue(sent[0][1][0].startswith(first), text) self.assertIn('Rob', sent[0][1]) - def test_text_only_service(self): - worker.Request('discord', '1', '@u', 'U', BOT).deliver(['x'], {'pie': True, 'rows': [('a', 1.0)], 'title': 't'}) + def test_a_service_that_takes_no_images_gets_text(self): + self.patch(webhook, 'config_slackBotToken', False) # Slack replies carry charts only with the bot token + worker.Request('slack', 'C1', '@u', 'U', BOT).deliver(['x'], {'pie': True, 'rows': [('a', 1.0)], 'title': 't'}) self.assertEqual(self.sent[-1][0], 'text') From e8b635df818bc5d8e0e227e2713d543cf5d03410 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:13:02 +0000 Subject: [PATCH 06/81] Tabs everywhere: the tests, shortman.py and sws_gen.py re-indented; .editorconfig A whitespace-only change (git diff -w shows nothing but .editorconfig, .gitignore and CLAUDE.md): the 4-space files now indent with tabs like the rest of the code. Lines inside multi-line strings that are data were left exactly as they were. One style means an edit's quoted indentation always matches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- .editorconfig | 14 + .gitignore | 1 + CLAUDE.md | 2 +- lib/shortman.py | 48 +- tests/common.py | 142 ++-- tests/test_charts.py | 508 +++++++------- tests/test_edges.py | 748 ++++++++++---------- tests/test_finbot.py | 176 ++--- tests/test_jobs.py | 1232 ++++++++++++++++----------------- tests/test_providers.py | 1428 +++++++++++++++++++-------------------- tests/test_reports.py | 812 +++++++++++----------- tests/test_util.py | 264 ++++---- util/sws_gen.py | 84 +-- 13 files changed, 2737 insertions(+), 2722 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1951c484 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.py] +indent_style = tab +indent_size = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index 4e27a261..dea55888 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .* !.gitignore +!.editorconfig !.python-version !.claude !.github diff --git a/CLAUDE.md b/CLAUDE.md index 388aa1cb..b0ce1f7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm ## Conventions - A report is a list of lines. Markup through `webhook.bold/italic(text, service)`, user-written text through `webhook.escape(text, service)`, links through `util.link`/`util.finance_link`. -- Tabs in code; in tests match the file. New and changed lines stay under 120 characters (an edit quotes whole lines). +- Tabs everywhere, tests included (`.editorconfig`). New and changed lines stay under 120 characters (an edit quotes whole lines). - stdout is discarded under systemd (`StandardOutput=null`): anything worth logging goes to stderr. - A chart renderer in `lib/util.py` gets `@_drawn` (the bot draws in one native thread); it must not call another `@_drawn` function, or that thread waits on itself. diff --git a/lib/shortman.py b/lib/shortman.py index 46ca8727..e1f1a4c4 100644 --- a/lib/shortman.py +++ b/lib/shortman.py @@ -3,27 +3,27 @@ from lib import net def fetch(market_data): - local_market_data = market_data.copy() - print("Fetching ASX shorts from Shortman") - url = 'https://www.shortman.com.au/downloadeddata/latest.csv' - try: - r = net.get(url, timeout=config_http_timeout) - except Exception as e: - print("Failure fetching", url, net.redact(e), file=sys.stderr) - return market_data # the ASX short interest is missing, the rest of the report is not - if r.status_code == 200: - print(r.status_code, "success shortman") - else: - print(r.status_code, "error communicating with", url, file=sys.stderr) - return market_data - csv = r.content.decode('utf-8') - csv = csv.split('\r\n') - csv.pop(0) # remove header - del csv[-1] # remove junk - for line in csv: - cells = line.split(',') # title, code, positions, on issue, percent shorted - ticker = cells[1] + '.AX' - short_percent = float(cells[4]) # every row, so a malformed one still fails the fetch - if ticker in market_data: - local_market_data[ticker]['short_percent'] = short_percent - return local_market_data + local_market_data = market_data.copy() + print("Fetching ASX shorts from Shortman") + url = 'https://www.shortman.com.au/downloadeddata/latest.csv' + try: + r = net.get(url, timeout=config_http_timeout) + except Exception as e: + print("Failure fetching", url, net.redact(e), file=sys.stderr) + return market_data # the ASX short interest is missing, the rest of the report is not + if r.status_code == 200: + print(r.status_code, "success shortman") + else: + print(r.status_code, "error communicating with", url, file=sys.stderr) + return market_data + csv = r.content.decode('utf-8') + csv = csv.split('\r\n') + csv.pop(0) # remove header + del csv[-1] # remove junk + for line in csv: + cells = line.split(',') # title, code, positions, on issue, percent shorted + ticker = cells[1] + '.AX' + short_percent = float(cells[4]) # every row, so a malformed one still fails the fetch + if ticker in market_data: + local_market_data[ticker]['short_percent'] = short_percent + return local_market_data diff --git a/tests/common.py b/tests/common.py index 39fc46a2..915e647b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,7 @@ import performance, shorts, price, reminder, cal, trades, milestone, rating REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), - (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper'))} + (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper'))} REAL_GET_HOLDINGS = util.get_holdings_and_watchlist REAL_PAYLOAD_WRAPPER = webhook.payload_wrapper # FinbotCase replaces it; tests of the real thing use this REAL_REPORT_ERROR = webhook.report_error # likewise @@ -24,86 +24,86 @@ def skip_if_old_env(case): - """For the tests that start bot.py: it will not start while the checkout has a leftover .env (config.check_startup_environment).""" - if config.OLD_ENV.exists(): - case.skipTest(f'bot.py will not start while {config.OLD_ENV} exists') + """For the tests that start bot.py: it will not start while the checkout has a leftover .env (config.check_startup_environment).""" + if config.OLD_ENV.exists(): + case.skipTest(f'bot.py will not start while {config.OLD_ENV} exists') STATES = ('REGULAR', 'PRE', 'POST', 'CLOSED') def market_entry(ticker): - i = int(ticker[1:]) if ticker[1:].isdigit() else 0 # varied data so threshold/sort/sell branches all have rows - return {'marketState': STATES[i % 4], 'regularMarketTime': time.time(), 'quoteType': 'EQUITY', - 'profile_title': 'Co ' + ticker, 'profile_exchange': 'NasdaqGS', 'percent_change': (i - 12) * 1.5, 'percent_change_premarket': (i - 12) * 1.5, 'percent_change_postmarket': (i - 12) * 1.5, - 'market_cap': 1e9, 'price_to_earnings_trailing': 9, 'price_to_earnings_forward': 9, - 'beta': 2.0, 'short_percent': 20, 'fiftyTwoWeekHigh': 120.0, 'fiftyTwoWeekLow': 80.0, - 'regularMarketPrice': 100.0, 'earnings_date': time.time() + 86400 * 3, - 'ex_dividend_date': time.time() + 86400 * 3, 'recommend': 'buy' if i % 2 else 'sell', 'recommend_index': 1.2 if i % 2 else 4.2, - 'recommend_analysts': 8, 'price_to_earnings_peg': 1.2, 'currency': 'USD'} + i = int(ticker[1:]) if ticker[1:].isdigit() else 0 # varied data so threshold/sort/sell branches all have rows + return {'marketState': STATES[i % 4], 'regularMarketTime': time.time(), 'quoteType': 'EQUITY', + 'profile_title': 'Co ' + ticker, 'profile_exchange': 'NasdaqGS', 'percent_change': (i - 12) * 1.5, 'percent_change_premarket': (i - 12) * 1.5, 'percent_change_postmarket': (i - 12) * 1.5, + 'market_cap': 1e9, 'price_to_earnings_trailing': 9, 'price_to_earnings_forward': 9, + 'beta': 2.0, 'short_percent': 20, 'fiftyTwoWeekHigh': 120.0, 'fiftyTwoWeekLow': 80.0, + 'regularMarketPrice': 100.0, 'earnings_date': time.time() + 86400 * 3, + 'ex_dividend_date': time.time() + 86400 * 3, 'recommend': 'buy' if i % 2 else 'sell', 'recommend_index': 1.2 if i % 2 else 4.2, + 'recommend_analysts': 8, 'price_to_earnings_peg': 1.2, 'currency': 'USD'} class FinbotCase(unittest.TestCase): - """Base class: self.sent collects ('photo'|'text', ...) tuples for everything the bot tried to send.""" + """Base class: self.sent collects ('photo'|'text', ...) tuples for everything the bot tried to send.""" - def patch(self, target, attr, value): - p = mock.patch.object(target, attr, value) - p.start() - self.addCleanup(p.stop) + def patch(self, target, attr, value): + p = mock.patch.object(target, attr, value) + p.start() + self.addCleanup(p.stop) - def setUp(self): - fake = mock.Mock(status_code=200, text='{}') - fake.json.return_value = {'ok': True, 'result': {'message_id': 1, 'chat': {'id': 5}}} - self.patch(__import__('requests').Session, 'request', mock.Mock(return_value=fake)) # no real network traffic - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.patch(util, 'config_var_dir', tmp.name) - self.patch(util, 'config_cache_dir', tmp.name) - self.patch(config, 'config_var_dir', tmp.name) - self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) - self.patch(bot, '_updates_seen', collections.OrderedDict()) # each test starts with no update taken - for m in (performance, shorts, price, cal, trades, milestone, rating, worker, reminder, telegram): - self.patch(m, 'webhooks', webhook.webhooks) - self.sent = [] - self.markups = [] # reply_markup of each photo sent - self.errors = [] - self.patch(webhook, 'report_error', lambda e, *a, **k: self.errors.append(webhook.error_line(e, k.get('context')))) - self.patch(webhook, 'sendPhoto', lambda chat, img, cap, svc, **k: (self.sent.append(('photo', len(img.read()), cap.split('\n')[0])), self.markups.append(k.get('reply_markup')))) - self.patch(webhook, 'payload_wrapper', lambda svc, url, payload, *a, **k: self.sent.append(('text', payload)) or []) - self.patch(util, 'get_holdings_and_watchlist', lambda: TICKERS) - self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)}) - self.patch(yahoo, 'price_history', lambda t, days=None, **k: ({days: 1.5} if days else {'Max': 5, '1Y': 3}, None)) - self.patch(yahoo, 'prefetch_history', lambda ts: None) - self.patch(yahoo, 'historic_high', lambda t: (100.0, 50.0)) - dates = pd.date_range(end=pd.Timestamp.today().normalize(), periods=400) - self.patch(yahoo, 'price_series', lambda t: ('Co ' + t, 'NASDAQ', pd.Series(range(100, 500), index=dates, name=t, dtype=float))) - self.patch(shortman, 'fetch', lambda md: md) - holdings = [{'id': i, 'instrument': {'code': t, 'market_code': 'NASDAQ'}, 'portfolio': {'name': 'Rob'}, - 'capital_gain_percent': str(i * 4 - 40), 'value': '1000'} for i, t in enumerate(TICKERS)] - self.patch(sharesight, 'get_performance_wrapper', lambda d: {1: {'report': {'holdings': holdings}}}) - self.patch(sharesight, 'get_portfolios', lambda: {'Rob': 1}) - self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': { - 'holdings': holdings, 'currency_gain_percent': '0', 'capital_gain_percent': '2.5', 'total_gain_percent': '0'}}) - self.patch(sharesight, 'get_trades', lambda name, pid, days=7: [{ - 'id': 1, 'transaction_type': 'BUY', 'symbol': 'T1', 'market': 'NASDAQ', 'portfolio': name, - 'transaction_date': time.strftime('%Y-%m-%d'), 'quantity': '10', 'price': '5.0', 'value': '50', 'brokerage_currency_code': 'USD', 'holding_id': 7}]) - self.typing = [] # every typing indicator a test starts; none may still be running when the test ends - real_start = worker.TypingIndicator.start - self.patch(worker.TypingIndicator, 'start', lambda indicator: (self.typing.append(indicator), real_start(indicator))) - self.addCleanup(self.no_typing_left) + def setUp(self): + fake = mock.Mock(status_code=200, text='{}') + fake.json.return_value = {'ok': True, 'result': {'message_id': 1, 'chat': {'id': 5}}} + self.patch(__import__('requests').Session, 'request', mock.Mock(return_value=fake)) # no real network traffic + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.patch(util, 'config_var_dir', tmp.name) + self.patch(util, 'config_cache_dir', tmp.name) + self.patch(config, 'config_var_dir', tmp.name) + self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) + self.patch(bot, '_updates_seen', collections.OrderedDict()) # each test starts with no update taken + for m in (performance, shorts, price, cal, trades, milestone, rating, worker, reminder, telegram): + self.patch(m, 'webhooks', webhook.webhooks) + self.sent = [] + self.markups = [] # reply_markup of each photo sent + self.errors = [] + self.patch(webhook, 'report_error', lambda e, *a, **k: self.errors.append(webhook.error_line(e, k.get('context')))) + self.patch(webhook, 'sendPhoto', lambda chat, img, cap, svc, **k: (self.sent.append(('photo', len(img.read()), cap.split('\n')[0])), self.markups.append(k.get('reply_markup')))) + self.patch(webhook, 'payload_wrapper', lambda svc, url, payload, *a, **k: self.sent.append(('text', payload)) or []) + self.patch(util, 'get_holdings_and_watchlist', lambda: TICKERS) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)}) + self.patch(yahoo, 'price_history', lambda t, days=None, **k: ({days: 1.5} if days else {'Max': 5, '1Y': 3}, None)) + self.patch(yahoo, 'prefetch_history', lambda ts: None) + self.patch(yahoo, 'historic_high', lambda t: (100.0, 50.0)) + dates = pd.date_range(end=pd.Timestamp.today().normalize(), periods=400) + self.patch(yahoo, 'price_series', lambda t: ('Co ' + t, 'NASDAQ', pd.Series(range(100, 500), index=dates, name=t, dtype=float))) + self.patch(shortman, 'fetch', lambda md: md) + holdings = [{'id': i, 'instrument': {'code': t, 'market_code': 'NASDAQ'}, 'portfolio': {'name': 'Rob'}, + 'capital_gain_percent': str(i * 4 - 40), 'value': '1000'} for i, t in enumerate(TICKERS)] + self.patch(sharesight, 'get_performance_wrapper', lambda d: {1: {'report': {'holdings': holdings}}}) + self.patch(sharesight, 'get_portfolios', lambda: {'Rob': 1}) + self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': { + 'holdings': holdings, 'currency_gain_percent': '0', 'capital_gain_percent': '2.5', 'total_gain_percent': '0'}}) + self.patch(sharesight, 'get_trades', lambda name, pid, days=7: [{ + 'id': 1, 'transaction_type': 'BUY', 'symbol': 'T1', 'market': 'NASDAQ', 'portfolio': name, + 'transaction_date': time.strftime('%Y-%m-%d'), 'quantity': '10', 'price': '5.0', 'value': '50', 'brokerage_currency_code': 'USD', 'holding_id': 7}]) + self.typing = [] # every typing indicator a test starts; none may still be running when the test ends + real_start = worker.TypingIndicator.start + self.patch(worker.TypingIndicator, 'start', lambda indicator: (self.typing.append(indicator), real_start(indicator))) + self.addCleanup(self.no_typing_left) - def no_typing_left(self): - left = [indicator for indicator in self.typing if indicator.is_active()] - for indicator in left: - indicator.stop() - self.assertEqual(left, [], 'a typing indicator was left running') + def no_typing_left(self): + left = [indicator for indicator in self.typing if indicator.is_active()] + for indicator in left: + indicator.stop() + self.assertEqual(left, [], 'a typing indicator was left running') - def command(self, text, chat='55'): - """Run a chat message through the real command parser and handlers.""" - self.sent.clear() - self.markups.clear() - self.errors.clear() - worker.process_request('telegram', chat, '@u', text, BOT, 'U') - self.assertEqual(self.errors, [], f'{text} crashed') # the worker reports crashes instead of raising - return list(self.sent) + def command(self, text, chat='55'): + """Run a chat message through the real command parser and handlers.""" + self.sent.clear() + self.markups.clear() + self.errors.clear() + worker.process_request('telegram', chat, '@u', text, BOT, 'U') + self.assertEqual(self.errors, [], f'{text} crashed') # the worker reports crashes instead of raising + return list(self.sent) diff --git a/tests/test_charts.py b/tests/test_charts.py index 7c09862b..0af42bd1 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -2,7 +2,7 @@ 1. image sanity: a real 1280x800 PNG, drawn, with nothing touching the edges (no clipped text) 2. contracts: blue/red by sign, row order, titles and sort order, captured from what is handed to matplotlib 3. one golden image per chart type, compared with a tolerance. Refresh after an intended restyle with: - UPDATE_GOLDEN=1 python -m unittest tests.test_charts + UPDATE_GOLDEN=1 python -m unittest tests.test_charts """ import datetime, io, os, time, unittest import gevent @@ -24,287 +24,287 @@ def decode(buf): - buf.seek(0) - return Image.open(buf).convert('RGB') + buf.seek(0) + return Image.open(buf).convert('RGB') def price_frame(days=120): - dates = [datetime.date(2026, 1, 1) + datetime.timedelta(days=i) for i in range(days)] - closes = [100 + 12 * np.sin(i / 9) + i * 0.15 for i in range(days)] - return pd.DataFrame({'Date': dates, 'Close': closes}) + dates = [datetime.date(2026, 1, 1) + datetime.timedelta(days=i) for i in range(days)] + closes = [100 + 12 * np.sin(i / 9) + i * 0.15 for i in range(days)] + return pd.DataFrame({'Date': dates, 'Close': closes}) def compare_series(): - index = pd.date_range('2026-01-01', periods=120) - return [(name, pd.Series(100 + np.cumsum(np.sin(np.arange(120) / k) + drift), index=index)) - for name, k, drift in (('AAA', 7, 0.2), ('BBB', 11, -0.1), ('CCC', 5, 0.05))] + index = pd.date_range('2026-01-01', periods=120) + return [(name, pd.Series(100 + np.cumsum(np.sin(np.arange(120) / k) + drift), index=index)) + for name, k, drift in (('AAA', 7, 0.2), ('BBB', 11, -0.1), ('CCC', 5, 0.05))] CHARTS = { - 'hbar_mixed': lambda: util.hbar_chart(MIXED, 'Price movement 5.0%, 5 Sep - 5 Oct 2026', 'subtitle here'), - 'price_line': lambda: util.graph(price_frame(), 'Apple Inc (AAPL)', 'USD'), - 'compare_lines': lambda: util.compare_graph(compare_series(), 'Compare 120 days', 'subtitle here'), - 'shorts': lambda: util.shorts_chart([('GME', 47.3), ('AMC', 32.1), ('TSLA', 21.6), ('AAPL', 15.2)], 15), + 'hbar_mixed': lambda: util.hbar_chart(MIXED, 'Price movement 5.0%, 5 Sep - 5 Oct 2026', 'subtitle here'), + 'price_line': lambda: util.graph(price_frame(), 'Apple Inc (AAPL)', 'USD'), + 'compare_lines': lambda: util.compare_graph(compare_series(), 'Compare 120 days', 'subtitle here'), + 'shorts': lambda: util.shorts_chart([('GME', 47.3), ('AMC', 32.1), ('TSLA', 21.6), ('AAPL', 15.2)], 15), } # inputs that have broken chart code before or are likely to: sizes, signs, long names, zeros, a single row EDGE_ROWS = { - 'one_row': [('AAA', 5)], - 'one_negative': [('AAA', -5)], - 'all_positive': [(f'T{i}', i + 1) for i in range(15)], - 'all_negative': [(f'T{i}', -(i + 1)) for i in range(15)], - 'all_zero': [(f'T{i}', 0) for i in range(5)], - 'forty_rows': [(f'T{i}', (i - 20) * 1.5) for i in range(40)], - 'long_labels': [('A very long company name indeed (TICKER)', 12), ('Another equally long label here ok', -8)], - 'huge_values': [('AAA', 12345), ('BBB', -9876)], - 'tiny_values': [('AAA', 0.01), ('BBB', -0.02)], + 'one_row': [('AAA', 5)], + 'one_negative': [('AAA', -5)], + 'all_positive': [(f'T{i}', i + 1) for i in range(15)], + 'all_negative': [(f'T{i}', -(i + 1)) for i in range(15)], + 'all_zero': [(f'T{i}', 0) for i in range(5)], + 'forty_rows': [(f'T{i}', (i - 20) * 1.5) for i in range(40)], + 'long_labels': [('A very long company name indeed (TICKER)', 12), ('Another equally long label here ok', -8)], + 'huge_values': [('AAA', 12345), ('BBB', -9876)], + 'tiny_values': [('AAA', 0.01), ('BBB', -0.02)], } class ImageSanity(unittest.TestCase): - def check_image(self, buf, name, bg=BG, edge_clean=True): - image = decode(buf) - self.assertEqual(image.size, (1280, 800), name) - pixels = np.asarray(image) - self.assertGreater(len({tuple(p) for p in pixels.reshape(-1, 3)[::97]}), 8, f'{name} looks blank') - self.assertGreater(pixels.std(), 5, f'{name} looks blank') - allowed = bg if isinstance(bg[0], tuple) else (bg,) - self.assertIn(tuple(int(c) for c in pixels[0, 0]), allowed, name) - if not edge_clean: # the shorts beach draws its sea right to the edge - return - edge = np.concatenate([pixels[:3].reshape(-1, 3), pixels[-3:].reshape(-1, 3), pixels[:, :3].reshape(-1, 3), pixels[:, -3:].reshape(-1, 3)]) - self.assertTrue((edge == bg).all(), f'{name}: something is drawn on the image edge (clipped text or bars?)') - - def test_each_chart_type(self): - for name, make in CHARTS.items(): - with self.subTest(chart=name): - if name == 'shorts': - self.check_image(make(), name, bg=SHORTS_BG, edge_clean=False) - else: - self.check_image(make(), name) - - def test_edge_case_rows(self): - for name, rows in EDGE_ROWS.items(): - with self.subTest(rows=name): - self.check_image(util.hbar_chart(rows, 'Title', 'sub'), name) - self.check_image(util.rows_chart(rows, 'Title'), name + '/rows_chart') - - def test_zoomed_axis(self): - self.check_image(util.hbar_chart([('AAA', 1.2), ('BBB', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed') - self.check_image(util.hbar_chart([('WWWWWWWW', 1.2), ('MMM', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed wide labels') - self.check_image(util.hbar_chart([(f'T{i}', 1 + i / 10) for i in range(15)], 'Ratings', xlim=(0.9, 3.0)), 'zoomed 15 rows') - - def test_shorts_chart_sizes_and_verdicts(self): - for n in (1, 5, 45): - rows = [(f'T{i}', 60.0 - i) for i in range(n)] - self.check_image(util.shorts_chart(rows, 15), f'shorts {n} rows', bg=SHORTS_BG, edge_clean=False) - self.check_image(util.shorts_chart([('A VERY LONG LABEL', 15.0), ('B', 0.4)], 15), 'shorts long label and tiny value', bg=SHORTS_BG, edge_clean=False) - tiers = [(0, 'board shorts'), (4.9, 'board shorts'), (5, 'cargo shorts'), (10, 'boxers'), (15, 'gym shorts'), (20, 'cutoffs'), - (30, 'short shorts'), (40, 'daisy dukes'), (50, 'hot pants'), (60, 'speedos'), (70, 'budgie smugglers'), - (80, 'just a belt'), (90, 'never nudes'), (200, 'never nudes')] - self.assertEqual([util.shorts_verdict(p) for p, _ in tiers], [name for _, name in tiers]) - - def test_bar_graph_has_top_and_bottom(self): - self.check_image(util.bar_graph([('AAA', 9)], [('ZZZ', -9)], 'Top and bottom'), 'bar_graph') - - def test_flat_and_tiny_price_series(self): - flat = pd.DataFrame({'Date': [datetime.date(2026, 1, 1) + datetime.timedelta(days=i) for i in range(10)], 'Close': [5.0] * 10}) - self.check_image(util.graph(flat, 'Flat', 'USD'), 'flat price') - two = price_frame(2) - self.check_image(util.graph(two, 'Two points', 'USD'), 'two points') + def check_image(self, buf, name, bg=BG, edge_clean=True): + image = decode(buf) + self.assertEqual(image.size, (1280, 800), name) + pixels = np.asarray(image) + self.assertGreater(len({tuple(p) for p in pixels.reshape(-1, 3)[::97]}), 8, f'{name} looks blank') + self.assertGreater(pixels.std(), 5, f'{name} looks blank') + allowed = bg if isinstance(bg[0], tuple) else (bg,) + self.assertIn(tuple(int(c) for c in pixels[0, 0]), allowed, name) + if not edge_clean: # the shorts beach draws its sea right to the edge + return + edge = np.concatenate([pixels[:3].reshape(-1, 3), pixels[-3:].reshape(-1, 3), pixels[:, :3].reshape(-1, 3), pixels[:, -3:].reshape(-1, 3)]) + self.assertTrue((edge == bg).all(), f'{name}: something is drawn on the image edge (clipped text or bars?)') + + def test_each_chart_type(self): + for name, make in CHARTS.items(): + with self.subTest(chart=name): + if name == 'shorts': + self.check_image(make(), name, bg=SHORTS_BG, edge_clean=False) + else: + self.check_image(make(), name) + + def test_edge_case_rows(self): + for name, rows in EDGE_ROWS.items(): + with self.subTest(rows=name): + self.check_image(util.hbar_chart(rows, 'Title', 'sub'), name) + self.check_image(util.rows_chart(rows, 'Title'), name + '/rows_chart') + + def test_zoomed_axis(self): + self.check_image(util.hbar_chart([('AAA', 1.2), ('BBB', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed') + self.check_image(util.hbar_chart([('WWWWWWWW', 1.2), ('MMM', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed wide labels') + self.check_image(util.hbar_chart([(f'T{i}', 1 + i / 10) for i in range(15)], 'Ratings', xlim=(0.9, 3.0)), 'zoomed 15 rows') + + def test_shorts_chart_sizes_and_verdicts(self): + for n in (1, 5, 45): + rows = [(f'T{i}', 60.0 - i) for i in range(n)] + self.check_image(util.shorts_chart(rows, 15), f'shorts {n} rows', bg=SHORTS_BG, edge_clean=False) + self.check_image(util.shorts_chart([('A VERY LONG LABEL', 15.0), ('B', 0.4)], 15), 'shorts long label and tiny value', bg=SHORTS_BG, edge_clean=False) + tiers = [(0, 'board shorts'), (4.9, 'board shorts'), (5, 'cargo shorts'), (10, 'boxers'), (15, 'gym shorts'), (20, 'cutoffs'), + (30, 'short shorts'), (40, 'daisy dukes'), (50, 'hot pants'), (60, 'speedos'), (70, 'budgie smugglers'), + (80, 'just a belt'), (90, 'never nudes'), (200, 'never nudes')] + self.assertEqual([util.shorts_verdict(p) for p, _ in tiers], [name for _, name in tiers]) + + def test_bar_graph_has_top_and_bottom(self): + self.check_image(util.bar_graph([('AAA', 9)], [('ZZZ', -9)], 'Top and bottom'), 'bar_graph') + + def test_flat_and_tiny_price_series(self): + flat = pd.DataFrame({'Date': [datetime.date(2026, 1, 1) + datetime.timedelta(days=i) for i in range(10)], 'Close': [5.0] * 10}) + self.check_image(util.graph(flat, 'Flat', 'USD'), 'flat price') + two = price_frame(2) + self.check_image(util.graph(two, 'Two points', 'USD'), 'two points') class RenderPool(unittest.TestCase): - """The bot draws charts in one native thread (util.render_off_hub), so the gevent hub keeps serving while a chart is drawn.""" - - def setUp(self): - util.render_off_hub() - self.addCleanup(setattr, util, '_render_pool', None) - - def test_charts_asked_for_at_once_all_finish(self): - jobs = [gevent.spawn(util.pie_chart, [('a', 1.0), ('b', 2.0)], f't{i}') for i in range(4)] - jobs += [gevent.spawn(util.hbar_chart, MIXED, 'T'), gevent.spawn(util.rows_chart, MIXED, 'T')] - gevent.joinall(jobs, timeout=120) - self.assertTrue(all(job.successful() for job in jobs), 'a chart did not finish (several native threads deadlock matplotlib)') - self.assertTrue(all(decode(job.value).size == (1280, 800) for job in jobs)) - - def test_the_hub_keeps_serving_while_a_chart_is_drawn(self): - gaps, done = [], [] - def ticker(): - last = time.monotonic() - while not done: - gevent.sleep(0.01) - now = time.monotonic() - gaps.append(now - last) - last = now - tick = gevent.spawn(ticker) - util.shorts_chart([(f'T{i}', 60.0 - 2 * i) for i in range(24)], 15) # about a second of drawing - done.append(True) - tick.join() - self.assertLess(max(gaps), 0.5) # drawn in the hub, the ticker stopped for the whole chart + """The bot draws charts in one native thread (util.render_off_hub), so the gevent hub keeps serving while a chart is drawn.""" + + def setUp(self): + util.render_off_hub() + self.addCleanup(setattr, util, '_render_pool', None) + + def test_charts_asked_for_at_once_all_finish(self): + jobs = [gevent.spawn(util.pie_chart, [('a', 1.0), ('b', 2.0)], f't{i}') for i in range(4)] + jobs += [gevent.spawn(util.hbar_chart, MIXED, 'T'), gevent.spawn(util.rows_chart, MIXED, 'T')] + gevent.joinall(jobs, timeout=120) + self.assertTrue(all(job.successful() for job in jobs), 'a chart did not finish (several native threads deadlock matplotlib)') + self.assertTrue(all(decode(job.value).size == (1280, 800) for job in jobs)) + + def test_the_hub_keeps_serving_while_a_chart_is_drawn(self): + gaps, done = [], [] + def ticker(): + last = time.monotonic() + while not done: + gevent.sleep(0.01) + now = time.monotonic() + gaps.append(now - last) + last = now + tick = gevent.spawn(ticker) + util.shorts_chart([(f'T{i}', 60.0 - 2 * i) for i in range(24)], 15) # about a second of drawing + done.append(True) + tick.join() + self.assertLess(max(gaps), 0.5) # drawn in the hub, the ticker stopped for the whole chart class Contracts(unittest.TestCase): - def capture_bars(self, rows, **kwargs): - calls = [] - real = Axes.barh - def spy(ax, y, width, *a, **k): - calls.append({'y': list(y), 'width': list(width), 'color': list(k.get('color', []))}) - return real(ax, y, width, *a, **k) - with mock.patch.object(Axes, 'barh', spy): - util.hbar_chart(rows, 'T', **kwargs) - return calls[0] - - def capture_text(self, make): - texts = [] - real = Figure.text - def spy(fig, x, y, s, *a, **k): - texts.append(s) - return real(fig, x, y, s, *a, **k) - with mock.patch.object(Figure, 'text', spy): - make() - return texts - - def test_bars_are_blue_when_positive_and_red_when_negative(self): - bars = self.capture_bars([('A', 3), ('B', 0), ('C', -2)]) - self.assertEqual(bars['color'], ['#3987e5', '#3987e5', '#fb7185']) - - def test_first_row_is_on_top_and_spacers_are_skipped(self): - bars = self.capture_bars(MIXED) - self.assertEqual(bars['width'], [42, 17, 3, -5, -30]) - self.assertEqual(bars['y'], [0, 1, 2, 4, 5]) # row 3 is the blank spacer - calls = [] - real = Axes.set_ylim - with mock.patch.object(Axes, 'set_ylim', lambda ax, *a, **k: (calls.append(a), real(ax, *a, **k))[1]): - util.hbar_chart(MIXED, 'T') - self.assertTrue(any(len(a) == 2 and a[0] > a[1] for a in calls), 'y axis must be inverted so row 0 is drawn first (top)') - - def test_pixels_follow_the_sign_of_the_data(self): - def count(rows): - pixels = np.asarray(decode(util.hbar_chart(rows, 'T'))).reshape(-1, 3) - return (pixels == BLUE).all(axis=1).sum(), (pixels == RED).all(axis=1).sum() - blue, red = count([('A', 5), ('B', 3)]) - self.assertGreater(blue, 1000); self.assertEqual(red, 0) - blue, red = count([('A', -5), ('B', -3)]) - self.assertEqual(blue, 0); self.assertGreater(red, 1000) - blue, red = count([('A', 5), ('B', -3)]) - self.assertGreater(blue, 1000); self.assertGreater(red, 1000) - - def test_session_tints_the_background(self): - corner = lambda session: tuple(np.asarray(decode(util.hbar_chart(MIXED, 'T', session=session)))[2, 2][:3]) - plain, close, mid, pre = corner(None), corner('close'), corner('midsession'), corner('premarket') - self.assertEqual(plain, (0x14, 0x18, 0x1f)) - self.assertEqual(close, plain) # at close stays untinted - self.assertTrue(mid[1] > mid[0] and mid[1] > plain[1]) # green - self.assertTrue(pre[0] > plain[0] and pre[1] > plain[1] and pre[0] > pre[2] and pre[1] > pre[2]) # yellow: red and green channels both lead blue - self.assertLess(max(abs(int(a) - int(b)) for a, b in zip(pre, plain)), 20) # subtle - - def test_longer_bar_is_drawn_longer(self): - pixels = np.asarray(decode(util.hbar_chart([('A', 40), ('B', 10)], 'T'))) - blue_rows = np.where((pixels == BLUE).all(axis=2).any(axis=1))[0] - top_band, bottom_band = pixels[blue_rows[0]], pixels[blue_rows[-1]] - self.assertGreater((top_band == BLUE).all(axis=1).sum(), 2 * (bottom_band == BLUE).all(axis=1).sum()) - - def test_titles_are_drawn(self): - texts = self.capture_text(lambda: util.hbar_chart(MIXED, 'My Title', 'My subtitle')) - self.assertIn('My Title', texts) - self.assertIn('My subtitle', texts) - texts = self.capture_text(lambda: util.graph(price_frame(), 'Apple Inc (AAPL)', 'USD')) - self.assertIn('Apple Inc (AAPL)', texts) - self.assertTrue(any('over period' in t for t in texts)) - - def test_price_line_colour_follows_direction(self): - def line_colour(closes): - frame = pd.DataFrame({'Date': [datetime.date(2026, 1, 1) + datetime.timedelta(days=i) for i in range(len(closes))], 'Close': closes}) - texts = [] - real = Figure.text - with mock.patch.object(Figure, 'text', lambda fig, x, y, s, *a, **k: (texts.append((s, k.get('color'))), real(fig, x, y, s, *a, **k))[1]): - util.graph(frame, 'T', 'USD') - return next(color for s, color in texts if 'over period' in s) - self.assertEqual(line_colour([1, 2, 3]), '#34d399') # up: green - self.assertEqual(line_colour([3, 2, 1]), '#fb7185') # down: red - self.assertEqual(line_colour([2, 3, 2]), '#9aa4b2') # flat + def capture_bars(self, rows, **kwargs): + calls = [] + real = Axes.barh + def spy(ax, y, width, *a, **k): + calls.append({'y': list(y), 'width': list(width), 'color': list(k.get('color', []))}) + return real(ax, y, width, *a, **k) + with mock.patch.object(Axes, 'barh', spy): + util.hbar_chart(rows, 'T', **kwargs) + return calls[0] + + def capture_text(self, make): + texts = [] + real = Figure.text + def spy(fig, x, y, s, *a, **k): + texts.append(s) + return real(fig, x, y, s, *a, **k) + with mock.patch.object(Figure, 'text', spy): + make() + return texts + + def test_bars_are_blue_when_positive_and_red_when_negative(self): + bars = self.capture_bars([('A', 3), ('B', 0), ('C', -2)]) + self.assertEqual(bars['color'], ['#3987e5', '#3987e5', '#fb7185']) + + def test_first_row_is_on_top_and_spacers_are_skipped(self): + bars = self.capture_bars(MIXED) + self.assertEqual(bars['width'], [42, 17, 3, -5, -30]) + self.assertEqual(bars['y'], [0, 1, 2, 4, 5]) # row 3 is the blank spacer + calls = [] + real = Axes.set_ylim + with mock.patch.object(Axes, 'set_ylim', lambda ax, *a, **k: (calls.append(a), real(ax, *a, **k))[1]): + util.hbar_chart(MIXED, 'T') + self.assertTrue(any(len(a) == 2 and a[0] > a[1] for a in calls), 'y axis must be inverted so row 0 is drawn first (top)') + + def test_pixels_follow_the_sign_of_the_data(self): + def count(rows): + pixels = np.asarray(decode(util.hbar_chart(rows, 'T'))).reshape(-1, 3) + return (pixels == BLUE).all(axis=1).sum(), (pixels == RED).all(axis=1).sum() + blue, red = count([('A', 5), ('B', 3)]) + self.assertGreater(blue, 1000); self.assertEqual(red, 0) + blue, red = count([('A', -5), ('B', -3)]) + self.assertEqual(blue, 0); self.assertGreater(red, 1000) + blue, red = count([('A', 5), ('B', -3)]) + self.assertGreater(blue, 1000); self.assertGreater(red, 1000) + + def test_session_tints_the_background(self): + corner = lambda session: tuple(np.asarray(decode(util.hbar_chart(MIXED, 'T', session=session)))[2, 2][:3]) + plain, close, mid, pre = corner(None), corner('close'), corner('midsession'), corner('premarket') + self.assertEqual(plain, (0x14, 0x18, 0x1f)) + self.assertEqual(close, plain) # at close stays untinted + self.assertTrue(mid[1] > mid[0] and mid[1] > plain[1]) # green + self.assertTrue(pre[0] > plain[0] and pre[1] > plain[1] and pre[0] > pre[2] and pre[1] > pre[2]) # yellow: red and green channels both lead blue + self.assertLess(max(abs(int(a) - int(b)) for a, b in zip(pre, plain)), 20) # subtle + + def test_longer_bar_is_drawn_longer(self): + pixels = np.asarray(decode(util.hbar_chart([('A', 40), ('B', 10)], 'T'))) + blue_rows = np.where((pixels == BLUE).all(axis=2).any(axis=1))[0] + top_band, bottom_band = pixels[blue_rows[0]], pixels[blue_rows[-1]] + self.assertGreater((top_band == BLUE).all(axis=1).sum(), 2 * (bottom_band == BLUE).all(axis=1).sum()) + + def test_titles_are_drawn(self): + texts = self.capture_text(lambda: util.hbar_chart(MIXED, 'My Title', 'My subtitle')) + self.assertIn('My Title', texts) + self.assertIn('My subtitle', texts) + texts = self.capture_text(lambda: util.graph(price_frame(), 'Apple Inc (AAPL)', 'USD')) + self.assertIn('Apple Inc (AAPL)', texts) + self.assertTrue(any('over period' in t for t in texts)) + + def test_price_line_colour_follows_direction(self): + def line_colour(closes): + frame = pd.DataFrame({'Date': [datetime.date(2026, 1, 1) + datetime.timedelta(days=i) for i in range(len(closes))], 'Close': closes}) + texts = [] + real = Figure.text + with mock.patch.object(Figure, 'text', lambda fig, x, y, s, *a, **k: (texts.append((s, k.get('color'))), real(fig, x, y, s, *a, **k))[1]): + util.graph(frame, 'T', 'USD') + return next(color for s, color in texts if 'over period' in s) + self.assertEqual(line_colour([1, 2, 3]), '#34d399') # up: green + self.assertEqual(line_colour([3, 2, 1]), '#fb7185') # down: red + self.assertEqual(line_colour([2, 3, 2]), '#9aa4b2') # flat class ReportChartInputs(FinbotCase): - """What the reports hand to the chart: sort order, titles and dates, taken from the real report code.""" - - def run_report(self, call): - captured = [] - real = util.hbar_chart - def spy(rows, title, subtitle='', **kw): - captured.append({'rows': list(rows), 'title': title, 'subtitle': subtitle}) - return real(rows, title, subtitle, **kw) - self.patch(util, 'hbar_chart', spy) - call() - self.assertTrue(captured, 'no chart was drawn') - return captured[0] - - def test_price_top_lists_best_first_then_worst(self): - chart = self.run_report(lambda: self.command('.price top 7d')) - values = [v for _, v in chart['rows'] if v is not None] - top, bottom = values[:10], values[10:] - self.assertEqual(top, sorted(top, reverse=True)) - self.assertEqual(bottom, sorted(bottom)) # the worst performer comes first, like the caption's second list - self.assertGreater(min(top), max(bottom)) - self.assertIn(None, [v for _, v in chart['rows']]) # spacer between the two lists - - def test_price_threshold_chart_is_sorted_and_titled_with_the_threshold(self): - chart = self.run_report(lambda: self.command('.price 5%')) - values = [v for _, v in chart['rows'] if v is not None] - self.assertEqual(values, sorted(values, reverse=True)) - self.assertIn('5', chart['title']) - - def test_price_over_a_period_has_a_dated_title(self): - chart = self.run_report(lambda: self.command('.price 7d')) - self.assertRegex(chart['title'], r'\d{1,2} \w{3} - \d{1,2} \w{3} \d{4}') # 28 Sep - 5 Oct 2026 - - def test_performance_is_sorted_by_percent(self): - chart = self.run_report(lambda: self.command('.performance 7d')) - values = [v for _, v in chart['rows'] if v is not None] - self.assertEqual(values, sorted(values, reverse=True)) - - def test_shorts_is_sorted_and_carries_the_threshold(self): - captured = [] - real = util.shorts_chart - def spy(rows, threshold, *a, **k): - captured.append((list(rows), threshold)) - return real(rows, threshold, *a, **k) - self.patch(util, 'shorts_chart', spy) - self.command('.shorts') - self.assertTrue(captured, 'no chart was drawn') - rows, threshold = captured[0] - values = [v for _, v in rows] - self.assertEqual(values, sorted(values, reverse=True)) - self.assertTrue(all(v > threshold for v in values)) + """What the reports hand to the chart: sort order, titles and dates, taken from the real report code.""" + + def run_report(self, call): + captured = [] + real = util.hbar_chart + def spy(rows, title, subtitle='', **kw): + captured.append({'rows': list(rows), 'title': title, 'subtitle': subtitle}) + return real(rows, title, subtitle, **kw) + self.patch(util, 'hbar_chart', spy) + call() + self.assertTrue(captured, 'no chart was drawn') + return captured[0] + + def test_price_top_lists_best_first_then_worst(self): + chart = self.run_report(lambda: self.command('.price top 7d')) + values = [v for _, v in chart['rows'] if v is not None] + top, bottom = values[:10], values[10:] + self.assertEqual(top, sorted(top, reverse=True)) + self.assertEqual(bottom, sorted(bottom)) # the worst performer comes first, like the caption's second list + self.assertGreater(min(top), max(bottom)) + self.assertIn(None, [v for _, v in chart['rows']]) # spacer between the two lists + + def test_price_threshold_chart_is_sorted_and_titled_with_the_threshold(self): + chart = self.run_report(lambda: self.command('.price 5%')) + values = [v for _, v in chart['rows'] if v is not None] + self.assertEqual(values, sorted(values, reverse=True)) + self.assertIn('5', chart['title']) + + def test_price_over_a_period_has_a_dated_title(self): + chart = self.run_report(lambda: self.command('.price 7d')) + self.assertRegex(chart['title'], r'\d{1,2} \w{3} - \d{1,2} \w{3} \d{4}') # 28 Sep - 5 Oct 2026 + + def test_performance_is_sorted_by_percent(self): + chart = self.run_report(lambda: self.command('.performance 7d')) + values = [v for _, v in chart['rows'] if v is not None] + self.assertEqual(values, sorted(values, reverse=True)) + + def test_shorts_is_sorted_and_carries_the_threshold(self): + captured = [] + real = util.shorts_chart + def spy(rows, threshold, *a, **k): + captured.append((list(rows), threshold)) + return real(rows, threshold, *a, **k) + self.patch(util, 'shorts_chart', spy) + self.command('.shorts') + self.assertTrue(captured, 'no chart was drawn') + rows, threshold = captured[0] + values = [v for _, v in rows] + self.assertEqual(values, sorted(values, reverse=True)) + self.assertTrue(all(v > threshold for v in values)) class GoldenImages(unittest.TestCase): - """One reference image per chart type. Tolerant on purpose: compares 160x100 thumbnails, so font anti-aliasing and - minor matplotlib differences pass, while changed colours, layout, bar sizes or missing text do not.""" - TOLERANCE = 2.0 # mean absolute difference, in 0-255 grey levels, of the 160x100 thumbnails - - @staticmethod - def thumb(image): - return image.resize((160, 100), Image.LANCZOS) # goldens are stored at this size too: a few KB each, not 100 KB - - def check(self, name): - thumb = self.thumb(decode(CHARTS[name]())) - path = os.path.join(GOLDEN, name + '.png') - if os.environ.get('UPDATE_GOLDEN') or not os.path.exists(path): - os.makedirs(GOLDEN, exist_ok=True) - thumb.save(path, optimize=True) - if not os.environ.get('UPDATE_GOLDEN'): - self.fail(f'{name}: no golden image existed, so one was written to {path}. Review it and commit it.') - return - diff = np.abs(np.asarray(thumb, dtype=float) - np.asarray(Image.open(path).convert('RGB'), dtype=float)).mean() - self.assertLess(diff, self.TOLERANCE, f'{name} no longer matches tests/golden/{name}.png (diff {diff:.2f}). ' - f'If the change is intended, run: UPDATE_GOLDEN=1 python -m unittest tests.test_charts') - - def test_hbar_mixed(self): self.check('hbar_mixed') - def test_price_line(self): self.check('price_line') - def test_shorts(self): self.check('shorts') - def test_compare_lines(self): self.check('compare_lines') + """One reference image per chart type. Tolerant on purpose: compares 160x100 thumbnails, so font anti-aliasing and + minor matplotlib differences pass, while changed colours, layout, bar sizes or missing text do not.""" + TOLERANCE = 2.0 # mean absolute difference, in 0-255 grey levels, of the 160x100 thumbnails + + @staticmethod + def thumb(image): + return image.resize((160, 100), Image.LANCZOS) # goldens are stored at this size too: a few KB each, not 100 KB + + def check(self, name): + thumb = self.thumb(decode(CHARTS[name]())) + path = os.path.join(GOLDEN, name + '.png') + if os.environ.get('UPDATE_GOLDEN') or not os.path.exists(path): + os.makedirs(GOLDEN, exist_ok=True) + thumb.save(path, optimize=True) + if not os.environ.get('UPDATE_GOLDEN'): + self.fail(f'{name}: no golden image existed, so one was written to {path}. Review it and commit it.') + return + diff = np.abs(np.asarray(thumb, dtype=float) - np.asarray(Image.open(path).convert('RGB'), dtype=float)).mean() + self.assertLess(diff, self.TOLERANCE, f'{name} no longer matches tests/golden/{name}.png (diff {diff:.2f}). ' + f'If the change is intended, run: UPDATE_GOLDEN=1 python -m unittest tests.test_charts') + + def test_hbar_mixed(self): self.check('hbar_mixed') + def test_price_line(self): self.check('price_line') + def test_shorts(self): self.check('shorts') + def test_compare_lines(self): self.check('compare_lines') if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_edges.py b/tests/test_edges.py index 76c06df8..46992e06 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -5,417 +5,417 @@ from tests.common import * COMMANDS = ('.price', '.price 7d', '.price top', '.session', '.premarket', '.performance 7d', '.shorts', '.peg', '.buy', - '.sell', '.beta', '.pe', '.marketcap', '.earnings', '.dividend', '.trades', '.holdings', '.watchlist', - '.T1', '.history T1', '.compare T1 T2') + '.sell', '.beta', '.pe', '.marketcap', '.earnings', '.dividend', '.trades', '.holdings', '.watchlist', + '.T1', '.history T1', '.compare T1 T2') class UpstreamEmpty(FinbotCase): - """Yahoo answers, but with nothing usable. Every command must reply sanely and never crash.""" - - ONE_LINE_ERROR = ('.history T1', '.compare T1 T2') # these say so with a single error line when the ticker has no data - - def run_all(self): - for cmd in COMMANDS: - with self.subTest(cmd=cmd): - if cmd in self.ONE_LINE_ERROR: - self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', cmd, BOT, 'U') - self.assertLessEqual(len(self.errors), 1, cmd) - else: - self.command(cmd) - - def test_yahoo_returns_empty_dict(self): - self.patch(yahoo, 'fetch', lambda ts: {}) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {}) - self.run_all() - - def test_yahoo_raises(self): - def down(*a, **k): - raise RuntimeError('Yahoo API unavailable') - for name in ('fetch', 'fetch_detail', 'price_series'): - self.patch(yahoo, name, down) - for cmd in ('.price', '.peg', '.T1', '.compare T1 T2'): - with self.subTest(cmd=cmd): - self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', cmd, BOT, 'U') - self.assertEqual(len(self.errors), 1, cmd) # exactly one one-line report, not a traceback to the user - self.assertTrue(self.errors[0].startswith('⚠️')) - - def test_sharesight_down_is_reported_once(self): - def down(*a, **k): - raise RuntimeError('Sharesight performance error: boom') - self.patch(sharesight, 'get_performance_wrapper', down) - self.patch(sharesight, 'get_performance', down) - self.patch(sharesight, 'get_portfolios', down) - self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U') - self.assertEqual(len(self.errors), 1) - - def test_a_failed_command_stops_typing_before_saying_why(self): - def down(*a, **k): - raise RuntimeError('Sharesight performance error: boom') - self.patch(sharesight, 'get_portfolios', down) - typing_when_reported = [] - self.patch(webhook, 'report_error', lambda e, *a, **k: typing_when_reported.append([t.is_active() for t in self.typing])) - worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U') - self.assertEqual(typing_when_reported, [[False]]) # its one indicator had stopped when the error line went out - - def test_no_holdings_and_empty_watchlist(self): - self.patch(util, 'get_holdings_and_watchlist', lambda: []) - for cmd in ('.watchlist', '.price', '.earnings'): - with self.subTest(cmd=cmd): - self.command(cmd) + """Yahoo answers, but with nothing usable. Every command must reply sanely and never crash.""" + + ONE_LINE_ERROR = ('.history T1', '.compare T1 T2') # these say so with a single error line when the ticker has no data + + def run_all(self): + for cmd in COMMANDS: + with self.subTest(cmd=cmd): + if cmd in self.ONE_LINE_ERROR: + self.sent.clear(); self.errors.clear() + worker.process_request('telegram', '55', '@u', cmd, BOT, 'U') + self.assertLessEqual(len(self.errors), 1, cmd) + else: + self.command(cmd) + + def test_yahoo_returns_empty_dict(self): + self.patch(yahoo, 'fetch', lambda ts: {}) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {}) + self.run_all() + + def test_yahoo_raises(self): + def down(*a, **k): + raise RuntimeError('Yahoo API unavailable') + for name in ('fetch', 'fetch_detail', 'price_series'): + self.patch(yahoo, name, down) + for cmd in ('.price', '.peg', '.T1', '.compare T1 T2'): + with self.subTest(cmd=cmd): + self.sent.clear(); self.errors.clear() + worker.process_request('telegram', '55', '@u', cmd, BOT, 'U') + self.assertEqual(len(self.errors), 1, cmd) # exactly one one-line report, not a traceback to the user + self.assertTrue(self.errors[0].startswith('⚠️')) + + def test_sharesight_down_is_reported_once(self): + def down(*a, **k): + raise RuntimeError('Sharesight performance error: boom') + self.patch(sharesight, 'get_performance_wrapper', down) + self.patch(sharesight, 'get_performance', down) + self.patch(sharesight, 'get_portfolios', down) + self.sent.clear(); self.errors.clear() + worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U') + self.assertEqual(len(self.errors), 1) + + def test_a_failed_command_stops_typing_before_saying_why(self): + def down(*a, **k): + raise RuntimeError('Sharesight performance error: boom') + self.patch(sharesight, 'get_portfolios', down) + typing_when_reported = [] + self.patch(webhook, 'report_error', lambda e, *a, **k: typing_when_reported.append([t.is_active() for t in self.typing])) + worker.process_request('telegram', '55', '@u', '.performance 7d', BOT, 'U') + self.assertEqual(typing_when_reported, [[False]]) # its one indicator had stopped when the error line went out + + def test_no_holdings_and_empty_watchlist(self): + self.patch(util, 'get_holdings_and_watchlist', lambda: []) + for cmd in ('.watchlist', '.price', '.earnings'): + with self.subTest(cmd=cmd): + self.command(cmd) class WatchlistActions(FinbotCase): - def test_add_duplicate_delete_and_unknown_action(self): - add = self.command('.watchlist add MSFT') - self.assertTrue(add) - self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), ['MSFT']) - again = self.command('.watchlist add MSFT') - self.assertNotEqual(add, again) # a duplicate is reported differently from a first add - self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), ['MSFT']) - self.command('.watchlist rm MSFT') - self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), []) - self.assertIn('not a valid watchlist action', str(self.command('.watchlist frobnicate MSFT'))) + def test_add_duplicate_delete_and_unknown_action(self): + add = self.command('.watchlist add MSFT') + self.assertTrue(add) + self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), ['MSFT']) + again = self.command('.watchlist add MSFT') + self.assertNotEqual(add, again) # a duplicate is reported differently from a first add + self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), ['MSFT']) + self.command('.watchlist rm MSFT') + self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), []) + self.assertIn('not a valid watchlist action', str(self.command('.watchlist frobnicate MSFT'))) class BadArguments(FinbotCase): - def test_compare_argument_errors_give_usage(self): - for cmd in ('.compare', '.compare T1', '.compare T1 T2 T3 T4 T5 T6 T7', '.compare T1 T1'): - with self.subTest(cmd=cmd): - self.assertIn('Usage', str(self.command(cmd))) + def test_compare_argument_errors_give_usage(self): + for cmd in ('.compare', '.compare T1', '.compare T1 T2 T3 T4 T5 T6 T7', '.compare T1 T1'): + with self.subTest(cmd=cmd): + self.assertIn('Usage', str(self.command(cmd))) - def test_compare_with_a_ticker_that_has_no_history(self): - def no_history(t): - raise RuntimeError(f'no price history for {t}') - self.patch(yahoo, 'price_series', no_history) - self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', '.compare T1 T2', BOT, 'U') - self.assertEqual(len(self.errors), 1) + def test_compare_with_a_ticker_that_has_no_history(self): + def no_history(t): + raise RuntimeError(f'no price history for {t}') + self.patch(yahoo, 'price_series', no_history) + self.sent.clear(); self.errors.clear() + worker.process_request('telegram', '55', '@u', '.compare T1 T2', BOT, 'U') + self.assertEqual(len(self.errors), 1) - def test_history_without_a_ticker_or_with_extra_argument_gives_usage(self): - self.assertEqual(self.command('.history'), [('text', ['Usage: .history SYMBOL'])]) # not the profile of a ticker called HISTORY - self.assertEqual(self.command('.history T1 1y'), [('text', ['Usage: .history SYMBOL'])]) + def test_history_without_a_ticker_or_with_extra_argument_gives_usage(self): + self.assertEqual(self.command('.history'), [('text', ['Usage: .history SYMBOL'])]) # not the profile of a ticker called HISTORY + self.assertEqual(self.command('.history T1 1y'), [('text', ['Usage: .history SYMBOL'])]) class PortfolioNames(FinbotCase): - def test_an_unknown_portfolio_gets_the_same_answer_everywhere(self): - for cmd in ('.trades 7d Nope', '.performance Nope', '.holdings Nope', '.pie Nope'): - with self.subTest(cmd=cmd): # it used to be a ValueError, "no Sharesight data found", or two wordings of this - self.assertEqual(self.command(cmd), [('text', ['Nope portfolio not found. I only know about:', 'Rob'])]) + def test_an_unknown_portfolio_gets_the_same_answer_everywhere(self): + for cmd in ('.trades 7d Nope', '.performance Nope', '.holdings Nope', '.pie Nope'): + with self.subTest(cmd=cmd): # it used to be a ValueError, "no Sharesight data found", or two wordings of this + self.assertEqual(self.command(cmd), [('text', ['Nope portfolio not found. I only know about:', 'Rob'])]) - def test_a_portfolio_name_in_any_case(self): - self.patch(sharesight, 'get_holdings_new', lambda name, pid: {'T1:NASDAQ': {'code': 'T1', 'market_code': 'NASDAQ', 'name': 'Co T1', 'holding_id': 1}}) - self.assertIn('Holdings for Rob (1)', str(self.command('.holdings rob'))) + def test_a_portfolio_name_in_any_case(self): + self.patch(sharesight, 'get_holdings_new', lambda name, pid: {'T1:NASDAQ': {'code': 'T1', 'market_code': 'NASDAQ', 'name': 'Co T1', 'holding_id': 1}}) + self.assertIn('Holdings for Rob (1)', str(self.command('.holdings rob'))) - def test_trades_with_no_buy_or_sell_still_get_an_answer(self): - self.patch(sharesight, 'get_trades', lambda name, pid, days=7: [{'id': 2, 'transaction_type': 'DIVIDEND', 'portfolio': name}]) - self.assertIn('No trades', str(self.command('.trades 7d'))) # it used to say nothing at all + def test_trades_with_no_buy_or_sell_still_get_an_answer(self): + self.patch(sharesight, 'get_trades', lambda name, pid, days=7: [{'id': 2, 'transaction_type': 'DIVIDEND', 'portfolio': name}]) + self.assertIn('No trades', str(self.command('.trades 7d'))) # it used to say nothing at all class CommandArguments(FinbotCase): - def test_period_and_portfolio_in_either_order(self): - for text, expected in (('', (30, None)), ('7d', (7, None)), ('Rob', (30, 'Rob')), ('7d Rob', (7, 'Rob')), ('Rob 7d', (7, 'Rob')), - ('My Portfolio', (30, 'My Portfolio')), ('My Portfolio 3m', (90, 'My Portfolio')), ('Rob ', (30, 'Rob'))): - with self.subTest(text=text): - self.assertEqual(worker.period_and_portfolio(text, 30), expected) - - def test_a_percentage_is_a_number_and_nan_a_ticker(self): - for arg, expected in (('', (5, None)), ('2.5', (2.5, None)), ('3%', (3.0, None)), ('T1', (5, 'T1')), ('nan', (5, 'NAN')), ('inf', (5, 'INF'))): - with self.subTest(arg=arg): - self.assertEqual(worker.percent_or_ticker(arg, 5), expected) - self.assertIn('2.5%', str(self.command('.session 2.5'))) # a threshold, not a ticker called 2.5 - - def test_a_bare_number_on_price_is_a_percent(self): - """As on .session, .premarket and .shorts, and as the README has it; a period carries its unit.""" - self.assertIn('3% at close', str(self.command('.price 3'))) # it used to be a 3-day report - self.assertIn('Price movement 3%', str(self.command('.price 3 7d'))) # the 3 used to be dropped - self.assertIn('Price movement 5%', str(self.command('.price 7d'))) - - def test_top_is_gone(self): - self.assertIn('Co TOP', str(self.command('.top 7d'))) # .price top 7d is the command; .top is a ticker now, like any word - - def test_a_ratio_command_reads_its_ticker_as_the_other_commands_do(self): - self.assertIn('Co T1 ', str(self.command('.pe t1'))) - self.assertIn('Top 15 tracked stocks by pe ratio', str(self.command('.pe top '))) # trailing space and all - - def test_hello_leaves_the_word_list_as_it_was(self): - before = list(worker.verb) - for _ in range(30): - line = self.command('.hello')[0][1][0] - self.assertNotIn("('", line) # a verb, not a tuple of them - self.assertTrue(line.endswith(' you, U! 😇'), line) - self.assertEqual(worker.verb, before) - self.assertEqual(self.command('hi there'), []) # hello by name only: "hi @bot" + def test_period_and_portfolio_in_either_order(self): + for text, expected in (('', (30, None)), ('7d', (7, None)), ('Rob', (30, 'Rob')), ('7d Rob', (7, 'Rob')), ('Rob 7d', (7, 'Rob')), + ('My Portfolio', (30, 'My Portfolio')), ('My Portfolio 3m', (90, 'My Portfolio')), ('Rob ', (30, 'Rob'))): + with self.subTest(text=text): + self.assertEqual(worker.period_and_portfolio(text, 30), expected) + + def test_a_percentage_is_a_number_and_nan_a_ticker(self): + for arg, expected in (('', (5, None)), ('2.5', (2.5, None)), ('3%', (3.0, None)), ('T1', (5, 'T1')), ('nan', (5, 'NAN')), ('inf', (5, 'INF'))): + with self.subTest(arg=arg): + self.assertEqual(worker.percent_or_ticker(arg, 5), expected) + self.assertIn('2.5%', str(self.command('.session 2.5'))) # a threshold, not a ticker called 2.5 + + def test_a_bare_number_on_price_is_a_percent(self): + """As on .session, .premarket and .shorts, and as the README has it; a period carries its unit.""" + self.assertIn('3% at close', str(self.command('.price 3'))) # it used to be a 3-day report + self.assertIn('Price movement 3%', str(self.command('.price 3 7d'))) # the 3 used to be dropped + self.assertIn('Price movement 5%', str(self.command('.price 7d'))) + + def test_top_is_gone(self): + self.assertIn('Co TOP', str(self.command('.top 7d'))) # .price top 7d is the command; .top is a ticker now, like any word + + def test_a_ratio_command_reads_its_ticker_as_the_other_commands_do(self): + self.assertIn('Co T1 ', str(self.command('.pe t1'))) + self.assertIn('Top 15 tracked stocks by pe ratio', str(self.command('.pe top '))) # trailing space and all + + def test_hello_leaves_the_word_list_as_it_was(self): + before = list(worker.verb) + for _ in range(30): + line = self.command('.hello')[0][1][0] + self.assertNotIn("('", line) # a verb, not a tuple of them + self.assertTrue(line.endswith(' you, U! 😇'), line) + self.assertEqual(worker.verb, before) + self.assertEqual(self.command('hi there'), []) # hello by name only: "hi @bot" class WsgiRejections(FinbotCase): - SECRET = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} - - def request(self, path='/telegram', body=None, headers=None): - body = body if isinstance(body, bytes) else json.dumps(body if body is not None else {}).encode() - env = {'PATH_INFO': path, 'wsgi.input': io.BytesIO(body), **(headers or {})} - status = [] - with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): - out = bot.main(env, lambda s, *a, **k: status.append(s)) - return b''.join(out), status[-1] - - def test_telegram_requires_the_secret_header(self): - message = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.help'}} - self.assertIn(b'Unauthorized', self.request(body=message)[0]) - self.assertIn(b'Unauthorized', self.request(body=message, headers={'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'wrong'})[0]) - self.assertEqual(self.sent, []) # nothing was processed - - def test_unknown_path_is_404(self): - self.assertEqual(self.request(path='/nope')[1], '404 Not Found') - - def test_malformed_body_is_refused_not_a_crash(self): - self.assertEqual(self.request(body=b'not json')[1], '403 Forbidden') # no secret: turned away before the body is read - for body in (b'not json', b'', b'[1, 2]', b'7'): - self.assertEqual(self.request(body=body, headers=self.SECRET)[1], '400 Bad Request', body) - self.assertEqual(self.errors, []) - - def test_strangers_cannot_post_into_the_chats(self): - self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') - self.patch(bot, 'config_slackOutgoingToken', 'stok') - for path, body, headers, status in (('/', b'', {}, '404 Not Found'), ('/wp-login.php', b'log=x&pwd=y', {}, '404 Not Found'), - ('/telegram', b'log=x', {}, '403 Forbidden'), ('/telegram', {'message': {}}, {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'wrong'}, '403 Forbidden'), - ('/slack', b'', {}, '400 Bad Request'), ('/slack', b'token=stok', {}, '400 Bad Request'), ('/slack', {'type': 'event_callback'}, {}, '403 Forbidden'), - ('/slack', {'token': 'wrong', 'type': 'event_callback'}, {}, '403 Forbidden'), ('/slack', {'token': 'stok'}, {}, '200 OK')): - with self.subTest(path=path, body=body): - self.assertEqual(self.request(path, body, headers)[1], status) - self.assertEqual(self.errors, []) # nothing reached the chats - self.assertEqual(self.sent, []) - - def test_a_failure_before_authentication_is_only_logged(self): - def broken(*a, **k): - raise RuntimeError('boom') - self.patch(bot, 'urlparse', broken) # fails while routing, before the secret is even looked at - env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(b'{}'), **self.SECRET} - status = [] - with contextlib.redirect_stderr(io.StringIO()) as err: - bot.main(env, lambda s, *a, **k: status.append(s)) - self.assertEqual((status[-1][:3], self.errors), ('500', [])) - self.assertIn('boom', err.getvalue()) - - def test_a_failure_after_authentication_is_reported_once_and_not_retried(self): - broken = {'message': {'message_id': 1, 'from': {'first_name': 'A'}, 'text': '.help'}} # no chat - self.assertEqual(self.request(body=broken, headers=self.SECRET)[1], '200 OK') # a retry would only fail and report again - self.assertEqual(len(self.errors), 1) - self.assertIn("KeyError: 'chat'", self.errors[0]) - - def test_telegram_edge_updates(self): - secret = self.SECRET - self.assertEqual(self.request(body={'edited_message': {}}, headers=secret)[0], b'Unsupported') - self.assertEqual(self.request(body={'callback_query': {'id': '1'}}, headers=secret)[0], b'Unsupported') - odd = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'new_chat_members': []}} - self.assertIn(b'Unhandled', self.request(body=odd, headers=secret)[0]) - - def test_a_telegram_update_sent_again_runs_once(self): - calls = [] - self.patch(worker, 'process_request', lambda *a: calls.append(a)) - body = {'update_id': 7, 'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.price'}} - for _ in range(2): # Telegram sends an update again when it got no answer - self.assertEqual(self.request(body=body, headers=self.SECRET)[1], '200 OK') - gevent.sleep(0.05) - self.assertEqual(len(calls), 1) - - def test_button_presses_are_logged(self): - calls = [] - self.patch(worker, 'process_callback', lambda *a: calls.append(a)) - secret = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} - for sender, who in (({'username': 'rob', 'first_name': 'Rob'}, '@rob'), ({'first_name': 'Ann'}, 'Ann'), ({}, 'unknown')): - body = json.dumps({'callback_query': {'id': '1', 'from': sender, 'data': 'j|7|abcd', 'message': {'message_id': 9, 'chat': {'id': 55}}}}).encode() - err = io.StringIO() - with contextlib.redirect_stderr(err): - bot.main({'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(body), **secret}, lambda *a, **k: None) - self.assertIn(f'[Telegram button]: {who} chat 55 j|7|abcd', err.getvalue()) + SECRET = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} + + def request(self, path='/telegram', body=None, headers=None): + body = body if isinstance(body, bytes) else json.dumps(body if body is not None else {}).encode() + env = {'PATH_INFO': path, 'wsgi.input': io.BytesIO(body), **(headers or {})} + status = [] + with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): + out = bot.main(env, lambda s, *a, **k: status.append(s)) + return b''.join(out), status[-1] + + def test_telegram_requires_the_secret_header(self): + message = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.help'}} + self.assertIn(b'Unauthorized', self.request(body=message)[0]) + self.assertIn(b'Unauthorized', self.request(body=message, headers={'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'wrong'})[0]) + self.assertEqual(self.sent, []) # nothing was processed + + def test_unknown_path_is_404(self): + self.assertEqual(self.request(path='/nope')[1], '404 Not Found') + + def test_malformed_body_is_refused_not_a_crash(self): + self.assertEqual(self.request(body=b'not json')[1], '403 Forbidden') # no secret: turned away before the body is read + for body in (b'not json', b'', b'[1, 2]', b'7'): + self.assertEqual(self.request(body=body, headers=self.SECRET)[1], '400 Bad Request', body) + self.assertEqual(self.errors, []) + + def test_strangers_cannot_post_into_the_chats(self): + self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') + self.patch(bot, 'config_slackOutgoingToken', 'stok') + for path, body, headers, status in (('/', b'', {}, '404 Not Found'), ('/wp-login.php', b'log=x&pwd=y', {}, '404 Not Found'), + ('/telegram', b'log=x', {}, '403 Forbidden'), ('/telegram', {'message': {}}, {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'wrong'}, '403 Forbidden'), + ('/slack', b'', {}, '400 Bad Request'), ('/slack', b'token=stok', {}, '400 Bad Request'), ('/slack', {'type': 'event_callback'}, {}, '403 Forbidden'), + ('/slack', {'token': 'wrong', 'type': 'event_callback'}, {}, '403 Forbidden'), ('/slack', {'token': 'stok'}, {}, '200 OK')): + with self.subTest(path=path, body=body): + self.assertEqual(self.request(path, body, headers)[1], status) + self.assertEqual(self.errors, []) # nothing reached the chats + self.assertEqual(self.sent, []) + + def test_a_failure_before_authentication_is_only_logged(self): + def broken(*a, **k): + raise RuntimeError('boom') + self.patch(bot, 'urlparse', broken) # fails while routing, before the secret is even looked at + env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(b'{}'), **self.SECRET} + status = [] + with contextlib.redirect_stderr(io.StringIO()) as err: + bot.main(env, lambda s, *a, **k: status.append(s)) + self.assertEqual((status[-1][:3], self.errors), ('500', [])) + self.assertIn('boom', err.getvalue()) + + def test_a_failure_after_authentication_is_reported_once_and_not_retried(self): + broken = {'message': {'message_id': 1, 'from': {'first_name': 'A'}, 'text': '.help'}} # no chat + self.assertEqual(self.request(body=broken, headers=self.SECRET)[1], '200 OK') # a retry would only fail and report again + self.assertEqual(len(self.errors), 1) + self.assertIn("KeyError: 'chat'", self.errors[0]) + + def test_telegram_edge_updates(self): + secret = self.SECRET + self.assertEqual(self.request(body={'edited_message': {}}, headers=secret)[0], b'Unsupported') + self.assertEqual(self.request(body={'callback_query': {'id': '1'}}, headers=secret)[0], b'Unsupported') + odd = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'new_chat_members': []}} + self.assertIn(b'Unhandled', self.request(body=odd, headers=secret)[0]) + + def test_a_telegram_update_sent_again_runs_once(self): + calls = [] + self.patch(worker, 'process_request', lambda *a: calls.append(a)) + body = {'update_id': 7, 'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A'}, 'text': '.price'}} + for _ in range(2): # Telegram sends an update again when it got no answer + self.assertEqual(self.request(body=body, headers=self.SECRET)[1], '200 OK') + gevent.sleep(0.05) + self.assertEqual(len(calls), 1) + + def test_button_presses_are_logged(self): + calls = [] + self.patch(worker, 'process_callback', lambda *a: calls.append(a)) + secret = {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} + for sender, who in (({'username': 'rob', 'first_name': 'Rob'}, '@rob'), ({'first_name': 'Ann'}, 'Ann'), ({}, 'unknown')): + body = json.dumps({'callback_query': {'id': '1', 'from': sender, 'data': 'j|7|abcd', 'message': {'message_id': 9, 'chat': {'id': 55}}}}).encode() + err = io.StringIO() + with contextlib.redirect_stderr(err): + bot.main({'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(body), **secret}, lambda *a, **k: None) + self.assertIn(f'[Telegram button]: {who} chat 55 j|7|abcd', err.getvalue()) class SlackEvents(FinbotCase): - """Slack sends every message in a subscribed channel, the bot's own posts included.""" - - def setUp(self): - super().setUp() - self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') - self.patch(bot, 'config_slackOutgoingToken', 'stok') - self.requests = [] - self.patch(worker, 'process_request', lambda *a: self.requests.append(a)) - - def event(self, event_id=None, **event): - body = json.dumps({'token': 'stok', 'type': 'event_callback', 'authorizations': [{'user_id': 'UBOT'}], 'event_id': event_id, - 'event': dict({'type': 'message', 'channel': 'C1', 'ts': '1.2'}, **event)}).encode() - status = [] - with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): - bot.main({'PATH_INFO': '/slack', 'wsgi.input': io.BytesIO(body)}, lambda s, *a, **k: status.append(s)) - gevent.sleep(0.05) # the worker runs in a (gevent) thread - return status[-1] - - def test_only_people_are_answered(self): - for event in ({'subtype': 'bot_message', 'bot_id': 'B1', 'text': "⚠️ /slack: KeyError: 'user'"}, # a webhook report - {'user': 'UBOT', 'bot_id': 'B2', 'text': '.holdings: Please try again'}, # this bot's own reply - {'user': 'UBOT', 'text': '.holdings'}, - {'subtype': 'channel_join', 'user': 'U1', 'text': '<@U1> has joined the channel'}, - {'subtype': 'message_changed', 'message': {'text': '.price'}}, - {'text': '.price'}): # no user at all - with self.subTest(event=event): - self.assertEqual(self.event(**event), '200 OK') - self.assertEqual((self.requests, self.errors), ([], [])) - self.assertEqual(self.event(user='U1', text='.price <@UBOT>'), '200 OK') - self.assertEqual(self.event(user='U2', subtype='thread_broadcast', text='.trades'), '200 OK') - self.assertEqual([(r[0], r[1], r[2], r[3], r[4]) for r in self.requests], - [('slack', 'C1', '<@U1>', '.price @UBOT', '@UBOT'), ('slack', 'C1', '<@U2>', '.trades', '@UBOT')]) - - def test_a_retried_event_runs_once(self): - for _ in range(2): # Slack resends an event it got no answer to within 3 seconds, with the same event_id - self.assertEqual(self.event('Ev1', user='U1', text='.price'), '200 OK') - self.assertEqual(self.event('Ev2', user='U1', text='.price'), '200 OK') - self.assertEqual(len(self.requests), 2) - - def test_slack_does_not_change_the_telegram_bot_name(self): - self.patch(bot, 'botName', '@finbot_tg') - self.event(user='U1', text='.price') - self.assertEqual(bot.botName, '@finbot_tg') + """Slack sends every message in a subscribed channel, the bot's own posts included.""" + + def setUp(self): + super().setUp() + self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') + self.patch(bot, 'config_slackOutgoingToken', 'stok') + self.requests = [] + self.patch(worker, 'process_request', lambda *a: self.requests.append(a)) + + def event(self, event_id=None, **event): + body = json.dumps({'token': 'stok', 'type': 'event_callback', 'authorizations': [{'user_id': 'UBOT'}], 'event_id': event_id, + 'event': dict({'type': 'message', 'channel': 'C1', 'ts': '1.2'}, **event)}).encode() + status = [] + with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): + bot.main({'PATH_INFO': '/slack', 'wsgi.input': io.BytesIO(body)}, lambda s, *a, **k: status.append(s)) + gevent.sleep(0.05) # the worker runs in a (gevent) thread + return status[-1] + + def test_only_people_are_answered(self): + for event in ({'subtype': 'bot_message', 'bot_id': 'B1', 'text': "⚠️ /slack: KeyError: 'user'"}, # a webhook report + {'user': 'UBOT', 'bot_id': 'B2', 'text': '.holdings: Please try again'}, # this bot's own reply + {'user': 'UBOT', 'text': '.holdings'}, + {'subtype': 'channel_join', 'user': 'U1', 'text': '<@U1> has joined the channel'}, + {'subtype': 'message_changed', 'message': {'text': '.price'}}, + {'text': '.price'}): # no user at all + with self.subTest(event=event): + self.assertEqual(self.event(**event), '200 OK') + self.assertEqual((self.requests, self.errors), ([], [])) + self.assertEqual(self.event(user='U1', text='.price <@UBOT>'), '200 OK') + self.assertEqual(self.event(user='U2', subtype='thread_broadcast', text='.trades'), '200 OK') + self.assertEqual([(r[0], r[1], r[2], r[3], r[4]) for r in self.requests], + [('slack', 'C1', '<@U1>', '.price @UBOT', '@UBOT'), ('slack', 'C1', '<@U2>', '.trades', '@UBOT')]) + + def test_a_retried_event_runs_once(self): + for _ in range(2): # Slack resends an event it got no answer to within 3 seconds, with the same event_id + self.assertEqual(self.event('Ev1', user='U1', text='.price'), '200 OK') + self.assertEqual(self.event('Ev2', user='U1', text='.price'), '200 OK') + self.assertEqual(len(self.requests), 2) + + def test_slack_does_not_change_the_telegram_bot_name(self): + self.patch(bot, 'botName', '@finbot_tg') + self.event(user='U1', text='.price') + self.assertEqual(bot.botName, '@finbot_tg') class UserTextIsEscaped(FinbotCase): - """Replies go out as Telegram HTML: text a user wrote is shown, never read as markup. One stray < in a .plan entry made Telegram - refuse that reply and every .plan reply after it.""" - - def test_a_plan_is_shown_as_text(self): - reply = self.command('.plan buy & hold')[0][1] - self.assertIn('buy <cheap> & hold', reply[0]) - self.assertEqual(self.command('.plan')[0][1], reply) # and the same way again - self.sent.clear() - worker.process_request('slack', 'C1', '<@U1>', '.plan sell <dear>', BOT, 'U') # Slack escapes what it sends - text = '\n'.join(self.sent[0][1]) - self.assertIn('*<@U1>*', text) # a Slack mention stays markup - self.assertIn('_sell <dear>_', text) # escaped once, not twice - self.assertIn('_buy <cheap> & hold_', text) - - def test_a_telegram_name_is_escaped_where_it_arrives(self): - requests = [] - self.patch(worker, 'process_request', lambda *a: requests.append(a)) - body = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A&'}, 'text': '.hello'}} - env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(json.dumps(body).encode()), 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} - with contextlib.redirect_stderr(io.StringIO()): - bot.main(env, lambda *a, **k: None) - gevent.sleep(0.05) # the worker runs in a (gevent) thread - self.assertEqual((requests[0][2], requests[0][5]), ('@A<b>&', '@A<b>&')) # the handle and the name + """Replies go out as Telegram HTML: text a user wrote is shown, never read as markup. One stray < in a .plan entry made Telegram + refuse that reply and every .plan reply after it.""" + + def test_a_plan_is_shown_as_text(self): + reply = self.command('.plan buy & hold')[0][1] + self.assertIn('buy <cheap> & hold', reply[0]) + self.assertEqual(self.command('.plan')[0][1], reply) # and the same way again + self.sent.clear() + worker.process_request('slack', 'C1', '<@U1>', '.plan sell <dear>', BOT, 'U') # Slack escapes what it sends + text = '\n'.join(self.sent[0][1]) + self.assertIn('*<@U1>*', text) # a Slack mention stays markup + self.assertIn('_sell <dear>_', text) # escaped once, not twice + self.assertIn('_buy <cheap> & hold_', text) + + def test_a_telegram_name_is_escaped_where_it_arrives(self): + requests = [] + self.patch(worker, 'process_request', lambda *a: requests.append(a)) + body = {'message': {'message_id': 1, 'chat': {'id': 5}, 'from': {'first_name': 'A&'}, 'text': '.hello'}} + env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(json.dumps(body).encode()), 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} + with contextlib.redirect_stderr(io.StringIO()): + bot.main(env, lambda *a, **k: None) + gevent.sleep(0.05) # the worker runs in a (gevent) thread + self.assertEqual((requests[0][2], requests[0][5]), ('@A<b>&', '@A<b>&')) # the handle and the name class SecretsStayOutOfLogs(FinbotCase): - """A failed request's error message carries its URL: the Telegram token, a Slack or Discord webhook path, a Yahoo crumb.""" - SECRET_URL = 'https://api.telegram.org/bot123456:SECRET-tok_en/sendMessage?chat_id=1' - - def stderr_of(self, call): - err = io.StringIO() - with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): - call() - return err.getvalue() - - def test_a_failed_send_logs_no_token(self): - def refuse(url, **kwargs): - raise ConnectionError(f"Max retries exceeded with url: {url} (headers {kwargs.get('headers')})") - self.patch(webhook.net, 'post', refuse) - self.patch(webhook, 'config_slackBotToken', 'xoxb-SECRET') - log = self.stderr_of(lambda: webhook.write('telegram', self.SECRET_URL, 'hi', '1')) - log += self.stderr_of(lambda: webhook.write('slack', 'https://hooks.slack.com/services/T0/B0/SECRETpath', 'hi', 'C1')) - self.assertIn('Failure sending', log) - for secret in ('SECRET-tok_en', 'SECRETpath', 'xoxb-SECRET'): - self.assertNotIn(secret, log) - - def test_the_traceback_of_a_reported_error_is_redacted(self): - self.patch(webhook, 'report_error', REAL_REPORT_ERROR) - self.patch(webhook, 'write', lambda *a, **k: None) - try: - raise ConnectionError('failed: ' + self.SECRET_URL) - except ConnectionError as e: - log = self.stderr_of(lambda: webhook.report_error(e, 'telegram', '55', context='x')) - self.assertIn('Traceback', log) - self.assertIn('bot', log) - self.assertNotIn('SECRET-tok_en', log) + """A failed request's error message carries its URL: the Telegram token, a Slack or Discord webhook path, a Yahoo crumb.""" + SECRET_URL = 'https://api.telegram.org/bot123456:SECRET-tok_en/sendMessage?chat_id=1' + + def stderr_of(self, call): + err = io.StringIO() + with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): + call() + return err.getvalue() + + def test_a_failed_send_logs_no_token(self): + def refuse(url, **kwargs): + raise ConnectionError(f"Max retries exceeded with url: {url} (headers {kwargs.get('headers')})") + self.patch(webhook.net, 'post', refuse) + self.patch(webhook, 'config_slackBotToken', 'xoxb-SECRET') + log = self.stderr_of(lambda: webhook.write('telegram', self.SECRET_URL, 'hi', '1')) + log += self.stderr_of(lambda: webhook.write('slack', 'https://hooks.slack.com/services/T0/B0/SECRETpath', 'hi', 'C1')) + self.assertIn('Failure sending', log) + for secret in ('SECRET-tok_en', 'SECRETpath', 'xoxb-SECRET'): + self.assertNotIn(secret, log) + + def test_the_traceback_of_a_reported_error_is_redacted(self): + self.patch(webhook, 'report_error', REAL_REPORT_ERROR) + self.patch(webhook, 'write', lambda *a, **k: None) + try: + raise ConnectionError('failed: ' + self.SECRET_URL) + except ConnectionError as e: + log = self.stderr_of(lambda: webhook.report_error(e, 'telegram', '55', context='x')) + self.assertIn('Traceback', log) + self.assertIn('bot', log) + self.assertNotIn('SECRET-tok_en', log) class SharedSession(unittest.TestCase): - def test_the_session_keeps_no_cookies(self): - """Yahoo's cookie goes in an explicit header: a stored one would be sent to fc.yahoo.com, which then sets no new one.""" - import email.message, urllib.request - from lib import net - headers = email.message.Message() - headers['Set-Cookie'] = 'A3=d=AQAB; Max-Age=31557600; Domain=.yahoo.com; Path=/; Secure' - response = type('Response', (), {'info': lambda self: headers})() - net.session.cookies.extract_cookies(response, urllib.request.Request('https://fc.yahoo.com/')) - self.assertEqual(len(net.session.cookies), 0) + def test_the_session_keeps_no_cookies(self): + """Yahoo's cookie goes in an explicit header: a stored one would be sent to fc.yahoo.com, which then sets no new one.""" + import email.message, urllib.request + from lib import net + headers = email.message.Message() + headers['Set-Cookie'] = 'A3=d=AQAB; Max-Age=31557600; Domain=.yahoo.com; Path=/; Secure' + response = type('Response', (), {'info': lambda self: headers})() + net.session.cookies.extract_cookies(response, urllib.request.Request('https://fc.yahoo.com/')) + self.assertEqual(len(net.session.cookies), 0) class StartupSignals(unittest.TestCase): - """A reload (SIGHUP) that arrives while the bot is still starting must not kill it.""" - - def test_sighup_during_startup_is_ignored_and_works_afterwards(self): - skip_if_old_env(self) - with tempfile.TemporaryDirectory() as data: - env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} - env.update(DATA_DIR=data, port='0', scheduler='False') - proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True) - self.addCleanup(proc.wait) - self.addCleanup(proc.kill) - deadline = time.monotonic() + 30 - while not int(Path(f'/proc/{proc.pid}/status').read_text().split('SigIgn:')[1].split()[0], 16) & 1: # until SIGHUP is ignored - self.assertLess(time.monotonic(), deadline) - time.sleep(0.001) - proc.send_signal(signal.SIGHUP) # still importing: the default action would kill it - lines = [] - for line in proc.stderr: - lines.append(line) - if line.startswith('Opening socket'): - break - self.assertIsNone(proc.poll(), ''.join(lines)) - time.sleep(1) # the handler goes in just before serving - proc.send_signal(signal.SIGHUP) - for line in proc.stderr: # a reload now runs (there is no preferences.conf here, so it says so) - lines.append(line) - if 'reload requested' in line: - break - self.assertIsNone(proc.poll(), ''.join(lines)) - self.assertIn('reload requested but', ''.join(lines)) - - def test_usr1_flushes_the_cache_and_keeps_the_state(self): - skip_if_old_env(self) - with tempfile.TemporaryDirectory() as data: - cache = Path(data, 'cache') - (cache / 'matplotlib').mkdir(parents=True) - for name in ('finbot_yahoo_cookie.json', 'finbot_graph_T1_None.png', 'finbot_x.json.abc.tmp'): # the last: a write in flight - (cache / name).write_text('{}') - Path(data, 'finbot_watchlist.json').write_text('["T1"]') # state, not cache - env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} - env.update(DATA_DIR=data, port='0', scheduler='False') - proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True) - self.addCleanup(proc.wait) - self.addCleanup(proc.kill) - deadline = time.monotonic() + 30 - while not int(Path(f'/proc/{proc.pid}/status').read_text().split('SigIgn:')[1].split()[0], 16) & (1 << (signal.SIGUSR1 - 1)): - self.assertLess(time.monotonic(), deadline) - time.sleep(0.001) - proc.send_signal(signal.SIGUSR1) # still starting: ignored, where its default action would kill the bot - lines = [] - for line in proc.stderr: - lines.append(line) - if line.startswith('Opening socket'): - break - self.assertIsNone(proc.poll(), ''.join(lines)) - self.assertEqual(len(os.listdir(cache)), 4) # nothing flushed yet - time.sleep(1) # the handler goes in just before serving - proc.send_signal(signal.SIGUSR1) - for line in proc.stderr: - lines.append(line) - if 'cache: flushed' in line: - break - self.assertIn('cache: flushed 2 files', ''.join(lines)) - self.assertEqual(sorted(os.listdir(cache)), ['finbot_x.json.abc.tmp', 'matplotlib']) - self.assertTrue(Path(data, 'finbot_watchlist.json').exists()) + """A reload (SIGHUP) that arrives while the bot is still starting must not kill it.""" + + def test_sighup_during_startup_is_ignored_and_works_afterwards(self): + skip_if_old_env(self) + with tempfile.TemporaryDirectory() as data: + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} + env.update(DATA_DIR=data, port='0', scheduler='False') + proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True) + self.addCleanup(proc.wait) + self.addCleanup(proc.kill) + deadline = time.monotonic() + 30 + while not int(Path(f'/proc/{proc.pid}/status').read_text().split('SigIgn:')[1].split()[0], 16) & 1: # until SIGHUP is ignored + self.assertLess(time.monotonic(), deadline) + time.sleep(0.001) + proc.send_signal(signal.SIGHUP) # still importing: the default action would kill it + lines = [] + for line in proc.stderr: + lines.append(line) + if line.startswith('Opening socket'): + break + self.assertIsNone(proc.poll(), ''.join(lines)) + time.sleep(1) # the handler goes in just before serving + proc.send_signal(signal.SIGHUP) + for line in proc.stderr: # a reload now runs (there is no preferences.conf here, so it says so) + lines.append(line) + if 'reload requested' in line: + break + self.assertIsNone(proc.poll(), ''.join(lines)) + self.assertIn('reload requested but', ''.join(lines)) + + def test_usr1_flushes_the_cache_and_keeps_the_state(self): + skip_if_old_env(self) + with tempfile.TemporaryDirectory() as data: + cache = Path(data, 'cache') + (cache / 'matplotlib').mkdir(parents=True) + for name in ('finbot_yahoo_cookie.json', 'finbot_graph_T1_None.png', 'finbot_x.json.abc.tmp'): # the last: a write in flight + (cache / name).write_text('{}') + Path(data, 'finbot_watchlist.json').write_text('["T1"]') # state, not cache + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} + env.update(DATA_DIR=data, port='0', scheduler='False') + proc = subprocess.Popen([sys.executable, '-u', 'bot.py'], cwd=ROOT, env=env, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True) + self.addCleanup(proc.wait) + self.addCleanup(proc.kill) + deadline = time.monotonic() + 30 + while not int(Path(f'/proc/{proc.pid}/status').read_text().split('SigIgn:')[1].split()[0], 16) & (1 << (signal.SIGUSR1 - 1)): + self.assertLess(time.monotonic(), deadline) + time.sleep(0.001) + proc.send_signal(signal.SIGUSR1) # still starting: ignored, where its default action would kill the bot + lines = [] + for line in proc.stderr: + lines.append(line) + if line.startswith('Opening socket'): + break + self.assertIsNone(proc.poll(), ''.join(lines)) + self.assertEqual(len(os.listdir(cache)), 4) # nothing flushed yet + time.sleep(1) # the handler goes in just before serving + proc.send_signal(signal.SIGUSR1) + for line in proc.stderr: + lines.append(line) + if 'cache: flushed' in line: + break + self.assertIn('cache: flushed 2 files', ''.join(lines)) + self.assertEqual(sorted(os.listdir(cache)), ['finbot_x.json.abc.tmp', 'matplotlib']) + self.assertTrue(Path(data, 'finbot_watchlist.json').exists()) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_finbot.py b/tests/test_finbot.py index 5eec41e3..fa8c9b2f 100644 --- a/tests/test_finbot.py +++ b/tests/test_finbot.py @@ -6,97 +6,97 @@ class Plumbing(FinbotCase): - def test_chunked_send_keeps_keyboard_on_last_chunk(self): - posted = [] - - class Response: - status_code = 200 - def json(self): return {'ok': True, 'result': {'message_id': len(posted), 'chat': {'id': 5}}} - - def post(url, **kwargs): - posted.append(kwargs['json']) - return Response() - - with mock.patch.object(webhook.net, 'post', post), mock.patch.object(webhook.time, 'sleep', lambda s: None): - results = REAL_PAYLOAD_WRAPPER('telegram', 'https://api.telegram.org/botX/sendMessage?chat_id=5', - ['x' * 60] * 300, '5', reply_markup={'keyboard': []}) - self.assertGreater(len(results), 1) - self.assertEqual(len(posted), len(results)) - self.assertIn('reply_markup', posted[-1]) - self.assertTrue(all('reply_markup' not in p for p in posted[:-1])) - - def test_chunks_respect_both_the_line_count_and_the_size(self): - chunks = webhook.chunked(['a' * 30] * 10 + ['b' * 500], limit=100, max_lines=4) - self.assertEqual([len(chunk) for chunk in chunks], [3, 3, 3, 1, 1]) # 3 x 30 plus newlines is 92; a fourth would pass 100 - self.assertTrue(all(len('\n'.join(chunk)) <= 100 for chunk in chunks)) - self.assertEqual(chunks[-1], ['b' * 99 + '…']) # a line over the limit on its own is cut short - self.assertEqual(webhook.chunked(['x'] * 9, limit=4096, max_lines=4), [['x'] * 4, ['x'] * 4, ['x']]) - - def test_zoneinfo_offset_is_not_lmt(self): - tz = zoneinfo.ZoneInfo('Australia/Melbourne') - self.assertEqual(datetime.datetime(2022, 8, 18, tzinfo=tz).utcoffset(), datetime.timedelta(hours=10)) - - def test_reminder_runs(self): - with mock.patch.object(reminder, 'data_json', lambda name, default=None: []): - reminder.lambda_handler() - - def test_bot_handler(self): - def post(message): - body = json.dumps({'update_id': 1, 'message': dict({'message_id': 5, 'chat': {'id': 55, 'type': 'private'}, - 'from': {'id': 9, 'first_name': 'Rob'}, 'date': 1}, **message)}).encode() - env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(body), 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} - err = io.StringIO() - with contextlib.redirect_stderr(err): - out = bot.main(env, lambda *a, **k: None) - return out, err.getvalue() - self.assertEqual(post({'sticker': {'file_id': 'x'}}), ([b''], '')) # stickers: silent, not logged - self.assertGreaterEqual(len(bot.git_version()), 7) - head = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=bot.__file__.rsplit('/', 1)[0], capture_output=True, text=True).stdout.strip() - self.assertEqual(bot.git_version(), head[:7]) # matches what git says + def test_chunked_send_keeps_keyboard_on_last_chunk(self): + posted = [] + + class Response: + status_code = 200 + def json(self): return {'ok': True, 'result': {'message_id': len(posted), 'chat': {'id': 5}}} + + def post(url, **kwargs): + posted.append(kwargs['json']) + return Response() + + with mock.patch.object(webhook.net, 'post', post), mock.patch.object(webhook.time, 'sleep', lambda s: None): + results = REAL_PAYLOAD_WRAPPER('telegram', 'https://api.telegram.org/botX/sendMessage?chat_id=5', + ['x' * 60] * 300, '5', reply_markup={'keyboard': []}) + self.assertGreater(len(results), 1) + self.assertEqual(len(posted), len(results)) + self.assertIn('reply_markup', posted[-1]) + self.assertTrue(all('reply_markup' not in p for p in posted[:-1])) + + def test_chunks_respect_both_the_line_count_and_the_size(self): + chunks = webhook.chunked(['a' * 30] * 10 + ['b' * 500], limit=100, max_lines=4) + self.assertEqual([len(chunk) for chunk in chunks], [3, 3, 3, 1, 1]) # 3 x 30 plus newlines is 92; a fourth would pass 100 + self.assertTrue(all(len('\n'.join(chunk)) <= 100 for chunk in chunks)) + self.assertEqual(chunks[-1], ['b' * 99 + '…']) # a line over the limit on its own is cut short + self.assertEqual(webhook.chunked(['x'] * 9, limit=4096, max_lines=4), [['x'] * 4, ['x'] * 4, ['x']]) + + def test_zoneinfo_offset_is_not_lmt(self): + tz = zoneinfo.ZoneInfo('Australia/Melbourne') + self.assertEqual(datetime.datetime(2022, 8, 18, tzinfo=tz).utcoffset(), datetime.timedelta(hours=10)) + + def test_reminder_runs(self): + with mock.patch.object(reminder, 'data_json', lambda name, default=None: []): + reminder.lambda_handler() + + def test_bot_handler(self): + def post(message): + body = json.dumps({'update_id': 1, 'message': dict({'message_id': 5, 'chat': {'id': 55, 'type': 'private'}, + 'from': {'id': 9, 'first_name': 'Rob'}, 'date': 1}, **message)}).encode() + env = {'PATH_INFO': '/telegram', 'wsgi.input': io.BytesIO(body), 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'tok'} + err = io.StringIO() + with contextlib.redirect_stderr(err): + out = bot.main(env, lambda *a, **k: None) + return out, err.getvalue() + self.assertEqual(post({'sticker': {'file_id': 'x'}}), ([b''], '')) # stickers: silent, not logged + self.assertGreaterEqual(len(bot.git_version()), 7) + head = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=bot.__file__.rsplit('/', 1)[0], capture_output=True, text=True).stdout.strip() + self.assertEqual(bot.git_version(), head[:7]) # matches what git says class GitVersion(unittest.TestCase): - SHA = '0123456789abcdef0123456789abcdef01234567' - - def repo(self, tmp, head, refs=None, packed=None): - git = Path(tmp, '.git') - git.mkdir(parents=True) - (git / 'HEAD').write_text(head + '\n') - for ref, sha in (refs or {}).items(): - (git / ref).parent.mkdir(parents=True, exist_ok=True) - (git / ref).write_text(sha + '\n') - if packed: - (git / 'packed-refs').write_text('# pack-refs with: peeled fully-peeled sorted\n' + packed) - return git - - def test_loose_ref_detached_and_packed(self): - with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c: - self.repo(a, 'ref: refs/heads/work', {'refs/heads/work': self.SHA}) - self.repo(b, self.SHA) - self.repo(c, 'ref: refs/heads/work', packed=f'{self.SHA} refs/heads/work\n{"f" * 40} refs/heads/other\n') - self.assertEqual([bot.git_version(d) for d in (a, b, c)], ['0123456'] * 3) - - def test_linked_worktree(self): - with tempfile.TemporaryDirectory() as main, tempfile.TemporaryDirectory() as tree: - git = self.repo(main, 'ref: refs/heads/main', {'refs/heads/main': self.SHA}) - linked = git / 'worktrees' / 'w' - linked.mkdir(parents=True) - (linked / 'HEAD').write_text('ref: refs/heads/main\n') - (linked / 'commondir').write_text('../..\n') - Path(tree, '.git').write_text(f'gitdir: {linked}\n') - self.assertEqual(bot.git_version(tree), '0123456') - - def test_unreadable_is_unknown(self): - with tempfile.TemporaryDirectory() as empty, tempfile.TemporaryDirectory() as odd: - self.repo(odd, 'ref: refs/heads/gone') - self.assertEqual([bot.git_version(empty), bot.git_version(odd), bot.git_version('/nonexistent')], ['unknown'] * 3) - self.repo(empty, 'garbage') - self.assertEqual(bot.git_version(empty), 'unknown') - - def test_does_not_run_git(self): - with mock.patch('subprocess.run', side_effect=AssertionError('git was run')): - self.assertGreaterEqual(len(bot.git_version()), 7) + SHA = '0123456789abcdef0123456789abcdef01234567' + + def repo(self, tmp, head, refs=None, packed=None): + git = Path(tmp, '.git') + git.mkdir(parents=True) + (git / 'HEAD').write_text(head + '\n') + for ref, sha in (refs or {}).items(): + (git / ref).parent.mkdir(parents=True, exist_ok=True) + (git / ref).write_text(sha + '\n') + if packed: + (git / 'packed-refs').write_text('# pack-refs with: peeled fully-peeled sorted\n' + packed) + return git + + def test_loose_ref_detached_and_packed(self): + with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b, tempfile.TemporaryDirectory() as c: + self.repo(a, 'ref: refs/heads/work', {'refs/heads/work': self.SHA}) + self.repo(b, self.SHA) + self.repo(c, 'ref: refs/heads/work', packed=f'{self.SHA} refs/heads/work\n{"f" * 40} refs/heads/other\n') + self.assertEqual([bot.git_version(d) for d in (a, b, c)], ['0123456'] * 3) + + def test_linked_worktree(self): + with tempfile.TemporaryDirectory() as main, tempfile.TemporaryDirectory() as tree: + git = self.repo(main, 'ref: refs/heads/main', {'refs/heads/main': self.SHA}) + linked = git / 'worktrees' / 'w' + linked.mkdir(parents=True) + (linked / 'HEAD').write_text('ref: refs/heads/main\n') + (linked / 'commondir').write_text('../..\n') + Path(tree, '.git').write_text(f'gitdir: {linked}\n') + self.assertEqual(bot.git_version(tree), '0123456') + + def test_unreadable_is_unknown(self): + with tempfile.TemporaryDirectory() as empty, tempfile.TemporaryDirectory() as odd: + self.repo(odd, 'ref: refs/heads/gone') + self.assertEqual([bot.git_version(empty), bot.git_version(odd), bot.git_version('/nonexistent')], ['unknown'] * 3) + self.repo(empty, 'garbage') + self.assertEqual(bot.git_version(empty), 'unknown') + + def test_does_not_run_git(self): + with mock.patch('subprocess.run', side_effect=AssertionError('git was run')): + self.assertGreaterEqual(len(bot.git_version()), 7) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_jobs.py b/tests/test_jobs.py index e95b1b7e..c65df849 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -12,657 +12,657 @@ def at(day, hour, minute, second=0): - return datetime.datetime(2026, 10, day, hour, minute, second, tzinfo=UTC) # 13 Oct 2026 is a Tuesday + return datetime.datetime(2026, 10, day, hour, minute, second, tzinfo=UTC) # 13 Oct 2026 is a Tuesday class Clock: - def __init__(self, now): - self.now = now + def __init__(self, now): + self.now = now - def __call__(self): - return self.now + def __call__(self): + return self.now class ParseTests(unittest.TestCase): - def test_shipped_schedule_has_no_errors(self): - parsed, errors = jobs.parse_schedule(DEFAULT_SCHEDULE.read_text(), UTC) - self.assertEqual(errors, []) - self.assertEqual(len(parsed), 9) - weekly = next(j for j in parsed if j.expr == '28 21 * * Sat') - self.assertEqual([label for label, _, _ in weekly.commands], - ['cal.py earnings', 'cal.py ex-dividend', 'price.py 7', 'performance.py 7', 'rating.py']) - - def test_pasted_crontab_lines(self): - text = ('MAILTO=me\n' - '*/20 * * * Mon-Fri ~/finbot/trades.py > ~/finbot.trades 2>&1\n' - '28 21 * * Sat { ./cal.py earnings; ./price.py 7 ;} &> /dev/null # weekly (UTC)\n' - '@daily /opt/finbot/reminder.py\n') - parsed, errors = jobs.parse_schedule(text, UTC) - self.assertEqual(errors, []) - self.assertEqual([[label for label, _, _ in j.commands] for j in parsed], - [['trades.py'], ['cal.py earnings', 'price.py 7'], ['reminder.py']]) - - def test_quoted_arguments_may_hold_anything(self): - url = 'https://example.com/feed?a=1&b=2;c>d $x |y' - parsed, errors = jobs.parse_schedule(f'01 0-10,23 * * Mon-Fri podcasts.py "{url}" > ~/out 2>&1 # hourly (UTC)\n', UTC) - self.assertEqual(errors, []) - self.assertEqual(parsed[0].commands[0][2], {'feed_url': url}) - parsed, errors = jobs.parse_schedule(f"5 4 * * * podcasts.py '{url}'; shorts.py\n", UTC) - self.assertEqual([(label[:9], kw) for label, _, kw in parsed[0].commands], [('podcasts.', {'feed_url': url}), ('shorts.py', {})]) - - def test_unquoted_shell_syntax_is_still_refused(self): - for line in ('0 1 * * * trades.py | tee x', '0 1 * * * trades.py && shorts.py & ', '0 1 * * * trades.py $(date)', '0 1 * * * trades.py `x`', - '0 1 * * * trades.py < in', '0 1 * * * (trades.py)', '0 1 * * * trades.py "unterminated'): - parsed, errors = jobs.parse_schedule(line + '\n', UTC) - self.assertEqual((parsed, len(errors)), ([], 1), line) - - def test_redirects_and_braces_are_dropped(self): - for command in ('trades.py > /dev/null', 'trades.py >/dev/null 2>&1', 'trades.py &> /dev/null', 'trades.py >> ~/x 2>&1', - '{ trades.py ;} > ~/x', '/opt/finbot/trades.py # note (x)'): - parsed, errors = jobs.parse_schedule(f'0 1 * * * {command}\n', UTC) - self.assertEqual(errors, [], command) - self.assertEqual([label for label, _, _ in parsed[0].commands], ['trades.py'], command) - - def test_bin_run_wrapper_is_no_longer_accepted(self): - for command in ('bin/run trades.py', 'cd /opt/finbot; trades.py'): - parsed, errors = jobs.parse_schedule(f'0 1 * * * {command}\n', UTC) - self.assertEqual((parsed, len(errors)), ([], 1), command) - - def test_bad_lines_are_skipped_and_named(self): - text = '0 1 * * * ls | wc -l\n0 1 * * * price.py bogus\n0 1 * * * nothere.py\n61 1 * * * trades.py\n0 1 * *\nFOO=bar\n5 4 * * * shorts.py\n' - parsed, errors = jobs.parse_schedule(text, UTC) - self.assertEqual([j.expr for j in parsed], ['5 4 * * *']) - self.assertEqual([number for number, _, _ in errors], [1, 2, 3, 4, 5, 6]) - - def test_impossible_times_are_marked_and_reboot_is_just_unsupported(self): - for line in ('0 0 30 2 * trades.py', '0 9 31 4,6,9,11 * trades.py', '61 1 * * * trades.py', '0 0 * 13 * trades.py', '@bogus trades.py'): - parsed, errors = jobs.parse_schedule(line + '\n', UTC) - self.assertEqual(parsed, [], line) - self.assertIsInstance(errors[0][2], jobs.ImpossibleTime, line) - parsed, errors = jobs.parse_schedule('@reboot trades.py\n0 0 29 2 * trades.py\n', UTC) - self.assertEqual([j.expr for j in parsed], ['0 0 29 2 *']) # a leap day does come - self.assertNotIsInstance(errors[0][2], jobs.ImpossibleTime) - - def test_cron_tz_applies_to_the_lines_below(self): - parsed, _ = jobs.parse_schedule('1 1 * * * trades.py\nCRON_TZ=Australia/Melbourne\n2 2 * * * trades.py\nCRON_TZ=Nowhere/Land\n', UTC) - self.assertEqual([j.zone.key for j in parsed], ['UTC', 'Australia/Melbourne']) - - def test_local_zone_follows_tz(self): - with mock.patch.dict(os.environ, {'TZ': 'Australia/Melbourne'}): - self.assertEqual(jobs.local_zone().key, 'Australia/Melbourne') + def test_shipped_schedule_has_no_errors(self): + parsed, errors = jobs.parse_schedule(DEFAULT_SCHEDULE.read_text(), UTC) + self.assertEqual(errors, []) + self.assertEqual(len(parsed), 9) + weekly = next(j for j in parsed if j.expr == '28 21 * * Sat') + self.assertEqual([label for label, _, _ in weekly.commands], + ['cal.py earnings', 'cal.py ex-dividend', 'price.py 7', 'performance.py 7', 'rating.py']) + + def test_pasted_crontab_lines(self): + text = ('MAILTO=me\n' + '*/20 * * * Mon-Fri ~/finbot/trades.py > ~/finbot.trades 2>&1\n' + '28 21 * * Sat { ./cal.py earnings; ./price.py 7 ;} &> /dev/null # weekly (UTC)\n' + '@daily /opt/finbot/reminder.py\n') + parsed, errors = jobs.parse_schedule(text, UTC) + self.assertEqual(errors, []) + self.assertEqual([[label for label, _, _ in j.commands] for j in parsed], + [['trades.py'], ['cal.py earnings', 'price.py 7'], ['reminder.py']]) + + def test_quoted_arguments_may_hold_anything(self): + url = 'https://example.com/feed?a=1&b=2;c>d $x |y' + parsed, errors = jobs.parse_schedule(f'01 0-10,23 * * Mon-Fri podcasts.py "{url}" > ~/out 2>&1 # hourly (UTC)\n', UTC) + self.assertEqual(errors, []) + self.assertEqual(parsed[0].commands[0][2], {'feed_url': url}) + parsed, errors = jobs.parse_schedule(f"5 4 * * * podcasts.py '{url}'; shorts.py\n", UTC) + self.assertEqual([(label[:9], kw) for label, _, kw in parsed[0].commands], [('podcasts.', {'feed_url': url}), ('shorts.py', {})]) + + def test_unquoted_shell_syntax_is_still_refused(self): + for line in ('0 1 * * * trades.py | tee x', '0 1 * * * trades.py && shorts.py & ', '0 1 * * * trades.py $(date)', '0 1 * * * trades.py `x`', + '0 1 * * * trades.py < in', '0 1 * * * (trades.py)', '0 1 * * * trades.py "unterminated'): + parsed, errors = jobs.parse_schedule(line + '\n', UTC) + self.assertEqual((parsed, len(errors)), ([], 1), line) + + def test_redirects_and_braces_are_dropped(self): + for command in ('trades.py > /dev/null', 'trades.py >/dev/null 2>&1', 'trades.py &> /dev/null', 'trades.py >> ~/x 2>&1', + '{ trades.py ;} > ~/x', '/opt/finbot/trades.py # note (x)'): + parsed, errors = jobs.parse_schedule(f'0 1 * * * {command}\n', UTC) + self.assertEqual(errors, [], command) + self.assertEqual([label for label, _, _ in parsed[0].commands], ['trades.py'], command) + + def test_bin_run_wrapper_is_no_longer_accepted(self): + for command in ('bin/run trades.py', 'cd /opt/finbot; trades.py'): + parsed, errors = jobs.parse_schedule(f'0 1 * * * {command}\n', UTC) + self.assertEqual((parsed, len(errors)), ([], 1), command) + + def test_bad_lines_are_skipped_and_named(self): + text = '0 1 * * * ls | wc -l\n0 1 * * * price.py bogus\n0 1 * * * nothere.py\n61 1 * * * trades.py\n0 1 * *\nFOO=bar\n5 4 * * * shorts.py\n' + parsed, errors = jobs.parse_schedule(text, UTC) + self.assertEqual([j.expr for j in parsed], ['5 4 * * *']) + self.assertEqual([number for number, _, _ in errors], [1, 2, 3, 4, 5, 6]) + + def test_impossible_times_are_marked_and_reboot_is_just_unsupported(self): + for line in ('0 0 30 2 * trades.py', '0 9 31 4,6,9,11 * trades.py', '61 1 * * * trades.py', '0 0 * 13 * trades.py', '@bogus trades.py'): + parsed, errors = jobs.parse_schedule(line + '\n', UTC) + self.assertEqual(parsed, [], line) + self.assertIsInstance(errors[0][2], jobs.ImpossibleTime, line) + parsed, errors = jobs.parse_schedule('@reboot trades.py\n0 0 29 2 * trades.py\n', UTC) + self.assertEqual([j.expr for j in parsed], ['0 0 29 2 *']) # a leap day does come + self.assertNotIsInstance(errors[0][2], jobs.ImpossibleTime) + + def test_cron_tz_applies_to_the_lines_below(self): + parsed, _ = jobs.parse_schedule('1 1 * * * trades.py\nCRON_TZ=Australia/Melbourne\n2 2 * * * trades.py\nCRON_TZ=Nowhere/Land\n', UTC) + self.assertEqual([j.zone.key for j in parsed], ['UTC', 'Australia/Melbourne']) + + def test_local_zone_follows_tz(self): + with mock.patch.dict(os.environ, {'TZ': 'Australia/Melbourne'}): + self.assertEqual(jobs.local_zone().key, 'Australia/Melbourne') class JobArguments(unittest.TestCase): - def test_price(self): - self.assertEqual(price.job(['midsession']), ('price.py midsession', {'midsession': True})) - self.assertEqual(price.job(['premarket'])[1], {'premarket': True}) - self.assertEqual(price.job(['interday'])[1], {'interday': True}) - self.assertEqual(price.job(['close'])[1], {'close': True}) - self.assertEqual(price.job(['28']), ('price.py 28', {'threshold': 0, 'days': 28, 'top': 10})) - for bad in ([], ['soon'], ['7', '8']): - with self.assertRaises(ValueError): - price.job(bad) - - def test_cal_and_performance(self): - self.assertEqual(cal.job(['earnings']), ('cal.py earnings', {'event': 'earnings'})) - self.assertEqual(cal.job(['ex-dividend']), ('cal.py ex-dividend', {'event': 'ex-dividend'})) - self.assertEqual(performance.job([]), ('performance.py', {})) - self.assertEqual(performance.job(['7']), ('performance.py 7', {'past_days': 7})) - for bad, module in ((['x'], cal), ([], cal), (['x'], performance)): - with self.assertRaises(ValueError): - module.job(bad) - - def test_podcasts(self): - url = 'https://example.com/podcast.rss' - label, kwargs = podcasts.job([url]) - self.assertEqual(kwargs, {'feed_url': url}) - self.assertIn('finbot_podcast_seen_' + label.split()[1], podcasts.get_seen_filename(url)) # the label is the start of the state file's hash - self.assertEqual(label, 'podcasts.py ' + hashlib.md5(url.encode()).hexdigest()[:8]) - for bad in ([], [url, url]): - with self.assertRaises(ValueError): - podcasts.job(bad) - - def test_cleanup_arguments(self): - self.assertEqual(cleanup.job([]), ('cleanup.py', {})) - self.assertEqual(cleanup.job(['7']), ('cleanup.py 7', {'days': 7})) - for bad in (['x'], ['1', '2']): - with self.assertRaises(ValueError): - cleanup.job(bad) - - def test_plain_scripts(self): - for name, module in jobs.SCRIPTS.items(): - if name not in ('cal', 'cleanup', 'performance', 'podcasts', 'price'): - self.assertEqual(module.job([]), (name + '.py', {})) - - def test_cli_prints_usage_and_exits(self): - with mock.patch('sys.stderr', new_callable=io.StringIO) as err, self.assertRaises(SystemExit) as exit_: - webhook.cli('price.py', price.job, lambda **k: None, ['bogus']) - self.assertEqual(exit_.exception.code, 1) - self.assertIn('Usage: price.py [midsession', err.getvalue()) - - def test_cli_runs_the_handler(self): - calls = [] - webhook.cli('price.py', price.job, lambda **k: calls.append(k), ['midsession']) - self.assertEqual(calls, [{'midsession': True}]) + def test_price(self): + self.assertEqual(price.job(['midsession']), ('price.py midsession', {'midsession': True})) + self.assertEqual(price.job(['premarket'])[1], {'premarket': True}) + self.assertEqual(price.job(['interday'])[1], {'interday': True}) + self.assertEqual(price.job(['close'])[1], {'close': True}) + self.assertEqual(price.job(['28']), ('price.py 28', {'threshold': 0, 'days': 28, 'top': 10})) + for bad in ([], ['soon'], ['7', '8']): + with self.assertRaises(ValueError): + price.job(bad) + + def test_cal_and_performance(self): + self.assertEqual(cal.job(['earnings']), ('cal.py earnings', {'event': 'earnings'})) + self.assertEqual(cal.job(['ex-dividend']), ('cal.py ex-dividend', {'event': 'ex-dividend'})) + self.assertEqual(performance.job([]), ('performance.py', {})) + self.assertEqual(performance.job(['7']), ('performance.py 7', {'past_days': 7})) + for bad, module in ((['x'], cal), ([], cal), (['x'], performance)): + with self.assertRaises(ValueError): + module.job(bad) + + def test_podcasts(self): + url = 'https://example.com/podcast.rss' + label, kwargs = podcasts.job([url]) + self.assertEqual(kwargs, {'feed_url': url}) + self.assertIn('finbot_podcast_seen_' + label.split()[1], podcasts.get_seen_filename(url)) # the label is the start of the state file's hash + self.assertEqual(label, 'podcasts.py ' + hashlib.md5(url.encode()).hexdigest()[:8]) + for bad in ([], [url, url]): + with self.assertRaises(ValueError): + podcasts.job(bad) + + def test_cleanup_arguments(self): + self.assertEqual(cleanup.job([]), ('cleanup.py', {})) + self.assertEqual(cleanup.job(['7']), ('cleanup.py 7', {'days': 7})) + for bad in (['x'], ['1', '2']): + with self.assertRaises(ValueError): + cleanup.job(bad) + + def test_plain_scripts(self): + for name, module in jobs.SCRIPTS.items(): + if name not in ('cal', 'cleanup', 'performance', 'podcasts', 'price'): + self.assertEqual(module.job([]), (name + '.py', {})) + + def test_cli_prints_usage_and_exits(self): + with mock.patch('sys.stderr', new_callable=io.StringIO) as err, self.assertRaises(SystemExit) as exit_: + webhook.cli('price.py', price.job, lambda **k: None, ['bogus']) + self.assertEqual(exit_.exception.code, 1) + self.assertIn('Usage: price.py [midsession', err.getvalue()) + + def test_cli_runs_the_handler(self): + calls = [] + webhook.cli('price.py', price.job, lambda **k: calls.append(k), ['midsession']) + self.assertEqual(calls, [{'midsession': True}]) class CleanupJob(FinbotCase): - def test_removes_only_old_finbot_cache_files(self): - folder = tempfile.mkdtemp() - self.addCleanup(lambda: __import__('shutil').rmtree(folder, ignore_errors=True)) - self.patch(cleanup, 'config_cache_dir', folder) - old, new = time.time() - 40 * 86400, time.time() - 5 * 86400 - for name, stamp in (('finbot_old.json', old), ('finbot_new.json', new), ('other_old.json', old), ('finbot_old.png', old)): - path = os.path.join(folder, name) - Path(path).write_text('x') - os.utime(path, (stamp, stamp)) - os.mkdir(os.path.join(folder, 'finbot_dir')) - os.utime(os.path.join(folder, 'finbot_dir'), (old, old)) - self.assertEqual(cleanup.lambda_handler(30), 2) - self.assertEqual(sorted(os.listdir(folder)), ['finbot_dir', 'finbot_new.json', 'other_old.json']) - - def test_a_temp_file_being_written_is_left_alone(self): - folder = tempfile.mkdtemp() - self.addCleanup(lambda: __import__('shutil').rmtree(folder, ignore_errors=True)) - self.patch(cleanup, 'config_cache_dir', folder) - for name, stamp in (('finbot_a.json.x1.tmp', time.time() - 5), ('finbot_b.json.x2.tmp', time.time() - 40 * 86400)): - Path(folder, name).write_text('x') - os.utime(Path(folder, name), (stamp, stamp)) - self.assertEqual(cleanup.lambda_handler(0), 1) # a flush: the stale one goes, the write in flight stays - self.assertEqual(os.listdir(folder), ['finbot_a.json.x1.tmp']) - - def test_missing_cache_dir_is_fine(self): - self.patch(cleanup, 'config_cache_dir', '/nonexistent/finbot-cache') - self.assertEqual(cleanup.lambda_handler(30), 0) + def test_removes_only_old_finbot_cache_files(self): + folder = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(folder, ignore_errors=True)) + self.patch(cleanup, 'config_cache_dir', folder) + old, new = time.time() - 40 * 86400, time.time() - 5 * 86400 + for name, stamp in (('finbot_old.json', old), ('finbot_new.json', new), ('other_old.json', old), ('finbot_old.png', old)): + path = os.path.join(folder, name) + Path(path).write_text('x') + os.utime(path, (stamp, stamp)) + os.mkdir(os.path.join(folder, 'finbot_dir')) + os.utime(os.path.join(folder, 'finbot_dir'), (old, old)) + self.assertEqual(cleanup.lambda_handler(30), 2) + self.assertEqual(sorted(os.listdir(folder)), ['finbot_dir', 'finbot_new.json', 'other_old.json']) + + def test_a_temp_file_being_written_is_left_alone(self): + folder = tempfile.mkdtemp() + self.addCleanup(lambda: __import__('shutil').rmtree(folder, ignore_errors=True)) + self.patch(cleanup, 'config_cache_dir', folder) + for name, stamp in (('finbot_a.json.x1.tmp', time.time() - 5), ('finbot_b.json.x2.tmp', time.time() - 40 * 86400)): + Path(folder, name).write_text('x') + os.utime(Path(folder, name), (stamp, stamp)) + self.assertEqual(cleanup.lambda_handler(0), 1) # a flush: the stale one goes, the write in flight stays + self.assertEqual(os.listdir(folder), ['finbot_a.json.x1.tmp']) + + def test_missing_cache_dir_is_fine(self): + self.patch(cleanup, 'config_cache_dir', '/nonexistent/finbot-cache') + self.assertEqual(cleanup.lambda_handler(30), 0) class SchedulerFlag(unittest.TestCase): - def flag(self, **env): - code = 'from lib import config; print(config.config_scheduler)' - e = {k: v for k, v in os.environ.items() if k != 'scheduler'} - e.update(env) - repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return subprocess.run([sys.executable, '-c', code], cwd=repo, env=e, capture_output=True, text=True, check=True).stdout.split()[-1] + def flag(self, **env): + code = 'from lib import config; print(config.config_scheduler)' + e = {k: v for k, v in os.environ.items() if k != 'scheduler'} + e.update(env) + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return subprocess.run([sys.executable, '-c', code], cwd=repo, env=e, capture_output=True, text=True, check=True).stdout.split()[-1] - def test_on_by_default_and_off_for_false(self): - self.assertEqual(self.flag(), 'True') - for value in ('True', 'true', '1', 't'): - self.assertEqual(self.flag(scheduler=value), 'True', value) - for value in ('False', 'false', '0', 'no'): - self.assertEqual(self.flag(scheduler=value), 'False', value) + def test_on_by_default_and_off_for_false(self): + self.assertEqual(self.flag(), 'True') + for value in ('True', 'true', '1', 't'): + self.assertEqual(self.flag(scheduler=value), 'True', value) + for value in ('False', 'false', '0', 'no'): + self.assertEqual(self.flag(scheduler=value), 'False', value) class SchedulerBase(FinbotCase): - def setUp(self): - super().setUp() - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.file = Path(tmp.name, 'schedule.cron') - self.file.write_text('*/20 * * * Mon-Fri trades.py\n') - self.clock = Clock(at(13, 21, 3)) - self.ran = [] - self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', lambda **k: self.ran.append(('trades', k))) - self.patch(jobs.SCRIPTS['shorts'], 'lambda_handler', lambda **k: self.ran.append(('shorts', k))) - - def scheduler(self, catchup=5): - s = jobs.Scheduler(lambda: self.file, catchup=catchup, clock=self.clock, sleep=lambda seconds: None, - spawn=lambda f, *a: f(*a), zone=UTC, runner=jobs.run_in_process) - self.assertTrue(s.load()) - return s + def setUp(self): + super().setUp() + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.file = Path(tmp.name, 'schedule.cron') + self.file.write_text('*/20 * * * Mon-Fri trades.py\n') + self.clock = Clock(at(13, 21, 3)) + self.ran = [] + self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', lambda **k: self.ran.append(('trades', k))) + self.patch(jobs.SCRIPTS['shorts'], 'lambda_handler', lambda **k: self.ran.append(('shorts', k))) + + def scheduler(self, catchup=5): + s = jobs.Scheduler(lambda: self.file, catchup=catchup, clock=self.clock, sleep=lambda seconds: None, + spawn=lambda f, *a: f(*a), zone=UTC, runner=jobs.run_in_process) + self.assertTrue(s.load()) + return s class SchedulerTests(SchedulerBase): - def test_fires_on_the_minute_on_weekdays_only(self): - s = self.scheduler() - self.clock.now = at(13, 21, 19, 59) - self.assertEqual(s.due(self.clock.now), []) - self.clock.now = at(13, 21, 20) - self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 20)]) - self.assertEqual(s.due(self.clock.now), []) # not twice - self.clock.now = at(13, 21, 40, 30) - self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 40)]) - self.clock.now = at(16, 23, 50) # the rest of the week, seen at once: the many missed runs coalesce into one - self.assertEqual([t for _, t in s.due(self.clock.now)], [at(16, 23, 40)]) - self.clock.now = at(17, 21, 20) # a Saturday - self.assertEqual(s.due(self.clock.now), []) - - def test_tick_runs_the_job_and_returns_the_wait(self): - s = self.scheduler() - self.clock.now = at(13, 21, 20) - wait = s.tick() - self.assertEqual(self.ran, [('trades', {})]) - self.assertEqual(wait, 30) # the next run is 20 minutes away: wake at most every 30 seconds - self.clock.now = at(13, 21, 39, 50) - self.assertEqual(s.tick(), 10) - - def test_a_run_missed_within_the_catch_up_window_fires_once(self): - first = self.scheduler() - first.last = {first.jobs[0].key: at(13, 21, 0).isoformat()} - util.json_write(jobs.STATE_FILE, first.last, persist=True) - self.clock.now = at(13, 21, 43) - s = self.scheduler() - self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 40)]) # 21:20 is older than 5 minutes: skipped - - def test_a_corrupt_state_file_is_reported_and_the_schedule_starts_afresh(self): - Path(util.config_var_dir, jobs.STATE_FILE).write_text('{"*/20 * * * Mon-Fri|UTC|trades.py": "2026-10-13T21:') # cut off mid-write - s = self.scheduler() - self.assertEqual((s.last, len(self.errors)), ({}, 1)) - self.assertIn('cannot read finbot_jobs.json', self.errors[0]) - self.clock.now = at(13, 21, 20) - self.assertEqual(len(s.due(self.clock.now)), 1) # runs as usual, and the record is written afresh - self.assertEqual(util.json_load(jobs.STATE_FILE, persist=True), {s.jobs[0].key: at(13, 21, 20).isoformat()}) - s.reload() # and a reload is no trouble either - self.assertEqual(len(self.errors), 1) - - def test_no_record_means_nothing_is_missed(self): - self.clock.now = at(13, 21, 43) - s = self.scheduler() - self.assertEqual(s.due(self.clock.now), []) - - def test_a_restart_just_after_a_run_does_not_repeat_it(self): - s = self.scheduler() - self.clock.now = at(13, 21, 20, 5) - self.assertEqual(len(s.due(self.clock.now)), 1) - self.clock.now = at(13, 21, 21) - again = self.scheduler() - self.assertEqual(again.due(self.clock.now), []) - - def test_an_overlapping_run_is_skipped(self): - s = self.scheduler() - s.jobs[0].running = True - s.run_job(s.jobs[0], at(13, 21, 20)) - self.assertEqual(self.ran, []) - s.jobs[0].running = False - s.run_job(s.jobs[0], at(13, 21, 20)) - self.assertEqual(self.ran, [('trades', {})]) - self.assertFalse(s.jobs[0].running) - - def test_a_failing_job_is_reported_and_the_next_command_still_runs(self): - self.file.write_text('*/20 * * * * trades.py; shorts.py\n') - def boom(**k): - raise RuntimeError('boom') - self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', boom) - s = self.scheduler() - s.run_job(s.jobs[0], at(13, 21, 20)) - self.assertEqual(self.ran, [('shorts', {})]) - self.assertTrue(any('boom' in e for e in self.errors)) - - def test_system_exit_inside_a_job_is_reported_not_raised(self): - def quit_(**k): - raise SystemExit(3) - self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', quit_) - s = self.scheduler() - s.run_job(s.jobs[0], at(13, 21, 20)) - self.assertEqual(len(self.errors), 1) - self.assertFalse(s.jobs[0].running) - - def test_a_job_that_hangs_is_abandoned_and_reported(self): - self.patch(jobs, 'JOB_TIMEOUT', 0.05) - self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', lambda **k: gevent.sleep(5)) - s = self.scheduler() - s.run_job(s.jobs[0], at(13, 21, 20)) - self.assertTrue(any('still running' in e for e in self.errors)) - self.assertFalse(s.jobs[0].running) - - def test_reload_applies_edits_keeps_state_and_survives_a_bad_file(self): - s = self.scheduler() - old_key = s.jobs[0].key - base = s.base[old_key] - self.file.write_text('*/20 * * * Mon-Fri trades.py\n5 4 * * * shorts.py\n') - self.assertTrue(s.load()) - self.assertEqual(len(s.jobs), 2) - self.assertEqual(s.base[old_key], base) - self.file.write_text('# nothing usable\nnot a job\n') - self.assertFalse(s.load()) - self.assertEqual(len(s.jobs), 2) - - def test_an_impossible_time_refuses_the_whole_file(self): - s = self.scheduler() - self.file.write_text('*/20 * * * Mon-Fri trades.py\n0 9 30 2 * shorts.py\n5 4 * * * nothere.py\n') - with self.assertRaises(jobs.ImpossibleTime) as caught: - s.load() - self.assertEqual(len(caught.exception.lines), 1) - self.assertIn("line 2: '0 9 30 2 *' never comes", caught.exception.lines[0]) - self.assertEqual(self.errors, []) # load reports nothing for a refused file... - s.reload() # ...a reload says why it was refused, and the running schedule carries on - self.assertEqual(len(self.errors), 1) - self.assertIn('not reloaded, the running schedule is unchanged', self.errors[0]) - self.assertEqual([j.text for j in s.jobs], ['*/20 * * * Mon-Fri trades.py']) - self.clock.now = at(13, 21, 20) - self.assertEqual(len(s.due(self.clock.now)), 1) - - def test_load_reports_bad_lines_once_and_runs_the_rest(self): - self.file.write_text('0 1 * * * nothere.py\n*/20 * * * * trades.py\n') - s = self.scheduler() - self.assertEqual(len(s.jobs), 1) - self.assertEqual(len(self.errors), 1) - - def test_run_forever_survives_an_error(self): - s = self.scheduler() - calls = [] - def sleep(seconds): - calls.append(seconds) - if len(calls) == 3: - raise KeyboardInterrupt - s.sleep = sleep - with mock.patch.object(s, 'tick', side_effect=[RuntimeError('tick failed'), 30, 30]): - with self.assertRaises(KeyboardInterrupt): - s.run_forever() - self.assertEqual(calls, [30, 30, 30]) - self.assertTrue(any('tick failed' in e for e in self.errors)) + def test_fires_on_the_minute_on_weekdays_only(self): + s = self.scheduler() + self.clock.now = at(13, 21, 19, 59) + self.assertEqual(s.due(self.clock.now), []) + self.clock.now = at(13, 21, 20) + self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 20)]) + self.assertEqual(s.due(self.clock.now), []) # not twice + self.clock.now = at(13, 21, 40, 30) + self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 40)]) + self.clock.now = at(16, 23, 50) # the rest of the week, seen at once: the many missed runs coalesce into one + self.assertEqual([t for _, t in s.due(self.clock.now)], [at(16, 23, 40)]) + self.clock.now = at(17, 21, 20) # a Saturday + self.assertEqual(s.due(self.clock.now), []) + + def test_tick_runs_the_job_and_returns_the_wait(self): + s = self.scheduler() + self.clock.now = at(13, 21, 20) + wait = s.tick() + self.assertEqual(self.ran, [('trades', {})]) + self.assertEqual(wait, 30) # the next run is 20 minutes away: wake at most every 30 seconds + self.clock.now = at(13, 21, 39, 50) + self.assertEqual(s.tick(), 10) + + def test_a_run_missed_within_the_catch_up_window_fires_once(self): + first = self.scheduler() + first.last = {first.jobs[0].key: at(13, 21, 0).isoformat()} + util.json_write(jobs.STATE_FILE, first.last, persist=True) + self.clock.now = at(13, 21, 43) + s = self.scheduler() + self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 40)]) # 21:20 is older than 5 minutes: skipped + + def test_a_corrupt_state_file_is_reported_and_the_schedule_starts_afresh(self): + Path(util.config_var_dir, jobs.STATE_FILE).write_text('{"*/20 * * * Mon-Fri|UTC|trades.py": "2026-10-13T21:') # cut off mid-write + s = self.scheduler() + self.assertEqual((s.last, len(self.errors)), ({}, 1)) + self.assertIn('cannot read finbot_jobs.json', self.errors[0]) + self.clock.now = at(13, 21, 20) + self.assertEqual(len(s.due(self.clock.now)), 1) # runs as usual, and the record is written afresh + self.assertEqual(util.json_load(jobs.STATE_FILE, persist=True), {s.jobs[0].key: at(13, 21, 20).isoformat()}) + s.reload() # and a reload is no trouble either + self.assertEqual(len(self.errors), 1) + + def test_no_record_means_nothing_is_missed(self): + self.clock.now = at(13, 21, 43) + s = self.scheduler() + self.assertEqual(s.due(self.clock.now), []) + + def test_a_restart_just_after_a_run_does_not_repeat_it(self): + s = self.scheduler() + self.clock.now = at(13, 21, 20, 5) + self.assertEqual(len(s.due(self.clock.now)), 1) + self.clock.now = at(13, 21, 21) + again = self.scheduler() + self.assertEqual(again.due(self.clock.now), []) + + def test_an_overlapping_run_is_skipped(self): + s = self.scheduler() + s.jobs[0].running = True + s.run_job(s.jobs[0], at(13, 21, 20)) + self.assertEqual(self.ran, []) + s.jobs[0].running = False + s.run_job(s.jobs[0], at(13, 21, 20)) + self.assertEqual(self.ran, [('trades', {})]) + self.assertFalse(s.jobs[0].running) + + def test_a_failing_job_is_reported_and_the_next_command_still_runs(self): + self.file.write_text('*/20 * * * * trades.py; shorts.py\n') + def boom(**k): + raise RuntimeError('boom') + self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', boom) + s = self.scheduler() + s.run_job(s.jobs[0], at(13, 21, 20)) + self.assertEqual(self.ran, [('shorts', {})]) + self.assertTrue(any('boom' in e for e in self.errors)) + + def test_system_exit_inside_a_job_is_reported_not_raised(self): + def quit_(**k): + raise SystemExit(3) + self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', quit_) + s = self.scheduler() + s.run_job(s.jobs[0], at(13, 21, 20)) + self.assertEqual(len(self.errors), 1) + self.assertFalse(s.jobs[0].running) + + def test_a_job_that_hangs_is_abandoned_and_reported(self): + self.patch(jobs, 'JOB_TIMEOUT', 0.05) + self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', lambda **k: gevent.sleep(5)) + s = self.scheduler() + s.run_job(s.jobs[0], at(13, 21, 20)) + self.assertTrue(any('still running' in e for e in self.errors)) + self.assertFalse(s.jobs[0].running) + + def test_reload_applies_edits_keeps_state_and_survives_a_bad_file(self): + s = self.scheduler() + old_key = s.jobs[0].key + base = s.base[old_key] + self.file.write_text('*/20 * * * Mon-Fri trades.py\n5 4 * * * shorts.py\n') + self.assertTrue(s.load()) + self.assertEqual(len(s.jobs), 2) + self.assertEqual(s.base[old_key], base) + self.file.write_text('# nothing usable\nnot a job\n') + self.assertFalse(s.load()) + self.assertEqual(len(s.jobs), 2) + + def test_an_impossible_time_refuses_the_whole_file(self): + s = self.scheduler() + self.file.write_text('*/20 * * * Mon-Fri trades.py\n0 9 30 2 * shorts.py\n5 4 * * * nothere.py\n') + with self.assertRaises(jobs.ImpossibleTime) as caught: + s.load() + self.assertEqual(len(caught.exception.lines), 1) + self.assertIn("line 2: '0 9 30 2 *' never comes", caught.exception.lines[0]) + self.assertEqual(self.errors, []) # load reports nothing for a refused file... + s.reload() # ...a reload says why it was refused, and the running schedule carries on + self.assertEqual(len(self.errors), 1) + self.assertIn('not reloaded, the running schedule is unchanged', self.errors[0]) + self.assertEqual([j.text for j in s.jobs], ['*/20 * * * Mon-Fri trades.py']) + self.clock.now = at(13, 21, 20) + self.assertEqual(len(s.due(self.clock.now)), 1) + + def test_load_reports_bad_lines_once_and_runs_the_rest(self): + self.file.write_text('0 1 * * * nothere.py\n*/20 * * * * trades.py\n') + s = self.scheduler() + self.assertEqual(len(s.jobs), 1) + self.assertEqual(len(self.errors), 1) + + def test_run_forever_survives_an_error(self): + s = self.scheduler() + calls = [] + def sleep(seconds): + calls.append(seconds) + if len(calls) == 3: + raise KeyboardInterrupt + s.sleep = sleep + with mock.patch.object(s, 'tick', side_effect=[RuntimeError('tick failed'), 30, 30]): + with self.assertRaises(KeyboardInterrupt): + s.run_forever() + self.assertEqual(calls, [30, 30, 30]) + self.assertTrue(any('tick failed' in e for e in self.errors)) class DaylightSaving(unittest.TestCase): - """Melbourne's clocks go back an hour at 03:00 on 5 April 2026 and forward at 02:00 on 4 October 2026. The fire times must be - right on any croniter: the distro packages (up to 6.0) got zoneinfo's changes wrong.""" - MEL = zoneinfo.ZoneInfo('Australia/Melbourne') - - def fires(self, expr, start, end): - job = jobs.parse_schedule(f'{expr} trades.py\n', self.MEL)[0][0] - s = jobs.Scheduler(lambda: None, zone=self.MEL) - times, t = [], start - while (t := s.next_fire(job, t)) <= end: - times.append(t.astimezone(UTC).astimezone(self.MEL).strftime('%d %H:%M %Z')) # by way of UTC: the real local time - return times - - def mel(self, *args, **kw): - return datetime.datetime(*args, tzinfo=self.MEL, **kw) - - def test_a_daily_job_fires_once_at_its_time(self): - self.assertEqual(self.fires('0 9 * * *', self.mel(2026, 10, 2), self.mel(2026, 10, 6)), ['02 09:00 AEST', '03 09:00 AEST', '04 09:00 AEDT', '05 09:00 AEDT']) - self.assertEqual(self.fires('0 9 * * *', self.mel(2026, 4, 3), self.mel(2026, 4, 7)), ['03 09:00 AEDT', '04 09:00 AEDT', '05 09:00 AEST', '06 09:00 AEST']) - - def test_a_skipped_or_repeated_time_fires_once(self): - self.assertEqual(self.fires('30 2 * * *', self.mel(2026, 10, 3), self.mel(2026, 10, 6)), ['03 02:30 AEST', '04 03:30 AEDT', '05 02:30 AEDT']) - self.assertEqual(self.fires('30 2 * * *', self.mel(2026, 4, 4), self.mel(2026, 4, 7)), ['04 02:30 AEDT', '05 02:30 AEDT', '06 02:30 AEST']) - self.assertEqual(self.fires('*/20 * * * *', self.mel(2026, 10, 4, 1, 30), self.mel(2026, 10, 4, 3, 50)), ['04 01:40 AEST', '04 03:00 AEDT', '04 03:20 AEDT', '04 03:40 AEDT']) - self.assertEqual(self.fires('*/20 * * * *', self.mel(2026, 4, 5, 1, 30), self.mel(2026, 4, 5, 3, 30)), - ['05 01:40 AEDT', '05 02:00 AEDT', '05 02:20 AEDT', '05 02:40 AEDT', '05 03:00 AEST', '05 03:20 AEST']) - - def test_starting_in_the_repeated_hour_runs_nothing_twice(self): - second_time_round = self.mel(2026, 4, 5, 2, 10, fold=1).astimezone(UTC) - self.assertEqual(self.fires('*/20 * * * *', second_time_round, self.mel(2026, 4, 5, 3, 0, 30)), ['05 03:00 AEST']) - self.assertEqual(self.fires('30 2 * * *', second_time_round, self.mel(2026, 4, 6, 12)), ['06 02:30 AEST']) - - def test_the_scheduler_runs_a_daily_job_once_across_the_change(self): - file = Path(tempfile.mkdtemp(), 'schedule.cron') - file.write_text('CRON_TZ=Australia/Melbourne\n0 9 * * * trades.py\n') - clock = Clock(self.mel(2026, 10, 3, 12).astimezone(UTC)) - s = jobs.Scheduler(lambda: file, clock=clock, zone=UTC, runner=lambda *a: 0) - with mock.patch.object(util, 'config_var_dir', tempfile.mkdtemp()), contextlib.redirect_stderr(io.StringIO()): - self.assertTrue(s.load()) - fired = [] - while clock.now < self.mel(2026, 10, 5, 12): - fired += [t.astimezone(UTC).astimezone(self.MEL).strftime('%d %H:%M') for _, t in s.due(clock.now)] - clock.now += datetime.timedelta(minutes=10) - self.assertEqual(fired, ['04 09:00', '05 09:00']) + """Melbourne's clocks go back an hour at 03:00 on 5 April 2026 and forward at 02:00 on 4 October 2026. The fire times must be + right on any croniter: the distro packages (up to 6.0) got zoneinfo's changes wrong.""" + MEL = zoneinfo.ZoneInfo('Australia/Melbourne') + + def fires(self, expr, start, end): + job = jobs.parse_schedule(f'{expr} trades.py\n', self.MEL)[0][0] + s = jobs.Scheduler(lambda: None, zone=self.MEL) + times, t = [], start + while (t := s.next_fire(job, t)) <= end: + times.append(t.astimezone(UTC).astimezone(self.MEL).strftime('%d %H:%M %Z')) # by way of UTC: the real local time + return times + + def mel(self, *args, **kw): + return datetime.datetime(*args, tzinfo=self.MEL, **kw) + + def test_a_daily_job_fires_once_at_its_time(self): + self.assertEqual(self.fires('0 9 * * *', self.mel(2026, 10, 2), self.mel(2026, 10, 6)), ['02 09:00 AEST', '03 09:00 AEST', '04 09:00 AEDT', '05 09:00 AEDT']) + self.assertEqual(self.fires('0 9 * * *', self.mel(2026, 4, 3), self.mel(2026, 4, 7)), ['03 09:00 AEDT', '04 09:00 AEDT', '05 09:00 AEST', '06 09:00 AEST']) + + def test_a_skipped_or_repeated_time_fires_once(self): + self.assertEqual(self.fires('30 2 * * *', self.mel(2026, 10, 3), self.mel(2026, 10, 6)), ['03 02:30 AEST', '04 03:30 AEDT', '05 02:30 AEDT']) + self.assertEqual(self.fires('30 2 * * *', self.mel(2026, 4, 4), self.mel(2026, 4, 7)), ['04 02:30 AEDT', '05 02:30 AEDT', '06 02:30 AEST']) + self.assertEqual(self.fires('*/20 * * * *', self.mel(2026, 10, 4, 1, 30), self.mel(2026, 10, 4, 3, 50)), ['04 01:40 AEST', '04 03:00 AEDT', '04 03:20 AEDT', '04 03:40 AEDT']) + self.assertEqual(self.fires('*/20 * * * *', self.mel(2026, 4, 5, 1, 30), self.mel(2026, 4, 5, 3, 30)), + ['05 01:40 AEDT', '05 02:00 AEDT', '05 02:20 AEDT', '05 02:40 AEDT', '05 03:00 AEST', '05 03:20 AEST']) + + def test_starting_in_the_repeated_hour_runs_nothing_twice(self): + second_time_round = self.mel(2026, 4, 5, 2, 10, fold=1).astimezone(UTC) + self.assertEqual(self.fires('*/20 * * * *', second_time_round, self.mel(2026, 4, 5, 3, 0, 30)), ['05 03:00 AEST']) + self.assertEqual(self.fires('30 2 * * *', second_time_round, self.mel(2026, 4, 6, 12)), ['06 02:30 AEST']) + + def test_the_scheduler_runs_a_daily_job_once_across_the_change(self): + file = Path(tempfile.mkdtemp(), 'schedule.cron') + file.write_text('CRON_TZ=Australia/Melbourne\n0 9 * * * trades.py\n') + clock = Clock(self.mel(2026, 10, 3, 12).astimezone(UTC)) + s = jobs.Scheduler(lambda: file, clock=clock, zone=UTC, runner=lambda *a: 0) + with mock.patch.object(util, 'config_var_dir', tempfile.mkdtemp()), contextlib.redirect_stderr(io.StringIO()): + self.assertTrue(s.load()) + fired = [] + while clock.now < self.mel(2026, 10, 5, 12): + fired += [t.astimezone(UTC).astimezone(self.MEL).strftime('%d %H:%M') for _, t in s.due(clock.now)] + clock.now += datetime.timedelta(minutes=10) + self.assertEqual(fired, ['04 09:00', '05 09:00']) class StartupRefusal(unittest.TestCase): - def test_the_bot_will_not_start_with_an_impossible_time(self): - skip_if_old_env(self) - with tempfile.TemporaryDirectory() as data: - Path(data, 'schedule.cron').write_text('*/20 * * * Mon-Fri trades.py\n0 9 31 6 * price.py close\n') - env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} - env.update(DATA_DIR=data, port='0') - out = subprocess.run([sys.executable, 'bot.py'], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) - self.assertEqual(out.returncode, 1, out.stderr) - self.assertIn(f"ERROR schedule: {data}/schedule.cron line 2: '0 9 31 6 *' never comes", out.stderr) + def test_the_bot_will_not_start_with_an_impossible_time(self): + skip_if_old_env(self) + with tempfile.TemporaryDirectory() as data: + Path(data, 'schedule.cron').write_text('*/20 * * * Mon-Fri trades.py\n0 9 31 6 * price.py close\n') + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS} + env.update(DATA_DIR=data, port='0') + out = subprocess.run([sys.executable, 'bot.py'], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) + self.assertEqual(out.returncode, 1, out.stderr) + self.assertIn(f"ERROR schedule: {data}/schedule.cron line 2: '0 9 31 6 *' never comes", out.stderr) class ManualRuns(SchedulerBase): - def setUp(self): - super().setUp() - self.file.write_text('# a comment\n*/20 * * * Mon-Fri trades.py > /dev/null\n28 21 * * Sat price.py close; trades.py # weekly\n' - '5 4 * * * { price.py close ;}\n6 4 * * * price.py 7\n' - '0 3 * * * performance.py 365; performance.py 7; performance.py 90; performance.py 30; performance.py 180\n' - '0 2 * * * cleanup.py\n1 * * * * podcasts.py "https://example.com/feed"\n') - self.s = self.scheduler() - self.patch(jobs, 'current', self.s) - self.patch(jobs.SCRIPTS['price'], 'lambda_handler', lambda **k: self.ran.append(('price', k))) - - def test_commands_are_unique_clean_and_in_order(self): - self.assertEqual([label for label, _, _ in self.s.commands()], ['performance.py 7', 'performance.py 30', 'performance.py 90', 'performance.py 180', - 'performance.py 365', 'price.py 7', 'price.py close', 'trades.py']) - - def test_select_by_number_or_command_text(self): - self.assertEqual(self.s.select('7')[0], 'price.py close') - self.assertEqual(self.s.select('8')[0], 'trades.py') - self.assertEqual(self.s.select(' "price.py" close > /dev/null')[0], 'price.py close') - self.assertEqual(self.s.select('~/finbot/price.py 7')[0], 'price.py 7') - - def test_select_refuses_what_is_not_offered(self): - for text in ('0', '9', 'cleanup.py', 'podcasts.py "https://example.com/feed"', 'price.py 99', 'cal.py earnings', 'nonsense', 'ls | wc', '', 'price.py close; trades.py'): - with self.assertRaises(jobs.ScheduleError, msg=text): - self.s.select(text) - - def test_run_now_runs_one_command_and_leaves_the_schedule_alone(self): - base, last = dict(self.s.base), dict(self.s.last) - self.assertEqual(self.s.run_now('7'), ('price.py close', 'done')) - self.assertEqual(self.ran, [('price', {'close': True})]) - self.assertEqual((self.s.base, self.s.last), (base, last)) - self.assertTrue(all(not job.running for job in self.s.jobs)) - self.assertEqual(self.s.busy, set()) - - def test_run_now_refuses_while_the_command_is_running_and_reports_failures(self): - self.s.busy.add('trades.py') - self.assertEqual(self.s.run_now('8'), ('trades.py', 'already running')) - self.assertEqual(self.ran, []) - self.s.busy.clear() - def boom(**k): - raise RuntimeError('boom') - self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', boom) - self.assertEqual(self.s.run_now('8'), ('trades.py', 'failed')) - self.assertTrue(any('boom' in e for e in self.errors)) - self.assertEqual(self.s.busy, set()) - - def test_a_scheduled_run_skips_a_command_that_a_manual_run_is_in_the_middle_of(self): - self.s.busy.add('trades.py') - self.assertEqual(self.s.run_job(self.s.jobs[1], at(13, 21, 28)), 'already running') - self.assertEqual(self.ran, [('price', {'close': True})]) # price.py close ran, trades.py was skipped - - def test_run_now_times_out(self): - self.patch(jobs, 'JOB_TIMEOUT', 0.05) - self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', lambda **k: gevent.sleep(5)) - self.assertEqual(self.s.run_now('8'), ('trades.py', 'timed out')) - self.assertEqual(self.s.busy, set()) - - def list_in_chat(self, text='.jobs'): - return self.command(text)[0][1] - - def test_list_in_chat_is_just_the_commands(self): - for alias in ('.jobs', '.cron', '.crons', '.schedule', '@' + BOT.lstrip('@') + ' jobs'): - text = '\n'.join(map(str, self.list_in_chat(alias))) - for line in ('1. performance 7', '2. performance 30', '5. performance 365', '6. price 7', '7. price close', '8. trades'): - self.assertIn(line, text, alias) - for noise in ('*/20', '#', 'Mon-Fri', 'next', 'last', '/dev/null', 'weekly', '.py', 'cleanup', 'podcasts'): - self.assertNotIn(noise, text, alias) - self.s.busy.add('price.py 7') - self.assertIn('6. price 7 (running)', '\n'.join(map(str, self.list_in_chat()))) - - def test_telegram_gets_a_button_per_command_and_slack_does_not(self): - payload, markup = reports.prepare_jobs_payload('telegram', '') - buttons = [b for row in markup['inline_keyboard'] for b in row] - self.assertEqual([b['text'] for b in buttons], ['1. performance 7', '2. performance 30', '3. performance 90', '4. performance 180', - '5. performance 365', '6. price 7', '7. price close', '8. trades']) - for b in buttons: - self.assertLessEqual(len(b['callback_data'].encode()), 64) - payload, markup = reports.prepare_jobs_payload('slack', '') - self.assertIsNone(markup) - - def test_the_chat_command_passes_the_keyboard_on(self): - seen = [] - self.patch(webhook, 'payload_wrapper', lambda svc, url, payload, *a, **k: seen.append(k.get('reply_markup')) or []) - worker.process_request('telegram', '55', '@u', '.jobs', BOT, 'U') - self.assertEqual(len(seen[0]['inline_keyboard'][0]), 2) - - def test_button_press_runs_that_command(self): - sent = [] - self.patch(telegram, 'answerCallbackQuery', lambda cid, text=None: sent.append(text)) - markup = reports.prepare_jobs_payload('telegram', '')[1] - data = next(b['callback_data'] for row in markup['inline_keyboard'] for b in row if b['text'].startswith('7.')) - self.sent.clear() - worker.process_callback('telegram', 'cb1', '55', '9', data) - self.assertEqual(self.ran, [('price', {'close': True})]) - self.assertEqual(self.sent, [('text', ['7. price close: done'])]) - self.assertEqual(sent, ['Running price close…']) - - def stale_buttons(self): - """Capture the toast and the in-place edit that a refused button press produces.""" - self.toasts, self.edits = [], [] - self.patch(telegram, 'answerCallbackQuery', lambda cid, text=None: self.toasts.append(text)) - self.patch(webhook, 'editMessageText', lambda chat, mid, text, markup=None: self.edits.append((chat, mid, text, markup))) - - def test_forged_button_data_never_runs_anything_else(self): - self.stale_buttons() - good = jobs.checksum('trades.py') - for data in ('j|-1|' + good, 'j|0|' + good, 'j|9|' + good, 'j|8|0000', 'j|8|', 'j|8', 'j|8|' + good + '|x', 'j||' + good, - 'j|٨|' + good, 'j| 8|' + good, 'j|8.0|' + good, 'j|trades.py|' + good, 'j|1|' + good, 'j|8|price.py close', - 'j|8|' + good.upper() + 'x', 'j|99999999999999999999|' + good): - self.sent.clear() - self.toasts.clear(); self.edits.clear() - worker.process_callback('telegram', 'cb', '55', '9', data) - self.assertEqual(self.ran, [], data) - self.assertEqual(self.toasts, ['Buttons updated, try again'], data) - self.assertEqual(len(self.edits), 1, data) - self.assertEqual(self.sent, [], data) # no new message, the old one is edited - # cleanup and podcasts are scheduled but cannot be picked, by number or by their checksum - for label in ('cleanup.py', 'podcasts.py'): - for n in range(1, 12): - with self.assertRaises(jobs.ScheduleError): - self.s.pick(str(n), jobs.checksum(label)) - worker.process_callback('telegram', 'cb', '55', '9', 'j|8|' + good) # the genuine button still works - self.assertEqual(self.ran, [('trades', {})]) - - def test_button_from_an_old_schedule_updates_the_message(self): - self.stale_buttons() - worker.process_callback('telegram', 'cb1', '55', '9', 'j|7|0000') - worker.process_callback('telegram', 'cb1', '55', '9', 'j|9|0000') - self.assertEqual(self.ran, []) - self.assertEqual(self.sent, []) - self.assertEqual(self.toasts, ['Buttons updated, try again'] * 2) - chat, mid, text, markup = self.edits[0] - self.assertEqual((chat, mid), ('55', '9')) # the message with the old buttons is edited in place - self.assertIn('7. price close', text) - self.assertEqual(markup, reports.prepare_jobs_payload('telegram', '')[1]) # with the current buttons - self.patch(jobs, 'current', None) # scheduler off: the list is replaced by the notice and the buttons removed - worker.process_callback('telegram', 'cb1', '55', '9', 'j|7|0000') - self.assertEqual(self.edits[-1][2:], ('Scheduled jobs are off (scheduler=False)', {'inline_keyboard': []})) - - def test_run_in_chat(self): - self.assertEqual(self.command('.jobs run 8'), [('text', ['8. trades: done'])]) - self.assertEqual(self.ran, [('trades', {})]) - self.ran.clear() - self.assertEqual(self.command('.cron run price close')[0][1], ['7. price close: done']) - self.assertEqual(self.ran, [('price', {'close': True})]) - - def test_run_in_chat_refusals(self): - self.assertIn('not in the schedule', self.list_in_chat('.jobs run cal.py earnings')[0]) - self.assertIn('no command 99', self.list_in_chat('.jobs run 99')[0]) - self.assertIn('one command at a time', self.list_in_chat('.jobs run price.py close; trades.py')[0]) - self.assertIn('Usage', self.list_in_chat('.jobs run')[0]) - self.assertIn('Usage', self.list_in_chat('.jobs bogus')[0]) - self.assertEqual(self.ran, []) - - def test_scheduler_off(self): - self.patch(jobs, 'current', None) - self.assertIn('off', self.list_in_chat('.jobs')[0]) - self.assertIn('off', self.list_in_chat('.jobs run 8')[0]) - - def test_job_is_still_a_ticker(self): - self.assertNotIn('Scheduled', str(self.command('.job'))) + def setUp(self): + super().setUp() + self.file.write_text('# a comment\n*/20 * * * Mon-Fri trades.py > /dev/null\n28 21 * * Sat price.py close; trades.py # weekly\n' + '5 4 * * * { price.py close ;}\n6 4 * * * price.py 7\n' + '0 3 * * * performance.py 365; performance.py 7; performance.py 90; performance.py 30; performance.py 180\n' + '0 2 * * * cleanup.py\n1 * * * * podcasts.py "https://example.com/feed"\n') + self.s = self.scheduler() + self.patch(jobs, 'current', self.s) + self.patch(jobs.SCRIPTS['price'], 'lambda_handler', lambda **k: self.ran.append(('price', k))) + + def test_commands_are_unique_clean_and_in_order(self): + self.assertEqual([label for label, _, _ in self.s.commands()], ['performance.py 7', 'performance.py 30', 'performance.py 90', 'performance.py 180', + 'performance.py 365', 'price.py 7', 'price.py close', 'trades.py']) + + def test_select_by_number_or_command_text(self): + self.assertEqual(self.s.select('7')[0], 'price.py close') + self.assertEqual(self.s.select('8')[0], 'trades.py') + self.assertEqual(self.s.select(' "price.py" close > /dev/null')[0], 'price.py close') + self.assertEqual(self.s.select('~/finbot/price.py 7')[0], 'price.py 7') + + def test_select_refuses_what_is_not_offered(self): + for text in ('0', '9', 'cleanup.py', 'podcasts.py "https://example.com/feed"', 'price.py 99', 'cal.py earnings', 'nonsense', 'ls | wc', '', 'price.py close; trades.py'): + with self.assertRaises(jobs.ScheduleError, msg=text): + self.s.select(text) + + def test_run_now_runs_one_command_and_leaves_the_schedule_alone(self): + base, last = dict(self.s.base), dict(self.s.last) + self.assertEqual(self.s.run_now('7'), ('price.py close', 'done')) + self.assertEqual(self.ran, [('price', {'close': True})]) + self.assertEqual((self.s.base, self.s.last), (base, last)) + self.assertTrue(all(not job.running for job in self.s.jobs)) + self.assertEqual(self.s.busy, set()) + + def test_run_now_refuses_while_the_command_is_running_and_reports_failures(self): + self.s.busy.add('trades.py') + self.assertEqual(self.s.run_now('8'), ('trades.py', 'already running')) + self.assertEqual(self.ran, []) + self.s.busy.clear() + def boom(**k): + raise RuntimeError('boom') + self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', boom) + self.assertEqual(self.s.run_now('8'), ('trades.py', 'failed')) + self.assertTrue(any('boom' in e for e in self.errors)) + self.assertEqual(self.s.busy, set()) + + def test_a_scheduled_run_skips_a_command_that_a_manual_run_is_in_the_middle_of(self): + self.s.busy.add('trades.py') + self.assertEqual(self.s.run_job(self.s.jobs[1], at(13, 21, 28)), 'already running') + self.assertEqual(self.ran, [('price', {'close': True})]) # price.py close ran, trades.py was skipped + + def test_run_now_times_out(self): + self.patch(jobs, 'JOB_TIMEOUT', 0.05) + self.patch(jobs.SCRIPTS['trades'], 'lambda_handler', lambda **k: gevent.sleep(5)) + self.assertEqual(self.s.run_now('8'), ('trades.py', 'timed out')) + self.assertEqual(self.s.busy, set()) + + def list_in_chat(self, text='.jobs'): + return self.command(text)[0][1] + + def test_list_in_chat_is_just_the_commands(self): + for alias in ('.jobs', '.cron', '.crons', '.schedule', '@' + BOT.lstrip('@') + ' jobs'): + text = '\n'.join(map(str, self.list_in_chat(alias))) + for line in ('1. performance 7', '2. performance 30', '5. performance 365', '6. price 7', '7. price close', '8. trades'): + self.assertIn(line, text, alias) + for noise in ('*/20', '#', 'Mon-Fri', 'next', 'last', '/dev/null', 'weekly', '.py', 'cleanup', 'podcasts'): + self.assertNotIn(noise, text, alias) + self.s.busy.add('price.py 7') + self.assertIn('6. price 7 (running)', '\n'.join(map(str, self.list_in_chat()))) + + def test_telegram_gets_a_button_per_command_and_slack_does_not(self): + payload, markup = reports.prepare_jobs_payload('telegram', '') + buttons = [b for row in markup['inline_keyboard'] for b in row] + self.assertEqual([b['text'] for b in buttons], ['1. performance 7', '2. performance 30', '3. performance 90', '4. performance 180', + '5. performance 365', '6. price 7', '7. price close', '8. trades']) + for b in buttons: + self.assertLessEqual(len(b['callback_data'].encode()), 64) + payload, markup = reports.prepare_jobs_payload('slack', '') + self.assertIsNone(markup) + + def test_the_chat_command_passes_the_keyboard_on(self): + seen = [] + self.patch(webhook, 'payload_wrapper', lambda svc, url, payload, *a, **k: seen.append(k.get('reply_markup')) or []) + worker.process_request('telegram', '55', '@u', '.jobs', BOT, 'U') + self.assertEqual(len(seen[0]['inline_keyboard'][0]), 2) + + def test_button_press_runs_that_command(self): + sent = [] + self.patch(telegram, 'answerCallbackQuery', lambda cid, text=None: sent.append(text)) + markup = reports.prepare_jobs_payload('telegram', '')[1] + data = next(b['callback_data'] for row in markup['inline_keyboard'] for b in row if b['text'].startswith('7.')) + self.sent.clear() + worker.process_callback('telegram', 'cb1', '55', '9', data) + self.assertEqual(self.ran, [('price', {'close': True})]) + self.assertEqual(self.sent, [('text', ['7. price close: done'])]) + self.assertEqual(sent, ['Running price close…']) + + def stale_buttons(self): + """Capture the toast and the in-place edit that a refused button press produces.""" + self.toasts, self.edits = [], [] + self.patch(telegram, 'answerCallbackQuery', lambda cid, text=None: self.toasts.append(text)) + self.patch(webhook, 'editMessageText', lambda chat, mid, text, markup=None: self.edits.append((chat, mid, text, markup))) + + def test_forged_button_data_never_runs_anything_else(self): + self.stale_buttons() + good = jobs.checksum('trades.py') + for data in ('j|-1|' + good, 'j|0|' + good, 'j|9|' + good, 'j|8|0000', 'j|8|', 'j|8', 'j|8|' + good + '|x', 'j||' + good, + 'j|٨|' + good, 'j| 8|' + good, 'j|8.0|' + good, 'j|trades.py|' + good, 'j|1|' + good, 'j|8|price.py close', + 'j|8|' + good.upper() + 'x', 'j|99999999999999999999|' + good): + self.sent.clear() + self.toasts.clear(); self.edits.clear() + worker.process_callback('telegram', 'cb', '55', '9', data) + self.assertEqual(self.ran, [], data) + self.assertEqual(self.toasts, ['Buttons updated, try again'], data) + self.assertEqual(len(self.edits), 1, data) + self.assertEqual(self.sent, [], data) # no new message, the old one is edited + # cleanup and podcasts are scheduled but cannot be picked, by number or by their checksum + for label in ('cleanup.py', 'podcasts.py'): + for n in range(1, 12): + with self.assertRaises(jobs.ScheduleError): + self.s.pick(str(n), jobs.checksum(label)) + worker.process_callback('telegram', 'cb', '55', '9', 'j|8|' + good) # the genuine button still works + self.assertEqual(self.ran, [('trades', {})]) + + def test_button_from_an_old_schedule_updates_the_message(self): + self.stale_buttons() + worker.process_callback('telegram', 'cb1', '55', '9', 'j|7|0000') + worker.process_callback('telegram', 'cb1', '55', '9', 'j|9|0000') + self.assertEqual(self.ran, []) + self.assertEqual(self.sent, []) + self.assertEqual(self.toasts, ['Buttons updated, try again'] * 2) + chat, mid, text, markup = self.edits[0] + self.assertEqual((chat, mid), ('55', '9')) # the message with the old buttons is edited in place + self.assertIn('7. price close', text) + self.assertEqual(markup, reports.prepare_jobs_payload('telegram', '')[1]) # with the current buttons + self.patch(jobs, 'current', None) # scheduler off: the list is replaced by the notice and the buttons removed + worker.process_callback('telegram', 'cb1', '55', '9', 'j|7|0000') + self.assertEqual(self.edits[-1][2:], ('Scheduled jobs are off (scheduler=False)', {'inline_keyboard': []})) + + def test_run_in_chat(self): + self.assertEqual(self.command('.jobs run 8'), [('text', ['8. trades: done'])]) + self.assertEqual(self.ran, [('trades', {})]) + self.ran.clear() + self.assertEqual(self.command('.cron run price close')[0][1], ['7. price close: done']) + self.assertEqual(self.ran, [('price', {'close': True})]) + + def test_run_in_chat_refusals(self): + self.assertIn('not in the schedule', self.list_in_chat('.jobs run cal.py earnings')[0]) + self.assertIn('no command 99', self.list_in_chat('.jobs run 99')[0]) + self.assertIn('one command at a time', self.list_in_chat('.jobs run price.py close; trades.py')[0]) + self.assertIn('Usage', self.list_in_chat('.jobs run')[0]) + self.assertIn('Usage', self.list_in_chat('.jobs bogus')[0]) + self.assertEqual(self.ran, []) + + def test_scheduler_off(self): + self.patch(jobs, 'current', None) + self.assertIn('off', self.list_in_chat('.jobs')[0]) + self.assertIn('off', self.list_in_chat('.jobs run 8')[0]) + + def test_job_is_still_a_ticker(self): + self.assertNotIn('Scheduled', str(self.command('.job'))) class ChildProcess(FinbotCase): - """Jobs run in their own process, so heavy work cannot hold up the bot's web server.""" - - def sleeper(self, seconds): - real = jobs.subprocess.Popen - self.children = [] - def popen(args, **kw): - self.children.append(real([sys.executable, '-c', f'import time; time.sleep({seconds})'])) - return self.children[-1] - self.patch(jobs.subprocess, 'Popen', popen) - - def test_every_command_can_be_handed_to_a_child(self): - parsed, _ = jobs.parse_schedule(DEFAULT_SCHEDULE.read_text(), UTC) - for job in parsed: - for label, module, kwargs in job.commands: - self.assertEqual(json.loads(json.dumps(kwargs)), kwargs, label) - - def test_a_real_child_runs_the_handler_and_reports_its_exit_status(self): - real = jobs.subprocess.Popen - self.patch(jobs.subprocess, 'Popen', lambda args, **kw: real(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kw)) - with tempfile.TemporaryDirectory() as data: - env = {'DATA_DIR': data, 'telegramBotToken': '', 'slack_webhook': '', 'discord_webhook': ''} - with mock.patch.dict(os.environ, env): - self.assertEqual(jobs.run_child('cleanup.py', cleanup, {}), 0) - self.assertEqual(jobs.run_child('cleanup.py 0', cleanup, {'days': 'not a number'}), jobs.jobrun.REPORTED) - - def test_a_job_process_survives_a_reload_signal(self): - with tempfile.TemporaryDirectory() as scripts, tempfile.TemporaryDirectory() as data: - Path(scripts, 'sleeper.py').write_text('import time\ndef lambda_handler():\n print("started", flush=True)\n time.sleep(1)\n') - env = {**os.environ, 'PYTHONPATH': scripts, 'DATA_DIR': data, 'telegramBotToken': '', 'slack_webhook': '', 'discord_webhook': ''} - proc = subprocess.Popen([sys.executable, '-m', 'lib.jobrun', 'sleeper', '{}'], cwd=ROOT, env=env, stdout=subprocess.PIPE, text=True) - self.addCleanup(proc.kill) - for line in proc.stdout: - if line.strip() == 'started': - break - proc.send_signal(signal.SIGHUP) # what `systemctl kill -s HUP finbot` does to every process of the unit - self.assertEqual(proc.wait(timeout=30), 0) - - def test_a_script_that_cannot_be_imported_is_reported(self): - with tempfile.TemporaryDirectory() as data: - env = {**os.environ, 'DATA_DIR': data, 'telegramBotToken': '', 'slack_webhook': '', 'discord_webhook': ''} - out = subprocess.run([sys.executable, '-m', 'lib.jobrun', 'nosuchscript', '{}'], cwd=ROOT, env=env, capture_output=True, text=True) - self.assertEqual(out.returncode, jobs.jobrun.REPORTED, out.stderr) - self.assertIn('⚠️ nosuchscript.py: ModuleNotFoundError', out.stderr) - - def test_scheduler_reports_a_child_that_ended_without_saying_why(self): - file = Path(tempfile.mkdtemp(), 'schedule.cron') - file.write_text('* * * * * trades.py\n') - for status, reason in ((-signal.SIGHUP, 'killed by SIGHUP'), (-signal.SIGKILL, 'killed by SIGKILL'), (1, 'exited with status 1 without saying why'), - (jobs.jobrun.REPORTED, None)): # the child reported this one itself - with self.subTest(status=status): - self.errors.clear() - s = jobs.Scheduler(lambda: file, zone=UTC, runner=lambda label, module, kwargs: status) - self.assertTrue(s.load()) - self.assertEqual(s.run_now('1'), ('trades.py', 'failed')) - self.assertEqual(self.errors, [f'⚠️ trades.py: RuntimeError: {reason}'] if reason else []) - self.assertEqual(s.busy, set()) - - def test_a_report_script_loads_neither_pandas_nor_the_scheduler(self): - """A job process imports only its own script: price used to pull in pandas and, through charts, reports and jobs, every - script there is.""" - code = 'import sys, price, performance, trades, shorts, cal; print(sorted(m for m in ("pandas", "lib.jobs", "matplotlib") if m in sys.modules))' - with tempfile.TemporaryDirectory() as data: - out = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, env={**os.environ, 'DATA_DIR': data}) - self.assertEqual(out.stdout.strip().splitlines()[-1:], ['[]'], out.stderr) - - def test_the_event_loop_keeps_running_while_a_child_works(self): - self.sleeper(1) - ticks = [] - def ticker(): - for _ in range(5): - gevent.sleep(0.1) - ticks.append(time.monotonic()) - greenlet = gevent.spawn(ticker) - started = time.monotonic() - self.assertEqual(jobs.run_child('cleanup.py', cleanup, {}), 0) - greenlet.join() - self.assertGreater(time.monotonic() - started, 0.9) - self.assertEqual(len(ticks), 5) # the loop was not blocked by the child - self.assertLess(ticks[-1] - started, 0.8) - - def test_a_timeout_kills_the_child(self): - self.sleeper(30) - started = time.monotonic() - with self.assertRaises(gevent.Timeout): - with gevent.Timeout(0.3): - jobs.run_child('cleanup.py', cleanup, {}) - self.assertLess(time.monotonic() - started, 5) - self.assertIsNotNone(self.children[0].poll()) # the child was stopped, not left running + """Jobs run in their own process, so heavy work cannot hold up the bot's web server.""" + + def sleeper(self, seconds): + real = jobs.subprocess.Popen + self.children = [] + def popen(args, **kw): + self.children.append(real([sys.executable, '-c', f'import time; time.sleep({seconds})'])) + return self.children[-1] + self.patch(jobs.subprocess, 'Popen', popen) + + def test_every_command_can_be_handed_to_a_child(self): + parsed, _ = jobs.parse_schedule(DEFAULT_SCHEDULE.read_text(), UTC) + for job in parsed: + for label, module, kwargs in job.commands: + self.assertEqual(json.loads(json.dumps(kwargs)), kwargs, label) + + def test_a_real_child_runs_the_handler_and_reports_its_exit_status(self): + real = jobs.subprocess.Popen + self.patch(jobs.subprocess, 'Popen', lambda args, **kw: real(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kw)) + with tempfile.TemporaryDirectory() as data: + env = {'DATA_DIR': data, 'telegramBotToken': '', 'slack_webhook': '', 'discord_webhook': ''} + with mock.patch.dict(os.environ, env): + self.assertEqual(jobs.run_child('cleanup.py', cleanup, {}), 0) + self.assertEqual(jobs.run_child('cleanup.py 0', cleanup, {'days': 'not a number'}), jobs.jobrun.REPORTED) + + def test_a_job_process_survives_a_reload_signal(self): + with tempfile.TemporaryDirectory() as scripts, tempfile.TemporaryDirectory() as data: + Path(scripts, 'sleeper.py').write_text('import time\ndef lambda_handler():\n print("started", flush=True)\n time.sleep(1)\n') + env = {**os.environ, 'PYTHONPATH': scripts, 'DATA_DIR': data, 'telegramBotToken': '', 'slack_webhook': '', 'discord_webhook': ''} + proc = subprocess.Popen([sys.executable, '-m', 'lib.jobrun', 'sleeper', '{}'], cwd=ROOT, env=env, stdout=subprocess.PIPE, text=True) + self.addCleanup(proc.kill) + for line in proc.stdout: + if line.strip() == 'started': + break + proc.send_signal(signal.SIGHUP) # what `systemctl kill -s HUP finbot` does to every process of the unit + self.assertEqual(proc.wait(timeout=30), 0) + + def test_a_script_that_cannot_be_imported_is_reported(self): + with tempfile.TemporaryDirectory() as data: + env = {**os.environ, 'DATA_DIR': data, 'telegramBotToken': '', 'slack_webhook': '', 'discord_webhook': ''} + out = subprocess.run([sys.executable, '-m', 'lib.jobrun', 'nosuchscript', '{}'], cwd=ROOT, env=env, capture_output=True, text=True) + self.assertEqual(out.returncode, jobs.jobrun.REPORTED, out.stderr) + self.assertIn('⚠️ nosuchscript.py: ModuleNotFoundError', out.stderr) + + def test_scheduler_reports_a_child_that_ended_without_saying_why(self): + file = Path(tempfile.mkdtemp(), 'schedule.cron') + file.write_text('* * * * * trades.py\n') + for status, reason in ((-signal.SIGHUP, 'killed by SIGHUP'), (-signal.SIGKILL, 'killed by SIGKILL'), (1, 'exited with status 1 without saying why'), + (jobs.jobrun.REPORTED, None)): # the child reported this one itself + with self.subTest(status=status): + self.errors.clear() + s = jobs.Scheduler(lambda: file, zone=UTC, runner=lambda label, module, kwargs: status) + self.assertTrue(s.load()) + self.assertEqual(s.run_now('1'), ('trades.py', 'failed')) + self.assertEqual(self.errors, [f'⚠️ trades.py: RuntimeError: {reason}'] if reason else []) + self.assertEqual(s.busy, set()) + + def test_a_report_script_loads_neither_pandas_nor_the_scheduler(self): + """A job process imports only its own script: price used to pull in pandas and, through charts, reports and jobs, every + script there is.""" + code = 'import sys, price, performance, trades, shorts, cal; print(sorted(m for m in ("pandas", "lib.jobs", "matplotlib") if m in sys.modules))' + with tempfile.TemporaryDirectory() as data: + out = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, env={**os.environ, 'DATA_DIR': data}) + self.assertEqual(out.stdout.strip().splitlines()[-1:], ['[]'], out.stderr) + + def test_the_event_loop_keeps_running_while_a_child_works(self): + self.sleeper(1) + ticks = [] + def ticker(): + for _ in range(5): + gevent.sleep(0.1) + ticks.append(time.monotonic()) + greenlet = gevent.spawn(ticker) + started = time.monotonic() + self.assertEqual(jobs.run_child('cleanup.py', cleanup, {}), 0) + greenlet.join() + self.assertGreater(time.monotonic() - started, 0.9) + self.assertEqual(len(ticks), 5) # the loop was not blocked by the child + self.assertLess(ticks[-1] - started, 0.8) + + def test_a_timeout_kills_the_child(self): + self.sleeper(30) + started = time.monotonic() + with self.assertRaises(gevent.Timeout): + with gevent.Timeout(0.3): + jobs.run_child('cleanup.py', cleanup, {}) + self.assertLess(time.monotonic() - started, 5) + self.assertIsNotNone(self.children[0].poll()) # the child was stopped, not left running if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_providers.py b/tests/test_providers.py index 6af35bda..43736735 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -9,759 +9,759 @@ def sample(*path): - with open(os.path.join(DOC, *path), encoding='utf-8') as f: - return json.load(f) + with open(os.path.join(DOC, *path), encoding='utf-8') as f: + return json.load(f) class Resp: - def __init__(self, payload=None, status=200, text=''): - self.payload, self.status_code, self.text = payload, status, text or json.dumps(payload) - def json(self): - if self.payload is None: - raise ValueError('no json') - return self.payload + def __init__(self, payload=None, status=200, text=''): + self.payload, self.status_code, self.text = payload, status, text or json.dumps(payload) + def json(self): + if self.payload is None: + raise ValueError('no json') + return self.payload def serve(*responses): - """net.get replacement: replays the responses in order (exceptions are raised); the last one repeats.""" - queue = list(responses) - calls = [] - def get(url, **kwargs): - calls.append(url) - item = queue.pop(0) if len(queue) > 1 else queue[0] - if isinstance(item, Exception): - raise item - return item - get.calls = calls - return get + """net.get replacement: replays the responses in order (exceptions are raised); the last one repeats.""" + queue = list(responses) + calls = [] + def get(url, **kwargs): + calls.append(url) + item = queue.pop(0) if len(queue) > 1 else queue[0] + if isinstance(item, Exception): + raise item + return item + get.calls = calls + return get class ProviderCase(FinbotCase): - def setUp(self): - super().setUp() - # these tests exercise the real parsers, so undo the canned data from the base fixture - for module, name in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (sharesight, 'get_portfolios'), - (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper')): - self.patch(module, name, REAL[(module.__name__, name)]) - self.patch(yahoo, 'getCookie', lambda *a, **k: 'A=B') - self.patch(yahoo, 'getCrumb', lambda *a, **k: 'crumb') - self.patch(sharesight, 'get_token', lambda: 'token') - self.patch(sharesight.time, 'sleep', lambda s: None) - - def http(self, module, *responses): - getter = serve(*responses) - self.patch(module.net, 'get', getter) - return getter + def setUp(self): + super().setUp() + # these tests exercise the real parsers, so undo the canned data from the base fixture + for module, name in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (sharesight, 'get_portfolios'), + (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper')): + self.patch(module, name, REAL[(module.__name__, name)]) + self.patch(yahoo, 'getCookie', lambda *a, **k: 'A=B') + self.patch(yahoo, 'getCrumb', lambda *a, **k: 'crumb') + self.patch(sharesight, 'get_token', lambda: 'token') + self.patch(sharesight.time, 'sleep', lambda s: None) + + def http(self, module, *responses): + getter = serve(*responses) + self.patch(module.net, 'get', getter) + return getter class YahooQuotes(ProviderCase): - def test_parses_real_v7_quote(self): - self.http(yahoo, Resp(sample('yahoo', 'sample_yahoo_v7.json'))) - data = yahoo.fetch(['AAPL'])['AAPL'] - self.assertEqual(data['profile_title'], 'Apple') - self.assertEqual(data['percent_change'], 0.09) - self.assertEqual(data['percent_change_postmarket'], -0.13) - self.assertEqual(data['quoteType'], 'EQUITY') - self.assertEqual(data['marketState'], 'CLOSED') - self.assertEqual(data['profile_exchange'], 'NasdaqGS') - self.assertEqual(data['exchangeTimezoneName'], 'America/New_York') - self.assertGreater(data['market_cap'], 1e12) - - def test_second_call_is_served_from_cache(self): - getter = self.http(yahoo, Resp(sample('yahoo', 'sample_yahoo_v7.json'))) - yahoo.fetch(['AAPL']) - yahoo.fetch(['AAPL']) - self.assertEqual(len(getter.calls), 1) - - def test_falls_back_to_second_host(self): - getter = self.http(yahoo, Resp(status=500), Resp(sample('yahoo', 'sample_yahoo_v7.json'))) - self.assertIn('AAPL', yahoo.fetch(['AAPL'])) - self.assertIn('query1', getter.calls[1]) - - def test_all_hosts_failing_raises(self): - for failure in (Resp(status=500), ConnectionError('down')): - with self.subTest(failure=failure): - self.http(yahoo, failure) - with self.assertRaises(RuntimeError): - yahoo.fetch(['AAPL']) - - def test_empty_result_is_none(self): - self.http(yahoo, Resp({'quoteResponse': {'result': None, 'error': {'code': 'x'}}})) - self.assertEqual(yahoo.fetch(['NOPE']), {}) - - def test_no_tickers_is_none(self): - self.assertEqual(yahoo.fetch([]), {}) - - def test_incomplete_quotes_are_skipped(self): - body = sample('yahoo', 'sample_yahoo_v7.json') - good = body['quoteResponse']['result'][0] - no_price = {k: v for k, v in good.items() if k != 'regularMarketPrice'} | {'symbol': 'NOPRICE'} - no_name = {k: v for k, v in good.items() if k not in ('longName', 'shortName')} | {'symbol': 'NONAME'} - body['quoteResponse']['result'] = [good, no_price, no_name] - self.http(yahoo, Resp(body)) - self.assertEqual(list(yahoo.fetch(['AAPL', 'NOPRICE', 'NONAME'])), ['AAPL']) + def test_parses_real_v7_quote(self): + self.http(yahoo, Resp(sample('yahoo', 'sample_yahoo_v7.json'))) + data = yahoo.fetch(['AAPL'])['AAPL'] + self.assertEqual(data['profile_title'], 'Apple') + self.assertEqual(data['percent_change'], 0.09) + self.assertEqual(data['percent_change_postmarket'], -0.13) + self.assertEqual(data['quoteType'], 'EQUITY') + self.assertEqual(data['marketState'], 'CLOSED') + self.assertEqual(data['profile_exchange'], 'NasdaqGS') + self.assertEqual(data['exchangeTimezoneName'], 'America/New_York') + self.assertGreater(data['market_cap'], 1e12) + + def test_second_call_is_served_from_cache(self): + getter = self.http(yahoo, Resp(sample('yahoo', 'sample_yahoo_v7.json'))) + yahoo.fetch(['AAPL']) + yahoo.fetch(['AAPL']) + self.assertEqual(len(getter.calls), 1) + + def test_falls_back_to_second_host(self): + getter = self.http(yahoo, Resp(status=500), Resp(sample('yahoo', 'sample_yahoo_v7.json'))) + self.assertIn('AAPL', yahoo.fetch(['AAPL'])) + self.assertIn('query1', getter.calls[1]) + + def test_all_hosts_failing_raises(self): + for failure in (Resp(status=500), ConnectionError('down')): + with self.subTest(failure=failure): + self.http(yahoo, failure) + with self.assertRaises(RuntimeError): + yahoo.fetch(['AAPL']) + + def test_empty_result_is_none(self): + self.http(yahoo, Resp({'quoteResponse': {'result': None, 'error': {'code': 'x'}}})) + self.assertEqual(yahoo.fetch(['NOPE']), {}) + + def test_no_tickers_is_none(self): + self.assertEqual(yahoo.fetch([]), {}) + + def test_incomplete_quotes_are_skipped(self): + body = sample('yahoo', 'sample_yahoo_v7.json') + good = body['quoteResponse']['result'][0] + no_price = {k: v for k, v in good.items() if k != 'regularMarketPrice'} | {'symbol': 'NOPRICE'} + no_name = {k: v for k, v in good.items() if k not in ('longName', 'shortName')} | {'symbol': 'NONAME'} + body['quoteResponse']['result'] = [good, no_price, no_name] + self.http(yahoo, Resp(body)) + self.assertEqual(list(yahoo.fetch(['AAPL', 'NOPRICE', 'NONAME'])), ['AAPL']) class YahooDetail(ProviderCase): - @staticmethod - def combined(): - """quoteSummary as Yahoo returns it for several modules at once, assembled from the per-module samples.""" - merged = {} - for module in ('price', 'summaryProfile', 'summaryDetail', 'defaultKeyStatistics', 'financialData', 'calendarEvents', 'earnings'): - merged.update(sample('yahoo', f'sample_yahoo_v10_{module}.json')['quoteSummary']['result'][0]) - return {'quoteSummary': {'result': [merged], 'error': None}} - - def test_parses_real_quote_summary(self): - self.http(yahoo, Resp(self.combined())) - data = yahoo.fetch_detail('NVDA')['NVDA'] - self.assertEqual(data['profile_title'], 'NVIDIA') - self.assertTrue(data['profile_bio']) - - def test_a_cached_detail_needs_no_cookie(self): - self.http(yahoo, Resp(self.combined())) - yahoo.fetch_detail('NVDA') - def refuse(): - raise AssertionError('a cached detail took the Yahoo auth lock') - self.patch(yahoo, 'credentials', refuse) - self.assertIn('NVDA', yahoo.fetch_detail('NVDA')) - - def test_http_failure_gives_empty(self): - for failure in (Resp(status=404), ConnectionError('down')): - with self.subTest(failure=failure): - self.http(yahoo, failure) - self.assertEqual(yahoo.fetch_detail('DRNA'), {}) - - def test_missing_names_give_empty(self): - body = self.combined() - body['quoteSummary']['result'][0]['price'].update(longName=None, shortName=None) - self.http(yahoo, Resp(body)) - self.assertEqual(yahoo.fetch_detail('DUB'), {}) - self.http(yahoo, Resp({'quoteSummary': {'result': None, 'error': {}}})) - self.assertEqual(yahoo.fetch_detail('GONE'), {}) + @staticmethod + def combined(): + """quoteSummary as Yahoo returns it for several modules at once, assembled from the per-module samples.""" + merged = {} + for module in ('price', 'summaryProfile', 'summaryDetail', 'defaultKeyStatistics', 'financialData', 'calendarEvents', 'earnings'): + merged.update(sample('yahoo', f'sample_yahoo_v10_{module}.json')['quoteSummary']['result'][0]) + return {'quoteSummary': {'result': [merged], 'error': None}} + + def test_parses_real_quote_summary(self): + self.http(yahoo, Resp(self.combined())) + data = yahoo.fetch_detail('NVDA')['NVDA'] + self.assertEqual(data['profile_title'], 'NVIDIA') + self.assertTrue(data['profile_bio']) + + def test_a_cached_detail_needs_no_cookie(self): + self.http(yahoo, Resp(self.combined())) + yahoo.fetch_detail('NVDA') + def refuse(): + raise AssertionError('a cached detail took the Yahoo auth lock') + self.patch(yahoo, 'credentials', refuse) + self.assertIn('NVDA', yahoo.fetch_detail('NVDA')) + + def test_http_failure_gives_empty(self): + for failure in (Resp(status=404), ConnectionError('down')): + with self.subTest(failure=failure): + self.http(yahoo, failure) + self.assertEqual(yahoo.fetch_detail('DRNA'), {}) + + def test_missing_names_give_empty(self): + body = self.combined() + body['quoteSummary']['result'][0]['price'].update(longName=None, shortName=None) + self.http(yahoo, Resp(body)) + self.assertEqual(yahoo.fetch_detail('DUB'), {}) + self.http(yahoo, Resp({'quoteSummary': {'result': None, 'error': {}}})) + self.assertEqual(yahoo.fetch_detail('GONE'), {}) class YahooChart(ProviderCase): - def test_real_chart_to_dataframe_and_basics(self): - chart = sample('yahoo', 'sample_yahoo_v8_chart.json') - df = yahoo.chart_json_to_df(chart) - self.assertEqual(len(df), 2) - self.assertTrue({'Date', 'Close'} <= set(df.columns)) - basics = yahoo.chart_json_to_stock_basics(chart) - self.assertEqual((basics['symbol'], basics['currency']), ('CRWD', 'USD')) - - def test_fetch_chart_json_failures_raise(self): - for failure in (Resp(status=404), ConnectionError('down')): - with self.subTest(failure=failure): - self.http(yahoo, failure) - with self.assertRaises(RuntimeError): - yahoo.fetch_chart_json('AAPL', full=True) - - @staticmethod - def chart(closes, failing=()): - """A v8 chart response with one daily bar per close, the last one today (14:30 UTC, New York's open).""" - today = datetime.datetime.now(datetime.timezone.utc).replace(hour=14, minute=30, second=0, microsecond=0) - stamps = [int((today - datetime.timedelta(days=len(closes) - 1 - i)).timestamp()) for i in range(len(closes))] - meta = {'currency': 'USD', 'symbol': 'T1', 'exchangeTimezoneName': 'America/New_York', 'regularMarketTime': stamps[-1], - 'regularMarketPrice': closes[-1], 'longName': 'Co T1'} - quote = {'open': closes, 'high': closes, 'low': closes, 'close': closes, 'volume': [1] * len(closes)} - return {'chart': {'result': [{'meta': meta, 'timestamp': stamps, 'indicators': {'quote': [quote]}}], 'error': None}} - - def test_1d_is_the_change_since_the_previous_close(self): - self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) - self.http(yahoo, Resp(self.chart([90] * 30 + [100, 105, 110, 115, 120, 116.4]))) # up 20% in a week, down 3% today - table, image = yahoo.price_history('T1', graph=False) - self.assertEqual(table['1D'], -3) - self.assertGreater(table['5D'], 10) # the week, not the day - - def test_a_failed_chart_fetch_raises(self): - self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) - for failure in (Resp(status=429), ConnectionError('down')): - with self.subTest(failure=failure): - self.http(yahoo, failure) - with self.assertRaises(RuntimeError): - yahoo.price_history('AAPL', 7, graph=False) - - def test_one_failed_ticker_does_not_take_the_report_down(self): - self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) - self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) # quotes as canned in the base fixture, charts over HTTP - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)}) - self.patch(sharesight, 'get_performance_wrapper', lambda d: {}) # no Sharesight figures: every ticker needs its Yahoo history - good = Resp(self.chart([100.0] * 20 + [110.0])) - def get(url, **kwargs): - return Resp(status=404) if '/chart/T3?' in url or '/chart/SPY?' in url else good - self.patch(yahoo.net, 'get', get) - def slack(text): # Slack: the reports come as text - self.sent.clear(); self.errors.clear() - worker.process_request('slack', 'C1', '<@U1>', text, BOT, 'U') - self.assertEqual(self.errors, [], text) - return '\n'.join(map(str, self.sent[-1][1])) - price_list = slack('.price 7d') # T3 is left out, the rest are listed - self.assertNotIn('Co T3 (', price_list) - self.assertIn('Co T4 (', price_list) - self.patch(sharesight, 'get_portfolios', lambda: {'Rob': 1}) - self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': {'holdings': [{'portfolio': {'name': 'Rob'}}], - 'currency_gain_percent': '0', 'capital_gain_percent': '2.5', 'total_gain_percent': '0'}}) - report = slack('.performance 7d') # the S&P 500 benchmark has no history - self.assertIn('⚠️ S&P 500: no price history', report) - self.assertIn('NASDAQ 100', report) - self.command('.T3') # the profile goes out without its history lines - self.sent.clear(); self.errors.clear() - worker.process_request('telegram', '55', '@u', '.price T3 7d', BOT, 'U') - self.assertEqual(len(self.errors), 1) # the one ticker asked for: one line saying why - self.assertTrue(self.errors[0].startswith('⚠️ .price: RuntimeError: 404 error for T3')) - - def test_the_chart_request_carries_the_cookie_of_its_crumb(self): - headers = [] - self.patch(yahoo.net, 'get', lambda url, **kwargs: headers.append(kwargs.get('headers', {})) or Resp(self.chart([100.0] * 5))) - yahoo.fetch_chart_json('T1', full=True) - self.assertEqual(headers[0].get('Cookie'), 'A=B') # the crumb used to go without its cookie - - def test_with_the_cache_off_no_chart_is_written(self): - self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) - self.patch(yahoo, 'config_cache', False) - self.http(yahoo, Resp(self.chart([100.0] * 30 + [110.0]))) - table, image = yahoo.price_history('T1', 7, graph=True) - self.assertIsNotNone(image) - self.assertEqual([name for name in os.listdir(util.config_cache_dir) if name.startswith('finbot_graph_')], []) - - def test_price_series_raises_on_failure_and_on_empty_history(self): - self.http(yahoo, Resp(status=500)) - with self.assertRaises(RuntimeError): - yahoo.price_series('AAPL') - empty = sample('yahoo', 'sample_yahoo_v8_chart.json') - empty['chart']['result'][0]['indicators']['quote'][0]['close'] = [None, None] - self.http(yahoo, Resp(empty)) - with self.assertRaises(RuntimeError): - yahoo.price_series('EMPTY') + def test_real_chart_to_dataframe_and_basics(self): + chart = sample('yahoo', 'sample_yahoo_v8_chart.json') + df = yahoo.chart_json_to_df(chart) + self.assertEqual(len(df), 2) + self.assertTrue({'Date', 'Close'} <= set(df.columns)) + basics = yahoo.chart_json_to_stock_basics(chart) + self.assertEqual((basics['symbol'], basics['currency']), ('CRWD', 'USD')) + + def test_fetch_chart_json_failures_raise(self): + for failure in (Resp(status=404), ConnectionError('down')): + with self.subTest(failure=failure): + self.http(yahoo, failure) + with self.assertRaises(RuntimeError): + yahoo.fetch_chart_json('AAPL', full=True) + + @staticmethod + def chart(closes, failing=()): + """A v8 chart response with one daily bar per close, the last one today (14:30 UTC, New York's open).""" + today = datetime.datetime.now(datetime.timezone.utc).replace(hour=14, minute=30, second=0, microsecond=0) + stamps = [int((today - datetime.timedelta(days=len(closes) - 1 - i)).timestamp()) for i in range(len(closes))] + meta = {'currency': 'USD', 'symbol': 'T1', 'exchangeTimezoneName': 'America/New_York', 'regularMarketTime': stamps[-1], + 'regularMarketPrice': closes[-1], 'longName': 'Co T1'} + quote = {'open': closes, 'high': closes, 'low': closes, 'close': closes, 'volume': [1] * len(closes)} + return {'chart': {'result': [{'meta': meta, 'timestamp': stamps, 'indicators': {'quote': [quote]}}], 'error': None}} + + def test_1d_is_the_change_since_the_previous_close(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + self.http(yahoo, Resp(self.chart([90] * 30 + [100, 105, 110, 115, 120, 116.4]))) # up 20% in a week, down 3% today + table, image = yahoo.price_history('T1', graph=False) + self.assertEqual(table['1D'], -3) + self.assertGreater(table['5D'], 10) # the week, not the day + + def test_a_failed_chart_fetch_raises(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + for failure in (Resp(status=429), ConnectionError('down')): + with self.subTest(failure=failure): + self.http(yahoo, failure) + with self.assertRaises(RuntimeError): + yahoo.price_history('AAPL', 7, graph=False) + + def test_one_failed_ticker_does_not_take_the_report_down(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) # quotes as canned in the base fixture, charts over HTTP + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)}) + self.patch(sharesight, 'get_performance_wrapper', lambda d: {}) # no Sharesight figures: every ticker needs its Yahoo history + good = Resp(self.chart([100.0] * 20 + [110.0])) + def get(url, **kwargs): + return Resp(status=404) if '/chart/T3?' in url or '/chart/SPY?' in url else good + self.patch(yahoo.net, 'get', get) + def slack(text): # Slack: the reports come as text + self.sent.clear(); self.errors.clear() + worker.process_request('slack', 'C1', '<@U1>', text, BOT, 'U') + self.assertEqual(self.errors, [], text) + return '\n'.join(map(str, self.sent[-1][1])) + price_list = slack('.price 7d') # T3 is left out, the rest are listed + self.assertNotIn('Co T3 (', price_list) + self.assertIn('Co T4 (', price_list) + self.patch(sharesight, 'get_portfolios', lambda: {'Rob': 1}) + self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': {'holdings': [{'portfolio': {'name': 'Rob'}}], + 'currency_gain_percent': '0', 'capital_gain_percent': '2.5', 'total_gain_percent': '0'}}) + report = slack('.performance 7d') # the S&P 500 benchmark has no history + self.assertIn('⚠️ S&P 500: no price history', report) + self.assertIn('NASDAQ 100', report) + self.command('.T3') # the profile goes out without its history lines + self.sent.clear(); self.errors.clear() + worker.process_request('telegram', '55', '@u', '.price T3 7d', BOT, 'U') + self.assertEqual(len(self.errors), 1) # the one ticker asked for: one line saying why + self.assertTrue(self.errors[0].startswith('⚠️ .price: RuntimeError: 404 error for T3')) + + def test_the_chart_request_carries_the_cookie_of_its_crumb(self): + headers = [] + self.patch(yahoo.net, 'get', lambda url, **kwargs: headers.append(kwargs.get('headers', {})) or Resp(self.chart([100.0] * 5))) + yahoo.fetch_chart_json('T1', full=True) + self.assertEqual(headers[0].get('Cookie'), 'A=B') # the crumb used to go without its cookie + + def test_with_the_cache_off_no_chart_is_written(self): + self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) + self.patch(yahoo, 'config_cache', False) + self.http(yahoo, Resp(self.chart([100.0] * 30 + [110.0]))) + table, image = yahoo.price_history('T1', 7, graph=True) + self.assertIsNotNone(image) + self.assertEqual([name for name in os.listdir(util.config_cache_dir) if name.startswith('finbot_graph_')], []) + + def test_price_series_raises_on_failure_and_on_empty_history(self): + self.http(yahoo, Resp(status=500)) + with self.assertRaises(RuntimeError): + yahoo.price_series('AAPL') + empty = sample('yahoo', 'sample_yahoo_v8_chart.json') + empty['chart']['result'][0]['indicators']['quote'][0]['close'] = [None, None] + self.http(yahoo, Resp(empty)) + with self.assertRaises(RuntimeError): + yahoo.price_series('EMPTY') class SharesightApi(ProviderCase): - def test_portfolios_from_real_response(self): - self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_portfolios.json'))) - self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) - - def test_portfolio_exclusion_and_inclusion(self): - self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_portfolios.json'))) - self.patch(sharesight, 'config_exclude_portfolios', ['445825']) - self.assertEqual(sharesight.get_portfolios(), {'RobSMSF': 684141}) - self.patch(sharesight, 'config_exclude_portfolios', []) - self.patch(sharesight, 'config_include_portfolios', ['445825']) - self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825}) - - def test_portfolio_failures_raise(self): - for failure in (Resp({'error': 'nope', 'error_code': 'x'}, 401), ConnectionError('down'), Resp({'portfolios': []})): - with self.subTest(failure=failure): - self.http(sharesight, failure) - with self.assertRaises(RuntimeError): - sharesight.get_portfolios() - - def test_trades_from_real_response(self): - self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_trades_684141_1.json'))) - trades = sharesight.get_trades('RobSMSF', 684141, 1) - self.assertEqual(len(trades), 2) - self.assertEqual(trades[0]['portfolio'], 'RobSMSF') - self.assertEqual(trades[0]['symbol'], 'FANG') - - def test_trade_failures_raise(self): - self.http(sharesight, ConnectionError('down')) - with self.assertRaises(RuntimeError): - sharesight.get_trades('RobSMSF', 684141, 1) - self.http(sharesight, Resp({'trades': [], 'error': 'bad', 'error_code': 'x'}, 400)) - with self.assertRaises(RuntimeError): - sharesight.get_trades('RobSMSF', 684141, 2) - - def test_performance_and_holdings_from_real_response(self): - self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_performance_684141_0.json'))) - self.assertEqual(len(sharesight.get_performance(684141, 0)['report']['holdings']), 18) - tickers = sharesight.get_holdings('RobSMSF', 684141) - self.assertIn('ARM', tickers) # US venues carry no Yahoo suffix - self.assertEqual(tickers, sorted(tickers)) - - def test_performance_retries_when_sharesight_is_busy(self): - busy = Resp({'error': 'Too many parallel requests', 'error_code': 'x'}, 429) - getter = self.http(sharesight, busy, busy, Resp(sample('sharesight', 'finbot_sharesight_performance_684141_0.json'))) - self.assertIn('report', sharesight.get_performance(684141, 0)) - self.assertEqual(len(getter.calls), 3) - - def test_performance_gives_up_after_repeated_busy_or_hard_errors(self): - for failure in (Resp({'error': 'Too many parallel requests', 'error_code': 'x'}, 429), - Resp({'error': 'forbidden', 'error_code': 'x'}, 403), ConnectionError('down')): - with self.subTest(failure=failure): - self.http(sharesight, failure) - with self.assertRaises(RuntimeError): - sharesight.get_performance(684141, 7) + def test_portfolios_from_real_response(self): + self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_portfolios.json'))) + self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) + + def test_portfolio_exclusion_and_inclusion(self): + self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_portfolios.json'))) + self.patch(sharesight, 'config_exclude_portfolios', ['445825']) + self.assertEqual(sharesight.get_portfolios(), {'RobSMSF': 684141}) + self.patch(sharesight, 'config_exclude_portfolios', []) + self.patch(sharesight, 'config_include_portfolios', ['445825']) + self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825}) + + def test_portfolio_failures_raise(self): + for failure in (Resp({'error': 'nope', 'error_code': 'x'}, 401), ConnectionError('down'), Resp({'portfolios': []})): + with self.subTest(failure=failure): + self.http(sharesight, failure) + with self.assertRaises(RuntimeError): + sharesight.get_portfolios() + + def test_trades_from_real_response(self): + self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_trades_684141_1.json'))) + trades = sharesight.get_trades('RobSMSF', 684141, 1) + self.assertEqual(len(trades), 2) + self.assertEqual(trades[0]['portfolio'], 'RobSMSF') + self.assertEqual(trades[0]['symbol'], 'FANG') + + def test_trade_failures_raise(self): + self.http(sharesight, ConnectionError('down')) + with self.assertRaises(RuntimeError): + sharesight.get_trades('RobSMSF', 684141, 1) + self.http(sharesight, Resp({'trades': [], 'error': 'bad', 'error_code': 'x'}, 400)) + with self.assertRaises(RuntimeError): + sharesight.get_trades('RobSMSF', 684141, 2) + + def test_performance_and_holdings_from_real_response(self): + self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_performance_684141_0.json'))) + self.assertEqual(len(sharesight.get_performance(684141, 0)['report']['holdings']), 18) + tickers = sharesight.get_holdings('RobSMSF', 684141) + self.assertIn('ARM', tickers) # US venues carry no Yahoo suffix + self.assertEqual(tickers, sorted(tickers)) + + def test_performance_retries_when_sharesight_is_busy(self): + busy = Resp({'error': 'Too many parallel requests', 'error_code': 'x'}, 429) + getter = self.http(sharesight, busy, busy, Resp(sample('sharesight', 'finbot_sharesight_performance_684141_0.json'))) + self.assertIn('report', sharesight.get_performance(684141, 0)) + self.assertEqual(len(getter.calls), 3) + + def test_performance_gives_up_after_repeated_busy_or_hard_errors(self): + for failure in (Resp({'error': 'Too many parallel requests', 'error_code': 'x'}, 429), + Resp({'error': 'forbidden', 'error_code': 'x'}, 403), ConnectionError('down')): + with self.subTest(failure=failure): + self.http(sharesight, failure) + with self.assertRaises(RuntimeError): + sharesight.get_performance(684141, 7) class SharesightKeepWarm(ProviderCase): - """The background refresher keeps the portfolio list and the holdings report (days=0) cached ahead of their TTLs.""" - - def serve_sharesight(self): - portfolios = sample('sharesight', 'finbot_sharesight_portfolios.json') - performance = sample('sharesight', 'finbot_sharesight_performance_684141_0.json') - calls = [] - def get(url, **kwargs): - calls.append(url) - return Resp(performance if '/performance' in url else portfolios) - self.patch(sharesight.net, 'get', get) - return calls - - def age(self, cache_file, seconds): - path = os.path.join(util.config_cache_dir, cache_file) - old = os.path.getmtime(path) - seconds - os.utime(path, (old, old)) - - def test_cold_cache_is_filled_then_left_alone(self): - calls = self.serve_sharesight() - self.assertEqual(sharesight.warm()[0], 3) # portfolio list + one holdings report per portfolio (2) - self.assertEqual(sum('/performance' in c for c in calls), 2) - self.assertTrue(all('start_date=' + datetime.date.today().isoformat() in c for c in calls if '/performance' in c)) # days=0 - self.assertEqual(sharesight.warm()[0], 0) - self.assertEqual(len(calls), 3) # nothing refetched while fresh - - def test_refreshes_before_the_ttl_runs_out(self): - calls = self.serve_sharesight() - sharesight.warm()[0] - calls.clear() - self.age(sharesight.performance_cache_file(684141, 0), 0.95 * sharesight.config_performance_cache_seconds) # 95% of its lifetime - self.assertEqual(sharesight.warm()[0], 1) - self.assertEqual(len(calls), 1) - self.assertIn('684141/performance', calls[0]) - calls.clear() - self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) - self.assertEqual(sharesight.warm()[0], 1) - self.assertTrue(calls[0].endswith('/portfolios')) - - def test_a_request_never_waits_on_a_cache_the_job_has_warmed(self): - calls = self.serve_sharesight() - sharesight.warm()[0] - calls.clear() - self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) - self.assertEqual(len(sharesight.get_performance(684141, 0)['report']['holdings']), 18) - self.assertEqual(calls, []) - - def test_holdings_wrapper_is_served_entirely_from_the_warmed_cache(self): - calls = self.serve_sharesight() - sharesight.warm()[0] - calls.clear() - tickers = sharesight.get_holdings_wrapper() # get_portfolios() + get_holdings() per portfolio, no cache of its own - self.assertIn('ARM', tickers) - self.assertEqual(calls, []) - - def test_failed_refresh_keeps_the_old_cache(self): - self.serve_sharesight() - sharesight.warm()[0] - self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) - self.http(sharesight, ConnectionError('down')) - with self.assertRaises(RuntimeError): - sharesight.warm()[0] - self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) # still served from the cache - - def test_loop_survives_failures_and_backs_off(self): - class Stop: - def __init__(self): self.waits = [] - def is_set(self): return len(self.waits) >= 2 - def wait(self, seconds): self.waits.append(seconds) - outcomes = [RuntimeError('boom'), 0] - def warm(): - outcome = outcomes.pop(0) - if isinstance(outcome, Exception): - raise outcome - return outcome, {} - self.patch(sharesight, 'warm', warm) - self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) - stop = Stop() - sharesight.keep_warm(retry=300, stop=stop) - self.assertEqual(stop.waits, [300, 3000]) # slow retry after the failure, then sleep until the next cache is due - - def test_with_cache_off_nothing_is_fetched_until_a_reload_turns_it_on(self): - class Stop: - def __init__(self): self.waits = [] - def is_set(self): return len(self.waits) >= 4 - def wait(self, seconds): - self.waits.append(seconds) - if len(self.waits) == 2: - sharesight.config_cache = True # what a reload that switches it on does - passes = [] - self.patch(sharesight, 'warm', lambda: (passes.append(1), (0, {}))[1]) - self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) - self.patch(sharesight, 'config_cache', False) - stop = Stop() - sharesight.keep_warm(idle=60, stop=stop) - self.assertEqual((stop.waits, len(passes)), ([60, 60, 3000, 3000], 2)) # idle while off: no Sharesight request every 5 seconds - - def test_a_flush_wakes_the_loop_to_refill_at_once(self): - passes = [] - self.patch(sharesight, 'warm', lambda: (passes.append(1), (0, {}))[1]) - self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) - loop = gevent.spawn(sharesight.keep_warm) - self.addCleanup(loop.kill) - gevent.sleep(0.05) - self.assertEqual(len(passes), 1) # then it sleeps until the next cache is due, 50 minutes away - sharesight.wake() - gevent.sleep(0.05) - self.assertEqual(len(passes), 2) - - def test_a_warming_pass_reads_the_cached_portfolio_list_once(self): - self.serve_sharesight() - sharesight.warm()[0] # fill the caches - reads = [] - real = util.read_cache - self.patch(util, 'read_cache', lambda name, *a, **k: (reads.append(name), real(name, *a, **k))[1]) - fetched, portfolios = sharesight.warm() # what one pass of keep_warm does - due = sharesight.next_due(portfolios=portfolios) - self.assertEqual((fetched, reads), (0, [sharesight.PORTFOLIOS_CACHE])) - self.assertAlmostEqual(due, sharesight.next_due(), delta=1) # the same answer as reading the list again - self.assertEqual(portfolios, {'Rob': 445825, 'RobSMSF': 684141}) - - def test_next_due_is_90_percent_of_the_ttl(self): - self.serve_sharesight() - self.assertEqual(sharesight.next_due(), 0) # cold cache - sharesight.warm()[0] - due = sharesight.next_due() - self.assertAlmostEqual(due, 0.9 * min(sharesight.config_cache_seconds, sharesight.config_performance_cache_seconds), delta=5) - - def test_refresh_flag_bypasses_a_fresh_cache(self): - calls = self.serve_sharesight() - sharesight.get_portfolios() - sharesight.get_portfolios() - self.assertEqual(len(calls), 1) - sharesight.get_portfolios(refresh=True) - self.assertEqual(len(calls), 2) + """The background refresher keeps the portfolio list and the holdings report (days=0) cached ahead of their TTLs.""" + + def serve_sharesight(self): + portfolios = sample('sharesight', 'finbot_sharesight_portfolios.json') + performance = sample('sharesight', 'finbot_sharesight_performance_684141_0.json') + calls = [] + def get(url, **kwargs): + calls.append(url) + return Resp(performance if '/performance' in url else portfolios) + self.patch(sharesight.net, 'get', get) + return calls + + def age(self, cache_file, seconds): + path = os.path.join(util.config_cache_dir, cache_file) + old = os.path.getmtime(path) - seconds + os.utime(path, (old, old)) + + def test_cold_cache_is_filled_then_left_alone(self): + calls = self.serve_sharesight() + self.assertEqual(sharesight.warm()[0], 3) # portfolio list + one holdings report per portfolio (2) + self.assertEqual(sum('/performance' in c for c in calls), 2) + self.assertTrue(all('start_date=' + datetime.date.today().isoformat() in c for c in calls if '/performance' in c)) # days=0 + self.assertEqual(sharesight.warm()[0], 0) + self.assertEqual(len(calls), 3) # nothing refetched while fresh + + def test_refreshes_before_the_ttl_runs_out(self): + calls = self.serve_sharesight() + sharesight.warm()[0] + calls.clear() + self.age(sharesight.performance_cache_file(684141, 0), 0.95 * sharesight.config_performance_cache_seconds) # 95% of its lifetime + self.assertEqual(sharesight.warm()[0], 1) + self.assertEqual(len(calls), 1) + self.assertIn('684141/performance', calls[0]) + calls.clear() + self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) + self.assertEqual(sharesight.warm()[0], 1) + self.assertTrue(calls[0].endswith('/portfolios')) + + def test_a_request_never_waits_on_a_cache_the_job_has_warmed(self): + calls = self.serve_sharesight() + sharesight.warm()[0] + calls.clear() + self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) + self.assertEqual(len(sharesight.get_performance(684141, 0)['report']['holdings']), 18) + self.assertEqual(calls, []) + + def test_holdings_wrapper_is_served_entirely_from_the_warmed_cache(self): + calls = self.serve_sharesight() + sharesight.warm()[0] + calls.clear() + tickers = sharesight.get_holdings_wrapper() # get_portfolios() + get_holdings() per portfolio, no cache of its own + self.assertIn('ARM', tickers) + self.assertEqual(calls, []) + + def test_failed_refresh_keeps_the_old_cache(self): + self.serve_sharesight() + sharesight.warm()[0] + self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) + self.http(sharesight, ConnectionError('down')) + with self.assertRaises(RuntimeError): + sharesight.warm()[0] + self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825, 'RobSMSF': 684141}) # still served from the cache + + def test_loop_survives_failures_and_backs_off(self): + class Stop: + def __init__(self): self.waits = [] + def is_set(self): return len(self.waits) >= 2 + def wait(self, seconds): self.waits.append(seconds) + outcomes = [RuntimeError('boom'), 0] + def warm(): + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome, {} + self.patch(sharesight, 'warm', warm) + self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) + stop = Stop() + sharesight.keep_warm(retry=300, stop=stop) + self.assertEqual(stop.waits, [300, 3000]) # slow retry after the failure, then sleep until the next cache is due + + def test_with_cache_off_nothing_is_fetched_until_a_reload_turns_it_on(self): + class Stop: + def __init__(self): self.waits = [] + def is_set(self): return len(self.waits) >= 4 + def wait(self, seconds): + self.waits.append(seconds) + if len(self.waits) == 2: + sharesight.config_cache = True # what a reload that switches it on does + passes = [] + self.patch(sharesight, 'warm', lambda: (passes.append(1), (0, {}))[1]) + self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) + self.patch(sharesight, 'config_cache', False) + stop = Stop() + sharesight.keep_warm(idle=60, stop=stop) + self.assertEqual((stop.waits, len(passes)), ([60, 60, 3000, 3000], 2)) # idle while off: no Sharesight request every 5 seconds + + def test_a_flush_wakes_the_loop_to_refill_at_once(self): + passes = [] + self.patch(sharesight, 'warm', lambda: (passes.append(1), (0, {}))[1]) + self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) + loop = gevent.spawn(sharesight.keep_warm) + self.addCleanup(loop.kill) + gevent.sleep(0.05) + self.assertEqual(len(passes), 1) # then it sleeps until the next cache is due, 50 minutes away + sharesight.wake() + gevent.sleep(0.05) + self.assertEqual(len(passes), 2) + + def test_a_warming_pass_reads_the_cached_portfolio_list_once(self): + self.serve_sharesight() + sharesight.warm()[0] # fill the caches + reads = [] + real = util.read_cache + self.patch(util, 'read_cache', lambda name, *a, **k: (reads.append(name), real(name, *a, **k))[1]) + fetched, portfolios = sharesight.warm() # what one pass of keep_warm does + due = sharesight.next_due(portfolios=portfolios) + self.assertEqual((fetched, reads), (0, [sharesight.PORTFOLIOS_CACHE])) + self.assertAlmostEqual(due, sharesight.next_due(), delta=1) # the same answer as reading the list again + self.assertEqual(portfolios, {'Rob': 445825, 'RobSMSF': 684141}) + + def test_next_due_is_90_percent_of_the_ttl(self): + self.serve_sharesight() + self.assertEqual(sharesight.next_due(), 0) # cold cache + sharesight.warm()[0] + due = sharesight.next_due() + self.assertAlmostEqual(due, 0.9 * min(sharesight.config_cache_seconds, sharesight.config_performance_cache_seconds), delta=5) + + def test_refresh_flag_bypasses_a_fresh_cache(self): + calls = self.serve_sharesight() + sharesight.get_portfolios() + sharesight.get_portfolios() + self.assertEqual(len(calls), 1) + sharesight.get_portfolios(refresh=True) + self.assertEqual(len(calls), 2) class TelegramSending(FinbotCase): - """webhook.write against failing endpoints. The real send path, not the base fixture's recorder.""" - - def post(self, outcome): - def fake(url, **kwargs): - if isinstance(outcome, Exception): - raise outcome - return outcome - self.patch(webhook.net, 'post', fake) - return webhook.write('telegram', 'https://api.telegram.org/botX/sendMessage?chat_id=5', 'hi', '5') - - def test_success_returns_json(self): - self.assertEqual(self.post(Resp({'ok': True})), {'ok': True}) - - def test_http_error_and_exception_return_none(self): - self.assertIsNone(self.post(Resp(status=429))) - self.assertIsNone(self.post(ConnectionError('down'))) - - def test_non_json_success_returns_empty_dict(self): - self.assertEqual(self.post(Resp(None)), {}) - - def test_error_line_redacts_secrets_and_truncates(self): - line = webhook.error_line(RuntimeError('GET /bot123456:ABC-def_1/send?crumb=SECRET&x=1 failed'), 'ctx') - self.assertNotIn('ABC-def_1', line) - self.assertNotIn('SECRET', line) - self.assertTrue(line.startswith('⚠️ ctx: ')) - self.assertLessEqual(len(webhook.error_line('x' * 1000)), 205) - - def test_error_reports_are_escaped_for_telegram_and_slack(self): - sent = [] - self.patch(webhook, 'write', lambda service, url, text, *a, **k: sent.append((service, text))) - self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/', 'slack': 'https://hooks.slack.com/x', 'discord': 'https://discord.com/x/slack'}) - self.patch(webhook, 'config_telegramChatID', '5') - error = RuntimeError("404 for ZZ at https://x/?a=1&crumb=abc; '<' not supported between ") - with contextlib.redirect_stderr(io.StringIO()): - REAL_REPORT_ERROR(error, context='.compare ') - REAL_REPORT_ERROR(error, service='telegram', chat_id='55', context='.compare') - texts = dict(sent[:3]) - self.assertIn('crumb=<redacted>', texts['telegram']) # Telegram's HTML parser rejects a bare - self.assertNotIn('<', texts['telegram'] + sent[3][1]) - self.assertIn('.compare <b>: RuntimeError', texts['telegram']) - self.assertEqual(texts['slack'], texts['telegram']) - self.assertIn('crumb=', texts['discord']) - - def test_report_error_never_raises(self): - self.patch(webhook, 'write', mock.Mock(side_effect=RuntimeError('send failed'))) - webhook.report_error(ValueError('boom'), service='telegram', chat_id='5') - webhook.report_error(ValueError('boom')) - - def test_guarded_reports_and_exits_nonzero(self): - reported = [] - self.patch(webhook, 'report_error', lambda e, **k: reported.append(e)) - with self.assertRaises(SystemExit) as raised: - webhook.guarded('job', lambda: 1 / 0) - self.assertEqual(raised.exception.code, 1) - self.assertEqual(len(reported), 1) - self.assertEqual(webhook.guarded('job', lambda: 'fine'), 'fine') + """webhook.write against failing endpoints. The real send path, not the base fixture's recorder.""" + + def post(self, outcome): + def fake(url, **kwargs): + if isinstance(outcome, Exception): + raise outcome + return outcome + self.patch(webhook.net, 'post', fake) + return webhook.write('telegram', 'https://api.telegram.org/botX/sendMessage?chat_id=5', 'hi', '5') + + def test_success_returns_json(self): + self.assertEqual(self.post(Resp({'ok': True})), {'ok': True}) + + def test_http_error_and_exception_return_none(self): + self.assertIsNone(self.post(Resp(status=429))) + self.assertIsNone(self.post(ConnectionError('down'))) + + def test_non_json_success_returns_empty_dict(self): + self.assertEqual(self.post(Resp(None)), {}) + + def test_error_line_redacts_secrets_and_truncates(self): + line = webhook.error_line(RuntimeError('GET /bot123456:ABC-def_1/send?crumb=SECRET&x=1 failed'), 'ctx') + self.assertNotIn('ABC-def_1', line) + self.assertNotIn('SECRET', line) + self.assertTrue(line.startswith('⚠️ ctx: ')) + self.assertLessEqual(len(webhook.error_line('x' * 1000)), 205) + + def test_error_reports_are_escaped_for_telegram_and_slack(self): + sent = [] + self.patch(webhook, 'write', lambda service, url, text, *a, **k: sent.append((service, text))) + self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/', 'slack': 'https://hooks.slack.com/x', 'discord': 'https://discord.com/x/slack'}) + self.patch(webhook, 'config_telegramChatID', '5') + error = RuntimeError("404 for ZZ at https://x/?a=1&crumb=abc; '<' not supported between ") + with contextlib.redirect_stderr(io.StringIO()): + REAL_REPORT_ERROR(error, context='.compare ') + REAL_REPORT_ERROR(error, service='telegram', chat_id='55', context='.compare') + texts = dict(sent[:3]) + self.assertIn('crumb=<redacted>', texts['telegram']) # Telegram's HTML parser rejects a bare + self.assertNotIn('<', texts['telegram'] + sent[3][1]) + self.assertIn('.compare <b>: RuntimeError', texts['telegram']) + self.assertEqual(texts['slack'], texts['telegram']) + self.assertIn('crumb=', texts['discord']) + + def test_report_error_never_raises(self): + self.patch(webhook, 'write', mock.Mock(side_effect=RuntimeError('send failed'))) + webhook.report_error(ValueError('boom'), service='telegram', chat_id='5') + webhook.report_error(ValueError('boom')) + + def test_guarded_reports_and_exits_nonzero(self): + reported = [] + self.patch(webhook, 'report_error', lambda e, **k: reported.append(e)) + with self.assertRaises(SystemExit) as raised: + webhook.guarded('job', lambda: 1 / 0) + self.assertEqual(raised.exception.code, 1) + self.assertEqual(len(reported), 1) + self.assertEqual(webhook.guarded('job', lambda: 'fine'), 'fine') class ChartDelivery(FinbotCase): - """Charts go to Telegram, and to Slack in replies (uploaded into the channel). A report whose chart cannot be sent goes out as - text instead of vanishing.""" - UPLOAD_URL = 'https://files.slack.com/upload/v1/abc' - - def setUp(self): - super().setUp() - self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) - - def slack_answers(self, *outcomes): - """net.post for the upload steps: the outcomes in turn (an exception is raised). Returns the calls made.""" - calls = [] - def post(url, **kwargs): - calls.append((url, kwargs)) - outcome = outcomes[len(calls) - 1] - if isinstance(outcome, Exception): - raise outcome - return outcome - self.patch(webhook.net, 'post', post) - return calls - - def upload_steps(self): - return [Resp({'ok': True, 'upload_url': self.UPLOAD_URL, 'file_id': 'F1'}), Resp(None, text='OK - 3'), Resp({'ok': True, 'files': [{'id': 'F1'}]})] - - def test_where_charts_can_go(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - cases = (('telegram',), ('telegram', False), ('slack',), ('slack', False), ('discord', False)) - self.assertEqual([webhook.takes_charts(*case) for case in cases], [True, True, True, False, True]) # Slack: replies, not scheduled reports - self.patch(webhook, 'config_slackBotToken', False) - self.assertFalse(webhook.takes_charts('slack')) - - def test_slack_replies_upload_their_charts(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - for cmd, heading in (('.beta', '*Beta over 1.5'), ('.price', '*5% at close'), ('.compare T1 T2', '*')): - with self.subTest(cmd=cmd): - calls = self.slack_answers(*self.upload_steps()) - self.sent.clear(); self.errors.clear() - worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') - self.assertEqual(self.errors, []) - self.assertEqual([url for url, _ in calls], ['https://slack.com/api/files.getUploadURLExternal', self.UPLOAD_URL, - 'https://slack.com/api/files.completeUploadExternal']) - (_, ticket), (_, upload), (_, share) = calls - self.assertEqual(ticket['headers'], {'Authorization': 'Bearer xoxb-test'}) - self.assertTrue(upload['data'].startswith(b'\x89PNG')) # the chart's bytes, as the body - self.assertEqual((ticket['data']['filename'], ticket['data']['length']), ('chart.png', len(upload['data']))) - self.assertEqual((share['data']['channel_id'], json.loads(share['data']['files'])), ('C1', [{'id': 'F1'}])) - self.assertTrue(share['data']['initial_comment'].startswith(heading), share['data']['initial_comment'][:80]) # the report - self.assertNotIn(share['data']['initial_comment'].split('\n'), [payload for _, payload in self.sent]) # not sent again as text - - def test_a_slack_upload_that_fails_goes_out_as_text(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - good = self.upload_steps() - text = [('text', ['*Heading*', 'line 2'])] - for outcomes, expect in (([Resp({'ok': False, 'error': 'missing_scope'})], text), ([ConnectionError('down')], text), - ([good[0], Resp(None, status=500, text='nope')], text), - ([good[0], good[1], Resp({'ok': False, 'error': 'not_in_channel'})], text), - ([good[0], good[1], webhook.requests.exceptions.ReadTimeout('slow')], []), # it may have been shared after all - (good, [])): - with self.subTest(outcomes=outcomes): - self.slack_answers(*outcomes) - self.sent.clear() - with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): - webhook.sendPhoto('C1', io.BytesIO(b'png'), '*Heading*\nline 2', 'slack') - self.assertEqual(self.sent, expect) - - def test_scheduled_reports_to_slack_stay_text(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - webhook.webhooks['slack'] = 'https://hooks.slack.com/services/x' # an incoming webhook cannot upload - sent = [] - self.patch(webhook, 'sendPhoto', lambda chat, image, caption, service, **k: sent.append((service, 'chart'))) - self.patch(webhook, 'payload_wrapper', lambda service, url, payload, *a, **k: sent.append((service, 'text')) or []) - for report in (price.lambda_handler, lambda: performance.lambda_handler(past_days=7), shorts.lambda_handler): - with self.subTest(report=report): - sent.clear() - report() - self.assertEqual(sorted(set(sent)), [('slack', 'text'), ('telegram', 'chart')]) - - def test_slack_without_a_bot_token_gets_text_and_no_chart_is_drawn(self): - self.patch(webhook, 'config_slackBotToken', False) - def refuse(*a, **k): - raise AssertionError('a chart was drawn for Slack') - for name in ('rows_chart', 'pie_chart', 'bar_graph', 'shorts_chart'): - self.patch(util, name, refuse) - webhook.webhooks['slack'] = 'https://hooks.slack.com/services/x' - for cmd in ('.beta', '.pie Rob', '.buy', '.sell', '.peg', '.price', '.price top', '.price T1 7d', '.performance 7d', '.shorts', '.history T1', '.compare T1 T2'): - with self.subTest(cmd=cmd): - self.sent.clear(); self.errors.clear() - worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') - self.assertEqual(self.errors, []) - self.assertTrue(self.sent) - self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) - - def test_the_graph_preference_off_sends_no_chart_anywhere(self): - """graph=False means no charts, as the README says; it used to hold back only the price-line charts.""" - self.patch(webhook, 'config_graph', False) - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - posts = [] - self.patch(webhook.net, 'post', lambda url, **k: posts.append(url) or Resp({'ok': True, 'result': True})) - for service, chat in (('telegram', '55'), ('slack', 'C1')): - for cmd in ('.price', '.price top', '.performance 7d', '.shorts', '.buy', '.peg', '.beta', '.compare T1 T2', '.pie Rob'): - with self.subTest(service=service, cmd=cmd): - self.sent.clear(); self.errors.clear() - worker.process_request(service, chat, '@u', cmd, BOT, 'U') - self.assertEqual(self.errors, []) - self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) - self.assertEqual([url for url in posts if 'sendPhoto' in url or 'files.' in url], []) - - def test_a_chart_telegram_refuses_goes_out_as_text(self): - def post(outcome): - def fake(url, **kwargs): - if isinstance(outcome, Exception): - raise outcome - return outcome - return fake - text = [('text', ['Heading', 'line 2'])] - for outcome, expect in ((Resp({'ok': False, 'error_code': 400, 'description': "Bad Request: can't parse entities"}, status=400), text), - (Resp(None, status=502), text), (ConnectionError('down'), text), - (webhook.requests.exceptions.ReadTimeout('slow'), []), # it may have arrived after all: no duplicate - (Resp({'ok': True, 'result': {'message_id': 1}}), [])): - with self.subTest(outcome=outcome): - self.sent.clear() - self.patch(webhook.net, 'post', post(outcome)) - with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): - webhook.sendPhoto('5', io.BytesIO(b'png'), 'Heading\nline 2', 'telegram', reply_markup={'inline_keyboard': []}) - self.assertEqual(self.sent, expect) + """Charts go to Telegram, and to Slack in replies (uploaded into the channel). A report whose chart cannot be sent goes out as + text instead of vanishing.""" + UPLOAD_URL = 'https://files.slack.com/upload/v1/abc' + + def setUp(self): + super().setUp() + self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) + + def slack_answers(self, *outcomes): + """net.post for the upload steps: the outcomes in turn (an exception is raised). Returns the calls made.""" + calls = [] + def post(url, **kwargs): + calls.append((url, kwargs)) + outcome = outcomes[len(calls) - 1] + if isinstance(outcome, Exception): + raise outcome + return outcome + self.patch(webhook.net, 'post', post) + return calls + + def upload_steps(self): + return [Resp({'ok': True, 'upload_url': self.UPLOAD_URL, 'file_id': 'F1'}), Resp(None, text='OK - 3'), Resp({'ok': True, 'files': [{'id': 'F1'}]})] + + def test_where_charts_can_go(self): + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + cases = (('telegram',), ('telegram', False), ('slack',), ('slack', False), ('discord', False)) + self.assertEqual([webhook.takes_charts(*case) for case in cases], [True, True, True, False, True]) # Slack: replies, not scheduled reports + self.patch(webhook, 'config_slackBotToken', False) + self.assertFalse(webhook.takes_charts('slack')) + + def test_slack_replies_upload_their_charts(self): + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + for cmd, heading in (('.beta', '*Beta over 1.5'), ('.price', '*5% at close'), ('.compare T1 T2', '*')): + with self.subTest(cmd=cmd): + calls = self.slack_answers(*self.upload_steps()) + self.sent.clear(); self.errors.clear() + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') + self.assertEqual(self.errors, []) + self.assertEqual([url for url, _ in calls], ['https://slack.com/api/files.getUploadURLExternal', self.UPLOAD_URL, + 'https://slack.com/api/files.completeUploadExternal']) + (_, ticket), (_, upload), (_, share) = calls + self.assertEqual(ticket['headers'], {'Authorization': 'Bearer xoxb-test'}) + self.assertTrue(upload['data'].startswith(b'\x89PNG')) # the chart's bytes, as the body + self.assertEqual((ticket['data']['filename'], ticket['data']['length']), ('chart.png', len(upload['data']))) + self.assertEqual((share['data']['channel_id'], json.loads(share['data']['files'])), ('C1', [{'id': 'F1'}])) + self.assertTrue(share['data']['initial_comment'].startswith(heading), share['data']['initial_comment'][:80]) # the report + self.assertNotIn(share['data']['initial_comment'].split('\n'), [payload for _, payload in self.sent]) # not sent again as text + + def test_a_slack_upload_that_fails_goes_out_as_text(self): + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + good = self.upload_steps() + text = [('text', ['*Heading*', 'line 2'])] + for outcomes, expect in (([Resp({'ok': False, 'error': 'missing_scope'})], text), ([ConnectionError('down')], text), + ([good[0], Resp(None, status=500, text='nope')], text), + ([good[0], good[1], Resp({'ok': False, 'error': 'not_in_channel'})], text), + ([good[0], good[1], webhook.requests.exceptions.ReadTimeout('slow')], []), # it may have been shared after all + (good, [])): + with self.subTest(outcomes=outcomes): + self.slack_answers(*outcomes) + self.sent.clear() + with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): + webhook.sendPhoto('C1', io.BytesIO(b'png'), '*Heading*\nline 2', 'slack') + self.assertEqual(self.sent, expect) + + def test_scheduled_reports_to_slack_stay_text(self): + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + webhook.webhooks['slack'] = 'https://hooks.slack.com/services/x' # an incoming webhook cannot upload + sent = [] + self.patch(webhook, 'sendPhoto', lambda chat, image, caption, service, **k: sent.append((service, 'chart'))) + self.patch(webhook, 'payload_wrapper', lambda service, url, payload, *a, **k: sent.append((service, 'text')) or []) + for report in (price.lambda_handler, lambda: performance.lambda_handler(past_days=7), shorts.lambda_handler): + with self.subTest(report=report): + sent.clear() + report() + self.assertEqual(sorted(set(sent)), [('slack', 'text'), ('telegram', 'chart')]) + + def test_slack_without_a_bot_token_gets_text_and_no_chart_is_drawn(self): + self.patch(webhook, 'config_slackBotToken', False) + def refuse(*a, **k): + raise AssertionError('a chart was drawn for Slack') + for name in ('rows_chart', 'pie_chart', 'bar_graph', 'shorts_chart'): + self.patch(util, name, refuse) + webhook.webhooks['slack'] = 'https://hooks.slack.com/services/x' + for cmd in ('.beta', '.pie Rob', '.buy', '.sell', '.peg', '.price', '.price top', '.price T1 7d', '.performance 7d', '.shorts', '.history T1', '.compare T1 T2'): + with self.subTest(cmd=cmd): + self.sent.clear(); self.errors.clear() + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') + self.assertEqual(self.errors, []) + self.assertTrue(self.sent) + self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) + + def test_the_graph_preference_off_sends_no_chart_anywhere(self): + """graph=False means no charts, as the README says; it used to hold back only the price-line charts.""" + self.patch(webhook, 'config_graph', False) + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + posts = [] + self.patch(webhook.net, 'post', lambda url, **k: posts.append(url) or Resp({'ok': True, 'result': True})) + for service, chat in (('telegram', '55'), ('slack', 'C1')): + for cmd in ('.price', '.price top', '.performance 7d', '.shorts', '.buy', '.peg', '.beta', '.compare T1 T2', '.pie Rob'): + with self.subTest(service=service, cmd=cmd): + self.sent.clear(); self.errors.clear() + worker.process_request(service, chat, '@u', cmd, BOT, 'U') + self.assertEqual(self.errors, []) + self.assertEqual({kind for kind, *_ in self.sent}, {'text'}) + self.assertEqual([url for url in posts if 'sendPhoto' in url or 'files.' in url], []) + + def test_a_chart_telegram_refuses_goes_out_as_text(self): + def post(outcome): + def fake(url, **kwargs): + if isinstance(outcome, Exception): + raise outcome + return outcome + return fake + text = [('text', ['Heading', 'line 2'])] + for outcome, expect in ((Resp({'ok': False, 'error_code': 400, 'description': "Bad Request: can't parse entities"}, status=400), text), + (Resp(None, status=502), text), (ConnectionError('down'), text), + (webhook.requests.exceptions.ReadTimeout('slow'), []), # it may have arrived after all: no duplicate + (Resp({'ok': True, 'result': {'message_id': 1}}), [])): + with self.subTest(outcome=outcome): + self.sent.clear() + self.patch(webhook.net, 'post', post(outcome)) + with contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()): + webhook.sendPhoto('5', io.BytesIO(b'png'), 'Heading\nline 2', 'telegram', reply_markup={'inline_keyboard': []}) + self.assertEqual(self.sent, expect) class DiscordWebhook(FinbotCase): - """Discord's own webhook endpoint (the Slack-compatible one takes no flags): no link previews, no pings, charts attached.""" - URL = 'https://discord.com/api/webhooks/1/abc' - - def setUp(self): - super().setUp() - self.patch(webhook, 'webhooks', {'discord': self.URL}) - self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) - self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) - self.posts = [] - self.patch(webhook.net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or Resp({'id': '1'})) - - def test_text_goes_to_the_native_endpoint_without_previews_or_pings(self): - webhook.write('discord', self.URL, 'hello @everyone') - url, kwargs = self.posts[0] - self.assertEqual(url, self.URL + '?wait=true') # without wait Discord answers 204, which counted as a failure - self.assertEqual(kwargs['json'], {'content': 'hello @everyone', 'flags': 4, 'allowed_mentions': {'parse': []}}) - - def test_a_scheduled_report_carries_its_chart(self): - price.lambda_handler() - (url, kwargs), = self.posts - self.assertEqual(url, self.URL + '?wait=true') - payload = json.loads(kwargs['data']['payload_json']) - self.assertEqual((payload['flags'], payload['allowed_mentions'], payload['attachments'][0]['filename']), (4, {'parse': []}, 'chart.png')) - self.assertTrue(payload['content'].startswith('**5% at close'), payload['content'][:60]) - self.assertLessEqual(len(payload['content']), 2000) - name, image, kind = kwargs['files']['files[0]'] - self.assertEqual((name, kind), ('chart.png', 'image/png')) - self.assertTrue(image.read().startswith(b'\x89PNG')) - - def test_a_long_report_is_split_by_size(self): - line = 'x' * 150 - webhook.payload_wrapper('discord', self.URL, [line] * 30) - sizes = [len(kwargs['json']['content']) for _, kwargs in self.posts] - self.assertGreater(len(sizes), 2) - self.assertTrue(all(size <= 2000 for size in sizes), sizes) # 20 lines a chunk used to make 3,019 characters - - def test_links_are_masked_links(self): - self.assertEqual(util.link('https://x.example/a(b)', 'T[1]', 'discord'), '[T\\[1\\]]()') + """Discord's own webhook endpoint (the Slack-compatible one takes no flags): no link previews, no pings, charts attached.""" + URL = 'https://discord.com/api/webhooks/1/abc' + + def setUp(self): + super().setUp() + self.patch(webhook, 'webhooks', {'discord': self.URL}) + self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) + self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) + self.posts = [] + self.patch(webhook.net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or Resp({'id': '1'})) + + def test_text_goes_to_the_native_endpoint_without_previews_or_pings(self): + webhook.write('discord', self.URL, 'hello @everyone') + url, kwargs = self.posts[0] + self.assertEqual(url, self.URL + '?wait=true') # without wait Discord answers 204, which counted as a failure + self.assertEqual(kwargs['json'], {'content': 'hello @everyone', 'flags': 4, 'allowed_mentions': {'parse': []}}) + + def test_a_scheduled_report_carries_its_chart(self): + price.lambda_handler() + (url, kwargs), = self.posts + self.assertEqual(url, self.URL + '?wait=true') + payload = json.loads(kwargs['data']['payload_json']) + self.assertEqual((payload['flags'], payload['allowed_mentions'], payload['attachments'][0]['filename']), (4, {'parse': []}, 'chart.png')) + self.assertTrue(payload['content'].startswith('**5% at close'), payload['content'][:60]) + self.assertLessEqual(len(payload['content']), 2000) + name, image, kind = kwargs['files']['files[0]'] + self.assertEqual((name, kind), ('chart.png', 'image/png')) + self.assertTrue(image.read().startswith(b'\x89PNG')) + + def test_a_long_report_is_split_by_size(self): + line = 'x' * 150 + webhook.payload_wrapper('discord', self.URL, [line] * 30) + sizes = [len(kwargs['json']['content']) for _, kwargs in self.posts] + self.assertGreater(len(sizes), 2) + self.assertTrue(all(size <= 2000 for size in sizes), sizes) # 20 lines a chunk used to make 3,019 characters + + def test_links_are_masked_links(self): + self.assertEqual(util.link('https://x.example/a(b)', 'T[1]', 'discord'), '[T\\[1\\]]()') class SlackSending(FinbotCase): - """Slack replies go through chat.postMessage with the bot token; scheduled reports through the incoming webhook.""" - - def setUp(self): - super().setUp() - self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) - self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) - self.posts = [] - self.patch(webhook.net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or Resp({'ok': True})) - - def test_link_previews_are_turned_off_in_the_body(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - webhook.write('slack', 'https://slack.com/api/chat.postMessage', 'hi', 'C1') - webhook.write('slack', 'https://hooks.slack.com/services/T/B/x', 'hi') - self.assertEqual(len(self.posts), 2) - for url, kwargs in self.posts: - with self.subTest(url=url): # they used to go in the headers, where Slack does not look - self.assertEqual((kwargs['json']['unfurl_links'], kwargs['json']['unfurl_media']), (False, False)) - self.assertNotIn('unfurl_links', kwargs['headers']) - - def test_a_reply_without_a_bot_token_is_logged_not_sent(self): - self.patch(webhook, 'config_slackBotToken', False) - err = io.StringIO() - with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): - for cmd in ('.help', '.price', '.compare T1 T2'): - worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') - self.assertEqual((self.posts, self.errors), ([], [])) - self.assertEqual(err.getvalue().count('ERROR slack: a reply to channel C1 needs slackBotToken in finbot.env; not sent'), 3) - - def test_the_slack_api_says_what_is_missing(self): - self.patch(webhook, 'config_slackBotToken', False) - with self.assertRaisesRegex(RuntimeError, 'Slack files.getUploadURLExternal: needs slackBotToken in finbot.env'): - webhook.slack_api('files.getUploadURLExternal', filename='chart.png', length=1) # it used to be a TypeError - self.assertEqual(self.posts, []) + """Slack replies go through chat.postMessage with the bot token; scheduled reports through the incoming webhook.""" + + def setUp(self): + super().setUp() + self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) + self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) + self.posts = [] + self.patch(webhook.net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or Resp({'ok': True})) + + def test_link_previews_are_turned_off_in_the_body(self): + self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + webhook.write('slack', 'https://slack.com/api/chat.postMessage', 'hi', 'C1') + webhook.write('slack', 'https://hooks.slack.com/services/T/B/x', 'hi') + self.assertEqual(len(self.posts), 2) + for url, kwargs in self.posts: + with self.subTest(url=url): # they used to go in the headers, where Slack does not look + self.assertEqual((kwargs['json']['unfurl_links'], kwargs['json']['unfurl_media']), (False, False)) + self.assertNotIn('unfurl_links', kwargs['headers']) + + def test_a_reply_without_a_bot_token_is_logged_not_sent(self): + self.patch(webhook, 'config_slackBotToken', False) + err = io.StringIO() + with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): + for cmd in ('.help', '.price', '.compare T1 T2'): + worker.process_request('slack', 'C1', '<@U1>', cmd, BOT, 'U') + self.assertEqual((self.posts, self.errors), ([], [])) + self.assertEqual(err.getvalue().count('ERROR slack: a reply to channel C1 needs slackBotToken in finbot.env; not sent'), 3) + + def test_the_slack_api_says_what_is_missing(self): + self.patch(webhook, 'config_slackBotToken', False) + with self.assertRaisesRegex(RuntimeError, 'Slack files.getUploadURLExternal: needs slackBotToken in finbot.env'): + webhook.slack_api('files.getUploadURLExternal', filename='chart.png', length=1) # it used to be a TypeError + self.assertEqual(self.posts, []) class TelegramWebhook(FinbotCase): - """bot.py registers the webhook once as it starts. Importing lib.telegram, as every job process does, touches nothing.""" - - def setUp(self): - super().setUp() - self.patch(telegram, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) - self.patch(telegram, 'config_telegramOutgoingWebhook', 'https://example.com/telegram') - - def test_register_unregisters_registers_and_names_the_bot(self): - calls = [] - def post(url, **kwargs): - calls.append((url.rsplit('/', 1)[1], kwargs.get('params', {}).get('url'))) - return Resp({'ok': True, 'result': {'username': 'finbot'} if url.endswith('getMe') else True}) - self.patch(telegram.net, 'post', post) - with contextlib.redirect_stderr(io.StringIO()): - self.assertEqual(telegram.register(), '@finbot') - self.assertEqual(calls, [('setWebhook', ''), ('setWebhook', 'https://example.com/telegram'), ('getMe', None)]) - - def test_a_refused_call_raises(self): - self.patch(telegram.net, 'post', lambda url, **k: Resp({'ok': False, 'description': 'Bad Request: bad webhook: HTTPS url must be provided'})) - with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(RuntimeError) as caught: - telegram.register() - self.assertIn('HTTPS url must be provided', str(caught.exception)) - - def test_edits_toasts_and_typing_go_through_call(self): - """A chart edit that Telegram refuses is an error (unless nothing changed); a toast, a text edit or a typing notice that - fails, or an edit lost on the way, is only logged.""" - def answer(outcome): - def post(url, **kwargs): - if isinstance(outcome, Exception): - raise outcome - return outcome - return post - refused = Resp({'ok': False, 'description': 'Bad Request: message to edit not found'}) - unchanged = Resp({'ok': False, 'description': 'Bad Request: message is not modified: specified new message content is the same'}) - for outcome, raises in ((refused, True), (unchanged, False), (ConnectionError('down'), False), (Resp({'ok': True, 'result': True}), False)): - with self.subTest(outcome=outcome): - self.patch(telegram.net, 'post', answer(outcome)) - with contextlib.redirect_stderr(io.StringIO()): - if raises: - self.assertRaisesRegex(RuntimeError, 'not found', webhook.editMessageMedia, '5', '9', io.BytesIO(b'png'), 'caption') - else: - webhook.editMessageMedia('5', '9', io.BytesIO(b'png'), 'caption') - telegram.answerCallbackQuery('1', 'Loading…') - webhook.editMessageText('5', '9', 'text') - webhook.pleaseHold('typing', 'telegram', '5') - - def test_importing_never_calls_telegram(self): - code = ('import requests\n' - 'def refuse(*a, **k): raise SystemExit("network call on import")\n' - 'requests.Session.request = refuse\n' - 'from lib import telegram, yahoo, worker, jobrun\n' - 'print("quiet")') - with tempfile.TemporaryDirectory() as data: - out = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, - env={**os.environ, 'DATA_DIR': data, 'telegramBotToken': '123:abc', 'telegramOutgoingWebhook': 'https://example.com/telegram'}) - self.assertEqual(out.stdout.split()[-1:], ['quiet'], out.stderr) - - def test_the_bot_will_not_start_unregistered_and_never_logs_the_token(self): - skip_if_old_env(self) - with tempfile.TemporaryDirectory() as data: - env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS and 'proxy' not in k.lower()} - env.update(DATA_DIR=data, port='0', scheduler='False', telegramBotToken='123456:SECRET-token', telegramOutgoingToken='tok', - telegramOutgoingWebhook='https://example.com/telegram', - HTTPS_PROXY='http://127.0.0.1:9', https_proxy='http://127.0.0.1:9') # unreachable, so the registration fails at once and offline - out = subprocess.run([sys.executable, 'bot.py'], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) - self.assertEqual(out.returncode, 1, out.stderr) - self.assertIn('ERROR ⚠️ telegram webhook:', out.stderr) - self.assertNotIn('SECRET-token', out.stderr + out.stdout) + """bot.py registers the webhook once as it starts. Importing lib.telegram, as every job process does, touches nothing.""" + + def setUp(self): + super().setUp() + self.patch(telegram, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) + self.patch(telegram, 'config_telegramOutgoingWebhook', 'https://example.com/telegram') + + def test_register_unregisters_registers_and_names_the_bot(self): + calls = [] + def post(url, **kwargs): + calls.append((url.rsplit('/', 1)[1], kwargs.get('params', {}).get('url'))) + return Resp({'ok': True, 'result': {'username': 'finbot'} if url.endswith('getMe') else True}) + self.patch(telegram.net, 'post', post) + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(telegram.register(), '@finbot') + self.assertEqual(calls, [('setWebhook', ''), ('setWebhook', 'https://example.com/telegram'), ('getMe', None)]) + + def test_a_refused_call_raises(self): + self.patch(telegram.net, 'post', lambda url, **k: Resp({'ok': False, 'description': 'Bad Request: bad webhook: HTTPS url must be provided'})) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(RuntimeError) as caught: + telegram.register() + self.assertIn('HTTPS url must be provided', str(caught.exception)) + + def test_edits_toasts_and_typing_go_through_call(self): + """A chart edit that Telegram refuses is an error (unless nothing changed); a toast, a text edit or a typing notice that + fails, or an edit lost on the way, is only logged.""" + def answer(outcome): + def post(url, **kwargs): + if isinstance(outcome, Exception): + raise outcome + return outcome + return post + refused = Resp({'ok': False, 'description': 'Bad Request: message to edit not found'}) + unchanged = Resp({'ok': False, 'description': 'Bad Request: message is not modified: specified new message content is the same'}) + for outcome, raises in ((refused, True), (unchanged, False), (ConnectionError('down'), False), (Resp({'ok': True, 'result': True}), False)): + with self.subTest(outcome=outcome): + self.patch(telegram.net, 'post', answer(outcome)) + with contextlib.redirect_stderr(io.StringIO()): + if raises: + self.assertRaisesRegex(RuntimeError, 'not found', webhook.editMessageMedia, '5', '9', io.BytesIO(b'png'), 'caption') + else: + webhook.editMessageMedia('5', '9', io.BytesIO(b'png'), 'caption') + telegram.answerCallbackQuery('1', 'Loading…') + webhook.editMessageText('5', '9', 'text') + webhook.pleaseHold('typing', 'telegram', '5') + + def test_importing_never_calls_telegram(self): + code = ('import requests\n' + 'def refuse(*a, **k): raise SystemExit("network call on import")\n' + 'requests.Session.request = refuse\n' + 'from lib import telegram, yahoo, worker, jobrun\n' + 'print("quiet")') + with tempfile.TemporaryDirectory() as data: + out = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, + env={**os.environ, 'DATA_DIR': data, 'telegramBotToken': '123:abc', 'telegramOutgoingWebhook': 'https://example.com/telegram'}) + self.assertEqual(out.stdout.split()[-1:], ['quiet'], out.stderr) + + def test_the_bot_will_not_start_unregistered_and_never_logs_the_token(self): + skip_if_old_env(self) + with tempfile.TemporaryDirectory() as data: + env = {k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k not in config.STARTUP_KEYS and 'proxy' not in k.lower()} + env.update(DATA_DIR=data, port='0', scheduler='False', telegramBotToken='123456:SECRET-token', telegramOutgoingToken='tok', + telegramOutgoingWebhook='https://example.com/telegram', + HTTPS_PROXY='http://127.0.0.1:9', https_proxy='http://127.0.0.1:9') # unreachable, so the registration fails at once and offline + out = subprocess.run([sys.executable, 'bot.py'], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) + self.assertEqual(out.returncode, 1, out.stderr) + self.assertIn('ERROR ⚠️ telegram webhook:', out.stderr) + self.assertNotIn('SECRET-token', out.stderr + out.stdout) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_reports.py b/tests/test_reports.py index 49c06a3d..084ea115 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -5,427 +5,427 @@ class Commands(FinbotCase): - def test_text_commands(self): - for cmd in ('.help', '/start', '.hello', '.thanks', '.who', '.plan', '.holdings', '.marketcap', - '.pe', '.fpe', '.peg', '.beta', '.buy', '.sell', '.earnings', '.dividend', - '.earnings T1', '.dividend T1', '.trades', '.trades 30d', '.price T1', '.session T1', - '.premarket T1', '.shorts T1', '.watchlist', '.T1'): - with self.subTest(cmd=cmd): - self.assertTrue(self.command(cmd), f'{cmd} sent nothing') - - def test_chart_commands(self): - for cmd in ('.price', '.price 7d', '.price top', '.price bottom', '.session', '.premarket', '.postmarket', - '.performance', '.performance 7d', '.performance 1y', '.shorts', '.peg', '.buy', '.sell', '.beta', '.price top 7d', - '.forward pe', '.fpe bottom'): - with self.subTest(cmd=cmd): - out = self.command(cmd) - self.assertTrue(out, f'{cmd} sent nothing') - self.assertTrue(any(kind == 'photo' for kind, *_ in out), f'{cmd} sent no chart: {out}') - - def test_one_button_asks_for_both_ratings_reports(self): - for text in ('.buy & sell', '.buy sell', '.buy/sell', '.buy and sell'): - with self.subTest(text=text): - captions = [caption for kind, _, caption in self.command(text) if kind == 'photo'] - self.assertEqual(len(captions), 2, captions) - self.assertIn('analyst buy ratings', captions[0]) - self.assertIn('analyst sell ratings', captions[1]) - self.assertIn('analyst sell ratings', str(self.command('.sell & buy')[0])) # in the order asked - self.assertEqual(len(self.command('.buy & buy')), 1) # each report once - - def test_the_keyboard_is_alphabetical_and_every_button_is_a_command(self): - buttons = [button['text'] for row in charts.command_keyboard()['keyboard'] for button in row] - self.assertEqual(buttons, sorted(buttons)) - self.assertEqual(len(buttons), 15) - self.assertIn('.buy & sell', buttons) - self.assertIn('.forward pe', buttons) - for text in buttons: # not taken for a ticker by the catch-all .SYMBOL - with self.subTest(text=text): - self.assertIsNot(next(handler for pattern, handler in worker.COMMANDS if worker.match(pattern, text, BOT)), worker.on_profile) - - def test_history_and_compare(self): - for cmd in ('.history T1', '.compare T1 T2', '.compare T1 T2 3m'): - with self.subTest(cmd=cmd): - self.assertTrue(self.command(cmd), f'{cmd} sent nothing') - - def test_trades_and_watchlist_report_when_nothing_is_stored(self): - # first run: no state files exist yet, which used to crash these - for cmd in ('.trades', '.watchlist'): - self.assertTrue(self.command(cmd), cmd) - - def test_unknown_stickers_and_noise_are_ignored(self): - self.assertEqual(self.command('lorem ipsum dolor sit amet'), []) + def test_text_commands(self): + for cmd in ('.help', '/start', '.hello', '.thanks', '.who', '.plan', '.holdings', '.marketcap', + '.pe', '.fpe', '.peg', '.beta', '.buy', '.sell', '.earnings', '.dividend', + '.earnings T1', '.dividend T1', '.trades', '.trades 30d', '.price T1', '.session T1', + '.premarket T1', '.shorts T1', '.watchlist', '.T1'): + with self.subTest(cmd=cmd): + self.assertTrue(self.command(cmd), f'{cmd} sent nothing') + + def test_chart_commands(self): + for cmd in ('.price', '.price 7d', '.price top', '.price bottom', '.session', '.premarket', '.postmarket', + '.performance', '.performance 7d', '.performance 1y', '.shorts', '.peg', '.buy', '.sell', '.beta', '.price top 7d', + '.forward pe', '.fpe bottom'): + with self.subTest(cmd=cmd): + out = self.command(cmd) + self.assertTrue(out, f'{cmd} sent nothing') + self.assertTrue(any(kind == 'photo' for kind, *_ in out), f'{cmd} sent no chart: {out}') + + def test_one_button_asks_for_both_ratings_reports(self): + for text in ('.buy & sell', '.buy sell', '.buy/sell', '.buy and sell'): + with self.subTest(text=text): + captions = [caption for kind, _, caption in self.command(text) if kind == 'photo'] + self.assertEqual(len(captions), 2, captions) + self.assertIn('analyst buy ratings', captions[0]) + self.assertIn('analyst sell ratings', captions[1]) + self.assertIn('analyst sell ratings', str(self.command('.sell & buy')[0])) # in the order asked + self.assertEqual(len(self.command('.buy & buy')), 1) # each report once + + def test_the_keyboard_is_alphabetical_and_every_button_is_a_command(self): + buttons = [button['text'] for row in charts.command_keyboard()['keyboard'] for button in row] + self.assertEqual(buttons, sorted(buttons)) + self.assertEqual(len(buttons), 15) + self.assertIn('.buy & sell', buttons) + self.assertIn('.forward pe', buttons) + for text in buttons: # not taken for a ticker by the catch-all .SYMBOL + with self.subTest(text=text): + self.assertIsNot(next(handler for pattern, handler in worker.COMMANDS if worker.match(pattern, text, BOT)), worker.on_profile) + + def test_history_and_compare(self): + for cmd in ('.history T1', '.compare T1 T2', '.compare T1 T2 3m'): + with self.subTest(cmd=cmd): + self.assertTrue(self.command(cmd), f'{cmd} sent nothing') + + def test_trades_and_watchlist_report_when_nothing_is_stored(self): + # first run: no state files exist yet, which used to crash these + for cmd in ('.trades', '.watchlist'): + self.assertTrue(self.command(cmd), cmd) + + def test_unknown_stickers_and_noise_are_ignored(self): + self.assertEqual(self.command('lorem ipsum dolor sit amet'), []) class ReportFunctions(FinbotCase): - def test_prepare_help(self): - self.assertTrue(reports.prepare_help('telegram', BOT)) - - def test_a_portfolio_with_no_holdings_is_reported_by_name(self): - """Its name used to be read from its first holding: an empty portfolio crashed .performance.""" - self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': {'holdings': [], 'capital_gain_percent': '0.0'}}) - payload, _ = performance.report('telegram', 7) - self.assertTrue(any('>Rob' in line for line in payload), payload) - - def test_prepare_holdings(self): - self.assertTrue(reports.prepare_holdings_payload(None, 'telegram')) - - def test_prepare_marketcap(self): - self.assertTrue(reports.prepare_marketcap_payload('telegram', 'top', length=5)) - - def test_prepare_rating(self): - for action in ('buy', 'sell'): - self.assertTrue(reports.prepare_rating_payload('telegram', action, length=5)[0]) - - def test_ratings_list_the_strongest_first(self): - """1 is a strong buy and 5 a strong sell: .sell used to list the mildest sells first. On equal ratings, more analysts first; - fewer than 7 analysts and the stock is left out (T7, T8: the ASX small caps that are always a perfect 1.0).""" - ratings = {'T1': ('buy', 1.5, 7), 'T2': ('strong_buy', 1.2, 12), 'T3': ('buy', 1.5, 9), 'T7': ('strong_buy', 1.0, 6), - 'T4': ('sell', 4.2, 7), 'T5': ('underperform', 4.6, 10), 'T6': ('sell', 4.2, 8), 'T8': ('strong_buy', 1.0, 2)} - self.patch(util, 'get_holdings_and_watchlist', lambda: list(ratings)) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | dict(zip(('recommend', 'recommend_index', 'recommend_analysts'), ratings[t]))}) - def order(action): - payload, chart = reports.prepare_rating_payload('telegram', action) - return [line.split('Co ')[1].split(' ')[0] for line in payload[1:]], [label for label, _ in chart['rows']] - self.assertEqual(order('buy'), (['T2', 'T3', 'T1'], ['T2', 'T3', 'T1'])) - self.assertEqual(order('sell'), (['T5', 'T6', 'T4'], ['T5', 'T6', 'T4'])) - self.assertIn('(7+ analysts)', reports.prepare_rating_payload('telegram', 'buy')[0][0]) - self.patch(util, 'get_holdings_and_watchlist', lambda: ['T7', 'T8']) - self.assertEqual(reports.prepare_rating_payload('telegram', 'buy'), (['No tracked stocks rated buy by 7+ analysts'], None)) - - def test_a_small_cap_quoted_in_won_needs_the_bigger_move_too(self): - """demote_volatile compares market caps in US dollars: unconverted, a small cap quoted in won was never small.""" - self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | ({'regularMarketPrice': 1380.0} if t == 'KRW=X' else {'currency': 'KRW', 'market_cap': 1e11}) for t in ts}) - self.patch(price, 'config_demote_volatile', True) - quote = price.gather()[1]['T1'] - self.assertAlmostEqual(quote['market_cap_usd'], 1e11 / 1380.0) # US$72M - self.assertEqual(price.trigger_bar('T1', quote, 5), 5 * price.config_volatility_multiplier) - - def test_the_small_caps_betas_are_fetched_together(self): - """A scheduled price report asks for the beta of each small cap that moved (US$150M to US$1B): together, not one by one.""" - calls = [] - self.patch(yahoo, 'fetch_details', lambda tickers, strict=False: calls.append(sorted(tickers)) or {t: market_entry(t) for t in tickers}) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: self.fail('a beta fetched on its own')) - self.patch(price, 'config_demote_volatile', True) - self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'market_cap': 5e8} for t in ts}) - price.lambda_handler() - self.assertEqual(len(calls), 1) - self.assertTrue(calls[0]) - - def test_forward_pe_lists_need_as_many_analysts_as_buy(self): - """A forward P/E is analysts' estimate: with few of them it says little, as a perfect 1.0 buy from two brokers does.""" - analysts = {'T1': 7, 'T2': 6, 'T3': 12, 'T4': 2} - self.patch(util, 'get_holdings_and_watchlist', lambda: list(analysts)) - self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'price_to_earnings_forward': -5 if t == 'T3' else 20} for t in ts}) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | {'recommend_analysts': analysts[t]}}) - def listed(action): - payload, chart = reports.prepare_value_payload('telegram', action) - return payload[0], [line.split('Co ')[1].split(' ')[0] for line in payload[1:]], chart - heading, names, chart = listed('forward pe') - self.assertEqual((names, [label for label, _ in chart['rows']]), (['T1'], ['T1'])) # T2 and T4: too few; T3: negative - self.assertIn('forward pe ratio (7+ analysts)', heading) - self.assertEqual(listed('negative forward pe')[1], ['T3']) - self.assertIn('Co T2', str(reports.prepare_value_payload('telegram', 'forward pe', 'T2'))) # one ticker asked for: shown - self.patch(reports, 'config_min_analysts', 13) # the one setting moves .buy and .forward pe together - self.assertEqual(reports.prepare_value_payload('telegram', 'forward pe')[0], ['No tracked stocks with 13+ analysts have a forward pe ratio']) - self.assertEqual(reports.prepare_rating_payload('telegram', 'buy')[0], ['No tracked stocks rated buy by 13+ analysts']) - - def test_market_caps_compare_in_us_dollars(self): - """Yahoo gives a market cap in the stock's own currency: unconverted, a won market cap topped every list.""" - stocks = {'K1.KS': ('KRW', 1.01e15), 'K2.KS': ('KRW', 5e11), 'U1': ('USD', 5.39e12), 'L1.L': ('GBp', 6.25e10), 'Z1': ('XYZ', 1e12)} - fx = {'KRW=X': 1380.0, 'GBP=X': 0.79} # units per US dollar; nothing for XYZ - def fetch(tickers): - return {t: market_entry(t) | {'regularMarketPrice': fx[t]} if t in fx else market_entry(t) | {'currency': stocks[t][0], 'market_cap': stocks[t][1], 'beta': 2.0} - for t in tickers if t in fx or t in stocks} - self.patch(yahoo, 'fetch', fetch) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: fetch([t])) - self.patch(util, 'get_holdings_and_watchlist', lambda: list(stocks)) - payload = reports.prepare_marketcap_payload('telegram') - self.assertIn('by market cap (USD)', payload[0]) - self.assertEqual([line.split('Co ')[1].split(' ')[0] for line in payload[1:]], ['U1', 'K1.KS', 'L1.L', 'K2.KS']) # Z1: no rate, left out - self.assertIn('mkt cap: 731.88 B', payload[2]) # 1.01 Q won - self.assertIn('mkt cap: KRW 1.01 Q (USD 731.88 B)', reports.prepare_marketcap_payload('telegram', 'K1.KS')[0]) - self.assertIn('mkt cap: GBP 62.50 B (USD 79.11 B)', reports.prepare_marketcap_payload('telegram', 'L1.L')[0]) # pence quote, pound cap - beta = reports.prepare_beta_payload('telegram')[0] - self.assertIn('Co K2.KS', str(beta)) # 500 billion won is US$362M: a small cap, which it never was unconverted - self.assertNotIn('Co K1.KS', str(beta)) - - def test_prepare_value(self): - for action, heading in (('pe', 'Top 5 tracked stocks by pe ratio'), ('bottom forward pe', 'Bottom 5 tracked stocks by forward pe ratio'), - ('peg', 'Top 5 tracked stocks by peg ratio'), ('negative peg', 'negative peg: no data found')): - with self.subTest(action=action): - payload, chart = reports.prepare_value_payload('telegram', action, length=5) - self.assertIn(heading, payload[0]) - - def test_prepare_beta(self): - payload, chart = reports.prepare_beta_payload('telegram') - self.assertIn('Beta over 1.5', payload[0]) - self.assertEqual(len(chart['rows']), len(payload) - 1) # a bar per stock listed - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | {'beta': 1.0}}) - self.assertEqual(reports.prepare_beta_payload('telegram'), (['No tracked stocks have a beta over 1.5 and mkt cap under US$2B'], None)) - - def test_prepare_profile(self): - self.assertTrue(reports.prepare_profile_payload('telegram', '@u', 'T1')) - - def test_a_profile_leaves_out_what_yahoo_did_not_send(self): - """Each of these fields missing (or a zero equity) used to crash the whole profile.""" - full = market_entry('T1') | {'insiderBuy': 1, 'insiderSell': 0, 'insiderBuyValue': 5e6, 'insiderSellValue': 0, 'netIncomeToCommon': 1e6, - 'sharesOutstanding': 1e6, 'shareholder_equity': 1e9, 'total_debt': 9e8, 'total_cash': 1e8, 'profile_industry': 'Tools'} - for missing in ('marketState', 'profile_exchange', 'market_cap', 'currency', 'regularMarketPrice', 'sharesOutstanding', 'percent_change'): - with self.subTest(missing=missing): - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: {k: v for k, v in full.items() if k != missing}}) - self.assertIn('Co T1', reports.prepare_profile_payload('telegram', '@u', 'T1')[0]) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: full | {'shareholder_equity': 0}}) - self.assertNotIn('Net debt/equity', str(reports.prepare_profile_payload('telegram', '@u', 'T1'))) - - def test_a_profile_warns_of_a_ps_ratio_only_above_15(self): - for ps, warned in ((12.3, False), (15.1, True)): # forward P/E 99 warns (above 30): its warning used to show on the PS line too - with self.subTest(ps=ps): - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | {'price_to_sales': ps, 'price_to_earnings_forward': 99, 'profile_industry': 'Tools'}}) - line = next(line for line in reports.prepare_profile_payload('telegram', '@u', 'T1') if 'PS ratio' in line) - self.assertEqual('⚠️' in line, warned, line) - - def test_prepare_watchlist(self): - self.assertTrue(reports.prepare_watchlist('telegram', '@u')) - - def test_prepare_compare_usage(self): - caption = reports.prepare_compare('telegram', ['T1'])[0] - self.assertIn('Usage', ' '.join(caption)) + def test_prepare_help(self): + self.assertTrue(reports.prepare_help('telegram', BOT)) + + def test_a_portfolio_with_no_holdings_is_reported_by_name(self): + """Its name used to be read from its first holding: an empty portfolio crashed .performance.""" + self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': {'holdings': [], 'capital_gain_percent': '0.0'}}) + payload, _ = performance.report('telegram', 7) + self.assertTrue(any('>Rob' in line for line in payload), payload) + + def test_prepare_holdings(self): + self.assertTrue(reports.prepare_holdings_payload(None, 'telegram')) + + def test_prepare_marketcap(self): + self.assertTrue(reports.prepare_marketcap_payload('telegram', 'top', length=5)) + + def test_prepare_rating(self): + for action in ('buy', 'sell'): + self.assertTrue(reports.prepare_rating_payload('telegram', action, length=5)[0]) + + def test_ratings_list_the_strongest_first(self): + """1 is a strong buy and 5 a strong sell: .sell used to list the mildest sells first. On equal ratings, more analysts first; + fewer than 7 analysts and the stock is left out (T7, T8: the ASX small caps that are always a perfect 1.0).""" + ratings = {'T1': ('buy', 1.5, 7), 'T2': ('strong_buy', 1.2, 12), 'T3': ('buy', 1.5, 9), 'T7': ('strong_buy', 1.0, 6), + 'T4': ('sell', 4.2, 7), 'T5': ('underperform', 4.6, 10), 'T6': ('sell', 4.2, 8), 'T8': ('strong_buy', 1.0, 2)} + self.patch(util, 'get_holdings_and_watchlist', lambda: list(ratings)) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | dict(zip(('recommend', 'recommend_index', 'recommend_analysts'), ratings[t]))}) + def order(action): + payload, chart = reports.prepare_rating_payload('telegram', action) + return [line.split('Co ')[1].split(' ')[0] for line in payload[1:]], [label for label, _ in chart['rows']] + self.assertEqual(order('buy'), (['T2', 'T3', 'T1'], ['T2', 'T3', 'T1'])) + self.assertEqual(order('sell'), (['T5', 'T6', 'T4'], ['T5', 'T6', 'T4'])) + self.assertIn('(7+ analysts)', reports.prepare_rating_payload('telegram', 'buy')[0][0]) + self.patch(util, 'get_holdings_and_watchlist', lambda: ['T7', 'T8']) + self.assertEqual(reports.prepare_rating_payload('telegram', 'buy'), (['No tracked stocks rated buy by 7+ analysts'], None)) + + def test_a_small_cap_quoted_in_won_needs_the_bigger_move_too(self): + """demote_volatile compares market caps in US dollars: unconverted, a small cap quoted in won was never small.""" + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | ({'regularMarketPrice': 1380.0} if t == 'KRW=X' else {'currency': 'KRW', 'market_cap': 1e11}) for t in ts}) + self.patch(price, 'config_demote_volatile', True) + quote = price.gather()[1]['T1'] + self.assertAlmostEqual(quote['market_cap_usd'], 1e11 / 1380.0) # US$72M + self.assertEqual(price.trigger_bar('T1', quote, 5), 5 * price.config_volatility_multiplier) + + def test_the_small_caps_betas_are_fetched_together(self): + """A scheduled price report asks for the beta of each small cap that moved (US$150M to US$1B): together, not one by one.""" + calls = [] + self.patch(yahoo, 'fetch_details', lambda tickers, strict=False: calls.append(sorted(tickers)) or {t: market_entry(t) for t in tickers}) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: self.fail('a beta fetched on its own')) + self.patch(price, 'config_demote_volatile', True) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'market_cap': 5e8} for t in ts}) + price.lambda_handler() + self.assertEqual(len(calls), 1) + self.assertTrue(calls[0]) + + def test_forward_pe_lists_need_as_many_analysts_as_buy(self): + """A forward P/E is analysts' estimate: with few of them it says little, as a perfect 1.0 buy from two brokers does.""" + analysts = {'T1': 7, 'T2': 6, 'T3': 12, 'T4': 2} + self.patch(util, 'get_holdings_and_watchlist', lambda: list(analysts)) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'price_to_earnings_forward': -5 if t == 'T3' else 20} for t in ts}) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | {'recommend_analysts': analysts[t]}}) + def listed(action): + payload, chart = reports.prepare_value_payload('telegram', action) + return payload[0], [line.split('Co ')[1].split(' ')[0] for line in payload[1:]], chart + heading, names, chart = listed('forward pe') + self.assertEqual((names, [label for label, _ in chart['rows']]), (['T1'], ['T1'])) # T2 and T4: too few; T3: negative + self.assertIn('forward pe ratio (7+ analysts)', heading) + self.assertEqual(listed('negative forward pe')[1], ['T3']) + self.assertIn('Co T2', str(reports.prepare_value_payload('telegram', 'forward pe', 'T2'))) # one ticker asked for: shown + self.patch(reports, 'config_min_analysts', 13) # the one setting moves .buy and .forward pe together + self.assertEqual(reports.prepare_value_payload('telegram', 'forward pe')[0], ['No tracked stocks with 13+ analysts have a forward pe ratio']) + self.assertEqual(reports.prepare_rating_payload('telegram', 'buy')[0], ['No tracked stocks rated buy by 13+ analysts']) + + def test_market_caps_compare_in_us_dollars(self): + """Yahoo gives a market cap in the stock's own currency: unconverted, a won market cap topped every list.""" + stocks = {'K1.KS': ('KRW', 1.01e15), 'K2.KS': ('KRW', 5e11), 'U1': ('USD', 5.39e12), 'L1.L': ('GBp', 6.25e10), 'Z1': ('XYZ', 1e12)} + fx = {'KRW=X': 1380.0, 'GBP=X': 0.79} # units per US dollar; nothing for XYZ + def fetch(tickers): + return {t: market_entry(t) | {'regularMarketPrice': fx[t]} if t in fx else market_entry(t) | {'currency': stocks[t][0], 'market_cap': stocks[t][1], 'beta': 2.0} + for t in tickers if t in fx or t in stocks} + self.patch(yahoo, 'fetch', fetch) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: fetch([t])) + self.patch(util, 'get_holdings_and_watchlist', lambda: list(stocks)) + payload = reports.prepare_marketcap_payload('telegram') + self.assertIn('by market cap (USD)', payload[0]) + self.assertEqual([line.split('Co ')[1].split(' ')[0] for line in payload[1:]], ['U1', 'K1.KS', 'L1.L', 'K2.KS']) # Z1: no rate, left out + self.assertIn('mkt cap: 731.88 B', payload[2]) # 1.01 Q won + self.assertIn('mkt cap: KRW 1.01 Q (USD 731.88 B)', reports.prepare_marketcap_payload('telegram', 'K1.KS')[0]) + self.assertIn('mkt cap: GBP 62.50 B (USD 79.11 B)', reports.prepare_marketcap_payload('telegram', 'L1.L')[0]) # pence quote, pound cap + beta = reports.prepare_beta_payload('telegram')[0] + self.assertIn('Co K2.KS', str(beta)) # 500 billion won is US$362M: a small cap, which it never was unconverted + self.assertNotIn('Co K1.KS', str(beta)) + + def test_prepare_value(self): + for action, heading in (('pe', 'Top 5 tracked stocks by pe ratio'), ('bottom forward pe', 'Bottom 5 tracked stocks by forward pe ratio'), + ('peg', 'Top 5 tracked stocks by peg ratio'), ('negative peg', 'negative peg: no data found')): + with self.subTest(action=action): + payload, chart = reports.prepare_value_payload('telegram', action, length=5) + self.assertIn(heading, payload[0]) + + def test_prepare_beta(self): + payload, chart = reports.prepare_beta_payload('telegram') + self.assertIn('Beta over 1.5', payload[0]) + self.assertEqual(len(chart['rows']), len(payload) - 1) # a bar per stock listed + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | {'beta': 1.0}}) + self.assertEqual(reports.prepare_beta_payload('telegram'), (['No tracked stocks have a beta over 1.5 and mkt cap under US$2B'], None)) + + def test_prepare_profile(self): + self.assertTrue(reports.prepare_profile_payload('telegram', '@u', 'T1')) + + def test_a_profile_leaves_out_what_yahoo_did_not_send(self): + """Each of these fields missing (or a zero equity) used to crash the whole profile.""" + full = market_entry('T1') | {'insiderBuy': 1, 'insiderSell': 0, 'insiderBuyValue': 5e6, 'insiderSellValue': 0, 'netIncomeToCommon': 1e6, + 'sharesOutstanding': 1e6, 'shareholder_equity': 1e9, 'total_debt': 9e8, 'total_cash': 1e8, 'profile_industry': 'Tools'} + for missing in ('marketState', 'profile_exchange', 'market_cap', 'currency', 'regularMarketPrice', 'sharesOutstanding', 'percent_change'): + with self.subTest(missing=missing): + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: {k: v for k, v in full.items() if k != missing}}) + self.assertIn('Co T1', reports.prepare_profile_payload('telegram', '@u', 'T1')[0]) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: full | {'shareholder_equity': 0}}) + self.assertNotIn('Net debt/equity', str(reports.prepare_profile_payload('telegram', '@u', 'T1'))) + + def test_a_profile_warns_of_a_ps_ratio_only_above_15(self): + for ps, warned in ((12.3, False), (15.1, True)): # forward P/E 99 warns (above 30): its warning used to show on the PS line too + with self.subTest(ps=ps): + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | {'price_to_sales': ps, 'price_to_earnings_forward': 99, 'profile_industry': 'Tools'}}) + line = next(line for line in reports.prepare_profile_payload('telegram', '@u', 'T1') if 'PS ratio' in line) + self.assertEqual('⚠️' in line, warned, line) + + def test_prepare_watchlist(self): + self.assertTrue(reports.prepare_watchlist('telegram', '@u')) + + def test_prepare_compare_usage(self): + caption = reports.prepare_compare('telegram', ['T1'])[0] + self.assertIn('Usage', ' '.join(caption)) class ScheduledReports(FinbotCase): - """The cron entry points (non-interactive: they post to the default channel).""" - - def test_price(self): - price.lambda_handler() - self.assertTrue(self.sent) - - def test_performance(self): - performance.lambda_handler(past_days=7) - self.assertTrue(self.sent) - - def test_shorts(self): - shorts.lambda_handler() - self.assertTrue(self.sent) - - def test_earnings_and_dividends(self): - for kwargs in ({'event': 'earnings'}, {'event': 'ex-dividend'}): - with self.subTest(kwargs=kwargs): - self.sent.clear() - cal.lambda_handler(**kwargs) - self.assertTrue(self.sent) - - def test_earnings_skip_an_adr_whose_primary_listing_yahoo_does_not_know(self): - """T1 has no earnings date, so its primary listing is looked up; Yahoo knowing nothing of it used to crash the report.""" - self.patch(cal, 'primary_listing', {'T1': 'T1.XX'}) - self.patch(yahoo, 'fetch', lambda ts: {t: {k: v for k, v in market_entry(t).items() if not (t == 'T1' and k == 'earnings_date')} for t in ts}) - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {} if t == 'T1.XX' else {t: market_entry(t)}) - cal.lambda_handler(event='earnings') - self.assertIn('Co T2 ', str(self.sent)) - self.assertNotIn('Co T1 ', str(self.sent)) - - def test_trades(self): - trades.lambda_handler(interactive=True, service='telegram', chat_id='5') - self.assertTrue(self.sent) - - def test_trades_asks_yahoo_only_for_new_trades(self): - fetched = [] - self.patch(yahoo, 'fetch', lambda tickers: fetched.append(set(tickers)) or {t: market_entry(t) for t in tickers}) - trades.lambda_handler() - self.assertEqual(fetched, [{'T1'}]) - fetched.clear() - trades.lambda_handler() # the trade is known now: nothing to report and nothing to fetch - self.assertEqual(fetched, []) - - def test_milestone(self): - milestone.lambda_handler() - self.assertTrue(self.sent) - - def test_milestone_records_a_ticker_only_with_its_new_values(self): - """A first sighting without 52-week values used to be recorded as 0/0, so the next run reported a record high for it.""" - self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) # beyond the 52-week range (120/80): nothing new - self.patch(yahoo, 'fetch', lambda ts: {t: {k: v for k, v in market_entry(t).items() if not k.startswith('fiftyTwoWeek')} for t in ts}) - milestone.lambda_handler() - self.assertFalse(util.json_load('finbot_milestone.json', persist=True)) - self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) - self.sent.clear() - milestone.lambda_handler() - self.assertNotIn('Record high', str(self.sent)) - self.assertEqual(util.json_load('finbot_milestone.json', persist=True)['T1'], {'record_high': 150.0, 'record_low': 50.0}) # all-time - - def test_milestone_records_are_all_time(self): - """A new 52-week high below the all-time high is no record. The record used to become the 52-week high after the first - run, so a stock far below its peak posted a record high each time it set a new 52-week one.""" - self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) - milestone.lambda_handler() # first sighting: the 52-week range (120/80) is inside the history (150/50) - for high, expected in ((130.0, False), (160.0, True), (155.0, False)): - with self.subTest(high=high): - self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'fiftyTwoWeekHigh': high} for t in ts}) - self.sent.clear() - milestone.lambda_handler() - self.assertEqual('Record high' in str(self.sent), expected) - - def test_a_52_week_record_kept_before_is_replaced_by_the_history(self): - util.json_write('finbot_milestone.json', {f'T{i}': {'high': 110.0, 'low': 90.0} for i in range(25)}, persist=True) - self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) - milestone.lambda_handler() # 120 beats the old 52-week record of 110, but not the history's 150 - self.assertNotIn('Record high', str(self.sent)) - - def test_rating(self): - rating.lambda_handler(interactive=True, service='telegram', chat_id='5') - self.assertTrue(self.sent) - - def test_rating_keeps_the_last_rating_of_a_ticker_missing_from_a_run(self): - rating.lambda_handler() # records every ticker's rating - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {} if t == 'T1' else {t: market_entry(t)}) - rating.lambda_handler() # no T1 this time: its rating used to be dropped from the record - self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | ({'recommend': 'sell', 'recommend_index': 4.5} if t == 'T1' else {})}) - self.sent.clear() - rating.lambda_handler() - self.assertIn('Co T1 ', str(self.sent)) # so its change from buy to sell went unreported - - def test_an_interactive_rating_records_nothing(self): - rating.lambda_handler(interactive=True, service='telegram', chat_id='5') - self.assertIsNone(util.json_load('finbot_rating.json', persist=True)) - - def test_slack_webhook_without_a_bot_token_gets_scheduled_reports(self): - """Scheduled reports reach Slack through its incoming webhook, which takes no channel and no token. They used to send - Telegram's chat id along, which made write() need slackBotToken and crash without one, before Telegram got its copy.""" - self.patch(webhook, 'config_slackBotToken', False) - webhook.webhooks['slack'] = 'https://hooks.slack.com/services/T/B/x' - self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) - self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) - posts = [] - def post(url, **kwargs): - posts.append((url.split('/')[2], 'Authorization' in kwargs.get('headers', {}), (kwargs.get('json') or {}).get('channel'))) - return mock.Mock(status_code=200, json=lambda: {'ok': True, 'result': {'message_id': 1, 'chat': {'id': 1}}}) - self.patch(webhook.net, 'post', post) - for report in (price.lambda_handler, shorts.lambda_handler, milestone.lambda_handler): - with self.subTest(report=report): - posts.clear() - report() - self.assertIn(('hooks.slack.com', False, None), posts) - self.assertTrue(any(host == 'api.telegram.org' for host, _, _ in posts)) - - def test_nowhere_to_post_is_an_error(self): - self.patch(webhook, 'webhooks', {}) - with self.assertRaisesRegex(RuntimeError, 'nowhere to post'): - price.lambda_handler() + """The cron entry points (non-interactive: they post to the default channel).""" + + def test_price(self): + price.lambda_handler() + self.assertTrue(self.sent) + + def test_performance(self): + performance.lambda_handler(past_days=7) + self.assertTrue(self.sent) + + def test_shorts(self): + shorts.lambda_handler() + self.assertTrue(self.sent) + + def test_earnings_and_dividends(self): + for kwargs in ({'event': 'earnings'}, {'event': 'ex-dividend'}): + with self.subTest(kwargs=kwargs): + self.sent.clear() + cal.lambda_handler(**kwargs) + self.assertTrue(self.sent) + + def test_earnings_skip_an_adr_whose_primary_listing_yahoo_does_not_know(self): + """T1 has no earnings date, so its primary listing is looked up; Yahoo knowing nothing of it used to crash the report.""" + self.patch(cal, 'primary_listing', {'T1': 'T1.XX'}) + self.patch(yahoo, 'fetch', lambda ts: {t: {k: v for k, v in market_entry(t).items() if not (t == 'T1' and k == 'earnings_date')} for t in ts}) + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {} if t == 'T1.XX' else {t: market_entry(t)}) + cal.lambda_handler(event='earnings') + self.assertIn('Co T2 ', str(self.sent)) + self.assertNotIn('Co T1 ', str(self.sent)) + + def test_trades(self): + trades.lambda_handler(interactive=True, service='telegram', chat_id='5') + self.assertTrue(self.sent) + + def test_trades_asks_yahoo_only_for_new_trades(self): + fetched = [] + self.patch(yahoo, 'fetch', lambda tickers: fetched.append(set(tickers)) or {t: market_entry(t) for t in tickers}) + trades.lambda_handler() + self.assertEqual(fetched, [{'T1'}]) + fetched.clear() + trades.lambda_handler() # the trade is known now: nothing to report and nothing to fetch + self.assertEqual(fetched, []) + + def test_milestone(self): + milestone.lambda_handler() + self.assertTrue(self.sent) + + def test_milestone_records_a_ticker_only_with_its_new_values(self): + """A first sighting without 52-week values used to be recorded as 0/0, so the next run reported a record high for it.""" + self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) # beyond the 52-week range (120/80): nothing new + self.patch(yahoo, 'fetch', lambda ts: {t: {k: v for k, v in market_entry(t).items() if not k.startswith('fiftyTwoWeek')} for t in ts}) + milestone.lambda_handler() + self.assertFalse(util.json_load('finbot_milestone.json', persist=True)) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) + self.sent.clear() + milestone.lambda_handler() + self.assertNotIn('Record high', str(self.sent)) + self.assertEqual(util.json_load('finbot_milestone.json', persist=True)['T1'], {'record_high': 150.0, 'record_low': 50.0}) # all-time + + def test_milestone_records_are_all_time(self): + """A new 52-week high below the all-time high is no record. The record used to become the 52-week high after the first + run, so a stock far below its peak posted a record high each time it set a new 52-week one.""" + self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) + milestone.lambda_handler() # first sighting: the 52-week range (120/80) is inside the history (150/50) + for high, expected in ((130.0, False), (160.0, True), (155.0, False)): + with self.subTest(high=high): + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'fiftyTwoWeekHigh': high} for t in ts}) + self.sent.clear() + milestone.lambda_handler() + self.assertEqual('Record high' in str(self.sent), expected) + + def test_a_52_week_record_kept_before_is_replaced_by_the_history(self): + util.json_write('finbot_milestone.json', {f'T{i}': {'high': 110.0, 'low': 90.0} for i in range(25)}, persist=True) + self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) + milestone.lambda_handler() # 120 beats the old 52-week record of 110, but not the history's 150 + self.assertNotIn('Record high', str(self.sent)) + + def test_rating(self): + rating.lambda_handler(interactive=True, service='telegram', chat_id='5') + self.assertTrue(self.sent) + + def test_rating_keeps_the_last_rating_of_a_ticker_missing_from_a_run(self): + rating.lambda_handler() # records every ticker's rating + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {} if t == 'T1' else {t: market_entry(t)}) + rating.lambda_handler() # no T1 this time: its rating used to be dropped from the record + self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | ({'recommend': 'sell', 'recommend_index': 4.5} if t == 'T1' else {})}) + self.sent.clear() + rating.lambda_handler() + self.assertIn('Co T1 ', str(self.sent)) # so its change from buy to sell went unreported + + def test_an_interactive_rating_records_nothing(self): + rating.lambda_handler(interactive=True, service='telegram', chat_id='5') + self.assertIsNone(util.json_load('finbot_rating.json', persist=True)) + + def test_slack_webhook_without_a_bot_token_gets_scheduled_reports(self): + """Scheduled reports reach Slack through its incoming webhook, which takes no channel and no token. They used to send + Telegram's chat id along, which made write() need slackBotToken and crash without one, before Telegram got its copy.""" + self.patch(webhook, 'config_slackBotToken', False) + webhook.webhooks['slack'] = 'https://hooks.slack.com/services/T/B/x' + self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) + self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) + posts = [] + def post(url, **kwargs): + posts.append((url.split('/')[2], 'Authorization' in kwargs.get('headers', {}), (kwargs.get('json') or {}).get('channel'))) + return mock.Mock(status_code=200, json=lambda: {'ok': True, 'result': {'message_id': 1, 'chat': {'id': 1}}}) + self.patch(webhook.net, 'post', post) + for report in (price.lambda_handler, shorts.lambda_handler, milestone.lambda_handler): + with self.subTest(report=report): + posts.clear() + report() + self.assertIn(('hooks.slack.com', False, None), posts) + self.assertTrue(any(host == 'api.telegram.org' for host, _, _ in posts)) + + def test_nowhere_to_post_is_an_error(self): + self.patch(webhook, 'webhooks', {}) + with self.assertRaisesRegex(RuntimeError, 'nowhere to post'): + price.lambda_handler() class PeriodButtons(FinbotCase): - """Default views line up with the buttons: the highlighted button is the period actually shown.""" - - def buttons(self, command): - self.command(command) - self.assertTrue(self.markups and self.markups[0], f'{command} sent no buttons') - return [b['text'] for b in self.markups[0]['inline_keyboard'][0]] - - def test_performance_defaults_to_one_month(self): - self.assertEqual(self.buttons('.performance'), ['7D', '● 1M', '3M', '1Y', 'Max']) - self.assertIn('month', self.sent[0][2]) # "Performance over the past month", not 4 weeks - - def test_performance_period_argument_moves_the_marker(self): - self.assertEqual(self.buttons('.performance 7d'), ['● 7D', '1M', '3M', '1Y', 'Max']) - self.assertEqual(self.buttons('.performance 1y'), ['7D', '1M', '3M', '● 1Y', 'Max']) - - def test_price_list_has_a_1d_button_marked_by_default(self): - self.assertEqual(self.buttons('.price'), ['● 1D', '7D', '1M', '3M', '1Y', 'Max']) - self.assertEqual(self.buttons('.price top'), ['● 1D', '7D', '1M', '3M', '1Y', 'Max']) - self.assertEqual(self.buttons('.price 1m'), ['1D', '7D', '● 1M', '3M', '1Y', 'Max']) - - def test_other_charts_do_not_offer_1d(self): - for command in ('.performance', '.compare T1 T2'): - with self.subTest(command=command): - self.assertNotIn('1D', [b.lstrip('● ') for b in self.buttons(command)]) - - def press(self, data): - edits, toasts = [], [] - self.patch(webhook, 'editMessageMedia', lambda chat, msg, img, caption, markup=None: edits.append((caption.split('\n')[0], markup))) - self.patch(telegram, 'answerCallbackQuery', lambda cid, text=None: toasts.append(text)) - worker.process_callback('telegram', 'cb', '55', '9', data) - return edits, toasts - - def test_pressing_1d_rebuilds_todays_list_and_moves_the_marker(self): - edits, toasts = self.press('c|l|d|3,0') - self.assertEqual(toasts, ['Loading 1D…']) - self.assertEqual(self.errors, []) - self.assertEqual(len(edits), 1) - self.assertEqual([b['text'] for b in edits[0][1]['inline_keyboard'][0]][0], '● 1D') - - def test_a_button_a_chart_does_not_offer_is_ignored(self): - edits, toasts = self.press('c|f|d|') # performance has no 1D - self.assertEqual((edits, toasts), ([], [None])) - edits, toasts = self.press('c|l|zz|3,0') - self.assertEqual((edits, toasts), ([], [None])) + """Default views line up with the buttons: the highlighted button is the period actually shown.""" + + def buttons(self, command): + self.command(command) + self.assertTrue(self.markups and self.markups[0], f'{command} sent no buttons') + return [b['text'] for b in self.markups[0]['inline_keyboard'][0]] + + def test_performance_defaults_to_one_month(self): + self.assertEqual(self.buttons('.performance'), ['7D', '● 1M', '3M', '1Y', 'Max']) + self.assertIn('month', self.sent[0][2]) # "Performance over the past month", not 4 weeks + + def test_performance_period_argument_moves_the_marker(self): + self.assertEqual(self.buttons('.performance 7d'), ['● 7D', '1M', '3M', '1Y', 'Max']) + self.assertEqual(self.buttons('.performance 1y'), ['7D', '1M', '3M', '● 1Y', 'Max']) + + def test_price_list_has_a_1d_button_marked_by_default(self): + self.assertEqual(self.buttons('.price'), ['● 1D', '7D', '1M', '3M', '1Y', 'Max']) + self.assertEqual(self.buttons('.price top'), ['● 1D', '7D', '1M', '3M', '1Y', 'Max']) + self.assertEqual(self.buttons('.price 1m'), ['1D', '7D', '● 1M', '3M', '1Y', 'Max']) + + def test_other_charts_do_not_offer_1d(self): + for command in ('.performance', '.compare T1 T2'): + with self.subTest(command=command): + self.assertNotIn('1D', [b.lstrip('● ') for b in self.buttons(command)]) + + def press(self, data): + edits, toasts = [], [] + self.patch(webhook, 'editMessageMedia', lambda chat, msg, img, caption, markup=None: edits.append((caption.split('\n')[0], markup))) + self.patch(telegram, 'answerCallbackQuery', lambda cid, text=None: toasts.append(text)) + worker.process_callback('telegram', 'cb', '55', '9', data) + return edits, toasts + + def test_pressing_1d_rebuilds_todays_list_and_moves_the_marker(self): + edits, toasts = self.press('c|l|d|3,0') + self.assertEqual(toasts, ['Loading 1D…']) + self.assertEqual(self.errors, []) + self.assertEqual(len(edits), 1) + self.assertEqual([b['text'] for b in edits[0][1]['inline_keyboard'][0]][0], '● 1D') + + def test_a_button_a_chart_does_not_offer_is_ignored(self): + edits, toasts = self.press('c|f|d|') # performance has no 1D + self.assertEqual((edits, toasts), ([], [None])) + edits, toasts = self.press('c|l|zz|3,0') + self.assertEqual((edits, toasts), ([], [None])) class Podcasts(FinbotCase): - def test_format_helpers(self): - import podcasts - self.assertEqual(podcasts.format_duration('3725'), '01:02') - self.assertEqual(podcasts.format_duration('1:02:03'), '01:02') - self.assertEqual(podcasts.format_duration('62:05'), '01:02') - self.assertEqual(podcasts.format_duration('nonsense'), '') - self.assertTrue(podcasts.how_long_ago(podcasts.published('Mon, 01 Jan 2024 00:00:00 +0000')).endswith('d ago')) - self.assertEqual(podcasts.published('Mon, 01 Jan 2024 00:00:00 -0000').tzinfo, datetime.timezone.utc) # no zone given: UTC - self.assertIsNone(podcasts.published('yesterday')) - - def test_new_episodes_are_posted_once_newest_first(self): - """A feed whose items mix dated and undated ones used to crash the sort (aware and naive times).""" - import podcasts - def item(n, date): - return f'g{n}Show: ep {n}{date}600' - feed = ('Show' - + item(1, 'Mon, 01 Jan 2024 00:00:00 +0000') + item(2, '') + item(3, 'Tue, 02 Jan 2024 00:00:00 GMT') - + '').encode() - self.patch(podcasts.net, 'get', lambda url, **k: mock.Mock(content=feed, raise_for_status=lambda: None)) - podcasts.lambda_handler('https://example.com/feed') - self.assertEqual([line.split(' (')[0] for line in self.sent[0][1]], ['📣 Show: ep 3 00:10', - '📣 Show: ep 1 00:10', '📣 Show: ep 2 00:10']) - self.sent.clear() - podcasts.lambda_handler('https://example.com/feed') - self.assertEqual(self.sent, []) # all seen now + def test_format_helpers(self): + import podcasts + self.assertEqual(podcasts.format_duration('3725'), '01:02') + self.assertEqual(podcasts.format_duration('1:02:03'), '01:02') + self.assertEqual(podcasts.format_duration('62:05'), '01:02') + self.assertEqual(podcasts.format_duration('nonsense'), '') + self.assertTrue(podcasts.how_long_ago(podcasts.published('Mon, 01 Jan 2024 00:00:00 +0000')).endswith('d ago')) + self.assertEqual(podcasts.published('Mon, 01 Jan 2024 00:00:00 -0000').tzinfo, datetime.timezone.utc) # no zone given: UTC + self.assertIsNone(podcasts.published('yesterday')) + + def test_new_episodes_are_posted_once_newest_first(self): + """A feed whose items mix dated and undated ones used to crash the sort (aware and naive times).""" + import podcasts + def item(n, date): + return f'g{n}Show: ep {n}{date}600' + feed = ('Show' + + item(1, 'Mon, 01 Jan 2024 00:00:00 +0000') + item(2, '') + item(3, 'Tue, 02 Jan 2024 00:00:00 GMT') + + '').encode() + self.patch(podcasts.net, 'get', lambda url, **k: mock.Mock(content=feed, raise_for_status=lambda: None)) + podcasts.lambda_handler('https://example.com/feed') + self.assertEqual([line.split(' (')[0] for line in self.sent[0][1]], ['📣 Show: ep 3 00:10', + '📣 Show: ep 1 00:10', '📣 Show: ep 2 00:10']) + self.sent.clear() + podcasts.lambda_handler('https://example.com/feed') + self.assertEqual(self.sent, []) # all seen now class PieCommand(FinbotCase): - def setUp(self): - super().setUp() - def holdings(name, pid): - data = [('A', 'Semiconductors', 'Electronic Technology', 500), ('B', 'Semiconductors', 'Electronic Technology', 300), - ('C', 'Biotechnology', 'Health Technology', 200), ('D', None, None, 100), ('E', 'Gold', 'Minerals', 0)] - return {c + ':NASDAQ': {'code': c, 'market_code': 'NASDAQ', 'industry_classification_name': i, - 'sector_classification_name': s, 'value': v} for c, i, s, v in data} - self.patch(sharesight, 'get_holdings_new', holdings) - - def test_by_industry_default(self): - sent = self.command('.pie Rob') - self.assertEqual(sent[0][0], 'photo') - self.assertIn('Rob by industry', sent[0][2]) - - def test_by_sector_and_payload(self): - payload, chart = reports.prepare_pie('rob', 'sector', 'telegram') - self.assertEqual([r[0] for r in chart['rows']], ['Electronic Technology', 'Health Technology', 'Unclassified']) - self.assertIn('Electronic Technology: 72.7%', payload) # 800 of 1100; zero-value Gold is left out - self.assertEqual(self.command('.pie Rob sector')[0][0], 'photo') - - def test_folds_into_other_and_renders(self): - self.patch(sharesight, 'get_holdings_new', lambda n, p: {f'T{i}:X': {'code': f'T{i}', 'industry_classification_name': f'Ind {i}', 'value': 100 - i} for i in range(12)}) - payload, chart = reports.prepare_pie('Rob', 'industry', 'telegram') - self.assertEqual(len(chart['rows']), 8) - self.assertEqual(chart['rows'][-1][0], 'Other') - self.assertTrue(util.pie_chart(chart['rows'], chart['title'], chart['subtitle']).read().startswith(b'\x89PNG')) - - def test_unknown_or_missing_portfolio_lists_known(self): - for text, first in (('.pie', '.pie: please try again'), ('.pie Nope', 'Nope portfolio not found')): - sent = self.command(text) - self.assertTrue(sent[0][1][0].startswith(first), text) - self.assertIn('Rob', sent[0][1]) - - def test_a_service_that_takes_no_images_gets_text(self): - self.patch(webhook, 'config_slackBotToken', False) # Slack replies carry charts only with the bot token - worker.Request('slack', 'C1', '@u', 'U', BOT).deliver(['x'], {'pie': True, 'rows': [('a', 1.0)], 'title': 't'}) - self.assertEqual(self.sent[-1][0], 'text') + def setUp(self): + super().setUp() + def holdings(name, pid): + data = [('A', 'Semiconductors', 'Electronic Technology', 500), ('B', 'Semiconductors', 'Electronic Technology', 300), + ('C', 'Biotechnology', 'Health Technology', 200), ('D', None, None, 100), ('E', 'Gold', 'Minerals', 0)] + return {c + ':NASDAQ': {'code': c, 'market_code': 'NASDAQ', 'industry_classification_name': i, + 'sector_classification_name': s, 'value': v} for c, i, s, v in data} + self.patch(sharesight, 'get_holdings_new', holdings) + + def test_by_industry_default(self): + sent = self.command('.pie Rob') + self.assertEqual(sent[0][0], 'photo') + self.assertIn('Rob by industry', sent[0][2]) + + def test_by_sector_and_payload(self): + payload, chart = reports.prepare_pie('rob', 'sector', 'telegram') + self.assertEqual([r[0] for r in chart['rows']], ['Electronic Technology', 'Health Technology', 'Unclassified']) + self.assertIn('Electronic Technology: 72.7%', payload) # 800 of 1100; zero-value Gold is left out + self.assertEqual(self.command('.pie Rob sector')[0][0], 'photo') + + def test_folds_into_other_and_renders(self): + self.patch(sharesight, 'get_holdings_new', lambda n, p: {f'T{i}:X': {'code': f'T{i}', 'industry_classification_name': f'Ind {i}', 'value': 100 - i} for i in range(12)}) + payload, chart = reports.prepare_pie('Rob', 'industry', 'telegram') + self.assertEqual(len(chart['rows']), 8) + self.assertEqual(chart['rows'][-1][0], 'Other') + self.assertTrue(util.pie_chart(chart['rows'], chart['title'], chart['subtitle']).read().startswith(b'\x89PNG')) + + def test_unknown_or_missing_portfolio_lists_known(self): + for text, first in (('.pie', '.pie: please try again'), ('.pie Nope', 'Nope portfolio not found')): + sent = self.command(text) + self.assertTrue(sent[0][1][0].startswith(first), text) + self.assertIn('Rob', sent[0][1]) + + def test_a_service_that_takes_no_images_gets_text(self): + self.patch(webhook, 'config_slackBotToken', False) # Slack replies carry charts only with the bot token + worker.Request('slack', 'C1', '@u', 'U', BOT).deliver(['x'], {'pie': True, 'rows': [('a', 1.0)], 'title': 't'}) + self.assertEqual(self.sent[-1][0], 'text') if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_util.py b/tests/test_util.py index 4b7e03f7..2b9793a8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,144 +5,144 @@ class Parsing(unittest.TestCase): - def test_days_from_human_days(self): - for arg, days in (('7', 7), ('7d', 7), ('7D', 7), ('2w', 14), ('3m', 90), ('1y', 365), ('0', 0)): - with self.subTest(arg=arg): - self.assertEqual(util.days_from_human_days(arg), days) - today = datetime.date.today() - self.assertEqual(util.days_from_human_days('ytd'), (today - today.replace(month=1, day=1)).days) - - def test_days_from_human_days_rejects_non_periods(self): - for arg in ('AAPL', '', 'x5d', '1.5y', 'week'): - with self.subTest(arg=arg), self.assertRaises(ValueError): - util.days_from_human_days(arg) - - def test_transform_to_yahoo(self): - for args, expected in ((('AAPL',), 'AAPL'), (('FANG', 'ASX'), 'FANG.AX'), (('ARM', 'NASDAQ'), 'ARM'), - (('ARM:NASDAQ',), 'ARM'), (('BHP.AX',), 'BHP.AX'), (('0700', 'HKG'), '0700.HK'), - (('BRK', 'B'), 'BRK-B'), (('2330', 'TAI'), '2330.TW'), (('VOD', 'LSE'), 'VOD.L'), - (('7203', 'TSE'), '7203.T'), (('SHOP', 'TSX'), 'SHOP.TO'), (('AIR', 'NZX'), 'AIR.NZ'), (('D05', 'SGX'), 'D05.SI')): - with self.subTest(args=args): - self.assertEqual(util.transform_to_yahoo(*args), expected) - - def test_each_market_has_its_own_flag_and_currency(self): # Sharesight's market codes: TSE is Tokyo, TSX Toronto, SGX Singapore - for market, ticker, flag, currency in (('TSE', '7203.T', '🇯🇵', 'JPY'), ('TSX', 'SHOP.TO', '🇨🇦', 'CAD'), ('NZX', 'AIR.NZ', '🇳🇿', 'NZD'), - ('SGX', 'D05.SI', '🇸🇬', 'SGD'), ('CPSE', None, '🇩🇰', 'DKK')): - with self.subTest(market=market): - self.assertEqual((util.flag_from_market(market), util.currency_from_market(market)), (flag, currency)) - if ticker: - self.assertEqual(util.flag_from_ticker(ticker), flag) - - def test_make_paragraphs_keeps_every_word(self): - self.assertEqual(util.make_paragraphs('Acme makes widgets. It sells them.'), 'Acme makes widgets. It sells them.') - long = ' '.join(['word'] * 22) + ' end. Tail sentence here.' - self.assertEqual(util.make_paragraphs(long), ' '.join(['word'] * 22) + ' end.\n\nTail sentence here.') - - def test_transform_title(self): - self.assertEqual(util.transform_title('APPLE INC'), 'Apple') - self.assertEqual(util.transform_title('Acme FPO'), 'Acme') - self.assertEqual(util.transform_title('Mixed Case Name'), 'Mixed Case Name') - for title, expected in (('Allianz Se', 'Allianz'), ('Foo SE AG', 'Foo'), ('Johnson & Johnson &', 'Johnson & Johnson'), - ('Black and White and', 'Black and White'), ('Big Holdings Things Ltd', 'Big Things')): - with self.subTest(title=title): # a suffix comes off the end only; no run of spaces is left behind - self.assertEqual(util.transform_title(title), expected) + def test_days_from_human_days(self): + for arg, days in (('7', 7), ('7d', 7), ('7D', 7), ('2w', 14), ('3m', 90), ('1y', 365), ('0', 0)): + with self.subTest(arg=arg): + self.assertEqual(util.days_from_human_days(arg), days) + today = datetime.date.today() + self.assertEqual(util.days_from_human_days('ytd'), (today - today.replace(month=1, day=1)).days) + + def test_days_from_human_days_rejects_non_periods(self): + for arg in ('AAPL', '', 'x5d', '1.5y', 'week'): + with self.subTest(arg=arg), self.assertRaises(ValueError): + util.days_from_human_days(arg) + + def test_transform_to_yahoo(self): + for args, expected in ((('AAPL',), 'AAPL'), (('FANG', 'ASX'), 'FANG.AX'), (('ARM', 'NASDAQ'), 'ARM'), + (('ARM:NASDAQ',), 'ARM'), (('BHP.AX',), 'BHP.AX'), (('0700', 'HKG'), '0700.HK'), + (('BRK', 'B'), 'BRK-B'), (('2330', 'TAI'), '2330.TW'), (('VOD', 'LSE'), 'VOD.L'), + (('7203', 'TSE'), '7203.T'), (('SHOP', 'TSX'), 'SHOP.TO'), (('AIR', 'NZX'), 'AIR.NZ'), (('D05', 'SGX'), 'D05.SI')): + with self.subTest(args=args): + self.assertEqual(util.transform_to_yahoo(*args), expected) + + def test_each_market_has_its_own_flag_and_currency(self): # Sharesight's market codes: TSE is Tokyo, TSX Toronto, SGX Singapore + for market, ticker, flag, currency in (('TSE', '7203.T', '🇯🇵', 'JPY'), ('TSX', 'SHOP.TO', '🇨🇦', 'CAD'), ('NZX', 'AIR.NZ', '🇳🇿', 'NZD'), + ('SGX', 'D05.SI', '🇸🇬', 'SGD'), ('CPSE', None, '🇩🇰', 'DKK')): + with self.subTest(market=market): + self.assertEqual((util.flag_from_market(market), util.currency_from_market(market)), (flag, currency)) + if ticker: + self.assertEqual(util.flag_from_ticker(ticker), flag) + + def test_make_paragraphs_keeps_every_word(self): + self.assertEqual(util.make_paragraphs('Acme makes widgets. It sells them.'), 'Acme makes widgets. It sells them.') + long = ' '.join(['word'] * 22) + ' end. Tail sentence here.' + self.assertEqual(util.make_paragraphs(long), ' '.join(['word'] * 22) + ' end.\n\nTail sentence here.') + + def test_transform_title(self): + self.assertEqual(util.transform_title('APPLE INC'), 'Apple') + self.assertEqual(util.transform_title('Acme FPO'), 'Acme') + self.assertEqual(util.transform_title('Mixed Case Name'), 'Mixed Case Name') + for title, expected in (('Allianz Se', 'Allianz'), ('Foo SE AG', 'Foo'), ('Johnson & Johnson &', 'Johnson & Johnson'), + ('Black and White and', 'Black and White'), ('Big Holdings Things Ltd', 'Big Things')): + with self.subTest(title=title): # a suffix comes off the end only; no run of spaces is left behind + self.assertEqual(util.transform_title(title), expected) class Formatting(unittest.TestCase): - def test_signed_percent(self): - for value, decimals, expected in ((12, None, '+12%'), (-3.5, None, '-3.5%'), (0, None, '0%'), (1234, None, '+1,234%'), - (2.345, 1, '+2.3%'), (-0.04, 1, '-0.0%')): - with self.subTest(value=value): - self.assertEqual(util.signed_percent(value, decimals), expected) - - def test_days_english(self): - for days, expected in ((None, 'today'), (0, 'today'), (1, 'the past day'), (7, 'the past week')): - with self.subTest(days=days): - self.assertEqual(util.days_english(days), expected) - - def test_date_range_english(self): - end = datetime.date(2026, 10, 5) - self.assertEqual(util.date_range_english(7, end), '28 Sep - 5 Oct 2026') - self.assertEqual(util.date_range_english(30, end), '5 Sep - 5 Oct 2026') - self.assertEqual(util.date_range_english(10, datetime.date(2026, 1, 5)), '26 Dec 2025 - 5 Jan 2026') # crosses a year - with self.assertRaises(TypeError): - util.date_range_english(None, end) # callers must pass a period - - def test_ordinal(self): - for number, suffix in ((1, 'st'), (2, 'nd'), (3, 'rd'), (4, 'th'), (11, 'th'), (12, 'th'), (13, 'th'), (21, 'st'), (112, 'th')): - with self.subTest(number=number): - self.assertEqual(util.ordinal(number), suffix) - - def test_get_emoji(self): - self.assertEqual([util.get_emoji(n) for n in (1, -1, 0)], ['🔼', '🔻', '▪️']) - - def test_links_per_service(self): - telegram_link = util.link('https://example.com/x', 'text', 'telegram') - slack_link = util.link('https://example.com/x', 'text', 'slack') - self.assertIn(' Date: Wed, 14 Oct 2026 23:24:27 +0000 Subject: [PATCH 07/81] Config as two objects: config.env (finbot.env) and config.prefs (preferences.conf), read where used; no star import - lib/config.py builds frozen Env and Prefs dataclasses, one place that lists every setting. A reload replaces config.prefs in one assignment, so a request never sees half a reload; it used to rewrite each changed name in every module that had copied it with `from lib.config import *`. The job processes' snapshot of the preferences lives in the same object. - Every module reads config.env. / config.prefs. when it runs: no star import, no UNSET sentinel (defaults are None, resolved at call time), no names copied into 19 modules. preferences.conf's keys come from the Prefs fields. - The greeting words moved to lib/words.py, the country suffixes to util.YAHOO_COUNTRY, and cal reads finbot_adr.json when it uses it (it was read once at import, so an edit needed a restart). - Tests change settings through self.env(...) / self.prefs(...); the tests that policed the star import and the sentinel became one guard that nothing star-imports config or freezes a preference into a default. - Checked by recording every request the bot sends for 70 chat commands on Telegram and Slack, 8 buttons and 13 scheduled reports to all three networks: byte for byte the same before and after, charts included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 8 +- bot.py | 35 ++-- cal.py | 20 +- cleanup.py | 6 +- lib/config.py | 421 +++++++++++++--------------------------- lib/jobs.py | 7 +- lib/reports.py | 20 +- lib/sharesight.py | 64 +++--- lib/shortman.py | 4 +- lib/simplywallst.py | 4 +- lib/telegram.py | 10 +- lib/util.py | 74 +++++-- lib/webhook.py | 38 ++-- lib/words.py | 142 ++++++++++++++ lib/worker.py | 21 +- lib/yahoo.py | 54 +++--- milestone.py | 6 +- performance.py | 14 +- price.py | 36 ++-- rating.py | 1 - reminder.py | 10 +- shorts.py | 8 +- tests/common.py | 19 +- tests/test_config.py | 108 +++++------ tests/test_datadir.py | 28 +-- tests/test_edges.py | 19 +- tests/test_finbot.py | 2 +- tests/test_jobs.py | 15 +- tests/test_providers.py | 78 +++++--- tests/test_reports.py | 21 +- tests/test_util.py | 6 +- trades.py | 15 +- util/sync_defaults.py | 7 +- 33 files changed, 691 insertions(+), 630 deletions(-) create mode 100644 lib/words.py diff --git a/CLAUDE.md b/CLAUDE.md index b0ce1f7e..9e7a1474 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm ## Facts - Python 3.13.5; tests are offline and need no network (`tests/`). CI runs them on the latest 3.x and 3.13.5, except for pull requests that change only README.md, CLAUDE.md or `img/*.png` (install, compile and tests are skipped; the allowlist in `.github/workflows/ci.yml` must never include a path a test reads). - Production runs the Debian system packages (including `python3-croniter`, for the scheduler in `lib/jobs.py`); `requirements.txt` is for pip/uv users and CI. -- A new preference goes in five places: `PREFERENCE_KEYS` and `preference_values` (`lib/config.py`), `defaults/preferences.conf`, the README's preferences table, and `DEFAULTS` plus the key count in `tests/test_config.py`. The tests catch all but the README (no test may read README.md: CI skips the tests for a README-only change). +- A new preference goes in five places: a `Prefs` field and its line in `preference_values` (`lib/config.py`), `defaults/preferences.conf`, the README's preferences table, and `DEFAULTS` plus the key count in `tests/test_config.py`. The tests catch all but the README (no test may read README.md: CI skips the tests for a README-only change). - If Yahoo and Sharesight are unreachable (the cloud environment's network policy can block them), check behaviour against the offline fixtures in `tests/common.py`. For a refactor, compare old and new output: load `git show HEAD:` as a module beside the new one and diff what both produce for the same inputs. ## Code map (open the one file you need) @@ -34,8 +34,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/util.py`: formatting, links, market tables, cache and state files, all matplotlib rendering. - `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources, cached in `DATA_DIR/cache`. -- `lib/config.py`: finbot.env + preferences.conf → `config_*` names (star-imported), reload. `lib/jobs.py` + `lib/jobrun.py`: the - scheduler; each run is a child process. +- `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (preferences.conf, a new object on each reload). + `lib/jobs.py` + `lib/jobrun.py`: the scheduler; each run is a child process. - `tests/common.py`: `FinbotCase` (offline fixtures). `self.command('.price')` returns what was sent: `('text', lines)` or `('photo', size, caption)`. - Which test file: commands and scheduled reports `test_reports`; failures, bad input, WSGI and Slack events `test_edges`; @@ -44,6 +44,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm `test_finbot`. ## Conventions +- Read settings as `config.env.` / `config.prefs.` where they are used, never into a default or module constant (a + reload must be seen); in tests, `self.env(...)` / `self.prefs(...)` change them. - A report is a list of lines. Markup through `webhook.bold/italic(text, service)`, user-written text through `webhook.escape(text, service)`, links through `util.link`/`util.finance_link`. - Tabs everywhere, tests included (`.editorconfig`). New and changed lines stay under 120 characters (an edit quotes whole lines). diff --git a/bot.py b/bot.py index da1c69ee..1a127277 100755 --- a/bot.py +++ b/bot.py @@ -12,7 +12,6 @@ import threading from urllib.parse import urlparse -from lib.config import * from lib import config from lib import worker from lib import jobs @@ -73,19 +72,19 @@ def print_headers(): def reply(status, body=b''): start_response(status, [('Content-type', 'application/json')]) return [body] - print_headers() if config_print_headers else None + print_headers() if config.prefs.print_headers else None # route on the path first, and turn strangers away before reading the body uri = environ.get('PATH_INFO', '') - if config_telegramOutgoingWebhook and uri == urlparse(config_telegramOutgoingWebhook).path: + if config.env.telegramOutgoingWebhook and uri == urlparse(config.env.telegramOutgoingWebhook).path: service = 'telegram' - elif config_slackOutgoingWebhook and uri == urlparse(config_slackOutgoingWebhook).path: + elif config.env.slackOutgoingWebhook and uri == urlparse(config.env.slackOutgoingWebhook).path: service = 'slack' else: print("Unknown URI", repr(uri), file=sys.stderr) return reply('404 Not Found', b'

404

') if service == 'telegram': # Telegram proves itself with a header secret = environ.get('HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN') - if secret != config_telegramOutgoingToken: + if secret != config.env.telegramOutgoingToken: print_headers() print("Fatal: Telegram authorisation header", "not present" if secret is None else "is present but incorrect", file=sys.stderr) return reply('403 Forbidden', b'

Unauthorized

') @@ -125,7 +124,7 @@ def reply(status, body=b''): user = userRealName = '@' + first_name if "text" in msg: message = msg["text"] - print("[Telegram]:", user, message, file=sys.stderr) if debug else None + print("[Telegram]:", user, message, file=sys.stderr) if config.prefs.debug else None elif "photo" in msg: message = msg.get("caption", '') file_id = msg["photo"][-1]["file_id"] @@ -140,7 +139,7 @@ def reply(status, body=b''): print("warning: Slack authorisation field not present", file=sys.stderr) print_body() return reply('403 Forbidden', b'

Unauthorized

') - if inbound['token'] != config_slackOutgoingToken: + if inbound['token'] != config.env.slackOutgoingToken: print("Slack auth incorrect", file=sys.stderr) print_body() return reply('403 Forbidden', b'

Unauthorized

') @@ -170,7 +169,7 @@ def reply(status, body=b''): user = userRealName = '<@' + event['user'] + '>' # ZXCVBN becomes <@ZXCVBN> bot_name = '@' + bot_user # QWERTY becomes @QWERTY chat_id = event['channel'] - print(f"[{service}]:", user, message, file=sys.stderr) if debug else None + print(f"[{service}]:", user, message, file=sys.stderr) if config.prefs.debug else None # this condition spawns a worker before returning else: print(f"[{service}]: unhandled 'type'", file=sys.stderr) @@ -188,7 +187,7 @@ def git_version(checkout=None): """Short hash of the checked-out commit, read straight from the .git directory: no git binary to run, and no "dubious ownership" refusal when the service user does not own the checkout. 'unknown' when it cannot be read.""" try: - git = Path(checkout or REPO_DIR) / '.git' + git = Path(checkout or config.REPO_DIR) / '.git' if git.is_file(): # a linked worktree or submodule: .git is a file saying "gitdir: " git = (git.parent / git.read_text().split(':', 1)[1].strip()).resolve() common = (git / (git / 'commondir').read_text().strip()).resolve() if (git / 'commondir').is_file() else git # refs of a worktree @@ -226,7 +225,7 @@ def flush_cache(*args): with yahoo._auth: removed = cleanup.lambda_handler(days=0) sharesight.wake() - print(f"cache: flushed {removed} file{'' if removed == 1 else 's'} from {config_cache_dir}", file=sys.stderr) + print(f"cache: flushed {removed} file{'' if removed == 1 else 's'} from {config.env.cache_dir}", file=sys.stderr) except Exception as e: print('ERROR', webhook.error_line(e, 'cache flush'), file=sys.stderr) @@ -243,8 +242,8 @@ def flush_cache(*args): print('ERROR config:', line, file=sys.stderr) if os.getuid() == 0: print("Running as superuser. This is not recommended.", file=sys.stderr) - if config_scheduler: # read before anything else happens: an impossible time in it stops the start - scheduler = jobs.Scheduler(lambda: data_file('schedule.cron')) + if config.env.scheduler: # read before anything else happens: an impossible time in it stops the start + scheduler = jobs.Scheduler(lambda: config.data_file('schedule.cron')) try: if not scheduler.load(): scheduler = None @@ -256,18 +255,18 @@ def flush_cache(*args): else: print('Scheduled reports are off (scheduler=False)', file=sys.stderr) util.render_off_hub() # charts drawn in a thread of their own: no chat waits while another's chart is drawn - httpd = pywsgi.WSGIServer((config_ip, config_port), main) - httpd.secure_repr = False if debug else None - print(f'Opening socket on http://{config_ip}:{config_port}', file=sys.stderr) - if config_telegramBotToken and config_telegramOutgoingWebhook: + httpd = pywsgi.WSGIServer((config.env.ip, config.env.port), main) + httpd.secure_repr = False if config.prefs.debug else None + print(f'Opening socket on http://{config.env.ip}:{config.env.port}', file=sys.stderr) + if config.env.telegramBotToken and config.env.telegramOutgoingWebhook: try: botName = telegram.register() # here and only here: the job processes import lib.telegram too except Exception as e: print('ERROR', webhook.error_line(e, 'telegram webhook'), file=sys.stderr) sys.exit(1) - if config_telegramBotToken: + if config.env.telegramBotToken: threading.Thread(target=charts.refresh_keyboards, daemon=True).start() # update stale DM keyboards in the background - if config_sharesight_auth.get('client_id'): + if config.env.sharesight_auth.get('client_id'): threading.Thread(target=sharesight.keep_warm, daemon=True).start() # keep the portfolio list and holdings report cached, while cache is on if scheduler: jobs.current = scheduler # for the .jobs chat command diff --git a/cal.py b/cal.py index 10b9dd0f..42da8465 100755 --- a/cal.py +++ b/cal.py @@ -3,19 +3,23 @@ import datetime import sys -from lib.config import * +from lib import config from lib import telegram from lib import util from lib import webhook from lib import yahoo +def primary_listings(): + """{ADR or second listing: its primary listing}, from finbot_adr.json: read when used, so an edit is seen without a restart.""" + return config.data_json('finbot_adr.json', {}) + EVENTS = {'earnings': ('earnings_date', 'Upcoming earnings'), 'ex-dividend': ('ex_dividend_date', 'Upcoming ex-dividends')} # -> (field, heading) -def lambda_handler(chat_id=None, days=UNSET, service=None, specific_stock=None, interactive=False, event='earnings'): +def lambda_handler(chat_id=None, days=None, service=None, specific_stock=None, interactive=False, event='earnings'): """The tracked stocks' (or one stock's) upcoming earnings or ex-dividend dates, as `event` says. A scheduled earnings report is pinned on Telegram.""" - if days is UNSET: - days = config_future_days + if days is None: + days = config.prefs.future_days field, heading = EVENTS[event] def prepare_payload(service, market_data): now = datetime.datetime.now() @@ -27,14 +31,14 @@ def prepare_payload(service, market_data): ticker_primary = ticker if field not in market_data[ticker]: # an ADR or a second listing: its primary listing may have the date title_orig = market_data[ticker]['profile_title'] - if ticker in primary_listing: # dict from lib.config - ticker_primary = primary_listing[ticker] + if ticker in primary_listings(): + ticker_primary = primary_listings()[ticker] elif ticker.endswith('.AX') and market_data[ticker].get('financialCurrency') == 'NZD': ticker_primary = ticker.replace('.AX', '.NZ') # fixes GTK.AX and SKO.AX market_data = market_data | yahoo.fetch_detail(ticker_primary) country = market_data.get(ticker_primary, {}).get('profile_country') # only in the details - if country in yahoo_country and '.' not in ticker_primary: # dict from lib.config - ticker_primary = ticker_primary + '.' + yahoo_country[country] # fixes ASML and INFY + if country in util.YAHOO_COUNTRY and '.' not in ticker_primary: + ticker_primary = ticker_primary + '.' + util.YAHOO_COUNTRY[country] # fixes ASML and INFY market_data = market_data | yahoo.fetch_detail(ticker_primary) primary = market_data.get(ticker_primary, {}) if primary.get('profile_title') != title_orig or field not in primary: # not the same company, or no date there either diff --git a/cleanup.py b/cleanup.py index 40bafe80..d51bf316 100755 --- a/cleanup.py +++ b/cleanup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import os, sys, time -from lib.config import * +from lib import config from lib import webhook def lambda_handler(days=30): @@ -11,7 +11,7 @@ def lambda_handler(days=30): cutoff = now - days * 86400 removed = 0 try: - entries = list(os.scandir(config_cache_dir)) + entries = list(os.scandir(config.env.cache_dir)) except FileNotFoundError: return 0 for entry in entries: @@ -23,7 +23,7 @@ def lambda_handler(days=30): removed += 1 except OSError as e: print("cleanup: can't remove", entry.path, e, file=sys.stderr) - print(f"cleanup: removed {removed} cache files older than {days} days from {config_cache_dir}") + print(f"cleanup: removed {removed} cache files older than {days} days from {config.env.cache_dir}") return removed def job(argv): diff --git a/lib/config.py b/lib/config.py index e7e3259c..15284210 100644 --- a/lib/config.py +++ b/lib/config.py @@ -1,3 +1,4 @@ +import dataclasses import difflib import json import os @@ -13,15 +14,9 @@ REPO_DIR = Path(__file__).resolve().parent.parent os.chdir(REPO_DIR) -UNSET = object() # "argument not given": a default that follows a preference is resolved when the function runs, so a reload is seen - STARTUP_KEYS = ('DATA_DIR', 'ip', 'port', 'telegramChatID', 'scheduler', 'scheduler_catchup', 'sharesight_code', 'sharesight_client_id', 'sharesight_client_secret', 'slack_webhook', 'discord_webhook', 'slackBotToken', 'slackOutgoingToken', 'slackOutgoingWebhook', 'telegramBotToken', 'telegramOutgoingToken', 'telegramOutgoingWebhook') -PREFERENCE_KEYS = ('alliterate', 'cache', 'cache_seconds', 'chunk_maxlines', 'country_code', 'debug', 'demote_leveraged', 'demote_volatile', - 'exclude_portfolios', 'future_days', 'graph', 'http_timeout', 'hyperlink', 'hyperlinkFooter', 'hyperlinkProvider', 'include_portfolios', - 'min_analysts', 'past_days', 'performance_cache_seconds', 'performance_use_sharesight', 'price_percent', 'print_headers', 'shorts_percent', 'timezone', - 'trades_use_yahoo', 'volatility_multiplier') SECRET_KEYS = ('sharesight_code', 'sharesight_client_id', 'sharesight_client_secret', 'slack_webhook', 'discord_webhook', 'slackBotToken', 'slackOutgoingToken', 'slackOutgoingWebhook', 'telegramBotToken', 'telegramOutgoingToken', 'telegramOutgoingWebhook') # never printed PREFERENCES_FILE = 'preferences.conf' @@ -155,31 +150,34 @@ def check(self): raise ConfigError(self.errors) +@dataclasses.dataclass(frozen=True) +class Env: + """The startup settings: finbot.env, which the launcher loads into the environment. Fixed for the life of the process.""" + data_dir: str # state: watchlist, seen trades, ratings, the schedule's record, preferences.conf + cache_dir: str # data_dir/cache + ip: str + port: int + scheduler: bool # run the scheduled reports (schedule.cron) inside bot.py + scheduler_catchup: int # minutes: a run missed while the bot was down still fires if it was due this recently + telegramChatID: str # '' when unset, like every token below + telegramBotToken: str + telegramOutgoingWebhook: str + telegramOutgoingToken: str + slackBotToken: str + slackOutgoingWebhook: str + slackOutgoingToken: str + sharesight_auth: dict + webhooks: dict # service -> outbound URL: Telegram's Bot API base, the Slack and Discord incoming webhooks + + @property + def preferences_path(self): + return self.data_dir + '/' + PREFERENCES_FILE + + def startup_values(getenv): - """The settings fixed when the bot starts (finbot.env), computed from getenv(name, default).""" + """The settings fixed when the bot starts (finbot.env), computed from getenv(name, default). Raises ConfigError.""" r = _Reader(getenv) data_dir = (getenv('DATA_DIR') or (getenv('STATE_DIRECTORY') or '').split(':')[0] or 'var').rstrip('/') or '/' - values = { - 'config_data_dir': data_dir, - 'config_sharesight_auth': { - "grant_type": 'client_credentials', - "code": getenv('sharesight_code'), - "client_id": getenv('sharesight_client_id'), - "client_secret": getenv('sharesight_client_secret'), - "redirect_uri": 'urn:ietf:wg:oauth:2.0:oob' - }, - 'config_scheduler': r.bool('scheduler', True), # run the scheduled reports (schedule.cron) inside bot.py - 'config_scheduler_catchup': r.int('scheduler_catchup', 5), # minutes: a run missed while the bot was down still fires if it was due this recently - 'config_ip': r.str('ip', '127.0.0.1'), - 'config_port': r.int('port', 5000), - 'config_slackBotToken': r.str('slackBotToken', False), - 'config_slackOutgoingToken': r.str('slackOutgoingToken', False), - 'config_slackOutgoingWebhook': r.str('slackOutgoingWebhook', False), - 'config_telegramBotToken': r.str('telegramBotToken', False), - 'config_telegramOutgoingWebhook': r.str('telegramOutgoingWebhook', False), - 'config_telegramChatID': r.str('telegramChatID', False), - 'config_telegramOutgoingToken': r.str('telegramOutgoingToken', False), - } hooks = {} if getenv('slack_webhook'): hooks['slack'] = getenv('slack_webhook').rstrip('/') @@ -187,47 +185,102 @@ def startup_values(getenv): hooks['discord'] = getenv('discord_webhook').rstrip('/').removesuffix('/slack') # the native endpoint, not the Slack-compatible one if getenv('telegramBotToken'): hooks['telegram'] = 'https://api.telegram.org/bot' + getenv('telegramBotToken').rstrip('/') + '/' - values['webhooks'] = hooks + env = Env( + data_dir=data_dir, + cache_dir=data_dir + '/cache', + ip=r.str('ip', '127.0.0.1'), + port=r.int('port', 5000), + scheduler=r.bool('scheduler', True), + scheduler_catchup=r.int('scheduler_catchup', 5), + telegramChatID=r.str('telegramChatID', ''), + telegramBotToken=r.str('telegramBotToken', ''), + telegramOutgoingWebhook=r.str('telegramOutgoingWebhook', ''), + telegramOutgoingToken=r.str('telegramOutgoingToken', ''), + slackBotToken=r.str('slackBotToken', ''), + slackOutgoingWebhook=r.str('slackOutgoingWebhook', ''), + slackOutgoingToken=r.str('slackOutgoingToken', ''), + sharesight_auth={ + "grant_type": 'client_credentials', + "code": getenv('sharesight_code'), + "client_id": getenv('sharesight_client_id'), + "client_secret": getenv('sharesight_client_secret'), + "redirect_uri": 'urn:ietf:wg:oauth:2.0:oob' + }, + webhooks=hooks) r.check() - return values - - -def preference_values(getenv): - """The preferences (preferences.conf), computed from getenv(name, default). Raises ConfigError listing every bad value.""" + return env + + +@dataclasses.dataclass(frozen=True) +class Prefs: + """The preferences: preferences.conf, which the bot reads at start and on every reload. Read them as config.prefs. when + they are used, never into a default or a module constant, so that a reload is seen.""" + alliterate: bool + cache: bool + cache_seconds: int + chunk_maxlines: int + country_code: str + debug: bool + demote_leveraged: bool + demote_volatile: bool + exclude_portfolios: tuple + future_days: int + graph: bool + http_timeout: int + hyperlink: bool + hyperlinkFooter: bool + hyperlinkProvider: str + include_portfolios: tuple + min_analysts: int # .buy, .sell and .forward pe list only stocks this many analysts cover + past_days: int + performance_cache_seconds: int # Sharesight performance reports (30 minutes) + performance_use_sharesight: bool + price_percent: float + print_headers: bool + shorts_percent: int + timezone: str + trades_use_yahoo: bool # set False if Yahoo breaks + volatility_multiplier: float + source: dict = dataclasses.field(default_factory=dict, compare=False, repr=False) # the file's own lines, for job processes + + +def preference_values(getenv, source=None): + """The preferences (preferences.conf), computed from getenv(name, default); `source` is the file's own {key: value}. Raises + ConfigError listing every bad value.""" r = _Reader(getenv) - values = { - 'config_alliterate': r.bool('alliterate', False), - 'config_cache': r.bool('cache', True), - 'config_cache_seconds': r.int('cache_seconds', 43200), - 'config_chunk_maxlines': r.int('chunk_maxlines', 20), - 'config_country_code': r.str('country_code', 'AU'), - 'debug': r.bool('debug', False), - 'config_demote_leveraged': r.bool('demote_leveraged', True), - 'config_demote_volatile': r.bool('demote_volatile', True), - 'config_exclude_portfolios': r.str('exclude_portfolios', '').split(), - 'config_future_days': r.int('future_days', 30), - 'config_graph': r.bool('graph', True), - 'config_http_timeout': r.int('http_timeout', 18), - 'config_hyperlink': r.bool('hyperlink', True), - 'config_hyperlinkFooter': r.bool('hyperlinkFooter', True), - 'config_hyperlinkProvider': r.str('hyperlinkProvider', 'google').lower(), - 'config_include_portfolios': r.str('include_portfolios', '').split(), - 'config_min_analysts': r.int('min_analysts', 7), # .buy, .sell and .forward pe list only stocks this many analysts cover - 'config_past_days': r.int('past_days', 28), - 'config_performance_cache_seconds': r.int('performance_cache_seconds', 1800), # Sharesight performance reports (30 minutes) - 'config_performance_use_sharesight': r.bool('performance_use_sharesight', False), - 'config_price_percent': r.float('price_percent', 5), - 'config_print_headers': r.bool('print_headers', False), - 'config_shorts_percent': r.int('shorts_percent', 15), - 'config_timezone': r.str('timezone', 'Australia/Melbourne'), - 'config_trades_use_yahoo': r.bool('trades_use_yahoo', True), # set False if Yahoo breaks - 'config_volatility_multiplier': r.float('volatility_multiplier', 2), - } + prefs = Prefs( + alliterate=r.bool('alliterate', False), + cache=r.bool('cache', True), + cache_seconds=r.int('cache_seconds', 43200), + chunk_maxlines=r.int('chunk_maxlines', 20), + country_code=r.str('country_code', 'AU'), + debug=r.bool('debug', False), + demote_leveraged=r.bool('demote_leveraged', True), + demote_volatile=r.bool('demote_volatile', True), + exclude_portfolios=tuple(r.str('exclude_portfolios', '').split()), + future_days=r.int('future_days', 30), + graph=r.bool('graph', True), + http_timeout=r.int('http_timeout', 18), + hyperlink=r.bool('hyperlink', True), + hyperlinkFooter=r.bool('hyperlinkFooter', True), + hyperlinkProvider=r.str('hyperlinkProvider', 'google').lower(), + include_portfolios=tuple(r.str('include_portfolios', '').split()), + min_analysts=r.int('min_analysts', 7), + past_days=r.int('past_days', 28), + performance_cache_seconds=r.int('performance_cache_seconds', 1800), + performance_use_sharesight=r.bool('performance_use_sharesight', False), + price_percent=r.float('price_percent', 5), + print_headers=r.bool('print_headers', False), + shorts_percent=r.int('shorts_percent', 15), + timezone=r.str('timezone', 'Australia/Melbourne'), + trades_use_yahoo=r.bool('trades_use_yahoo', True), + volatility_multiplier=r.float('volatility_multiplier', 2), + source=dict(source or {})) r.check() - return values + return prefs -PREFERENCE_NAMES = frozenset(preference_values(lambda name, default=None: default)) # the config_* names a reload can change +PREFERENCE_KEYS = tuple(field.name for field in dataclasses.fields(Prefs) if field.compare) # preferences.conf's keys: what a reload can change def _getenv_for(preferences): @@ -250,16 +303,14 @@ def _fail(e): try: - globals().update(startup_values(os.environ.get)) - config_var_dir = config_data_dir - config_cache_dir = config_data_dir + '/cache' - preferences_path = config_data_dir + '/' + PREFERENCES_FILE + env = startup_values(os.environ.get) + preferences_path = env.preferences_path _preferences = {} if os.environ.get(PASSED) else (read_preferences(preferences_path) or {}) - globals().update(preference_values(_getenv_for(_preferences))) + prefs = preference_values(_getenv_for(_preferences), _preferences) except ConfigError as e: _fail(e) -for _dir in (config_var_dir, config_cache_dir): +for _dir in (env.data_dir, env.cache_dir): if os.environ.get('FINBOT_CHECK_ONLY'): # util/check_config.py, possibly run as root: must not create directories the service user then cannot write break try: @@ -268,11 +319,11 @@ def _fail(e): print("can't create", _dir, e) # matplotlib needs a writable config/cache dir; without a home directory (systemd DynamicUser, containers) it fails -os.environ.setdefault('MPLCONFIGDIR', os.path.abspath(config_cache_dir + '/matplotlib')) +os.environ.setdefault('MPLCONFIGDIR', os.path.abspath(env.cache_dir + '/matplotlib')) def data_file(name): # user copy in the data dir wins over the shipped default in defaults/ - user = config_var_dir + '/' + name + user = env.data_dir + '/' + name return user if os.path.isfile(user) else str(REPO_DIR / 'defaults' / name) _data_files = {} # path -> (modification time, parsed JSON) @@ -294,33 +345,22 @@ def data_json(name, default=None): def reload(): - """Re-read preferences.conf and apply it everywhere: this module and every module that copied a name with `from lib.config import *`. + """Re-read preferences.conf and apply it: config.prefs is replaced in one assignment, so a request never sees half of a reload. All or nothing. Returns one line per change; raises ConfigError (nothing applied) if the file is missing, unreadable or invalid.""" - global _preferences + global prefs new = read_preferences(preferences_path) if new is None: raise ConfigError(f'reload requested but {preferences_path} does not exist; nothing reloaded') - values = preference_values(_getenv_for(new)) - changes = [] - for name, value in values.items(): - old = globals()[name] - if old == value: - continue - changes.append(f"{name.removeprefix('config_')} {old!r} -> {value!r}") - for module in list(sys.modules.values()): - namespace = getattr(module, '__dict__', None) - try: - if namespace is not None and name in namespace and namespace[name] == old: - namespace[name] = value - except Exception: - pass - _preferences = new + values = preference_values(_getenv_for(new), new) + changes = [f"{name} {getattr(prefs, name)!r} -> {getattr(values, name)!r}" for name in PREFERENCE_KEYS + if getattr(prefs, name) != getattr(values, name)] + prefs = values return changes def child_environment(): """For a job process: the preferences this process runs on, so it never sees a file edited since the last reload.""" - return dict(_preferences, **{PASSED: '1'}) + return dict(prefs.source, **{PASSED: '1'}) def check_startup_environment(): @@ -334,7 +374,7 @@ def check_startup_environment(): if OLD_ENV.exists(): problems.append(f'{OLD_ENV} is the old single config file, which finbot no longer reads; it will not start while the file is there') problems.append(f'split it with {REPO_DIR}/util/split_env.py, which writes finbot.env.new and preferences.conf.new beside it; put finbot.env ' - f'where your launcher loads it and {PREFERENCES_FILE} in {config_data_dir}, then delete {OLD_ENV}') + f'where your launcher loads it and {PREFERENCES_FILE} in {env.data_dir}, then delete {OLD_ENV}') if problems: raise ConfigError(problems) @@ -344,208 +384,15 @@ def startup_report(): provided = [key for key in STARTUP_KEYS if key in os.environ] lines = [f'startup environment provides {len(provided)} of {len(STARTUP_KEYS)} startup keys (values hidden)'] if os.path.isfile(preferences_path): - lines.append(f'preferences from {preferences_path} ({len(_preferences)} set; reload with SIGHUP)') + lines.append(f'preferences from {preferences_path} ({len(prefs.source)} set; reload with SIGHUP)') else: - lines.append(f'no {PREFERENCES_FILE} in {config_data_dir}, using the defaults; reload will have nothing to re-read') + lines.append(f'no {PREFERENCES_FILE} in {env.data_dir}, using the defaults; reload will have nothing to re-read') return lines def startup_warnings(): """Problems in finbot.env that do not stop the start, a line each: a setting that leaves part of the bot unable to work.""" warnings = [] - if config_slackOutgoingWebhook and not config_slackBotToken: + if env.slackOutgoingWebhook and not env.slackBotToken: warnings.append('slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply') return warnings - - -adjectives = [ - 'absolutely', - 'amazingly', - 'awfully', - 'bitchingly', - 'bloodly', - 'comfortably', - 'darned', - 'distinctly', - 'especially', - 'ever so', - 'enjoyably', - 'exceedingly', - 'exceptionally', - 'extraordinarily', - 'extremely', - 'flatteringly', - 'fortunately', - 'fortuitously', - 'greatly', - 'honourably', - 'hugely', - 'humblingly', - 'inordinately', - 'jolly', - 'joyfully', - 'keenly', - 'kindly', - 'knowingly', - 'luckily', - 'memorably', - 'mostly', - 'needlessly', - 'overwhelmingly', - 'pleasurably', - 'questionably', - 'rewardingly', - 'serendipitously', - 'seriously', - 'significantly', - 'so', - 'super', - 'supremely', - 'surprisingly', - 'terribly', - 'terrifically', - 'thoroughly', - 'totally', - 'tremendously', - 'uber', - 'uncomprehensibly', - 'unexceptionably', - 'unquestionably', - 'unmentionably', - 'unusually', - 'discomfortingly', - 'incomprehensibly', - 'very', - 'wholesomely', - 'xtra', - 'yawningly', - 'zealously' -] - -adjectives_two = [ - 'amazing', - 'bitching', - 'comforting', - 'comprehensible', - 'dapper', - 'enjoyable', - 'exceptional', - 'extraordinary', - 'flattering', - 'fortuitous', - 'fortunate', - 'great', - 'grateful', - 'honouring', - 'huge', - 'humbling', - 'inordinate', - 'joyous', - 'keen', - 'knowing', - 'kind', - 'lucky', - 'memorable', - 'mentionable', - 'nice', - 'overwhelming', - 'pleasurable', - 'questionable', - 'rewarding', - 'serendipitous', - 'significant', - 'special', - 'super', - 'terrific', - 'tremendous', - 'useful', - 'virtuous', - 'wholesome', - 'xenial', - 'yawning', - 'zany' -] - -verb = [ - 'pretend to greet', - 'apparently share this moment with', - 'coincide in temporal reality with', - 'cross digital paths with', - 'simulate becoming acquainted with', - 'fire photons at', - 'traverse cyberspace with', - 'co-exist in spacetime with', - 'fire electrons at', - 'encode character sets with', - 'convert utf-8 to binary and then back to utf-8 with', - 'update this pixel matrix with', - 'lose money with', - 'maintain character with', - 'act like I comprehend' -] - -noTradesVerb = [ - "The money is probably resting in another account", - "Oh well. The market would have just gone down anyway", - "Heating bills don't pay themselves you know", - "You were fearful when others were also fearful", - "I bet you're regretting that extra 1¢ on your limit order now", - "This is why you can't have nice things", - "But I'm sure that's just a rounding error", - "Nothing + nothing = more nothing. Well played", - "Or maybe there was. They don't pay me for this you know", - "I guess that's right. I'm kinda busy with ChatGPT in another window", - "I like this contrarian play you're having on 'being in it to win it'", - "With your stock picking skills, this is probably for the best" -] - -primary_listing = data_json('finbot_adr.json', {}) # ADR -> its primary listing - -yahoo_country = { - 'Argentina': 'BA', - 'Austria': 'VI', - 'Australia': 'AX', - 'Belgium': 'BR', - 'Brazil': 'SA', - 'Canada': 'TO', - 'Chile': 'SN', - 'Colombia': 'CL', - 'Czech Republic': 'PR', - 'Denmark': 'CO', - 'Estonia': 'TL', - 'Finland': 'HE', - 'France': 'PA', - 'Germany': 'DE', - 'Hungary': 'BD', - 'Iceland': 'IC', - 'India': 'NS', - 'Indonesia': 'JK', - 'Ireland': 'IR', - 'Israel': 'TA', - 'Italy': 'MI', - 'Latvia': 'RG', - 'Lithuania': 'VS', - 'Mexico': 'MX', - 'Netherlands': 'AS', - 'New Zealand': 'NZ', - 'Norway': 'OL', - 'Portugal': 'LS', - 'Qatar': 'QA', - 'Saudi Arabia': 'SR', - 'South Africa': 'JO', - 'Spain': 'MC', - 'Sweden': 'ST', - 'Switzerland': 'SW', - 'Thailand': 'BK', - 'Turkey': 'IS', - 'United Kingdom': 'L', - 'Venezuela': 'CR' - # markets with numeric tickers purposefully omitted -} - -# `from lib.config import *` is how the other modules get their config_* names; keep the loader machinery out of their namespaces -_INTERNAL = {'difflib', 're', 'sys', 'ConfigError', 'key_problem', 'parse_assignments', 'read_text', 'read_preferences', 'read_startup_file', - 'startup_values', 'preference_values', 'reload', 'child_environment', 'print_errors', 'check_startup_environment', 'startup_report', 'preferences_path', - 'startup_warnings', 'STARTUP_KEYS', 'PREFERENCE_KEYS', 'SECRET_KEYS', 'PREFERENCES_FILE', 'PASSED', 'PREFERENCE_NAMES', 'OLD_ENV', - 'TRUE_WORDS', 'FALSE_WORDS'} -__all__ = [name for name in dir() if not name.startswith('_') and name not in _INTERNAL] diff --git a/lib/jobs.py b/lib/jobs.py index 7b67dbf7..602a9ed2 100644 --- a/lib/jobs.py +++ b/lib/jobs.py @@ -20,7 +20,6 @@ from gevent import subprocess from croniter import croniter, CroniterBadDateError -from lib.config import * from lib import config from lib import jobrun from lib import util @@ -47,7 +46,7 @@ def run_child(label, module, kwargs): """Run a command in its own process (python -m lib.jobrun) and return its exit status. The child inherits the environment, user and output of the bot, so it behaves like the old cron job. A timeout or any other interruption stops the child.""" env = dict(os.environ, **config.child_environment()) # the preferences this process runs on, not whatever the file says now - proc = subprocess.Popen([sys.executable, '-m', 'lib.jobrun', module.__name__, json.dumps(kwargs)], cwd=REPO_DIR, env=env) + proc = subprocess.Popen([sys.executable, '-m', 'lib.jobrun', module.__name__, json.dumps(kwargs)], cwd=config.REPO_DIR, env=env) try: return proc.wait() except BaseException: @@ -240,9 +239,9 @@ class Scheduler: """Fires the jobs of a schedule file. `path` is a callable returning the file (the data dir copy wins over the default, so a new copy is found on reload). `clock`, `sleep` and `spawn` can be replaced, which the tests do.""" - def __init__(self, path, catchup=config_scheduler_catchup, clock=None, sleep=None, spawn=None, zone=None, runner=None): + def __init__(self, path, catchup=None, clock=None, sleep=None, spawn=None, zone=None, runner=None): self.path = path - self.catchup = datetime.timedelta(minutes=catchup) + self.catchup = datetime.timedelta(minutes=config.env.scheduler_catchup if catchup is None else catchup) self.clock = clock or (lambda: datetime.datetime.now(datetime.timezone.utc)) self.sleep = sleep or gevent.sleep self.spawn = spawn or gevent.spawn diff --git a/lib/reports.py b/lib/reports.py index 01f8dc45..62a4a40a 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -2,7 +2,7 @@ import hashlib, re import datetime import sys -from lib.config import * +from lib import config from lib import sharesight from lib import util from lib import webhook @@ -165,7 +165,7 @@ def prepare_compare(service, args): spec = period_days or ('max' if full_history else 'default') cache_key = hashlib.sha1(f"{service}|{','.join(tickers)}|{spec}".encode()).hexdigest()[:16] image_cache_file, caption_cache_file = f"finbot_compare_{cache_key}.png", f"finbot_compare_{cache_key}.json" - if config_cache: + if config.prefs.cache: cached_image = util.read_binary_cache(image_cache_file) cached_caption = util.read_cache(caption_cache_file) if cached_image and cached_caption: @@ -209,7 +209,7 @@ def prepare_compare(service, args): for pct, t in reversed(results): link = util.finance_link(t, exchanges[t], service, days=span_days, brief=False) caption.append(f"{util.get_emoji(pct)} {names[t]} ({link}): {pct:+,.1f}%") - if config_cache: + if config.prefs.cache: util.write_binary_cache(image_cache_file, image) util.json_write(caption_cache_file, caption) image.seek(0) @@ -386,7 +386,7 @@ def prepare_rating_payload(service, action, length=15): rating first (1 is a strong buy, 5 a strong sell); on equal ratings, the one more analysts give first.""" found = [] # (rating, analysts, line, ticker) for ticker, data in yahoo.fetch_details(util.get_holdings_and_watchlist()).items(): - if 'recommend' not in data or data['recommend_analysts'] < config_min_analysts: + if 'recommend' not in data or data['recommend_analysts'] < config.prefs.min_analysts: continue recommend = data['recommend'].replace('_', ' ') if (action == 'buy' and 'buy' in recommend) or (action == 'sell' and recommend in ('sell', 'underperform')): @@ -396,8 +396,8 @@ def prepare_rating_payload(service, action, length=15): found = sorted(found, key=lambda row: (row[0] if action == 'buy' else -row[0], -row[1]))[:length] payload = [line for _, _, line, _ in found] if not payload: - return [f"No tracked stocks rated {action} by {config_min_analysts}+ analysts"], None - message = f"Top {length} analyst {action} ratings for tracked stocks ({config_min_analysts}+ analysts)" + return [f"No tracked stocks rated {action} by {config.prefs.min_analysts}+ analysts"], None + message = f"Top {length} analyst {action} ratings for tracked stocks ({config.prefs.min_analysts}+ analysts)" shown = [(ticker.split('.')[0], rating) for rating, _, _, ticker in found] low, high = min(r for _, r in shown), max(r for _, r in shown) spread = (high - low) or 0.2 @@ -417,7 +417,7 @@ def prepare_value_payload(service, action='pe', ticker_select=None, length=15): market_data |= yahoo.fetch_details(market_data, strict=bool(ticker_select)) covered = None # for a forward P/E list: the tickers enough analysts cover if 'forward' in action and not ticker_select: - covered = {ticker for ticker, detail in yahoo.fetch_details(market_data).items() if detail.get('recommend_analysts', 0) >= config_min_analysts} + covered = {ticker for ticker, detail in yahoo.fetch_details(market_data).items() if detail.get('recommend_analysts', 0) >= config.prefs.min_analysts} for ticker in market_data: if covered is not None and ticker not in covered: continue @@ -476,9 +476,9 @@ def prepare_value_payload(service, action='pe', ticker_select=None, length=15): payload.reverse() chart_rows.reverse() payload = payload[:length] - coverage = f" ({config_min_analysts}+ analysts)" if covered is not None else '' + coverage = f" ({config.prefs.min_analysts}+ analysts)" if covered is not None else '' if not payload: - payload = [f"No tracked stocks with {config_min_analysts}+ analysts have a {heading_trail} ratio" if covered is not None else f"{action}: no data found"] + payload = [f"No tracked stocks with {config.prefs.min_analysts}+ analysts have a {heading_trail} ratio" if covered is not None else f"{action}: no data found"] else: heading = f'{heading_type} {length} tracked stocks by {heading_trail} ratio{coverage}' payload.insert(0, f"{webhook.bold(heading, service)}") @@ -668,7 +668,7 @@ def prepare_profile_payload(service, user, ticker): payload.append(f"{util.get_emoji(data[key])} {webhook.bold(label, service)} {data[key]:,}%") break - if 'profile_website' in data and config_hyperlinkFooter and config_hyperlink: + if 'profile_website' in data and config.prefs.hyperlinkFooter and config.prefs.hyperlink: if payload: payload.append("") payload.append(f"{gfinance_link} | {util.yahoo_link(ticker, service, brief=True, text='yahoo')} | {sws_link}") diff --git a/lib/sharesight.py b/lib/sharesight.py index a39caf34..8dd9c159 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -3,7 +3,7 @@ import datetime import threading import requests -from lib.config import * +from lib import config from lib import net import lib.util as util @@ -21,7 +21,7 @@ def _post(url, **kwargs): def _request(send, url, what, **kwargs): """Send a request to Sharesight and return the JSON answer. A request that fails raises RuntimeError naming `what`.""" try: - r = send(url, timeout=config_http_timeout, **kwargs) + r = send(url, timeout=config.prefs.http_timeout, **kwargs) except Exception as e: print("Error", net.redact(e), url, file=sys.stderr) raise RuntimeError(f"Sharesight {what} request failed: {e}") @@ -43,27 +43,27 @@ def __call__(self, r): return r def get_token(): - if config_cache: + if config.prefs.cache: cacheFile = "finbot_sharesight_token.json" cache = util.json_load(cacheFile) if cache: cache_expiry = datetime.datetime.fromtimestamp(cache['created_at'] + cache['expires_in']) ttl = cache_expiry - datetime.datetime.now() if ttl > datetime.timedelta(seconds=20): - print("cache hit:", cacheFile, "TTL:", util.td_to_human(ttl), file=sys.stderr) if debug else None + print("cache hit:", cacheFile, "TTL:", util.td_to_human(ttl), file=sys.stderr) if config.prefs.debug else None return cache['access_token'] else: - print("cache miss:", cacheFile, file=sys.stderr) if debug else None + print("cache miss:", cacheFile, file=sys.stderr) if config.prefs.debug else None print("Fetching Sharesight auth token") - data = _request(_post, "https://api.sharesight.com/oauth2/token", 'auth', data=config_sharesight_auth) + data = _request(_post, "https://api.sharesight.com/oauth2/token", 'auth', data=config.env.sharesight_auth) _check(data, 'auth') - if config_cache and 'access_token' in data: + if config.prefs.cache and 'access_token' in data: util.json_write(cacheFile, data) - print("Got a Sharesight token", file=sys.stderr) if debug else None + print("Got a Sharesight token", file=sys.stderr) if config.prefs.debug else None return data['access_token'] -PORTFOLIOS_CACHE = "finbot_sharesight_portfolios.json" # ttl: config_cache_seconds +PORTFOLIOS_CACHE = "finbot_sharesight_portfolios.json" # ttl: the cache_seconds preference def performance_cache_file(portfolio_id, days): return "finbot_sharesight_performance_" + str(portfolio_id) + "_" + str(days) + '.json' @@ -71,18 +71,18 @@ def performance_cache_file(portfolio_id, days): def get_portfolios(refresh=False): """refresh=True skips the cache read and fetches again (the cache is rewritten).""" portfolio_dict = {} - data = util.read_cache(PORTFOLIOS_CACHE, config_cache_seconds) if config_cache and not refresh else None + data = util.read_cache(PORTFOLIOS_CACHE, config.prefs.cache_seconds) if config.prefs.cache and not refresh else None if not data: token = get_token() print("Fetching Sharesight portfolios") data = _request(_get, "https://api.sharesight.com/api/v3/portfolios", 'portfolios', headers={'Content-type': 'application/json'}, auth=BearerAuth(token)) _check(data, 'portfolios') - if config_cache and 'portfolios' in data: + if config.prefs.cache and 'portfolios' in data: util.json_write(PORTFOLIOS_CACHE, data) for portfolio in data['portfolios']: - if str(portfolio['id']) in config_exclude_portfolios: + if str(portfolio['id']) in config.prefs.exclude_portfolios: print(portfolio['id'], "(" + portfolio['name'] + ") in exclusion list. Skipping.") - elif str(portfolio['id']) not in config_include_portfolios and config_include_portfolios: + elif str(portfolio['id']) not in config.prefs.include_portfolios and config.prefs.include_portfolios: print("Exclusion list is defined and does not contain", portfolio['id'], "(" + portfolio['name'] + "). Skipping.") else: portfolio_dict[portfolio['name']] = portfolio['id'] @@ -109,10 +109,10 @@ def pick_portfolios(name=None, portfolios=None): raise PortfolioNotFound(name, portfolios) return dict([found]) -def get_trades(portfolio_name, portfolio_id, days=UNSET): - if days is UNSET: - days = config_past_days - if config_cache: +def get_trades(portfolio_name, portfolio_id, days=None): + if days is None: + days = config.prefs.past_days + if config.prefs.cache: cache_file = "finbot_sharesight_trades_" + str(portfolio_id) + "_" + str(days) + ".json" cache = util.read_cache(cache_file, 299) # max freq 5 min if cache: @@ -128,7 +128,7 @@ def get_trades(portfolio_name, portfolio_id, days=UNSET): _check(data, 'trades') for trade in data['trades']: trade['portfolio'] = portfolio_name # inject custom field - if config_cache and 'trades' in data: + if config.prefs.cache and 'trades' in data: util.json_write(cache_file, data) return data['trades'] @@ -165,14 +165,14 @@ def get_holdings_wrapper(): tickers.update(get_holdings(portfolio_name, portfolio_id)) return sorted(tickers) -def get_performance(portfolio_id, days, config_cache_seconds=UNSET, refresh=False): - if config_cache_seconds is UNSET: - config_cache_seconds = config_performance_cache_seconds +def get_performance(portfolio_id, days, seconds=None, refresh=False): + if seconds is None: + seconds = config.prefs.performance_cache_seconds start_date = datetime.datetime.now() - datetime.timedelta(days=days) start_date = start_date.strftime('%Y-%m-%d') # 2023-04-25 cache_file = performance_cache_file(portfolio_id, days) - if config_cache and not refresh: - cache = util.read_cache(cache_file, config_cache_seconds) + if config.prefs.cache and not refresh: + cache = util.read_cache(cache_file, seconds) if cache: return cache token = get_token() @@ -186,13 +186,13 @@ def get_performance(portfolio_id, days, config_cache_seconds=UNSET, refresh=Fals continue break _check(data, 'performance') - if config_cache and 'report' in data: + if config.prefs.cache and 'report' in data: util.json_write(cache_file, data) return data -def get_performance_wrapper(days=UNSET): - if days is UNSET: - days = config_past_days +def get_performance_wrapper(days=None): + if days is None: + days = config.prefs.past_days performance = {} portfolios = get_portfolios() for portfolio_name, portfolio_id in portfolios.items(): @@ -208,14 +208,14 @@ def warm(margin=0.9): the portfolios on instead of reading the cached list again.""" fetched = 0 age = util.cache_age(PORTFOLIOS_CACHE) - if age is None or age >= config_cache_seconds * margin: + if age is None or age >= config.prefs.cache_seconds * margin: portfolios = get_portfolios(refresh=True) fetched += 1 else: portfolios = get_portfolios() for portfolio_name, portfolio_id in portfolios.items(): age = util.cache_age(performance_cache_file(portfolio_id, 0)) - if age is None or age >= margin * config_performance_cache_seconds: + if age is None or age >= margin * config.prefs.performance_cache_seconds: get_performance(portfolio_id, 0, refresh=True) fetched += 1 return fetched, portfolios @@ -225,10 +225,10 @@ def next_due(margin=0.9, portfolios=None): used; without it the cached list is read.""" waits = [] age = util.cache_age(PORTFOLIOS_CACHE) - waits.append(0 if age is None else margin * config_cache_seconds - age) + waits.append(0 if age is None else margin * config.prefs.cache_seconds - age) for portfolio_name, portfolio_id in (get_portfolios() if portfolios is None else portfolios).items(): age = util.cache_age(performance_cache_file(portfolio_id, 0)) - waits.append(0 if age is None else margin * config_performance_cache_seconds - age) + waits.append(0 if age is None else margin * config.prefs.performance_cache_seconds - age) return max(0, min(waits)) _wake = threading.Event() # set by wake(): the caches were flushed, refill them now @@ -242,7 +242,7 @@ def keep_warm(min_wait=5, retry=300, idle=60, stop=None): While the cache preference is off (a reload can switch it either way) it fetches nothing, since nothing would be written and everything would always be due, and looks again every `idle` seconds. Never raises; after a failure it waits `retry` seconds.""" while not (stop and stop.is_set()): - if not config_cache: + if not config.prefs.cache: wait = idle else: try: diff --git a/lib/shortman.py b/lib/shortman.py index e1f1a4c4..489fb0e8 100644 --- a/lib/shortman.py +++ b/lib/shortman.py @@ -1,5 +1,5 @@ import sys -from lib.config import * +from lib import config from lib import net def fetch(market_data): @@ -7,7 +7,7 @@ def fetch(market_data): print("Fetching ASX shorts from Shortman") url = 'https://www.shortman.com.au/downloadeddata/latest.csv' try: - r = net.get(url, timeout=config_http_timeout) + r = net.get(url, timeout=config.prefs.http_timeout) except Exception as e: print("Failure fetching", url, net.redact(e), file=sys.stderr) return market_data # the ASX short interest is missing, the rest of the report is not diff --git a/lib/simplywallst.py b/lib/simplywallst.py index c49388d1..206ea1ed 100644 --- a/lib/simplywallst.py +++ b/lib/simplywallst.py @@ -1,11 +1,11 @@ -from lib.config import data_json +from lib import config FILES = ['finbot_sws_custom.json', 'finbot_sws_asx200.json', 'finbot_sws_sp500.json', 'finbot_sws_nasdaq100.json'] # the first to list a ticker wins def get_url(symbol, name, exchange): """The ticker's page on simplywall.st, from the lists in FILES; without one, a search that lands on it.""" for file in FILES: - url = data_json(file, {}).get(symbol) + url = config.data_json(file, {}).get(symbol) if url: return url return 'https://www.google.com/search?q=site:simplywall.st+(' + name + '+' + exchange + ':' + symbol.split('.')[0] + ')+Stock&btnI' diff --git a/lib/telegram.py b/lib/telegram.py index c918aca5..908396ca 100644 --- a/lib/telegram.py +++ b/lib/telegram.py @@ -1,11 +1,11 @@ import json import sys -from lib.config import * +from lib import config from lib import net def call(method, **kwargs): """POST a Bot API method and return its result. Raises RuntimeError with Telegram's description when it answers not ok.""" - response = net.post(webhooks['telegram'] + method, timeout=config_http_timeout, **kwargs) + response = net.post(config.env.webhooks['telegram'] + method, timeout=config.prefs.http_timeout, **kwargs) try: output = response.json() except ValueError: @@ -22,12 +22,12 @@ def try_call(method, **kwargs): print(f"Telegram {method} failed:", net.redact(e), file=sys.stderr) def setWebhook(): - print("registering", config_telegramOutgoingWebhook, file=sys.stderr) - params = {'url': config_telegramOutgoingWebhook, "allowed_updates": json.dumps(['message', 'callback_query']), 'secret_token': config_telegramOutgoingToken} + print("registering", config.env.telegramOutgoingWebhook, file=sys.stderr) + params = {'url': config.env.telegramOutgoingWebhook, "allowed_updates": json.dumps(['message', 'callback_query']), 'secret_token': config.env.telegramOutgoingToken} return call('setWebhook', params=params) def delWebhook(): - print("deregistering", config_telegramOutgoingWebhook, file=sys.stderr) + print("deregistering", config.env.telegramOutgoingWebhook, file=sys.stderr) return call('setWebhook', params={'url': ''}) # unsubscribe def getMe(): diff --git a/lib/util.py b/lib/util.py index 8a65736a..9e6a9c6b 100644 --- a/lib/util.py +++ b/lib/util.py @@ -8,7 +8,7 @@ import sys import tempfile import time -from lib.config import * # first: sets MPLCONFIGDIR before matplotlib is imported (lazily, by the chart functions) +from lib import config # first: sets MPLCONFIGDIR before matplotlib is imported (lazily, by the chart functions) from lib import sharesight @@ -88,6 +88,48 @@ def transform_title(title): 'WAR': ('🇵🇱', 'PLN'), } +YAHOO_COUNTRY = { # country -> its Yahoo ticker suffix, for a listing that has none (cal.py) + 'Argentina': 'BA', + 'Austria': 'VI', + 'Australia': 'AX', + 'Belgium': 'BR', + 'Brazil': 'SA', + 'Canada': 'TO', + 'Chile': 'SN', + 'Colombia': 'CL', + 'Czech Republic': 'PR', + 'Denmark': 'CO', + 'Estonia': 'TL', + 'Finland': 'HE', + 'France': 'PA', + 'Germany': 'DE', + 'Hungary': 'BD', + 'Iceland': 'IC', + 'India': 'NS', + 'Indonesia': 'JK', + 'Ireland': 'IR', + 'Israel': 'TA', + 'Italy': 'MI', + 'Latvia': 'RG', + 'Lithuania': 'VS', + 'Mexico': 'MX', + 'Netherlands': 'AS', + 'New Zealand': 'NZ', + 'Norway': 'OL', + 'Portugal': 'LS', + 'Qatar': 'QA', + 'Saudi Arabia': 'SR', + 'South Africa': 'JO', + 'Spain': 'MC', + 'Sweden': 'ST', + 'Switzerland': 'SW', + 'Thailand': 'BK', + 'Turkey': 'IS', + 'United Kingdom': 'L', + 'Venezuela': 'CR' + # markets with numeric tickers purposefully omitted +} + def flag_from_market(market): if market in MARKETS: return MARKETS[market][0] @@ -104,20 +146,20 @@ def currency_from_market(market): def cache_path(name, persist=False): """Where a cache file lives, or with persist=True a state file (in the data directory).""" - return (config_var_dir if persist else config_cache_dir) + "/" + name + return (config.env.data_dir if persist else config.env.cache_dir) + "/" + name def _fresh_cache(cacheFile, maxSeconds): - """The cache file's path if it was written less than maxSeconds ago (config_cache_seconds when UNSET), else None.""" - if maxSeconds is UNSET: - maxSeconds = config_cache_seconds + """The cache file's path if it was written less than maxSeconds ago (the cache_seconds preference when None), else None.""" + if maxSeconds is None: + maxSeconds = config.prefs.cache_seconds path, age = cache_path(cacheFile), cache_age(cacheFile) if age is None: - print("cache miss:", path, file=sys.stderr) if debug else None + print("cache miss:", path, file=sys.stderr) if config.prefs.debug else None return None if age >= maxSeconds: - print("cache expired:", path, file=sys.stderr) if debug else None + print("cache expired:", path, file=sys.stderr) if config.prefs.debug else None return None - print("cache hit:", path, "TTL:", td_to_human(datetime.timedelta(seconds=maxSeconds - age)), file=sys.stderr) if debug else None + print("cache hit:", path, "TTL:", td_to_human(datetime.timedelta(seconds=maxSeconds - age)), file=sys.stderr) if config.prefs.debug else None return path def _read_fresh(cacheFile, maxSeconds, read): @@ -130,7 +172,7 @@ def _read_fresh(cacheFile, maxSeconds, read): except FileNotFoundError: return None -def read_cache(cacheFile, maxSeconds=UNSET): +def read_cache(cacheFile, maxSeconds=None): """A cache file's JSON, or None when it is missing or older than maxSeconds.""" return _read_fresh(cacheFile, maxSeconds, lambda path: json.loads(path.read_text(encoding="utf-8"))) @@ -165,7 +207,7 @@ def write_file(path, data, sync=False): def json_write(filename, data, persist=False): """A cache file, or with persist=True a state file in the data directory (also flushed to disk). See write_file.""" path = cache_path(filename, persist) - if debug: + if config.prefs.debug: print("cache write:", path, file=sys.stderr) write_file(path, json.dumps(data, indent=4).encode('utf-8'), sync=persist) @@ -173,7 +215,7 @@ def json_load(filename, persist=False): path = Path(cache_path(filename, persist)) return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None -def read_binary_cache(cacheFile, maxSeconds=UNSET): +def read_binary_cache(cacheFile, maxSeconds=None): """A cache file's bytes (as a file object), or None when it is missing or older than maxSeconds.""" return _read_fresh(cacheFile, maxSeconds, lambda path: io.BytesIO(path.read_bytes())) @@ -201,7 +243,7 @@ def security_line(flag, title, link): def link(url, text, service='telegram'): """text linking to url in the service's markup: HTML on Telegram, on Slack, a masked link on Discord (the angle brackets keep its preview away; brackets in the text and parentheses in the url would end it early).""" - if not config_hyperlink: + if not config.prefs.hyperlink: return text if service == 'telegram': return '
' + text + '' @@ -212,7 +254,7 @@ def link(url, text, service='telegram'): return text def finance_link(symbol, exchange, service='telegram', days=1, brief=True, text=None): - if config_hyperlinkProvider == 'google': + if config.prefs.hyperlinkProvider == 'google': return gfinance_link(symbol, exchange, service, days, brief, text) return yahoo_link(symbol, service, brief, text) @@ -252,7 +294,7 @@ def gfinance_link(symbol, exchange, service='telegram', days=1, brief=True, text text = ticker else: text = symbol # US - if service in {'telegram', 'discord', 'slack'} and config_hyperlink: + if service in {'telegram', 'discord', 'slack'} and config.prefs.hyperlink: return link(url + ticker + '?window=' + window, text, service) return symbol @@ -380,8 +422,10 @@ def render_off_hub(): deadlock matplotlib under gevent's patched locks. matplotlib is imported here, at start, instead of by the first chart. Job processes and the tests draw in place.""" global _render_pool + import importlib from gevent.threadpool import ThreadPool - import matplotlib.backends.backend_agg, matplotlib.dates, matplotlib.figure, matplotlib.patches, matplotlib.path, matplotlib.ticker # noqa: F401 + for module in ('matplotlib.backends.backend_agg', 'matplotlib.dates', 'matplotlib.figure', 'matplotlib.patches', 'matplotlib.path', 'matplotlib.ticker'): + importlib.import_module(module) _render_pool = ThreadPool(1) def _drawn(draw): diff --git a/lib/webhook.py b/lib/webhook.py index ba5ce1bc..b420d517 100644 --- a/lib/webhook.py +++ b/lib/webhook.py @@ -6,7 +6,7 @@ import sys import traceback -from lib.config import * +from lib import config from lib import net from lib import telegram @@ -28,10 +28,10 @@ def write(service, url, payload_string, chat_id=None, reply_markup=None): payload['unfurl_links'] = False # no link previews: Slack reads these from the body, not the headers payload['unfurl_media'] = False if chat_id: - if not config_slackBotToken: + if not config.env.slackBotToken: print(f"ERROR slack: a reply to channel {chat_id} needs slackBotToken in finbot.env; not sent", file=sys.stderr) return None - headers['Authorization'] = 'Bearer ' + config_slackBotToken + headers['Authorization'] = 'Bearer ' + config.env.slackBotToken payload['channel'] = chat_id elif 'api.telegram.org' in url: payload['parse_mode'] = 'HTML' @@ -40,7 +40,7 @@ def write(service, url, payload_string, chat_id=None, reply_markup=None): if reply_markup: payload['reply_markup'] = reply_markup # keyboard (Telegram only) try: - r = net.post(url, headers=headers, json=payload, timeout=config_http_timeout) + r = net.post(url, headers=headers, json=payload, timeout=config.prefs.http_timeout) except Exception as e: print("Failure sending to", service + ":", net.redact(e), file=sys.stderr) return None @@ -59,14 +59,14 @@ def chat_url(service, chat_id): if service == 'slack': return 'https://slack.com/api/chat.postMessage' if service == 'telegram': - return webhooks['telegram'] + 'sendMessage?chat_id=' + str(chat_id) - return webhooks.get(service) + return config.env.webhooks['telegram'] + 'sendMessage?chat_id=' + str(chat_id) + return config.env.webhooks.get(service) def default_chats(): """Where a scheduled report goes: [(service, url, chat id)]. Telegram posts into telegramChatID (left out without one); Slack and Discord post through their incoming webhooks, which take no chat id. Raises RuntimeError when there is nowhere to post.""" - chats = [(service, chat_url(service, config_telegramChatID), config_telegramChatID) if service == 'telegram' else (service, url, None) - for service, url in webhooks.items() if service != 'telegram' or config_telegramChatID] + chats = [(service, chat_url(service, config.env.telegramChatID), config.env.telegramChatID) if service == 'telegram' else (service, url, None) + for service, url in config.env.webhooks.items() if service != 'telegram' or config.env.telegramChatID] if not chats: raise RuntimeError('nowhere to post: finbot.env sets no telegramChatID, slack_webhook or discord_webhook') return chats @@ -139,12 +139,12 @@ def payload_wrapper(service, url, payload, chat_id=None, reply_markup=None): payload = [str(line) for line in payload] payload_string = '\n'.join(payload) print("Preparing outbound to", service, str(len(payload_string)), "bytes") - print("Payload: " + payload_string) if debug else None + print("Payload: " + payload_string) if config.prefs.debug else None limit = DISCORD_LIMIT if service == 'discord' else 4096 if len(payload_string) <= limit: return [write(service, url, payload_string, chat_id, reply_markup)] print(service, f"payload is over {limit:,} bytes. Splitting.") - chunks = chunked(payload, limit, config_chunk_maxlines) + chunks = chunked(payload, limit, config.prefs.chunk_maxlines) results = [] for idx, chunk in enumerate(chunks): if idx: @@ -222,11 +222,11 @@ def takes_charts(service, reply=True): """Whether a report to `service` can carry a chart image: never with the graph preference off; on Telegram and Discord always; on Slack in a reply to a chat (`reply`), uploaded into its channel with the bot token, but not in a scheduled report, which goes through the incoming webhook and takes text only. Elsewhere a report goes out as text.""" - return config_graph and (service in ('telegram', 'discord') or (service == 'slack' and reply and bool(config_slackBotToken))) + return config.prefs.graph and (service in ('telegram', 'discord') or (service == 'slack' and reply and bool(config.env.slackBotToken))) def telegram_photo(chat_id, image_data, caption, reply_markup=None): """sendPhoto, the report as its caption (trimmed to fit). Telegram's answer, MAYBE_SENT, or None if it was refused.""" - url = webhooks['telegram'] + "sendPhoto?chat_id=" + str(chat_id) + url = config.env.webhooks['telegram'] + "sendPhoto?chat_id=" + str(chat_id) data = { 'disable_notification': True, 'chat_id': chat_id, @@ -238,7 +238,7 @@ def telegram_photo(chat_id, image_data, caption, reply_markup=None): if hasattr(image_data, 'seek'): image_data.seek(0) try: - r = net.post(url, data=data, files={"photo": ('image.png', image_data)}, timeout=config_http_timeout) + r = net.post(url, data=data, files={"photo": ('image.png', image_data)}, timeout=config.prefs.http_timeout) except requests.exceptions.ReadTimeout as e: print("Telegram sendPhoto timed out, the chart may still arrive:", net.redact(e), file=sys.stderr) return MAYBE_SENT @@ -263,8 +263,8 @@ def discord_photo(image_data, caption): payload = {'content': fit_caption(caption, DISCORD_LIMIT, len), 'flags': SUPPRESS_EMBEDS, 'allowed_mentions': NO_MENTIONS, 'attachments': [{'id': 0, 'filename': 'chart.png'}]} try: - r = net.post(discord_url(webhooks['discord']), data={'payload_json': json.dumps(payload)}, - files={'files[0]': ('chart.png', image_data, 'image/png')}, timeout=config_http_timeout) + r = net.post(discord_url(config.env.webhooks['discord']), data={'payload_json': json.dumps(payload)}, + files={'files[0]': ('chart.png', image_data, 'image/png')}, timeout=config.prefs.http_timeout) except requests.exceptions.ReadTimeout as e: print("Discord chart timed out, it may still arrive:", net.redact(e), file=sys.stderr) return MAYBE_SENT @@ -282,9 +282,9 @@ def discord_photo(image_data, caption): def slack_api(method, **data): """POST a Slack Web API method as the bot, form-encoded, and return its answer. Raises RuntimeError with Slack's error if not ok.""" - if not config_slackBotToken: + if not config.env.slackBotToken: raise RuntimeError(f"Slack {method}: needs slackBotToken in finbot.env") - r = net.post('https://slack.com/api/' + method, headers={'Authorization': 'Bearer ' + config_slackBotToken}, data=data, timeout=config_http_timeout) + r = net.post('https://slack.com/api/' + method, headers={'Authorization': 'Bearer ' + config.env.slackBotToken}, data=data, timeout=config.prefs.http_timeout) try: output = r.json() except ValueError: @@ -302,7 +302,7 @@ def slack_photo(chat_id, image_data, caption): data = image_data.read() if hasattr(image_data, 'read') else bytes(image_data) try: ticket = slack_api('files.getUploadURLExternal', filename='chart.png', length=len(data)) - r = net.post(ticket['upload_url'], data=data, timeout=config_http_timeout) + r = net.post(ticket['upload_url'], data=data, timeout=config.prefs.http_timeout) if r.status_code != 200: raise RuntimeError(f"Slack upload: {r.status_code} {r.text[:200]}") share = {'files': json.dumps([{'id': ticket['file_id']}]), 'channel_id': chat_id, 'initial_comment': caption} @@ -335,7 +335,7 @@ def sendPhoto(chat_id, image_data, caption, service, reply_markup=None): return output if chartable: print("chart not sent, sending the report as text", file=sys.stderr) - return payload_wrapper(service, chat_url(service, chat_id) if chat_id else webhooks.get(service), caption.split('\n'), chat_id) + return payload_wrapper(service, chat_url(service, chat_id) if chat_id else config.env.webhooks.get(service), caption.split('\n'), chat_id) def editMessageText(chat_id, message_id, text, reply_markup=None): """Telegram: replace a text message's text and buttons in place. A failure is only logged.""" diff --git a/lib/words.py b/lib/words.py new file mode 100644 index 00000000..265b993f --- /dev/null +++ b/lib/words.py @@ -0,0 +1,142 @@ +"""The words of the easter eggs: .hello, .thanks, and the quip when there were no trades.""" + +adjectives = [ + 'absolutely', + 'amazingly', + 'awfully', + 'bitchingly', + 'bloodly', + 'comfortably', + 'darned', + 'distinctly', + 'especially', + 'ever so', + 'enjoyably', + 'exceedingly', + 'exceptionally', + 'extraordinarily', + 'extremely', + 'flatteringly', + 'fortunately', + 'fortuitously', + 'greatly', + 'honourably', + 'hugely', + 'humblingly', + 'inordinately', + 'jolly', + 'joyfully', + 'keenly', + 'kindly', + 'knowingly', + 'luckily', + 'memorably', + 'mostly', + 'needlessly', + 'overwhelmingly', + 'pleasurably', + 'questionably', + 'rewardingly', + 'serendipitously', + 'seriously', + 'significantly', + 'so', + 'super', + 'supremely', + 'surprisingly', + 'terribly', + 'terrifically', + 'thoroughly', + 'totally', + 'tremendously', + 'uber', + 'uncomprehensibly', + 'unexceptionably', + 'unquestionably', + 'unmentionably', + 'unusually', + 'discomfortingly', + 'incomprehensibly', + 'very', + 'wholesomely', + 'xtra', + 'yawningly', + 'zealously' +] + +adjectives_two = [ + 'amazing', + 'bitching', + 'comforting', + 'comprehensible', + 'dapper', + 'enjoyable', + 'exceptional', + 'extraordinary', + 'flattering', + 'fortuitous', + 'fortunate', + 'great', + 'grateful', + 'honouring', + 'huge', + 'humbling', + 'inordinate', + 'joyous', + 'keen', + 'knowing', + 'kind', + 'lucky', + 'memorable', + 'mentionable', + 'nice', + 'overwhelming', + 'pleasurable', + 'questionable', + 'rewarding', + 'serendipitous', + 'significant', + 'special', + 'super', + 'terrific', + 'tremendous', + 'useful', + 'virtuous', + 'wholesome', + 'xenial', + 'yawning', + 'zany' +] + +verb = [ + 'pretend to greet', + 'apparently share this moment with', + 'coincide in temporal reality with', + 'cross digital paths with', + 'simulate becoming acquainted with', + 'fire photons at', + 'traverse cyberspace with', + 'co-exist in spacetime with', + 'fire electrons at', + 'encode character sets with', + 'convert utf-8 to binary and then back to utf-8 with', + 'update this pixel matrix with', + 'lose money with', + 'maintain character with', + 'act like I comprehend' +] + +noTradesVerb = [ + "The money is probably resting in another account", + "Oh well. The market would have just gone down anyway", + "Heating bills don't pay themselves you know", + "You were fearful when others were also fearful", + "I bet you're regretting that extra 1¢ on your limit order now", + "This is why you can't have nice things", + "But I'm sure that's just a rounding error", + "Nothing + nothing = more nothing. Well played", + "Or maybe there was. They don't pay me for this you know", + "I guess that's right. I'm kinda busy with ChatGPT in another window", + "I like this contrarian play you're having on 'being in it to win it'", + "With your stock picking skills, this is probably for the best" +] diff --git a/lib/worker.py b/lib/worker.py index 193cf716..300c8d42 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -6,7 +6,8 @@ import re import sys import threading -from lib.config import * +from lib import config +from lib import words from lib import util from lib import webhook from lib import reports @@ -215,11 +216,11 @@ def on_help(request, m): charts.mark_keyboard(request.chat_id) # this chat now has the current keyboard def on_hello(request, m): - """Easter egg: a greeting, alliterated when config_alliterate is on.""" - verbs = verb + [f"{webhook.strike(said, request.service)} I mean {meant}" for said, meant in (('study', 'meet'), ('observe', 'see'), ('profile', 'know'))] + """Easter egg: a greeting, alliterated when the alliterate preference is on.""" + verbs = words.verb + [f"{webhook.strike(said, request.service)} I mean {meant}" for said, meant in (('study', 'meet'), ('observe', 'see'), ('profile', 'know'))] while True: - first, second = random.choice(adjectives), random.choice(adjectives_two) - if not config_alliterate or first[0].lower() == second[0].lower(): + first, second = random.choice(words.adjectives), random.choice(words.adjectives_two) + if not config.prefs.alliterate or first[0].lower() == second[0].lower(): break request.say([f"{first.capitalize()} {second} to {random.choice(verbs)} you, {request.user_name}! 😇"]) @@ -229,11 +230,11 @@ def on_thanks(request, m): """Easter egg: you're welcome, glitching one time in a thousand and on April Fools' Day.""" today = datetime.date.today() glitch = webhook.strike(GLITCH, request.service) if random.randrange(1, 1000) == 1 or (today.month, today.day) == (4, 1) else '' - request.say([f"{glitch}You're {random.choice(adjectives)} welcome, {request.user_name}! 😇"]) + request.say([f"{glitch}You're {random.choice(words.adjectives)} welcome, {request.user_name}! 😇"]) def on_calendar(request, m): """Upcoming earnings or ex-dividend dates.""" - days, ticker = period_or_ticker(m.group('arg'), config_future_days) + days, ticker = period_or_ticker(m.group('arg'), config.prefs.future_days) event = 'ex-dividend' if m.group('kind').lower().startswith('div') else 'earnings' with request.typing(): cal.lambda_handler(request.chat_id, days, request.service, ticker, interactive=True, event=event) @@ -246,14 +247,14 @@ def on_performance(request, m): def on_session(request, m): """Mid-session moves (.session), or pre-market ones (.premarket, also post-market).""" - threshold, ticker = percent_or_ticker(m.group('arg'), config_price_percent) + threshold, ticker = percent_or_ticker(m.group('arg'), config.prefs.price_percent) premarket = m.group('kind').lower() != 'session' with request.typing(): price.lambda_handler(request.chat_id, threshold, request.service, request.user, ticker, interactive=True, midsession=not premarket, premarket=premarket) def on_price(request, m): """.price [percent|SYMBOL|top|bottom] [period]: a bare number is a percent, as on the other commands; a period has its unit.""" - threshold, ticker, days, top = config_price_percent, None, None, None + threshold, ticker, days, top = config.prefs.price_percent, None, None, None for i, arg in enumerate(m.groups()): if not arg: continue @@ -271,7 +272,7 @@ def on_price(request, m): price.lambda_handler(request.chat_id, threshold, request.service, request.user, ticker, interactive=True, interday=days is None, days=days, top=top) def on_shorts(request, m): - threshold, ticker = percent_or_ticker(m.group(1), config_shorts_percent) + threshold, ticker = percent_or_ticker(m.group(1), config.prefs.shorts_percent) with request.typing(): shorts.lambda_handler(request.chat_id, threshold, ticker, request.service, interactive=True) diff --git a/lib/yahoo.py b/lib/yahoo.py index 743a2978..75f13d71 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -6,7 +6,7 @@ import threading import zoneinfo import sys -from lib.config import * +from lib import config from lib import net from lib import util from http.cookies import SimpleCookie @@ -24,7 +24,7 @@ def getCookie(maxAge=1209600): # 14 days return cache[0][0] + '=' + cache[0][1] url = 'https://fc.yahoo.com/' # 404, but that is ok because cookie is in response header try: - r = net.get(url, headers=HEADERS, timeout=config_http_timeout) + r = net.get(url, headers=HEADERS, timeout=config.prefs.http_timeout) except Exception as e: print(net.redact(e), file=sys.stderr) r = requests.Response() # empty: falls through to the fallback cookie below @@ -45,13 +45,13 @@ def getCrumb(seconds=1209600): # 14 days with _auth: cookie = getCookie() cacheFile = "finbot_yahoo_crumb.json" - if config_cache and (cache := util.read_cache(cacheFile, seconds)): + if config.prefs.cache and (cache := util.read_cache(cacheFile, seconds)): return cache r = _get_first(_hosts('/v1/test/getcrumb'), {**HEADERS, 'Cookie': cookie}) if r is None: print("Exhausted Yahoo API attempts. Returning fallback crumb", file=sys.stderr) return 'jkQEU8yLqxs' - if config_cache: + if config.prefs.cache: util.json_write(cacheFile, r.text) print("Got a new Yahoo crumb", file=sys.stderr) return r.text @@ -77,7 +77,7 @@ def _get_first(urls, headers): """The answer of the first URL that answers 200, or None when none does. Each failure is logged.""" for url in urls: try: - r = net.get(url, headers=headers, timeout=config_http_timeout) + r = net.get(url, headers=headers, timeout=config.prefs.http_timeout) except Exception as e: print(net.redact(e), file=sys.stderr) continue @@ -146,7 +146,7 @@ def fetch(tickers): return {} tickers = sorted(set(tickers)) # de-dupe cacheFile = "finbot_yahoo_" + hashlib.sha256("_".join(tickers).encode()).hexdigest() + '.json' - if config_cache and (cache := util.read_cache(cacheFile, 300)): + if config.prefs.cache and (cache := util.read_cache(cacheFile, 300)): return cache cookie, crumb = credentials() r = _get_first(_hosts('/v7/finance/quote?crumb=' + crumb + '&symbols=' + ','.join(tickers)), {**HEADERS, 'Content-type': 'application/json', 'Cookie': cookie}) @@ -172,7 +172,7 @@ def fetch(tickers): if stamps: # the first still to come, else the announced date quote['earnings_date'] = next((stamp for stamp in stamps if stamp > now), stamps[0]) yahoo_output[item['symbol']] = quote - if config_cache: + if config.prefs.cache: util.json_write(cacheFile, yahoo_output) return yahoo_output @@ -227,13 +227,13 @@ def fetch(tickers): ) RECOMMENDATION = ('recommend', 'recommend_index', 'recommend_analysts') # all three, or none -def fetch_detail(ticker, seconds=UNSET): +def fetch_detail(ticker, seconds=None): """quoteSummary for one ticker: {ticker: {...}}, or {} when Yahoo has nothing usable (no name or no price).""" - if seconds is UNSET: - seconds = config_cache_seconds + if seconds is None: + seconds = config.prefs.cache_seconds now = datetime.datetime.now() cacheFile = "finbot_yahoo_detail_" + ticker + '.json' - data = util.read_cache(cacheFile, seconds) if config_cache else None + data = util.read_cache(cacheFile, seconds) if config.prefs.cache else None if data: print('.', sep=' ', end='', flush=True) else: @@ -245,7 +245,7 @@ def fetch_detail(ticker, seconds=UNSET): print(ticker + '†', sep=' ', end='', flush=True) return {} # catches some delisted stocks like "DRNA" data = r.json() - if config_cache: + if config.prefs.cache: util.json_write(cacheFile, data) result = dig(data, 'quoteSummary', 'result', 0) if result is None: # Yahoo answers result=null for unknown symbols @@ -303,13 +303,13 @@ def fetch_detail(ticker, seconds=UNSET): detail['insiderSell'], detail['insiderSellValue'] = totals['Sale'] return {ticker: dict(sorted(detail.items()))} -def price_history(ticker, days=None, seconds=UNSET, graph=UNSET, graphCache=True): +def price_history(ticker, days=None, seconds=None, graph=None, graphCache=True): """({period: percent change}, chart image or None): the change over `days`, else over each standard period. Raises RuntimeError when the history cannot be fetched or has no prices.""" - if graph is UNSET: - graph = config_graph - if seconds is UNSET: - seconds = config_cache_seconds + if graph is None: + graph = config.prefs.graph + if seconds is None: + seconds = config.prefs.cache_seconds import pandas as pd from pandas.tseries.offsets import BDay as businessday percent_dict = {} @@ -364,10 +364,10 @@ def price_history(ticker, days=None, seconds=UNSET, graph=UNSET, graphCache=True label = "Max" if "Max" in percent_dict else "10Y" title = f"{company_name} ({ticker}) {label} {util.signed_percent(percent_dict[label])} Last: {price}" image_cache_file = "finbot_graph_" + ticker + "_" + str(days) + ".png" - if config_cache and graphCache and (image := util.read_binary_cache(image_cache_file, seconds)): + if config.prefs.cache and graphCache and (image := util.read_binary_cache(image_cache_file, seconds)): return percent_dict, image image = util.graph(df, title, stock.get('currency')) - if config_cache: + if config.prefs.cache: util.write_binary_cache(image_cache_file, image) return percent_dict, image @@ -436,28 +436,28 @@ def prefetch_history(tickers, workers=8): if isinstance(result, Exception): print("prefetch failed for", ticker, result, file=sys.stderr) -def fetch_chart_json(ticker, days=3665, seconds=UNSET, full=False): +def fetch_chart_json(ticker, days=3665, seconds=None, full=False): """Yahoo's daily chart for the last `days`, or with full=True its entire history (cached separately). Raises RuntimeError when Yahoo cannot be reached or refuses.""" - if seconds is UNSET: - seconds = config_cache_seconds + if seconds is None: + seconds = config.prefs.cache_seconds now = datetime.datetime.now() cacheFile = "finbot_yahoo_history_" + ticker + ("_max" if full else "") + ".json" - if config_cache and (cache := util.read_cache(cacheFile, seconds)): + if config.prefs.cache and (cache := util.read_cache(cacheFile, seconds)): return cache cookie, crumb = credentials() start = '0' if full else str(int((now - datetime.timedelta(days=days)).timestamp())) url = f'https://query1.finance.yahoo.com/v8/finance/chart/{ticker}?period1={start}&period2={int(now.timestamp())}&interval=1d&crumb={crumb}' - print("Fetching", net.redact(url), file=sys.stderr) if debug else None + print("Fetching", net.redact(url), file=sys.stderr) if config.prefs.debug else None try: - r = net.get(url, headers={**HEADERS, 'Content-type': 'application/json', 'Cookie': cookie}, timeout=config_http_timeout) + r = net.get(url, headers={**HEADERS, 'Content-type': 'application/json', 'Cookie': cookie}, timeout=config.prefs.http_timeout) except Exception as e: raise RuntimeError(f"Yahoo unreachable for {ticker}: {net.redact(e)}") from e if r.status_code != 200: raise RuntimeError(f"{r.status_code} error for {ticker} from Yahoo's chart API") print('↓', sep=' ', end='', flush=True) data = r.json() - if config_cache: + if config.prefs.cache: util.json_write(cacheFile, data) return data @@ -484,7 +484,7 @@ def historic_high(ticker): raise RuntimeError(f"no high/low history for {ticker}") highrow = df.iloc[df['High'].argmax()] lowrow = df.iloc[df['Low'].argmin()] - if debug: + if config.prefs.debug: print(ticker, "High", highrow['Date'], round(highrow['High'], 2), file=sys.stderr) print(ticker, "Low", lowrow['Date'], round(lowrow['Low'], 2), file=sys.stderr) return round(highrow['High'], 2), round(lowrow['Low'], 2) diff --git a/milestone.py b/milestone.py index 5ccbd3d6..cc782bf2 100755 --- a/milestone.py +++ b/milestone.py @@ -2,7 +2,7 @@ import sys from itertools import groupby -from lib.config import * +from lib import config from lib import webhook from lib import util from lib import yahoo @@ -35,10 +35,10 @@ def prepare_milestone_payload(service, market_data): emoji = util.flag_from_ticker(ticker) records[ticker] = {'record_high': max(oldhigh, newhigh), 'record_low': min(oldlow, newlow)} # all-time: a 52-week high below it is no record if newhigh > oldhigh: - print("DEBUG", ticker, newhigh, "is higher than", oldhigh) if debug else None + print("DEBUG", ticker, newhigh, "is higher than", oldhigh) if config.prefs.debug else None payloadhigh.append(f"{emoji} {title} ({ticker_link})") if newlow < oldlow: - print("DEBUG", ticker, newlow, "is lower than", oldlow) if debug else None + print("DEBUG", ticker, newlow, "is lower than", oldlow) if config.prefs.debug else None payloadlow.append(f"{emoji} {title} ({ticker_link})") payloadlow.sort() payloadhigh.sort() diff --git a/performance.py b/performance.py index dc684740..577e6cfc 100755 --- a/performance.py +++ b/performance.py @@ -2,7 +2,7 @@ import sys -from lib.config import * +from lib import config from lib import sharesight from lib import webhook from lib import util @@ -31,11 +31,11 @@ def benchmark(ticker, market, text, past_days, service): link = util.finance_link(ticker, market, service=service, days=past_days, brief=True, text=text) return f"{util.get_emoji(percent)} {link} {util.signed_percent(percent, 2)}", percent -def report(service, past_days=UNSET, portfolio_select=None, interactive=False, performance=None): +def report(service, past_days=None, portfolio_select=None, interactive=False, performance=None): """(payload, chart) of the performance report for one service: each portfolio and the benchmarks, best first. performance: gather()'s, fetched when not given.""" - if past_days is UNSET: - past_days = config_past_days + if past_days is None: + past_days = config.prefs.past_days if performance is None: performance = gather(past_days, portfolio_select) lines = [] # (percent, text), sorted into the payload below @@ -61,9 +61,9 @@ def report(service, past_days=UNSET, portfolio_select=None, interactive=False, p graph = util.rows_chart(chart_rows, "Performance", f"% change {'' if period == 'today' else 'over '}{period}", value_fmt=lambda v: util.signed_percent(v, 2)) return payload, graph -def lambda_handler(chat_id=None, past_days=UNSET, service=None, user='', portfolio_select=None, interactive=False): - if past_days is UNSET: - past_days = config_past_days +def lambda_handler(chat_id=None, past_days=None, service=None, user='', portfolio_select=None, interactive=False): + if past_days is None: + past_days = config.prefs.past_days performance = gather(past_days, portfolio_select) for service, url, chat in webhook.destinations(interactive, service, chat_id): payload, graph = report(service, past_days, portfolio_select, interactive, performance) diff --git a/price.py b/price.py index 37be6dae..a4b95de2 100755 --- a/price.py +++ b/price.py @@ -3,7 +3,7 @@ import sys import datetime -from lib.config import * +from lib import config from lib import sharesight from lib import webhook from lib import util @@ -15,10 +15,10 @@ def gather(specific_stock=None, days=None): Sharesight has none for (the watchlist) their Yahoo history, fetched together up front.""" tickers = [util.transform_to_yahoo(specific_stock)] if specific_stock else util.get_holdings_and_watchlist() market_data = yahoo.fetch(tickers) - if config_demote_volatile: # trigger_bar compares market caps, in US dollars + if config.prefs.demote_volatile: # trigger_bar compares market caps, in US dollars yahoo.add_usd_market_caps(market_data) # Sharesight reports a holding's performance only for the time it was held: held for 1Y and asked for 5Y, it gives 1Y - if days and (not specific_stock or config_performance_use_sharesight): + if days and (not specific_stock or config.prefs.performance_use_sharesight): for data in sharesight.get_performance_wrapper(days).values(): for holding in data['report']['holdings']: ticker = util.transform_to_yahoo(holding['instrument']['code'], holding['instrument']['market_code']) @@ -26,40 +26,40 @@ def gather(specific_stock=None, days=None): market_data[ticker]['percent_change_period'] = float(holding['capital_gain_percent']) elif ticker in tickers: print("Notice: price.py:", ticker, "has no data", file=sys.stderr) - if not specific_stock and not config_performance_use_sharesight: + if not specific_stock and not config.prefs.performance_use_sharesight: yahoo.prefetch_history([t for t in market_data if 'percent_change_period' not in market_data[t]]) return tickers, market_data def needs_beta(quote): """Whether trigger_bar must know this ticker's beta: a small cap between US$150M and US$1B, which counts as volatile when its beta is over 1.5. The beta is only in Yahoo's details.""" - return config_demote_volatile and 1.5e8 <= quote.get('market_cap_usd', float('inf')) < 1e9 + return config.prefs.demote_volatile and 1.5e8 <= quote.get('market_cap_usd', float('inf')) < 1e9 def trigger_bar(ticker, quote, threshold, details=None): """The move this ticker needs before it alone sends a scheduled report: more for leveraged ETFs (demote_leveraged) and volatile small caps (demote_volatile; their market caps in US dollars), else the threshold itself. details: Yahoo's details of the tickers that need_beta, fetched together; without it this ticker's are fetched here.""" title = quote['profile_title'].lower() - if config_demote_leveraged and ('2x' in title or '3x' in title): + if config.prefs.demote_leveraged and ('2x' in title or '3x' in title): return threshold * 1.3 cap = quote.get('market_cap_usd') - if config_demote_volatile and cap is not None and cap < 1e9: + if config.prefs.demote_volatile and cap is not None and cap < 1e9: if cap < 1e7: - return threshold * config_volatility_multiplier * 1.3 + return threshold * config.prefs.volatility_multiplier * 1.3 if cap < 1.5e8: - return threshold * config_volatility_multiplier + return threshold * config.prefs.volatility_multiplier beta = (yahoo.fetch_detail(ticker) if details is None else details).get(ticker, {}).get('beta') if beta is None or beta > 1.5: - return threshold * config_volatility_multiplier + return threshold * config.prefs.volatility_multiplier return threshold -def report(service, threshold=UNSET, user='', specific_stock=None, interactive=False, midsession=False, premarket=False, interday=False, +def report(service, threshold=None, user='', specific_stock=None, interactive=False, midsession=False, premarket=False, interday=False, days=None, close=False, top=None, market=None): """(payload, chart) of the price report for one service: the moves of `threshold` percent or more (or the top and bottom `top`), today's, over `days`, at the close, mid-session or pre-market. A scheduled report goes out only when one ticker passes its trigger_bar; it then lists every ticker past the threshold. market: gather()'s (tickers, market data), fetched when not given.""" - if threshold is UNSET: - threshold = config_price_percent + if threshold is None: + threshold = config.prefs.price_percent tickers, market_data = market or gather(specific_stock, days) now = datetime.datetime.now() rows = [] # (ticker, percent, report line without its percent) @@ -88,10 +88,10 @@ def report(service, threshold=UNSET, user='', specific_stock=None, interactive=F elif days: percent = quote.get('percent_change_period') # Sharesight's figure, for a holding if percent is None: - if config_performance_use_sharesight: + if config.prefs.performance_use_sharesight: continue if specific_stock: # the one ticker asked for: no history is reported in one line - history, graph = yahoo.price_history(ticker, days, graph=config_graph and webhook.takes_charts(service, interactive), graphCache=False) + history, graph = yahoo.price_history(ticker, days, graph=config.prefs.graph and webhook.takes_charts(service, interactive), graphCache=False) else: try: history, _ = yahoo.price_history(ticker, days, graph=False) @@ -161,10 +161,10 @@ def report(service, threshold=UNSET, user='', specific_stock=None, interactive=F payload = [f"{user}, no price movements meet threshold {threshold:g}%"] return payload, graph -def lambda_handler(chat_id=None, threshold=UNSET, service=None, user='', specific_stock=None, interactive=False, midsession=False, +def lambda_handler(chat_id=None, threshold=None, service=None, user='', specific_stock=None, interactive=False, midsession=False, premarket=False, interday=False, days=None, close=False, top=None): - if threshold is UNSET: - threshold = config_price_percent + if threshold is None: + threshold = config.prefs.price_percent market = gather(specific_stock, days) def buttons(service): # a chart's period buttons: one ticker's chart, and the daily and N-day lists (not the intraday ones) if service != 'telegram': diff --git a/rating.py b/rating.py index 34c0877d..c9c47d9e 100755 --- a/rating.py +++ b/rating.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import sys -from lib.config import * from lib import webhook from lib import util from lib import yahoo diff --git a/reminder.py b/reminder.py index 7205c783..7de0f988 100755 --- a/reminder.py +++ b/reminder.py @@ -4,7 +4,7 @@ import datetime import zoneinfo from dateutil.relativedelta import relativedelta -from lib.config import * +from lib import config from lib import util from lib import webhook @@ -12,20 +12,20 @@ def lambda_handler(): def prepare_finance_calendar_payload(service): payload = [] - tz = zoneinfo.ZoneInfo(config_timezone) + tz = zoneinfo.ZoneInfo(config.prefs.timezone) localtime = datetime.datetime.now(tz) month_and_day = str(localtime.strftime('%m-%d')) # 09-20 year = str(localtime.strftime('%Y')) # 2023 # --- Simple, static one-line reminders loaded from reminders.json --- - for reminder in data_json("reminder.json", []): + for reminder in config.data_json("reminder.json", []): if reminder.get("month_day") != month_and_day: continue if reminder.get("year") and reminder["year"] != year: continue gate_country = reminder.get("gate_country") - if gate_country and config_country_code != gate_country: + if gate_country and config.prefs.country_code != gate_country: continue message = reminder["message"] if "{flag}" in message: @@ -36,7 +36,7 @@ def prepare_finance_calendar_payload(service): payload.append(message) # --- Complex, dynamically-computed reminders (kept in code) --- - if config_country_code == 'AU': + if config.prefs.country_code == 'AU': flag = "🇦🇺" if month_and_day == '06-23': payload.append("💸 Realise capital gains/losses by EOFY June 30" + flag) diff --git a/shorts.py b/shorts.py index 2e44772b..af829caa 100755 --- a/shorts.py +++ b/shorts.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 import sys -from lib.config import * +from lib import config from lib import webhook from lib import util from lib import yahoo from lib import shortman -def lambda_handler(chat_id=None, threshold=UNSET, specific_stock=None, service=None, interactive=False): - if threshold is UNSET: - threshold = config_shorts_percent +def lambda_handler(chat_id=None, threshold=None, specific_stock=None, service=None, interactive=False): + if threshold is None: + threshold = config.prefs.shorts_percent def prepare_shorts_payload(service, market_data): found = [] # (percent, line, chart label) for ticker in tickers: diff --git a/tests/common.py b/tests/common.py index 915e647b..e92773b6 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,6 +1,6 @@ """Shared fixture: points finbot at a temp cache dir and replaces every external service (Telegram, Yahoo, Sharesight, Short Man) with canned data. Nothing here touches the network.""" -import collections, io, os, sys, tempfile, time, unittest +import collections, dataclasses, io, os, sys, tempfile, time, unittest import pandas as pd from unittest import mock @@ -10,7 +10,7 @@ os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok', telegramChatID='1') import bot # first, and once: it monkey-patches with gevent, which must happen before requests imports ssl and never mid-test -from lib import config, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports +from lib import config, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports, words import performance, shorts, price, reminder, cal, trades, milestone, rating REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), @@ -51,19 +51,22 @@ def patch(self, target, attr, value): p.start() self.addCleanup(p.stop) + def env(self, **changes): + """Startup settings for this test: config.env with these fields changed.""" + self.patch(config, 'env', dataclasses.replace(config.env, **changes)) + + def prefs(self, **changes): + """Preferences for this test: config.prefs with these fields changed (what a reload would do).""" + self.patch(config, 'prefs', dataclasses.replace(config.prefs, **changes)) + def setUp(self): fake = mock.Mock(status_code=200, text='{}') fake.json.return_value = {'ok': True, 'result': {'message_id': 1, 'chat': {'id': 5}}} self.patch(__import__('requests').Session, 'request', mock.Mock(return_value=fake)) # no real network traffic tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) - self.patch(util, 'config_var_dir', tmp.name) - self.patch(util, 'config_cache_dir', tmp.name) - self.patch(config, 'config_var_dir', tmp.name) - self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) + self.env(data_dir=tmp.name, cache_dir=tmp.name, webhooks={'telegram': 'https://api.telegram.org/botX/'}) self.patch(bot, '_updates_seen', collections.OrderedDict()) # each test starts with no update taken - for m in (performance, shorts, price, cal, trades, milestone, rating, worker, reminder, telegram): - self.patch(m, 'webhooks', webhook.webhooks) self.sent = [] self.markups = [] # reply_markup of each photo sent self.errors = [] diff --git a/tests/test_config.py b/tests/test_config.py index 431ab57a..b1b1cde2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,5 @@ """The two config files: finbot.env (the startup file, loaded by the launcher) and preferences.conf (read by the bot, reloadable).""" -import ast, contextlib, inspect, io, os, re, subprocess, sys, tempfile, unittest +import ast, contextlib, dataclasses, io, os, re, subprocess, sys, tempfile, unittest from pathlib import Path from unittest import mock from lib import config @@ -9,13 +9,17 @@ import performance, price, shorts, trades REPO = Path(config.__file__).resolve().parent.parent -DEFAULTS = {'config_past_days': 28, 'config_future_days': 30, 'config_price_percent': 5.0, 'config_shorts_percent': 15, - 'config_cache_seconds': 43200, 'config_performance_cache_seconds': 1800, 'config_graph': True, 'config_http_timeout': 18, - 'config_chunk_maxlines': 20, 'config_country_code': 'AU', 'config_timezone': 'Australia/Melbourne', 'config_cache': True, - 'config_alliterate': False, 'config_hyperlink': True, 'config_hyperlinkFooter': True, 'config_hyperlinkProvider': 'google', - 'config_demote_volatile': True, 'config_demote_leveraged': True, 'config_volatility_multiplier': 2.0, 'config_exclude_portfolios': [], - 'config_include_portfolios': [], 'config_trades_use_yahoo': True, 'config_performance_use_sharesight': False, - 'config_print_headers': False, 'config_min_analysts': 7, 'debug': False} +DEFAULTS = {'past_days': 28, 'future_days': 30, 'price_percent': 5.0, 'shorts_percent': 15, 'cache_seconds': 43200, + 'performance_cache_seconds': 1800, 'graph': True, 'http_timeout': 18, 'chunk_maxlines': 20, 'country_code': 'AU', + 'timezone': 'Australia/Melbourne', 'cache': True, 'alliterate': False, 'hyperlink': True, 'hyperlinkFooter': True, + 'hyperlinkProvider': 'google', 'demote_volatile': True, 'demote_leveraged': True, 'volatility_multiplier': 2.0, 'exclude_portfolios': (), + 'include_portfolios': (), 'trades_use_yahoo': True, 'performance_use_sharesight': False, 'print_headers': False, 'min_analysts': 7, + 'debug': False} + + +def fields(prefs): + """A Prefs as {name: value}, without the file's own lines.""" + return {name: getattr(prefs, name) for name in config.PREFERENCE_KEYS} def keys_in(path): @@ -38,13 +42,13 @@ def test_secrets_are_startup_keys(self): def test_the_preference_defaults_are_the_old_defaults(self): values = config.preference_values(lambda name, default=None: default) - self.assertEqual(values, DEFAULTS) + self.assertEqual(fields(values), DEFAULTS) def test_templates_have_every_key_and_the_real_defaults(self): self.assertEqual(sorted(keys_in(REPO / 'defaults' / 'preferences.conf')), sorted(config.PREFERENCE_KEYS)) self.assertEqual(sorted(keys_in(REPO / 'defaults' / 'finbot.env')), sorted(config.STARTUP_KEYS)) prefs = config.read_preferences(REPO / 'defaults' / 'preferences.conf') - self.assertEqual(config.preference_values(config._getenv_for(prefs)), DEFAULTS) # the template changes nothing + self.assertEqual(fields(config.preference_values(config._getenv_for(prefs))), DEFAULTS) # the template changes nothing startup = config.read_startup_file(REPO / 'defaults' / 'finbot.env') config.startup_values(lambda name, default=None: startup.get(name, default)) # and is valid as it stands self.assertFalse((REPO / 'defaults' / 'env.example').exists()) @@ -101,14 +105,14 @@ def test_invalid_values_are_named(self): def test_booleans_take_the_usual_words_and_refuse_a_typo(self): for word, value in (('True', True), ('yes', True), ('ON', True), ('1', True), ('false', False), ('No', False), ('off', False), ('0', False)): with self.subTest(word=word): - self.assertIs(config.preference_values(config._getenv_for({'graph': word}))['config_graph'], value) + self.assertIs(config.preference_values(config._getenv_for({'graph': word})).graph, value) with self.assertRaises(config.ConfigError) as caught: # it used to mean False without a word config.preference_values(config._getenv_for({'graph': 'Ture'})) self.assertIn("graph='Ture' is not true or false", caught.exception.errors[0]) def test_a_blank_value_means_the_default(self): values = config.preference_values(config._getenv_for({'cache': '', 'future_days': '', 'country_code': ''})) - self.assertEqual((values['config_cache'], values['config_future_days'], values['config_country_code']), (True, 30, 'AU')) + self.assertEqual((values.cache, values.future_days, values.country_code), (True, 30, 'AU')) def test_unreadable_file_is_an_error_naming_it(self): path = Path(tempfile.mkdtemp(), 'preferences.conf') @@ -120,15 +124,15 @@ def test_unreadable_file_is_an_error_naming_it(self): def test_precedence_file_then_environment_then_default(self): with mock.patch.dict(os.environ, {'future_days': '5', 'past_days': '4'}): values = config.preference_values(config._getenv_for({'future_days': '30'})) - self.assertEqual((values['config_future_days'], values['config_past_days'], values['config_shorts_percent']), (30, 4, 15)) + self.assertEqual((values.future_days, values.past_days, values.shorts_percent), (30, 4, 15)) def test_the_old_multiplier_name_is_gone_everywhere(self): with mock.patch.dict(os.environ, {'volatile_multiplier': '3'}): - self.assertEqual(config.preference_values(config._getenv_for({}))['config_volatility_multiplier'], 2.0) # not read any more + self.assertEqual(config.preference_values(config._getenv_for({})).volatility_multiplier, 2.0) # not read any more with self.assertRaises(config.ConfigError) as caught: config.check_startup_environment() self.assertIn('volatile_multiplier was renamed to volatility_multiplier', caught.exception.errors[0]) - self.assertEqual(config.preference_values(config._getenv_for({'volatility_multiplier': '4'}))['config_volatility_multiplier'], 4.0) + self.assertEqual(config.preference_values(config._getenv_for({'volatility_multiplier': '4'})).volatility_multiplier, 4.0) def test_startup_file_linting(self): path = Path(tempfile.mkdtemp(), 'finbot.env') @@ -159,22 +163,18 @@ def back_to_defaults(self): def write(self, text): self.file.write_text(text) - def test_a_change_reaches_config_and_every_module_that_copied_it(self): + def test_a_change_is_seen_at_once(self): self.write('future_days=45\nprice_percent=7\nshorts_percent=12\nexclude_portfolios=1156263\nalliterate=1\nhyperlinkProvider=yahoo\n' 'hyperlinkFooter=false\ndebug=1\ngraph=0\ndemote_volatile=0\nvolatility_multiplier=3\npast_days=14\nmin_analysts=3\n') + before = config.prefs changes = config.reload() self.assertEqual(len(changes), 13) - self.assertEqual(reports.config_min_analysts, 3) # .buy, .sell and .forward pe read it self.assertIn('future_days 30 -> 45', changes) - want = {'config_future_days': 45, 'config_past_days': 14, 'config_price_percent': 7.0} - for module in (config, cal, worker, price, trades, performance, shorts, sharesight, yahoo, util): - for name, value in want.items(): - if hasattr(module, name): - self.assertEqual(getattr(module, name), value, f'{module.__name__}.{name}') - self.assertEqual(worker.config_future_days, 45) - self.assertEqual((config.config_exclude_portfolios, config.config_alliterate, config.config_hyperlinkProvider, config.config_hyperlinkFooter), - (['1156263'], True, 'yahoo', False)) - self.assertEqual((worker.debug, config.debug, yahoo.config_graph, price.config_demote_volatile, price.config_volatility_multiplier), (True, True, False, False, 3.0)) + self.assertIsNot(config.prefs, before) # one new object: no request sees half a reload + self.assertEqual((config.prefs.future_days, config.prefs.past_days, config.prefs.price_percent, config.prefs.min_analysts), (45, 14, 7.0, 3)) + self.assertEqual((config.prefs.exclude_portfolios, config.prefs.alliterate, config.prefs.hyperlinkProvider, config.prefs.hyperlinkFooter), + (('1156263',), True, 'yahoo', False)) + self.assertEqual((config.prefs.debug, config.prefs.graph, config.prefs.demote_volatile, config.prefs.volatility_multiplier), (True, False, False, 3.0)) self.assertEqual(config.reload(), []) # nothing more to change def test_a_value_removed_from_the_file_goes_back_to_its_default(self): @@ -182,10 +182,10 @@ def test_a_value_removed_from_the_file_goes_back_to_its_default(self): config.reload() self.write('# empty\n') self.assertEqual(config.reload(), ['future_days 45 -> 30']) - self.assertEqual(cal.config_future_days, 30) + self.assertEqual(config.prefs.future_days, 30) def test_defaults_that_follow_a_preference_are_read_when_called(self): - cache_file = Path(config.config_cache_dir, 'finbot_test_reload.json') + cache_file = Path(config.env.cache_dir, 'finbot_test_reload.json') cache_file.write_text('{"a": 1}') self.addCleanup(cache_file.unlink) past = cache_file.stat().st_mtime - 100 @@ -205,7 +205,7 @@ def test_an_error_applies_nothing(self): self.write(text) with self.assertRaises(config.ConfigError): config.reload() - self.assertEqual((config.config_future_days, cal.config_future_days), (45, 45)) + self.assertEqual(config.prefs.future_days, 45) def test_a_missing_file_is_an_error_and_keeps_the_running_values(self): self.write('future_days=45\n') @@ -214,33 +214,27 @@ def test_a_missing_file_is_an_error_and_keeps_the_running_values(self): with self.assertRaises(config.ConfigError) as caught: config.reload() self.assertIn('does not exist; nothing reloaded', caught.exception.errors[0]) - self.assertEqual(cal.config_future_days, 45) + self.assertEqual(config.prefs.future_days, 45) self.file.write_text('') - def test_signature_defaults_are_the_sentinel_not_a_frozen_value(self): - sites = [(cal.lambda_handler, 'days'), (trades.lambda_handler, 'days'), (performance.lambda_handler, 'past_days'), - (price.lambda_handler, 'threshold'), (shorts.lambda_handler, 'threshold'), (sharesight.get_trades, 'days'), - (sharesight.get_performance, 'config_cache_seconds'), (sharesight.get_performance_wrapper, 'days'), (util.read_cache, 'maxSeconds'), - (util.read_binary_cache, 'maxSeconds'), (yahoo.fetch_detail, 'seconds'), (yahoo.price_history, 'seconds'), - (yahoo.price_history, 'graph'), (yahoo.fetch_chart_json, 'seconds')] - for func, param in sites: - self.assertIs(inspect.signature(func).parameters[param].default, config.UNSET, f'{func.__module__}.{func.__name__}({param})') - - def test_no_function_default_or_module_constant_freezes_a_preference(self): - names = set(config.PREFERENCE_NAMES) + def test_nothing_copies_the_config_or_freezes_a_preference(self): + """Modules read config.prefs. when they run, so that a reload is seen: no star import (which copied every name into + each module), and no function default or module constant computed from a preference.""" + def reads_prefs(node): + return any(isinstance(n, ast.Attribute) and n.attr == 'prefs' and isinstance(n.value, ast.Name) and n.value.id == 'config' for n in ast.walk(node)) offenders = [] - for path in sorted([*(REPO / 'lib').glob('*.py'), *REPO.glob('*.py')]): - if path.name == 'config.py': - continue + for path in sorted([*(REPO / 'lib').glob('*.py'), *REPO.glob('*.py'), *(REPO / 'util').glob('*.py')]): tree = ast.parse(path.read_text()) for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == 'lib.config': + offenders.append(f'{path.name}:{node.lineno} from lib.config import {", ".join(a.name for a in node.names)}') if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): for default in [*node.args.defaults, *[d for d in node.args.kw_defaults if d]]: - if {n.id for n in ast.walk(default) if isinstance(n, ast.Name)} & names: + if reads_prefs(default): offenders.append(f'{path.name}:{node.lineno} default of {node.name}') - for node in tree.body: # module-level constants copied from a preference - if isinstance(node, ast.Assign) and {n.id for n in ast.walk(node.value) if isinstance(n, ast.Name)} & names: - offenders.append(f'{path.name}:{node.lineno} module constant {[t.id for t in node.targets if isinstance(t, ast.Name)]}') + for node in tree.body: + if isinstance(node, ast.Assign) and reads_prefs(node.value): + offenders.append(f'{path.name}:{node.lineno} module constant') self.assertEqual(offenders, []) def test_a_job_process_runs_on_the_snapshot_not_the_edited_file(self): @@ -251,7 +245,7 @@ def test_a_job_process_runs_on_the_snapshot_not_the_edited_file(self): self.write('future_days=99\n') # edited, no reload sent data = tempfile.mkdtemp() Path(data, 'preferences.conf').write_text('future_days=99\n') - show = "from lib import config as c; print(c.config_future_days)" + show = "from lib import config as c; print(c.prefs.future_days)" run = lambda **extra: subprocess.run([sys.executable, '-c', show], cwd=REPO, capture_output=True, text=True, check=True, env={**{k: v for k, v in os.environ.items() if k not in config.PREFERENCE_KEYS and k != config.PASSED}, 'DATA_DIR': data, **extra}).stdout.split()[-1] self.assertEqual(run(**env), '45') # the parent's values, and the file is not read @@ -273,7 +267,7 @@ def popen(args, **kw): class Startup(unittest.TestCase): - def run_config(self, code='from lib import config as c; print(c.config_future_days)', prefs=None, **env): + def run_config(self, code='from lib import config as c; print(c.prefs.future_days)', prefs=None, **env): data = tempfile.mkdtemp() if prefs is not None: Path(data, 'preferences.conf').write_text(prefs) @@ -338,14 +332,12 @@ def test_a_leftover_env_in_the_checkout_refuses_to_start(self): def test_a_discord_webhook_uses_the_native_endpoint(self): for url in ('https://discord.com/api/webhooks/1/abc', 'https://discord.com/api/webhooks/1/abc/slack/'): # the old setup's suffix values = config.startup_values(lambda name, default=None: url if name == 'discord_webhook' else default) - self.assertEqual(values['webhooks']['discord'], 'https://discord.com/api/webhooks/1/abc') + self.assertEqual(values.webhooks['discord'], 'https://discord.com/api/webhooks/1/abc') def test_slack_commands_without_a_bot_token_are_warned_about(self): - with mock.patch.object(config, 'config_slackOutgoingWebhook', 'https://example.com/slack'): - with mock.patch.object(config, 'config_slackBotToken', False): - self.assertEqual(config.startup_warnings(), ['slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply']) - with mock.patch.object(config, 'config_slackBotToken', 'xoxb-1'): - self.assertEqual(config.startup_warnings(), []) + for token, warnings in (('', ['slackOutgoingWebhook is set but slackBotToken is not: Slack commands will get no reply']), ('xoxb-1', [])): + with mock.patch.object(config, 'env', dataclasses.replace(config.env, slackOutgoingWebhook='https://example.com/slack', slackBotToken=token)): + self.assertEqual(config.startup_warnings(), warnings) def test_startup_report_is_a_short_count_never_names_or_values(self): with mock.patch.dict(os.environ, {'slack_webhook': 'https://secret.example/abc', 'port': '5000'}, clear=True): @@ -379,7 +371,7 @@ def test_reload_all_reloads_preferences_and_the_schedule_independently(self): bot.reload_all() self.assertIn('ERROR config:', err.getvalue()) self.assertEqual(scheduler.reload.call_count, 2) - self.assertEqual(cal.config_future_days, 45) + self.assertEqual(config.prefs.future_days, 45) def test_reload_with_the_scheduler_off_still_reloads_preferences(self): file = Path(tempfile.mkdtemp(), 'preferences.conf') @@ -388,7 +380,7 @@ def test_reload_with_the_scheduler_off_still_reloads_preferences(self): with mock.patch.object(config, 'preferences_path', str(file)), mock.patch.object(bot, 'scheduler', None): with contextlib.redirect_stderr(io.StringIO()): bot.reload_all() - self.assertEqual(cal.config_future_days, 11) + self.assertEqual(config.prefs.future_days, 11) class Tools(unittest.TestCase): diff --git a/tests/test_datadir.py b/tests/test_datadir.py index f59b65af..ba8ce810 100644 --- a/tests/test_datadir.py +++ b/tests/test_datadir.py @@ -16,21 +16,21 @@ def run_config(code, **env): class DataDirTests(FinbotCase): def test_default_unchanged(self): - out = run_config("from lib import config as c; print(c.config_var_dir, c.config_cache_dir)") + out = run_config("from lib import config as c; print(c.env.data_dir, c.env.cache_dir)") self.assertEqual(out.split()[-2:], ['var', 'var/cache']) def test_data_dir_moves_var_and_cache(self): with tempfile.TemporaryDirectory() as d: - out = run_config("from lib import config as c; print(c.config_var_dir, c.config_cache_dir)", DATA_DIR=d) + out = run_config("from lib import config as c; print(c.env.data_dir, c.env.cache_dir)", DATA_DIR=d) self.assertEqual(out.split()[-2:], [d, d + '/cache']) self.assertTrue(os.path.isdir(d + '/cache')) def test_config_reads_environment_only(self): - out = run_config("import sys; from lib import config as c; print(c.config_price_percent, 'dotenv' in sys.modules)", price_percent='3.3') + out = run_config("import sys; from lib import config as c; print(c.prefs.price_percent, 'dotenv' in sys.modules)", price_percent='3.3') self.assertEqual(out.split()[-2:], ['3.3', 'False']) def test_state_directory_fallback(self): - show = "from lib import config as c; print(c.config_var_dir, c.config_cache_dir, c.os.environ['MPLCONFIGDIR'])" + show = "from lib import config as c; print(c.env.data_dir, c.env.cache_dir, c.os.environ['MPLCONFIGDIR'])" with tempfile.TemporaryDirectory() as d: e = {k: v for k, v in os.environ.items() if k not in ('DATA_DIR', 'MPLCONFIGDIR')} def run(**env): @@ -49,11 +49,11 @@ def test_util_import_sets_mplconfigdir_without_home(self): def test_data_file_override_and_fallback(self): name = 'finbot_adr.json' self.assertEqual(config.data_file(name), str(REPO / 'defaults' / name)) - Path(config.config_var_dir, name).write_text('{}') - self.assertEqual(config.data_file(name), config.config_var_dir + '/' + name) + Path(config.env.data_dir, name).write_text('{}') + self.assertEqual(config.data_file(name), config.env.data_dir + '/' + name) def test_a_data_file_is_parsed_again_only_when_it_changes(self): - path = Path(config.config_var_dir, 'finbot_adr.json') + path = Path(config.env.data_dir, 'finbot_adr.json') path.write_text('{"A": "B"}') self.assertEqual(config.data_json('finbot_adr.json'), {'A': 'B'}) with mock.patch('json.load', side_effect=AssertionError('parsed again')): @@ -64,12 +64,12 @@ def test_a_data_file_is_parsed_again_only_when_it_changes(self): def test_sync_defaults(self): self.assertIn(('finbot_adr.json', 'would copy'), sync_defaults.sync()) - self.assertFalse(Path(config.config_var_dir, 'finbot_adr.json').exists()) # dry run + self.assertFalse(Path(config.env.data_dir, 'finbot_adr.json').exists()) # dry run sync_defaults.sync(apply=True) - self.assertTrue(Path(config.config_var_dir, 'finbot_adr.json').exists()) - self.assertTrue(Path(config.config_var_dir, 'preferences.conf').exists()) - self.assertFalse(Path(config.config_var_dir, 'finbot.env').exists()) # the startup file is never copied into the data dir - mine = Path(config.config_var_dir, 'finbot_adr.json') + self.assertTrue(Path(config.env.data_dir, 'finbot_adr.json').exists()) + self.assertTrue(Path(config.env.data_dir, 'preferences.conf').exists()) + self.assertFalse(Path(config.env.data_dir, 'finbot.env').exists()) # the startup file is never copied into the data dir + mine = Path(config.env.data_dir, 'finbot_adr.json') mine.write_text('{"mine": 1}') sync_defaults.sync(apply=True) self.assertEqual(mine.read_text(), '{"mine": 1}') # never overwritten without --force @@ -79,9 +79,9 @@ def test_sync_defaults(self): self.assertEqual(Path(str(mine) + '.bak').read_text(), '{"mine": 1}') def test_sync_defaults_to_another_destination(self): - other = Path(config.config_var_dir, 'elsewhere') + other = Path(config.env.data_dir, 'elsewhere') self.assertIn(('finbot_adr.json', 'would copy'), sync_defaults.sync(dest=other)) self.assertFalse(other.exists()) # dry run sync_defaults.sync(apply=True, dest=other) self.assertTrue((other / 'finbot_adr.json').exists()) - self.assertFalse(Path(config.config_var_dir, 'finbot_adr.json').exists()) # the data dir is untouched + self.assertFalse(Path(config.env.data_dir, 'finbot_adr.json').exists()) # the data dir is untouched diff --git a/tests/test_edges.py b/tests/test_edges.py index 46992e06..dc212421 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -141,12 +141,12 @@ def test_a_ratio_command_reads_its_ticker_as_the_other_commands_do(self): self.assertIn('Top 15 tracked stocks by pe ratio', str(self.command('.pe top '))) # trailing space and all def test_hello_leaves_the_word_list_as_it_was(self): - before = list(worker.verb) + before = list(words.verb) for _ in range(30): line = self.command('.hello')[0][1][0] self.assertNotIn("('", line) # a verb, not a tuple of them self.assertTrue(line.endswith(' you, U! 😇'), line) - self.assertEqual(worker.verb, before) + self.assertEqual(words.verb, before) self.assertEqual(self.command('hi there'), []) # hello by name only: "hi @bot" @@ -177,8 +177,10 @@ def test_malformed_body_is_refused_not_a_crash(self): self.assertEqual(self.errors, []) def test_strangers_cannot_post_into_the_chats(self): - self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') - self.patch(bot, 'config_slackOutgoingToken', 'stok') + self.env(slackOutgoingWebhook='https://example.com/slack') + + self.env(slackOutgoingToken='stok') + for path, body, headers, status in (('/', b'', {}, '404 Not Found'), ('/wp-login.php', b'log=x&pwd=y', {}, '404 Not Found'), ('/telegram', b'log=x', {}, '403 Forbidden'), ('/telegram', {'message': {}}, {'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN': 'wrong'}, '403 Forbidden'), ('/slack', b'', {}, '400 Bad Request'), ('/slack', b'token=stok', {}, '400 Bad Request'), ('/slack', {'type': 'event_callback'}, {}, '403 Forbidden'), @@ -238,8 +240,10 @@ class SlackEvents(FinbotCase): def setUp(self): super().setUp() - self.patch(bot, 'config_slackOutgoingWebhook', 'https://example.com/slack') - self.patch(bot, 'config_slackOutgoingToken', 'stok') + self.env(slackOutgoingWebhook='https://example.com/slack') + + self.env(slackOutgoingToken='stok') + self.requests = [] self.patch(worker, 'process_request', lambda *a: self.requests.append(a)) @@ -319,7 +323,8 @@ def test_a_failed_send_logs_no_token(self): def refuse(url, **kwargs): raise ConnectionError(f"Max retries exceeded with url: {url} (headers {kwargs.get('headers')})") self.patch(webhook.net, 'post', refuse) - self.patch(webhook, 'config_slackBotToken', 'xoxb-SECRET') + self.env(slackBotToken='xoxb-SECRET') + log = self.stderr_of(lambda: webhook.write('telegram', self.SECRET_URL, 'hi', '1')) log += self.stderr_of(lambda: webhook.write('slack', 'https://hooks.slack.com/services/T0/B0/SECRETpath', 'hi', 'C1')) self.assertIn('Failure sending', log) diff --git a/tests/test_finbot.py b/tests/test_finbot.py index fa8c9b2f..76ae706d 100644 --- a/tests/test_finbot.py +++ b/tests/test_finbot.py @@ -37,7 +37,7 @@ def test_zoneinfo_offset_is_not_lmt(self): self.assertEqual(datetime.datetime(2022, 8, 18, tzinfo=tz).utcoffset(), datetime.timedelta(hours=10)) def test_reminder_runs(self): - with mock.patch.object(reminder, 'data_json', lambda name, default=None: []): + with mock.patch.object(config, 'data_json', lambda name, default=None: []): reminder.lambda_handler() def test_bot_handler(self): diff --git a/tests/test_jobs.py b/tests/test_jobs.py index c65df849..5d9e6dc3 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -150,7 +150,8 @@ class CleanupJob(FinbotCase): def test_removes_only_old_finbot_cache_files(self): folder = tempfile.mkdtemp() self.addCleanup(lambda: __import__('shutil').rmtree(folder, ignore_errors=True)) - self.patch(cleanup, 'config_cache_dir', folder) + self.env(cache_dir=folder) + old, new = time.time() - 40 * 86400, time.time() - 5 * 86400 for name, stamp in (('finbot_old.json', old), ('finbot_new.json', new), ('other_old.json', old), ('finbot_old.png', old)): path = os.path.join(folder, name) @@ -164,7 +165,8 @@ def test_removes_only_old_finbot_cache_files(self): def test_a_temp_file_being_written_is_left_alone(self): folder = tempfile.mkdtemp() self.addCleanup(lambda: __import__('shutil').rmtree(folder, ignore_errors=True)) - self.patch(cleanup, 'config_cache_dir', folder) + self.env(cache_dir=folder) + for name, stamp in (('finbot_a.json.x1.tmp', time.time() - 5), ('finbot_b.json.x2.tmp', time.time() - 40 * 86400)): Path(folder, name).write_text('x') os.utime(Path(folder, name), (stamp, stamp)) @@ -172,13 +174,14 @@ def test_a_temp_file_being_written_is_left_alone(self): self.assertEqual(os.listdir(folder), ['finbot_a.json.x1.tmp']) def test_missing_cache_dir_is_fine(self): - self.patch(cleanup, 'config_cache_dir', '/nonexistent/finbot-cache') + self.env(cache_dir='/nonexistent/finbot-cache') + self.assertEqual(cleanup.lambda_handler(30), 0) class SchedulerFlag(unittest.TestCase): def flag(self, **env): - code = 'from lib import config; print(config.config_scheduler)' + code = 'from lib import config; print(config.env.scheduler)' e = {k: v for k, v in os.environ.items() if k != 'scheduler'} e.update(env) repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -244,7 +247,7 @@ def test_a_run_missed_within_the_catch_up_window_fires_once(self): self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 40)]) # 21:20 is older than 5 minutes: skipped def test_a_corrupt_state_file_is_reported_and_the_schedule_starts_afresh(self): - Path(util.config_var_dir, jobs.STATE_FILE).write_text('{"*/20 * * * Mon-Fri|UTC|trades.py": "2026-10-13T21:') # cut off mid-write + Path(config.env.data_dir, jobs.STATE_FILE).write_text('{"*/20 * * * Mon-Fri|UTC|trades.py": "2026-10-13T21:') # cut off mid-write s = self.scheduler() self.assertEqual((s.last, len(self.errors)), ({}, 1)) self.assertIn('cannot read finbot_jobs.json', self.errors[0]) @@ -389,7 +392,7 @@ def test_the_scheduler_runs_a_daily_job_once_across_the_change(self): file.write_text('CRON_TZ=Australia/Melbourne\n0 9 * * * trades.py\n') clock = Clock(self.mel(2026, 10, 3, 12).astimezone(UTC)) s = jobs.Scheduler(lambda: file, clock=clock, zone=UTC, runner=lambda *a: 0) - with mock.patch.object(util, 'config_var_dir', tempfile.mkdtemp()), contextlib.redirect_stderr(io.StringIO()): + with mock.patch.object(config, 'env', dataclasses.replace(config.env, data_dir=tempfile.mkdtemp())), contextlib.redirect_stderr(io.StringIO()): self.assertTrue(s.load()) fired = [] while clock.now < self.mel(2026, 10, 5, 12): diff --git a/tests/test_providers.py b/tests/test_providers.py index 43736735..04f7da63 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -218,11 +218,12 @@ def test_the_chart_request_carries_the_cookie_of_its_crumb(self): def test_with_the_cache_off_no_chart_is_written(self): self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) - self.patch(yahoo, 'config_cache', False) + self.prefs(cache=False) + self.http(yahoo, Resp(self.chart([100.0] * 30 + [110.0]))) table, image = yahoo.price_history('T1', 7, graph=True) self.assertIsNotNone(image) - self.assertEqual([name for name in os.listdir(util.config_cache_dir) if name.startswith('finbot_graph_')], []) + self.assertEqual([name for name in os.listdir(config.env.cache_dir) if name.startswith('finbot_graph_')], []) def test_price_series_raises_on_failure_and_on_empty_history(self): self.http(yahoo, Resp(status=500)) @@ -242,10 +243,13 @@ def test_portfolios_from_real_response(self): def test_portfolio_exclusion_and_inclusion(self): self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_portfolios.json'))) - self.patch(sharesight, 'config_exclude_portfolios', ['445825']) + self.prefs(exclude_portfolios=['445825']) + self.assertEqual(sharesight.get_portfolios(), {'RobSMSF': 684141}) - self.patch(sharesight, 'config_exclude_portfolios', []) - self.patch(sharesight, 'config_include_portfolios', ['445825']) + self.prefs(exclude_portfolios=[]) + + self.prefs(include_portfolios=['445825']) + self.assertEqual(sharesight.get_portfolios(), {'Rob': 445825}) def test_portfolio_failures_raise(self): @@ -306,7 +310,7 @@ def get(url, **kwargs): return calls def age(self, cache_file, seconds): - path = os.path.join(util.config_cache_dir, cache_file) + path = os.path.join(config.env.cache_dir, cache_file) old = os.path.getmtime(path) - seconds os.utime(path, (old, old)) @@ -322,12 +326,12 @@ def test_refreshes_before_the_ttl_runs_out(self): calls = self.serve_sharesight() sharesight.warm()[0] calls.clear() - self.age(sharesight.performance_cache_file(684141, 0), 0.95 * sharesight.config_performance_cache_seconds) # 95% of its lifetime + self.age(sharesight.performance_cache_file(684141, 0), 0.95 * config.prefs.performance_cache_seconds) # 95% of its lifetime self.assertEqual(sharesight.warm()[0], 1) self.assertEqual(len(calls), 1) self.assertIn('684141/performance', calls[0]) calls.clear() - self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) + self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * config.prefs.cache_seconds) self.assertEqual(sharesight.warm()[0], 1) self.assertTrue(calls[0].endswith('/portfolios')) @@ -350,7 +354,7 @@ def test_holdings_wrapper_is_served_entirely_from_the_warmed_cache(self): def test_failed_refresh_keeps_the_old_cache(self): self.serve_sharesight() sharesight.warm()[0] - self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * sharesight.config_cache_seconds) + self.age(sharesight.PORTFOLIOS_CACHE, 0.95 * config.prefs.cache_seconds) self.http(sharesight, ConnectionError('down')) with self.assertRaises(RuntimeError): sharesight.warm()[0] @@ -380,11 +384,12 @@ def is_set(self): return len(self.waits) >= 4 def wait(self, seconds): self.waits.append(seconds) if len(self.waits) == 2: - sharesight.config_cache = True # what a reload that switches it on does + config.prefs = dataclasses.replace(config.prefs, cache=True) # what a reload that switches it on does passes = [] self.patch(sharesight, 'warm', lambda: (passes.append(1), (0, {}))[1]) self.patch(sharesight, 'next_due', lambda portfolios=None: 3000) - self.patch(sharesight, 'config_cache', False) + self.prefs(cache=False) + stop = Stop() sharesight.keep_warm(idle=60, stop=stop) self.assertEqual((stop.waits, len(passes)), ([60, 60, 3000, 3000], 2)) # idle while off: no Sharesight request every 5 seconds @@ -418,7 +423,7 @@ def test_next_due_is_90_percent_of_the_ttl(self): self.assertEqual(sharesight.next_due(), 0) # cold cache sharesight.warm()[0] due = sharesight.next_due() - self.assertAlmostEqual(due, 0.9 * min(sharesight.config_cache_seconds, sharesight.config_performance_cache_seconds), delta=5) + self.assertAlmostEqual(due, 0.9 * min(config.prefs.cache_seconds, config.prefs.performance_cache_seconds), delta=5) def test_refresh_flag_bypasses_a_fresh_cache(self): calls = self.serve_sharesight() @@ -460,8 +465,9 @@ def test_error_line_redacts_secrets_and_truncates(self): def test_error_reports_are_escaped_for_telegram_and_slack(self): sent = [] self.patch(webhook, 'write', lambda service, url, text, *a, **k: sent.append((service, text))) - self.patch(webhook, 'webhooks', {'telegram': 'https://api.telegram.org/botX/', 'slack': 'https://hooks.slack.com/x', 'discord': 'https://discord.com/x/slack'}) - self.patch(webhook, 'config_telegramChatID', '5') + self.env(webhooks={'telegram': 'https://api.telegram.org/botX/', 'slack': 'https://hooks.slack.com/x', 'discord': 'https://discord.com/x/slack'}) + self.env(telegramChatID='5') + error = RuntimeError("404 for ZZ at https://x/?a=1&crumb=abc; '<' not supported between ") with contextlib.redirect_stderr(io.StringIO()): REAL_REPORT_ERROR(error, context='.compare ') @@ -513,14 +519,17 @@ def upload_steps(self): return [Resp({'ok': True, 'upload_url': self.UPLOAD_URL, 'file_id': 'F1'}), Resp(None, text='OK - 3'), Resp({'ok': True, 'files': [{'id': 'F1'}]})] def test_where_charts_can_go(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + self.env(slackBotToken='xoxb-test') + cases = (('telegram',), ('telegram', False), ('slack',), ('slack', False), ('discord', False)) self.assertEqual([webhook.takes_charts(*case) for case in cases], [True, True, True, False, True]) # Slack: replies, not scheduled reports - self.patch(webhook, 'config_slackBotToken', False) + self.env(slackBotToken=False) + self.assertFalse(webhook.takes_charts('slack')) def test_slack_replies_upload_their_charts(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + self.env(slackBotToken='xoxb-test') + for cmd, heading in (('.beta', '*Beta over 1.5'), ('.price', '*5% at close'), ('.compare T1 T2', '*')): with self.subTest(cmd=cmd): calls = self.slack_answers(*self.upload_steps()) @@ -538,7 +547,8 @@ def test_slack_replies_upload_their_charts(self): self.assertNotIn(share['data']['initial_comment'].split('\n'), [payload for _, payload in self.sent]) # not sent again as text def test_a_slack_upload_that_fails_goes_out_as_text(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + self.env(slackBotToken='xoxb-test') + good = self.upload_steps() text = [('text', ['*Heading*', 'line 2'])] for outcomes, expect in (([Resp({'ok': False, 'error': 'missing_scope'})], text), ([ConnectionError('down')], text), @@ -554,8 +564,9 @@ def test_a_slack_upload_that_fails_goes_out_as_text(self): self.assertEqual(self.sent, expect) def test_scheduled_reports_to_slack_stay_text(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') - webhook.webhooks['slack'] = 'https://hooks.slack.com/services/x' # an incoming webhook cannot upload + self.env(slackBotToken='xoxb-test') + + self.env(webhooks={**config.env.webhooks, 'slack': 'https://hooks.slack.com/services/x'}) # an incoming webhook cannot upload sent = [] self.patch(webhook, 'sendPhoto', lambda chat, image, caption, service, **k: sent.append((service, 'chart'))) self.patch(webhook, 'payload_wrapper', lambda service, url, payload, *a, **k: sent.append((service, 'text')) or []) @@ -566,12 +577,13 @@ def test_scheduled_reports_to_slack_stay_text(self): self.assertEqual(sorted(set(sent)), [('slack', 'text'), ('telegram', 'chart')]) def test_slack_without_a_bot_token_gets_text_and_no_chart_is_drawn(self): - self.patch(webhook, 'config_slackBotToken', False) + self.env(slackBotToken=False) + def refuse(*a, **k): raise AssertionError('a chart was drawn for Slack') for name in ('rows_chart', 'pie_chart', 'bar_graph', 'shorts_chart'): self.patch(util, name, refuse) - webhook.webhooks['slack'] = 'https://hooks.slack.com/services/x' + self.env(webhooks={**config.env.webhooks, 'slack': 'https://hooks.slack.com/services/x'}) for cmd in ('.beta', '.pie Rob', '.buy', '.sell', '.peg', '.price', '.price top', '.price T1 7d', '.performance 7d', '.shorts', '.history T1', '.compare T1 T2'): with self.subTest(cmd=cmd): self.sent.clear(); self.errors.clear() @@ -582,8 +594,10 @@ def refuse(*a, **k): def test_the_graph_preference_off_sends_no_chart_anywhere(self): """graph=False means no charts, as the README says; it used to hold back only the price-line charts.""" - self.patch(webhook, 'config_graph', False) - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + self.prefs(graph=False) + + self.env(slackBotToken='xoxb-test') + posts = [] self.patch(webhook.net, 'post', lambda url, **k: posts.append(url) or Resp({'ok': True, 'result': True})) for service, chat in (('telegram', '55'), ('slack', 'C1')): @@ -621,7 +635,7 @@ class DiscordWebhook(FinbotCase): def setUp(self): super().setUp() - self.patch(webhook, 'webhooks', {'discord': self.URL}) + self.env(webhooks={'discord': self.URL}) self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) self.posts = [] @@ -667,7 +681,8 @@ def setUp(self): self.patch(webhook.net, 'post', lambda url, **kwargs: self.posts.append((url, kwargs)) or Resp({'ok': True})) def test_link_previews_are_turned_off_in_the_body(self): - self.patch(webhook, 'config_slackBotToken', 'xoxb-test') + self.env(slackBotToken='xoxb-test') + webhook.write('slack', 'https://slack.com/api/chat.postMessage', 'hi', 'C1') webhook.write('slack', 'https://hooks.slack.com/services/T/B/x', 'hi') self.assertEqual(len(self.posts), 2) @@ -677,7 +692,8 @@ def test_link_previews_are_turned_off_in_the_body(self): self.assertNotIn('unfurl_links', kwargs['headers']) def test_a_reply_without_a_bot_token_is_logged_not_sent(self): - self.patch(webhook, 'config_slackBotToken', False) + self.env(slackBotToken=False) + err = io.StringIO() with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): for cmd in ('.help', '.price', '.compare T1 T2'): @@ -686,7 +702,8 @@ def test_a_reply_without_a_bot_token_is_logged_not_sent(self): self.assertEqual(err.getvalue().count('ERROR slack: a reply to channel C1 needs slackBotToken in finbot.env; not sent'), 3) def test_the_slack_api_says_what_is_missing(self): - self.patch(webhook, 'config_slackBotToken', False) + self.env(slackBotToken=False) + with self.assertRaisesRegex(RuntimeError, 'Slack files.getUploadURLExternal: needs slackBotToken in finbot.env'): webhook.slack_api('files.getUploadURLExternal', filename='chart.png', length=1) # it used to be a TypeError self.assertEqual(self.posts, []) @@ -697,8 +714,9 @@ class TelegramWebhook(FinbotCase): def setUp(self): super().setUp() - self.patch(telegram, 'webhooks', {'telegram': 'https://api.telegram.org/botX/'}) - self.patch(telegram, 'config_telegramOutgoingWebhook', 'https://example.com/telegram') + self.env(webhooks={'telegram': 'https://api.telegram.org/botX/'}) + self.env(telegramOutgoingWebhook='https://example.com/telegram') + def test_register_unregisters_registers_and_names_the_bot(self): calls = [] diff --git a/tests/test_reports.py b/tests/test_reports.py index 084ea115..b5745ab4 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -95,17 +95,19 @@ def order(action): def test_a_small_cap_quoted_in_won_needs_the_bigger_move_too(self): """demote_volatile compares market caps in US dollars: unconverted, a small cap quoted in won was never small.""" self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | ({'regularMarketPrice': 1380.0} if t == 'KRW=X' else {'currency': 'KRW', 'market_cap': 1e11}) for t in ts}) - self.patch(price, 'config_demote_volatile', True) + self.prefs(demote_volatile=True) + quote = price.gather()[1]['T1'] self.assertAlmostEqual(quote['market_cap_usd'], 1e11 / 1380.0) # US$72M - self.assertEqual(price.trigger_bar('T1', quote, 5), 5 * price.config_volatility_multiplier) + self.assertEqual(price.trigger_bar('T1', quote, 5), 5 * config.prefs.volatility_multiplier) def test_the_small_caps_betas_are_fetched_together(self): """A scheduled price report asks for the beta of each small cap that moved (US$150M to US$1B): together, not one by one.""" calls = [] self.patch(yahoo, 'fetch_details', lambda tickers, strict=False: calls.append(sorted(tickers)) or {t: market_entry(t) for t in tickers}) self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: self.fail('a beta fetched on its own')) - self.patch(price, 'config_demote_volatile', True) + self.prefs(demote_volatile=True) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'market_cap': 5e8} for t in ts}) price.lambda_handler() self.assertEqual(len(calls), 1) @@ -125,7 +127,7 @@ def listed(action): self.assertIn('forward pe ratio (7+ analysts)', heading) self.assertEqual(listed('negative forward pe')[1], ['T3']) self.assertIn('Co T2', str(reports.prepare_value_payload('telegram', 'forward pe', 'T2'))) # one ticker asked for: shown - self.patch(reports, 'config_min_analysts', 13) # the one setting moves .buy and .forward pe together + self.prefs(min_analysts=13) # the one setting moves .buy and .forward pe together self.assertEqual(reports.prepare_value_payload('telegram', 'forward pe')[0], ['No tracked stocks with 13+ analysts have a forward pe ratio']) self.assertEqual(reports.prepare_rating_payload('telegram', 'buy')[0], ['No tracked stocks rated buy by 13+ analysts']) @@ -216,7 +218,7 @@ def test_earnings_and_dividends(self): def test_earnings_skip_an_adr_whose_primary_listing_yahoo_does_not_know(self): """T1 has no earnings date, so its primary listing is looked up; Yahoo knowing nothing of it used to crash the report.""" - self.patch(cal, 'primary_listing', {'T1': 'T1.XX'}) + self.patch(cal, 'primary_listings', lambda: {'T1': 'T1.XX'}) self.patch(yahoo, 'fetch', lambda ts: {t: {k: v for k, v in market_entry(t).items() if not (t == 'T1' and k == 'earnings_date')} for t in ts}) self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {} if t == 'T1.XX' else {t: market_entry(t)}) cal.lambda_handler(event='earnings') @@ -290,8 +292,9 @@ def test_an_interactive_rating_records_nothing(self): def test_slack_webhook_without_a_bot_token_gets_scheduled_reports(self): """Scheduled reports reach Slack through its incoming webhook, which takes no channel and no token. They used to send Telegram's chat id along, which made write() need slackBotToken and crash without one, before Telegram got its copy.""" - self.patch(webhook, 'config_slackBotToken', False) - webhook.webhooks['slack'] = 'https://hooks.slack.com/services/T/B/x' + self.env(slackBotToken=False) + + self.env(webhooks={**config.env.webhooks, 'slack': 'https://hooks.slack.com/services/T/B/x'}) self.patch(webhook, 'payload_wrapper', REAL_PAYLOAD_WRAPPER) self.patch(webhook, 'sendPhoto', REAL_SEND_PHOTO) posts = [] @@ -307,7 +310,7 @@ def post(url, **kwargs): self.assertTrue(any(host == 'api.telegram.org' for host, _, _ in posts)) def test_nowhere_to_post_is_an_error(self): - self.patch(webhook, 'webhooks', {}) + self.env(webhooks={}) with self.assertRaisesRegex(RuntimeError, 'nowhere to post'): price.lambda_handler() @@ -422,7 +425,7 @@ def test_unknown_or_missing_portfolio_lists_known(self): self.assertIn('Rob', sent[0][1]) def test_a_service_that_takes_no_images_gets_text(self): - self.patch(webhook, 'config_slackBotToken', False) # Slack replies carry charts only with the bot token + self.env(slackBotToken=False) # Slack replies carry charts only with the bot token worker.Request('slack', 'C1', '@u', 'U', BOT).deliver(['x'], {'pie': True, 'rows': [('a', 1.0)], 'title': 't'}) self.assertEqual(self.sent[-1][0], 'text') diff --git a/tests/test_util.py b/tests/test_util.py index 2b9793a8..2dac74ad 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -96,7 +96,7 @@ def test_json_roundtrip_and_missing_file(self): def test_a_failed_write_leaves_the_old_file_and_the_umask_alone(self): util.json_write('state.json', {'a': 1}, persist=True) - path = Path(util.config_var_dir, 'state.json') + path = Path(config.env.data_dir, 'state.json') previous = os.umask(0o027) self.addCleanup(os.umask, previous) def disk_full(*a, **k): @@ -106,10 +106,10 @@ def disk_full(*a, **k): with self.assertRaises(OSError): util.json_write('state.json', {'a': 2}, persist=True) self.assertEqual(json.loads(path.read_text()), {'a': 1}) # whole or not at all - self.assertEqual(os.listdir(util.config_var_dir), ['state.json']) # no temp file left behind + self.assertEqual(os.listdir(config.env.data_dir), ['state.json']) # no temp file left behind with mock.patch.object(util.os, 'replace', disk_full), self.assertRaises(OSError): util.write_binary_cache('img.png', io.BytesIO(b'png')) - self.assertEqual(os.listdir(util.config_var_dir), ['state.json']) + self.assertEqual(os.listdir(config.env.data_dir), ['state.json']) self.assertEqual(os.umask(0o027), 0o027) # the umask was not touched util.json_write('state.json', {'a': 3}, persist=True) self.assertEqual((json.loads(path.read_text()), oct(path.stat().st_mode & 0o777)), ({'a': 3}, '0o640')) diff --git a/trades.py b/trades.py index 7e0fdcd1..90600758 100755 --- a/trades.py +++ b/trades.py @@ -3,17 +3,18 @@ import random, sys import datetime -from lib.config import * +from lib import config +from lib import words from lib import sharesight from lib import webhook from lib import util from lib import yahoo -def lambda_handler(chat_id=None, days=UNSET, service=None, user='', portfolio_select=None, interactive=False): +def lambda_handler(chat_id=None, days=None, service=None, user='', portfolio_select=None, interactive=False): """The buys and sells of the past `days`, in every portfolio or the one named: on a scheduled run only those not reported before. Raises sharesight.PortfolioNotFound for a name that is none of them.""" - if days is UNSET: - days = config_past_days + if days is None: + days = config.prefs.past_days state_file = "finbot_sharesight_trades.json" def prepare_trade_payload(service, trades): @@ -30,7 +31,7 @@ def prepare_trade_payload(service, trades): if not wanted: # the usual case on a scheduled run return [] market_data = {} - if config_trades_use_yahoo: + if config.prefs.trades_use_yahoo: tickers = {util.transform_to_yahoo(trade['symbol'], trade['market']) for trade in wanted} # one yahoo.fetch for all try: print("Fetching tickers from Yahoo: ", tickers, file=sys.stderr) @@ -48,7 +49,7 @@ def prepare_trade_payload(service, trades): units = float(trade['quantity']) price = float(trade['price']) currency = trade['brokerage_currency_code'] - if config_trades_use_yahoo: + if config.prefs.trades_use_yahoo: ticker = util.transform_to_yahoo(symbol, market) try: market = market_data[ticker]['profile_exchange'] @@ -94,7 +95,7 @@ def prepare_trade_payload(service, trades): for service, url, chat in webhook.destinations(interactive, service, chat_id): payload = prepare_trade_payload(service, trades) if interactive and not payload: # no trades, or none of them a buy or a sell - payload = [f"{user} No trades {util.days_english(days, 'in the past ')}. {random.choice(noTradesVerb)}"] + payload = [f"{user} No trades {util.days_english(days, 'in the past ')}. {random.choice(words.noTradesVerb)}"] webhook.send(service, url, chat, payload) # write state file diff --git a/util/sync_defaults.py b/util/sync_defaults.py index d5e74657..3716e6cb 100755 --- a/util/sync_defaults.py +++ b/util/sync_defaults.py @@ -11,15 +11,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from lib import config -from lib.config import REPO_DIR def sync(apply=False, force=False, dest=None): """One (file, outcome) per default: copied / would copy, replaced (old copy kept as .bak), or skipped.""" apply = apply or force - dest_dir = Path(dest or config.config_var_dir) + dest_dir = Path(dest or config.env.data_dir) results = [] - for src in sorted((REPO_DIR / 'defaults').iterdir()): + for src in sorted((config.REPO_DIR / 'defaults').iterdir()): if not src.is_file(): continue if src.name == 'finbot.env': @@ -50,7 +49,7 @@ def sync(apply=False, force=False, dest=None): ap.add_argument('--force', action='store_true', help='also replace differing files (keeps .bak); implies --apply') ap.add_argument('--dest', metavar='DIR', help='destination directory (default: the data dir, DATA_DIR or var/)') args = ap.parse_args() - print(f"{REPO_DIR / 'defaults'} -> {Path(args.dest or config.config_var_dir).resolve()}") + print(f"{config.REPO_DIR / 'defaults'} -> {Path(args.dest or config.env.data_dir).resolve()}") for name, outcome in sync(args.apply or args.force, args.force, args.dest): label = {'copied': '', 'skipped - destination identical': '', 'skipped - destination differs': '[differs]'}.get(outcome, f'[{outcome}]') print(f"{name} {label}".rstrip()) From af891b4123065c740e4c19c51c5580b31b54a0bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:32:28 +0000 Subject: [PATCH 08/81] Cache and state files in their own modules: lib/cache.py and lib/state.py util.py's cache_path/read_cache/json_write/json_load family splits by what the file is for. lib/cache.py holds the copies of Yahoo and Sharesight answers and drawn charts, any of which may vanish at any time; lib/state.py holds what must last in DATA_DIR (the watchlist, plans, seen trades and so on), written whole and flushed. state.edit() is the locked read-modify-write for the next step. Output is unchanged: every command, button and scheduled report posts what it did. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 6 ++- cleanup.py | 2 +- lib/cache.py | 104 ++++++++++++++++++++++++++++++++++++++++ lib/charts.py | 19 ++++---- lib/jobs.py | 6 +-- lib/reports.py | 14 +++--- lib/sharesight.py | 39 +++++++-------- lib/state.py | 47 ++++++++++++++++++ lib/util.py | 88 +--------------------------------- lib/worker.py | 5 +- lib/yahoo.py | 37 +++++++------- milestone.py | 5 +- podcasts.py | 5 +- rating.py | 5 +- tests/common.py | 2 +- tests/test_config.py | 8 ++-- tests/test_edges.py | 6 +-- tests/test_jobs.py | 4 +- tests/test_providers.py | 4 +- tests/test_reports.py | 8 ++-- tests/test_util.py | 36 +++++++------- trades.py | 5 +- 22 files changed, 267 insertions(+), 188 deletions(-) create mode 100644 lib/cache.py create mode 100644 lib/state.py diff --git a/CLAUDE.md b/CLAUDE.md index 9e7a1474..da1efbed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,9 +31,11 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - `price.py performance.py trades.py shorts.py cal.py rating.py milestone.py reminder.py podcasts.py cleanup.py`: reports that also run on a schedule; each has `lambda_handler()` and `job(argv)` (its schedule-line argument parser). - `lib/webhook.py`: sending (chunking, photos, Slack upload), markup, escaping, error reporting. `lib/telegram.py`: Bot API calls. -- `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/util.py`: formatting, links, market tables, cache and - state files, all matplotlib rendering. +- `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/util.py`: formatting, links, market tables, all + matplotlib rendering. - `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources, cached in `DATA_DIR/cache`. +- `lib/cache.py`: cache files (any may vanish at any time). `lib/state.py`: state files in `DATA_DIR`, which must last; + change one with `with state.edit(name, default) as data:`, never load-then-save. - `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (preferences.conf, a new object on each reload). `lib/jobs.py` + `lib/jobrun.py`: the scheduler; each run is a child process. - `tests/common.py`: `FinbotCase` (offline fixtures). `self.command('.price')` returns what was sent: `('text', lines)` or diff --git a/cleanup.py b/cleanup.py index d51bf316..78ee9fd6 100755 --- a/cleanup.py +++ b/cleanup.py @@ -6,7 +6,7 @@ def lambda_handler(days=30): """Delete the cache files (finbot_*) that were last written more than `days` days ago (days=0: all of them, a flush), but not - a temp file written in the last minute: that is an atomic write in flight (util.write_file).""" + a temp file written in the last minute: that is an atomic write in flight (cache.write_file).""" now = time.time() cutoff = now - days * 86400 removed = 0 diff --git a/lib/cache.py b/lib/cache.py new file mode 100644 index 00000000..98a15895 --- /dev/null +++ b/lib/cache.py @@ -0,0 +1,104 @@ +"""Cache files in DATA_DIR/cache: copies of Yahoo and Sharesight answers and of drawn charts, each good for so many seconds. +Anything here may be deleted at any time (cleanup.py, a flush); what must last lives in lib/state.py.""" +import io +import json +import os +import sys +import tempfile +import time +from pathlib import Path + +from lib import config + + +def path(name): + """Where a cache file lives.""" + return config.env.cache_dir + '/' + name + + +def age(name): + """Seconds since the cache file was written, or None when it does not exist.""" + try: + return time.time() - os.path.getmtime(path(name)) + except OSError: + return None + + +def _fresh(name, seconds): + """The cache file's path if it was written less than `seconds` ago (the cache_seconds preference when None), else None.""" + if seconds is None: + seconds = config.prefs.cache_seconds + where, written = path(name), age(name) + if written is None: + print("cache miss:", where, file=sys.stderr) if config.prefs.debug else None + return None + if written >= seconds: + print("cache expired:", where, file=sys.stderr) if config.prefs.debug else None + return None + print("cache hit:", where, "TTL:", f"{seconds - written:.0f} s", file=sys.stderr) if config.prefs.debug else None + return where + + +def _read_fresh(name, seconds, read): + """read(path) of the cache file if it is fresh, else None; also None if the file goes (cleanup, a flush) after the check.""" + where = _fresh(name, seconds) + if not where: + return None + try: + return read(Path(where)) + except FileNotFoundError: + return None + + +def read(name, seconds=None): + """A cache file's JSON, or None when it is missing or older than `seconds` (the cache_seconds preference when None).""" + return _read_fresh(name, seconds, lambda where: json.loads(where.read_text(encoding="utf-8"))) + + +def read_bytes(name, seconds=None): + """A cache file's bytes (as a file object), or None when it is missing or older than `seconds`.""" + return _read_fresh(name, seconds, lambda where: io.BytesIO(where.read_bytes())) + + +def load(name): + """A cache file's JSON whatever its age, or None: for an answer that carries its own expiry (the Sharesight token).""" + where = Path(path(name)) + try: + return json.loads(where.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + + +def write(name, data): + """Write a cache file's JSON. See write_file.""" + where = path(name) + print("cache write:", where, file=sys.stderr) if config.prefs.debug else None + write_file(where, json.dumps(data, indent=4).encode('utf-8')) + + +def write_bytes(name, data): + """Write a cache file's bytes from a file object (a drawn chart).""" + data.seek(0) + write_file(path(name), data.getvalue()) + + +def write_file(where, data, sync=False): + """Write `data` (bytes) to `where` whole or not at all, mode 0640: it goes to a temp file beside it, which then replaces the + file, so a failure part way (a full disk, a kill) leaves the old file as it was. sync=True also flushes it to disk, for state + that must survive a power cut. The process umask is left alone.""" + where = os.path.realpath(where) # through a symlink, as an in-place write would + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(where), prefix=os.path.basename(where) + '.', suffix='.tmp') + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + if sync: + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, 0o640) + os.replace(tmp, where) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/lib/charts.py b/lib/charts.py index 124b7276..615206f8 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -5,6 +5,7 @@ import threading from lib import util +from lib import state from lib import webhook from lib import yahoo from lib import reports @@ -28,15 +29,15 @@ def _ref(tickers): if len(('c|h|w|' + inline).encode()) <= MAX_CALLBACK_BYTES: return inline key = hashlib.sha1(inline.encode()).hexdigest()[:10] - refs = util.json_load(REF_FILE, persist=True) or {} + refs = state.load(REF_FILE) or {} if refs.get(key) != tickers: refs[key] = tickers - util.json_write(REF_FILE, refs, persist=True) + state.save(REF_FILE, refs) return '#' + key def resolve(ref): if ref.startswith('#'): - tickers = (util.json_load(REF_FILE, persist=True) or {}).get(ref[1:]) + tickers = (state.load(REF_FILE) or {}).get(ref[1:]) if not tickers: raise KeyError("these buttons have expired, please re-run the command") return tickers @@ -59,9 +60,9 @@ def keyboard_version(): def mark_keyboard(chat_id): """Record that this chat now has the current command keyboard.""" with _keyboard_lock: - state = util.json_load(KEYBOARD_FILE, persist=True) or {} - state[str(chat_id)] = keyboard_version() - util.json_write(KEYBOARD_FILE, state, persist=True) + versions = state.load(KEYBOARD_FILE) or {} + versions[str(chat_id)] = keyboard_version() + state.save(KEYBOARD_FILE, versions) def push_keyboard(chat_id): """Telegram can only change a reply keyboard by sending a message, so send a short one carrying it.""" @@ -75,15 +76,15 @@ def ensure_keyboard(chat_id): if int(chat_id) <= 0: return with _keyboard_lock: - current = (util.json_load(KEYBOARD_FILE, persist=True) or {}).get(str(chat_id)) == keyboard_version() + current = (state.load(KEYBOARD_FILE) or {}).get(str(chat_id)) == keyboard_version() if not current: push_keyboard(chat_id) def refresh_keyboards(): """At startup: update every chat we know of whose keyboard is out of date.""" with _keyboard_lock: - state = util.json_load(KEYBOARD_FILE, persist=True) or {} - for chat_id, version in state.items(): + versions = state.load(KEYBOARD_FILE) or {} + for chat_id, version in versions.items(): if version != keyboard_version(): try: push_keyboard(chat_id) diff --git a/lib/jobs.py b/lib/jobs.py index 602a9ed2..86c6bb0f 100644 --- a/lib/jobs.py +++ b/lib/jobs.py @@ -21,8 +21,8 @@ from croniter import croniter, CroniterBadDateError from lib import config +from lib import state from lib import jobrun -from lib import util from lib import webhook import cal, cleanup, milestone, performance, podcasts, price, rating, reminder, shorts, trades @@ -270,7 +270,7 @@ def load(self): print('schedule: no usable line in', path, '- keeping the current schedule', file=sys.stderr) return False try: - last = util.json_load(STATE_FILE, persist=True) + last = state.load(STATE_FILE) except (OSError, ValueError) as e: # unreadable or not JSON: carry on as if there were no record, the next run rewrites it webhook.report_error(ScheduleError(f'cannot read {STATE_FILE}, starting without a record of past runs: {e}'), context='schedule') last = None @@ -324,7 +324,7 @@ def due(self, now): fired.append((job, latest)) if fired: try: - util.json_write(STATE_FILE, self.last, persist=True) + state.save(STATE_FILE, self.last) except OSError as e: print('schedule: cannot save', STATE_FILE, e, file=sys.stderr) return fired diff --git a/lib/reports.py b/lib/reports.py index 62a4a40a..7fdd7552 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -3,6 +3,8 @@ import datetime import sys from lib import config +from lib import cache +from lib import state from lib import sharesight from lib import util from lib import webhook @@ -60,7 +62,7 @@ def prepare_watchlist(service, user, action=None, ticker=None): ticker_link = util.finance_link(ticker, ticker, service) duplicate = False transformed = False - watchlist = util.json_load('finbot_watchlist.json', persist=True) or [] # absent until the first add + watchlist = state.load('finbot_watchlist.json') or [] # absent until the first add if action == 'add': if ticker in watchlist: duplicate = True @@ -136,7 +138,7 @@ def profile_title_sort(e): # disregards markup in sort command payload.insert(0, f"Hi {user}, I'm currently tracking:") else: payload.append('Watchlist is empty. Try ".watchlist add SYMBOL" to create it') - util.json_write('finbot_watchlist.json', watchlist, persist=True) + state.save('finbot_watchlist.json', watchlist) if action in ('add', 'delete'): payload = payload[:1] # confirmation only; don't list the whole watchlist return payload @@ -166,8 +168,8 @@ def prepare_compare(service, args): cache_key = hashlib.sha1(f"{service}|{','.join(tickers)}|{spec}".encode()).hexdigest()[:16] image_cache_file, caption_cache_file = f"finbot_compare_{cache_key}.png", f"finbot_compare_{cache_key}.json" if config.prefs.cache: - cached_image = util.read_binary_cache(image_cache_file) - cached_caption = util.read_cache(caption_cache_file) + cached_image = cache.read_bytes(image_cache_file) + cached_caption = cache.read(caption_cache_file) if cached_image and cached_caption: return cached_caption, cached_image, tickers, period_days names, exchanges, closes = {}, {}, {} @@ -210,8 +212,8 @@ def prepare_compare(service, args): link = util.finance_link(t, exchanges[t], service, days=span_days, brief=False) caption.append(f"{util.get_emoji(pct)} {names[t]} ({link}): {pct:+,.1f}%") if config.prefs.cache: - util.write_binary_cache(image_cache_file, image) - util.json_write(caption_cache_file, caption) + cache.write_bytes(image_cache_file, image) + cache.write(caption_cache_file, caption) image.seek(0) return caption, image, tickers, period_days diff --git a/lib/sharesight.py b/lib/sharesight.py index 8dd9c159..466ec446 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -4,6 +4,7 @@ import threading import requests from lib import config +from lib import cache from lib import net import lib.util as util @@ -45,13 +46,13 @@ def __call__(self, r): def get_token(): if config.prefs.cache: cacheFile = "finbot_sharesight_token.json" - cache = util.json_load(cacheFile) - if cache: - cache_expiry = datetime.datetime.fromtimestamp(cache['created_at'] + cache['expires_in']) + cached = cache.load(cacheFile) + if cached: + cache_expiry = datetime.datetime.fromtimestamp(cached['created_at'] + cached['expires_in']) ttl = cache_expiry - datetime.datetime.now() if ttl > datetime.timedelta(seconds=20): print("cache hit:", cacheFile, "TTL:", util.td_to_human(ttl), file=sys.stderr) if config.prefs.debug else None - return cache['access_token'] + return cached['access_token'] else: print("cache miss:", cacheFile, file=sys.stderr) if config.prefs.debug else None @@ -59,7 +60,7 @@ def get_token(): data = _request(_post, "https://api.sharesight.com/oauth2/token", 'auth', data=config.env.sharesight_auth) _check(data, 'auth') if config.prefs.cache and 'access_token' in data: - util.json_write(cacheFile, data) + cache.write(cacheFile, data) print("Got a Sharesight token", file=sys.stderr) if config.prefs.debug else None return data['access_token'] @@ -71,14 +72,14 @@ def performance_cache_file(portfolio_id, days): def get_portfolios(refresh=False): """refresh=True skips the cache read and fetches again (the cache is rewritten).""" portfolio_dict = {} - data = util.read_cache(PORTFOLIOS_CACHE, config.prefs.cache_seconds) if config.prefs.cache and not refresh else None + data = cache.read(PORTFOLIOS_CACHE, config.prefs.cache_seconds) if config.prefs.cache and not refresh else None if not data: token = get_token() print("Fetching Sharesight portfolios") data = _request(_get, "https://api.sharesight.com/api/v3/portfolios", 'portfolios', headers={'Content-type': 'application/json'}, auth=BearerAuth(token)) _check(data, 'portfolios') if config.prefs.cache and 'portfolios' in data: - util.json_write(PORTFOLIOS_CACHE, data) + cache.write(PORTFOLIOS_CACHE, data) for portfolio in data['portfolios']: if str(portfolio['id']) in config.prefs.exclude_portfolios: print(portfolio['id'], "(" + portfolio['name'] + ") in exclusion list. Skipping.") @@ -114,9 +115,9 @@ def get_trades(portfolio_name, portfolio_id, days=None): days = config.prefs.past_days if config.prefs.cache: cache_file = "finbot_sharesight_trades_" + str(portfolio_id) + "_" + str(days) + ".json" - cache = util.read_cache(cache_file, 299) # max freq 5 min - if cache: - return cache['trades'] + cached = cache.read(cache_file, 299) # max freq 5 min + if cached: + return cached['trades'] start_date = datetime.datetime.now() - datetime.timedelta(days=days) start_date = start_date.strftime('%Y-%m-%d') # 2022-08-20 token = get_token() @@ -129,7 +130,7 @@ def get_trades(portfolio_name, portfolio_id, days=None): for trade in data['trades']: trade['portfolio'] = portfolio_name # inject custom field if config.prefs.cache and 'trades' in data: - util.json_write(cache_file, data) + cache.write(cache_file, data) return data['trades'] def get_holdings(portfolio_name, portfolio_id): @@ -172,9 +173,9 @@ def get_performance(portfolio_id, days, seconds=None, refresh=False): start_date = start_date.strftime('%Y-%m-%d') # 2023-04-25 cache_file = performance_cache_file(portfolio_id, days) if config.prefs.cache and not refresh: - cache = util.read_cache(cache_file, seconds) - if cache: - return cache + cached = cache.read(cache_file, seconds) + if cached: + return cached token = get_token() endpoint = 'https://api.sharesight.com/api/v3/portfolios/' url = endpoint + str(portfolio_id) + '/performance?grouping=ungrouped&start_date=' + start_date @@ -187,7 +188,7 @@ def get_performance(portfolio_id, days, seconds=None, refresh=False): break _check(data, 'performance') if config.prefs.cache and 'report' in data: - util.json_write(cache_file, data) + cache.write(cache_file, data) return data def get_performance_wrapper(days=None): @@ -207,14 +208,14 @@ def warm(margin=0.9): so a request never has to wait on Sharesight. Returns (the number of fetches made, the portfolios): the caller can hand the portfolios on instead of reading the cached list again.""" fetched = 0 - age = util.cache_age(PORTFOLIOS_CACHE) + age = cache.age(PORTFOLIOS_CACHE) if age is None or age >= config.prefs.cache_seconds * margin: portfolios = get_portfolios(refresh=True) fetched += 1 else: portfolios = get_portfolios() for portfolio_name, portfolio_id in portfolios.items(): - age = util.cache_age(performance_cache_file(portfolio_id, 0)) + age = cache.age(performance_cache_file(portfolio_id, 0)) if age is None or age >= margin * config.prefs.performance_cache_seconds: get_performance(portfolio_id, 0, refresh=True) fetched += 1 @@ -224,10 +225,10 @@ def next_due(margin=0.9, portfolios=None): """Seconds until the soonest warmed cache needs refreshing (0 if one already does). `portfolios` is the list warm() just used; without it the cached list is read.""" waits = [] - age = util.cache_age(PORTFOLIOS_CACHE) + age = cache.age(PORTFOLIOS_CACHE) waits.append(0 if age is None else margin * config.prefs.cache_seconds - age) for portfolio_name, portfolio_id in (get_portfolios() if portfolios is None else portfolios).items(): - age = util.cache_age(performance_cache_file(portfolio_id, 0)) + age = cache.age(performance_cache_file(portfolio_id, 0)) waits.append(0 if age is None else margin * config.prefs.performance_cache_seconds - age) return max(0, min(waits)) diff --git a/lib/state.py b/lib/state.py new file mode 100644 index 00000000..0edb81af --- /dev/null +++ b/lib/state.py @@ -0,0 +1,47 @@ +"""State files in DATA_DIR: what must last from one run to the next (the watchlist, plans, seen trades, ratings, record highs, +the schedule's last runs, chart button refs, keyboard versions, seen podcast episodes). Written whole or not at all, and flushed.""" +import contextlib +import copy +import json +import threading +from pathlib import Path + +from lib import cache +from lib import config + +_locks = {} # file name -> the lock edit() holds while it changes the file +_locks_lock = threading.Lock() + + +def path(name): + """Where a state file lives.""" + return config.env.data_dir + '/' + name + + +def load(name, default=None): + """A state file's JSON, or `default` when there is none yet.""" + where = Path(path(name)) + return json.loads(where.read_text(encoding="utf-8")) if where.is_file() else default + + +def save(name, data): + """Write a state file's JSON, whole or not at all, flushed to disk (cache.write_file).""" + cache.write_file(path(name), json.dumps(data, indent=4).encode('utf-8'), sync=True) + + +def _lock(name): + with _locks_lock: + return _locks.setdefault(name, threading.Lock()) + + +@contextlib.contextmanager +def edit(name, default): + """Change a state file in place: `with state.edit(name, {}) as data: data[key] = value`. The file is read as the block starts + and written when it ends without an error; meanwhile no other edit of the same file runs in this process, so two chats + changing it at once both count. Keep slow work, a network call say, outside the block.""" + with _lock(name): + data = load(name) + if data is None: + data = copy.deepcopy(default) + yield data + save(name, data) diff --git a/lib/util.py b/lib/util.py index 9e6a9c6b..a727495a 100644 --- a/lib/util.py +++ b/lib/util.py @@ -1,15 +1,10 @@ import functools -import os import io import datetime -import json -from pathlib import Path import re -import sys -import tempfile -import time from lib import config # first: sets MPLCONFIGDIR before matplotlib is imported (lazily, by the chart functions) from lib import sharesight +from lib import state TITLE_TRIMS = [ # transform_title: (text, replacement), in order, as each sees what the ones before it made @@ -144,85 +139,6 @@ def flag_from_ticker(ticker): def currency_from_market(market): return MARKETS.get(market, (None, None))[1] -def cache_path(name, persist=False): - """Where a cache file lives, or with persist=True a state file (in the data directory).""" - return (config.env.data_dir if persist else config.env.cache_dir) + "/" + name - -def _fresh_cache(cacheFile, maxSeconds): - """The cache file's path if it was written less than maxSeconds ago (the cache_seconds preference when None), else None.""" - if maxSeconds is None: - maxSeconds = config.prefs.cache_seconds - path, age = cache_path(cacheFile), cache_age(cacheFile) - if age is None: - print("cache miss:", path, file=sys.stderr) if config.prefs.debug else None - return None - if age >= maxSeconds: - print("cache expired:", path, file=sys.stderr) if config.prefs.debug else None - return None - print("cache hit:", path, "TTL:", td_to_human(datetime.timedelta(seconds=maxSeconds - age)), file=sys.stderr) if config.prefs.debug else None - return path - -def _read_fresh(cacheFile, maxSeconds, read): - """read(path) of the cache file if it is fresh, else None; also None if the file goes (cleanup, a flush) after the check.""" - path = _fresh_cache(cacheFile, maxSeconds) - if not path: - return None - try: - return read(Path(path)) - except FileNotFoundError: - return None - -def read_cache(cacheFile, maxSeconds=None): - """A cache file's JSON, or None when it is missing or older than maxSeconds.""" - return _read_fresh(cacheFile, maxSeconds, lambda path: json.loads(path.read_text(encoding="utf-8"))) - -def cache_age(cacheFile): - """Seconds since the cache file was written, or None when it does not exist.""" - try: - return time.time() - os.path.getmtime(cache_path(cacheFile)) - except OSError: - return None - -def write_file(path, data, sync=False): - """Write `data` (bytes) to `path` whole or not at all, mode 0640: it goes to a temp file beside it, which then replaces the file, - so a failure part way (a full disk, a kill) leaves the old file as it was. sync=True also flushes it to disk, for state that - must survive a power cut. The process umask is left alone.""" - path = os.path.realpath(path) # through a symlink, as an in-place write would - fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=os.path.basename(path) + '.', suffix='.tmp') - try: - with os.fdopen(fd, 'wb') as f: - f.write(data) - if sync: - f.flush() - os.fsync(f.fileno()) - os.chmod(tmp, 0o640) - os.replace(tmp, path) - except BaseException: - try: - os.unlink(tmp) - except OSError: - pass - raise - -def json_write(filename, data, persist=False): - """A cache file, or with persist=True a state file in the data directory (also flushed to disk). See write_file.""" - path = cache_path(filename, persist) - if config.prefs.debug: - print("cache write:", path, file=sys.stderr) - write_file(path, json.dumps(data, indent=4).encode('utf-8'), sync=persist) - -def json_load(filename, persist=False): - path = Path(cache_path(filename, persist)) - return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None - -def read_binary_cache(cacheFile, maxSeconds=None): - """A cache file's bytes (as a file object), or None when it is missing or older than maxSeconds.""" - return _read_fresh(cacheFile, maxSeconds, lambda path: io.BytesIO(path.read_bytes())) - -def write_binary_cache(cacheFile, data): - data.seek(0) - write_file(cache_path(cacheFile), data.getvalue()) - def humanUnits(value, decimal_places=2): """1234567 -> '1.23 M'.""" for unit in ['', 'K', 'M', 'B', 'T', 'Q']: @@ -852,7 +768,7 @@ def days_from_human_days(arg): def get_holdings_and_watchlist(): tickers = set(sharesight.get_holdings_wrapper()) - tickers.update(json_load('finbot_watchlist.json', persist=True) or []) # absent until the first add + tickers.update(state.load('finbot_watchlist.json', [])) # absent until the first add if 'GOOG' in tickers and 'GOOGL' in tickers: tickers.remove("GOOGL") return sorted(tickers) diff --git a/lib/worker.py b/lib/worker.py index 300c8d42..74966386 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -7,6 +7,7 @@ import sys import threading from lib import config +from lib import state from lib import words from lib import util from lib import webhook @@ -347,10 +348,10 @@ def on_history(request, m): request.send(caption.split('\n'), image, charts.keyboard('h', [ticker], 'x') if image and request.service == 'telegram' else None) def on_plan(request, m): - plan = util.json_load('finbot_plan.json', persist=True) or {} + plan = state.load('finbot_plan.json') or {} if m.group(1) and not m.group(1).startswith('@'): plan[request.user] = m.group(1) - util.json_write('finbot_plan.json', plan, persist=True) + state.save('finbot_plan.json', plan) def who(user): # a Slack user is a mention (<@U123>), which is markup to keep; elsewhere a handle is text user = user.removeprefix('@') return user if request.service == 'slack' else webhook.escape(user, request.service) diff --git a/lib/yahoo.py b/lib/yahoo.py index 75f13d71..bd9b77fd 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -7,6 +7,7 @@ import zoneinfo import sys from lib import config +from lib import cache from lib import net from lib import util from http.cookies import SimpleCookie @@ -19,9 +20,9 @@ def getCookie(maxAge=1209600): # 14 days # we must always cache this, as crumb must match cookie with _auth: cacheFile = "finbot_yahoo_cookie.json" - cache = util.read_cache(cacheFile, maxAge - 30) # renewed 30 seconds before it expires - if cache: - return cache[0][0] + '=' + cache[0][1] + cached = cache.read(cacheFile, maxAge - 30) # renewed 30 seconds before it expires + if cached: + return cached[0][0] + '=' + cached[0][1] url = 'https://fc.yahoo.com/' # 404, but that is ok because cookie is in response header try: r = net.get(url, headers=HEADERS, timeout=config.prefs.http_timeout) @@ -36,7 +37,7 @@ def getCookie(maxAge=1209600): # 14 days cookie = SimpleCookie() cookie.load(r.headers['Set-Cookie']) cookielist = [[name, morsel.value, int(morsel.get('max-age') or 0)] for name, morsel in cookie.items()] - util.json_write(cacheFile, cookielist) + cache.write(cacheFile, cookielist) print("Got a new Yahoo cookie", file=sys.stderr) rmCrumb() # we must invalidate the non-matching crumb return cookielist[0][0] + '=' + cookielist[0][1] @@ -45,14 +46,14 @@ def getCrumb(seconds=1209600): # 14 days with _auth: cookie = getCookie() cacheFile = "finbot_yahoo_crumb.json" - if config.prefs.cache and (cache := util.read_cache(cacheFile, seconds)): - return cache + if config.prefs.cache and (cached := cache.read(cacheFile, seconds)): + return cached r = _get_first(_hosts('/v1/test/getcrumb'), {**HEADERS, 'Cookie': cookie}) if r is None: print("Exhausted Yahoo API attempts. Returning fallback crumb", file=sys.stderr) return 'jkQEU8yLqxs' if config.prefs.cache: - util.json_write(cacheFile, r.text) + cache.write(cacheFile, r.text) print("Got a new Yahoo crumb", file=sys.stderr) return r.text @@ -65,7 +66,7 @@ def credentials(): def rmCrumb(): """Set the cached crumb aside (as .old): it belongs to the cookie being replaced.""" - crumb = Path(util.cache_path("finbot_yahoo_crumb.json")) + crumb = Path(cache.path("finbot_yahoo_crumb.json")) if crumb.is_file(): crumb.replace(crumb.with_name(crumb.name + '.old')) @@ -146,8 +147,8 @@ def fetch(tickers): return {} tickers = sorted(set(tickers)) # de-dupe cacheFile = "finbot_yahoo_" + hashlib.sha256("_".join(tickers).encode()).hexdigest() + '.json' - if config.prefs.cache and (cache := util.read_cache(cacheFile, 300)): - return cache + if config.prefs.cache and (cached := cache.read(cacheFile, 300)): + return cached cookie, crumb = credentials() r = _get_first(_hosts('/v7/finance/quote?crumb=' + crumb + '&symbols=' + ','.join(tickers)), {**HEADERS, 'Content-type': 'application/json', 'Cookie': cookie}) if r is None: @@ -173,7 +174,7 @@ def fetch(tickers): quote['earnings_date'] = next((stamp for stamp in stamps if stamp > now), stamps[0]) yahoo_output[item['symbol']] = quote if config.prefs.cache: - util.json_write(cacheFile, yahoo_output) + cache.write(cacheFile, yahoo_output) return yahoo_output DETAIL_FIELDS = ( # (key, path in the quoteSummary result[, conversion]); a field Yahoo leaves out, sends as null or garbles is left out @@ -233,7 +234,7 @@ def fetch_detail(ticker, seconds=None): seconds = config.prefs.cache_seconds now = datetime.datetime.now() cacheFile = "finbot_yahoo_detail_" + ticker + '.json' - data = util.read_cache(cacheFile, seconds) if config.prefs.cache else None + data = cache.read(cacheFile, seconds) if config.prefs.cache else None if data: print('.', sep=' ', end='', flush=True) else: @@ -246,7 +247,7 @@ def fetch_detail(ticker, seconds=None): return {} # catches some delisted stocks like "DRNA" data = r.json() if config.prefs.cache: - util.json_write(cacheFile, data) + cache.write(cacheFile, data) result = dig(data, 'quoteSummary', 'result', 0) if result is None: # Yahoo answers result=null for unknown symbols print(ticker + 'x', sep=' ', end='', flush=True, file=sys.stderr) @@ -364,11 +365,11 @@ def price_history(ticker, days=None, seconds=None, graph=None, graphCache=True): label = "Max" if "Max" in percent_dict else "10Y" title = f"{company_name} ({ticker}) {label} {util.signed_percent(percent_dict[label])} Last: {price}" image_cache_file = "finbot_graph_" + ticker + "_" + str(days) + ".png" - if config.prefs.cache and graphCache and (image := util.read_binary_cache(image_cache_file, seconds)): + if config.prefs.cache and graphCache and (image := cache.read_bytes(image_cache_file, seconds)): return percent_dict, image image = util.graph(df, title, stock.get('currency')) if config.prefs.cache: - util.write_binary_cache(image_cache_file, image) + cache.write_bytes(image_cache_file, image) return percent_dict, image def price_series(ticker): @@ -443,8 +444,8 @@ def fetch_chart_json(ticker, days=3665, seconds=None, full=False): seconds = config.prefs.cache_seconds now = datetime.datetime.now() cacheFile = "finbot_yahoo_history_" + ticker + ("_max" if full else "") + ".json" - if config.prefs.cache and (cache := util.read_cache(cacheFile, seconds)): - return cache + if config.prefs.cache and (cached := cache.read(cacheFile, seconds)): + return cached cookie, crumb = credentials() start = '0' if full else str(int((now - datetime.timedelta(days=days)).timestamp())) url = f'https://query1.finance.yahoo.com/v8/finance/chart/{ticker}?period1={start}&period2={int(now.timestamp())}&interval=1d&crumb={crumb}' @@ -458,7 +459,7 @@ def fetch_chart_json(ticker, days=3665, seconds=None, full=False): print('↓', sep=' ', end='', flush=True) data = r.json() if config.prefs.cache: - util.json_write(cacheFile, data) + cache.write(cacheFile, data) return data def chart_json_to_df(chart_json): diff --git a/milestone.py b/milestone.py index cc782bf2..e2748705 100755 --- a/milestone.py +++ b/milestone.py @@ -3,6 +3,7 @@ import sys from itertools import groupby from lib import config +from lib import state from lib import webhook from lib import util from lib import yahoo @@ -16,7 +17,7 @@ def prepare_milestone_payload(service, market_data): payload = [] payloadhigh = [] payloadlow = [] - records = util.json_load('finbot_milestone.json', persist=True) or {} + records = state.load('finbot_milestone.json') or {} for ticker in tickers: try: oldhigh, oldlow = records[ticker]['record_high'], records[ticker]['record_low'] @@ -65,7 +66,7 @@ def prepare_milestone_payload(service, market_data): webhook.send(service, url, chat, payload) if records: - util.json_write('finbot_milestone.json', records, persist=True) + state.save('finbot_milestone.json', records) def job(argv): return 'milestone.py', {} diff --git a/podcasts.py b/podcasts.py index 1d088a55..a2d62f04 100755 --- a/podcasts.py +++ b/podcasts.py @@ -7,6 +7,7 @@ from email.utils import parsedate_to_datetime from datetime import datetime, timezone from lib import net +from lib import state from lib import webhook from lib import util @@ -87,7 +88,7 @@ def fetch_new_episodes(feed_url, seen): def lambda_handler(feed_url): """Post the feed's newest episodes not posted before, then record every new one as seen.""" seen_file = get_seen_filename(feed_url) - seen = set(util.json_load(seen_file, persist=True) or []) + seen = set(state.load(seen_file) or []) try: episodes, guids = fetch_new_episodes(feed_url, seen) except Exception as e: @@ -103,7 +104,7 @@ def lambda_handler(feed_url): duration = f" {episode['duration']}" if episode['duration'] else "" payload.append(f"📣 {podcast_name}: {util.link(episode['url'], title, service)}{duration} ({episode['ago']})") webhook.send(service, url, chat, payload) - util.json_write(seen_file, sorted(seen | guids), persist=True) # last, so that a failure above leaves these episodes for next run + state.save(seen_file, sorted(seen | guids)) # last, so that a failure above leaves these episodes for next run def job(argv): """Arguments of a scheduled run: (label, lambda_handler kwargs). Raises ValueError with the usage on a bad argument.""" diff --git a/rating.py b/rating.py index c9c47d9e..a35662eb 100755 --- a/rating.py +++ b/rating.py @@ -2,6 +2,7 @@ import sys from lib import webhook +from lib import state from lib import util from lib import yahoo @@ -54,7 +55,7 @@ def get_emoji(old, new): # MAIN # tickers = [util.transform_to_yahoo(specific_stock)] if specific_stock else util.get_holdings_and_watchlist() - old = util.json_load('finbot_rating.json', persist=True) + old = state.load('finbot_rating.json') old = old if isinstance(old, dict) else {} # absent until the first run market_data = yahoo.fetch_details(tickers, strict=bool(specific_stock)) @@ -63,7 +64,7 @@ def get_emoji(old, new): webhook.send(service, url, chat, payload) if new and not interactive: # added to what is recorded: a ticker missing from this run keeps its last rating - util.json_write('finbot_rating.json', old | new, persist=True) + state.save('finbot_rating.json', old | new) def job(argv): return 'rating.py', {} diff --git a/tests/common.py b/tests/common.py index e92773b6..0febcfcb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,7 @@ os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok', telegramChatID='1') import bot # first, and once: it monkey-patches with gevent, which must happen before requests imports ssl and never mid-test -from lib import config, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports, words +from lib import cache, config, state, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports, words import performance, shorts, price, reminder, cal, trades, milestone, rating REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), diff --git a/tests/test_config.py b/tests/test_config.py index b1b1cde2..40f0202b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,7 @@ from lib import config from util import check_config, split_env import bot, cal -from lib import reports, sharesight, util, worker, yahoo +from lib import cache, reports, sharesight, util, worker, yahoo import performance, price, shorts, trades REPO = Path(config.__file__).resolve().parent.parent @@ -192,11 +192,11 @@ def test_defaults_that_follow_a_preference_are_read_when_called(self): os.utime(cache_file, (past, past)) # 100 seconds old self.write('cache_seconds=50\n') config.reload() - self.assertIsNone(util.read_cache('finbot_test_reload.json')) # too old for 50 s + self.assertIsNone(cache.read('finbot_test_reload.json')) # too old for 50 s self.write('cache_seconds=200\n') config.reload() - self.assertEqual(util.read_cache('finbot_test_reload.json'), {'a': 1}) - self.assertIsNone(util.read_cache('finbot_test_reload.json', 50)) # an explicit value always wins + self.assertEqual(cache.read('finbot_test_reload.json'), {'a': 1}) + self.assertIsNone(cache.read('finbot_test_reload.json', 50)) # an explicit value always wins def test_an_error_applies_nothing(self): self.write('future_days=45\n') diff --git a/tests/test_edges.py b/tests/test_edges.py index dc212421..366a0cdb 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -71,12 +71,12 @@ class WatchlistActions(FinbotCase): def test_add_duplicate_delete_and_unknown_action(self): add = self.command('.watchlist add MSFT') self.assertTrue(add) - self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), ['MSFT']) + self.assertEqual(state.load('finbot_watchlist.json'), ['MSFT']) again = self.command('.watchlist add MSFT') self.assertNotEqual(add, again) # a duplicate is reported differently from a first add - self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), ['MSFT']) + self.assertEqual(state.load('finbot_watchlist.json'), ['MSFT']) self.command('.watchlist rm MSFT') - self.assertEqual(util.json_load('finbot_watchlist.json', persist=True), []) + self.assertEqual(state.load('finbot_watchlist.json'), []) self.assertIn('not a valid watchlist action', str(self.command('.watchlist frobnicate MSFT'))) diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 5d9e6dc3..55b19333 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -241,7 +241,7 @@ def test_tick_runs_the_job_and_returns_the_wait(self): def test_a_run_missed_within_the_catch_up_window_fires_once(self): first = self.scheduler() first.last = {first.jobs[0].key: at(13, 21, 0).isoformat()} - util.json_write(jobs.STATE_FILE, first.last, persist=True) + state.save(jobs.STATE_FILE, first.last) self.clock.now = at(13, 21, 43) s = self.scheduler() self.assertEqual([t for _, t in s.due(self.clock.now)], [at(13, 21, 40)]) # 21:20 is older than 5 minutes: skipped @@ -253,7 +253,7 @@ def test_a_corrupt_state_file_is_reported_and_the_schedule_starts_afresh(self): self.assertIn('cannot read finbot_jobs.json', self.errors[0]) self.clock.now = at(13, 21, 20) self.assertEqual(len(s.due(self.clock.now)), 1) # runs as usual, and the record is written afresh - self.assertEqual(util.json_load(jobs.STATE_FILE, persist=True), {s.jobs[0].key: at(13, 21, 20).isoformat()}) + self.assertEqual(state.load(jobs.STATE_FILE), {s.jobs[0].key: at(13, 21, 20).isoformat()}) s.reload() # and a reload is no trouble either self.assertEqual(len(self.errors), 1) diff --git a/tests/test_providers.py b/tests/test_providers.py index 04f7da63..d23ac476 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -410,8 +410,8 @@ def test_a_warming_pass_reads_the_cached_portfolio_list_once(self): self.serve_sharesight() sharesight.warm()[0] # fill the caches reads = [] - real = util.read_cache - self.patch(util, 'read_cache', lambda name, *a, **k: (reads.append(name), real(name, *a, **k))[1]) + real = cache.read + self.patch(cache, 'read', lambda name, *a, **k: (reads.append(name), real(name, *a, **k))[1]) fetched, portfolios = sharesight.warm() # what one pass of keep_warm does due = sharesight.next_due(portfolios=portfolios) self.assertEqual((fetched, reads), (0, [sharesight.PORTFOLIOS_CACHE])) diff --git a/tests/test_reports.py b/tests/test_reports.py index b5745ab4..8bdb222d 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -247,12 +247,12 @@ def test_milestone_records_a_ticker_only_with_its_new_values(self): self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) # beyond the 52-week range (120/80): nothing new self.patch(yahoo, 'fetch', lambda ts: {t: {k: v for k, v in market_entry(t).items() if not k.startswith('fiftyTwoWeek')} for t in ts}) milestone.lambda_handler() - self.assertFalse(util.json_load('finbot_milestone.json', persist=True)) + self.assertFalse(state.load('finbot_milestone.json')) self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) self.sent.clear() milestone.lambda_handler() self.assertNotIn('Record high', str(self.sent)) - self.assertEqual(util.json_load('finbot_milestone.json', persist=True)['T1'], {'record_high': 150.0, 'record_low': 50.0}) # all-time + self.assertEqual(state.load('finbot_milestone.json')['T1'], {'record_high': 150.0, 'record_low': 50.0}) # all-time def test_milestone_records_are_all_time(self): """A new 52-week high below the all-time high is no record. The record used to become the 52-week high after the first @@ -267,7 +267,7 @@ def test_milestone_records_are_all_time(self): self.assertEqual('Record high' in str(self.sent), expected) def test_a_52_week_record_kept_before_is_replaced_by_the_history(self): - util.json_write('finbot_milestone.json', {f'T{i}': {'high': 110.0, 'low': 90.0} for i in range(25)}, persist=True) + state.save('finbot_milestone.json', {f'T{i}': {'high': 110.0, 'low': 90.0} for i in range(25)}) self.patch(yahoo, 'historic_high', lambda t: (150.0, 50.0)) milestone.lambda_handler() # 120 beats the old 52-week record of 110, but not the history's 150 self.assertNotIn('Record high', str(self.sent)) @@ -287,7 +287,7 @@ def test_rating_keeps_the_last_rating_of_a_ticker_missing_from_a_run(self): def test_an_interactive_rating_records_nothing(self): rating.lambda_handler(interactive=True, service='telegram', chat_id='5') - self.assertIsNone(util.json_load('finbot_rating.json', persist=True)) + self.assertIsNone(state.load('finbot_rating.json')) def test_slack_webhook_without_a_bot_token_gets_scheduled_reports(self): """Scheduled reports reach Slack through its incoming webhook, which takes no channel and no token. They used to send diff --git a/tests/test_util.py b/tests/test_util.py index 2dac74ad..92a46c59 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -90,37 +90,37 @@ def test_flags_for_known_and_unknown_markets(self): class StateFiles(FinbotCase): def test_json_roundtrip_and_missing_file(self): - self.assertIsNone(util.json_load('absent.json', persist=True)) - util.json_write('state.json', {'a': [1, 2]}, persist=True) - self.assertEqual(util.json_load('state.json', persist=True), {'a': [1, 2]}) + self.assertIsNone(state.load('absent.json')) + state.save('state.json', {'a': [1, 2]}) + self.assertEqual(state.load('state.json'), {'a': [1, 2]}) def test_a_failed_write_leaves_the_old_file_and_the_umask_alone(self): - util.json_write('state.json', {'a': 1}, persist=True) + state.save('state.json', {'a': 1}) path = Path(config.env.data_dir, 'state.json') previous = os.umask(0o027) self.addCleanup(os.umask, previous) def disk_full(*a, **k): raise OSError(28, 'No space left on device') for broken in ('replace', 'fsync'): - with self.subTest(broken=broken), mock.patch.object(util.os, broken, disk_full): + with self.subTest(broken=broken), mock.patch.object(cache.os, broken, disk_full): with self.assertRaises(OSError): - util.json_write('state.json', {'a': 2}, persist=True) + state.save('state.json', {'a': 2}) self.assertEqual(json.loads(path.read_text()), {'a': 1}) # whole or not at all self.assertEqual(os.listdir(config.env.data_dir), ['state.json']) # no temp file left behind - with mock.patch.object(util.os, 'replace', disk_full), self.assertRaises(OSError): - util.write_binary_cache('img.png', io.BytesIO(b'png')) + with mock.patch.object(cache.os, 'replace', disk_full), self.assertRaises(OSError): + cache.write_bytes('img.png', io.BytesIO(b'png')) self.assertEqual(os.listdir(config.env.data_dir), ['state.json']) self.assertEqual(os.umask(0o027), 0o027) # the umask was not touched - util.json_write('state.json', {'a': 3}, persist=True) + state.save('state.json', {'a': 3}) self.assertEqual((json.loads(path.read_text()), oct(path.stat().st_mode & 0o777)), ({'a': 3}, '0o640')) def test_read_cache_missing_or_expired_is_falsy(self): - self.assertFalse(util.read_cache('absent.json', 60)) - util.json_write('old.json', {'a': 1}) - self.assertFalse(util.read_cache('old.json', -1)) # already expired + self.assertFalse(cache.read('absent.json', 60)) + cache.write('old.json', {'a': 1}) + self.assertFalse(cache.read('old.json', -1)) # already expired def test_a_cache_file_that_goes_while_it_is_read_is_a_miss(self): - util.json_write('gone.json', {'a': 1}) + cache.write('gone.json', {'a': 1}) real = Path.read_text def vanish(path, *a, **k): # cleanup or a flush deleted it between the age check and the read if path.name == 'gone.json': @@ -128,19 +128,19 @@ def vanish(path, *a, **k): # cleanup or a flush deleted it between the age check raise FileNotFoundError(path) return real(path, *a, **k) with mock.patch.object(Path, 'read_text', vanish): - self.assertIsNone(util.read_cache('gone.json', 60)) + self.assertIsNone(cache.read('gone.json', 60)) def test_binary_cache_roundtrip(self): - self.assertFalse(util.read_binary_cache('absent.png')) - util.write_binary_cache('img.png', io.BytesIO(b'png-bytes')) - self.assertEqual(util.read_binary_cache('img.png').read(), b'png-bytes') + self.assertFalse(cache.read_bytes('absent.png')) + cache.write_bytes('img.png', io.BytesIO(b'png-bytes')) + self.assertEqual(cache.read_bytes('img.png').read(), b'png-bytes') def test_holdings_and_watchlist_without_a_watchlist_file(self): # first run: no watchlist exists yet self.patch(util.sharesight, 'get_holdings_wrapper', lambda: ['AAPL', 'GOOG', 'GOOGL']) self.patch(util, 'get_holdings_and_watchlist', REAL_GET_HOLDINGS) self.assertEqual(util.get_holdings_and_watchlist(), ['AAPL', 'GOOG']) # GOOGL dropped when GOOG is present - util.json_write('finbot_watchlist.json', ['MSFT'], persist=True) + state.save('finbot_watchlist.json', ['MSFT']) self.assertEqual(util.get_holdings_and_watchlist(), ['AAPL', 'GOOG', 'MSFT']) diff --git a/trades.py b/trades.py index 90600758..b04f489e 100755 --- a/trades.py +++ b/trades.py @@ -4,6 +4,7 @@ import datetime from lib import config +from lib import state from lib import words from lib import sharesight from lib import webhook @@ -89,7 +90,7 @@ def prepare_trade_payload(service, trades): # MAIN # newtrades = set() portfolios = sharesight.pick_portfolios(portfolio_select) - known_trades = set(util.json_load(state_file, persist=True) or ()) + known_trades = set(state.load(state_file) or ()) trades = [trade for name, portfolio_id in portfolios.items() for trade in sharesight.get_trades(name, portfolio_id, days)] for service, url, chat in webhook.destinations(interactive, service, chat_id): @@ -101,7 +102,7 @@ def prepare_trade_payload(service, trades): # write state file if newtrades and not interactive: known_trades.update(newtrades) - util.json_write(state_file, list(known_trades), persist=True) + state.save(state_file, list(known_trades)) def job(argv): return 'trades.py', {} From 1348e87194cd57f2c343abe55e154cff099787b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:41:25 +0000 Subject: [PATCH 09/81] State files change under a lock; the watchlist is an object (lib/watchlist.py) state.edit() reads a state file, lets the block change it and writes it back only if it changed, while no other edit of that file runs in the process. Every read-modify-write uses it: the watchlist, plans, chart button refs, keyboard versions, and the seen trades, ratings, records and podcast episodes, which now add to the file as it is when the run ends instead of writing back what was read when it began. charts' keyboard lock goes. The watchlist: tickers(), find() (Yahoo, with the .AX retry), add() and remove(). A ticker is checked with Yahoo before the file is opened, so two chats adding at once both count; before, both read the list, waited on Yahoo, and the later write dropped the other's add. prepare_watchlist shrinks to phrasing the outcome: it fetches only the ticker it is changing, not the whole list it then threw away. yahoo.resolve() is the one .AX retry, shared with the company profile. Visible changes: the add/remove confirmation links to the stock's exchange (MSFT:NASDAQ, not MSFT:MSFT), and removing a ticker Yahoo no longer knows says it was removed (it was, but the reply said it could not be found). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 2 +- lib/charts.py | 21 ++----- lib/reports.py | 126 +++++++++++------------------------------- lib/state.py | 13 +++-- lib/util.py | 4 +- lib/watchlist.py | 36 ++++++++++++ lib/worker.py | 6 +- lib/yahoo.py | 9 +++ milestone.py | 14 +++-- podcasts.py | 5 +- rating.py | 6 +- tests/common.py | 2 +- tests/test_edges.py | 29 +++++++++- tests/test_reports.py | 7 +++ tests/test_util.py | 28 +++++++++- trades.py | 7 +-- 16 files changed, 176 insertions(+), 139 deletions(-) create mode 100644 lib/watchlist.py diff --git a/CLAUDE.md b/CLAUDE.md index da1efbed..4b9fc585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm matplotlib rendering. - `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources, cached in `DATA_DIR/cache`. - `lib/cache.py`: cache files (any may vanish at any time). `lib/state.py`: state files in `DATA_DIR`, which must last; - change one with `with state.edit(name, default) as data:`, never load-then-save. + change one with `with state.edit(name, default) as data:`, never load-then-save. `lib/watchlist.py`: the watchlist. - `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (preferences.conf, a new object on each reload). `lib/jobs.py` + `lib/jobrun.py`: the scheduler; each run is a child process. - `tests/common.py`: `FinbotCase` (offline fixtures). `self.command('.price')` returns what was sent: `('text', lines)` or diff --git a/lib/charts.py b/lib/charts.py index 615206f8..667741da 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -2,7 +2,6 @@ import hashlib import json import sys -import threading from lib import util from lib import state @@ -29,15 +28,13 @@ def _ref(tickers): if len(('c|h|w|' + inline).encode()) <= MAX_CALLBACK_BYTES: return inline key = hashlib.sha1(inline.encode()).hexdigest()[:10] - refs = state.load(REF_FILE) or {} - if refs.get(key) != tickers: + with state.edit(REF_FILE, {}) as refs: refs[key] = tickers - state.save(REF_FILE, refs) return '#' + key def resolve(ref): if ref.startswith('#'): - tickers = (state.load(REF_FILE) or {}).get(ref[1:]) + tickers = state.load(REF_FILE, {}).get(ref[1:]) if not tickers: raise KeyError("these buttons have expired, please re-run the command") return tickers @@ -52,17 +49,13 @@ def command_keyboard(per_row=3): return {'keyboard': rows, 'resize_keyboard': True, 'is_persistent': True} KEYBOARD_FILE = 'finbot_keyboards.json' # chat id -> version of the command keyboard that chat last received -_keyboard_lock = threading.Lock() - def keyboard_version(): return hashlib.sha1(json.dumps(command_keyboard(), sort_keys=True).encode()).hexdigest()[:8] def mark_keyboard(chat_id): """Record that this chat now has the current command keyboard.""" - with _keyboard_lock: - versions = state.load(KEYBOARD_FILE) or {} + with state.edit(KEYBOARD_FILE, {}) as versions: versions[str(chat_id)] = keyboard_version() - state.save(KEYBOARD_FILE, versions) def push_keyboard(chat_id): """Telegram can only change a reply keyboard by sending a message, so send a short one carrying it.""" @@ -75,16 +68,12 @@ def ensure_keyboard(chat_id): """DMs only (positive chat ids): bring a chat's keyboard up to date if its buttons have changed or it never had them.""" if int(chat_id) <= 0: return - with _keyboard_lock: - current = (state.load(KEYBOARD_FILE) or {}).get(str(chat_id)) == keyboard_version() - if not current: + if state.load(KEYBOARD_FILE, {}).get(str(chat_id)) != keyboard_version(): push_keyboard(chat_id) def refresh_keyboards(): """At startup: update every chat we know of whose keyboard is out of date.""" - with _keyboard_lock: - versions = state.load(KEYBOARD_FILE) or {} - for chat_id, version in versions.items(): + for chat_id, version in state.load(KEYBOARD_FILE, {}).items(): if version != keyboard_version(): try: push_keyboard(chat_id) diff --git a/lib/reports.py b/lib/reports.py index 7fdd7552..c7ca86d1 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -4,7 +4,7 @@ import sys from lib import config from lib import cache -from lib import state +from lib import watchlist from lib import sharesight from lib import util from lib import webhook @@ -57,91 +57,37 @@ def doDelta(inputList): return deltaString def prepare_watchlist(service, user, action=None, ticker=None): - if ticker: - ticker = ticker_orig = util.transform_to_yahoo(ticker) - ticker_link = util.finance_link(ticker, ticker, service) - duplicate = False - transformed = False - watchlist = state.load('finbot_watchlist.json') or [] # absent until the first add - if action == 'add': - if ticker in watchlist: - duplicate = True - else: - watchlist.append(ticker) - if len(watchlist): - market_data = yahoo.fetch(watchlist) - else: - market_data = None - print("") - if action == 'delete': - if ticker in watchlist: - watchlist.remove(ticker) - else: - print(ticker, "not in watchlist") - if action == 'add': - if '.' not in ticker and ticker not in market_data: - watchlist.remove(ticker) - ticker = ticker + '.AX' - transformed = True - ticker_link = util.finance_link(ticker, ticker, service) - print(ticker_orig, "not found. Trying", ticker) - if ticker in watchlist: - print(ticker, "already in watchlist") - duplicate = True - else: - watchlist.append(ticker) - market_data = yahoo.fetch(watchlist) - print("") - if ticker in market_data: - print("found", ticker) - else: - watchlist.remove(ticker) - print(ticker, "not found") - elif ticker not in market_data: - watchlist.remove(ticker) - def described(): # "Full Name (TICKER)" for the ticker being changed - title = ((market_data or {}).get(ticker) or {}).get('profile_title') - return webhook.bold(f"{title} ({ticker_link})" if title else ticker_link, service) - payload = [] - if market_data: - for item in market_data: - flag = util.flag_from_ticker(item) - item_link = util.finance_link(item, market_data.get(item).get('profile_exchange'), service) - profile_title = market_data[item]['profile_title'] - if item == ticker and action == 'delete': - pass - elif item == ticker and action == 'add': # highlight requested item - text = webhook.bold(webhook.italic(f"{flag} {profile_title} ({item_link})", service), service) - payload.append(text) - else: - payload.append(f"{flag} {profile_title} ({item_link})") - def profile_title_sort(e): # disregards markup in sort command - return re.findall('[A-Z].*', e) - payload.sort(key=profile_title_sort) + """The .watchlist reply: the companies watched, or one line on adding ('add') or removing ('delete') a ticker.""" + if action: + return [watchlist_change(service, user, action, util.transform_to_yahoo(ticker))] + watched = watchlist.tickers() + market_data = yahoo.fetch(watched) if watched else {} + lines = sorted((f"{util.flag_from_ticker(t)} {quote['profile_title']} ({util.finance_link(t, quote.get('profile_exchange'), service)})" + for t, quote in market_data.items()), key=lambda line: re.findall('[A-Z].*', line)) # by name, the flag aside + if not lines: + return ['Watchlist is empty. Try ".watchlist add SYMBOL" to create it'] + return [f"Hi {user}, I'm currently tracking:"] + lines + +def watchlist_change(service, user, action, asked): + """The reply to adding or removing `asked` (a Yahoo ticker).""" + def described(ticker, quote): # "Full Name (TICKER)", bold + quote = quote or {} + link = util.finance_link(ticker, quote.get('profile_exchange') or ticker, service) + return webhook.bold(f"{quote['profile_title']} ({link})" if quote.get('profile_title') else link, service) if action == 'delete': - if not market_data or ticker not in market_data: - payload.insert(0, "Beep Boop. I could not find " + webhook.bold(ticker, service) + " to remove it") - else: - payload.insert(0, f"Ok {user}, I deleted " + described()) - elif action == 'add': - if ticker not in market_data: - payload = ["Beep Boop. I could not find " + webhook.bold(ticker_orig, service) + " to add it"] - elif transformed and duplicate: - payload.insert(0, "Beep Boop. I could not find " + webhook.bold(ticker_orig, service) + " and I'm already tracking " + described()) - elif transformed: - payload.insert(0, "Beep Boop. I could not find " + webhook.bold(ticker_orig, service) + " so I added " + described()) - elif duplicate: - payload.insert(0, f"{user}, I'm already tracking " + described()) - else: - payload.insert(0, f"Ok {user}, I added " + described()) - elif not action and payload: - payload.insert(0, f"Hi {user}, I'm currently tracking:") - else: - payload.append('Watchlist is empty. Try ".watchlist add SYMBOL" to create it') - state.save('finbot_watchlist.json', watchlist) - if action in ('add', 'delete'): - payload = payload[:1] # confirmation only; don't list the whole watchlist - return payload + if asked not in watchlist.tickers(): + return f"Beep Boop. I could not find {webhook.bold(asked, service)} to remove it" + quote = yahoo.fetch([asked]).get(asked) + watchlist.remove(asked) + return f"Ok {user}, I deleted {described(asked, quote)}" + found, quote = watchlist.find(asked) + if not quote: + return f"Beep Boop. I could not find {webhook.bold(asked, service)} to add it" + added = watchlist.add(found) + if found != asked: + done = "so I added" if added else "and I'm already tracking" + return f"Beep Boop. I could not find {webhook.bold(asked, service)} {done} {described(found, quote)}" + return f"Ok {user}, I added {described(found, quote)}" if added else f"{user}, I'm already tracking {described(found, quote)}" def prepare_compare(service, args): """Compare 2-6 tickers as rebased % change. A trailing time period (3m/1y/90d/ytd) is optional; @@ -508,16 +454,10 @@ def prepare_beta_payload(service): def prepare_profile_payload(service, user, ticker): """The .SYMBOL report: what Yahoo knows about one company. A field Yahoo did not send leaves its line out.""" - ticker = ticker_orig = util.transform_to_yahoo(ticker) + ticker_orig = util.transform_to_yahoo(ticker) now = datetime.datetime.now() payload = [] - market_data = yahoo.fetch_detail(ticker, 600) - print("") - if ticker not in market_data and '.' not in ticker: - ticker = ticker + '.AX' - print("trying again with", ticker) - market_data = yahoo.fetch_detail(ticker, 600) - print("") + ticker, market_data = yahoo.resolve(ticker_orig, lambda symbol: yahoo.fetch_detail(symbol, 600)) if ticker not in market_data: return [f"{user} 🛑 Beep Boop. I could not find {ticker_orig}"] data = market_data[ticker] diff --git a/lib/state.py b/lib/state.py index 0edb81af..a709dec1 100644 --- a/lib/state.py +++ b/lib/state.py @@ -37,11 +37,12 @@ def _lock(name): @contextlib.contextmanager def edit(name, default): """Change a state file in place: `with state.edit(name, {}) as data: data[key] = value`. The file is read as the block starts - and written when it ends without an error; meanwhile no other edit of the same file runs in this process, so two chats - changing it at once both count. Keep slow work, a network call say, outside the block.""" + (a copy of `default` when there is none yet) and written as the block ends without an error, if the block changed it. + Meanwhile no other edit of the same file runs in this process, so two chats changing it at once both count. Keep slow work, + a network call say, outside the block.""" with _lock(name): - data = load(name) - if data is None: - data = copy.deepcopy(default) + data = load(name, copy.deepcopy(default)) + before = copy.deepcopy(data) yield data - save(name, data) + if data != before: + save(name, data) diff --git a/lib/util.py b/lib/util.py index a727495a..3268ee7d 100644 --- a/lib/util.py +++ b/lib/util.py @@ -4,7 +4,7 @@ import re from lib import config # first: sets MPLCONFIGDIR before matplotlib is imported (lazily, by the chart functions) from lib import sharesight -from lib import state +from lib import watchlist TITLE_TRIMS = [ # transform_title: (text, replacement), in order, as each sees what the ones before it made @@ -768,7 +768,7 @@ def days_from_human_days(arg): def get_holdings_and_watchlist(): tickers = set(sharesight.get_holdings_wrapper()) - tickers.update(state.load('finbot_watchlist.json', [])) # absent until the first add + tickers.update(watchlist.tickers()) if 'GOOG' in tickers and 'GOOGL' in tickers: tickers.remove("GOOGL") return sorted(tickers) diff --git a/lib/watchlist.py b/lib/watchlist.py new file mode 100644 index 00000000..b1ba936f --- /dev/null +++ b/lib/watchlist.py @@ -0,0 +1,36 @@ +"""The watchlist: tickers followed beside the Sharesight holdings, in DATA_DIR/finbot_watchlist.json. Every call reads the file, +so the bot and its scheduled reports (other processes) always see the same list.""" +from lib import state +from lib import yahoo + +FILE = 'finbot_watchlist.json' + + +def tickers(): + """The watched tickers, in the order they were added.""" + return state.load(FILE, []) + + +def find(ticker): + """(ticker, its Yahoo quote) for a ticker Yahoo knows, tried on the ASX when a bare symbol is unknown (BHP: BHP.AX); the + quote is None when Yahoo knows neither.""" + ticker, found = yahoo.resolve(ticker, lambda symbol: yahoo.fetch([symbol])) + return ticker, found.get(ticker) + + +def add(ticker): + """Watch a ticker (check it with find first). False when it was already watched.""" + with state.edit(FILE, []) as watched: + if ticker in watched: + return False + watched.append(ticker) + return True + + +def remove(ticker): + """Stop watching a ticker. False when it was not watched.""" + with state.edit(FILE, []) as watched: + if ticker not in watched: + return False + watched.remove(ticker) + return True diff --git a/lib/worker.py b/lib/worker.py index 74966386..e48522eb 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -348,10 +348,10 @@ def on_history(request, m): request.send(caption.split('\n'), image, charts.keyboard('h', [ticker], 'x') if image and request.service == 'telegram' else None) def on_plan(request, m): - plan = state.load('finbot_plan.json') or {} if m.group(1) and not m.group(1).startswith('@'): - plan[request.user] = m.group(1) - state.save('finbot_plan.json', plan) + with state.edit('finbot_plan.json', {}) as plans: + plans[request.user] = m.group(1) + plan = state.load('finbot_plan.json', {}) def who(user): # a Slack user is a mention (<@U123>), which is markup to keep; elsewhere a handle is text user = user.removeprefix('@') return user if request.service == 'slack' else webhook.escape(user, request.service) diff --git a/lib/yahoo.py b/lib/yahoo.py index bd9b77fd..d9f7875a 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -430,6 +430,15 @@ def fetch_details(tickers, strict=False): details |= detail return details +def resolve(ticker, lookup): + """(ticker, lookup(ticker)) for a ticker Yahoo knows: a bare symbol it does not know is tried on the ASX (BHP: BHP.AX). + lookup is fetch-shaped ({ticker: data}, without the tickers Yahoo does not know); its {} means Yahoo knows neither.""" + found = lookup(ticker) + if ticker not in found and '.' not in ticker: + ticker += '.AX' + found = lookup(ticker) + return ticker, found + def prefetch_history(tickers, workers=8): """Warm the price-history cache for many tickers at once, so the per-ticker lookups that follow are cache hits.""" tickers = list(tickers) diff --git a/milestone.py b/milestone.py index e2748705..2e38fcf7 100755 --- a/milestone.py +++ b/milestone.py @@ -17,7 +17,7 @@ def prepare_milestone_payload(service, market_data): payload = [] payloadhigh = [] payloadlow = [] - records = state.load('finbot_milestone.json') or {} + changed = {} # ticker -> its records after this run for ticker in tickers: try: oldhigh, oldlow = records[ticker]['record_high'], records[ticker]['record_low'] @@ -34,7 +34,7 @@ def prepare_milestone_payload(service, market_data): title = market_data[ticker]['profile_title'] ticker_link = util.finance_link(ticker, market_data[ticker]['profile_exchange'], service) emoji = util.flag_from_ticker(ticker) - records[ticker] = {'record_high': max(oldhigh, newhigh), 'record_low': min(oldlow, newlow)} # all-time: a 52-week high below it is no record + changed[ticker] = {'record_high': max(oldhigh, newhigh), 'record_low': min(oldlow, newlow)} # all-time: a 52-week high below it is no record if newhigh > oldhigh: print("DEBUG", ticker, newhigh, "is higher than", oldhigh) if config.prefs.debug else None payloadhigh.append(f"{emoji} {title} ({ticker_link})") @@ -55,18 +55,20 @@ def prepare_milestone_payload(service, market_data): payload.append(message) payload = payload + payloadlow payload = [i[0] for i in groupby(payload)] # de-dupe white space - return payload, records + return payload, changed # MAIN # tickers = util.get_holdings_and_watchlist() market_data = yahoo.fetch(tickers) + records, changed = state.load('finbot_milestone.json', {}), {} for service, url, chat in webhook.default_chats(): - payload, records = prepare_milestone_payload(service, market_data) + payload, changed = prepare_milestone_payload(service, market_data) webhook.send(service, url, chat, payload) - if records: - state.save('finbot_milestone.json', records) + if changed: + with state.edit('finbot_milestone.json', {}) as recorded: + recorded.update(changed) def job(argv): return 'milestone.py', {} diff --git a/podcasts.py b/podcasts.py index a2d62f04..1eb93b2f 100755 --- a/podcasts.py +++ b/podcasts.py @@ -88,7 +88,7 @@ def fetch_new_episodes(feed_url, seen): def lambda_handler(feed_url): """Post the feed's newest episodes not posted before, then record every new one as seen.""" seen_file = get_seen_filename(feed_url) - seen = set(state.load(seen_file) or []) + seen = set(state.load(seen_file, [])) try: episodes, guids = fetch_new_episodes(feed_url, seen) except Exception as e: @@ -104,7 +104,8 @@ def lambda_handler(feed_url): duration = f" {episode['duration']}" if episode['duration'] else "" payload.append(f"📣 {podcast_name}: {util.link(episode['url'], title, service)}{duration} ({episode['ago']})") webhook.send(service, url, chat, payload) - state.save(seen_file, sorted(seen | guids)) # last, so that a failure above leaves these episodes for next run + with state.edit(seen_file, []) as recorded: # last, so that a failure above leaves these episodes for next run + recorded[:] = sorted(guids.union(recorded)) def job(argv): """Arguments of a scheduled run: (label, lambda_handler kwargs). Raises ValueError with the usage on a bad argument.""" diff --git a/rating.py b/rating.py index a35662eb..d1807646 100755 --- a/rating.py +++ b/rating.py @@ -55,8 +55,7 @@ def get_emoji(old, new): # MAIN # tickers = [util.transform_to_yahoo(specific_stock)] if specific_stock else util.get_holdings_and_watchlist() - old = state.load('finbot_rating.json') - old = old if isinstance(old, dict) else {} # absent until the first run + old = state.load('finbot_rating.json', {}) market_data = yahoo.fetch_details(tickers, strict=bool(specific_stock)) for service, url, chat in webhook.destinations(interactive, service, chat_id): @@ -64,7 +63,8 @@ def get_emoji(old, new): webhook.send(service, url, chat, payload) if new and not interactive: # added to what is recorded: a ticker missing from this run keeps its last rating - state.save('finbot_rating.json', old | new) + with state.edit('finbot_rating.json', {}) as ratings: + ratings.update(new) def job(argv): return 'rating.py', {} diff --git a/tests/common.py b/tests/common.py index 0febcfcb..b46cb934 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,7 @@ os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok', telegramChatID='1') import bot # first, and once: it monkey-patches with gevent, which must happen before requests imports ssl and never mid-test -from lib import cache, config, state, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports, words +from lib import cache, config, state, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports, watchlist, words import performance, shorts, price, reminder, cal, trades, milestone, rating REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), diff --git a/tests/test_edges.py b/tests/test_edges.py index 366a0cdb..4a1c61f0 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -1,5 +1,5 @@ """Failure and no-data scenarios: empty or broken upstream data, bad requests, rejected callers.""" -import contextlib, io, json, os, signal, subprocess, sys, tempfile, time, unittest +import contextlib, io, json, os, signal, subprocess, sys, tempfile, threading, time, unittest from pathlib import Path import gevent from tests.common import * @@ -79,6 +79,33 @@ def test_add_duplicate_delete_and_unknown_action(self): self.assertEqual(state.load('finbot_watchlist.json'), []) self.assertIn('not a valid watchlist action', str(self.command('.watchlist frobnicate MSFT'))) + def test_a_bare_symbol_yahoo_does_not_know_is_tried_on_the_asx(self): + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts if t.endswith('.AX')}) + self.assertIn('so I added', str(self.command('.watchlist add BHP'))) + self.assertIn('already tracking', str(self.command('.watchlist add BHP'))) + self.assertIn('could not find', str(self.command('.watchlist add BHP.L'))) # a ticker with a market is not retried + self.assertEqual(watchlist.tickers(), ['BHP.AX']) + + def test_two_chats_adding_at_once_both_count(self): + # Yahoo is asked before the file is opened: both adds used to read the list, wait on Yahoo, and the later write won + def slow_fetch(tickers): + time.sleep(0.01) + return {t: market_entry(t) for t in tickers} + self.patch(yahoo, 'fetch', slow_fetch) + threads = [threading.Thread(target=reports.prepare_watchlist, args=('telegram', '@u', 'add', t)) for t in ('T1', 'T2')] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(sorted(watchlist.tickers()), ['T1', 'T2']) + + def test_removing_a_ticker_yahoo_no_longer_knows(self): + state.save('finbot_watchlist.json', ['GONE', 'T1']) + self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts if t != 'GONE'}) + self.assertIn('I deleted', str(self.command('.watchlist rm GONE'))) + self.assertIn('could not find', str(self.command('.watchlist rm GONE'))) + self.assertEqual(watchlist.tickers(), ['T1']) + class BadArguments(FinbotCase): def test_compare_argument_errors_give_usage(self): diff --git a/tests/test_reports.py b/tests/test_reports.py index 8bdb222d..27127c26 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -238,6 +238,13 @@ def test_trades_asks_yahoo_only_for_new_trades(self): trades.lambda_handler() # the trade is known now: nothing to report and nothing to fetch self.assertEqual(fetched, []) + def test_a_scheduled_run_keeps_the_trades_another_run_recorded_meanwhile(self): + meanwhile = lambda tickers: state.save('finbot_sharesight_trades.json', [999]) or {t: market_entry(t) for t in tickers} + self.patch(yahoo, 'fetch', meanwhile) # called after the seen trades were read, before the new ones are recorded + trades.lambda_handler() + known = state.load('finbot_sharesight_trades.json') + self.assertEqual((known[0], len(known)), (999, 2)) + def test_milestone(self): milestone.lambda_handler() self.assertTrue(self.sent) diff --git a/tests/test_util.py b/tests/test_util.py index 92a46c59..6f21f283 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,5 +1,5 @@ """Pure helpers in lib/util.py: table-driven pass and fail cases.""" -import datetime, json, unittest +import datetime, json, threading, unittest from pathlib import Path from tests.common import * @@ -114,6 +114,32 @@ def disk_full(*a, **k): state.save('state.json', {'a': 3}) self.assertEqual((json.loads(path.read_text()), oct(path.stat().st_mode & 0o777)), ({'a': 3}, '0o640')) + def test_edit_writes_only_a_change_and_never_changes_the_default(self): + default = {'seen': []} + with state.edit('edit.json', default): + pass + self.assertIsNone(state.load('edit.json')) # nothing changed, nothing written + with state.edit('edit.json', default) as data: + data['seen'].append(1) + self.assertEqual((state.load('edit.json'), default), ({'seen': [1]}, {'seen': []})) + with self.assertRaises(ValueError), state.edit('edit.json', default) as data: + data['seen'].append(2) + raise ValueError + self.assertEqual(state.load('edit.json'), {'seen': [1]}) # a block that fails writes nothing + + def test_edits_at_once_all_count(self): + # each edit reads the file after the one before it wrote, so none overwrites another with what it read earlier + def edit(key): + with state.edit('edit.json', {}) as data: + time.sleep(0.01) # the other edits start meanwhile + data[key] = True + threads = [threading.Thread(target=edit, args=(key,)) for key in 'abc'] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(state.load('edit.json'), dict.fromkeys('abc', True)) + def test_read_cache_missing_or_expired_is_falsy(self): self.assertFalse(cache.read('absent.json', 60)) cache.write('old.json', {'a': 1}) diff --git a/trades.py b/trades.py index b04f489e..2829604e 100755 --- a/trades.py +++ b/trades.py @@ -90,7 +90,7 @@ def prepare_trade_payload(service, trades): # MAIN # newtrades = set() portfolios = sharesight.pick_portfolios(portfolio_select) - known_trades = set(state.load(state_file) or ()) + known_trades = set(state.load(state_file, [])) trades = [trade for name, portfolio_id in portfolios.items() for trade in sharesight.get_trades(name, portfolio_id, days)] for service, url, chat in webhook.destinations(interactive, service, chat_id): @@ -99,10 +99,9 @@ def prepare_trade_payload(service, trades): payload = [f"{user} No trades {util.days_english(days, 'in the past ')}. {random.choice(words.noTradesVerb)}"] webhook.send(service, url, chat, payload) - # write state file if newtrades and not interactive: - known_trades.update(newtrades) - state.save(state_file, list(known_trades)) + with state.edit(state_file, []) as known: + known.extend(sorted(newtrades.difference(known))) def job(argv): return 'trades.py', {} From 7d9dcf244a08320c78ed7110922bd3567813e98f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:46:53 +0000 Subject: [PATCH 10/81] Sharesight portfolios are objects: sharesight.portfolios() gives Portfolio(name, id) A Portfolio has holdings(), tickers(), performance(days) and trades(days); sharesight.portfolios(name=None) picks every portfolio or the one named (in any case, else PortfolioNotFound), and sharesight.tickers() is everything held. They replace pick_portfolios, get_holdings, get_holdings_new, get_holdings_wrapper and get_performance_wrapper, so callers pass one object instead of a (name, id) pair. The cached, one-at-a-time requests stay in the module functions. A performance answer without a report is now an error naming the portfolio (it was a KeyError: 'report' further on), and is not cached. Output is otherwise unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 2 + lib/reports.py | 27 +++++----- lib/sharesight.py | 109 ++++++++++++++++++---------------------- lib/util.py | 2 +- performance.py | 4 +- price.py | 4 +- tests/common.py | 3 +- tests/test_edges.py | 4 +- tests/test_providers.py | 16 ++++-- tests/test_reports.py | 8 +-- tests/test_util.py | 2 +- trades.py | 3 +- 12 files changed, 91 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b9fc585..9dce94db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/util.py`: formatting, links, market tables, all matplotlib rendering. - `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources, cached in `DATA_DIR/cache`. + `sharesight.portfolios(name=None)` gives `Portfolio` objects: `holdings()`, `tickers()`, `performance(days)`, + `trades(days)`. - `lib/cache.py`: cache files (any may vanish at any time). `lib/state.py`: state files in `DATA_DIR`, which must last; change one with `with state.edit(name, default) as data:`, never load-then-save. `lib/watchlist.py`: the watchlist. - `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (preferences.conf, a new object on each reload). diff --git a/lib/reports.py b/lib/reports.py index c7ca86d1..6b4def1c 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -250,36 +250,37 @@ def prepare_help(service, botName): def prepare_holdings_payload(portfolio_name, service): """The holdings of the portfolio called `portfolio_name`. Raises sharesight.PortfolioNotFound.""" - portfolios = sharesight.get_portfolios() if not portfolio_name: - return [".holdings: please try again specifying a portfolio:"] + list(portfolios) - (name, portfolio_id), = sharesight.pick_portfolios(portfolio_name, portfolios).items() - holdings = sharesight.get_holdings_new(name, portfolio_id) + names = [portfolio.name for portfolio in sharesight.portfolios()] + return [".holdings: please try again specifying a portfolio:"] + names + portfolio, = sharesight.portfolios(portfolio_name) + holdings = portfolio.holdings() payload = sorted(holding_line(holding, service) for holding in holdings.values()) if payload: - payload.insert(0, webhook.bold(f"Holdings for {name} ({len(holdings)})", service)) + payload.insert(0, webhook.bold(f"Holdings for {portfolio.name} ({len(holdings)})", service)) return payload def prepare_who(service, code): """Which portfolios hold `code` (a ticker without its exchange), each holding linked to Sharesight.""" payload = [] - for portfolio_name, portfolio_id in sharesight.get_portfolios().items(): - holdings = sharesight.get_holdings_new(portfolio_name, portfolio_id) - found = [holding_line(holding, service, code) for holding in holdings.values() if holding['code'].upper() == code] + for portfolio in sharesight.portfolios(): + found = [holding_line(holding, service, code) + for holding in portfolio.holdings().values() if holding['code'].upper() == code] if found: - payload += [webhook.bold(f"{portfolio_name}:", service), "\n".join(found), ""] + payload += [webhook.bold(f"{portfolio.name}:", service), "\n".join(found), ""] return payload or [f'{code} not found in any portfolio'] def prepare_pie(portfolio_name, by, service, max_slices=8): """Portfolio value by industry (or by='sector'). Returns (payload, chart); chart is None unless there is something to draw. Raises sharesight.PortfolioNotFound.""" - portfolios = sharesight.get_portfolios() if not portfolio_name: - return [".pie: please try again specifying a portfolio:"] + list(portfolios), None - (name, portfolio_id), = sharesight.pick_portfolios(portfolio_name, portfolios).items() + names = [portfolio.name for portfolio in sharesight.portfolios()] + return [".pie: please try again specifying a portfolio:"] + names, None + portfolio, = sharesight.portfolios(portfolio_name) + name = portfolio.name key = 'sector_classification_name' if by == 'sector' else 'industry_classification_name' totals = {} - for holding in sharesight.get_holdings_new(name, portfolio_id).values(): + for holding in portfolio.holdings().values(): try: value = float(holding.get('value')) except (TypeError, ValueError): diff --git a/lib/sharesight.py b/lib/sharesight.py index 466ec446..a9fa941b 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -1,7 +1,8 @@ -import sys -import time +import dataclasses import datetime +import sys import threading +import time import requests from lib import config from lib import cache @@ -95,20 +96,51 @@ def get_portfolios(refresh=False): class PortfolioNotFound(LookupError): """No portfolio is called what was asked for. `lines` is the answer to give: the name, and the portfolios there are.""" - def __init__(self, name, portfolios): - self.lines = [f"{name} portfolio not found. I only know about:"] + list(portfolios) + def __init__(self, name, names): + self.lines = [f"{name} portfolio not found. I only know about:"] + list(names) super().__init__(' '.join(self.lines)) -def pick_portfolios(name=None, portfolios=None): - """{name as Sharesight spells it: id} of the portfolio called `name` (in any case), or of every portfolio when no name is given. - `portfolios` is get_portfolios() when not given. Raises PortfolioNotFound.""" - portfolios = get_portfolios() if portfolios is None else portfolios - if not name: - return portfolios - found = {k.lower(): (k, v) for k, v in portfolios.items()}.get(name.lower()) - if not found: - raise PortfolioNotFound(name, portfolios) - return dict([found]) +@dataclasses.dataclass(frozen=True) +class Portfolio: + """A Sharesight portfolio. Its methods ask Sharesight through the module functions (cached, one request at once).""" + name: str # as Sharesight spells it + id: int + + def performance(self, days=None): + """Sharesight's performance report over the past `days` (past_days when None); days=0 gives today's holdings.""" + return get_performance(self.id, config.prefs.past_days if days is None else days) + + def holdings(self): + """{'CODE:MARKET': Sharesight's instrument, with the holding's id and value added} of what it holds.""" + holdings = {} + for item in self.performance(0)['report']['holdings']: + instrument = item['instrument'] + key = instrument['code'] + ':' + instrument['market_code'] + holdings[key] = instrument | {'holding_id': item['id'], 'value': item.get('value')} + return holdings + + def tickers(self): + """What it holds, as Yahoo tickers, sorted.""" + return sorted({util.transform_to_yahoo(each['code'], each['market_code']) for each in self.holdings().values()}) + + def trades(self, days=None): + """Its trades of the past `days` (the past_days preference when None).""" + return get_trades(self.name, self.id, days) + +def portfolios(select=None): + """The portfolios to report on (after include_portfolios and exclude_portfolios), or the one called `select`, in any + case. Raises PortfolioNotFound for a name that is none of them.""" + found = [Portfolio(name, portfolio_id) for name, portfolio_id in get_portfolios().items()] + if not select: + return found + chosen = [portfolio for portfolio in found if portfolio.name.lower() == select.lower()] + if not chosen: + raise PortfolioNotFound(select, [portfolio.name for portfolio in found]) + return chosen + +def tickers(): + """Every portfolio's holdings as Yahoo tickers, sorted.""" + return sorted({ticker for portfolio in portfolios() for ticker in portfolio.tickers()}) def get_trades(portfolio_name, portfolio_id, days=None): if days is None: @@ -133,39 +165,6 @@ def get_trades(portfolio_name, portfolio_id, days=None): cache.write(cache_file, data) return data['trades'] -def get_holdings(portfolio_name, portfolio_id): - tickers = set() - holdings = get_holdings_new(portfolio_name, portfolio_id) - for holding in holdings: - symbol = holdings[holding]['code'] - market = holdings[holding]['market_code'] - ticker = util.transform_to_yahoo(symbol, market) - tickers.add(ticker) - return sorted(tickers) - -def get_holdings_new(portfolio_name, portfolio_id): - print("Fetching Sharesight holdings", portfolio_name, end=": ") - data = get_performance(portfolio_id, 0) - print(len(data['report']['holdings'])) - holdings = {} - for item in data['report']['holdings']: - code = item['instrument']['code'] - market_code = item['instrument']['market_code'] - symbol = code + ":" + market_code - holdings[symbol] = item['instrument'] - holdings[symbol]['holding_id'] = item['id'] - holdings[symbol]['value'] = item.get('value') - return holdings - -def get_holdings_wrapper(): - tickers = set() - portfolios = get_portfolios() - if not portfolios: - return None - for portfolio_name, portfolio_id in portfolios.items(): - tickers.update(get_holdings(portfolio_name, portfolio_id)) - return sorted(tickers) - def get_performance(portfolio_id, days, seconds=None, refresh=False): if seconds is None: seconds = config.prefs.performance_cache_seconds @@ -187,22 +186,12 @@ def get_performance(portfolio_id, days, seconds=None, refresh=False): continue break _check(data, 'performance') - if config.prefs.cache and 'report' in data: + if 'report' not in data: + raise RuntimeError(f"Sharesight sent no performance report for portfolio {portfolio_id}") + if config.prefs.cache: cache.write(cache_file, data) return data -def get_performance_wrapper(days=None): - if days is None: - days = config.prefs.past_days - performance = {} - portfolios = get_portfolios() - for portfolio_name, portfolio_id in portfolios.items(): - performance[portfolio_id] = get_performance(portfolio_id, days) - if not performance[portfolio_id]: - print("Could not get performance for portfolio:", portfolio_id, file=sys.stderr) - raise RuntimeError(f"Sharesight returned no performance for portfolio {portfolio_id}") - return performance - def warm(margin=0.9): """Refresh the portfolio list and each portfolio's holdings report (days=0) once they reach `margin` of their cache lifetime, so a request never has to wait on Sharesight. Returns (the number of fetches made, the portfolios): the caller can hand diff --git a/lib/util.py b/lib/util.py index 3268ee7d..a76fa71f 100644 --- a/lib/util.py +++ b/lib/util.py @@ -767,7 +767,7 @@ def days_from_human_days(arg): return int(arg.removesuffix('Y')) * 365 def get_holdings_and_watchlist(): - tickers = set(sharesight.get_holdings_wrapper()) + tickers = set(sharesight.tickers()) tickers.update(watchlist.tickers()) if 'GOOG' in tickers and 'GOOGL' in tickers: tickers.remove("GOOGL") diff --git a/performance.py b/performance.py index 577e6cfc..314b9f5f 100755 --- a/performance.py +++ b/performance.py @@ -14,8 +14,8 @@ def gather(past_days, portfolio_select=None): """{portfolio id: (its name, Sharesight's performance report over past_days)} of every portfolio, or of the one named (in any case). Raises sharesight.PortfolioNotFound for a name that is none of them, and RuntimeError when there is no report.""" - portfolios = sharesight.pick_portfolios(portfolio_select) - performance = {portfolio_id: (name, sharesight.get_performance(portfolio_id, past_days)) for name, portfolio_id in portfolios.items()} + performance = {portfolio.id: (portfolio.name, portfolio.performance(past_days)) + for portfolio in sharesight.portfolios(portfolio_select)} if not performance: print("Error: no Sharesight data found", file=sys.stderr) raise RuntimeError("no Sharesight data found") diff --git a/price.py b/price.py index a4b95de2..9e6bdb92 100755 --- a/price.py +++ b/price.py @@ -19,8 +19,8 @@ def gather(specific_stock=None, days=None): yahoo.add_usd_market_caps(market_data) # Sharesight reports a holding's performance only for the time it was held: held for 1Y and asked for 5Y, it gives 1Y if days and (not specific_stock or config.prefs.performance_use_sharesight): - for data in sharesight.get_performance_wrapper(days).values(): - for holding in data['report']['holdings']: + for portfolio in sharesight.portfolios(): + for holding in portfolio.performance(days)['report']['holdings']: ticker = util.transform_to_yahoo(holding['instrument']['code'], holding['instrument']['market_code']) if ticker in market_data: market_data[ticker]['percent_change_period'] = float(holding['capital_gain_percent']) diff --git a/tests/common.py b/tests/common.py index b46cb934..3f278f4d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,7 @@ import performance, shorts, price, reminder, cal, trades, milestone, rating REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), - (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper'))} + (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'))} REAL_GET_HOLDINGS = util.get_holdings_and_watchlist REAL_PAYLOAD_WRAPPER = webhook.payload_wrapper # FinbotCase replaces it; tests of the real thing use this REAL_REPORT_ERROR = webhook.report_error # likewise @@ -84,7 +84,6 @@ def setUp(self): self.patch(shortman, 'fetch', lambda md: md) holdings = [{'id': i, 'instrument': {'code': t, 'market_code': 'NASDAQ'}, 'portfolio': {'name': 'Rob'}, 'capital_gain_percent': str(i * 4 - 40), 'value': '1000'} for i, t in enumerate(TICKERS)] - self.patch(sharesight, 'get_performance_wrapper', lambda d: {1: {'report': {'holdings': holdings}}}) self.patch(sharesight, 'get_portfolios', lambda: {'Rob': 1}) self.patch(sharesight, 'get_performance', lambda pid, d, *a: {'report': { 'holdings': holdings, 'currency_gain_percent': '0', 'capital_gain_percent': '2.5', 'total_gain_percent': '0'}}) diff --git a/tests/test_edges.py b/tests/test_edges.py index 4a1c61f0..c2d99bfa 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -44,7 +44,6 @@ def down(*a, **k): def test_sharesight_down_is_reported_once(self): def down(*a, **k): raise RuntimeError('Sharesight performance error: boom') - self.patch(sharesight, 'get_performance_wrapper', down) self.patch(sharesight, 'get_performance', down) self.patch(sharesight, 'get_portfolios', down) self.sent.clear(); self.errors.clear() @@ -133,7 +132,8 @@ def test_an_unknown_portfolio_gets_the_same_answer_everywhere(self): self.assertEqual(self.command(cmd), [('text', ['Nope portfolio not found. I only know about:', 'Rob'])]) def test_a_portfolio_name_in_any_case(self): - self.patch(sharesight, 'get_holdings_new', lambda name, pid: {'T1:NASDAQ': {'code': 'T1', 'market_code': 'NASDAQ', 'name': 'Co T1', 'holding_id': 1}}) + holding = {'code': 'T1', 'market_code': 'NASDAQ', 'name': 'Co T1', 'holding_id': 1} + self.patch(sharesight.Portfolio, 'holdings', lambda portfolio: {'T1:NASDAQ': holding}) self.assertIn('Holdings for Rob (1)', str(self.command('.holdings rob'))) def test_trades_with_no_buy_or_sell_still_get_an_answer(self): diff --git a/tests/test_providers.py b/tests/test_providers.py index d23ac476..75f2d05a 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -41,7 +41,7 @@ def setUp(self): super().setUp() # these tests exercise the real parsers, so undo the canned data from the base fixture for module, name in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (sharesight, 'get_portfolios'), - (sharesight, 'get_trades'), (sharesight, 'get_performance'), (sharesight, 'get_performance_wrapper')): + (sharesight, 'get_trades'), (sharesight, 'get_performance')): self.patch(module, name, REAL[(module.__name__, name)]) self.patch(yahoo, 'getCookie', lambda *a, **k: 'A=B') self.patch(yahoo, 'getCrumb', lambda *a, **k: 'crumb') @@ -185,7 +185,7 @@ def test_one_failed_ticker_does_not_take_the_report_down(self): self.patch(yahoo, 'price_history', REAL[('lib.yahoo', 'price_history')]) self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) # quotes as canned in the base fixture, charts over HTTP self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)}) - self.patch(sharesight, 'get_performance_wrapper', lambda d: {}) # no Sharesight figures: every ticker needs its Yahoo history + self.patch(sharesight, 'get_portfolios', lambda: {}) # no Sharesight figures: every ticker needs its history good = Resp(self.chart([100.0] * 20 + [110.0])) def get(url, **kwargs): return Resp(status=404) if '/chart/T3?' in url or '/chart/SPY?' in url else good @@ -277,7 +277,7 @@ def test_trade_failures_raise(self): def test_performance_and_holdings_from_real_response(self): self.http(sharesight, Resp(sample('sharesight', 'finbot_sharesight_performance_684141_0.json'))) self.assertEqual(len(sharesight.get_performance(684141, 0)['report']['holdings']), 18) - tickers = sharesight.get_holdings('RobSMSF', 684141) + tickers = sharesight.Portfolio('RobSMSF', 684141).tickers() self.assertIn('ARM', tickers) # US venues carry no Yahoo suffix self.assertEqual(tickers, sorted(tickers)) @@ -295,6 +295,12 @@ def test_performance_gives_up_after_repeated_busy_or_hard_errors(self): with self.assertRaises(RuntimeError): sharesight.get_performance(684141, 7) + def test_an_answer_without_a_report_says_so(self): + self.http(sharesight, Resp({})) + with self.assertRaisesRegex(RuntimeError, 'no performance report for portfolio 684141'): + sharesight.get_performance(684141, 7) + self.assertIsNone(cache.read(sharesight.performance_cache_file(684141, 7))) # and is not cached + class SharesightKeepWarm(ProviderCase): """The background refresher keeps the portfolio list and the holdings report (days=0) cached ahead of their TTLs.""" @@ -343,11 +349,11 @@ def test_a_request_never_waits_on_a_cache_the_job_has_warmed(self): self.assertEqual(len(sharesight.get_performance(684141, 0)['report']['holdings']), 18) self.assertEqual(calls, []) - def test_holdings_wrapper_is_served_entirely_from_the_warmed_cache(self): + def test_every_holding_is_served_from_the_warmed_cache(self): calls = self.serve_sharesight() sharesight.warm()[0] calls.clear() - tickers = sharesight.get_holdings_wrapper() # get_portfolios() + get_holdings() per portfolio, no cache of its own + tickers = sharesight.tickers() # the portfolio list and each portfolio's holdings report, no cache of its own self.assertIn('ARM', tickers) self.assertEqual(calls, []) diff --git a/tests/test_reports.py b/tests/test_reports.py index 27127c26..bece5552 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -400,12 +400,12 @@ def item(n, date): class PieCommand(FinbotCase): def setUp(self): super().setUp() - def holdings(name, pid): + def holdings(portfolio): data = [('A', 'Semiconductors', 'Electronic Technology', 500), ('B', 'Semiconductors', 'Electronic Technology', 300), ('C', 'Biotechnology', 'Health Technology', 200), ('D', None, None, 100), ('E', 'Gold', 'Minerals', 0)] return {c + ':NASDAQ': {'code': c, 'market_code': 'NASDAQ', 'industry_classification_name': i, 'sector_classification_name': s, 'value': v} for c, i, s, v in data} - self.patch(sharesight, 'get_holdings_new', holdings) + self.patch(sharesight.Portfolio, 'holdings', holdings) def test_by_industry_default(self): sent = self.command('.pie Rob') @@ -419,7 +419,9 @@ def test_by_sector_and_payload(self): self.assertEqual(self.command('.pie Rob sector')[0][0], 'photo') def test_folds_into_other_and_renders(self): - self.patch(sharesight, 'get_holdings_new', lambda n, p: {f'T{i}:X': {'code': f'T{i}', 'industry_classification_name': f'Ind {i}', 'value': 100 - i} for i in range(12)}) + holdings = {f'T{i}:X': {'code': f'T{i}', 'industry_classification_name': f'Ind {i}', 'value': 100 - i} + for i in range(12)} + self.patch(sharesight.Portfolio, 'holdings', lambda portfolio: holdings) payload, chart = reports.prepare_pie('Rob', 'industry', 'telegram') self.assertEqual(len(chart['rows']), 8) self.assertEqual(chart['rows'][-1][0], 'Other') diff --git a/tests/test_util.py b/tests/test_util.py index 6f21f283..1ffa8310 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -163,7 +163,7 @@ def test_binary_cache_roundtrip(self): def test_holdings_and_watchlist_without_a_watchlist_file(self): # first run: no watchlist exists yet - self.patch(util.sharesight, 'get_holdings_wrapper', lambda: ['AAPL', 'GOOG', 'GOOGL']) + self.patch(util.sharesight, 'tickers', lambda: ['AAPL', 'GOOG', 'GOOGL']) self.patch(util, 'get_holdings_and_watchlist', REAL_GET_HOLDINGS) self.assertEqual(util.get_holdings_and_watchlist(), ['AAPL', 'GOOG']) # GOOGL dropped when GOOG is present state.save('finbot_watchlist.json', ['MSFT']) diff --git a/trades.py b/trades.py index 2829604e..ef5798df 100755 --- a/trades.py +++ b/trades.py @@ -89,9 +89,8 @@ def prepare_trade_payload(service, trades): # MAIN # newtrades = set() - portfolios = sharesight.pick_portfolios(portfolio_select) known_trades = set(state.load(state_file, [])) - trades = [trade for name, portfolio_id in portfolios.items() for trade in sharesight.get_trades(name, portfolio_id, days)] + trades = [trade for portfolio in sharesight.portfolios(portfolio_select) for trade in portfolio.trades(days)] for service, url, chat in webhook.destinations(interactive, service, chat_id): payload = prepare_trade_payload(service, trades) From bd85fb1f3efd155e9a3045a327b354de156aec06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:51:00 +0000 Subject: [PATCH 11/81] Stdout prints removed: systemd discarded every one finbot.service sends stdout to null, and job processes inherit it, so the 30 prints there (progress dots, "Preparing outbound", "Fetching ...", skip notices, a debug payload dump) were never read. They go, and a test keeps the bot and its reports printing only to stderr; the util/ tools, run by hand, still print to stdout. The one error among them, a data or cache directory that cannot be created, moves to stderr. On the way: get_trades printed len(data['trades']) before checking the answer, so a Sharesight error surfaced as KeyError: 'trades'; get_portfolios' include/exclude filter is one expression. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 3 ++- bot.py | 3 --- cal.py | 1 - cleanup.py | 1 - lib/config.py | 2 +- lib/reports.py | 4 ---- lib/sharesight.py | 18 ++++-------------- lib/shortman.py | 5 +---- lib/webhook.py | 5 ----- lib/yahoo.py | 7 +------ milestone.py | 3 --- podcasts.py | 1 - price.py | 6 ++---- shorts.py | 1 - tests/test_finbot.py | 15 ++++++++++++++- trades.py | 1 - 16 files changed, 25 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9dce94db..7c175d07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - A report is a list of lines. Markup through `webhook.bold/italic(text, service)`, user-written text through `webhook.escape(text, service)`, links through `util.link`/`util.finance_link`. - Tabs everywhere, tests included (`.editorconfig`). New and changed lines stay under 120 characters (an edit quotes whole lines). -- stdout is discarded under systemd (`StandardOutput=null`): anything worth logging goes to stderr. +- Log with `print(..., file=sys.stderr)`. systemd discards stdout (`StandardOutput=null`), so the bot and its reports never + print there (a test checks); only the util/ tools, run by hand, do. - A chart renderer in `lib/util.py` gets `@_drawn` (the bot draws in one native thread); it must not call another `@_drawn` function, or that thread waits on itself. - A new command: `COMMANDS`, `prepare_help`, the README command list, and `command_keyboard` if it gets a button. diff --git a/bot.py b/bot.py index 1a127277..dceb2f2a 100755 --- a/bot.py +++ b/bot.py @@ -127,8 +127,6 @@ def reply(status, body=b''): print("[Telegram]:", user, message, file=sys.stderr) if config.prefs.debug else None elif "photo" in msg: message = msg.get("caption", '') - file_id = msg["photo"][-1]["file_id"] - print("[Telegram photo]:", user, file_id, message) elif IGNORED_TELEGRAM_CONTENT & msg.keys(): return reply('200 OK') # stickers, GIFs, voice notes and the like: nothing to answer, nothing worth logging else: @@ -144,7 +142,6 @@ def reply(status, body=b''): print_body() return reply('403 Forbidden', b'

Unauthorized

') environ[AUTHENTICATED] = True - print("Incoming Slack request authenticated") if 'type' not in inbound: print(f"[{service}]: unhandled: no 'type'", file=sys.stderr) return reply('200 OK', b'Unhandled') diff --git a/cal.py b/cal.py index 42da8465..d737e996 100755 --- a/cal.py +++ b/cal.py @@ -79,7 +79,6 @@ def pin_if_earnings(service, results): market_data = yahoo.fetch(tickers) else: # ex-dividend dates are in Yahoo's details market_data = yahoo.fetch_details(tickers, strict=bool(specific_stock)) - print("") for service, url, chat in webhook.destinations(interactive, service, chat_id): pin_if_earnings(service, webhook.send(service, url, chat, prepare_payload(service, market_data))) diff --git a/cleanup.py b/cleanup.py index 78ee9fd6..cb1eb62f 100755 --- a/cleanup.py +++ b/cleanup.py @@ -23,7 +23,6 @@ def lambda_handler(days=30): removed += 1 except OSError as e: print("cleanup: can't remove", entry.path, e, file=sys.stderr) - print(f"cleanup: removed {removed} cache files older than {days} days from {config.env.cache_dir}") return removed def job(argv): diff --git a/lib/config.py b/lib/config.py index 15284210..49070c90 100644 --- a/lib/config.py +++ b/lib/config.py @@ -316,7 +316,7 @@ def _fail(e): try: os.makedirs(_dir, exist_ok=True) except OSError as e: # e.g. read-only filesystem (Lambda) - print("can't create", _dir, e) + print("config: can't create", _dir, e, file=sys.stderr) # matplotlib needs a writable config/cache dir; without a home directory (systemd DynamicUser, containers) it fails os.environ.setdefault('MPLCONFIGDIR', os.path.abspath(env.cache_dir + '/matplotlib')) diff --git a/lib/reports.py b/lib/reports.py index 6b4def1c..a24004c2 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -523,16 +523,12 @@ def prepare_profile_payload(service, user, ticker): earnings_date = datetime.datetime.fromtimestamp(data['earnings_date']) if earnings_date > now: payload.append(webhook.bold("Earnings date:", service) + f" {earnings_date:%b %d}") - else: - print("Skipping past earnings:", ticker, f"{earnings_date:%b %d}") if data.get('dividend', 0) > 0: payload.append(webhook.bold("Dividend:", service) + f" {data['dividend']}%") if 'ex_dividend_date' in data: ex_dividend_date = datetime.datetime.fromtimestamp(data['ex_dividend_date']) if ex_dividend_date > now: payload.append(webhook.bold("Ex-dividend date:", service) + f" {ex_dividend_date:%b %d}") - else: - print("Skipping past ex-dividend:", ticker, f"{ex_dividend_date:%b %d}") cashflow = data.get('free_cashflow', data.get('operating_cashflow')) if cashflow: payload.append(webhook.bold("Cashflow positive:", service) + (" no ⚠️ " if cashflow < 0 else " yes")) diff --git a/lib/sharesight.py b/lib/sharesight.py index a9fa941b..53223f5d 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -57,7 +57,6 @@ def get_token(): else: print("cache miss:", cacheFile, file=sys.stderr) if config.prefs.debug else None - print("Fetching Sharesight auth token") data = _request(_post, "https://api.sharesight.com/oauth2/token", 'auth', data=config.env.sharesight_auth) _check(data, 'auth') if config.prefs.cache and 'access_token' in data: @@ -72,26 +71,19 @@ def performance_cache_file(portfolio_id, days): def get_portfolios(refresh=False): """refresh=True skips the cache read and fetches again (the cache is rewritten).""" - portfolio_dict = {} data = cache.read(PORTFOLIOS_CACHE, config.prefs.cache_seconds) if config.prefs.cache and not refresh else None if not data: token = get_token() - print("Fetching Sharesight portfolios") data = _request(_get, "https://api.sharesight.com/api/v3/portfolios", 'portfolios', headers={'Content-type': 'application/json'}, auth=BearerAuth(token)) _check(data, 'portfolios') if config.prefs.cache and 'portfolios' in data: cache.write(PORTFOLIOS_CACHE, data) - for portfolio in data['portfolios']: - if str(portfolio['id']) in config.prefs.exclude_portfolios: - print(portfolio['id'], "(" + portfolio['name'] + ") in exclusion list. Skipping.") - elif str(portfolio['id']) not in config.prefs.include_portfolios and config.prefs.include_portfolios: - print("Exclusion list is defined and does not contain", portfolio['id'], "(" + portfolio['name'] + "). Skipping.") - else: - portfolio_dict[portfolio['name']] = portfolio['id'] - if not len(portfolio_dict): + include, exclude = config.prefs.include_portfolios, config.prefs.exclude_portfolios + portfolio_dict = {portfolio['name']: portfolio['id'] for portfolio in data['portfolios'] + if str(portfolio['id']) not in exclude and (not include or str(portfolio['id']) in include)} + if not portfolio_dict: print("No portfolios found. Exiting.", file=sys.stderr) raise RuntimeError("No Sharesight portfolios found") - print(portfolio_dict) return portfolio_dict class PortfolioNotFound(LookupError): @@ -153,11 +145,9 @@ def get_trades(portfolio_name, portfolio_id, days=None): start_date = datetime.datetime.now() - datetime.timedelta(days=days) start_date = start_date.strftime('%Y-%m-%d') # 2022-08-20 token = get_token() - print("Fetching Sharesight trades for", portfolio_name, end=": ") url = 'https://api.sharesight.com/api/v2/portfolios/' url = url + str(portfolio_id) + '/trades.json' + '?start_date=' + start_date data = _request(_get, url, 'trades', auth=BearerAuth(token)) - print(len(data['trades'])) _check(data, 'trades') for trade in data['trades']: trade['portfolio'] = portfolio_name # inject custom field diff --git a/lib/shortman.py b/lib/shortman.py index 489fb0e8..023a87a0 100644 --- a/lib/shortman.py +++ b/lib/shortman.py @@ -4,16 +4,13 @@ def fetch(market_data): local_market_data = market_data.copy() - print("Fetching ASX shorts from Shortman") url = 'https://www.shortman.com.au/downloadeddata/latest.csv' try: r = net.get(url, timeout=config.prefs.http_timeout) except Exception as e: print("Failure fetching", url, net.redact(e), file=sys.stderr) return market_data # the ASX short interest is missing, the rest of the report is not - if r.status_code == 200: - print(r.status_code, "success shortman") - else: + if r.status_code != 200: print(r.status_code, "error communicating with", url, file=sys.stderr) return market_data csv = r.content.decode('utf-8') diff --git a/lib/webhook.py b/lib/webhook.py index b420d517..0aad651f 100644 --- a/lib/webhook.py +++ b/lib/webhook.py @@ -134,16 +134,12 @@ def cli(script, job, handler, argv=None): def payload_wrapper(service, url, payload, chat_id=None, reply_markup=None): """Returns a list with the response of each message sent (several if the payload was chunked).""" if not payload: - print(service + ": Nothing to send") # informational return None payload = [str(line) for line in payload] payload_string = '\n'.join(payload) - print("Preparing outbound to", service, str(len(payload_string)), "bytes") - print("Payload: " + payload_string) if config.prefs.debug else None limit = DISCORD_LIMIT if service == 'discord' else 4096 if len(payload_string) <= limit: return [write(service, url, payload_string, chat_id, reply_markup)] - print(service, f"payload is over {limit:,} bytes. Splitting.") chunks = chunked(payload, limit, config.prefs.chunk_maxlines) results = [] for idx, chunk in enumerate(chunks): @@ -250,7 +246,6 @@ def telegram_photo(chat_id, image_data, caption, reply_markup=None): except ValueError: output = {} if r.status_code == 200 and output.get('ok'): - print(r.status_code, "OK telegram sendPhoto", data['caption']) return output print(r.status_code, "error telegram sendPhoto", output.get('error_code'), output.get('description') or r.text[:300], file=sys.stderr) return None diff --git a/lib/yahoo.py b/lib/yahoo.py index d9f7875a..515e6af0 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -235,15 +235,11 @@ def fetch_detail(ticker, seconds=None): now = datetime.datetime.now() cacheFile = "finbot_yahoo_detail_" + ticker + '.json' data = cache.read(cacheFile, seconds) if config.prefs.cache else None - if data: - print('.', sep=' ', end='', flush=True) - else: - print('↓', sep=' ', end='', flush=True) + if not data: cookie, crumb = credentials() # only on a miss: a cached detail needs neither, nor the lock modules = 'calendarEvents,defaultKeyStatistics,balanceSheetHistoryQuarterly,financialData,summaryProfile,summaryDetail,price,earnings,earningsTrend,insiderTransactions' r = _get_first(_hosts('/v10/finance/quoteSummary/' + ticker + '?modules=' + modules + '&crumb=' + crumb), {**HEADERS, 'Cookie': cookie}) if r is None: - print(ticker + '†', sep=' ', end='', flush=True) return {} # catches some delisted stocks like "DRNA" data = r.json() if config.prefs.cache: @@ -465,7 +461,6 @@ def fetch_chart_json(ticker, days=3665, seconds=None, full=False): raise RuntimeError(f"Yahoo unreachable for {ticker}: {net.redact(e)}") from e if r.status_code != 200: raise RuntimeError(f"{r.status_code} error for {ticker} from Yahoo's chart API") - print('↓', sep=' ', end='', flush=True) data = r.json() if config.prefs.cache: cache.write(cacheFile, data) diff --git a/milestone.py b/milestone.py index 2e38fcf7..bf496a57 100755 --- a/milestone.py +++ b/milestone.py @@ -2,7 +2,6 @@ import sys from itertools import groupby -from lib import config from lib import state from lib import webhook from lib import util @@ -36,10 +35,8 @@ def prepare_milestone_payload(service, market_data): emoji = util.flag_from_ticker(ticker) changed[ticker] = {'record_high': max(oldhigh, newhigh), 'record_low': min(oldlow, newlow)} # all-time: a 52-week high below it is no record if newhigh > oldhigh: - print("DEBUG", ticker, newhigh, "is higher than", oldhigh) if config.prefs.debug else None payloadhigh.append(f"{emoji} {title} ({ticker_link})") if newlow < oldlow: - print("DEBUG", ticker, newlow, "is lower than", oldlow) if config.prefs.debug else None payloadlow.append(f"{emoji} {title} ({ticker_link})") payloadlow.sort() payloadhigh.sort() diff --git a/podcasts.py b/podcasts.py index 1eb93b2f..0239ef4c 100755 --- a/podcasts.py +++ b/podcasts.py @@ -94,7 +94,6 @@ def lambda_handler(feed_url): except Exception as e: raise RuntimeError(f"fetching feed {feed_url}: {e}") if not guids: - print("no new episodes found") return for service, url, chat in webhook.default_chats(): payload = [] diff --git a/price.py b/price.py index 9e6bdb92..90d29cf2 100755 --- a/price.py +++ b/price.py @@ -76,11 +76,9 @@ def report(service, threshold=None, user='', specific_stock=None, interactive=Fa if midsession and state != 'REGULAR': continue # not in session (states: PREPRE PRE REGULAR POST POSTPOST CLOSED) if interday and not interactive and now - quoted > datetime.timedelta(days=1): - print(ticker + '⏭', sep='', end=' ', flush=True) # a market holiday: no repeat of the last session's moves - continue + continue # a market holiday: no repeat of the last session's moves if close and not interactive and (now - quoted > datetime.timedelta(hours=1.5) or state in ('PREPRE', 'PRE', 'REGULAR')): - print(ticker + '⏭', sep='', end=' ', flush=True) # its market did not just close - continue + continue # its market did not just close if premarket: percent = quote.get('percent_change_premarket', quote.get('percent_change_postmarket')) if percent is None: diff --git a/shorts.py b/shorts.py index af829caa..258a9d69 100755 --- a/shorts.py +++ b/shorts.py @@ -41,7 +41,6 @@ def prepare_shorts_payload(service, market_data): market_data = yahoo.fetch(tickers) market_data |= yahoo.fetch_details((ticker for ticker in tickers if '.' not in ticker), strict=bool(specific_stock)) # US short interest; Short Man has the ASX's - print("") market_data = shortman.fetch(market_data) for service, url, chat in webhook.destinations(interactive, service, chat_id): diff --git a/tests/test_finbot.py b/tests/test_finbot.py index 76ae706d..1914a4d9 100644 --- a/tests/test_finbot.py +++ b/tests/test_finbot.py @@ -1,5 +1,5 @@ """Plumbing: message chunking, timezones, the WSGI handler.""" -import contextlib, datetime, io, json, subprocess, tempfile, unittest, zoneinfo +import ast, contextlib, datetime, io, json, subprocess, tempfile, unittest, zoneinfo from pathlib import Path from unittest import mock from tests.common import * @@ -55,6 +55,19 @@ def post(message): self.assertEqual(bot.git_version(), head[:7]) # matches what git says +class Logging(unittest.TestCase): + def test_the_bot_and_its_reports_print_only_to_stderr(self): + """systemd discards finbot's stdout (StandardOutput=null), so a print there is never read. The util/ tools, run by + hand, may use it.""" + offenders = [] + for path in sorted([*Path(ROOT, 'lib').glob('*.py'), *Path(ROOT).glob('*.py')]): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.Call) and getattr(node.func, 'id', None) == 'print' and \ + not any(keyword.arg == 'file' for keyword in node.keywords): + offenders.append(f'{path.name}:{node.lineno}') + self.assertEqual(offenders, []) + + class GitVersion(unittest.TestCase): SHA = '0123456789abcdef0123456789abcdef01234567' diff --git a/trades.py b/trades.py index ef5798df..a57ff93d 100755 --- a/trades.py +++ b/trades.py @@ -26,7 +26,6 @@ def prepare_trade_payload(service, trades): if trade['transaction_type'] not in {'BUY', 'SELL'}: continue if not interactive and int(trade['id']) in known_trades: - print("Skipping known trade_id:", trade['id'], trade['transaction_date'], trade['portfolio'], trade['transaction_type'], trade['symbol']) continue wanted.append(trade) if not wanted: # the usual case on a scheduled run From 5c13613523c290708c26615e8de82c4d65339563 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:57:58 +0000 Subject: [PATCH 12/81] util.py split by subject: markets, formats, links and render lib/util.py (807 lines) held four unrelated things; each moves unchanged into its own module: - lib/markets.py: market tables, flags, currencies, exchange names, ticker translation; - lib/formats.py: company names trimmed, units, dates and spans in words, signed percents, ordinals; - lib/links.py: chat-markup links and Yahoo/Google Finance links; - lib/render.py: every matplotlib chart and the render pool. get_holdings_and_watchlist becomes watchlist.with_holdings(). util imported sharesight, which imported util back; that cycle is gone. Callers and test patch targets follow; the profile's local `links` list is now `sites`. Output is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 9 +- bot.py | 4 +- cal.py | 17 +- lib/charts.py | 11 +- lib/formats.py | 171 +++++++++++++++++ lib/links.py | 66 +++++++ lib/markets.py | 132 +++++++++++++ lib/{util.py => render.py} | 378 +------------------------------------ lib/reports.py | 99 +++++----- lib/sharesight.py | 7 +- lib/watchlist.py | 9 + lib/worker.py | 16 +- lib/yahoo.py | 17 +- milestone.py | 10 +- performance.py | 16 +- podcasts.py | 4 +- price.py | 26 +-- rating.py | 8 +- reminder.py | 11 +- shorts.py | 13 +- tests/common.py | 6 +- tests/test_charts.py | 66 +++---- tests/test_config.py | 2 +- tests/test_datadir.py | 4 +- tests/test_edges.py | 2 +- tests/test_providers.py | 4 +- tests/test_reports.py | 10 +- tests/test_util.py | 60 +++--- trades.py | 18 +- 29 files changed, 615 insertions(+), 581 deletions(-) create mode 100644 lib/formats.py create mode 100644 lib/links.py create mode 100644 lib/markets.py rename lib/{util.py => render.py} (61%) diff --git a/CLAUDE.md b/CLAUDE.md index 7c175d07..c59183c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,9 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - `price.py performance.py trades.py shorts.py cal.py rating.py milestone.py reminder.py podcasts.py cleanup.py`: reports that also run on a schedule; each has `lambda_handler()` and `job(argv)` (its schedule-line argument parser). - `lib/webhook.py`: sending (chunking, photos, Slack upload), markup, escaping, error reporting. `lib/telegram.py`: Bot API calls. -- `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/util.py`: formatting, links, market tables, all - matplotlib rendering. +- `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/markets.py`: market tables, flags, ticker + translation. `lib/formats.py`: names, units, dates, percents in words. `lib/links.py`: links. `lib/render.py`: every chart + (matplotlib). - `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources, cached in `DATA_DIR/cache`. `sharesight.portfolios(name=None)` gives `Portfolio` objects: `holdings()`, `tickers()`, `performance(days)`, `trades(days)`. @@ -51,11 +52,11 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - Read settings as `config.env.` / `config.prefs.` where they are used, never into a default or module constant (a reload must be seen); in tests, `self.env(...)` / `self.prefs(...)` change them. - A report is a list of lines. Markup through `webhook.bold/italic(text, service)`, user-written text through - `webhook.escape(text, service)`, links through `util.link`/`util.finance_link`. + `webhook.escape(text, service)`, links through `links.link`/`links.finance_link`. - Tabs everywhere, tests included (`.editorconfig`). New and changed lines stay under 120 characters (an edit quotes whole lines). - Log with `print(..., file=sys.stderr)`. systemd discards stdout (`StandardOutput=null`), so the bot and its reports never print there (a test checks); only the util/ tools, run by hand, do. -- A chart renderer in `lib/util.py` gets `@_drawn` (the bot draws in one native thread); it must not call another `@_drawn` +- A chart renderer in `lib/render.py` gets `@_drawn` (the bot draws in one native thread); it must not call another `@_drawn` function, or that thread waits on itself. - A new command: `COMMANDS`, `prepare_help`, the README command list, and `command_keyboard` if it gets a button. diff --git a/bot.py b/bot.py index dceb2f2a..82ec25c6 100755 --- a/bot.py +++ b/bot.py @@ -21,7 +21,7 @@ from lib import telegram from lib import webhook from lib import yahoo -from lib import util +from lib import render import cleanup botName = None # the Telegram bot's @username, from telegram.register() at start @@ -251,7 +251,7 @@ def flush_cache(*args): sys.exit(1) else: print('Scheduled reports are off (scheduler=False)', file=sys.stderr) - util.render_off_hub() # charts drawn in a thread of their own: no chat waits while another's chart is drawn + render.render_off_hub() # charts drawn in a thread of their own: no chat waits while another's chart is drawn httpd = pywsgi.WSGIServer((config.env.ip, config.env.port), main) httpd.secure_repr = False if config.prefs.debug else None print(f'Opening socket on http://{config.env.ip}:{config.env.port}', file=sys.stderr) diff --git a/cal.py b/cal.py index d737e996..256d7444 100755 --- a/cal.py +++ b/cal.py @@ -5,7 +5,10 @@ from lib import config from lib import telegram -from lib import util +from lib import formats +from lib import links +from lib import markets +from lib import watchlist from lib import webhook from lib import yahoo @@ -37,15 +40,15 @@ def prepare_payload(service, market_data): ticker_primary = ticker.replace('.AX', '.NZ') # fixes GTK.AX and SKO.AX market_data = market_data | yahoo.fetch_detail(ticker_primary) country = market_data.get(ticker_primary, {}).get('profile_country') # only in the details - if country in util.YAHOO_COUNTRY and '.' not in ticker_primary: - ticker_primary = ticker_primary + '.' + util.YAHOO_COUNTRY[country] # fixes ASML and INFY + if country in markets.YAHOO_COUNTRY and '.' not in ticker_primary: + ticker_primary = ticker_primary + '.' + markets.YAHOO_COUNTRY[country] # fixes ASML and INFY market_data = market_data | yahoo.fetch_detail(ticker_primary) primary = market_data.get(ticker_primary, {}) if primary.get('profile_title') != title_orig or field not in primary: # not the same company, or no date there either continue when = datetime.datetime.fromtimestamp(market_data[ticker_primary][field]) if now < when < soon or specific_stock: - found.append((when, util.flag_from_ticker(ticker_primary), market_data[ticker]['profile_title'], util.yahoo_link(ticker, service))) + found.append((when, markets.flag_from_ticker(ticker_primary), market_data[ticker]['profile_title'], links.yahoo_link(ticker, service))) found.sort(key=lambda item: item[0]) payload = [] for date in sorted({when.date() for when, *_ in found}): @@ -55,9 +58,9 @@ def prepare_payload(service, market_data): if when.date() == date: payload.append(f"{flag} {date:%b %d} {title} ({link})" if specific_stock else f"{flag} {title} ({link})") if payload and not specific_stock: - payload.insert(0, webhook.bold(f"{heading} {util.days_english(days, 'for the next ')}:", service)) + payload.insert(0, webhook.bold(f"{heading} {formats.days_english(days, 'for the next ')}:", service)) elif not payload and interactive: - payload = [f"No events found for {tickers[0]}" if specific_stock else f"No events found {util.days_english(days, 'for the next ')}"] + payload = [f"No events found for {tickers[0]}" if specific_stock else f"No events found {formats.days_english(days, 'for the next ')}"] return payload def pin_if_earnings(service, results): @@ -74,7 +77,7 @@ def pin_if_earnings(service, results): telegram.pinChatMessage(chat, message_id) # MAIN # - tickers = [util.transform_to_yahoo(specific_stock)] if specific_stock else util.get_holdings_and_watchlist() + tickers = [markets.transform_to_yahoo(specific_stock)] if specific_stock else watchlist.with_holdings() if event == 'earnings': market_data = yahoo.fetch(tickers) else: # ex-dividend dates are in Yahoo's details diff --git a/lib/charts.py b/lib/charts.py index 667741da..d6d782be 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -3,7 +3,8 @@ import json import sys -from lib import util +from lib import formats +from lib import links from lib import state from lib import webhook from lib import yahoo @@ -94,7 +95,7 @@ def history_payload(ticker, service, highlight=None, graph=False): the full history's chart with graph=True (if the graph preference is on), else None.""" market_data = yahoo.fetch_detail(ticker, 600) title = market_data.get(ticker, {}).get('profile_title', '') - ticker_link = util.finance_link(ticker, market_data.get(ticker, {}).get('profile_exchange', ''), service, days=1825, brief=False) + ticker_link = links.finance_link(ticker, market_data.get(ticker, {}).get('profile_exchange', ''), service, days=1825, brief=False) if ticker not in market_data or 'percent_change' not in market_data[ticker]: return [f".history: no data found for ticker {ticker}"], None, None table, image = yahoo.price_history(ticker) if graph else yahoo.price_history(ticker, graph=False) @@ -105,7 +106,7 @@ def history_payload(ticker, service, highlight=None, graph=False): label = webhook.bold(interval + ':', service) if interval == highlight: label = '● ' + label - lines.append(f"{util.get_emoji(percent)} {label} {util.signed_percent(percent)}") + lines.append(f"{formats.get_emoji(percent)} {label} {formats.signed_percent(percent)}") return lines, table, image def build(kind, tickers, period, service='telegram'): @@ -126,8 +127,8 @@ def build(kind, tickers, period, service='telegram'): raise RuntimeError(f"no data found for {ticker}") percent, image = yahoo.price_history(ticker, days, graphCache=False) percent = float(percent.get(days, percent.get('Max', next(iter(percent.values()), 0)))) - link = util.quote_link(ticker, market_data[ticker]['profile_exchange'], service, days=days) - return f"{util.get_emoji(percent)} {market_data[ticker]['profile_title']} ({link}) {util.signed_percent(round(percent))}", image + link = links.quote_link(ticker, market_data[ticker]['profile_exchange'], service, days=days) + return f"{formats.get_emoji(percent)} {market_data[ticker]['profile_title']} ({link}) {formats.signed_percent(round(percent))}", image if kind == 'c': args = list(tickers) + ([f'{days}d'] if days else ['max']) caption, image, *_ = reports.prepare_compare(service, args) diff --git a/lib/formats.py b/lib/formats.py new file mode 100644 index 00000000..fa42a6ea --- /dev/null +++ b/lib/formats.py @@ -0,0 +1,171 @@ +"""Text for people: company names trimmed, numbers in units, dates and spans in words, signed percents, ordinals.""" +import datetime +import re + +TITLE_TRIMS = [ # transform_title: (text, replacement), in order, as each sees what the ones before it made + (' - ', ' '), ('Roundhill ETF Trust ', ''), ('Class A Common Stock', ''), + ('BlackRock Institutional Trust Company N.A.', ''), ('Listed Funds Trust', ''), ('GraniteShares ETF Trust', ''), + ('ProShares Trust', ''), ('VanEck ETF Trust', ''), ('Direxion Shares ETF Trust', ''), (' of California', ''), + ('Magnificent', 'Mag'), ('First Trust NASDAQ Clean Edge Green Energy Index Fund', 'First Trust Clean Energy'), + ('First Trust Exchange-Traded Fund III First Trust Nasdaq Clean Edge Clean Energy Index Fund', 'First Trust Green Energy'), + ('Mirae Asset Global Investments (Hong Kong) Limited', ''), ('Space Exploration Technologies', 'SpaceX'), + ('Exchange Traded Funds Series OFC', ''), ('Global X Global X', 'Global X'), ('Tema ETF Trust', ''), (' AUD', ' '), + (' USD', ' '), ('New York Shares', ''), ('China Electric Vehicle and Battery ETF', 'China EV ETF'), + ('China Electric Vehicle ETF', 'China EV ETF'), ('Atlantica Sustainable Infrastructure', 'Atlantica Sustainable'), + ('Advanced Micro Devices', 'AMD'), ('Taiwan Semiconductor Manufacturing', 'TSM'), + ('Flight Centre Travel', 'Flight Centre'), ('Global X Funds ', ''), (' of Australia', ''), + ('National Australia Bank', 'NAB'), (' PLC', ''), (' p.l.c.', ''), (' P.l.c.', ''), (' P.L.C.', ''), (' Units', '') +] + +TITLE_SHORTENINGS = [ # and these after the suffixes come off + ('Microbalifesciences', 'Microba Life Sciences'), ('Walt Disney Co (The)', 'Disney'), ('Lisenergylimited', 'LI-S Energy'), + ('Invesco Capital Management LLC ', ''), ('QUALCOMM', 'Qualcomm'), ('Ordinary Shares Class A', ''), + ('Ordinary Shares Class C', ''), ('Lbt Innovations', 'LBT Innovations'), + ('Vanguard Information Technology', 'Vanguard Infotech'), ('The ', ''), (' Shares', ''), ('Rea ', 'REA '), + ('Csl ', 'CSL '), ('Battery ', 'Batt '), ('.com', ''), ('Etf', 'ETF'), ('N.V.', ''), (' NV ', ' '), ('New York Re', ''), + (' Australian', ' Aus'), (' Australia', ' Aus'), (' Infrastructure', ' Infra'), (' Manufacturing Company', ' '), + (' Limited', ' '), (' Ltd', ' '), (' Holdings', ' '), (' Holding', ' '), (' (Holdings)', ''), (' Corporation', ' '), + (' Incorporated', ' '), (' incorporated', ' '), (' Technologies', ' '), (' Technology', ' '), (' Enterprises', ' '), + (' Enterprise', ' '), (' Enterpr', ' '), (' Ventures', ' '), (' Resources', ' '), (' (Intrada', ''), (' Structured', ''), + (' Co.', ' '), (' Corp.', ' '), (' Corp', ' '), (' Tech ', ' '), (' Company', ' '), (' Group', ' '), (', Inc', ' '), + (' Inc', ' '), (' Plc', ' '), (' plc', ' '), (' Index', ' '), (' ADR', ' '), (' Daily', ' '), (' Long', ' '), + (' Bull', ' '), (' .', ' '), (' ,', ' ') +] + +def transform_title(title): + """A company or fund name as the reports show it: shorter, without fund-trust boilerplate and legal suffixes.""" + title = title.replace(' FPO', '') + if title.isupper() or title.islower(): + title = title.title() + for text, replacement in TITLE_TRIMS: + title = title.replace(text, replacement) + for suffix in (' AG', ' SE', ' Se'): + title = title.removesuffix(suffix) + title = re.sub(r'\[\w+\]$', '', title) + for text, replacement in TITLE_SHORTENINGS: + title = title.replace(text, replacement) + if title.islower(): + title = title.title() + for suffix in (' &', ' and'): + title = title.strip().removesuffix(suffix) + return ' '.join(title.replace(' Usd', ' USD').replace('Ishares', 'iShares').split()) + +def humanUnits(value, decimal_places=2): + """1234567 -> '1.23 M'.""" + for unit in ['', 'K', 'M', 'B', 'T', 'Q']: + if value < 1000.0 or unit == 'Q': + break + value /= 1000.0 + return f"{value:.{decimal_places}f} {unit}".rstrip() + +def security_line(flag, title, link): + """'🇦🇺 Title (link)': how a report line names a security (without a leading space where its market has no flag).""" + return f"{flag} {title} ({link})" if flag else f"{title} ({link})" + +def strip_url(url): + url = url.removeprefix('https://www.') + url = url.removeprefix('http://www.') + url = url.removeprefix('https://') + url = url.removeprefix('http://') + return url + +def make_paragraphs(walloftext): + """The text in paragraphs, each ending with the first sentence that takes it past 22 words.""" + paragraphs, buffer = [], [] + for word in walloftext.split(): + buffer.append(word) + if word.endswith(('!', '.')) and len(buffer) > 22: + paragraphs.append(' '.join(buffer)) + buffer = [] + if buffer: # the rest: it used to be dropped, and a short text with it + paragraphs.append(' '.join(buffer)) + return '\n\n'.join(paragraphs) + +def date_short(d, year=False): + """5 Oct (or 5 Oct 2026).""" + return f"{d.day} {d:%b}" + (f" {d.year}" if year else "") + +def date_range_english(days, end): + """'28 Sep - 5 Oct 2026' for the `days` ending on `end`; both ends carry their year when the range crosses a year boundary.""" + start = end - datetime.timedelta(days=days) + return f"{date_short(start, start.year != end.year)} - {date_short(end, True)}" + +def days_english(days, prefix='the past ', article=''): + if days is None or days == 0: + return 'today' + elif days == 1: + return prefix + article + 'day' + elif days == 7: + return prefix + article + 'week' + elif days == 30: + return prefix + article + 'month' + elif days == 365: + return prefix + article + 'year' + elif days % 7 == 0: + return prefix + str(int(days/7)) + ' weeks' + elif days % 30 == 0: + return prefix + str(int(days/30)) + ' months' + elif days % 365 == 0: + return prefix + str(int(days/365)) + ' years' + else: + return prefix + str(days) + ' days' + +def signed_percent(value, decimals=None): + """12 -> '+12%', -3.5 -> '-3.5%', 0 -> '0%'; thousands separated. decimals=None keeps the value's own digits.""" + if value == 0: + return '0%' + return f"{value:+,}%" if decimals is None else f"{value:+,.{decimals}f}%" + +def get_emoji(number): + if number > 0: + return '🔼' + elif number < 0: + return '🔻' + else: + return '▪️' + +def days_from_human_days(arg): + """7, 7d, 2w, 3m, 1y or ytd -> days. Raises ValueError for anything else.""" + arg = arg.upper() + if arg == 'YTD': + today = datetime.datetime.now().date() + return (today - today.replace(day=1, month=1)).days + for suffix, days in (('D', 1), ('W', 7), ('M', 30)): + try: + return int(arg.removesuffix(suffix)) * days + except ValueError: + pass + return int(arg.removesuffix('Y')) * 365 + +def ordinal(num): + value = str(num) + if len(value) > 1: + secondToLastDigit = value[-2] + if secondToLastDigit == '1': + return 'th' + lastDigit = value[-1] + if (lastDigit == '1'): + return 'st' + elif (lastDigit == '2'): + return 'nd' + elif (lastDigit == '3'): + return 'rd' + else: + return 'th' + +def td_to_human(timedelta): + days, remainder = divmod(timedelta.total_seconds(), 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + parts = [] + if days: + parts.append(f"{int(days)} day{'s' if days != 1 else ''}") + if hours: + parts.append(f"{int(hours)} hour{'s' if hours != 1 else ''}") + if minutes: + parts.append(f"{int(minutes)} minute{'s' if minutes != 1 else ''}") + if seconds: + parts.append(f"{int(seconds)} second{'s' if seconds != 1 else ''}") + if not parts: + return "0 seconds" + return ", ".join(parts) diff --git a/lib/links.py b/lib/links.py new file mode 100644 index 00000000..66881bac --- /dev/null +++ b/lib/links.py @@ -0,0 +1,66 @@ +"""Links: chat markup for a link, and a stock's page on Yahoo or Google Finance.""" +from lib import config +from lib import markets + +def yahoo_link(ticker, service='telegram', brief=True, text=None): + if not text: + text = ticker.split('.')[0] if brief else ticker + return link("https://au.finance.yahoo.com/quote/" + ticker, text, service) + +def link(url, text, service='telegram'): + """text linking to url in the service's markup: HTML on Telegram, on Slack, a masked link on Discord (the angle + brackets keep its preview away; brackets in the text and parentheses in the url would end it early).""" + if not config.prefs.hyperlink: + return text + if service == 'telegram': + return '' + text + '' + if service == 'slack': + return '<' + url + '|' + text + '>' + if service == 'discord': + return '[' + text.replace('[', '\\[').replace(']', '\\]') + '](<' + url.replace('(', '%28').replace(')', '%29') + '>)' + return text + +def finance_link(symbol, exchange, service='telegram', days=1, brief=True, text=None): + if config.prefs.hyperlinkProvider == 'google': + return gfinance_link(symbol, exchange, service, days, brief, text) + return yahoo_link(symbol, service, brief, text) + +def quote_link(ticker, exchange, service='telegram', days=1): + """finance_link, but to Yahoo for what Google Finance has no page for: Taipei Exchange, crypto (CCC) and indices (^).""" + if exchange in ('Taipei Exchange', 'CCC') or ticker.startswith('^'): + return yahoo_link(ticker, service) + return finance_link(ticker, exchange, service, days=days) + +def gfinance_link(symbol, exchange, service='telegram', days=1, brief=True, text=None): + window = '1D' + if not days: + days = 1 + if days > 1: + window = '5D' + if days > 7: + window = '1M' + if days > 31: + window = '6M' + if days > 183: + window = '1Y' + if days > 365: + window = '5Y' + if days > 1825: + window = 'Max' + url = "https://www.google.com/finance/quote/" + if '.' in exchange: + exchange = exchange.split('.')[1] + exchange = markets.transform_to_google(exchange) + symbol_short = symbol.replace(':', '.').split('.')[0] + symbol_short = symbol_short.replace('-', '.') # class shares e.g. BRK.A + ticker = symbol_short + ':' + exchange + if brief and not text: + text = symbol_short + elif not text: + if '.' in symbol: + text = ticker + else: + text = symbol # US + if service in {'telegram', 'discord', 'slack'} and config.prefs.hyperlink: + return link(url + ticker + '?window=' + window, text, service) + return symbol diff --git a/lib/markets.py b/lib/markets.py new file mode 100644 index 00000000..7b5855ff --- /dev/null +++ b/lib/markets.py @@ -0,0 +1,132 @@ +"""Markets: flags and currencies by market, exchange names, and ticker translation between Sharesight, Google and Yahoo.""" + +MARKETS = { # Sharesight market code -> (flag, currency); no currency where a market quotes in several (LSE and HKEX allow non-home currencies) + 'ASX': ('🇦🇺', 'AUD'), + 'BOM': ('🇮🇳', 'INR'), 'NSE': ('🇮🇳', 'INR'), + 'BMV': ('🇲🇽', 'MXN'), + 'BKK': ('🇹🇭', 'THB'), + 'BVMF': ('🇧🇷', 'BRL'), + 'SHE': ('🇨🇳', 'CNY'), 'SHA': ('🇨🇳', 'CNY'), + 'CPSE': ('🇩🇰', 'DKK'), + **dict.fromkeys(('EURONEXT', 'AMS', 'ATH', 'BIT', 'BME', 'DUB', 'EBR', 'EPA', 'ETR', 'FWB', 'FRA', 'VIE'), ('🇪🇺', 'EUR')), + 'HKG': ('🇭🇰', None), + 'ICSE': ('🇮🇸', 'ISK'), + 'JSE': ('🇿🇦', 'ZAR'), + 'KRX': ('🇰🇷', 'KRW'), 'KOSDAQ': ('🇰🇷', 'KRW'), + 'LSE': ('🇬🇧', 'GBP'), # better than trades.py using brokerage_currency_code, which is AUD on CMC + 'MISX': ('🇷🇺', 'RUB'), + 'OM': ('🇸🇪', 'SEK'), 'STO': ('🇸🇪', 'SEK'), + 'SWX': ('🇨🇭', 'CHF'), 'VTX': ('🇨🇭', 'CHF'), + 'TAI': ('🇹🇼', 'TWD'), 'TPE': ('🇹🇼', 'TWD'), + 'TASE': ('🇮🇱', 'ILS'), + 'NZX': ('🇳🇿', 'NZD'), + 'OB': ('🇳🇴', 'NOK'), + 'SGX': ('🇸🇬', 'SGD'), + 'TSE': ('🇯🇵', 'JPY'), + 'TSX': ('🇨🇦', 'CAD'), + 'NASDAQ': ('🇺🇸', 'USD'), 'NYSE': ('🇺🇸', 'USD'), 'BATS': ('🇺🇸', 'USD'), 'AMEX': ('🇺🇸', None), + 'WAR': ('🇵🇱', 'PLN'), +} + +YAHOO_COUNTRY = { # country -> its Yahoo ticker suffix, for a listing that has none (cal.py) + 'Argentina': 'BA', + 'Austria': 'VI', + 'Australia': 'AX', + 'Belgium': 'BR', + 'Brazil': 'SA', + 'Canada': 'TO', + 'Chile': 'SN', + 'Colombia': 'CL', + 'Czech Republic': 'PR', + 'Denmark': 'CO', + 'Estonia': 'TL', + 'Finland': 'HE', + 'France': 'PA', + 'Germany': 'DE', + 'Hungary': 'BD', + 'Iceland': 'IC', + 'India': 'NS', + 'Indonesia': 'JK', + 'Ireland': 'IR', + 'Israel': 'TA', + 'Italy': 'MI', + 'Latvia': 'RG', + 'Lithuania': 'VS', + 'Mexico': 'MX', + 'Netherlands': 'AS', + 'New Zealand': 'NZ', + 'Norway': 'OL', + 'Portugal': 'LS', + 'Qatar': 'QA', + 'Saudi Arabia': 'SR', + 'South Africa': 'JO', + 'Spain': 'MC', + 'Sweden': 'ST', + 'Switzerland': 'SW', + 'Thailand': 'BK', + 'Turkey': 'IS', + 'United Kingdom': 'L', + 'Venezuela': 'CR' + # markets with numeric tickers purposefully omitted +} + +def flag_from_market(market): + if market in MARKETS: + return MARKETS[market][0] + return '🇺🇸' if market.lower().startswith(('nasdaq', 'nyse')) else '' + +TICKER_FLAGS = {'AS': '🇳🇱', 'AX': '🇦🇺', 'HK': '🇭🇰', 'KS': '🇰🇷', 'KQ': '🇰🇷', 'L': '🇬🇧', 'NS': '🇮🇳', 'NZ': '🇳🇿', 'SI': '🇸🇬', 'T': '🇯🇵', 'TA': '🇮🇱', + 'TW': '🇹🇼', 'TWO': '🇹🇼', 'TO': '🇨🇦'} # Yahoo suffix -> flag + +def flag_from_ticker(ticker): + return TICKER_FLAGS.get(ticker.split('.')[1], '') if '.' in ticker else '🇺🇸' + +def currency_from_market(market): + return MARKETS.get(market, (None, None))[1] + +VENUES = { # the names Yahoo, Sharesight and Google give an exchange -> (Google Finance code, country) + **dict.fromkeys(('OTC', 'PNK', 'Other OTC', 'OTCPK'), ('OTCMKTS', None)), + **dict.fromkeys(('TO', 'TOR', 'Toronto'), ('TSE', 'CA')), + **dict.fromkeys(('TW', 'TWO', 'TAI', 'Taiwan', 'Taipei Exchange', 'Taipei'), ('TPE', 'TW')), + **dict.fromkeys(('HK', 'HKG', 'HKSE'), ('HKG', 'HK')), + **dict.fromkeys(('KQ', 'KOE', 'KOSDAQ'), ('KOSDAQ', 'KR')), + **dict.fromkeys(('KS', 'KRX', 'KSE', 'KSC'), ('KRX', 'KR')), + **dict.fromkeys(('L', 'LSE', 'London'), ('LON', 'UK')), + **dict.fromkeys(('T', 'TYO', 'JPX', 'Tokyo'), ('TYO', 'JP')), + **dict.fromkeys(('TA', 'TLV', 'Tel Aviv'), ('TLV', 'IL')), + **dict.fromkeys(('NZSE', 'NZE', 'New Zealand', 'Auckland'), (None, 'NZ')), +} + +def transform_to_google(exchange): + if 'Nasdaq' in exchange: + return 'NASDAQ' + if exchange == 'Cboe US': + return 'BATS' + return VENUES.get(exchange, (None, None))[0] or exchange + +def exchange_human(exchange): + if 'Nasdaq' in exchange or 'NYSE' in exchange or 'Other OTC' in exchange: + return 'US' + return VENUES.get(exchange, (None, None))[1] or exchange + +YAHOO_SUFFIX = { # exchange code (Sharesight, Google) -> Yahoo's ticker suffix + 'ASX': 'AX', 'HKG': 'HK', 'KRX': 'KS', 'KOSDAQ': 'KQ', 'LSE': 'L', 'NZX': 'NZ', 'SGX': 'SI', + 'TAI': 'TW', # Taiwan in YF and Sharesight; TPE in GF + 'TPE': 'TWO', # Taipei in YF, Taiwan in GF (GF format will not work as input); missing from GF + 'TSE': 'T', 'TYO': 'T', # Tokyo: TSE in Sharesight (Google's TSE is Toronto, which Sharesight calls TSX), TYO in GF + 'TSX': 'TO', 'TLV': 'TA', +} + +def transform_to_yahoo(ticker, market=None): + split = ticker.replace(':', '.').split('.') + if len(split) > 1: + ticker = split[0] + market = split[1] + if not market: + return ticker + market = YAHOO_SUFFIX.get(market, market) + if market in {'NASDAQ', 'NYSE', 'BATS', 'OTCMKTS', 'AMEX', 'NYSEARCA'}: # US venues: Yahoo symbols carry no suffix + return ticker + if market in {'A', 'B', 'C'}: # class shares + return ticker + '-' + market + return ticker + '.' + market diff --git a/lib/util.py b/lib/render.py similarity index 61% rename from lib/util.py rename to lib/render.py index a76fa71f..ce03aaf7 100644 --- a/lib/util.py +++ b/lib/render.py @@ -1,313 +1,8 @@ +"""Charts drawn as PNGs with matplotlib: the bot draws them in one native thread, off the gevent hub (render_off_hub).""" import functools import io -import datetime -import re from lib import config # first: sets MPLCONFIGDIR before matplotlib is imported (lazily, by the chart functions) -from lib import sharesight -from lib import watchlist - - -TITLE_TRIMS = [ # transform_title: (text, replacement), in order, as each sees what the ones before it made - (' - ', ' '), ('Roundhill ETF Trust ', ''), ('Class A Common Stock', ''), - ('BlackRock Institutional Trust Company N.A.', ''), ('Listed Funds Trust', ''), ('GraniteShares ETF Trust', ''), - ('ProShares Trust', ''), ('VanEck ETF Trust', ''), ('Direxion Shares ETF Trust', ''), (' of California', ''), - ('Magnificent', 'Mag'), ('First Trust NASDAQ Clean Edge Green Energy Index Fund', 'First Trust Clean Energy'), - ('First Trust Exchange-Traded Fund III First Trust Nasdaq Clean Edge Clean Energy Index Fund', 'First Trust Green Energy'), - ('Mirae Asset Global Investments (Hong Kong) Limited', ''), ('Space Exploration Technologies', 'SpaceX'), - ('Exchange Traded Funds Series OFC', ''), ('Global X Global X', 'Global X'), ('Tema ETF Trust', ''), (' AUD', ' '), - (' USD', ' '), ('New York Shares', ''), ('China Electric Vehicle and Battery ETF', 'China EV ETF'), - ('China Electric Vehicle ETF', 'China EV ETF'), ('Atlantica Sustainable Infrastructure', 'Atlantica Sustainable'), - ('Advanced Micro Devices', 'AMD'), ('Taiwan Semiconductor Manufacturing', 'TSM'), - ('Flight Centre Travel', 'Flight Centre'), ('Global X Funds ', ''), (' of Australia', ''), - ('National Australia Bank', 'NAB'), (' PLC', ''), (' p.l.c.', ''), (' P.l.c.', ''), (' P.L.C.', ''), (' Units', '') -] - -TITLE_SHORTENINGS = [ # and these after the suffixes come off - ('Microbalifesciences', 'Microba Life Sciences'), ('Walt Disney Co (The)', 'Disney'), ('Lisenergylimited', 'LI-S Energy'), - ('Invesco Capital Management LLC ', ''), ('QUALCOMM', 'Qualcomm'), ('Ordinary Shares Class A', ''), - ('Ordinary Shares Class C', ''), ('Lbt Innovations', 'LBT Innovations'), - ('Vanguard Information Technology', 'Vanguard Infotech'), ('The ', ''), (' Shares', ''), ('Rea ', 'REA '), - ('Csl ', 'CSL '), ('Battery ', 'Batt '), ('.com', ''), ('Etf', 'ETF'), ('N.V.', ''), (' NV ', ' '), ('New York Re', ''), - (' Australian', ' Aus'), (' Australia', ' Aus'), (' Infrastructure', ' Infra'), (' Manufacturing Company', ' '), - (' Limited', ' '), (' Ltd', ' '), (' Holdings', ' '), (' Holding', ' '), (' (Holdings)', ''), (' Corporation', ' '), - (' Incorporated', ' '), (' incorporated', ' '), (' Technologies', ' '), (' Technology', ' '), (' Enterprises', ' '), - (' Enterprise', ' '), (' Enterpr', ' '), (' Ventures', ' '), (' Resources', ' '), (' (Intrada', ''), (' Structured', ''), - (' Co.', ' '), (' Corp.', ' '), (' Corp', ' '), (' Tech ', ' '), (' Company', ' '), (' Group', ' '), (', Inc', ' '), - (' Inc', ' '), (' Plc', ' '), (' plc', ' '), (' Index', ' '), (' ADR', ' '), (' Daily', ' '), (' Long', ' '), - (' Bull', ' '), (' .', ' '), (' ,', ' ') -] - -def transform_title(title): - """A company or fund name as the reports show it: shorter, without fund-trust boilerplate and legal suffixes.""" - title = title.replace(' FPO', '') - if title.isupper() or title.islower(): - title = title.title() - for text, replacement in TITLE_TRIMS: - title = title.replace(text, replacement) - for suffix in (' AG', ' SE', ' Se'): - title = title.removesuffix(suffix) - title = re.sub(r'\[\w+\]$', '', title) - for text, replacement in TITLE_SHORTENINGS: - title = title.replace(text, replacement) - if title.islower(): - title = title.title() - for suffix in (' &', ' and'): - title = title.strip().removesuffix(suffix) - return ' '.join(title.replace(' Usd', ' USD').replace('Ishares', 'iShares').split()) - -MARKETS = { # Sharesight market code -> (flag, currency); no currency where a market quotes in several (LSE and HKEX allow non-home currencies) - 'ASX': ('🇦🇺', 'AUD'), - 'BOM': ('🇮🇳', 'INR'), 'NSE': ('🇮🇳', 'INR'), - 'BMV': ('🇲🇽', 'MXN'), - 'BKK': ('🇹🇭', 'THB'), - 'BVMF': ('🇧🇷', 'BRL'), - 'SHE': ('🇨🇳', 'CNY'), 'SHA': ('🇨🇳', 'CNY'), - 'CPSE': ('🇩🇰', 'DKK'), - **dict.fromkeys(('EURONEXT', 'AMS', 'ATH', 'BIT', 'BME', 'DUB', 'EBR', 'EPA', 'ETR', 'FWB', 'FRA', 'VIE'), ('🇪🇺', 'EUR')), - 'HKG': ('🇭🇰', None), - 'ICSE': ('🇮🇸', 'ISK'), - 'JSE': ('🇿🇦', 'ZAR'), - 'KRX': ('🇰🇷', 'KRW'), 'KOSDAQ': ('🇰🇷', 'KRW'), - 'LSE': ('🇬🇧', 'GBP'), # better than trades.py using brokerage_currency_code, which is AUD on CMC - 'MISX': ('🇷🇺', 'RUB'), - 'OM': ('🇸🇪', 'SEK'), 'STO': ('🇸🇪', 'SEK'), - 'SWX': ('🇨🇭', 'CHF'), 'VTX': ('🇨🇭', 'CHF'), - 'TAI': ('🇹🇼', 'TWD'), 'TPE': ('🇹🇼', 'TWD'), - 'TASE': ('🇮🇱', 'ILS'), - 'NZX': ('🇳🇿', 'NZD'), - 'OB': ('🇳🇴', 'NOK'), - 'SGX': ('🇸🇬', 'SGD'), - 'TSE': ('🇯🇵', 'JPY'), - 'TSX': ('🇨🇦', 'CAD'), - 'NASDAQ': ('🇺🇸', 'USD'), 'NYSE': ('🇺🇸', 'USD'), 'BATS': ('🇺🇸', 'USD'), 'AMEX': ('🇺🇸', None), - 'WAR': ('🇵🇱', 'PLN'), -} - -YAHOO_COUNTRY = { # country -> its Yahoo ticker suffix, for a listing that has none (cal.py) - 'Argentina': 'BA', - 'Austria': 'VI', - 'Australia': 'AX', - 'Belgium': 'BR', - 'Brazil': 'SA', - 'Canada': 'TO', - 'Chile': 'SN', - 'Colombia': 'CL', - 'Czech Republic': 'PR', - 'Denmark': 'CO', - 'Estonia': 'TL', - 'Finland': 'HE', - 'France': 'PA', - 'Germany': 'DE', - 'Hungary': 'BD', - 'Iceland': 'IC', - 'India': 'NS', - 'Indonesia': 'JK', - 'Ireland': 'IR', - 'Israel': 'TA', - 'Italy': 'MI', - 'Latvia': 'RG', - 'Lithuania': 'VS', - 'Mexico': 'MX', - 'Netherlands': 'AS', - 'New Zealand': 'NZ', - 'Norway': 'OL', - 'Portugal': 'LS', - 'Qatar': 'QA', - 'Saudi Arabia': 'SR', - 'South Africa': 'JO', - 'Spain': 'MC', - 'Sweden': 'ST', - 'Switzerland': 'SW', - 'Thailand': 'BK', - 'Turkey': 'IS', - 'United Kingdom': 'L', - 'Venezuela': 'CR' - # markets with numeric tickers purposefully omitted -} - -def flag_from_market(market): - if market in MARKETS: - return MARKETS[market][0] - return '🇺🇸' if market.lower().startswith(('nasdaq', 'nyse')) else '' - -TICKER_FLAGS = {'AS': '🇳🇱', 'AX': '🇦🇺', 'HK': '🇭🇰', 'KS': '🇰🇷', 'KQ': '🇰🇷', 'L': '🇬🇧', 'NS': '🇮🇳', 'NZ': '🇳🇿', 'SI': '🇸🇬', 'T': '🇯🇵', 'TA': '🇮🇱', - 'TW': '🇹🇼', 'TWO': '🇹🇼', 'TO': '🇨🇦'} # Yahoo suffix -> flag - -def flag_from_ticker(ticker): - return TICKER_FLAGS.get(ticker.split('.')[1], '') if '.' in ticker else '🇺🇸' - -def currency_from_market(market): - return MARKETS.get(market, (None, None))[1] - -def humanUnits(value, decimal_places=2): - """1234567 -> '1.23 M'.""" - for unit in ['', 'K', 'M', 'B', 'T', 'Q']: - if value < 1000.0 or unit == 'Q': - break - value /= 1000.0 - return f"{value:.{decimal_places}f} {unit}".rstrip() - -def yahoo_link(ticker, service='telegram', brief=True, text=None): - if not text: - text = ticker.split('.')[0] if brief else ticker - return link("https://au.finance.yahoo.com/quote/" + ticker, text, service) - -def security_line(flag, title, link): - """'🇦🇺 Title (link)': how a report line names a security (without a leading space where its market has no flag).""" - return f"{flag} {title} ({link})" if flag else f"{title} ({link})" - -def link(url, text, service='telegram'): - """text linking to url in the service's markup: HTML on Telegram, on Slack, a masked link on Discord (the angle - brackets keep its preview away; brackets in the text and parentheses in the url would end it early).""" - if not config.prefs.hyperlink: - return text - if service == 'telegram': - return '' + text + '' - if service == 'slack': - return '<' + url + '|' + text + '>' - if service == 'discord': - return '[' + text.replace('[', '\\[').replace(']', '\\]') + '](<' + url.replace('(', '%28').replace(')', '%29') + '>)' - return text - -def finance_link(symbol, exchange, service='telegram', days=1, brief=True, text=None): - if config.prefs.hyperlinkProvider == 'google': - return gfinance_link(symbol, exchange, service, days, brief, text) - return yahoo_link(symbol, service, brief, text) - -def quote_link(ticker, exchange, service='telegram', days=1): - """finance_link, but to Yahoo for what Google Finance has no page for: Taipei Exchange, crypto (CCC) and indices (^).""" - if exchange in ('Taipei Exchange', 'CCC') or ticker.startswith('^'): - return yahoo_link(ticker, service) - return finance_link(ticker, exchange, service, days=days) - -def gfinance_link(symbol, exchange, service='telegram', days=1, brief=True, text=None): - window = '1D' - if not days: - days = 1 - if days > 1: - window = '5D' - if days > 7: - window = '1M' - if days > 31: - window = '6M' - if days > 183: - window = '1Y' - if days > 365: - window = '5Y' - if days > 1825: - window = 'Max' - url = "https://www.google.com/finance/quote/" - if '.' in exchange: - exchange = exchange.split('.')[1] - exchange = transform_to_google(exchange) - symbol_short = symbol.replace(':', '.').split('.')[0] - symbol_short = symbol_short.replace('-', '.') # class shares e.g. BRK.A - ticker = symbol_short + ':' + exchange - if brief and not text: - text = symbol_short - elif not text: - if '.' in symbol: - text = ticker - else: - text = symbol # US - if service in {'telegram', 'discord', 'slack'} and config.prefs.hyperlink: - return link(url + ticker + '?window=' + window, text, service) - return symbol - -VENUES = { # the names Yahoo, Sharesight and Google give an exchange -> (Google Finance code, country) - **dict.fromkeys(('OTC', 'PNK', 'Other OTC', 'OTCPK'), ('OTCMKTS', None)), - **dict.fromkeys(('TO', 'TOR', 'Toronto'), ('TSE', 'CA')), - **dict.fromkeys(('TW', 'TWO', 'TAI', 'Taiwan', 'Taipei Exchange', 'Taipei'), ('TPE', 'TW')), - **dict.fromkeys(('HK', 'HKG', 'HKSE'), ('HKG', 'HK')), - **dict.fromkeys(('KQ', 'KOE', 'KOSDAQ'), ('KOSDAQ', 'KR')), - **dict.fromkeys(('KS', 'KRX', 'KSE', 'KSC'), ('KRX', 'KR')), - **dict.fromkeys(('L', 'LSE', 'London'), ('LON', 'UK')), - **dict.fromkeys(('T', 'TYO', 'JPX', 'Tokyo'), ('TYO', 'JP')), - **dict.fromkeys(('TA', 'TLV', 'Tel Aviv'), ('TLV', 'IL')), - **dict.fromkeys(('NZSE', 'NZE', 'New Zealand', 'Auckland'), (None, 'NZ')), -} - -def transform_to_google(exchange): - if 'Nasdaq' in exchange: - return 'NASDAQ' - if exchange == 'Cboe US': - return 'BATS' - return VENUES.get(exchange, (None, None))[0] or exchange - -def exchange_human(exchange): - if 'Nasdaq' in exchange or 'NYSE' in exchange or 'Other OTC' in exchange: - return 'US' - return VENUES.get(exchange, (None, None))[1] or exchange - -YAHOO_SUFFIX = { # exchange code (Sharesight, Google) -> Yahoo's ticker suffix - 'ASX': 'AX', 'HKG': 'HK', 'KRX': 'KS', 'KOSDAQ': 'KQ', 'LSE': 'L', 'NZX': 'NZ', 'SGX': 'SI', - 'TAI': 'TW', # Taiwan in YF and Sharesight; TPE in GF - 'TPE': 'TWO', # Taipei in YF, Taiwan in GF (GF format will not work as input); missing from GF - 'TSE': 'T', 'TYO': 'T', # Tokyo: TSE in Sharesight (Google's TSE is Toronto, which Sharesight calls TSX), TYO in GF - 'TSX': 'TO', 'TLV': 'TA', -} - -def transform_to_yahoo(ticker, market=None): - split = ticker.replace(':', '.').split('.') - if len(split) > 1: - ticker = split[0] - market = split[1] - if not market: - return ticker - market = YAHOO_SUFFIX.get(market, market) - if market in {'NASDAQ', 'NYSE', 'BATS', 'OTCMKTS', 'AMEX', 'NYSEARCA'}: # US venues: Yahoo symbols carry no suffix - return ticker - if market in {'A', 'B', 'C'}: # class shares - return ticker + '-' + market - return ticker + '.' + market - -def strip_url(url): - url = url.removeprefix('https://www.') - url = url.removeprefix('http://www.') - url = url.removeprefix('https://') - url = url.removeprefix('http://') - return url - -def make_paragraphs(walloftext): - """The text in paragraphs, each ending with the first sentence that takes it past 22 words.""" - paragraphs, buffer = [], [] - for word in walloftext.split(): - buffer.append(word) - if word.endswith(('!', '.')) and len(buffer) > 22: - paragraphs.append(' '.join(buffer)) - buffer = [] - if buffer: # the rest: it used to be dropped, and a short text with it - paragraphs.append(' '.join(buffer)) - return '\n\n'.join(paragraphs) - -def date_short(d, year=False): - """5 Oct (or 5 Oct 2026).""" - return f"{d.day} {d:%b}" + (f" {d.year}" if year else "") - -def date_range_english(days, end): - """'28 Sep - 5 Oct 2026' for the `days` ending on `end`; both ends carry their year when the range crosses a year boundary.""" - start = end - datetime.timedelta(days=days) - return f"{date_short(start, start.year != end.year)} - {date_short(end, True)}" - -def days_english(days, prefix='the past ', article=''): - if days is None or days == 0: - return 'today' - elif days == 1: - return prefix + article + 'day' - elif days == 7: - return prefix + article + 'week' - elif days == 30: - return prefix + article + 'month' - elif days == 365: - return prefix + article + 'year' - elif days % 7 == 0: - return prefix + str(int(days/7)) + ' weeks' - elif days % 30 == 0: - return prefix + str(int(days/30)) + ' months' - elif days % 365 == 0: - return prefix + str(int(days/365)) + ' years' - else: - return prefix + str(days) + ' days' +from lib import formats def fit_left_margin(fig, ax, pad_px=18): """Widen the left margin so the longest Y tick label is never clipped; the right edge stays put.""" @@ -497,7 +192,7 @@ def hbar_chart(rows, title, subtitle='', value_fmt=None, xlim=None, session=None Blue for >= 0, red below. Each row's label sits at the bar's base and its value at the tip. rows: [(label, value)]; a (label, None) row is a blank spacer. xlim zooms the value axis (bars then start at its left edge). session: 'midsession' or 'premarket' washes the background green or yellow.""" - value_fmt = value_fmt or (lambda v: signed_percent(round(v))) + value_fmt = value_fmt or (lambda v: formats.signed_percent(round(v))) bg, ink, ink2, _ = PALETTE up, down = '#3987e5', '#fb7185' if session in SESSION_TINTS: @@ -738,70 +433,3 @@ def pie_chart(rows, title, subtitle=''): def bar_graph(top_rows, bottom_rows, title, subtitle=''): """Top performers then, after a gap, the worst performers: the same order as the caption's two lists.""" return hbar_chart(list(top_rows) + [('', None)] + list(bottom_rows), title, subtitle) - -def signed_percent(value, decimals=None): - """12 -> '+12%', -3.5 -> '-3.5%', 0 -> '0%'; thousands separated. decimals=None keeps the value's own digits.""" - if value == 0: - return '0%' - return f"{value:+,}%" if decimals is None else f"{value:+,.{decimals}f}%" - -def get_emoji(number): - if number > 0: - return '🔼' - elif number < 0: - return '🔻' - else: - return '▪️' - -def days_from_human_days(arg): - """7, 7d, 2w, 3m, 1y or ytd -> days. Raises ValueError for anything else.""" - arg = arg.upper() - if arg == 'YTD': - today = datetime.datetime.now().date() - return (today - today.replace(day=1, month=1)).days - for suffix, days in (('D', 1), ('W', 7), ('M', 30)): - try: - return int(arg.removesuffix(suffix)) * days - except ValueError: - pass - return int(arg.removesuffix('Y')) * 365 - -def get_holdings_and_watchlist(): - tickers = set(sharesight.tickers()) - tickers.update(watchlist.tickers()) - if 'GOOG' in tickers and 'GOOGL' in tickers: - tickers.remove("GOOGL") - return sorted(tickers) - -def ordinal(num): - value = str(num) - if len(value) > 1: - secondToLastDigit = value[-2] - if secondToLastDigit == '1': - return 'th' - lastDigit = value[-1] - if (lastDigit == '1'): - return 'st' - elif (lastDigit == '2'): - return 'nd' - elif (lastDigit == '3'): - return 'rd' - else: - return 'th' - -def td_to_human(timedelta): - days, remainder = divmod(timedelta.total_seconds(), 86400) - hours, remainder = divmod(remainder, 3600) - minutes, seconds = divmod(remainder, 60) - parts = [] - if days: - parts.append(f"{int(days)} day{'s' if days != 1 else ''}") - if hours: - parts.append(f"{int(hours)} hour{'s' if hours != 1 else ''}") - if minutes: - parts.append(f"{int(minutes)} minute{'s' if minutes != 1 else ''}") - if seconds: - parts.append(f"{int(seconds)} second{'s' if seconds != 1 else ''}") - if not parts: - return "0 seconds" - return ", ".join(parts) diff --git a/lib/reports.py b/lib/reports.py index a24004c2..5d4d3b71 100755 --- a/lib/reports.py +++ b/lib/reports.py @@ -6,7 +6,10 @@ from lib import cache from lib import watchlist from lib import sharesight -from lib import util +from lib import formats +from lib import links +from lib import markets +from lib import render from lib import webhook from lib import yahoo from lib import simplywallst @@ -21,8 +24,8 @@ def last_col(line): def holding_line(holding, service, text=None): """'🇦🇺 Title (CODE)' for a Sharesight holding, CODE (or `text`) linking to the holding on Sharesight.""" - link = util.link(f"https://portfolio.sharesight.com/holdings/{holding['holding_id']}/dashboard", holding['code'] if text is None else text, service) - return f"{util.flag_from_market(holding['market_code'])} {util.transform_title(holding['name'])} ({link})" + link = links.link(f"https://portfolio.sharesight.com/holdings/{holding['holding_id']}/dashboard", holding['code'] if text is None else text, service) + return f"{markets.flag_from_market(holding['market_code'])} {formats.transform_title(holding['name'])} ({link})" def doDelta(inputList): deltaString = '' @@ -59,10 +62,10 @@ def doDelta(inputList): def prepare_watchlist(service, user, action=None, ticker=None): """The .watchlist reply: the companies watched, or one line on adding ('add') or removing ('delete') a ticker.""" if action: - return [watchlist_change(service, user, action, util.transform_to_yahoo(ticker))] + return [watchlist_change(service, user, action, markets.transform_to_yahoo(ticker))] watched = watchlist.tickers() market_data = yahoo.fetch(watched) if watched else {} - lines = sorted((f"{util.flag_from_ticker(t)} {quote['profile_title']} ({util.finance_link(t, quote.get('profile_exchange'), service)})" + lines = sorted((f"{markets.flag_from_ticker(t)} {quote['profile_title']} ({links.finance_link(t, quote.get('profile_exchange'), service)})" for t, quote in market_data.items()), key=lambda line: re.findall('[A-Z].*', line)) # by name, the flag aside if not lines: return ['Watchlist is empty. Try ".watchlist add SYMBOL" to create it'] @@ -72,7 +75,7 @@ def watchlist_change(service, user, action, asked): """The reply to adding or removing `asked` (a Yahoo ticker).""" def described(ticker, quote): # "Full Name (TICKER)", bold quote = quote or {} - link = util.finance_link(ticker, quote.get('profile_exchange') or ticker, service) + link = links.finance_link(ticker, quote.get('profile_exchange') or ticker, service) return webhook.bold(f"{quote['profile_title']} ({link})" if quote.get('profile_title') else link, service) if action == 'delete': if asked not in watchlist.tickers(): @@ -103,11 +106,11 @@ def prepare_compare(service, args): args.pop() else: try: - period_days = util.days_from_human_days(args[-1]) + period_days = formats.days_from_human_days(args[-1]) args.pop() except ValueError: pass - tickers = list(dict.fromkeys(util.transform_to_yahoo(a.upper()) for a in args)) # de-duplicated, order kept + tickers = list(dict.fromkeys(markets.transform_to_yahoo(a.upper()) for a in args)) # de-duplicated, order kept if not 2 <= len(tickers) <= 6: return usage, None, tickers, period_days spec = period_days or ('max' if full_history else 'default') @@ -146,17 +149,17 @@ def prepare_compare(service, args): if any(len(series) < 2 for _, series in cropped): raise RuntimeError("not enough price history in that period to compare") span_days = (datetime.datetime.now().date() - start.date()).days - period_label = util.days_english(period_days) if period_days and not note else f"{span_days} days" + period_label = formats.days_english(period_days) if period_days and not note else f"{span_days} days" by_performance = sorted(cropped, key=lambda item: item[1].iloc[-1] / item[1].iloc[0], reverse=True) title = ' vs '.join(t.split('.')[0] for t, _ in by_performance) # best first; line colours stay tied to input order subtitle = f"% change over {period_label}, since {start:%d %b %Y}" + note - image = util.compare_graph([(t.split('.')[0], series) for t, series in cropped], title, subtitle) + image = render.compare_graph([(t.split('.')[0], series) for t, series in cropped], title, subtitle) image.seek(0) caption = [webhook.bold(f"{period_label}, since {start:%d %b %Y}", service) + note] results = sorted(((series.iloc[-1] / series.iloc[0] - 1) * 100, t) for t, series in cropped) for pct, t in reversed(results): - link = util.finance_link(t, exchanges[t], service, days=span_days, brief=False) - caption.append(f"{util.get_emoji(pct)} {names[t]} ({link}): {pct:+,.1f}%") + link = links.finance_link(t, exchanges[t], service, days=span_days, brief=False) + caption.append(f"{formats.get_emoji(pct)} {names[t]} ({link}): {pct:+,.1f}%") if config.prefs.cache: cache.write_bytes(image_cache_file, image) cache.write(caption_cache_file, caption) @@ -245,7 +248,7 @@ def prepare_help(service, botName): else: payload.append(botName + ' price') payload.append("") - payload.append(f"Full help on {util.link('https://github.com/robdevops/finbot', 'GitHub', service)}") + payload.append(f"Full help on {links.link('https://github.com/robdevops/finbot', 'GitHub', service)}") return payload def prepare_holdings_payload(portfolio_name, service): @@ -307,23 +310,23 @@ def prepare_marketcap_payload(service, action='top', length=15): """The `length` tracked stocks of largest (action 'top') or smallest ('bottom') market cap, compared in US dollars at Yahoo's exchange rates: a market cap in won or yen is a far bigger number. Any other action is one ticker's, in its own currency too.""" if action not in ('top', 'bottom'): - ticker = util.transform_to_yahoo(action.upper()) + ticker = markets.transform_to_yahoo(action.upper()) data = yahoo.add_usd_market_caps(yahoo.fetch_detail(ticker, 600)).get(ticker, {}) if 'market_cap' not in data: return [f"Mkt cap not found for {ticker}"] currency = yahoo.market_cap_currency(data) or '' - cap = f"{currency} {util.humanUnits(data['market_cap'])}".strip() + cap = f"{currency} {formats.humanUnits(data['market_cap'])}".strip() if currency != 'USD' and 'market_cap_usd' in data: - cap += f" (USD {util.humanUnits(data['market_cap_usd'])})" - link = util.finance_link(ticker, data.get('profile_exchange', ''), service) - return [util.security_line(util.flag_from_ticker(ticker), data.get('profile_title', ticker), link) + f" mkt cap: {cap}"] + cap += f" (USD {formats.humanUnits(data['market_cap_usd'])})" + link = links.finance_link(ticker, data.get('profile_exchange', ''), service) + return [formats.security_line(markets.flag_from_ticker(ticker), data.get('profile_title', ticker), link) + f" mkt cap: {cap}"] caps = [] # (market cap in US dollars, line) - for ticker, data in yahoo.add_usd_market_caps(yahoo.fetch(util.get_holdings_and_watchlist())).items(): + for ticker, data in yahoo.add_usd_market_caps(yahoo.fetch(watchlist.with_holdings())).items(): if 'market_cap_usd' not in data: print(ticker, "no market cap" if 'market_cap' not in data else f"no exchange rate for {data.get('currency')}", file=sys.stderr) continue - link = util.finance_link(ticker, data['profile_exchange'], service) - caps.append((data['market_cap_usd'], util.security_line(util.flag_from_ticker(ticker), data['profile_title'], link) + f" mkt cap: {util.humanUnits(data['market_cap_usd'])}")) + link = links.finance_link(ticker, data['profile_exchange'], service) + caps.append((data['market_cap_usd'], formats.security_line(markets.flag_from_ticker(ticker), data['profile_title'], link) + f" mkt cap: {formats.humanUnits(data['market_cap_usd'])}")) if not caps: return ['No market cap data found'] caps.sort(key=lambda cap: cap[0], reverse=action == 'top') @@ -334,14 +337,14 @@ def prepare_rating_payload(service, action, length=15): and the consensus says little: ASX small caps covered by two to six brokers are nearly all a perfect 1.0 buy), the strongest rating first (1 is a strong buy, 5 a strong sell); on equal ratings, the one more analysts give first.""" found = [] # (rating, analysts, line, ticker) - for ticker, data in yahoo.fetch_details(util.get_holdings_and_watchlist()).items(): + for ticker, data in yahoo.fetch_details(watchlist.with_holdings()).items(): if 'recommend' not in data or data['recommend_analysts'] < config.prefs.min_analysts: continue recommend = data['recommend'].replace('_', ' ') if (action == 'buy' and 'buy' in recommend) or (action == 'sell' and recommend in ('sell', 'underperform')): rating, analysts = round(data['recommend_index'], 2), data['recommend_analysts'] - link = util.finance_link(ticker, data['profile_exchange'], service) - found.append((rating, analysts, f"{util.flag_from_ticker(ticker)} {data['profile_title']} ({link}) {rating} ({analysts} analysts)", ticker)) + link = links.finance_link(ticker, data['profile_exchange'], service) + found.append((rating, analysts, f"{markets.flag_from_ticker(ticker)} {data['profile_title']} ({link}) {rating} ({analysts} analysts)", ticker)) found = sorted(found, key=lambda row: (row[0] if action == 'buy' else -row[0], -row[1]))[:length] payload = [line for _, _, line, _ in found] if not payload: @@ -360,7 +363,7 @@ def prepare_value_payload(service, action='pe', ticker_select=None, length=15): take only stocks that min_analysts or more analysts cover, as .buy does.""" payload = [] chart_rows = [] # (ticker, ratio) - tickers = [ticker_select] if ticker_select else util.get_holdings_and_watchlist() + tickers = [ticker_select] if ticker_select else watchlist.with_holdings() market_data = yahoo.fetch(tickers) if action in ('peg', 'bottom peg', 'negative peg'): # the PEG is in Yahoo's details market_data |= yahoo.fetch_details(market_data, strict=bool(ticker_select)) @@ -403,8 +406,8 @@ def prepare_value_payload(service, action='pe', ticker_select=None, length=15): if ratio is None: continue profile_title = market_data.get(ticker).get('profile_title') - ticker_link = util.finance_link(ticker, market_data.get(ticker).get('profile_exchange'), service) - flag = util.flag_from_ticker(ticker) + ticker_link = links.finance_link(ticker, market_data.get(ticker).get('profile_exchange'), service) + flag = markets.flag_from_ticker(ticker) if ticker_select and action in {'pe', 'forward pe'}: parts = [] if trailing_ratio is not None: @@ -437,13 +440,13 @@ def prepare_value_payload(service, action='pe', ticker_select=None, length=15): def prepare_beta_payload(service): """(payload, chart) of the tracked stocks with a beta over 1.5 and a market cap under US$2B, the highest beta first.""" found = [] # (beta, line, chart row) - for ticker, data in yahoo.add_usd_market_caps(yahoo.fetch_details(util.get_holdings_and_watchlist())).items(): + for ticker, data in yahoo.add_usd_market_caps(yahoo.fetch_details(watchlist.with_holdings())).items(): if 'beta' not in data: continue beta = round(data['beta'], 1) if beta > 1.5 and data.get('market_cap_usd', float('inf')) < 2e9: - link = util.finance_link(ticker, data['profile_exchange'], service) - found.append((beta, util.security_line(util.flag_from_ticker(ticker), data['profile_title'], link) + f" {beta} β", (ticker.split('.')[0], float(beta)))) + link = links.finance_link(ticker, data['profile_exchange'], service) + found.append((beta, formats.security_line(markets.flag_from_ticker(ticker), data['profile_title'], link) + f" {beta} β", (ticker.split('.')[0], float(beta)))) if not found: return ['No tracked stocks have a beta over 1.5 and mkt cap under US$2B'], None found.sort(key=lambda row: row[0], reverse=True) @@ -455,7 +458,7 @@ def prepare_beta_payload(service): def prepare_profile_payload(service, user, ticker): """The .SYMBOL report: what Yahoo knows about one company. A field Yahoo did not send leaves its line out.""" - ticker_orig = util.transform_to_yahoo(ticker) + ticker_orig = markets.transform_to_yahoo(ticker) now = datetime.datetime.now() payload = [] ticker, market_data = yahoo.resolve(ticker_orig, lambda symbol: yahoo.fetch_detail(symbol, 600)) @@ -467,12 +470,12 @@ def prepare_profile_payload(service, user, ticker): state = data.get('marketState', '').rstrip() currency = data.get('currency', '') exchange = data.get('profile_exchange', '').replace('NasdaqCM', 'Nasdaq').replace('NasdaqGS', 'Nasdaq').replace('NYSEArca', 'NYSE') - ticker_link = util.finance_link(ticker, exchange, service, brief=False) - gfinance_link = util.gfinance_link(ticker, exchange, service, brief=True, text='google') # all three before exchange is renamed below - sws_link = util.link(simplywallst.get_url(ticker, title, exchange), 'simplywall.st', service) - macrotrends_link = util.link('https://www.google.com/search?q=site:macrotrends.net+' + title + '+PE Ratio+' + code + '&btnI', 'macrotrends', service) + ticker_link = links.finance_link(ticker, exchange, service, brief=False) + gfinance_link = links.gfinance_link(ticker, exchange, service, brief=True, text='google') # all three before exchange is renamed below + sws_link = links.link(simplywallst.get_url(ticker, title, exchange), 'simplywall.st', service) + macrotrends_link = links.link('https://www.google.com/search?q=site:macrotrends.net+' + title + '+PE Ratio+' + code + '&btnI', 'macrotrends', service) if 'profile_bio' in data: - payload.append(util.make_paragraphs(data['profile_bio'])) + payload.append(formats.make_paragraphs(data['profile_bio'])) if exchange == 'ASX': exchange_url = 'https://www2.asx.com.au/markets/company/' + code @@ -490,7 +493,7 @@ def prepare_profile_payload(service, user, ticker): exchange_url = 'https://quote.jpx.co.jp/jpx/template/quote.cgi?F=tmp/e_stock_detail&MKTN=T&QCODE=' + code else: exchange_url = 'https://www.google.com/search?q=stock+exchange+' + exchange + '+' + code + '&btnI' - exchange_link = util.link(exchange_url, exchange, service) + exchange_link = links.link(exchange_url, exchange, service) location = [data[key] for key in ('profile_city', 'profile_state', 'profile_country') if key in data] if payload: payload.append("") @@ -502,19 +505,19 @@ def prepare_profile_payload(service, user, ticker): if 'profile_employees' in data: payload.append(webhook.bold("Employees: ", service) + f"{data['profile_employees']:,}") if 'profile_website' in data: - links = [util.link(data['profile_website'], util.strip_url(data['profile_website']), service), exchange_link] + sites = [links.link(data['profile_website'], formats.strip_url(data['profile_website']), service), exchange_link] if 'NYSE' in exchange or 'Nasdaq' in exchange: - links += [util.link('https://finviz.com/quote.ashx?t=' + ticker, 'finviz', service), macrotrends_link, - util.link('https://seekingalpha.com/symbol/' + ticker, 'seekingalpha', service)] + sites += [links.link('https://finviz.com/quote.ashx?t=' + ticker, 'finviz', service), macrotrends_link, + links.link('https://seekingalpha.com/symbol/' + ticker, 'seekingalpha', service)] elif exchange == 'ASX': - links.append(util.link('https://www.shortman.com.au/stock?q=' + code.lower(), 'shortman', service)) - payload.append(webhook.bold("Links: ", service) + ' | '.join(links)) + sites.append(links.link('https://www.shortman.com.au/stock?q=' + code.lower(), 'shortman', service)) + payload.append(webhook.bold("Links: ", service) + ' | '.join(sites)) if payload: payload.append("") if 'currency' in data and 'market_cap' in data: - payload.append(webhook.bold("Mkt cap:", service) + f" {yahoo.market_cap_currency(data)} {util.humanUnits(data['market_cap'])}") # GBP, not pence + payload.append(webhook.bold("Mkt cap:", service) + f" {yahoo.market_cap_currency(data)} {formats.humanUnits(data['market_cap'])}") # GBP, not pence if all(key in data for key in ('shareholder_equity', 'total_debt', 'total_cash', 'profile_industry')) and data['shareholder_equity'] and 'Bank' not in data['profile_industry']: net_debt_equity_ratio = round((data['total_debt'] - data['total_cash']) / data['shareholder_equity'] * 100) if net_debt_equity_ratio > 0: @@ -554,9 +557,9 @@ def prepare_profile_payload(service, user, ticker): payload.append(webhook.bold(label, service) + (f" +{growth}% " if growth > 0 else f" {growth}% ⚠️ ")) if 'insiderBuy' in data: if data['insiderBuy'] > data['insiderSell']: - payload.append(webhook.bold("Net insider action (3M):", service) + f" Buy {currency} {util.humanUnits(data['insiderBuyValue'])} ") + payload.append(webhook.bold("Net insider action (3M):", service) + f" Buy {currency} {formats.humanUnits(data['insiderBuyValue'])} ") elif data['insiderBuy'] < data['insiderSell']: - payload.append(webhook.bold("Net insider action (3M):", service) + f" Sell {currency} {util.humanUnits(data['insiderSellValue'])}⚠️ ") + payload.append(webhook.bold("Net insider action (3M):", service) + f" Sell {currency} {formats.humanUnits(data['insiderSellValue'])}⚠️ ") if 'short_percent' in data: payload.append(webhook.bold("Shorted stock:", service) + f" {data['short_percent']}%" + ('⚠️ ' if data['short_percent'] > 10 else '')) if 'recommend' in data: @@ -600,17 +603,17 @@ def prepare_profile_payload(service, user, ticker): for interval in ('5Y', '1Y', '1M', '1D'): if interval in price_history: percent = price_history[interval] - payload.append(f"{util.get_emoji(percent)} {webhook.bold(interval + ':', service)} {percent}%") + payload.append(f"{formats.get_emoji(percent)} {webhook.bold(interval + ':', service)} {percent}%") if state != 'REGULAR': for key, label in (('percent_change_premarket', 'Pre-market:'), ('percent_change_postmarket', 'Post-market:')): if key in data: - payload.append(f"{util.get_emoji(data[key])} {webhook.bold(label, service)} {data[key]:,}%") + payload.append(f"{formats.get_emoji(data[key])} {webhook.bold(label, service)} {data[key]:,}%") break if 'profile_website' in data and config.prefs.hyperlinkFooter and config.prefs.hyperlink: if payload: payload.append("") - payload.append(f"{gfinance_link} | {util.yahoo_link(ticker, service, brief=True, text='yahoo')} | {sws_link}") + payload.append(f"{gfinance_link} | {links.yahoo_link(ticker, service, brief=True, text='yahoo')} | {sws_link}") heading = f"{webhook.bold(title, service)} ({ticker_link}) {MARKET_STATE_EMOJI.get(state, '🔴') if state else ''}" if ticker_orig == ticker: payload.insert(0, heading) diff --git a/lib/sharesight.py b/lib/sharesight.py index 53223f5d..347841f2 100644 --- a/lib/sharesight.py +++ b/lib/sharesight.py @@ -7,7 +7,8 @@ from lib import config from lib import cache from lib import net -import lib.util as util +from lib import formats +from lib import markets MAX_CONCURRENT = 1 # Sharesight rejects parallel requests beyond a few per account: allow one at a time across all chats and threads _request_slots = threading.BoundedSemaphore(MAX_CONCURRENT) @@ -52,7 +53,7 @@ def get_token(): cache_expiry = datetime.datetime.fromtimestamp(cached['created_at'] + cached['expires_in']) ttl = cache_expiry - datetime.datetime.now() if ttl > datetime.timedelta(seconds=20): - print("cache hit:", cacheFile, "TTL:", util.td_to_human(ttl), file=sys.stderr) if config.prefs.debug else None + print("cache hit:", cacheFile, "TTL:", formats.td_to_human(ttl), file=sys.stderr) if config.prefs.debug else None return cached['access_token'] else: print("cache miss:", cacheFile, file=sys.stderr) if config.prefs.debug else None @@ -113,7 +114,7 @@ def holdings(self): def tickers(self): """What it holds, as Yahoo tickers, sorted.""" - return sorted({util.transform_to_yahoo(each['code'], each['market_code']) for each in self.holdings().values()}) + return sorted({markets.transform_to_yahoo(each['code'], each['market_code']) for each in self.holdings().values()}) def trades(self, days=None): """Its trades of the past `days` (the past_days preference when None).""" diff --git a/lib/watchlist.py b/lib/watchlist.py index b1ba936f..d0edac4d 100644 --- a/lib/watchlist.py +++ b/lib/watchlist.py @@ -1,5 +1,6 @@ """The watchlist: tickers followed beside the Sharesight holdings, in DATA_DIR/finbot_watchlist.json. Every call reads the file, so the bot and its scheduled reports (other processes) always see the same list.""" +from lib import sharesight from lib import state from lib import yahoo @@ -34,3 +35,11 @@ def remove(ticker): return False watched.remove(ticker) return True + + +def with_holdings(): + """The watchlist and every Sharesight holding: the tickers the scheduled reports cover, sorted.""" + followed = set(sharesight.tickers()) | set(tickers()) + if 'GOOG' in followed and 'GOOGL' in followed: + followed.remove("GOOGL") + return sorted(followed) diff --git a/lib/worker.py b/lib/worker.py index e48522eb..27b2732e 100755 --- a/lib/worker.py +++ b/lib/worker.py @@ -9,7 +9,9 @@ from lib import config from lib import state from lib import words -from lib import util +from lib import formats +from lib import markets +from lib import render from lib import webhook from lib import reports from lib import charts @@ -94,7 +96,7 @@ def deliver(self, payload, chart=None): return spec = dict(chart) rows, title, subtitle = spec.pop('rows'), spec.pop('title'), spec.pop('subtitle', '') - image = util.pie_chart(rows, title, subtitle) if spec.pop('pie', False) else util.rows_chart(rows, title, subtitle, **spec) + image = render.pie_chart(rows, title, subtitle) if spec.pop('pie', False) else render.rows_chart(rows, title, subtitle, **spec) webhook.sendPhoto(self.chat_id, image, '\n'.join(payload) if payload else title, self.service) def process_job_callback(service, callback_id, chat_id, message_id, data): @@ -168,7 +170,7 @@ def period_or_ticker(arg, default): if not arg: return default, None try: - return util.days_from_human_days(arg), None + return formats.days_from_human_days(arg), None except ValueError: return default, arg.upper() @@ -188,7 +190,7 @@ def period_and_portfolio(text, days): for i in (0, -1): if words: try: - days = util.days_from_human_days(words[i]) + days = formats.days_from_human_days(words[i]) except ValueError: continue del words[i] @@ -263,7 +265,7 @@ def on_price(request, m): threshold = parse_percent(arg) except ValueError: try: - days = util.days_from_human_days(arg) + days = formats.days_from_human_days(arg) except ValueError: if i == 0 and arg in ('top', 'bottom'): # one chart shows both ends threshold, top = 0, 10 @@ -303,7 +305,7 @@ def handler(request, m): elif negative and 'neg' in arg: action = f'negative {kind}' elif arg and arg != 'top': - ticker = util.transform_to_yahoo(arg.upper()) + ticker = markets.transform_to_yahoo(arg.upper()) with request.typing(): payload, spec = reports.prepare_value_payload(request.service, action, ticker, length=15) request.deliver(payload, spec if chart else None) @@ -342,7 +344,7 @@ def on_history(request, m): if not m.group('ticker') or m.group('extra'): request.say(["Usage: .history SYMBOL"]) return - ticker = util.transform_to_yahoo(m.group('ticker').upper()) + ticker = markets.transform_to_yahoo(m.group('ticker').upper()) with request.typing(): caption, image = charts.build('h', [ticker], None, request.service) request.send(caption.split('\n'), image, charts.keyboard('h', [ticker], 'x') if image and request.service == 'telegram' else None) diff --git a/lib/yahoo.py b/lib/yahoo.py index 515e6af0..aa064c2e 100644 --- a/lib/yahoo.py +++ b/lib/yahoo.py @@ -9,7 +9,8 @@ from lib import config from lib import cache from lib import net -from lib import util +from lib import formats +from lib import render from http.cookies import SimpleCookie from pathlib import Path @@ -164,7 +165,7 @@ def fetch(tickers): title = next((name for name in (item.get('longName'), item.get('shortName')) if isinstance(name, str) and name), None) if not title or item.get('regularMarketPrice') is None: continue - quote = {'profile_title': util.transform_title(title), 'ticker': item['symbol'], 'regularMarketPrice': item['regularMarketPrice'], + quote = {'profile_title': formats.transform_title(title), 'ticker': item['symbol'], 'regularMarketPrice': item['regularMarketPrice'], 'currency': item.get('currency'), 'percent_change': 0.0, 'dividend': 0.0} for key, field, convert in QUOTE_FIELDS: if (value := _convert(convert, item.get(field))) is not None: @@ -252,7 +253,7 @@ def fetch_detail(ticker, seconds=None): if not title: # catches some delisted stocks like "DUB" print(f"{ticker}†", sep=' ', end='', flush=True, file=sys.stderr) return {} - detail = {'profile_title': util.transform_title(title)} + detail = {'profile_title': formats.transform_title(title)} for key, path, *convert in DETAIL_FIELDS: if (value := _convert(convert[0] if convert else None, dig(result, *path))) is not None: detail[key] = value @@ -354,16 +355,16 @@ def price_history(ticker, days=None, seconds=None, graph=None, graphCache=True): percent_dict[period] = round((price - past_price) / past_price * 100) if not graph or (days and days < 2): return percent_dict, None - company_name = util.transform_title(stock.get('longName', '')) + company_name = formats.transform_title(stock.get('longName', '')) if days: - title = f"{company_name} ({ticker}) {min(days, max_days)} days {util.signed_percent(percent_dict[days])} Last: {price}" + title = f"{company_name} ({ticker}) {min(days, max_days)} days {formats.signed_percent(percent_dict[days])} Last: {price}" else: label = "Max" if "Max" in percent_dict else "10Y" - title = f"{company_name} ({ticker}) {label} {util.signed_percent(percent_dict[label])} Last: {price}" + title = f"{company_name} ({ticker}) {label} {formats.signed_percent(percent_dict[label])} Last: {price}" image_cache_file = "finbot_graph_" + ticker + "_" + str(days) + ".png" if config.prefs.cache and graphCache and (image := cache.read_bytes(image_cache_file, seconds)): return percent_dict, image - image = util.graph(df, title, stock.get('currency')) + image = render.graph(df, title, stock.get('currency')) if config.prefs.cache: cache.write_bytes(image_cache_file, image) return percent_dict, image @@ -379,7 +380,7 @@ def price_series(ticker): if df.empty: raise RuntimeError(f"no price history for {ticker}") series = pd.Series(df['Close'].astype(float).values, index=pd.to_datetime(df['Date'].astype(str)), name=ticker) - name = util.transform_title(stock.get('longName') or stock.get('shortName') or ticker) + name = formats.transform_title(stock.get('longName') or stock.get('shortName') or ticker) return name, stock.get('fullExchangeName') or stock.get('exchangeName') or '', series def fetch_each(get, tickers, workers=8): diff --git a/milestone.py b/milestone.py index bf496a57..dc5303b6 100755 --- a/milestone.py +++ b/milestone.py @@ -4,7 +4,9 @@ from itertools import groupby from lib import state from lib import webhook -from lib import util +from lib import links +from lib import markets +from lib import watchlist from lib import yahoo # BUGS: @@ -31,8 +33,8 @@ def prepare_milestone_payload(service, market_data): except (KeyError, ValueError): continue # likewise: a ticker is recorded only with both of its new values title = market_data[ticker]['profile_title'] - ticker_link = util.finance_link(ticker, market_data[ticker]['profile_exchange'], service) - emoji = util.flag_from_ticker(ticker) + ticker_link = links.finance_link(ticker, market_data[ticker]['profile_exchange'], service) + emoji = markets.flag_from_ticker(ticker) changed[ticker] = {'record_high': max(oldhigh, newhigh), 'record_low': min(oldlow, newlow)} # all-time: a 52-week high below it is no record if newhigh > oldhigh: payloadhigh.append(f"{emoji} {title} ({ticker_link})") @@ -56,7 +58,7 @@ def prepare_milestone_payload(service, market_data): # MAIN # - tickers = util.get_holdings_and_watchlist() + tickers = watchlist.with_holdings() market_data = yahoo.fetch(tickers) records, changed = state.load('finbot_milestone.json', {}), {} for service, url, chat in webhook.default_chats(): diff --git a/performance.py b/performance.py index 314b9f5f..dc200aa6 100755 --- a/performance.py +++ b/performance.py @@ -5,7 +5,9 @@ from lib import config from lib import sharesight from lib import webhook -from lib import util +from lib import formats +from lib import links +from lib import render from lib import yahoo from lib import charts @@ -28,8 +30,8 @@ def benchmark(ticker, market, text, past_days, service): except Exception as e: # the rest of the report still goes out print(ticker, e, file=sys.stderr) return f"⚠️ {text}: no price history", None - link = util.finance_link(ticker, market, service=service, days=past_days, brief=True, text=text) - return f"{util.get_emoji(percent)} {link} {util.signed_percent(percent, 2)}", percent + link = links.finance_link(ticker, market, service=service, days=past_days, brief=True, text=text) + return f"{formats.get_emoji(percent)} {link} {formats.signed_percent(percent, 2)}", percent def report(service, past_days=None, portfolio_select=None, interactive=False, performance=None): """(payload, chart) of the performance report for one service: each portfolio and the benchmarks, best first. performance: @@ -41,9 +43,9 @@ def report(service, past_days=None, portfolio_select=None, interactive=False, pe lines = [] # (percent, text), sorted into the payload below chart_rows = [] for portfolio_id, (name, data) in performance.items(): # the name from the portfolio list: one with no holdings names none - link = util.link("https://portfolio.sharesight.com/portfolios/" + str(portfolio_id), name, service) + link = links.link("https://portfolio.sharesight.com/portfolios/" + str(portfolio_id), name, service) percent = float(data['report']['capital_gain_percent']) - lines.append((percent, f"{util.get_emoji(percent)} {link} {util.signed_percent(percent, 2)}")) + lines.append((percent, f"{formats.get_emoji(percent)} {link} {formats.signed_percent(percent, 2)}")) chart_rows.append((name, percent)) if not lines: return [], None @@ -53,12 +55,12 @@ def report(service, past_days=None, portfolio_select=None, interactive=False, pe if percent is not None: chart_rows.append((text, float(percent))) lines.sort(key=lambda item: item[0], reverse=True) - period = util.days_english(past_days) + period = formats.days_english(past_days) payload = [webhook.bold(f"Performance over {period}", service)] + [line for _, line in lines] graph = None if webhook.takes_charts(service, interactive): chart_rows.sort(key=lambda row: row[1], reverse=True) # best first - graph = util.rows_chart(chart_rows, "Performance", f"% change {'' if period == 'today' else 'over '}{period}", value_fmt=lambda v: util.signed_percent(v, 2)) + graph = render.rows_chart(chart_rows, "Performance", f"% change {'' if period == 'today' else 'over '}{period}", value_fmt=lambda v: formats.signed_percent(v, 2)) return payload, graph def lambda_handler(chat_id=None, past_days=None, service=None, user='', portfolio_select=None, interactive=False): diff --git a/podcasts.py b/podcasts.py index 0239ef4c..650f2651 100755 --- a/podcasts.py +++ b/podcasts.py @@ -9,7 +9,7 @@ from lib import net from lib import state from lib import webhook -from lib import util +from lib import links NAMESPACES = { "media": "http://search.yahoo.com/mrss/", @@ -101,7 +101,7 @@ def lambda_handler(feed_url): podcast_name = episode['podcast'] title = episode['title'].removeprefix(podcast_name).lstrip(string.punctuation + " ") duration = f" {episode['duration']}" if episode['duration'] else "" - payload.append(f"📣 {podcast_name}: {util.link(episode['url'], title, service)}{duration} ({episode['ago']})") + payload.append(f"📣 {podcast_name}: {links.link(episode['url'], title, service)}{duration} ({episode['ago']})") webhook.send(service, url, chat, payload) with state.edit(seen_file, []) as recorded: # last, so that a failure above leaves these episodes for next run recorded[:] = sorted(guids.union(recorded)) diff --git a/price.py b/price.py index 90d29cf2..8bf4c0b0 100755 --- a/price.py +++ b/price.py @@ -6,14 +6,18 @@ from lib import config from lib import sharesight from lib import webhook -from lib import util +from lib import formats +from lib import links +from lib import markets +from lib import render +from lib import watchlist from lib import yahoo from lib import charts def gather(specific_stock=None, days=None): """(tickers, market data) for a price report: Yahoo's quotes; over `days` each holding's Sharesight figure, and for the tickers Sharesight has none for (the watchlist) their Yahoo history, fetched together up front.""" - tickers = [util.transform_to_yahoo(specific_stock)] if specific_stock else util.get_holdings_and_watchlist() + tickers = [markets.transform_to_yahoo(specific_stock)] if specific_stock else watchlist.with_holdings() market_data = yahoo.fetch(tickers) if config.prefs.demote_volatile: # trigger_bar compares market caps, in US dollars yahoo.add_usd_market_caps(market_data) @@ -21,7 +25,7 @@ def gather(specific_stock=None, days=None): if days and (not specific_stock or config.prefs.performance_use_sharesight): for portfolio in sharesight.portfolios(): for holding in portfolio.performance(days)['report']['holdings']: - ticker = util.transform_to_yahoo(holding['instrument']['code'], holding['instrument']['market_code']) + ticker = markets.transform_to_yahoo(holding['instrument']['code'], holding['instrument']['market_code']) if ticker in market_data: market_data[ticker]['percent_change_period'] = float(holding['capital_gain_percent']) elif ticker in tickers: @@ -102,19 +106,19 @@ def report(service, threshold=None, user='', specific_stock=None, interactive=Fa percent = float(percent) if abs(percent) >= threshold or (specific_stock and interactive): exchange = quote['profile_exchange'] - exchanges.add(util.exchange_human(exchange)) - rows.append((ticker, percent, f"{util.get_emoji(percent)} {quote['profile_title']} ({util.quote_link(ticker, exchange, service, days=days)})")) + exchanges.add(markets.exchange_human(exchange)) + rows.append((ticker, percent, f"{formats.get_emoji(percent)} {quote['profile_title']} ({links.quote_link(ticker, exchange, service, days=days)})")) if not triggered: # a scheduled report: is there a move big enough for its ticker? Small caps' betas are fetched together details = yahoo.fetch_details([ticker for ticker, _, _ in rows if needs_beta(market_data[ticker])]) if not any(abs(percent) >= trigger_bar(ticker, market_data[ticker], threshold, details) for ticker, percent, _ in rows): rows = [] rows.sort(key=lambda row: row[1], reverse=True) - payload = [f"{line} {util.signed_percent(round(percent))}" for _, percent, line in rows] + payload = [f"{line} {formats.signed_percent(round(percent))}" for _, percent, line in rows] chart_rows = [(ticker, percent) for ticker, percent, _ in rows] label = lambda chart_rows: [(ticker.split('.')[0].removesuffix('-USD'), percent) for ticker, percent in chart_rows] if payload and not specific_stock: as_of = latest or datetime.date.today() - on = util.date_short(as_of, True) + on = formats.date_short(as_of, True) where = ", ".join(exchanges) if midsession: heading = f'≥ {threshold:g}% mid-session, {on} ({where})' @@ -123,9 +127,9 @@ def report(service, threshold=None, user='', specific_stock=None, interactive=Fa elif close: heading = f'≥ {threshold:g}% at close, {on} ({where})' elif top: - heading = f'Top {top} performers {util.date_range_english(days, as_of) if days else on}' + heading = f'Top {top} performers {formats.date_range_english(days, as_of) if days else on}' elif days: - heading = f'Price movement {threshold:g}%, {util.date_range_english(days, as_of)}' + heading = f'Price movement {threshold:g}%, {formats.date_range_english(days, as_of)}' else: heading = f'{threshold:g}% at close, {on}' chartable = webhook.takes_charts(service, interactive) @@ -135,11 +139,11 @@ def report(service, threshold=None, user='', specific_stock=None, interactive=Fa chart_top = chart_rows[:top] chart_bottom = [row for row in reversed(chart_rows[-top:]) if row not in chart_top] # worst first if chartable and chart_top: - graph = util.bar_graph(label(chart_top), label(chart_bottom), f"Top & bottom {top}", f"% change {util.days_english(days, 'in ', 'the past ')}") + graph = render.bar_graph(label(chart_top), label(chart_bottom), f"Top & bottom {top}", f"% change {formats.days_english(days, 'in ', 'the past ')}") else: payload.insert(0, webhook.bold(heading, service)) if chartable: - graph = util.rows_chart(label(chart_rows), heading, session='midsession' if midsession else 'premarket' if premarket else None) + graph = render.rows_chart(label(chart_rows), heading, session='midsession' if midsession else 'premarket' if premarket else None) elif not payload and interactive: if specific_stock: if midsession: diff --git a/rating.py b/rating.py index d1807646..816f31ee 100755 --- a/rating.py +++ b/rating.py @@ -3,7 +3,9 @@ import sys from lib import webhook from lib import state -from lib import util +from lib import links +from lib import markets +from lib import watchlist from lib import yahoo def lambda_handler(chat_id=None, specific_stock=None, service=None, interactive=False): @@ -28,7 +30,7 @@ def get_emoji(old, new): except (KeyError, ValueError): continue title = market_data[ticker]['profile_title'] - ticker_link = util.finance_link(ticker, market_data[ticker]['profile_exchange'], service, brief=False) + ticker_link = links.finance_link(ticker, market_data[ticker]['profile_exchange'], service, brief=False) new[ticker] = [action, index] try: old_action, old_index = old[ticker] @@ -54,7 +56,7 @@ def get_emoji(old, new): # MAIN # - tickers = [util.transform_to_yahoo(specific_stock)] if specific_stock else util.get_holdings_and_watchlist() + tickers = [markets.transform_to_yahoo(specific_stock)] if specific_stock else watchlist.with_holdings() old = state.load('finbot_rating.json', {}) market_data = yahoo.fetch_details(tickers, strict=bool(specific_stock)) diff --git a/reminder.py b/reminder.py index 7de0f988..7aaaf741 100755 --- a/reminder.py +++ b/reminder.py @@ -5,7 +5,8 @@ import zoneinfo from dateutil.relativedelta import relativedelta from lib import config -from lib import util +from lib import formats +from lib import links from lib import webhook @@ -47,9 +48,9 @@ def prepare_finance_calendar_payload(service): payload.append("• work expenses‡") payload.append("• investment subscriptions§") payload.append ("") - abr_link = util.link('https://abr.business.gov.au/Tools/DgrListing', 'DGR-registered', service) - stake_note = util.link('https://hellostake.com/au/support/stake-super/employer-and-personal-contributions/33442327233305#h_01JQAPN9A6ZM41NZKP6XZWN1KP', 'note about XX PCC in transaction description', service) - work_link = util.link('https://www.ato.gov.au/individuals-and-families/income-deductions-offsets-and-records/deductions-you-can-claim', 'eligible work expenses', service) + abr_link = links.link('https://abr.business.gov.au/Tools/DgrListing', 'DGR-registered', service) + stake_note = links.link('https://hellostake.com/au/support/stake-super/employer-and-personal-contributions/33442327233305#h_01JQAPN9A6ZM41NZKP6XZWN1KP', 'note about XX PCC in transaction description', service) + work_link = links.link('https://www.ato.gov.au/individuals-and-families/income-deductions-offsets-and-records/deductions-you-can-claim', 'eligible work expenses', service) payload.append(webhook.italic("* Organisation must be " + abr_link, service)) payload.append(webhook.italic("† Allow transfer time for Super, as contributions can only be deducted for the year they're received. Stake users: see " + stake_note, service)) payload.append(webhook.italic("‡ See " + work_link, service)) @@ -58,7 +59,7 @@ def prepare_finance_calendar_payload(service): if month_and_day == '08-18': myBirthday = datetime.datetime(2022,8,18,0,0,0,0, tzinfo=tz) difference = relativedelta(localtime, myBirthday) - payload.append("It's my " + str(difference.years) + util.ordinal(difference.years) + " birthday! 🥳") + payload.append("It's my " + str(difference.years) + formats.ordinal(difference.years) + " birthday! 🥳") if payload: heading = webhook.bold("Finance event reminders:", service) diff --git a/shorts.py b/shorts.py index 258a9d69..6819b4d1 100755 --- a/shorts.py +++ b/shorts.py @@ -3,7 +3,10 @@ import sys from lib import config from lib import webhook -from lib import util +from lib import links +from lib import markets +from lib import render +from lib import watchlist from lib import yahoo from lib import shortman @@ -20,13 +23,13 @@ def prepare_shorts_payload(service, market_data): if short_percent > threshold or specific_stock: code = ticker.split('.')[0] url = 'https://www.shortman.com.au/stock?q=' + code if ticker.endswith('.AX') else 'https://finance.yahoo.com/quote/' + ticker + '/key-statistics?p=' + ticker - found.append((short_percent, f"🩳 {market_data[ticker]['profile_title']} ({util.link(url, code, service)}) {round(short_percent)}%", code)) + found.append((short_percent, f"🩳 {market_data[ticker]['profile_title']} ({links.link(url, code, service)}) {round(short_percent)}%", code)) found.sort(key=lambda row: row[0], reverse=True) # largest first, as the chart has them payload, graph = [line for _, line, _ in found], None if payload and not specific_stock: payload.insert(0, webhook.bold(f'Tracked stocks shorted over {threshold:g}%', service)) if webhook.takes_charts(service, interactive): - graph = util.shorts_chart([(code, percent) for percent, _, code in found], threshold) + graph = render.shorts_chart([(code, percent) for percent, _, code in found], threshold) elif not payload and interactive: payload = [f"🩳 No short interest found for {tickers[0]}" if specific_stock else f"🩳 No stocks shorted over {threshold:g}%. Try specifying a number."] return payload, graph @@ -34,10 +37,10 @@ def prepare_shorts_payload(service, market_data): # MAIN # if specific_stock: - ticker = util.transform_to_yahoo(specific_stock) + ticker = markets.transform_to_yahoo(specific_stock) tickers = [ticker] else: - tickers = util.get_holdings_and_watchlist() + tickers = watchlist.with_holdings() market_data = yahoo.fetch(tickers) market_data |= yahoo.fetch_details((ticker for ticker in tickers if '.' not in ticker), strict=bool(specific_stock)) # US short interest; Short Man has the ASX's diff --git a/tests/common.py b/tests/common.py index 3f278f4d..225052fd 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,12 +10,12 @@ os.environ.update(telegramOutgoingWebhook='https://example.com/telegram', telegramOutgoingToken='tok', telegramChatID='1') import bot # first, and once: it monkey-patches with gevent, which must happen before requests imports ssl and never mid-test -from lib import cache, config, state, webhook, util, yahoo, sharesight, shortman, worker, charts, telegram, reports, watchlist, words +from lib import cache, config, state, webhook, formats, links, markets, render, yahoo, sharesight, shortman, worker, charts, telegram, reports, watchlist, words import performance, shorts, price, reminder, cal, trades, milestone, rating REAL = {(m.__name__, n): getattr(m, n) for m, n in ((yahoo, 'fetch'), (yahoo, 'fetch_detail'), (yahoo, 'price_series'), (yahoo, 'price_history'), (sharesight, 'get_portfolios'), (sharesight, 'get_trades'), (sharesight, 'get_performance'))} -REAL_GET_HOLDINGS = util.get_holdings_and_watchlist +REAL_GET_HOLDINGS = watchlist.with_holdings REAL_PAYLOAD_WRAPPER = webhook.payload_wrapper # FinbotCase replaces it; tests of the real thing use this REAL_REPORT_ERROR = webhook.report_error # likewise REAL_SEND_PHOTO = webhook.sendPhoto # likewise @@ -73,7 +73,7 @@ def setUp(self): self.patch(webhook, 'report_error', lambda e, *a, **k: self.errors.append(webhook.error_line(e, k.get('context')))) self.patch(webhook, 'sendPhoto', lambda chat, img, cap, svc, **k: (self.sent.append(('photo', len(img.read()), cap.split('\n')[0])), self.markups.append(k.get('reply_markup')))) self.patch(webhook, 'payload_wrapper', lambda svc, url, payload, *a, **k: self.sent.append(('text', payload)) or []) - self.patch(util, 'get_holdings_and_watchlist', lambda: TICKERS) + self.patch(watchlist, 'with_holdings', lambda: TICKERS) self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) for t in ts}) self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t)}) self.patch(yahoo, 'price_history', lambda t, days=None, **k: ({days: 1.5} if days else {'Max': 5, '1Y': 3}, None)) diff --git a/tests/test_charts.py b/tests/test_charts.py index 0af42bd1..76c30a96 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -41,10 +41,10 @@ def compare_series(): CHARTS = { - 'hbar_mixed': lambda: util.hbar_chart(MIXED, 'Price movement 5.0%, 5 Sep - 5 Oct 2026', 'subtitle here'), - 'price_line': lambda: util.graph(price_frame(), 'Apple Inc (AAPL)', 'USD'), - 'compare_lines': lambda: util.compare_graph(compare_series(), 'Compare 120 days', 'subtitle here'), - 'shorts': lambda: util.shorts_chart([('GME', 47.3), ('AMC', 32.1), ('TSLA', 21.6), ('AAPL', 15.2)], 15), + 'hbar_mixed': lambda: render.hbar_chart(MIXED, 'Price movement 5.0%, 5 Sep - 5 Oct 2026', 'subtitle here'), + 'price_line': lambda: render.graph(price_frame(), 'Apple Inc (AAPL)', 'USD'), + 'compare_lines': lambda: render.compare_graph(compare_series(), 'Compare 120 days', 'subtitle here'), + 'shorts': lambda: render.shorts_chart([('GME', 47.3), ('AMC', 32.1), ('TSLA', 21.6), ('AAPL', 15.2)], 15), } # inputs that have broken chart code before or are likely to: sizes, signs, long names, zeros, a single row EDGE_ROWS = { @@ -85,44 +85,44 @@ def test_each_chart_type(self): def test_edge_case_rows(self): for name, rows in EDGE_ROWS.items(): with self.subTest(rows=name): - self.check_image(util.hbar_chart(rows, 'Title', 'sub'), name) - self.check_image(util.rows_chart(rows, 'Title'), name + '/rows_chart') + self.check_image(render.hbar_chart(rows, 'Title', 'sub'), name) + self.check_image(render.rows_chart(rows, 'Title'), name + '/rows_chart') def test_zoomed_axis(self): - self.check_image(util.hbar_chart([('AAA', 1.2), ('BBB', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed') - self.check_image(util.hbar_chart([('WWWWWWWW', 1.2), ('MMM', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed wide labels') - self.check_image(util.hbar_chart([(f'T{i}', 1 + i / 10) for i in range(15)], 'Ratings', xlim=(0.9, 3.0)), 'zoomed 15 rows') + self.check_image(render.hbar_chart([('AAA', 1.2), ('BBB', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed') + self.check_image(render.hbar_chart([('WWWWWWWW', 1.2), ('MMM', 3.4)], 'Ratings', xlim=(0.9, 4.0)), 'zoomed wide labels') + self.check_image(render.hbar_chart([(f'T{i}', 1 + i / 10) for i in range(15)], 'Ratings', xlim=(0.9, 3.0)), 'zoomed 15 rows') def test_shorts_chart_sizes_and_verdicts(self): for n in (1, 5, 45): rows = [(f'T{i}', 60.0 - i) for i in range(n)] - self.check_image(util.shorts_chart(rows, 15), f'shorts {n} rows', bg=SHORTS_BG, edge_clean=False) - self.check_image(util.shorts_chart([('A VERY LONG LABEL', 15.0), ('B', 0.4)], 15), 'shorts long label and tiny value', bg=SHORTS_BG, edge_clean=False) + self.check_image(render.shorts_chart(rows, 15), f'shorts {n} rows', bg=SHORTS_BG, edge_clean=False) + self.check_image(render.shorts_chart([('A VERY LONG LABEL', 15.0), ('B', 0.4)], 15), 'shorts long label and tiny value', bg=SHORTS_BG, edge_clean=False) tiers = [(0, 'board shorts'), (4.9, 'board shorts'), (5, 'cargo shorts'), (10, 'boxers'), (15, 'gym shorts'), (20, 'cutoffs'), (30, 'short shorts'), (40, 'daisy dukes'), (50, 'hot pants'), (60, 'speedos'), (70, 'budgie smugglers'), (80, 'just a belt'), (90, 'never nudes'), (200, 'never nudes')] - self.assertEqual([util.shorts_verdict(p) for p, _ in tiers], [name for _, name in tiers]) + self.assertEqual([render.shorts_verdict(p) for p, _ in tiers], [name for _, name in tiers]) def test_bar_graph_has_top_and_bottom(self): - self.check_image(util.bar_graph([('AAA', 9)], [('ZZZ', -9)], 'Top and bottom'), 'bar_graph') + self.check_image(render.bar_graph([('AAA', 9)], [('ZZZ', -9)], 'Top and bottom'), 'bar_graph') def test_flat_and_tiny_price_series(self): flat = pd.DataFrame({'Date': [datetime.date(2026, 1, 1) + datetime.timedelta(days=i) for i in range(10)], 'Close': [5.0] * 10}) - self.check_image(util.graph(flat, 'Flat', 'USD'), 'flat price') + self.check_image(render.graph(flat, 'Flat', 'USD'), 'flat price') two = price_frame(2) - self.check_image(util.graph(two, 'Two points', 'USD'), 'two points') + self.check_image(render.graph(two, 'Two points', 'USD'), 'two points') class RenderPool(unittest.TestCase): - """The bot draws charts in one native thread (util.render_off_hub), so the gevent hub keeps serving while a chart is drawn.""" + """The bot draws charts in one native thread (render.render_off_hub), so the gevent hub keeps serving while a chart is drawn.""" def setUp(self): - util.render_off_hub() - self.addCleanup(setattr, util, '_render_pool', None) + render.render_off_hub() + self.addCleanup(setattr, render, '_render_pool', None) def test_charts_asked_for_at_once_all_finish(self): - jobs = [gevent.spawn(util.pie_chart, [('a', 1.0), ('b', 2.0)], f't{i}') for i in range(4)] - jobs += [gevent.spawn(util.hbar_chart, MIXED, 'T'), gevent.spawn(util.rows_chart, MIXED, 'T')] + jobs = [gevent.spawn(render.pie_chart, [('a', 1.0), ('b', 2.0)], f't{i}') for i in range(4)] + jobs += [gevent.spawn(render.hbar_chart, MIXED, 'T'), gevent.spawn(render.rows_chart, MIXED, 'T')] gevent.joinall(jobs, timeout=120) self.assertTrue(all(job.successful() for job in jobs), 'a chart did not finish (several native threads deadlock matplotlib)') self.assertTrue(all(decode(job.value).size == (1280, 800) for job in jobs)) @@ -137,7 +137,7 @@ def ticker(): gaps.append(now - last) last = now tick = gevent.spawn(ticker) - util.shorts_chart([(f'T{i}', 60.0 - 2 * i) for i in range(24)], 15) # about a second of drawing + render.shorts_chart([(f'T{i}', 60.0 - 2 * i) for i in range(24)], 15) # about a second of drawing done.append(True) tick.join() self.assertLess(max(gaps), 0.5) # drawn in the hub, the ticker stopped for the whole chart @@ -151,7 +151,7 @@ def spy(ax, y, width, *a, **k): calls.append({'y': list(y), 'width': list(width), 'color': list(k.get('color', []))}) return real(ax, y, width, *a, **k) with mock.patch.object(Axes, 'barh', spy): - util.hbar_chart(rows, 'T', **kwargs) + render.hbar_chart(rows, 'T', **kwargs) return calls[0] def capture_text(self, make): @@ -175,12 +175,12 @@ def test_first_row_is_on_top_and_spacers_are_skipped(self): calls = [] real = Axes.set_ylim with mock.patch.object(Axes, 'set_ylim', lambda ax, *a, **k: (calls.append(a), real(ax, *a, **k))[1]): - util.hbar_chart(MIXED, 'T') + render.hbar_chart(MIXED, 'T') self.assertTrue(any(len(a) == 2 and a[0] > a[1] for a in calls), 'y axis must be inverted so row 0 is drawn first (top)') def test_pixels_follow_the_sign_of_the_data(self): def count(rows): - pixels = np.asarray(decode(util.hbar_chart(rows, 'T'))).reshape(-1, 3) + pixels = np.asarray(decode(render.hbar_chart(rows, 'T'))).reshape(-1, 3) return (pixels == BLUE).all(axis=1).sum(), (pixels == RED).all(axis=1).sum() blue, red = count([('A', 5), ('B', 3)]) self.assertGreater(blue, 1000); self.assertEqual(red, 0) @@ -190,7 +190,7 @@ def count(rows): self.assertGreater(blue, 1000); self.assertGreater(red, 1000) def test_session_tints_the_background(self): - corner = lambda session: tuple(np.asarray(decode(util.hbar_chart(MIXED, 'T', session=session)))[2, 2][:3]) + corner = lambda session: tuple(np.asarray(decode(render.hbar_chart(MIXED, 'T', session=session)))[2, 2][:3]) plain, close, mid, pre = corner(None), corner('close'), corner('midsession'), corner('premarket') self.assertEqual(plain, (0x14, 0x18, 0x1f)) self.assertEqual(close, plain) # at close stays untinted @@ -199,16 +199,16 @@ def test_session_tints_the_background(self): self.assertLess(max(abs(int(a) - int(b)) for a, b in zip(pre, plain)), 20) # subtle def test_longer_bar_is_drawn_longer(self): - pixels = np.asarray(decode(util.hbar_chart([('A', 40), ('B', 10)], 'T'))) + pixels = np.asarray(decode(render.hbar_chart([('A', 40), ('B', 10)], 'T'))) blue_rows = np.where((pixels == BLUE).all(axis=2).any(axis=1))[0] top_band, bottom_band = pixels[blue_rows[0]], pixels[blue_rows[-1]] self.assertGreater((top_band == BLUE).all(axis=1).sum(), 2 * (bottom_band == BLUE).all(axis=1).sum()) def test_titles_are_drawn(self): - texts = self.capture_text(lambda: util.hbar_chart(MIXED, 'My Title', 'My subtitle')) + texts = self.capture_text(lambda: render.hbar_chart(MIXED, 'My Title', 'My subtitle')) self.assertIn('My Title', texts) self.assertIn('My subtitle', texts) - texts = self.capture_text(lambda: util.graph(price_frame(), 'Apple Inc (AAPL)', 'USD')) + texts = self.capture_text(lambda: render.graph(price_frame(), 'Apple Inc (AAPL)', 'USD')) self.assertIn('Apple Inc (AAPL)', texts) self.assertTrue(any('over period' in t for t in texts)) @@ -218,7 +218,7 @@ def line_colour(closes): texts = [] real = Figure.text with mock.patch.object(Figure, 'text', lambda fig, x, y, s, *a, **k: (texts.append((s, k.get('color'))), real(fig, x, y, s, *a, **k))[1]): - util.graph(frame, 'T', 'USD') + render.graph(frame, 'T', 'USD') return next(color for s, color in texts if 'over period' in s) self.assertEqual(line_colour([1, 2, 3]), '#34d399') # up: green self.assertEqual(line_colour([3, 2, 1]), '#fb7185') # down: red @@ -230,11 +230,11 @@ class ReportChartInputs(FinbotCase): def run_report(self, call): captured = [] - real = util.hbar_chart + real = render.hbar_chart def spy(rows, title, subtitle='', **kw): captured.append({'rows': list(rows), 'title': title, 'subtitle': subtitle}) return real(rows, title, subtitle, **kw) - self.patch(util, 'hbar_chart', spy) + self.patch(render, 'hbar_chart', spy) call() self.assertTrue(captured, 'no chart was drawn') return captured[0] @@ -265,11 +265,11 @@ def test_performance_is_sorted_by_percent(self): def test_shorts_is_sorted_and_carries_the_threshold(self): captured = [] - real = util.shorts_chart + real = render.shorts_chart def spy(rows, threshold, *a, **k): captured.append((list(rows), threshold)) return real(rows, threshold, *a, **k) - self.patch(util, 'shorts_chart', spy) + self.patch(render, 'shorts_chart', spy) self.command('.shorts') self.assertTrue(captured, 'no chart was drawn') rows, threshold = captured[0] diff --git a/tests/test_config.py b/tests/test_config.py index 40f0202b..6bba073e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,7 @@ from lib import config from util import check_config, split_env import bot, cal -from lib import cache, reports, sharesight, util, worker, yahoo +from lib import cache, reports, sharesight, worker, yahoo import performance, price, shorts, trades REPO = Path(config.__file__).resolve().parent.parent diff --git a/tests/test_datadir.py b/tests/test_datadir.py index ba8ce810..de6d55fa 100644 --- a/tests/test_datadir.py +++ b/tests/test_datadir.py @@ -39,9 +39,9 @@ def run(**env): self.assertEqual(run(STATE_DIRECTORY='/x/y', DATA_DIR=d)[0], d) # DATA_DIR wins self.assertEqual(run(DATA_DIR=d, MPLCONFIGDIR='/custom')[2], '/custom') # explicit value kept - def test_util_import_sets_mplconfigdir_without_home(self): + def test_render_import_sets_mplconfigdir_without_home(self): e = {k: v for k, v in os.environ.items() if k not in ('MPLCONFIGDIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'HOME')} - code = "import os; from lib import util; import matplotlib.dates; print(os.environ['MPLCONFIGDIR'])" # util loads matplotlib lazily + code = "import os; from lib import render; import matplotlib.dates; print(os.environ['MPLCONFIGDIR'])" # render loads matplotlib lazily with tempfile.TemporaryDirectory() as d: out = subprocess.run([sys.executable, '-c', code], cwd=REPO, env=dict(e, DATA_DIR=d), capture_output=True, text=True, check=True).stdout.strip() self.assertEqual(out.split()[-1], d + '/cache/matplotlib') diff --git a/tests/test_edges.py b/tests/test_edges.py index c2d99bfa..5a174530 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -60,7 +60,7 @@ def down(*a, **k): self.assertEqual(typing_when_reported, [[False]]) # its one indicator had stopped when the error line went out def test_no_holdings_and_empty_watchlist(self): - self.patch(util, 'get_holdings_and_watchlist', lambda: []) + self.patch(watchlist, 'with_holdings', lambda: []) for cmd in ('.watchlist', '.price', '.earnings'): with self.subTest(cmd=cmd): self.command(cmd) diff --git a/tests/test_providers.py b/tests/test_providers.py index 75f2d05a..d3e368c7 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -588,7 +588,7 @@ def test_slack_without_a_bot_token_gets_text_and_no_chart_is_drawn(self): def refuse(*a, **k): raise AssertionError('a chart was drawn for Slack') for name in ('rows_chart', 'pie_chart', 'bar_graph', 'shorts_chart'): - self.patch(util, name, refuse) + self.patch(render, name, refuse) self.env(webhooks={**config.env.webhooks, 'slack': 'https://hooks.slack.com/services/x'}) for cmd in ('.beta', '.pie Rob', '.buy', '.sell', '.peg', '.price', '.price top', '.price T1 7d', '.performance 7d', '.shorts', '.history T1', '.compare T1 T2'): with self.subTest(cmd=cmd): @@ -673,7 +673,7 @@ def test_a_long_report_is_split_by_size(self): self.assertTrue(all(size <= 2000 for size in sizes), sizes) # 20 lines a chunk used to make 3,019 characters def test_links_are_masked_links(self): - self.assertEqual(util.link('https://x.example/a(b)', 'T[1]', 'discord'), '[T\\[1\\]]()') + self.assertEqual(links.link('https://x.example/a(b)', 'T[1]', 'discord'), '[T\\[1\\]]()') class SlackSending(FinbotCase): diff --git a/tests/test_reports.py b/tests/test_reports.py index bece5552..b22500d4 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -81,7 +81,7 @@ def test_ratings_list_the_strongest_first(self): fewer than 7 analysts and the stock is left out (T7, T8: the ASX small caps that are always a perfect 1.0).""" ratings = {'T1': ('buy', 1.5, 7), 'T2': ('strong_buy', 1.2, 12), 'T3': ('buy', 1.5, 9), 'T7': ('strong_buy', 1.0, 6), 'T4': ('sell', 4.2, 7), 'T5': ('underperform', 4.6, 10), 'T6': ('sell', 4.2, 8), 'T8': ('strong_buy', 1.0, 2)} - self.patch(util, 'get_holdings_and_watchlist', lambda: list(ratings)) + self.patch(watchlist, 'with_holdings', lambda: list(ratings)) self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | dict(zip(('recommend', 'recommend_index', 'recommend_analysts'), ratings[t]))}) def order(action): payload, chart = reports.prepare_rating_payload('telegram', action) @@ -89,7 +89,7 @@ def order(action): self.assertEqual(order('buy'), (['T2', 'T3', 'T1'], ['T2', 'T3', 'T1'])) self.assertEqual(order('sell'), (['T5', 'T6', 'T4'], ['T5', 'T6', 'T4'])) self.assertIn('(7+ analysts)', reports.prepare_rating_payload('telegram', 'buy')[0][0]) - self.patch(util, 'get_holdings_and_watchlist', lambda: ['T7', 'T8']) + self.patch(watchlist, 'with_holdings', lambda: ['T7', 'T8']) self.assertEqual(reports.prepare_rating_payload('telegram', 'buy'), (['No tracked stocks rated buy by 7+ analysts'], None)) def test_a_small_cap_quoted_in_won_needs_the_bigger_move_too(self): @@ -116,7 +116,7 @@ def test_the_small_caps_betas_are_fetched_together(self): def test_forward_pe_lists_need_as_many_analysts_as_buy(self): """A forward P/E is analysts' estimate: with few of them it says little, as a perfect 1.0 buy from two brokers does.""" analysts = {'T1': 7, 'T2': 6, 'T3': 12, 'T4': 2} - self.patch(util, 'get_holdings_and_watchlist', lambda: list(analysts)) + self.patch(watchlist, 'with_holdings', lambda: list(analysts)) self.patch(yahoo, 'fetch', lambda ts: {t: market_entry(t) | {'price_to_earnings_forward': -5 if t == 'T3' else 20} for t in ts}) self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: {t: market_entry(t) | {'recommend_analysts': analysts[t]}}) def listed(action): @@ -140,7 +140,7 @@ def fetch(tickers): for t in tickers if t in fx or t in stocks} self.patch(yahoo, 'fetch', fetch) self.patch(yahoo, 'fetch_detail', lambda t, *a, **k: fetch([t])) - self.patch(util, 'get_holdings_and_watchlist', lambda: list(stocks)) + self.patch(watchlist, 'with_holdings', lambda: list(stocks)) payload = reports.prepare_marketcap_payload('telegram') self.assertIn('by market cap (USD)', payload[0]) self.assertEqual([line.split('Co ')[1].split(' ')[0] for line in payload[1:]], ['U1', 'K1.KS', 'L1.L', 'K2.KS']) # Z1: no rate, left out @@ -425,7 +425,7 @@ def test_folds_into_other_and_renders(self): payload, chart = reports.prepare_pie('Rob', 'industry', 'telegram') self.assertEqual(len(chart['rows']), 8) self.assertEqual(chart['rows'][-1][0], 'Other') - self.assertTrue(util.pie_chart(chart['rows'], chart['title'], chart['subtitle']).read().startswith(b'\x89PNG')) + self.assertTrue(render.pie_chart(chart['rows'], chart['title'], chart['subtitle']).read().startswith(b'\x89PNG')) def test_unknown_or_missing_portfolio_lists_known(self): for text, first in (('.pie', '.pie: please try again'), ('.pie Nope', 'Nope portfolio not found')): diff --git a/tests/test_util.py b/tests/test_util.py index 1ffa8310..75671a68 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,4 @@ -"""Pure helpers in lib/util.py: table-driven pass and fail cases.""" +"""Pure helpers (lib/markets.py, formats.py, links.py) and the cache and state files: table-driven pass and fail cases.""" import datetime, json, threading, unittest from pathlib import Path from tests.common import * @@ -8,14 +8,14 @@ class Parsing(unittest.TestCase): def test_days_from_human_days(self): for arg, days in (('7', 7), ('7d', 7), ('7D', 7), ('2w', 14), ('3m', 90), ('1y', 365), ('0', 0)): with self.subTest(arg=arg): - self.assertEqual(util.days_from_human_days(arg), days) + self.assertEqual(formats.days_from_human_days(arg), days) today = datetime.date.today() - self.assertEqual(util.days_from_human_days('ytd'), (today - today.replace(month=1, day=1)).days) + self.assertEqual(formats.days_from_human_days('ytd'), (today - today.replace(month=1, day=1)).days) def test_days_from_human_days_rejects_non_periods(self): for arg in ('AAPL', '', 'x5d', '1.5y', 'week'): with self.subTest(arg=arg), self.assertRaises(ValueError): - util.days_from_human_days(arg) + formats.days_from_human_days(arg) def test_transform_to_yahoo(self): for args, expected in ((('AAPL',), 'AAPL'), (('FANG', 'ASX'), 'FANG.AX'), (('ARM', 'NASDAQ'), 'ARM'), @@ -23,29 +23,29 @@ def test_transform_to_yahoo(self): (('BRK', 'B'), 'BRK-B'), (('2330', 'TAI'), '2330.TW'), (('VOD', 'LSE'), 'VOD.L'), (('7203', 'TSE'), '7203.T'), (('SHOP', 'TSX'), 'SHOP.TO'), (('AIR', 'NZX'), 'AIR.NZ'), (('D05', 'SGX'), 'D05.SI')): with self.subTest(args=args): - self.assertEqual(util.transform_to_yahoo(*args), expected) + self.assertEqual(markets.transform_to_yahoo(*args), expected) def test_each_market_has_its_own_flag_and_currency(self): # Sharesight's market codes: TSE is Tokyo, TSX Toronto, SGX Singapore for market, ticker, flag, currency in (('TSE', '7203.T', '🇯🇵', 'JPY'), ('TSX', 'SHOP.TO', '🇨🇦', 'CAD'), ('NZX', 'AIR.NZ', '🇳🇿', 'NZD'), ('SGX', 'D05.SI', '🇸🇬', 'SGD'), ('CPSE', None, '🇩🇰', 'DKK')): with self.subTest(market=market): - self.assertEqual((util.flag_from_market(market), util.currency_from_market(market)), (flag, currency)) + self.assertEqual((markets.flag_from_market(market), markets.currency_from_market(market)), (flag, currency)) if ticker: - self.assertEqual(util.flag_from_ticker(ticker), flag) + self.assertEqual(markets.flag_from_ticker(ticker), flag) def test_make_paragraphs_keeps_every_word(self): - self.assertEqual(util.make_paragraphs('Acme makes widgets. It sells them.'), 'Acme makes widgets. It sells them.') + self.assertEqual(formats.make_paragraphs('Acme makes widgets. It sells them.'), 'Acme makes widgets. It sells them.') long = ' '.join(['word'] * 22) + ' end. Tail sentence here.' - self.assertEqual(util.make_paragraphs(long), ' '.join(['word'] * 22) + ' end.\n\nTail sentence here.') + self.assertEqual(formats.make_paragraphs(long), ' '.join(['word'] * 22) + ' end.\n\nTail sentence here.') def test_transform_title(self): - self.assertEqual(util.transform_title('APPLE INC'), 'Apple') - self.assertEqual(util.transform_title('Acme FPO'), 'Acme') - self.assertEqual(util.transform_title('Mixed Case Name'), 'Mixed Case Name') + self.assertEqual(formats.transform_title('APPLE INC'), 'Apple') + self.assertEqual(formats.transform_title('Acme FPO'), 'Acme') + self.assertEqual(formats.transform_title('Mixed Case Name'), 'Mixed Case Name') for title, expected in (('Allianz Se', 'Allianz'), ('Foo SE AG', 'Foo'), ('Johnson & Johnson &', 'Johnson & Johnson'), ('Black and White and', 'Black and White'), ('Big Holdings Things Ltd', 'Big Things')): with self.subTest(title=title): # a suffix comes off the end only; no run of spaces is left behind - self.assertEqual(util.transform_title(title), expected) + self.assertEqual(formats.transform_title(title), expected) class Formatting(unittest.TestCase): @@ -53,39 +53,39 @@ def test_signed_percent(self): for value, decimals, expected in ((12, None, '+12%'), (-3.5, None, '-3.5%'), (0, None, '0%'), (1234, None, '+1,234%'), (2.345, 1, '+2.3%'), (-0.04, 1, '-0.0%')): with self.subTest(value=value): - self.assertEqual(util.signed_percent(value, decimals), expected) + self.assertEqual(formats.signed_percent(value, decimals), expected) def test_days_english(self): for days, expected in ((None, 'today'), (0, 'today'), (1, 'the past day'), (7, 'the past week')): with self.subTest(days=days): - self.assertEqual(util.days_english(days), expected) + self.assertEqual(formats.days_english(days), expected) def test_date_range_english(self): end = datetime.date(2026, 10, 5) - self.assertEqual(util.date_range_english(7, end), '28 Sep - 5 Oct 2026') - self.assertEqual(util.date_range_english(30, end), '5 Sep - 5 Oct 2026') - self.assertEqual(util.date_range_english(10, datetime.date(2026, 1, 5)), '26 Dec 2025 - 5 Jan 2026') # crosses a year + self.assertEqual(formats.date_range_english(7, end), '28 Sep - 5 Oct 2026') + self.assertEqual(formats.date_range_english(30, end), '5 Sep - 5 Oct 2026') + self.assertEqual(formats.date_range_english(10, datetime.date(2026, 1, 5)), '26 Dec 2025 - 5 Jan 2026') # crosses a year with self.assertRaises(TypeError): - util.date_range_english(None, end) # callers must pass a period + formats.date_range_english(None, end) # callers must pass a period def test_ordinal(self): for number, suffix in ((1, 'st'), (2, 'nd'), (3, 'rd'), (4, 'th'), (11, 'th'), (12, 'th'), (13, 'th'), (21, 'st'), (112, 'th')): with self.subTest(number=number): - self.assertEqual(util.ordinal(number), suffix) + self.assertEqual(formats.ordinal(number), suffix) def test_get_emoji(self): - self.assertEqual([util.get_emoji(n) for n in (1, -1, 0)], ['🔼', '🔻', '▪️']) + self.assertEqual([formats.get_emoji(n) for n in (1, -1, 0)], ['🔼', '🔻', '▪️']) def test_links_per_service(self): - telegram_link = util.link('https://example.com/x', 'text', 'telegram') - slack_link = util.link('https://example.com/x', 'text', 'slack') + telegram_link = links.link('https://example.com/x', 'text', 'telegram') + slack_link = links.link('https://example.com/x', 'text', 'slack') self.assertIn(' Date: Thu, 15 Oct 2026 00:09:09 +0000 Subject: [PATCH 13/81] Reports move into a lib/reports/ package; the button helpers into lib/buttons.py The ten scheduled scripts move from the top level into lib/reports/ (renamed, so their history follows), and lib/reports.py (622 lines) splits by subject beside them: about (.help, .jobs), history (.history, .compare), holdings (.holdings, .who, .pie, .watchlist), profile (.SYMBOL) and rankings (.marketcap, .pe, .fpe, .peg, .beta, .buy, .sell). The period-button helpers (PERIODS, keyboard, the callback refs) leave lib/charts.py for lib/buttons.py, which the reports use; charts.py keeps the rebuild behind a button press and imports the reports at the top. That ends the second import cycle: charts imported reports, which imported jobs, which imported every script, two of which imported charts. lib/jobrun.py takes a module path (lib.reports.price) and names the run by its last part. The next commit puts a script back at the top level for each report, for schedule lines and runs by hand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JVEkZofdPjQ4jtXFKipeUX --- CLAUDE.md | 18 +- bot.py | 2 +- lib/buttons.py | 44 ++ lib/charts.py | 77 +-- lib/jobrun.py | 14 +- lib/jobs.py | 2 +- lib/reports.py | 625 ------------------- lib/reports/__init__.py | 14 + lib/reports/about.py | 87 +++ cal.py => lib/reports/cal.py | 6 +- cleanup.py => lib/reports/cleanup.py | 7 +- lib/reports/history.py | 104 +++ lib/reports/holdings.py | 102 +++ milestone.py => lib/reports/milestone.py | 7 +- performance.py => lib/reports/performance.py | 12 +- podcasts.py => lib/reports/podcasts.py | 7 +- price.py => lib/reports/price.py | 17 +- lib/reports/profile.py | 214 +++++++ lib/reports/rankings.py | 164 +++++ rating.py => lib/reports/rating.py | 7 +- reminder.py => lib/reports/reminder.py | 7 +- shorts.py => lib/reports/shorts.py | 7 +- trades.py => lib/reports/trades.py | 6 +- lib/worker.py | 50 +- tests/common.py | 8 +- tests/test_config.py | 8 +- tests/test_edges.py | 2 +- tests/test_finbot.py | 2 +- tests/test_jobs.py | 12 +- tests/test_reports.py | 61 +- 30 files changed, 845 insertions(+), 848 deletions(-) create mode 100644 lib/buttons.py delete mode 100755 lib/reports.py create mode 100644 lib/reports/__init__.py create mode 100644 lib/reports/about.py rename cal.py => lib/reports/cal.py (97%) mode change 100755 => 100644 rename cleanup.py => lib/reports/cleanup.py (90%) mode change 100755 => 100644 create mode 100644 lib/reports/history.py create mode 100644 lib/reports/holdings.py rename milestone.py => lib/reports/milestone.py (96%) mode change 100755 => 100644 rename performance.py => lib/reports/performance.py (93%) mode change 100755 => 100644 rename podcasts.py => lib/reports/podcasts.py (97%) mode change 100755 => 100644 rename price.py => lib/reports/price.py (94%) mode change 100755 => 100644 create mode 100644 lib/reports/profile.py create mode 100644 lib/reports/rankings.py rename rating.py => lib/reports/rating.py (95%) mode change 100755 => 100644 rename reminder.py => lib/reports/reminder.py (96%) mode change 100755 => 100644 rename shorts.py => lib/reports/shorts.py (95%) mode change 100755 => 100644 rename trades.py => lib/reports/trades.py (97%) mode change 100755 => 100644 diff --git a/CLAUDE.md b/CLAUDE.md index c59183c3..b6fddb0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,13 +27,15 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm - `bot.py`: WSGI entry; routes /telegram and /slack, authenticates, hands each message to `lib/worker.py` in a thread; SIGHUP reload, SIGUSR1 cache flush. - `lib/worker.py`: `COMMANDS` (regex → `on_*` handler, first match wins, `.SYMBOL` last); `Request`; the typing indicator. -- `lib/reports.py`: `prepare_*` builders for the chat-only reports (lines, plus a chart spec). -- `price.py performance.py trades.py shorts.py cal.py rating.py milestone.py reminder.py podcasts.py cleanup.py`: reports that also - run on a schedule; each has `lambda_handler()` and `job(argv)` (its schedule-line argument parser). +- `lib/reports/`: what finbot says. Scheduled (each has `lambda_handler()` and `job(argv)`, its schedule-line parser): `price + performance trades shorts cal rating milestone reminder podcasts cleanup`; the top-level `price.py` and the rest are shims + that run them. Chat-only (`prepare_*`): `about` (.help .jobs), `history` (.history .compare), `holdings` (.holdings .who .pie + .watchlist), `profile` (.SYMBOL), `rankings` (.marketcap .pe .fpe .peg .beta .buy .sell). - `lib/webhook.py`: sending (chunking, photos, Slack upload), markup, escaping, error reporting. `lib/telegram.py`: Bot API calls. -- `lib/charts.py`: Telegram period buttons and the rebuild behind them. `lib/markets.py`: market tables, flags, ticker - translation. `lib/formats.py`: names, units, dates, percents in words. `lib/links.py`: links. `lib/render.py`: every chart - (matplotlib). +- `lib/buttons.py`: period buttons under a chart. `lib/charts.py`: a button press rebuilds its chart; the Telegram command + keyboard. +- `lib/markets.py`: market tables, flags, ticker translation. `lib/formats.py`: names, units, dates, percents in words. + `lib/links.py`: links. `lib/render.py`: every chart (matplotlib). - `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources, cached in `DATA_DIR/cache`. `sharesight.portfolios(name=None)` gives `Portfolio` objects: `holdings()`, `tickers()`, `performance(days)`, `trades(days)`. @@ -45,8 +47,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm `('photo', size, caption)`. - Which test file: commands and scheduled reports `test_reports`; failures, bad input, WSGI and Slack events `test_edges`; Yahoo/Sharesight parsing, sending, Telegram API `test_providers`; scheduler `test_jobs`; config `test_config`; charts - `test_charts` (refresh goldens with `UPDATE_GOLDEN=1`); pure helpers `test_util`; data dir `test_datadir`; chunking, git_version - `test_finbot`. + `test_charts` (refresh goldens with `UPDATE_GOLDEN=1`); markets, formats, links, cache and state files `test_util`; data dir + `test_datadir`; chunking, git_version `test_finbot`. ## Conventions - Read settings as `config.env.` / `config.prefs.` where they are used, never into a default or module constant (a diff --git a/bot.py b/bot.py index 82ec25c6..8d1f851e 100755 --- a/bot.py +++ b/bot.py @@ -22,7 +22,7 @@ from lib import webhook from lib import yahoo from lib import render -import cleanup +from lib.reports import cleanup botName = None # the Telegram bot's @username, from telegram.register() at start AUTHENTICATED = 'finbot.authenticated' # set in a request's WSGI environ once it has passed its service's check diff --git a/lib/buttons.py b/lib/buttons.py new file mode 100644 index 00000000..752a0081 --- /dev/null +++ b/lib/buttons.py @@ -0,0 +1,44 @@ +"""Period buttons (Week | Month | Quarter | Max) under a chart: their markup, and the callback data that names the tickers.""" +import hashlib + +from lib import state + +PERIODS = {'d': ('1D', 1), 'w': ('7D', 7), 'm': ('1M', 30), 'q': ('3M', 90), 'y': ('1Y', 365), 'x': ('Max', None)} +BUTTONS = {'h': 'wmqyx', 'p': 'wmqyx', 'c': 'wmqyx', 'f': 'wmqyx', 'l': 'dwmqyx'} # chart kind -> buttons offered (h=history, p=price chart, c=compare, f=performance, l=price list) +MAX_DAYS = 3650 # what Max means for the Sharesight-based reports +MAX_CALLBACK_BYTES = 64 # Telegram limit on callback_data +REF_FILE = 'finbot_chart_buttons.json' + +def period_for_days(days, default=None): + """Button key for a day count, if it is one of the button periods; `default` when it is not (or days is None).""" + for key, (_, d) in PERIODS.items(): + if d and d == days: + return key + return default + +def _ref(tickers): + """Tickers as callback data: inline if it fits, else a short id persisted to disk.""" + inline = ','.join(tickers) + if len(('c|h|w|' + inline).encode()) <= MAX_CALLBACK_BYTES: + return inline + key = hashlib.sha1(inline.encode()).hexdigest()[:10] + with state.edit(REF_FILE, {}) as refs: + refs[key] = tickers + return '#' + key + +def resolve(ref): + if ref.startswith('#'): + tickers = state.load(REF_FILE, {}).get(ref[1:]) + if not tickers: + raise KeyError("these buttons have expired, please re-run the command") + return tickers + return ref.split(',') + +def keyboard(kind, tickers, active=None): + """Telegram inline keyboard; the active period is marked.""" + ref = _ref(tickers) + row = [] + for key in BUTTONS[kind]: + label = PERIODS[key][0] + row.append({'text': ('● ' + label) if key == active else label, 'callback_data': f"c|{kind}|{key}|{ref}"}) + return {'inline_keyboard': [row]} diff --git a/lib/charts.py b/lib/charts.py index d6d782be..c195b604 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -1,45 +1,15 @@ -"""Period buttons (Week | Month | Quarter | Max) for Telegram chart messages, and the rebuild behind them.""" +"""A period button pressed: the chart rebuilt for that period. And the Telegram command keyboard, kept current in every DM.""" import hashlib import json import sys +from lib import buttons from lib import formats from lib import links from lib import state from lib import webhook from lib import yahoo -from lib import reports - -PERIODS = {'d': ('1D', 1), 'w': ('7D', 7), 'm': ('1M', 30), 'q': ('3M', 90), 'y': ('1Y', 365), 'x': ('Max', None)} -BUTTONS = {'h': 'wmqyx', 'p': 'wmqyx', 'c': 'wmqyx', 'f': 'wmqyx', 'l': 'dwmqyx'} # chart kind -> buttons offered (h=history, p=price chart, c=compare, f=performance, l=price list) -MAX_DAYS = 3650 # what Max means for the Sharesight-based reports -MAX_CALLBACK_BYTES = 64 # Telegram limit on callback_data -REF_FILE = 'finbot_chart_buttons.json' - -def period_for_days(days, default=None): - """Button key for a day count, if it is one of the button periods; `default` when it is not (or days is None).""" - for key, (_, d) in PERIODS.items(): - if d and d == days: - return key - return default - -def _ref(tickers): - """Tickers as callback data: inline if it fits, else a short id persisted to disk.""" - inline = ','.join(tickers) - if len(('c|h|w|' + inline).encode()) <= MAX_CALLBACK_BYTES: - return inline - key = hashlib.sha1(inline.encode()).hexdigest()[:10] - with state.edit(REF_FILE, {}) as refs: - refs[key] = tickers - return '#' + key - -def resolve(ref): - if ref.startswith('#'): - tickers = state.load(REF_FILE, {}).get(ref[1:]) - if not tickers: - raise KeyError("these buttons have expired, please re-run the command") - return tickers - return ref.split(',') +from lib.reports import history, performance, price COMMAND_BUTTONS = sorted(['.beta', '.buy & sell', '.dividend', '.earnings', '.forward pe', '.marketcap', '.pe', '.peg', '.performance', '.premarket', '.price', '.session', '.shorts', '.trades', '.watchlist']) # alphabetical, row by row; '.buy & sell' sends both rating reports @@ -50,6 +20,7 @@ def command_keyboard(per_row=3): return {'keyboard': rows, 'resize_keyboard': True, 'is_persistent': True} KEYBOARD_FILE = 'finbot_keyboards.json' # chat id -> version of the command keyboard that chat last received + def keyboard_version(): return hashlib.sha1(json.dumps(command_keyboard(), sort_keys=True).encode()).hexdigest()[:8] @@ -81,40 +52,12 @@ def refresh_keyboards(): except Exception as e: print("keyboard refresh failed for", chat_id, e, file=sys.stderr) -def keyboard(kind, tickers, active=None): - """Telegram inline keyboard; the active period is marked.""" - ref = _ref(tickers) - row = [] - for key in BUTTONS[kind]: - label = PERIODS[key][0] - row.append({'text': ('● ' + label) if key == active else label, 'callback_data': f"c|{kind}|{key}|{ref}"}) - return {'inline_keyboard': [row]} - -def history_payload(ticker, service, highlight=None, graph=False): - """Caption lines for .history: performance table, with the selected interval in bold. Returns (lines, percent table, image): - the full history's chart with graph=True (if the graph preference is on), else None.""" - market_data = yahoo.fetch_detail(ticker, 600) - title = market_data.get(ticker, {}).get('profile_title', '') - ticker_link = links.finance_link(ticker, market_data.get(ticker, {}).get('profile_exchange', ''), service, days=1825, brief=False) - if ticker not in market_data or 'percent_change' not in market_data[ticker]: - return [f".history: no data found for ticker {ticker}"], None, None - table, image = yahoo.price_history(ticker) if graph else yahoo.price_history(ticker, graph=False) - lines = [webhook.bold(f"{title} ({ticker_link}) performance history", service)] - for interval in ('Max', '10Y', '5Y', '3Y', '1Y', 'YTD', '6M', '3M', '1M', '7D', '1D'): - if interval in table: - percent = table[interval] - label = webhook.bold(interval + ':', service) - if interval == highlight: - label = '● ' + label - lines.append(f"{formats.get_emoji(percent)} {label} {formats.signed_percent(percent)}") - return lines, table, image - def build(kind, tickers, period, service='telegram'): """(caption, image) for a chart kind at a button period; period None = the command's default view.""" - days = PERIODS[period][1] if period else None + days = buttons.PERIODS[period][1] if period else None if kind == 'h': ticker = tickers[0] - lines, table, image = history_payload(ticker, service, PERIODS[period or 'x'][0], graph=not days) # default view is the full history + lines, table, image = history.history_payload(ticker, service, buttons.PERIODS[period or 'x'][0], graph=not days) # default view is the full history if table is None: raise RuntimeError(lines[0]) if days: @@ -131,19 +74,17 @@ def build(kind, tickers, period, service='telegram'): return f"{formats.get_emoji(percent)} {market_data[ticker]['profile_title']} ({link}) {formats.signed_percent(round(percent))}", image if kind == 'c': args = list(tickers) + ([f'{days}d'] if days else ['max']) - caption, image, *_ = reports.prepare_compare(service, args) + caption, image, *_ = history.prepare_compare(service, args) if not image: raise RuntimeError(caption[0]) return '\n'.join(caption), image if kind == 'f': # performance: tickers = [portfolio name or ''] - import performance - payload, image = performance.report(service, days or MAX_DAYS, tickers[0] or None, interactive=True) + payload, image = performance.report(service, days or buttons.MAX_DAYS, tickers[0] or None, interactive=True) if not image: raise RuntimeError(payload[0] if payload else 'no performance data') return '\n'.join(payload), image if kind == 'l': # price list: tickers = [threshold, top] - import price - span = {'interday': True} if period == 'd' else {'days': days or MAX_DAYS} # 'd' is today's moves: the command's default view, not a 1-day lookback + span = {'interday': True} if period == 'd' else {'days': days or buttons.MAX_DAYS} # 'd' is today's moves: the command's default view, not a 1-day lookback payload, image = price.report(service, threshold=float(tickers[0]), interactive=True, top=int(tickers[1]) or None, **span) if not image: raise RuntimeError(payload[0] if payload else 'no price data') diff --git a/lib/jobrun.py b/lib/jobrun.py index c3bc0d29..1f6b7c7d 100644 --- a/lib/jobrun.py +++ b/lib/jobrun.py @@ -1,4 +1,4 @@ -"""Runs one scheduled command in its own process: `python -m lib.jobrun