# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is A Go service that fetches market data (crypto/equities/futures/forex) and news, sends it to Claude for a daily investment-advice briefing, and posts the result to Discord. Runs as a systemd **user-scope** timer (`~/.config/systemd/user/`), currently every 5 hours. See `PLAN.md` for the full design rationale, cost analysis, and history of decisions (why Go, why user-scope systemd, why two Claude modes). ## Commands ```sh make build # go build -o stonks ./cmd/stonks make install # build, then install to ~/.local/bin/stonks (matches systemd/stonks.service's ExecStart) make vet # go vet ./... make fmt # gofmt -l . — list any unformatted files (should be empty); gofmt -w to fix make test # go test ./... — no test files exist yet, this is a no-op until some are added make clean # remove the built binary ``` Run locally (needs secrets loaded into the environment first — see `config/secrets.env.example`): ```sh set -a; source config/secrets.env; set +a go run ./cmd/stonks --config config/config.yaml --timeout 120s ``` There is no CI config — the Makefile plus the commands above are the whole toolchain. ## Architecture Pipeline (`cmd/stonks/main.go`): **fetch → analyze → split → output**, all driven by `config/config.yaml`. ``` config.Load() → runFetchers() → analyze.Build().Analyze() → analyze.SplitReport() → runOutputs() (internal/fetch) (internal/analyze) (internal/analyze) (internal/output) ``` ### Compile-time plugin registry (not runtime plugins) `internal/fetch`, `internal/analyze`, and `internal/output` each define an interface (`Fetcher`/`Analyzer`/`Output`) plus a `Register(name, constructor)` / `Build(cfg)` registry. Every concrete implementation self-registers via `init()`: ```go func init() { Register("yfinance", newYFinanceFetcher) } ``` `config.yaml`'s `fetchers:`/`outputs:` lists and `analyzer.mode` just reference these names; `cmd/stonks/main.go` imports every implementation package so the `init()`s run. **Adding a new fetcher/output/analyzer mode means writing a new file that self-registers, adding it to config, and rebuilding the binary** — there's no dynamic loading. This is a deliberate tradeoff (see PLAN.md "Modularity in Go"), not an oversight — don't try to add reflection-based plugin loading. Each `config.PluginConfig` carries a `Name` plus a raw `Params map[string]any`; plugins decode their own params shape via `cfg.DecodeParams(&myParams)` (round-trips through YAML marshal/unmarshal) rather than the top-level config knowing every plugin's shape. ### Fetchers (`internal/fetch`) Three registered under `yfinance`, `finnhub`, `rss`. They all return a `fetch.Result{Quotes, News}` and a possibly-non-nil error — **errors are non-fatal by design**: `runFetchers` in `main.go` merges partial results and only logs a warning, because a degraded report (some sources missing) beats no report. Only "zero data from every source" aborts the run. - `yfinance.go` calls Yahoo Finance's `/v8/finance/chart/{symbol}` endpoint directly via `net/http` (concurrent, one goroutine per symbol) — **not** a Yahoo client library. A prior version used `github.com/piquette/finance-go`, which calls Yahoo's older `/v6/finance/quote` batch endpoint; Yahoo has since locked that behind auth (401/404). If Yahoo requests start failing, check whether it's this same kind of endpoint deprecation before reaching for a library. Requires a browser-like `User-Agent` header or Yahoo returns 429. Covers all four asset classes (crypto, equities, futures, forex) — forex targets are plain codes like `USDTWD` in config, and this fetcher appends `=X` only for the outbound Yahoo query, keeping the plain code as `Quote.Symbol` everywhere else. - `finnhub.go` covers **equities only** (free tier doesn't cover crypto/futures/forex the same way) — quotes + per-ticker company news, requires `FINNHUB_API_KEY`. - `rss.go` general market news via configured feed URLs (`gofeed`). Feed URLs occasionally get retired outright (e.g. `feeds.reuters.com` no longer resolves) — a dead feed is a config/DNS problem, not a fetcher bug. ### Analyze (`internal/analyze`) Two interchangeable modes behind one `Analyzer` interface, selected by `analyzer.mode`: - `api.go`: calls the Messages API directly via `anthropic-sdk-go`, billed per token. - `subscription.go`: shells out to the `claude` CLI (`claude -p`, reading the prompt from stdin), billed against the host's logged-in Claude Pro/Max subscription instead of metered API usage. Requires `claude login` to have been run once on the deploying host/user. `prompt.go` builds the user-turn text from `fetch.Result` and holds `SystemPrompt`, which is the single source of truth for the report's structure (fixed Summary/Crypto/Equities/Futures/Forex/Watch section headers, in that order, every run) **and** instructs Claude to append a `===RECOMMENDATIONS_JSON===` marker followed by a JSON buy/sell list as the last thing in its response. `split.go`'s `SplitReport` parses that trailing block into `report.Report{Text, Recommendations}` via `extractJSONObject` (grabs from the first `{` to the last `}` after the marker — tolerant of stray code fences/prose Claude sometimes adds despite being asked not to, observed in practice with multi-agent mode on). If the marker's missing or the JSON still fails to parse, it degrades to a text-only report (logs a warning in `main.go`) rather than failing the run. Keep the marker instruction and the parser in sync if you touch either. Because the timer fires every 5 hours, `main.go` calls `archive.LoadLatest` before building the prompt to read back the previous run's `report.md` and passes it into `BuildUserPrompt` as `previousReport`. `SystemPrompt` instructs Claude to treat that as a "Previous Briefing" section: dedup commentary against it (fresh prices are never skipped, but unchanged analysis should collapse to a one-liner), and only carry forward previously-mentioned info if it's still genuinely important (new regulation, unresolved macro event). This is why archiving is no longer optional — see below. **Multi-agent mode** (`advisor.go`, optional via `analyzer.advisors` in config): the same data prompt is also piped to other one-shot CLIs (`gemini`, `codex exec -`, ...) concurrently. This is deliberately **not** a fourth `Analyzer` — advisors don't produce the final report shape, they're extra input to it. `main.go` runs them after building the base prompt, logs failures as warnings (same non-fatal pattern as fetchers — a failing/unauthenticated advisor is just excluded), then `AppendAdvisorSection` appends the successful ones to the prompt before the real `analyzer.Analyze` call. `SystemPrompt` tells Claude to treat that section as opinions to weigh, not ground truth to defer to. ### Report / Output (`internal/output`, `internal/report`) `internal/report` is a tiny shared-type package (`Report`, `Recommendation`) that exists purely so `analyze` and `output` don't import each other. `discord.go` sends the narrative text as one or more chunked messages (Discord's 2000-char `content` limit), then **always** sends a second message with two separate embeds — green "🟢 Buy" and red "🔴 Sell" — even when a list is empty (states "No buy/sell recommendations today." rather than omitting the embed). If you change the recommendation rendering, keep the two embeds separate; that was an explicit choice, not a simplification opportunity. ### Archiving (`internal/archive`, always on) `archive.Save`, called unconditionally from `main.go`, writes one timestamped subdirectory per run under `archive.dir` (default `~/.local/state/stonks/runs`) containing `fetched.json`, `prompt.txt` (the *final* prompt, post-advisor-section), `analysis_raw.md` (pre-`SplitReport`), `report.md`, `recommendations.json`. It's still a debugging aid, but it's also load-bearing now: `archive.LoadLatest` reads the most recent run's `report.md` back at the start of the next run so the prompt can dedup against it (see `prompt.go` above). A save failure is logged and non-fatal — it just means the *next* run won't have anything to dedup against, same non-fatal pattern as everything else here. ## Config and secrets `config/config.yaml` is checked in; `config/secrets.env` (gitignored, mode 600) holds `ANTHROPIC_API_KEY` (only used in `api` mode), `FINNHUB_API_KEY`, `DISCORD_WEBHOOK_URL`. Never read or print `config/secrets.env` contents when debugging — check populated-ness with something like `awk -F= '{print $1, (length($2)>0 ? "SET" : "EMPTY")}'` instead of `cat`. ## Deployment `systemd/stonks.service` + `stonks.timer` are **user-scope** units (`%h` paths, installed to `~/.config/systemd/user/`, managed via `systemctl --user`), not root-owned system units. This pairs naturally with `subscription` mode since `claude login`'s session lives under the same user's `$HOME`. User-scope services normally die at logout — `loginctl enable-linger "$USER"` is required for unattended firing, and is called out in `README.md`.