diff --git a/defaults/settings.conf b/defaults/settings.conf index 3bece61..f99894e 100644 --- a/defaults/settings.conf +++ b/defaults/settings.conf @@ -44,7 +44,8 @@ # Display #graph=True -# darkmode: the charts' theme. off: light; on: dark; auto: dark from 18:00 to 06:00 in the timezone setting +# darkmode: the charts' theme. off: light; on: dark; or the hours they are dark, in the timezone setting: +# 18:00-6:00, 18-6, 6pm-6am #darkmode=off #hyperlink=True #hyperlinkFooter=True diff --git a/doc/configuration.md b/doc/configuration.md index b8bcdf5..1419c84 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -53,37 +53,37 @@ A change takes effect on a reload. A true/false setting takes `true`/`false`, `y |---|---|---| | `admin_only` | `jobs` | The commands only the admins may use, by name, space separated (`jobs watchlist`). Closed by default: `/jobs` is one, and only the admins may use it (see `admins`: a chat's own administrators are admins there). Blank (`admin_only=`) restricts no command. Someone else gets a refusal, and a press of one of those commands' buttons is refused with a message to them alone (an alert on Telegram, a message only they see on Slack and Discord) | | `admins` | | Who the admins are besides each chat's own administrators (below), space separated, each the network and the sender's id as the log shows them: `telegram:@username` (or the numeric user id; never a first name, which anyone may take), `slack:U0123ABCD` (`<@U0123ABCD>` in the log), `discord:123456789012345678` | - -The administrators of the chat a person is in are admins there with no setting: the creator and administrators of a Telegram group (the bot asks `getChatMember`), the owner and anyone with an Administrator role in a Discord server, and the owners and admins of a Slack workspace (`users.info`; needs the `users:read` scope, which the manifest has). In a direct message with the bot, a person is an admin if they are one in any Telegram group finbot has seen, or Discord server the bot is in (a Slack workspace's admins are admins everywhere). finbot asks again every five minutes, so a change shows soon. A question whose connection fails is asked once more at once; where it still fails (a network down, or a scope missing), the network's last answer stands, else the `admins` setting alone decides, and the next message asks again. -| `past_days` | `28` | How many days back reports look | -| `future_days` | `30` | How many days ahead the events calendar looks | -| `price_percent` | `5` | Movers threshold, percent: the moves `/movers`, `/session` and their jobs list | -| `shorts_percent` | `15` | Short interest alert threshold, percent | -| `min_analysts` | `7` | `/buy`, `/sell`, and `/ratio` (the forward P/E and the PEG) list only stocks covered by at least this many analysts; with fewer, a consensus or an estimate says little (thinly covered small caps are nearly all a perfect buy). Their charts say so in the subtitle | -| `demote_volatile` | `True` | Volatile (small cap, high beta) stocks need a bigger move to alert | +| `cache` | `True` | Cache Yahoo and Sharesight responses | +| `cache_seconds` | `21600` | Cache lifetime in seconds (6 hours) | +| `country_code` | `AU` | Regional: your country, in any case, for the default jobs' messages that are for one (`AU`: the tax year's reminders) | +| `darkmode` | `off` | The charts' theme. `off`: light; `on`: dark; or the hours the charts are dark in the `timezone` setting, light otherwise: `18:00-6:00`, `18-6` or `6pm-6am`. The shorts chart keeps its own colours | | `demote_leveraged` | `True` | Leveraged (2x, 3x) funds need a bigger move to alert | -| `volatility_multiplier` | `2` | How much bigger a move volatile stocks need | +| `demote_volatile` | `True` | Volatile (small cap, high beta) stocks need a bigger move to alert | +| `exchange_suffix` | `AX` | Regional: Yahoo's suffix for your exchange (`AX` for the ASX, `NZ`, `L`, `TO` and so on). A bare symbol Yahoo does not know is tried again with it, in every command that takes a symbol (`BHP` becomes `BHP.AX`); empty, a symbol is taken as written | | `exclude_portfolios` | | Sharesight portfolio IDs to leave out, space separated | -| `include_portfolios` | | Sharesight portfolio IDs to report on, space separated (empty: all that are not excluded) | -| `trades_use_yahoo` | `True` | Use Yahoo for trades; set `False` if Yahoo breaks | +| `future_days` | `30` | How many days ahead the events calendar looks | | `graph` | `True` | Send charts with reports | -| `darkmode` | `off` | The charts' theme. `off`: light; `on`: dark; `auto`: dark from 18:00 to 06:00 in the `timezone` setting, light by day. The shorts chart keeps its own colours | +| `http_timeout` | `18` | Seconds to wait on outbound HTTP requests | | `hyperlink` | `True` | Hyperlink tickers in reports | | `hyperlinkFooter` | `True` | Add a website link footer to stock lookups | | `hyperlinkProvider` | `google` | Where ticker links go: `google` or `yahoo` (any other value stops a start or a reload, naming the key) | -| `exchange_suffix` | `AX` | Regional: Yahoo's suffix for your exchange (`AX` for the ASX, `NZ`, `L`, `TO` and so on). A bare symbol Yahoo does not know is tried again with it, in every command that takes a symbol (`BHP` becomes `BHP.AX`); empty, a symbol is taken as written | -| `country_code` | `AU` | Regional: your country, in any case, for the default jobs' messages that are for one (`AU`: the tax year's reminders) | -| `timezone` | `Australia/Melbourne` | Regional: your time zone, the clock of the times `/jobs` takes and shows, a job's zone where it names none, and what "today" is (ytd, reports' dates); a name that is no zone stops a start or a reload, naming the key | -| `telegram_preload_chat` | | A private Telegram channel or group of your own, by its id (`-1001234567890`, as the log shows a chat's): as a chart with period or portfolio buttons goes out, its other periods and portfolios are uploaded there, so that a press shows them without an upload (see [Telegram](telegram.md#preloading-charts)). Empty: off | -| `slack_preload_chat` | | The same on Slack: a private channel's id (`C0123ABCD`), the bot invited (see [Slack](slack.md#preloading-charts)). Empty: off | -| `preload_gap` | `0.1` | Seconds between the starts of two charts preloaded to a network (two uploads at a time, on Telegram and Slack alike, each chart sent as soon as it is drawn). A rate limit is waited out once and the upload tried again, as every request to a network is | -| `cache` | `True` | Cache Yahoo and Sharesight responses | -| `cache_seconds` | `21600` | Cache lifetime in seconds (6 hours) | -| `performance_cache_seconds` | `2400` | Cache lifetime of Sharesight performance reports in seconds: longer than the 30 minutes between `sharesight_warm` runs, so the holdings never run out | -| `http_timeout` | `18` | Seconds to wait on outbound HTTP requests | +| `include_portfolios` | | Sharesight portfolio IDs to report on, space separated (empty: all that are not excluded) | | `log_level` | `INFO` | What the log shows: that level and those above it, syslog style. `ERROR`: what failed and was lost (a message not sent); `WARN`: what failed and was got over (a retry, a fallback, a request refused); `INFO`: what finbot does (each message sent, each job run, each request sent, each request refused); `DEBUG`: the routine of a button press too (the press, its acknowledgement, the message edited in place), each request answered with success, and each cache miss, hit and write. Replaces `debug`. Under systemd each line carries its syslog priority (error, warning, info, debug), so `journalctl` colours it and `journalctl -p warning` filters by it | | `log_width` | `71` | The most characters of a log line that can run long: a longer one is cut there, ending in `…`. That is each request's line (its method and URL) and each chat message's (who sent what, in which network). A message of several lines is logged on one line, its line breaks spaces. `0`: no line is cut | +| `min_analysts` | `7` | `/buy`, `/sell`, and `/ratio` (the forward P/E and the PEG) list only stocks covered by at least this many analysts; with fewer, a consensus or an estimate says little (thinly covered small caps are nearly all a perfect buy). Their charts say so in the subtitle | +| `past_days` | `28` | How many days back reports look | +| `performance_cache_seconds` | `2400` | Cache lifetime of Sharesight performance reports in seconds: longer than the 30 minutes between `sharesight_warm` runs, so the holdings never run out | +| `preload_gap` | `0.1` | Seconds between the starts of two charts preloaded to a network (two uploads at a time, on Telegram and Slack alike, each chart sent as soon as it is drawn). A rate limit is waited out once and the upload tried again, as every request to a network is | +| `price_percent` | `5` | Movers threshold, percent: the moves `/movers`, `/session` and their jobs list | | `print_headers` | `False` | Log inbound request headers | +| `shorts_percent` | `15` | Short interest alert threshold, percent | +| `slack_preload_chat` | | The same on Slack: a private channel's id (`C0123ABCD`), the bot invited (see [Slack](slack.md#preloading-charts)). Empty: off | +| `telegram_preload_chat` | | A private Telegram channel or group of your own, by its id (`-1001234567890`, as the log shows a chat's): as a chart with period or portfolio buttons goes out, its other periods and portfolios are uploaded there, so that a press shows them without an upload (see [Telegram](telegram.md#preloading-charts)). Empty: off | +| `timezone` | `Australia/Melbourne` | Regional: your time zone, the clock of the times `/jobs` takes and shows, a job's zone where it names none, and what "today" is (ytd, reports' dates); a name that is no zone stops a start or a reload, naming the key | +| `trades_use_yahoo` | `True` | Use Yahoo for trades; set `False` if Yahoo breaks | +| `volatility_multiplier` | `2` | How much bigger a move volatile stocks need | + +The administrators of the chat a person is in are admins there with no setting: the creator and administrators of a Telegram group (the bot asks `getChatMember`), the owner and anyone with an Administrator role in a Discord server, and the owners and admins of a Slack workspace (`users.info`; needs the `users:read` scope, which the manifest has). In a direct message with the bot, a person is an admin if they are one in any Telegram group finbot has seen, or Discord server the bot is in (a Slack workspace's admins are admins everywhere). finbot asks again every five minutes, so a change shows soon. A question whose connection fails is asked once more at once; where it still fails (a network down, or a scope missing), the network's last answer stands, else the `admins` setting alone decides, and the next message asks again. A key in the wrong file, or one that is not listed, stops finbot starting; on a reload it is reported and nothing is applied. A key finbot no longer reads (`alliterate`, `chunk_maxlines`) is ignored, and the log at start says to delete it. diff --git a/lib/config.py b/lib/config.py index aa06130..a57160f 100644 --- a/lib/config.py +++ b/lib/config.py @@ -36,7 +36,7 @@ } RENAMED = {} # startup keys under a new name: the old still works (the new wins where both are set), with a note SERVICE_DATA_DIR = '/var/lib/private/finbot' # systemd's state directory of finbot.service (StateDirectory=) -DARKMODES = ('off', 'on', 'auto') # darkmode's values: light charts, dark ones, or dark at night (lib/render.py) +DARKMODES = ('off', 'on') # darkmode's words: light charts or dark ones; else the hours they are dark (lib/render.py) LOG_LEVELS = ('ERROR', 'WARN', 'INFO', 'DEBUG') # log_level's values, the most severe first (lib/log.py) @@ -150,6 +150,26 @@ def read_startup_file(path): FALSE_WORDS = ('false', '0', 'f', 'no', 'n', 'off') +_HOURS = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*-\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?', re.IGNORECASE) + +def night_hours(text): + """The minutes of the day (start, end) of "18:00-6:00", "18-6" or "6pm-6am", or None where it is no such hours + (or starts when it ends). The end may come before the start: the hours run past midnight.""" + if not (found := _HOURS.fullmatch(text.strip())): + return None + times = [] + for hours, mins, half in (found.group(1, 2, 3), found.group(4, 5, 6)): + hour, minute = int(hours), int(mins or 0) + if half: + if not 1 <= hour <= 12: + return None + hour = hour % 12 + (12 if half.lower() == 'pm' else 0) + if hour > 23 or minute > 59: + return None + times.append(hour * 60 + minute) + return tuple(times) if times[0] != times[1] else None + + class _Reader: """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.""" @@ -206,6 +226,17 @@ def choice(self, name, default, choices): self.errors.append(f"{name}={self.getenv(name, default)!r} is not one of {', '.join(choices)}") return default + def darkmode(self, name, default): + """off, on or the hours the charts are dark, "18:00-6:00", "18-6" or "6pm-6am": as off, on or HH:MM-HH:MM; + anything else is an error, giving default.""" + value = str(self.value(name, default)).strip() + if value.lower() in DARKMODES: + return value.lower() + if hours := night_hours(value): + return '%02d:%02d-%02d:%02d' % (*divmod(hours[0], 60), *divmod(hours[1], 60)) + self.errors.append(f"{name}={value!r} is not one of {', '.join(DARKMODES)} or hours such as 6pm-6am") + return default + def zone(self, name, default): """A time zone's name as zoneinfo knows it (Australia/Sydney, UTC); anything else is an error, giving default: a zone read wrong would schedule every new job in UTC.""" @@ -305,7 +336,7 @@ class Settings: cache: bool = True cache_seconds: int = 21600 country_code: str = 'AU' - darkmode: str = 'off' # charts' theme: one of DARKMODES + darkmode: str = 'off' # charts' theme: one of DARKMODES, or the hours they are dark (HH:MM-HH:MM) demote_leveraged: bool = True demote_volatile: bool = True exchange_suffix: str = 'AX' # Yahoo's suffix for the local exchange: a bare ticker Yahoo does not know gets it @@ -342,7 +373,7 @@ def setting_values(values): 'admin_only': r.blankable_words, 'exchange_suffix': lambda name, default: r.blankable(name, default).removeprefix('.').upper(), # blank: none 'country_code': lambda name, default: r.str(name, default).strip().upper(), - 'darkmode': lambda name, default: r.choice(name, default, DARKMODES), + 'darkmode': r.darkmode, 'hyperlinkProvider': lambda name, default: r.choice(name, default, ('google', 'yahoo')), 'log_level': lambda name, default: r.choice(name, default, LOG_LEVELS), 'timezone': r.zone} diff --git a/lib/render.py b/lib/render.py index 8bd0a6c..14f8fd9 100644 --- a/lib/render.py +++ b/lib/render.py @@ -51,7 +51,6 @@ def drawable(self): PALETTE = ('#14181f', '#eef1f5', '#9aa4b2', '#2a313c') # the dark charts: surface, ink, muted ink, grid lines LIGHT_PALETTE = ('#ffffff', '#1a1f27', '#5f6b7a', '#e1e5ea') # the light charts' (darkmode off) LIGHT_UP, LIGHT_DOWN = '#0f9d6b', '#e11d48' # a rise and a fall on a light surface: darker, to read on white -NIGHT = (18, 6) # darkmode auto: dark from 18:00 to 06:00 in the timezone setting CATEGORIES = ('#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767') # the dark # categorical slots, in fixed order; the green and red ones mean up and down, so lines that are neither leave them out NEITHER_UP_NOR_DOWN = tuple(color for color in CATEGORIES if color not in ('#008300', '#e66767')) @@ -61,14 +60,16 @@ def drawable(self): _dark_now = contextvars.ContextVar('dark_now', default=None) # draw() settles the theme once, for its whole drawing def is_dark(): - """Whether charts are drawn dark now: the darkmode setting (on; auto: at night in the timezone setting).""" + """Whether charts are drawn dark now: the darkmode setting (on; or within its hours in the timezone).""" if (settled := _dark_now.get()) is not None: return settled mode = config.settings.darkmode - if mode == 'auto': - hour = datetime.datetime.now(zoneinfo.ZoneInfo(config.settings.timezone)).hour - return hour >= NIGHT[0] or hour < NIGHT[1] - return mode == 'on' + if mode in ('on', 'off'): + return mode == 'on' + start, end = config.night_hours(mode) + now = datetime.datetime.now(zoneinfo.ZoneInfo(config.settings.timezone)) + minute = now.hour * 60 + now.minute + return start <= minute < end if start < end else minute >= start or minute < end def palette(): """The surface, ink, muted ink and grid colours of the theme in force.""" diff --git a/tests/test_charts.py b/tests/test_charts.py index 7ac36a1..723cabf 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -537,12 +537,23 @@ def test_off_is_light_and_on_is_dark(self): self.assertEqual(self.corner('off'), (255, 255, 255)) self.assertEqual(self.corner('on'), BG) - def test_auto_follows_the_night(self): - """Auto is dark from 18:00 to 06:00 and light between.""" - self.assertEqual(self.corner('auto', 12), (255, 255, 255)) - self.assertEqual(self.corner('auto', 23), BG) - self.assertEqual(self.corner('auto', 5), BG) - self.assertEqual(self.corner('auto', 6), (255, 255, 255)) + def test_hours_are_the_night(self): + """Hours in any spelling are dark within them, past midnight or not, and light outside.""" + for mode in ('20:00-7:00', '20-7', '8pm-7am'): + self.assertEqual(self.corner(mode, 19), (255, 255, 255), mode) + self.assertEqual(self.corner(mode, 20), BG, mode) + self.assertEqual(self.corner(mode, 6), BG, mode) + self.assertEqual(self.corner(mode, 7), (255, 255, 255), mode) + self.assertEqual(self.corner('9-17', 12), BG) + self.assertEqual(self.corner('9-17', 17), (255, 255, 255)) + + def test_hours_are_read(self): + """The setting reads hours, and refuses what is not.""" + for text in ('18:00-6:00', '18-6', '6pm-6am', ' 6PM - 6:00 am '): + self.assertEqual(config.setting_values({'darkmode': text}).darkmode, '18:00-06:00', text) + for text in ('18-18', '25-6', '0pm-6am', 'night', '18', 'auto'): + with self.assertRaises(config.ConfigError, msg=text): + config.setting_values({'darkmode': text}) def test_the_cache_keeps_a_chart_per_theme(self): """The same spec drawn light and dark are two images, not one read back."""