Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:
return 1
}
wait_for 'finbot is running'
test -f "$data/preferences.conf"
test -f "$data/settings.conf"
docker kill -s HUP finbot
wait_for 'config: reloaded'
docker kill -s USR1 finbot
Expand Down
22 changes: 11 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
path a test reads). This cloud environment cannot reach `deb.debian.org`, so only CI builds the image.
- Production runs the Debian system packages; `requirements.txt` is for pip/uv users and CI. The libraries in `requirements-optional.txt` are each imported only
inside the code of the mode that needs it, never at module load (a test starts finbot with them all blocked).
- A new preference goes in three places: a `Prefs` field with its default (`lib/config.py`; its type says how it is
read), `defaults/preferences.conf` (commented out at its default, `#key=value`: the template sets nothing, and finbot
never reads it), and the preferences table of `doc/configuration.md`. The tests catch a key
- A new setting goes in three places: a `Settings` field with its default (`lib/config.py`; its type says how it is
read), `defaults/settings.conf` (commented out at its default, `#key=value`: the template sets nothing, and finbot
never reads it), and the settings table of `doc/configuration.md`. The tests catch a key
missing from the template or a default that differs, but not the docs (no test may read README.md or `doc/*.md`: CI
skips the tests for a change to the docs alone).
- If Yahoo and Sharesight are unreachable (the cloud environment's network policy can block them), check behaviour
Expand Down Expand Up @@ -111,14 +111,14 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
`Portfolio` objects: `holdings()`, `tickers()`, `gain(days)` (the portfolio's), `trades(days)`. Sharesight is
optional (`sharesight.configured()`): without its keys `tickers()` is empty, so reports cover the watchlist alone, and
`portfolios()` raises `NotSetUp`, an answer.
- `lib/log.py`: the log, by level (`error warn info debug`), as the `log_level` preference lets in, and `cut()`, which
keeps a line that can run long (a request's, a chat message's) on one line, within the `log_width` preference;
- `lib/log.py`: the log, by level (`error warn info debug`), as the `log_level` setting lets in, and `cut()`, which
keeps a line that can run long (a request's, a chat message's) on one line, within the `log_width` setting;
bot.py's `WholeLines` writes each line whole, whichever threads log at once. `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; a file of one record per chat with
`state.load_for(chat, name, default)` and `state.edit_for`. A chat's key is its network and id (`Transport.key`,
`telegram:-1001234`), the same in both modes. `lib/watchlist.py`: each chat's watchlist (`tickers(chat)`), and
`with_holdings(chat)`, the stocks a report in that chat covers; a report takes the chat from its destination.
- `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.prefs` (preferences.conf, a new object on
- `lib/config.py`: `config.env` (finbot.env, fixed at start) and `config.settings` (settings.conf, a new object on
each reload). `lib/jobs.py`: the scheduler: each chat's jobs, the default ones and those it added (`JOBS_FILE`, by
chat key: the defaults it switched off and those it removed, `'deleted'`, the jobs it added; a direct message's
defaults start off, `'defaults': 'off'` with those switched on, and it is marked `'dm'`; `see` makes a chat known at
Expand Down Expand Up @@ -153,8 +153,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
checks `test_finbot`.

## Conventions
- Read settings as `config.env.<name>` / `config.prefs.<name>` where they are used, never into a default or module
constant (a reload must be seen); in tests, `self.env(...)` / `self.prefs(...)` change them.
- Read settings as `config.env.<name>` / `config.settings.<name>` where they are used, never into a default or module
constant (a reload must be seen); in tests, `self.env(...)` / `self.settings(...)` change them.
- A report is a list of lines. `service` is a dialect (`lib/dialects.py`), which equals the network's name: markup
with `service.bold(text)`, `.italic`, `.strike`, `.link(url, text)`, user-written text through
`service.escape(text)`, stock links through `links.finance_link(symbol, exchange, service)`. Tests pass `TELEGRAM`,
Expand All @@ -169,8 +169,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
sent, a job run; `net` logs each request it sends), `log.debug` (cache misses, hits and writes; the routine of a
press: the press, its acknowledgement and the edit in place, sent with `logged='debug'`, and a success answered to
a network). The
`log_level` preference sets what reaches the log; `lib/config.py` prints its own errors (it loads before
preferences exist). Everything goes to stderr: systemd discards stdout (`StandardOutput=null`), so the bot and its reports
`log_level` setting sets what reaches the log; `lib/config.py` prints its own errors (it loads before
settings exist). Everything goes to 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 report gives its chart as a `render.Chart` spec, not a drawing: `deliver()` draws it only for a chat that takes
charts, through `render.draw()`, which caches the image by the spec's hash (and the drawing code's).
Expand All @@ -185,6 +185,6 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
green). One module: `.venv/bin/python -m unittest tests.test_reports -b`.
- README.md is short: what finbot is, a quick start, and links. The rest is in `doc/`, a file per topic: `commands.md`
(the commands and reports, 16 KB: grep for the section instead of reading it whole), `configuration.md` (the keys
and preferences), `scheduling.md`, `caching.md`, `install.md` (a Debian host), `docker.md`, and a guide per network,
and settings), `scheduling.md`, `caching.md`, `install.md` (a Debian host), `docker.md`, and a guide per network,
`telegram.md`, `slack.md` and `discord.md`: a change to a network's setup or keys goes in its guide. `doc/yahoo/` and
`doc/sharesight/` hold large API samples that the tests replay: inspect them with python, don't read them.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ To run it as a service, under systemd on a Debian host, see [doc/install.md](doc

