# Stonks — Implementation Plan Daily financial news + market data analysis service. Fetches free-tier market data and news, sends it to Claude for investment analysis, and posts the result to Discord — on a systemd timer. Written in Go. ## Architecture ``` systemd timer (every 5h) --> stonks.service (oneshot) --> cmd/stonks/main.go | --------------------------------------------- | | | internal/fetch internal/analyze internal/output (price + news (Claude: API-key (Discord webhook, fetchers) or subscription more later) mode) ``` ## Directory layout ``` stonks/ ├── go.mod ├── cmd/stonks/main.go # entrypoint: load config, run pipeline ├── internal/ │ ├── config/ │ │ └── config.go # YAML config struct + loader │ ├── fetch/ │ │ ├── fetcher.go # Fetcher interface + registry (name -> constructor) │ │ ├── yfinance.go # prices: crypto/equities/futures via piquette/finance-go (yfinance-equivalent) │ │ ├── finnhub.go # quotes + company news via Finnhub free tier │ │ └── rss.go # general market news via RSS feeds (Reuters, CNBC, CoinDesk, ...) │ ├── analyze/ │ │ ├── analyzer.go # Analyzer interface + registry │ │ ├── api.go # Anthropic API-key mode (anthropic-sdk-go) │ │ └── subscription.go # Claude Code CLI mode (`claude -p`, subscription-billed) │ └── output/ │ ├── output.go # Output interface + registry │ └── discord.go # Discord webhook ├── config/ │ ├── config.yaml # targets, fetchers, analyzer, outputs │ └── secrets.env # ANTHROPIC_API_KEY / FINNHUB_API_KEY / DISCORD_WEBHOOK_URL (mode 600, not in git) └── systemd/ ├── stonks.service # user-scope unit (~/.config/systemd/user/) └── stonks.timer # user-scope unit (~/.config/systemd/user/) ``` ## Modularity in Go (compile-time registry, not runtime plugins) Go has no good runtime plugin-loading story (the stdlib `plugin` package is Linux-only, fragile, and requires matching toolchains — not worth it for this project). Instead: - Each fetcher/analyzer/output implements a small interface and self-registers via `init()`: ```go func init() { fetch.Register("yfinance", NewYFinanceFetcher) } ``` - `main.go` blank-imports every package so `init()` runs. - `config.yaml` lists which *registered* names to activate, with params. - **Adding a new source = write a new file implementing the interface, register it, add a blank import, rebuild the binary.** This satisfies "modularized, more sources can be specified" for a compiled language — it trades Python's no-rebuild dynamic loading for compile-time safety and simplicity. Flagging this trade-off explicitly since it's a real difference from a scripting-language design. ## Config example ```yaml targets: crypto: [BTC-USD, ETH-USD, SOL-USD, ZEC-USD] equities: [AAPL, GOOGL, TSLA, AVGO, NVDA, INTC, AMD, AMZN] futures: {gold: GC=F, silver: SI=F, crude_oil: CL=F} fetchers: - name: yfinance - name: finnhub params: api_key_env_var: FINNHUB_API_KEY - name: rss params: feeds: - https://feeds.reuters.com/reuters/businessNews - https://www.coindesk.com/arc/outboundfeeds/rss/ analyzer: mode: subscription # "subscription" (Claude Code CLI, uses Pro/Max plan) or "api" (billed per-token) model: claude-opus-5 # only used in api mode effort: medium # only used in api mode outputs: - name: discord params: {webhook_env_var: DISCORD_WEBHOOK_URL} ``` ## Data sources (free tier) | Category | Source | Notes | |---|---|---| | Prices — crypto/equities/futures | **yfinance-equivalent**: `github.com/piquette/finance-go`, a Go port of the same unofficial Yahoo Finance chart API that Python's `yfinance` scrapes | Free, no key. Covers BTC-USD/ETH-USD/SOL-USD/ZEC-USD, all 8 equities, and futures (`GC=F`/`SI=F`/`CL=F`) through one client. | | Quotes + company news | **Finnhub** free tier (`github.com/Finnhub-Stock-API/finnhub-go`, or a thin hand-rolled HTTP client — the API surface is small) | Free tier requires an API key (`FINNHUB_API_KEY`) but is generous (60 calls/min); used here as a second source for equity quotes and per-ticker company news headlines. Good redundancy against the yfinance-equivalent fetcher. | | General market news | RSS feeds (Reuters Business, CNBC, CoinDesk) | Free, no key. Use `gofeed` (`github.com/mmcdole/gofeed`) for parsing. | **Risk:** the yfinance-equivalent path hits an undocumented/unofficial Yahoo endpoint and can change without notice; Finnhub is an official, documented API and is the more stable of the two price sources. Mitigate by wrapping each fetcher call in error handling that logs and skips that source/asset class rather than failing the whole run — a degraded report beats a missed one, and having both yfinance-equivalent and Finnhub means one breaking doesn't blank out prices entirely. ## Claude integration — two modes ### `subscription` mode (recommended default — $0 marginal cost) - Requires the `claude` CLI installed and logged in on the host (`claude login`, authenticated against your Claude Pro/Max subscription). - The analyzer shells out via `os/exec`: `claude -p "" --output-format text`. - Usage draws from your subscription's rate limits/quotas rather than separate API billing. For one call/day this is a rounding error against those limits. - **Caveats:** ties the automated job to a personal OAuth login session on that machine rather than a stable API key — the login can eventually need manual re-auth, and running unattended automation against a personal-subscription login should be something you're comfortable with operationally (it's officially supported for scripting via `-p`/headless mode, but it's still your personal account driving it). ### `api` mode (fallback / no subscription available) - Uses `ANTHROPIC_API_KEY` via the official `anthropic-sdk-go` client (`github.com/anthropics/anthropic-sdk-go`), billed per token. - Model defaults to `claude-opus-5`; `claude-sonnet-5` or `claude-haiku-4-5` are configurable if you want to cut cost further (see Cost below — at this volume it barely matters). - One request/day: system prompt (analysis format/rules) + user turn with the fetched price/news JSON. `effort: medium` is sufficient — no agentic tool use needed. Both modes implement the same `Analyzer` interface, selected by `analyzer.mode` in config. ### Multi-agent mode (optional advisors) `analyzer.advisors` (a list of `{name, command, args}`) names additional one-shot CLIs — e.g. `gemini`, `codex exec -` — that receive the *same* fetched-data prompt (piped via stdin, `internal/analyze/advisor.go`) and produce an independent take. Advisors run concurrently; the ones that succeed are appended to Claude's final prompt as an "Other AI Analysts' Independent Takes" section, and `SystemPrompt` instructs Claude to weigh them (agree/disagree explicitly) rather than defer to them — Claude still writes the one final report and recommendations block. This is deliberately **not** a fourth `Analyzer` implementation — advisors don't produce the final report shape (no recommendations-JSON contract), they're inputs to it. A failing or unauthenticated advisor CLI is logged as a warning and excluded from the prompt, same graceful-degradation pattern as the fetchers; it never fails the run. Each advisor CLI needs its own separate login (`gemini`, `codex login`) — independent of the `claude`/Anthropic auth used for the final analysis. ## systemd integration — user scope (`systemctl --user`) Units live under `~/.config/systemd/user/` and are managed with `systemctl --user`, not root-owned `/etc/systemd/system/`. This fits naturally with `subscription` mode, since `claude login`'s session lives under the same user's `$HOME` anyway — no `User=`/permissions mismatch to manage. ```ini # ~/.config/systemd/user/stonks.service [Unit] Description=Daily financial news & market analysis After=network-online.target [Service] Type=oneshot EnvironmentFile=%h/.config/stonks/secrets.env ExecStart=%h/.local/bin/stonks --config %h/.config/stonks/config.yaml ``` ```ini # ~/.config/systemd/user/stonks.timer [Unit] Description=Run stonks every 5 hours [Timer] # Fires at 00:00, 05:00, 10:00, 15:00, 20:00 (wall-clock aligned). OnCalendar=*-*-* 0/5:00:00 Persistent=true [Install] WantedBy=timers.target ``` `%h` expands to the invoking user's home directory. Enable with: ```sh systemctl --user enable --now stonks.timer ``` **User-scope services normally stop when the user logs out.** Since this needs to fire every 5 hours unattended (including when nobody's logged in), enable lingering once: ```sh loginctl enable-linger "$USER" ``` This keeps the user's systemd instance (and anything scheduled under it) running after logout and across reboots, without needing root privileges for the service itself. `Persistent=true` catches up a missed run if the machine was off/asleep at trigger time. Secrets (`ANTHROPIC_API_KEY` if using `api` mode, `FINNHUB_API_KEY`, `DISCORD_WEBHOOK_URL`) live in `~/.config/stonks/secrets.env` (mode 600), never in `config.yaml` or git. `subscription` mode needs no secret in this file — it uses the same user's existing `claude login` session under `~/.config`. ## Feasibility Straightforward, no blockers: - yfinance-equivalent, Finnhub, and RSS are all simple HTTP calls — Finnhub is documented and key-based, the other two need no auth. - A single non-agentic Claude call (or CLI invocation) — no tool use, no streaming complexity needed at this size. - Discord webhook is a plain `POST` with a JSON body. - systemd user-scope timer + oneshot service + `loginctl enable-linger` is a standard, documented pattern for unattended per-user automation. Main risks are the usual "free/unofficial data source" risks (the yfinance-equivalent path can break if Yahoo changes their endpoint; RSS feed URLs occasionally get retired) — both mitigated by graceful per-fetcher degradation rather than hard failure, and by having Finnhub as a documented, more stable second price source. Estimated build time: a working v1 (one fetcher, one output, hardcoded targets) in a few hours; the full config-driven/registry version with graceful degradation, a bit more. ## Cost The timer now fires every 5 hours (`OnCalendar=*-*-* 0/5:00:00`) — 5 runs/day instead of 1. **`subscription` mode: still $0 marginal cost**, covered by your existing Claude Pro/Max subscription. The one thing worth watching at 5x the frequency: subscription plans have rolling usage windows (typically ~5 hours), so if this job's prompt is large or other subscription usage is heavy at the same time, it's worth an occasional `journalctl --user -u stonks.service` check for rate-limit errors — not a cost concern, just a headroom one. **`api` mode**, for ~5-15k input / ~1-2k output tokens per run (no caching benefit between runs — each is a cold prompt), now at 5 runs/day: | Model | Input $/MTok | Output $/MTok | Daily (worst case: 5 × 15k in / 2k out) | Daily (typical: 5 × 8k in / 1.5k out) | Monthly (typical) | |---|---|---|---|---|---| | Claude Opus 5 | $5.00 | $25.00 | $0.625 | $0.39 | ~$11.70 | | Claude Sonnet 5 (intro, through 2026-08-31) | $2.00 | $10.00 | $0.25 | $0.155 | ~$4.65 | | Claude Sonnet 5 (standard) | $3.00 | $15.00 | $0.375 | $0.235 | ~$7.05 | | Claude Haiku 4.5 | $1.00 | $5.00 | $0.125 | $0.08 | ~$2.40 | Still cheap in absolute terms, but ~5x the daily-cadence estimate — worth knowing if you're on `api` mode and cost-sensitive. `subscription` mode sidesteps this entirely since it isn't metered per token. ## Open questions / next steps - Confirm which analyzer mode to build first (recommend: `subscription` as default, `api` as a fallback path — both are cheap to implement since they share the `Analyzer` interface). - Confirm `claude login` will run interactively once during setup (subscription mode), and get a free Finnhub API key (finnhub.io — instant signup) for `FINNHUB_API_KEY`. - Confirm `loginctl enable-linger` is acceptable on the target host (needed for the user-scope timer to fire while logged out). - Once confirmed, implementation order: `internal/fetch` (yfinance-equivalent + finnhub + rss) → `internal/analyze` (both modes) → `internal/output` (discord) → wire up `cmd/stonks/main.go` + config loader → user-scope systemd units.