Files
stonks/CLAUDE.md
T
2026-09-05 09:55:24 +08:00

83 lines
8.0 KiB
Markdown

# 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
go build -o stonks ./cmd/stonks # build
go vet ./... # static checks
gofmt -l . # list any unformatted files (should be empty); gofmt -w to fix
go test ./... # no test files exist yet — this is a no-op until some are added
```
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 Makefile or CI config — 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 (Crypto/Equities/Futures/Forex/Watch sections) **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.
**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`, optional)
`archive.Save`, called from `main.go` when `config.Archive.Enabled`, 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`. This is a debugging aid, not part of the pipeline — a save failure is logged and non-fatal, same 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`.