Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
8160a37
Slack replies without a bot token say so; user text is escaped; boole…
claude Oct 14, 2026
d71c1b1
Dead plumbing removed, quiet tests, a chat-networks table and a code …
claude Oct 14, 2026
40c51b7
SIGUSR1 flushes the cache: systemctl kill -s USR1 finbot, or systemct…
claude Oct 14, 2026
551caa7
Charts drawn off the gevent hub; a retried update runs once; report s…
claude Oct 14, 2026
8e09893
Discord: the native webhook, with charts, masked links and no link pr…
claude Oct 14, 2026
e8b635d
Tabs everywhere: the tests, shortman.py and sws_gen.py re-indented; .…
claude Oct 14, 2026
34b11b7
Config as two objects: config.env (finbot.env) and config.prefs (pref…
claude Oct 14, 2026
af891b4
Cache and state files in their own modules: lib/cache.py and lib/stat…
claude Oct 14, 2026
1348e87
State files change under a lock; the watchlist is an object (lib/watc…
claude Oct 14, 2026
7d9dcf2
Sharesight portfolios are objects: sharesight.portfolios() gives Port…
claude Oct 14, 2026
bd85fb1
Stdout prints removed: systemd discarded every one
claude Oct 14, 2026
5c13613
util.py split by subject: markets, formats, links and render
claude Oct 14, 2026
3da57c0
Reports move into a lib/reports/ package; the button helpers into lib…
claude Oct 15, 2026
03d8147
A top-level script runs each report, as before
claude Oct 15, 2026
1940085
Markup by dialect: service.bold(text) in place of webhook.bold(text, …
claude Oct 15, 2026
a3cd2aa
A docstring for every function in the bot, lib/ and util/
claude Oct 15, 2026
db95ce0
A docstring for every test and test helper too
claude Oct 15, 2026
fda8ed2
util/record_outputs.py: the old/new output comparison, now in the repo
claude Oct 15, 2026
8b6fa8f
Transports and one delivery path
claude Oct 15, 2026
4dd8f8a
Inbound updates per network: route, verify, parse, dispatch
claude Oct 15, 2026
5f9edf0
Slack signing secrets
claude Oct 15, 2026
a4328ce
One-line docstrings where they fit
claude Oct 15, 2026
aebc37f
No data is an answer, not an error (D11)
claude Oct 15, 2026
3339148
Telegram: period buttons only where their presses arrive
claude Oct 15, 2026
d08177f
A setup guide per chat network, under doc/
claude Oct 15, 2026
bb371af
Change figures signed everywhere (D13)
claude Oct 15, 2026
234ce06
The EOFY reminder moves into reminder.json (D12)
claude Oct 15, 2026
5d3e8ca
One command table makes .help and the keyboard
claude Oct 15, 2026
6254fac
Every report runs as run(dest=None, **kwargs) (D7)
claude Oct 15, 2026
c9635dd
trades and cal: gather once, then report per network (R5, R6)
claude Oct 15, 2026
2721975
milestone, rating, shorts and podcasts: gather, then report per network
claude Oct 15, 2026
35dbbc9
Chart specs, drawn once and only where they go (C1, C2, C8, D3a)
claude Oct 15, 2026
13ff982
Line and compare charts become specs too; drawing leaves Yahoo (C10, …
claude Oct 15, 2026
2032271
Bar charts take the shared heading; one categorical palette (C3, C4)
claude Oct 15, 2026
f29d8b0
Chart builders per kind, typed button arguments, tests on specs (C9, …
claude Oct 15, 2026
2325933
Small rewrites: names, tables, a clear Sharesight error (R12, R16)
claude Oct 15, 2026
dc2c348
Links: Yahoo wherever Google has no page; the text alone without hype…
claude Oct 15, 2026
4da65cd
Pin only the scheduled earnings report
claude Oct 15, 2026
9bb30ce
price: one mode instead of five flags, headings from a table (R3)
claude Oct 15, 2026
956bb99
rankings: one ratio table and one sorted list for .pe, .fpe and .peg …
claude Oct 15, 2026
93a8c4e
profile: sections joined by blank lines, exchange pages from a table …
claude Oct 15, 2026
e776b1e
Yahoo: one cache helper, raw answers cached everywhere (R9)
claude Oct 15, 2026
8e76b3c
Scheduler: a deadline per command, a lock, and no gevent (M6, D17)
claude Oct 15, 2026
98091c8
bot.py: a bounded body read, and signal work in a thread (M6)
claude Oct 15, 2026
1a0e433
Slack: scheduled reports as the bot, charts and all, with slackChanne…
claude Oct 15, 2026
ec5099b
Slack: ⏳ on a command's message while finbot works on it (M8)
claude Oct 15, 2026
6ebad5b
Slack: period buttons under a chart, redrawn in place (M8, D9)
claude Oct 15, 2026
a94557e
CLAUDE.md: the code map after M6 and M8
claude Oct 15, 2026
bf0e2a6
worker: .compare and .history docstrings no longer say their buttons …
claude Oct 15, 2026
74d26a5
Bugs: Slack refusals, Slack links and escapes, secret paths, silent r…
claude Oct 15, 2026
9ce68fe
record_outputs: Slack's buttons and presses, and scheduled reports th…
claude Oct 15, 2026
1175eb1
The typing notice belongs to the transport: it stops before any answe…
claude Oct 15, 2026
fc4e572
Period buttons: no 3M, so a chart has five buttons at most (Discord's…
claude Oct 15, 2026
80618e5
Commands by their whole name, one argument reader, the local-exchange…
claude Oct 15, 2026
ec8ce44
Rankings: one stock's figure and a ranked list are separate functions…
claude Oct 15, 2026
4be22c4
Reports: blank lines between sections the one way; dead code out
claude Oct 15, 2026
ab743ba
Price report: every move worked out once, then formatted per network
claude Oct 15, 2026
e3cc79a
tests: a golden image for the pie, before the shared chart heading ch…
claude Oct 15, 2026
3b7ca94
Charts: renderers take their spec; titles fit; the line chart's figur…
claude Oct 15, 2026
3f9e55f
Yahoo: an outage is an error, not an unknown symbol; history without …
claude Oct 15, 2026
5d04880
Sharesight: one request function with every check; gains for the repo…
claude Oct 15, 2026
3fac2f0
Markets: one table by market code, TSE back to Toronto, a Yahoo suffi…
claude Oct 15, 2026
b17d728
Config: each preference's default on its Prefs field, read in one loo…
claude Oct 15, 2026
fe74815
Small ones: compact cache files, no chdir at import, tests clean up a…
claude Oct 15, 2026
9d29939
Telegram: messages and charts go through the one Bot API call, which …
claude Oct 15, 2026
be80746
worker: a message and a button press arrive as inbound's Message and …
claude Oct 15, 2026
54e9faa
inbound: reading an update is apart from how it arrives
claude Oct 15, 2026
f3433bc
delivery: deliver() takes chart specs only; the scheduled destination…
claude Oct 15, 2026
ac0550b
buttons: the data a button carries is made and read in one file
claude Oct 15, 2026
1726b4a
Charts with period buttons: the first view is built as a button press…
claude Oct 15, 2026
a31190f
Slack: .jobs buttons in Block Kit; a pressed chart's earlier file is …
claude Oct 15, 2026
501412c
Retire chunk_maxlines: a long report splits by size alone
claude Oct 15, 2026
84eae66
Every line within 120 columns, and a test that keeps it so (D24)
claude Oct 15, 2026
cb0f412
tests: the three largest files split by subject; CLAUDE.md's maps bro…
claude Oct 15, 2026
e5dcdbb
Price moves over a period: the list and one stock's view take the sam…
claude Oct 15, 2026
c1eeee3
Slack: a period pressed within 15 minutes of its upload shows that up…
claude Oct 15, 2026
875a4bd
yahoo_warm.py: the price histories of every stock followed, fetched a…
claude Oct 15, 2026
5d4f964
.jobs: the system jobs listed under System, without a number or a button
claude Oct 15, 2026
c27ed72
Price moves over a period always from Yahoo: performance_use_sharesig…
claude Oct 15, 2026
8bc4fe4
sharesight_warm.py: Sharesight's caches refreshed on a fixed schedule…
claude Oct 15, 2026
d4fbd25
A flush runs both warm jobs straight away
claude Oct 15, 2026
9502187
Merge remote-tracking branch 'origin/main' into claude/kind-newton-ad…
claude Oct 15, 2026
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
3 changes: 2 additions & 1 deletion .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ PYTHON_VERSION="$(tr -d '[:space:]' < .python-version)"
uv python install "$PYTHON_VERSION"

# (re)create .venv only when it is missing or on a different Python
if [ ! -x .venv/bin/python ] || [ "$(.venv/bin/python -c 'import platform; print(platform.python_version())')" != "$PYTHON_VERSION" ]; then
if [ ! -x .venv/bin/python ] ||
[ "$(.venv/bin/python -c 'import platform; print(platform.python_version())')" != "$PYTHON_VERSION" ]; then
rm -rf .venv
uv venv --python "$PYTHON_VERSION" .venv
fi
Expand Down
14 changes: 14 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ jobs:
if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
files=$(git -c core.quotePath=false diff --name-only --no-renames "origin/$BASE_REF...HEAD")
echo "$files"
if [ -n "$files" ] && ! grep -qvE '^(README\.md|CLAUDE\.md|img/[^/]+\.png)$' <<< "$files"; then
docs='^(README\.md|CLAUDE\.md|doc/[^/]+\.md|img/[^/]+\.png)$'
if [ -n "$files" ] && ! grep -qvE "$docs" <<< "$files"; then
docs_only=true
echo "::notice::Only docs changed: install, compile and tests are skipped"
fi
Expand All @@ -48,7 +49,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.
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.*
!.gitignore
!.editorconfig
!.python-version
!.claude
!.github
Expand Down
101 changes: 96 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`). 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.

Expand All @@ -18,7 +18,98 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
- To merge: `main` takes only a branch that is up to date with it and green on both CI jobs. Merge `origin/main` into the work branch (a merge commit, never a rebase), run the tests, push, wait for CI on that head, then merge the PR with a merge commit.

## 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.
- 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:<file>` as a module beside the new one and diff what both produce for the same inputs.
- 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, `doc/*.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 three places: a `Prefs` field with its default (`lib/config.py`; its type says how it is
read), `defaults/preferences.conf`, and the README's preferences table. The tests catch a key missing from the
template or a default that differs, but not 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, `util/record_outputs.py` (or `--against COMMIT`) records what every command, button and scheduled
report posts, for this tree and for HEAD in the same run, and prints the steps that differ (exit status 1 when any
do). A change to how the tests capture output goes in `tests/common.py`'s `REAL_SENDING`, which the tool puts back to
record real requests.

## Code map (open the one file you need)
- `bot.py`: WSGI entry (route, verify, parse, then a thread of `lib/worker.py` per message or button press); SIGHUP
reload and SIGUSR1 cache flush, each in a thread. `lib/inbound.py`: a class per network that reads its updates
(`Telegram`, `Slack`: a `Message`, a `Press`, or a `Response` given straight back), apart from a class per way they
arrive (`TelegramWebhook`, `SlackWebhook`: the path, how it proves itself); `bot.take(network, update)` takes an
update however it arrived.
- `lib/worker.py`: `COMMANDS`, a table of `Command(names, handler, usage, section, buttons, percent, period,
keywords)`, which also makes `.help` and the Telegram command keyboard, kept current in DMs. `read()` finds the
command by its whole name (any other first word that may be a ticker is `.SYMBOL`) and `read_args()` its `Args`: a
percent, a period and a keyword from either end, the words between; a handler raises `Usage` for arguments it cannot
take, and gets a ticker with `ticker(args)`. `Request` (`request.reply(lines, chart, buttons)`); each command gets
the typing notice while it works. `process_request(message)` and `process_callback(press)` take inbound's objects.
- `lib/reports/`: what finbot says. Scheduled (each has `run(dest=None, **kwargs)`, which posts to the chat that asked
or else the default chats, and `job(argv)`, its schedule-line parser): `price performance trades shorts cal rating
milestone reminder podcasts cleanup sharesight_warm yahoo_warm`; 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/delivery.py`: `Report(lines, chart, buttons)`, the destinations (the chat that asked, or the default chats),
`deliver(dest, report)`, the one-line error report. `lib/transports.py`: a class per way of posting (`TelegramBot`,
`SlackBot`, `SlackWebhook`, `DiscordWebhook`): splitting, captions, charts, buttons, edits, and the typing notice
(`start_typing`: after half a second; Slack's ⏳ on the message), which `text()` and `chart()` stop before posting.
`lib/telegram.py`: Bot API calls (`call`, `try_call`).
- `lib/buttons.py`: `Button`, a chart's period buttons (`keyboard`), a `.jobs` command's (`job_button`), and what a
pressed button's data asks for (`parse`); each transport draws them. `lib/charts.py`: a chart with period buttons,
built by its kind's builder (`build`) for the command's first view (`worker.chart_reply`) and for each press.
- `lib/dialects.py`: each network's markup and message sizes. `lib/markets.py`: one table by market code (flag,
currency, Yahoo suffix, Google code; Sharesight's codes, so TSE is Toronto), and `code(name)` for any name an exchange
goes by. `lib/formats.py`: names, units, dates, percents in words. `lib/links.py`: stock links. `lib/render.py`:
every chart, `Chart` specs and `draw()`.
- `lib/yahoo.py lib/sharesight.py lib/shortman.py lib/simplywallst.py`: data sources. Yahoo's and Sharesight's answers
are cached in `DATA_DIR/cache` as sent, not parsed, so a parser fix needs no flush (`cache.cached`).
`yahoo.symbol(typed)` is Yahoo's symbol for a ticker as written, tried on the local exchange (`exchange_suffix`)
when Yahoo does not know it bare: every ticker a chat gives goes through it. `sharesight.portfolios(name=None)` gives
`Portfolio` objects: `holdings()`, `tickers()`, `gain(days)` (the portfolio's), `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). `lib/jobs.py` + `lib/jobrun.py`: the scheduler, its shared state under one lock; each command runs in
a child process, stopped after `JOB_TIMEOUT`.
- `tests/common.py`: `FinbotCase` (offline fixtures). `self.command('.price')` returns what was sent:
`('text', lines)` or `('photo', size, caption)`. `wsgi_environ(path, body, **headers)` is a request for `bot.main`.
`ProviderCase` runs the real parsers on the answers saved under `doc/` (`sample`), as `Resp` and `serve` return
them; `temp_dir(self)` is a directory removed after the test, `bare_environment()` the environment without finbot's
settings.
- Which test file: commands and scheduled reports `test_reports`; failures, bad input, secrets in logs, signals
`test_edges`; webhook requests, Slack events and signatures `test_inbound`; Yahoo `test_yahoo`; Sharesight
`test_sharesight`; posting to each network `test_sending`; registering Telegram's webhook `test_telegram_api`; the
schedule file and job arguments `test_schedule`; the scheduler and manual runs `test_scheduler`; config
`test_config`; charts `test_charts` (refresh goldens with `UPDATE_GOLDEN=1`); markets, formats, links, buttons,
cache and state files `test_util`; data dir `test_datadir`; chunking, git_version, and the docstring, stdout and
line-width 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.
- 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`,
`SLACK`, `DISCORD`.
- A command that cannot give what was asked (no such portfolio, no prices) raises `answer.Answer(*lines)`, which the
chat gets as a plain reply; `yahoo.NoData` is one. Any other exception becomes a one-line ⚠️ error report.
- Every function and method has a docstring, tests included (a test checks): one line where it fits, reworded rather
than spilling a few words onto a second. Tabs everywhere (`.editorconfig`). Every line stays within 120 columns,
tabs counting 4 (an edit quotes whole lines); a test checks all but README.md, CLAUDE.md and `doc/`.
- 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 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).
- A chart renderer in `lib/render.py` takes its `Chart` and is listed in `RENDERERS` by kind; it gets `@_drawn` (the
bot draws in one native thread) and must not call another `@_drawn` function, or that thread waits on itself.
- A new command: a `Command` in `COMMANDS` (its names, and what its arguments may hold), whose usage, section and
buttons make its `.help` lines and keyboard buttons, and the README command list.

## 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 36 KB: grep for the section instead of reading it whole. Each network's setup is in its own guide,
`doc/telegram.md`, `doc/slack.md` and `doc/discord.md`: a change to a network's setup or keys goes there. `doc/yahoo/`
and `doc/sharesight/` hold large API samples that the tests replay: inspect them with python, don't read them.
Loading
Loading