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

8.0 KiB

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

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):

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():

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.