### The chat networks
Each network needs a bot of its own. By default, finbot fetches its updates from each network itself (pull mode).
Alternatively, if you prefer to run an HTTPS gateway (a reverse proxy on a domain with a valid certificate), HTTP mode
subscribes the bot to the updates each network pushes to it (each guide's _HTTP mode_).
Alternatively, if you prefer to run an HTTPS gateway (a reverse proxy on a domain with a valid certificate), push mode
subscribes the bot to the updates each network pushes to it (each guide's _push mode_).
* **Telegram:** create the bot with [@BotFather](https://telegram.me/BotFather), set `telegramBotToken`, restart
finbot ([guide](doc/telegram.md)).
* **Slack:** create the app from the manifest that `util/slack_manifest.py` prints, add an app-level token, set
Expand All @@ -62,7 +62,7 @@ jobs, which `/jobs` switches on and off, adds to and runs ([Scheduling](doc/sche
## Documentation
* [Commands and reports](doc/commands.md), with screenshots
* [Scheduling](doc/scheduling.md): each chat's jobs, and the reports they can run
* [Configuration](doc/configuration.md): `finbot.env` and `preferences.conf`, the data directory, Sharesight
* [Configuration](doc/configuration.md): `finbot.env` and `settings.conf`, the data directory, Sharesight
* [Caching](doc/caching.md)
* Installing: [Docker](doc/docker.md) · [Python, or a Debian host with systemd](doc/install.md)
* Networks: [Telegram](doc/telegram.md) · [Slack](doc/slack.md) · [Discord](doc/discord.md)
Expand Down
12 changes: 6 additions & 6 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def print_headers(say=log.info):
"""Log the request headers (at INFO, or as say() logs), Telegram's secret header redacted."""
for name, value in sorted(environ.items()):
say((name, '<redacted>' if name == 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' else value))
if config.prefs.print_headers:
if config.settings.print_headers:
print_headers()
path = environ.get('PATH_INFO', '')
source = route(path)
Expand Down Expand Up @@ -260,17 +260,17 @@ def run():
return lambda *args: threading.Thread(target=run, daemon=True).start()

def reload_all(*args):
"""SIGHUP: re-read preferences.conf; a bad one is reported and leaves the running config untouched. A key set in
defaults/preferences.conf, which is never read, is pointed at the file to set it in. (Each chat's jobs need no
"""SIGHUP: re-read settings.conf; a bad one is reported and leaves the running config untouched. A key set in
defaults/settings.conf, which is never read, is pointed at the file to set it in. (Each chat's jobs need no
reload: a change made with .jobs counts at once.)"""
try:
changes = config.reload()
for change in changes:
log.info('config:', change)
log.info(f"config: reloaded {config.preferences_path}, {len(changes)} change{'' if len(changes) == 1 else 's'}")
log.info(f"config: reloaded {config.settings_path}, {len(changes)} change{'' if len(changes) == 1 else 's'}")
except config.ConfigError as e:
config.print_errors(e)
if note := config.template_note(config.preferences_path):
if note := config.template_note(config.settings_path):
log.warn('WARN config:', note)

def flush_cache(*args):
Expand Down Expand Up @@ -339,7 +339,7 @@ def http_server():
threading.Thread(target=scheduler.run_forever, daemon=True).start() # the scheduled reports, formerly cron jobs
for listener in listeners(): # the networks without a webhook: finbot fetches their updates
threading.Thread(target=listener.run, args=(take,), daemon=True).start()
# Startup is done. systemctl reload finbot: pick up an edited preferences.conf
# Startup is done. systemctl reload finbot: pick up an edited settings.conf
signal.signal(signal.SIGHUP, in_thread(reload_all))
signal.signal(signal.SIGUSR1, in_thread(flush_cache)) # systemctl kill -s USR1 finbot: delete the cache
log.info('finbot is running: SIGHUP reloads, SIGUSR1 flushes the cache')
Expand Down
6 changes: 3 additions & 3 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# finbot with Docker Compose (doc/docker.md). The configuration lives beside the checkout, outside the image and the
# container: ../finbot.env, the startup file, and ../finbot-data, DATA_DIR (preferences.conf, state and cache), both
# container: ../finbot.env, the startup file, and ../finbot-data, DATA_DIR (settings.conf, state and cache), both
# made by util/docker_setup.sh. Build and start: docker compose up -d --build
services:
finbot:
Expand All @@ -13,11 +13,11 @@ services:
env_file: ../finbot.env
environment:
DATA_DIR: /data
ip: 0.0.0.0 # HTTP mode listens inside the container: publish its port below
ip: 0.0.0.0 # push mode listens inside the container: publish its port below
volumes:
- ../finbot-data:/data
- ../finbot.env:/run/finbot.env:ro # checked before the bot starts
# ports: # HTTP mode only, on the host's loopback, behind your reverse proxy
# ports: # push mode only, on the host's loopback, behind your reverse proxy
# - 127.0.0.1:5000:5000
logging:
driver: ${FINBOT_LOG_DRIVER:-journald} # json-file where the host has no journald
Expand Down
10 changes: 5 additions & 5 deletions defaults/finbot.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
# systemd: EnvironmentFile=/opt/finbot.env (in finbot.service)
# docker: --env-file /opt/finbot.env
# init.d: set -a; . /opt/finbot.env; set +a; ./bot.py
# Keep it private: chmod 600, owned by root. It holds the credentials.
# Keep it private: chmod 600, owned by root:root. It holds the credentials (check_config.py warns if not).
# Flat KEY=value lines: no spaces around "=", no quotes (docker keeps quotes literally), no trailing comments; comments
# on their own line.
# Everything else (thresholds, windows, display, caching, ...) is in preferences.conf, which the bot reloads without a
# Everything else (thresholds, windows, display, caching, ...) is in settings.conf, which the bot reloads without a
# restart.
# A preference key in this file is an error: finbot refuses to start. Lint both files with:
# A setting key in this file is an error: finbot refuses to start. Lint both files with:
# util/check_config.py /opt/finbot.env

# Where state and cache live. Leave unset under systemd with StateDirectory=finbot (it uses $STATE_DIRECTORY,
# /var/lib/finbot).
# DATA_DIR=/var/lib/finbot

# The listening socket, for a network in http mode (its webhook URL set below)
# The listening socket, for a network in push mode (its webhook URL set below)
ip=127.0.0.1
port=5000

Expand All @@ -36,7 +36,7 @@ slackAppToken=
# Discord's bot token: reports, and commands over the gateway API (pull mode, the default)
discordBotToken=

# Chat networks: inbound over HTTPS (http mode). Telegram: set both to have it post its updates to your server
# Chat networks: inbound over HTTPS (push mode). Telegram: set both to have it post its updates to your server
# instead of finbot fetching them
telegramOutgoingWebhook=
telegramOutgoingToken=
Expand Down
61 changes: 0 additions & 61 deletions defaults/preferences.conf

This file was deleted.

63 changes: 63 additions & 0 deletions defaults/settings.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# finbot settings: how the bot behaves.
#
# This is only the template; finbot never reads it. The bot reads <DATA_DIR>/settings.conf at
# start and on every reload, and runs on its built-in defaults without one.
# util/sync_defaults.py --apply copies this file there.
#
# Every key below is commented out at its default: remove the # to change one.
# Apply an edit: sudo systemctl reload finbot (docker: docker compose kill -s HUP finbot)
# Check it first: util/check_config.py /path/to/finbot.env
#
# Format: one KEY=value per line. Not an INI file: no [sections], no spaces around "=",
# no quotes, no trailing comments (comments go on their own line). An unknown key stops
# the start.
# Credentials, ip and port are not set here but in finbot.env, which needs a restart.

# Reports: how far to look back and forward, and when to alert
#past_days=28
#future_days=30
#price_percent=5
#shorts_percent=15
# .buy, .sell and .forward pe list only stocks covered by at least this many analysts
#min_analysts=7
# Price alerts: volatile and leveraged stocks need a bigger move, by volatility_multiplier
#demote_volatile=True
#demote_leveraged=True
#volatility_multiplier=2

# Portfolios to report on: Sharesight portfolio IDs, space separated (empty: all not excluded)
#exclude_portfolios=
#include_portfolios=

# Data sources: set trades_use_yahoo to False if Yahoo breaks
#trades_use_yahoo=True

# Display
#graph=True
#hyperlink=True
#hyperlinkFooter=True
#hyperlinkProvider=google

# Regional
# exchange_suffix: a ticker Yahoo does not know is retried with your exchange's suffix
# (AX: BHP is tried as BHP.AX; NZ, L, TO and so on for others). Empty: taken as written.
# country_code: for the default jobs' messages meant for one country (AU: tax year reminders)
# timezone: the clock of the times .jobs takes and shows, and of a job that names no zone
#exchange_suffix=AX
#country_code=AU
#timezone=Australia/Melbourne

# Caching and network, in seconds
#cache=True
#cache_seconds=21600
#performance_cache_seconds=2400
#http_timeout=18

# Diagnostics
# log_level: ERROR, WARN, INFO (what finbot does, and each request it sends) or DEBUG
# (each cache miss, hit and write too); the log shows that level and those above it
# log_width: the most characters of a long log line (a request, a chat message), cut with
# an ellipsis (…); 0 cuts none
#log_level=INFO
#log_width=71
#print_headers=False
Loading