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

13 KiB
Raw Blame History

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

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

  • 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 "<prompt with data appended>" --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.

# ~/.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
# ~/.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:

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:

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.