Skip to content
3 changes: 2 additions & 1 deletion defaults/settings.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 22 additions & 22 deletions doc/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
37 changes: 34 additions & 3 deletions lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
13 changes: 7 additions & 6 deletions lib/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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."""
Expand Down
23 changes: 17 additions & 6 deletions tests/test_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down