Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e1725e0
.pe takes trailing (or current) for the trailing P/E
claude Oct 19, 2026
7cbde4f
/ratio replaces /pe and /peg: the ratio, then its end, as optional words
claude Oct 19, 2026
eea3e1c
doc/classes.md: every class, by what it is for
claude Oct 19, 2026
e610a5c
doc/slack.md: say why both manifests must not be installed
claude Oct 19, 2026
0a9cf17
Slack manifest leaves out /help, which Slack keeps for itself
claude Oct 19, 2026
d2e9d1b
slack_manifest.py --only NAMES: a manifest with just those slash comm…
claude Oct 19, 2026
4665649
Slack manifest leaves out /who, which Slack keeps for itself; /help i…
claude Oct 19, 2026
a167756
doc/slack.md: the manifest's mode comes from --url, not from finbot.env
claude Oct 19, 2026
030a9a1
Slack: say why a press uploads its chart, and word the not-ready-yet …
claude Oct 19, 2026
5905a37
A bar chart of one bar is skipped: the report goes as text
claude Oct 19, 2026
fb1cac0
jobs > Edit: Disable all button; /jobs top level has no page buttons
claude Oct 20, 2026
5a52ad0
jobs > Edit: Cancel button
claude Oct 20, 2026
ef25037
schedule log line after a change names the chat (network and id)
claude Oct 20, 2026
8c99820
jobs > Edit: Select all / Select none, Delete and OK only with a sele…
claude Oct 20, 2026
36a62de
jobs > Edit selects jobs and changes nothing until OK, which shows on…
claude Oct 20, 2026
668921a
jobs > Edit: OK and Delete change a chat's jobs in one change, so the…
claude Oct 20, 2026
05dce2c
Startup logs the enabled networks and how each reaches finbot (push o…
claude Oct 20, 2026
30a07eb
Discord: a refused interaction's token stays out of the log; the sign…
claude Oct 20, 2026
8603f6e
Discord: log the endpoint check that passes; a refused update is one …
claude Oct 20, 2026
85920e4
Keyboard: .pe and .jobs buttons, sorted; /peg is a slash command of i…
claude Oct 20, 2026
d85ad4e
No /peg slash command: .peg and /peg are typed names of /ratio peg, a…
claude Oct 20, 2026
eec42a3
Discord: an interaction's webhook answering Unknown Webhook (10015) i…
claude Oct 21, 2026
45f36ab
Treemap grouping menu: Done, as its last button
claude Oct 21, 2026
b759534
Treemap grouping menu: the last button reads OK
claude Oct 21, 2026
24742c3
CLAUDE.md: drop the pull-request rules, so Claude Code's own PR and C…
claude Oct 21, 2026
c067548
Merge branch 'main' into claude/kind-newton-ad0z2q
robdevops Oct 21, 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
18 changes: 6 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,14 @@
## 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 -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).
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.
3. Reply briefly: what changed, test result, and the pushed commit's short hash.

## The work branch is the dev branch
The work branch named in the session instructions (`claude/...`) is the persistent dev branch. The owner's test environment pulls it and restarts the service, so:
- **Never rewrite the work branch's history, and never reset the branch after a merge**: no reset to `main`, no rebase, no amend of pushed commits, no force-push. A force-push breaks `git pull` on the test environment. After a merge, leave the branch as it is.
- If the owner wants a fixed name such as `dev`, they will say so; only push to another branch with their explicit permission.

## Pull requests from the work branch
An open PR takes in every commit pushed to its branch, so a PR opened for one small change can merge far more under its old title (#35 was titled as a CI tweak and merged 14 review fixes). The merge commit on `main` keeps that title for good.
- Before each push, check for an open PR from the work branch. If there is one, update its title and description so they cover every commit it now holds, not only the change it was opened for.
- Never make such a PR mergeable (resolve its conflict, merge `main` into it, enable auto-merge) while its title or description is out of date.
- To merge: `main` takes only a branch that is up to date with it and green on every CI job. 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, and
its `docker` job builds the image (`Dockerfile`, Debian's packages), imports every module in it, draws a chart in
Expand Down Expand Up @@ -84,8 +78,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
Discord's registered commands); `suggest(chat, text)` completes a slash command's arguments as they are typed
(Discord's autocomplete), from the cache alone, offering what a command `takes` (tickers or portfolios). `read()`
finds the command by its whole name, the longest that matches (any other first word that may be a ticker is
`.SYMBOL`, that word its one word), and `read_args()` its `Args`: a percent, a period and a keyword from either end,
the words between. `answered()` runs a message's command and a job's alike: more or fewer words than its `words`
`.SYMBOL`, that word its one word), and `read_args()` its `Args`: a percent, a period, a keyword and an option (`.ratio`'s
ratio, `.pe` and `.peg` its names) from either end, the words between. `answered()` runs a message's command and a job's alike: more or fewer words than its `words`
(`Command.fits`), or a handler's `Usage`, get its usage; an Answer is the reply (Nothing never in a job's run); a
handler gets a ticker with `ticker(args)`. `Request(dest, user, bot_name)` (`request.reply(lines, chart,
buttons)`); each command gets the typing notice while it works. `process_request(message)` answers a message
Expand All @@ -105,8 +99,8 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
(`delivery.interactive(dest)`); a job's run, on schedule, Run now or `.jobs run`, posts nothing. Chat-only
(`prepare_*`): `about` (.help, and the .jobs menu's screens:
`jobs_screen`, `add_screen`, `next_screen`), `history` (.price, also called .compare and .history: one stock's line, or
two to eight rebased), `holdings` (.holdings .who .treemap .watchlist), `profile` (.SYMBOL), `rankings` (.marketcap .pe
.fpe .peg .beta .buy .sell).
two to eight rebased), `holdings` (.holdings .who .treemap .watchlist), `profile` (.SYMBOL), `rankings` (.marketcap .ratio
.beta .buy .sell).
- `lib/delivery.py`: `Report(lines, chart, buttons)`, the destinations (the chat that asked, or a chat by its key with
`chat(key)`, as its network's bot), `deliver(dest, report)` (a chart refused goes again with its heading alone as the
caption, the lines as text after it), the one-line error report (to its chat; one that belongs to no chat only
Expand Down Expand Up @@ -262,5 +256,5 @@ An open PR takes in every commit pushed to its branch, so a PR opened for one sm
- 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 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
`telegram.md`, `slack.md` and `discord.md`: a change to a network's setup or keys goes in its guide. `classes.md` lists every class by what it is for (keep it current when one is added, renamed or removed). `doc/yahoo/` and
`doc/sharesight/` hold large API samples that the tests replay: inspect them with python, don't read them.
13 changes: 11 additions & 2 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ def listeners():
app-level token): finbot fetches the updates of those networks itself."""
return [kind() for kind in LISTENERS if kind.wanted()]

def networks_line():
"""'networks: discord (push), slack (pull)': the networks finbot.env enables, by name, each with how its updates
reach finbot: push (it posts them to a webhook URL) or pull (finbot fetches them); 'networks: none' for none."""
how = sorted((hook.service, 'push') if hook.path() else (hook.service, 'pull')
for hook, kind in zip(WEBHOOKS, LISTENERS) if getattr(config.env, kind.TOKENS[0]) and (hook.path() or
kind.wanted()))
return 'networks: ' + (', '.join(f'{service} ({way})' for service, way in how) or 'none')

def doubled(message):
"""Whether this message repeats one that the same person sent in the same chat in the last DOUBLED seconds:
Telegram's mobile app can send a command twice (/start in a new chat), each its own update, and one answer is
Expand Down Expand Up @@ -123,8 +131,8 @@ def print_headers(say=log.info):
return respond(inbound.BAD_REQUEST)
if why := source.refuse(environ, body, update):
shown = {k: '<redacted>' if k in source.SECRET_FIELDS else v for k, v in update.items()}
log.warn("warning:", why)
log.warn(f"{source.service.capitalize()} refused update", json.dumps(shown, indent=4, default=str))
log.warn(f"{source.service.capitalize()} refused update: {why}")
log.debug(f"{source.service.capitalize()} refused update", json.dumps(shown, indent=4, default=str))
return respond(source.REFUSED)
environ[AUTHENTICATED] = True
return respond(take(source.network, update))
Expand Down Expand Up @@ -271,6 +279,7 @@ def http_server():
except config.ConfigError as e:
config.print_errors(e)
sys.exit(1)
log.info(networks_line())
for line in config.startup_report():
log.info('config:', line)
for line in config.startup_warnings():
Expand Down
54 changes: 54 additions & 0 deletions doc/classes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Classes

Every class in `bot.py` and `lib/`, by what it is for. A class named in two places (`Telegram`, `Slack`, `Discord`) is
a network's update reader in its `*_common.py` and a markup dialect in `lib/dialects.py`.

## Networks: updates in, messages out
| Network | Reader of its updates | Bot that posts | Webhook (push) | Pull client |
|---|---|---|---|---|
| Telegram | `Telegram` | `TelegramBot` | `TelegramWebhook` | `TelegramPoll` (long polling) |
| Slack | `Slack` | `SlackBot` | `SlackWebhook` | `SlackSocket` (Socket Mode) |
| Discord | `Discord` | `DiscordBot` | `DiscordInteractions` | `DiscordGateway` |

- **Bases:** `inbound.Network` (a reader), `inbound.Webhook`, `transports.Transport` (a bot), `pull.Listener` and
`pull.SocketListener` (a pull client; `pull.WebSocket` is its connection, `pull.Closed` raised when the server closes it).
- **What a reader hands on** (`lib/inbound.py`): `Message`, `Press` (a button pressed), `Suggest` (a slash command's
arguments as typed) and `Response` (what the network gets back at once); `Recent` remembers what was taken lately, to
drop repeats.
- **`bot.py`:** `WholeLines` writes each log line whole, whichever threads log at once; `Sent` is a response body that
starts some work once the server has sent it.

## Markup and delivery
- `Dialect` (a `str` of the network's name) with `Telegram`, `Slack` and `Discord`: its markup, link syntax and limits.
- `delivery.Report` (lines, a chart spec, buttons) and `delivery.Preloader` (charts drawn ahead of presses).

## Commands and buttons
- `worker.Command` (a row of the command table), `CommandJob` (a command run as a job), `Args` (what a command was
given), `Request` (a command being answered), `Slash` (a slash command to register) and `Usage` (raised for arguments a
command cannot take).
- `buttons.Button`; `ChartPress`, `MenuPress` and `PickPress` (a press, read from its data); `Act` (what a picker's
press asks for).

## Scheduling
- `jobs.Scheduler` (runs the jobs) and `Job`; `catalog.Item` (a default job); `when.Schedule` (when a job runs).

## Reports and charts
- `render.Chart` (a chart spec, drawn later); `render._Canvas` and `_Axes` (what a chart is drawn on).
- Data a report works from: `movers.Moves`, `trades.Trade`, `yahoo.Line` (a price line) and `markets.Market`.

## Data sources and configuration
- `sharesight.Portfolio`.
- `config.Env` (finbot.env, fixed at start), `Settings` (settings.conf, new on each reload) and the private `_Reader`
that reads their typed fields.

## Errors
- **Replies, not failures:** `answer.Answer` (lines to reply with in place of the report) and `answer.Nothing` (nothing
to show: said only to a command or a button, never in a job's run). Both are also raised by:
- `sharesight.NotSetUp` and `PortfolioNotFound`;
- `yahoo.NoData`.
- **Failures:**
- `net.RateLimited` (a 429 and the wait it asks for);
- `transports.MaybeSent` (a chart's post timed out: it may have arrived);
- `sharesight.Refused` (credentials refused: the token is renewed);
- `when.ScheduleError` (a schedule or job that cannot be);
- `config.ConfigError` (problems in finbot.env or settings.conf, each naming its file and line).
14 changes: 6 additions & 8 deletions doc/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,9 @@ Where:
/jobs add reminder schedule "text"
/marketcap [symbol|top|bottom]
/movers [percent|top|bottom|session|premarket] [period]
/pe [symbol|top|bottom|negative]
/peg [symbol|top|bottom|negative]
/performance [period] [portfolio]
/price [symbol...] [period]
/ratio [symbol] [forward|current|peg] [top|bottom|negative|reverse]
/shorts [percent|symbol]
/trades [period] [portfolio]
/treemap [portfolio] [sector|industry|country|currency|market]
Expand All @@ -66,12 +65,12 @@ The bot answers commands in every chat it is in. By default, it fetches its upda
* A watch list per chat, which anyone in it can change, picked up by the reports there
* Listing of portfolios and portfolio performance
* A treemap of a portfolio (`/treemap portfolio [sector|industry|country|currency|market]`): the treemap tiles each holding by value, grouped and coloured by industry (the default), sector, country (its market's, as two letters, with a flag in the caption), currency or market
* Buttons to pick from where a command takes a portfolio or tickers and is typed without them: `/treemap`, `/holdings` (the first portfolio alphabetically; a treemap by industry, or by the grouping typed) and `/price` (the first ticker, over 7D unless a period or `max` is typed) answer at once, with the buttons under the chart: portfolios four to a row and `/treemap`'s Grouped by button after them, which opens a menu of Country, Currency, Industry, Market and Sector with Back (the one shown marked; a choice redoes the chart in place and keeps the menu open, Back returns to the portfolios); for tickers the period row, ending in `+` to the tickers (those held, else the chat's watchlist) with `◀` `▶` pages: up to 8 ticked (drawn in the network's selection colour), which OK draws (a ninth is refused with a message to the presser alone, the chart left as it is). With one ticker ticked, `+` clears it, so that the first tick picks afresh. Another portfolio or ticker, a grouping or a period redoes the chart in place (a tick or a page of tickers changes only the buttons, on Telegram and Discord), and a `/treemap` draws its other charts in the background so the next press is quick. `/price T1 T2 [period]` typed with tickers gets the same period row and `+` when the tickers are among those held (else watched) and the period is a button one. Every chart with a period row, from any command, draws its other periods in the background once it is sent, so a period press is quick. Thirty-two items make a page (fewer on Discord, which holds five rows of five buttons). A job of one posts the same, buttons and all (but draws no charts ahead)
* Buttons to pick from where a command takes a portfolio or tickers and is typed without them: `/treemap`, `/holdings` (the first portfolio alphabetically; a treemap by industry, or by the grouping typed) and `/price` (the first ticker, over 7D unless a period or `max` is typed) answer at once, with the buttons under the chart: portfolios four to a row and `/treemap`'s Grouped by button after them, which opens a menu of Country, Currency, Industry, Market and Sector with OK last (the one shown marked; a choice redoes the chart in place and keeps the menu open, OK returns to the portfolios); for tickers the period row, ending in `+` to the tickers (those held, else the chat's watchlist) with `◀` `▶` pages: up to 8 ticked (drawn in the network's selection colour), which OK draws (a ninth is refused with a message to the presser alone, the chart left as it is). With one ticker ticked, `+` clears it, so that the first tick picks afresh. Another portfolio or ticker, a grouping or a period redoes the chart in place (a tick or a page of tickers changes only the buttons, on Telegram and Discord), and a `/treemap` draws its other charts in the background so the next press is quick. `/price T1 T2 [period]` typed with tickers gets the same period row and `+` when the tickers are among those held (else watched) and the period is a button one. Every chart with a period row, from any command, draws its other periods in the background once it is sent, so a period press is quick. Thirty-two items make a page (fewer on Discord, which holds five rows of five buttons). A job of one posts the same, buttons and all (but draws no charts ahead)
* Listing of holdings sorted by various attributes
* Running the other stock reports on demand

## Reports
Every chart's subtitle ends with the date it shows, or the dates it spans (`12 Oct - 19 Oct 2026`).
Every chart's subtitle ends with the date it shows, or the dates it spans (`12 Oct - 19 Oct 2026`). A bar chart of a single bar is not drawn: the report goes as its text, with its buttons.

### Stock lookup
![Screenshot showing stock info on Slack](../img/stockinfo.png?raw=true "Screenshot showing stock info on Slack")
Expand Down Expand Up @@ -257,15 +256,14 @@ Command:
/shorts [percent]
```

### pe / peg
### ratio (pe, peg)
![Forward PE report in Slack](../img/finbot_fpe.png?raw=true "Forward PE report in Slack")

List securities by forward P/E ratio (`/pe`), or PEG ratio (`/peg`). Both are analysts' estimates (a forward P/E of earnings, a PEG of their growth), so their lists take only stocks covered by at least `min_analysts` analysts (7 by default; see `settings.conf`), as `/buy` and `/sell` do.
List the stocks followed by a ratio: `/ratio` is the forward P/E, lowest first; `/ratio current` the trailing P/E (`current` or `trailing`), of the earnings reported; `/ratio peg` the PEG. Then, optionally, the end: `top` (the default), `bottom` or `reverse` for the highest first, or `negative`, in either order: `/ratio peg reverse`, `/ratio current negative`. A ticker (`/ratio T1`) gets its own figures. `/pe` and `/peg` still work when typed, as `/ratio` and `/ratio peg` (only `/ratio` is in the slash menu); Telegram's keyboard has `.pe`. A forward P/E and a PEG are analysts' estimates (of earnings, of their growth), so their lists take only stocks covered by at least `min_analysts` analysts (7 by default; see `settings.conf`), as `/buy` and `/sell` do; a trailing P/E is no estimate, so every stock counts.

Command:
```
/pe [SYMBOL|top|bottom|negative]
/peg [SYMBOL|top|bottom|negative]
/ratio [SYMBOL] [forward|current|peg] [top|bottom|negative|reverse]
```

### Price
Expand Down
2 changes: 1 addition & 1 deletion doc/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ The administrators of the chat a person is in are admins there with no setting:
| `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`, `/pe` and `/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 |
| `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 |
| `demote_leveraged` | `True` | Leveraged (2x, 3x) funds need a bigger move to alert |
| `volatility_multiplier` | `2` | How much bigger a move volatile stocks need |
Expand Down
2 changes: 1 addition & 1 deletion doc/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ discordPublicKey=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde
## Slash commands

finbot registers a slash command for each line of `/help`, as it starts, and again only when the list has changed:
`/price`, `/dividend` and `/earnings`, `/buy` and `/sell`, `/pe`, `/stock`, `/jobs`,
`/price`, `/dividend` and `/earnings`, `/buy` and `/sell`, `/ratio`, `/stock`, `/jobs`,
and the rest. Each takes its arguments, if any, as one text option, as a message would: `/movers` with `5 7d` is
the message `.movers 5 7d`. They work in the server's channels and in a direct message with the bot.

Expand Down
Loading