83 lines
8.0 KiB
Markdown
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`.
|