diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..363ef1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/stonks +config/secrets.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9862ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# 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`. diff --git a/PLAN.md b/PLAN.md index b7cd8fe..0b78776 100644 --- a/PLAN.md +++ b/PLAN.md @@ -5,7 +5,7 @@ Daily financial news + market data analysis service. Fetches free-tier market da ## Architecture ``` -systemd timer (daily) --> stonks.service (oneshot) --> cmd/stonks/main.go +systemd timer (every 5h) --> stonks.service (oneshot) --> cmd/stonks/main.go | --------------------------------------------- | | | @@ -110,6 +110,12 @@ outputs: 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. @@ -129,10 +135,11 @@ ExecStart=%h/.local/bin/stonks --config %h/.config/stonks/config.yaml ```ini # ~/.config/systemd/user/stonks.timer [Unit] -Description=Run stonks daily +Description=Run stonks every 5 hours [Timer] -OnCalendar=*-*-* 07:00:00 +# Fires at 00:00, 05:00, 10:00, 15:00, 20:00 (wall-clock aligned). +OnCalendar=*-*-* 0/5:00:00 Persistent=true [Install] @@ -145,7 +152,7 @@ WantedBy=timers.target systemctl --user enable --now stonks.timer ``` -**User-scope services normally stop when the user logs out.** Since this needs to fire daily unattended (including when nobody's logged in), enable lingering once: +**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: ```sh loginctl enable-linger "$USER" @@ -169,18 +176,20 @@ Estimated build time: a working v1 (one fetcher, one output, hardcoded targets) ## Cost -**`subscription` mode: $0 marginal cost**, covered by your existing Claude Pro/Max subscription, assuming usage stays well within its rate limits — trivially true for one call/day. +The timer now fires every 5 hours (`OnCalendar=*-*-* 0/5:00:00`) — 5 runs/day instead of 1. -**`api` mode**, for ~5-15k input / ~1-2k output tokens per day (no caching benefit at one call/day): +**`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. -| Model | Input $/MTok | Output $/MTok | Daily (worst case: 15k in / 2k out) | Daily (typical: 8k in / 1.5k out) | Monthly (typical) | +**`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.125 | $0.078 | ~$2.30 | -| Claude Sonnet 5 (intro, through 2026-08-31) | $2.00 | $10.00 | $0.050 | $0.031 | ~$0.94 | -| Claude Sonnet 5 (standard) | $3.00 | $15.00 | $0.075 | $0.047 | ~$1.40 | -| Claude Haiku 4.5 | $1.00 | $5.00 | $0.025 | $0.016 | ~$0.47 | +| 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 | -Even at Opus 5 pricing in `api` mode this is under $3/month — the choice between modes should be driven by which auth story you'd rather run unattended (personal subscription login vs. a static API key), not by cost. +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a34f08a --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# stonks + +Daily financial news + market data analysis. Fetches crypto/equity/futures prices (yfinance-equivalent + Finnhub) and market news (RSS), asks Claude for an investment-advice briefing, and posts it to Discord — on a systemd user timer. + +See [PLAN.md](PLAN.md) for the full design, cost analysis, and rationale. + +## Build + +```sh +go build -o stonks ./cmd/stonks +``` + +## Configure + +```sh +mkdir -p ~/.config/stonks +cp config/config.yaml ~/.config/stonks/config.yaml +cp config/secrets.env.example ~/.config/stonks/secrets.env +chmod 600 ~/.config/stonks/secrets.env +$EDITOR ~/.config/stonks/secrets.env # fill in FINNHUB_API_KEY, DISCORD_WEBHOOK_URL, (ANTHROPIC_API_KEY if using api mode) +``` + +If `analyzer.mode: subscription` (the default), install Claude Code and authenticate once: + +```sh +claude login +``` + +### Multi-agent mode (optional) + +`analyzer.advisors` in `config.yaml` lists additional CLI-based AI analysts (Gemini, Codex) that get the same fetched data and produce an independent take; their output is appended to Claude's prompt as extra context (Claude still writes the one final report — see PLAN.md § "Claude integration"). Each needs to be installed and logged in separately: + +```sh +gemini # first run walks through auth +codex login +``` + +A failing or unauthenticated advisor is logged as a warning and simply excluded — it never fails the run. Remove an entry (or the whole `advisors:` list) to disable multi-agent mode. + +### Archiving runs (optional) + +Set `archive.enabled: true` in `config.yaml` to save each run's fetched data, full prompt (including any advisor section), raw and parsed analysis to `archive.dir` (default `~/.local/state/stonks/runs`), one timestamped subdirectory per run: `fetched.json`, `prompt.txt`, `analysis_raw.md`, `report.md`, `recommendations.json`. Useful for debugging prompt or parsing issues without reproducing a live run. Off by default — these accumulate over time with no automatic cleanup. + +## Install as a user-scope systemd service + +```sh +mkdir -p ~/.local/bin ~/.config/systemd/user +go build -o ~/.local/bin/stonks ./cmd/stonks +cp systemd/stonks.service systemd/stonks.timer ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now stonks.timer +loginctl enable-linger "$USER" # keep the timer firing while logged out +``` + +Run it once manually to verify before waiting for the timer: + +```sh +systemctl --user start stonks.service +journalctl --user -u stonks.service -f +``` + +## Adding a new fetcher / output + +Implement the `fetch.Fetcher` / `output.Output` interface in a new file under `internal/fetch` / `internal/output`, self-register it in an `init()` (see `internal/fetch/rss.go` for an example), add the name to `config.yaml`, and rebuild. See PLAN.md § "Modularity in Go" for why this is compile-time rather than a runtime plugin system. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index dcd5f52..3645bab 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -20,4 +20,3 @@ Daily - AAPL, GOOGL, TSLA, AVGO, NVDA, INTC, AMD, AMZN - Futures - XAU, XAG, Crude Oil - diff --git a/cmd/stonks/main.go b/cmd/stonks/main.go new file mode 100644 index 0000000..370bbdf --- /dev/null +++ b/cmd/stonks/main.go @@ -0,0 +1,162 @@ +// Command stonks fetches market data and news, asks Claude for a daily +// investment-advice briefing, and posts it to the configured outputs. +// Intended to be run once a day by a systemd user timer. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "time" + + "github.com/ytshih/stonks/internal/analyze" + "github.com/ytshih/stonks/internal/archive" + "github.com/ytshih/stonks/internal/config" + "github.com/ytshih/stonks/internal/fetch" + "github.com/ytshih/stonks/internal/output" + "github.com/ytshih/stonks/internal/report" +) + +func main() { + configPath := flag.String("config", "config/config.yaml", "path to config.yaml") + timeout := flag.Duration("timeout", 5*time.Minute, "overall run timeout") + flag.Parse() + + if err := run(*configPath, *timeout); err != nil { + log.Fatalf("stonks: %v", err) + } +} + +func run(configPath string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + result, err := runFetchers(ctx, cfg) + if err != nil { + // Fetch errors are logged but non-fatal — a degraded report (some + // asset classes or news missing) is better than no report at all. + log.Printf("warning: one or more fetchers had errors: %v", err) + } + if len(result.Quotes) == 0 && len(result.News) == 0 { + return fmt.Errorf("no data fetched from any source; aborting rather than sending an empty report") + } + + analyzer, err := analyze.Build(cfg.Analyzer) + if err != nil { + return fmt.Errorf("build analyzer: %w", err) + } + + prompt := analyze.BuildUserPrompt(cfg.Targets, result) + + if len(cfg.Analyzer.Advisors) > 0 { + advisorResults := analyze.RunAdvisors(ctx, cfg.Analyzer.Advisors, prompt) + for _, r := range advisorResults { + if r.Err != nil { + log.Printf("warning: advisor %q failed, excluding it from Claude's prompt: %v", r.Name, r.Err) + } + } + prompt = analyze.AppendAdvisorSection(prompt, advisorResults) + } + + raw, err := analyzer.Analyze(ctx, prompt) + if err != nil { + return fmt.Errorf("analyze: %w", err) + } + + rep, err := analyze.SplitReport(raw) + if err != nil { + // Missing/malformed recommendations block degrades to a text-only + // report rather than failing the whole run. + log.Printf("warning: could not parse recommendations block: %v", err) + } + + if cfg.Archive.Enabled { + dir, archErr := archive.Save(cfg.Archive, archive.Run{ + Result: result, + Prompt: prompt, + RawResponse: raw, + Report: rep, + }, time.Now()) + if archErr != nil { + log.Printf("warning: could not save run archive: %v", archErr) + } else { + log.Printf("stonks: run archived to %s", dir) + } + } + + if err := runOutputs(ctx, cfg, rep); err != nil { + return fmt.Errorf("send outputs: %w", err) + } + + log.Printf("stonks: report sent (%d quotes, %d news items, %d recommendations)", + len(result.Quotes), len(result.News), len(rep.Recommendations)) + return nil +} + +func runFetchers(ctx context.Context, cfg *config.Config) (fetch.Result, error) { + var result fetch.Result + var errs []error + + for _, fc := range cfg.Fetchers { + f, err := fetch.Build(fc) + if err != nil { + errs = append(errs, err) + continue + } + + fetched, err := f.Fetch(ctx, cfg.Targets) + if err != nil { + errs = append(errs, fmt.Errorf("fetcher %q: %w", f.Name(), err)) + // fall through — a partial result from this fetcher is still + // worth merging in. + } + result.Merge(fetched) + } + + return result, joinErrs(errs) +} + +func runOutputs(ctx context.Context, cfg *config.Config, rep report.Report) error { + var errs []error + sent := 0 + + for _, oc := range cfg.Outputs { + o, err := output.Build(oc) + if err != nil { + errs = append(errs, err) + continue + } + if err := o.Send(ctx, rep); err != nil { + errs = append(errs, fmt.Errorf("output %q: %w", o.Name(), err)) + continue + } + sent++ + } + + if sent == 0 { + return joinErrs(errs) + } + if len(errs) > 0 { + // At least one output succeeded — log the rest as warnings rather + // than failing the whole run. + log.Printf("warning: some outputs failed: %v", joinErrs(errs)) + } + return nil +} + +func joinErrs(errs []error) error { + if len(errs) == 0 { + return nil + } + msg := fmt.Sprintf("%d error(s):", len(errs)) + for _, e := range errs { + msg += "\n - " + e.Error() + } + return fmt.Errorf("%s", msg) +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..a9e3e7f --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,60 @@ +targets: + crypto: [BTC-USD, ETH-USD, SOL-USD, ZEC-USD] + equities: [AAPL, GOOGL, TSLA, AVGO, NVDA, INTC, AMD, AMZN, CRCL, MSTR, META, ORCL] + futures: + gold: GC=F + silver: SI=F + crude_oil: CL=F + # Bare base+quote codes, no provider suffix — the yfinance fetcher adds + # whatever suffix Yahoo needs (=X) internally. + forex: [JPYTWD, USDTWD] + +fetchers: + - name: yfinance + - name: finnhub + params: + api_key_env_var: FINNHUB_API_KEY + news_lookback_days: 1 + - name: rss + params: + feeds: + # feeds.reuters.com was retired (domain no longer resolves) — see + # PLAN.md's note on RSS feed URLs occasionally getting retired. + - https://www.investing.com/rss/news.rss + - https://www.coindesk.com/arc/outboundfeeds/rss/ + max_items_per_feed: 10 + max_age_hours: 48 + +analyzer: + # "subscription" shells out to the `claude` CLI, billed against a Claude + # Pro/Max subscription's usage limits ($0 marginal cost for one call/day). + # "api" calls the Messages API directly with ANTHROPIC_API_KEY, billed + # per token (~$1-3/month at this volume — see PLAN.md). + mode: subscription + model: claude-opus-5 # only used in "api" mode + effort: medium # only used in "api" mode + # Multi-agent mode: the same fetched data is also piped to these CLIs; + # their independent takes are appended to Claude's prompt as extra + # context (Claude still writes the one final report). Requires `gemini` + # and `codex` to be installed and separately logged in on this host + # (`gemini` / `codex login`) — remove an entry, or the whole list, to + # disable it for that CLI. A failing/unauthenticated advisor is logged + # as a warning and simply excluded, it does not fail the run. + advisors: + #- name: Gemini + # command: gemini + - name: Codex + command: codex + args: [exec, "-"] + +outputs: + - name: discord + params: + webhook_env_var: DISCORD_WEBHOOK_URL + +# Optional: save each run's fetched data + full prompt + raw/parsed analysis +# to disk (one timestamped subdirectory per run) — useful for debugging +# prompt or parsing issues without reproducing a live run. Off by default. +archive: + enabled: false + dir: ~/.local/state/stonks/runs diff --git a/config/secrets.env.example b/config/secrets.env.example new file mode 100644 index 0000000..c1d2afc --- /dev/null +++ b/config/secrets.env.example @@ -0,0 +1,16 @@ +# Copy this file to secrets.env (or ~/.config/stonks/secrets.env for the +# deployed systemd unit) and fill in real values. Never commit the filled-in +# version — keep it out of git and set its permissions to 600. +# +# chmod 600 secrets.env + +# Only needed if analyzer.mode is "api" in config.yaml. Not needed in +# "subscription" mode, which uses the `claude` CLI's own login session. +ANTHROPIC_API_KEY= + +# Free key from https://finnhub.io — required by the finnhub fetcher. +FINNHUB_API_KEY= + +# Discord incoming webhook URL for the channel the report should post to. +# Server Settings -> Integrations -> Webhooks -> New Webhook -> Copy URL. +DISCORD_WEBHOOK_URL= diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e387b2 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/ytshih/stonks + +go 1.27.0 + +require ( + github.com/anthropics/anthropic-sdk-go v1.71.0 + github.com/mmcdole/gofeed v1.4.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect + github.com/mmcdole/goxpp/v2 v2.0.0 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c7f15cd --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/anthropics/anthropic-sdk-go v1.71.0 h1:DK9xG3s5t+xUIOp+R+1LIRjkU/wG57YyQO3630+gYOE= +github.com/anthropics/anthropic-sdk-go v1.71.0/go.mod h1:x+lPk/cCl48uRegeP0hlYYBN1b7bEBTveInIMgLicnY= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/mmcdole/gofeed v1.4.2 h1:XFFOtsNNZg+zudjtMXb8BI0J/YdnSIGfjEpPgPnZib0= +github.com/mmcdole/gofeed v1.4.2/go.mod h1:X5x1PyeibJi152VEya0AsV+PW4daYmCD4LJaJbeFkcs= +github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ= +github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= +github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/analyze/advisor.go b/internal/analyze/advisor.go new file mode 100644 index 0000000..43329a3 --- /dev/null +++ b/internal/analyze/advisor.go @@ -0,0 +1,82 @@ +package analyze + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "sync" + + "github.com/ytshih/stonks/internal/config" +) + +// AdvisorPreamble is prepended to the shared data prompt when consulting an +// advisor CLI (multi-agent mode) — advisors get a simpler ask than Claude's +// SystemPrompt: an independent take, no recommendations-JSON contract. +const AdvisorPreamble = `You are a financial analyst. Given the following market prices and recent news for a fixed watchlist (crypto, NASDAQ equities, futures, forex), give a concise independent investment analysis: what stands out, and any assets you'd flag as a buy or sell with a one-sentence reason each. Plain text, under 400 words.` + +// AdvisorResult is one advisor CLI's outcome. Err is set (and Output empty) +// on failure — callers should log it and simply omit this advisor from the +// final prompt, not fail the run. +type AdvisorResult struct { + Name string + Output string + Err error +} + +// RunAdvisors runs every configured advisor CLI concurrently, piping +// AdvisorPreamble + dataPrompt to each one's stdin, and returns one result +// per advisor in the same order as cfg. +func RunAdvisors(ctx context.Context, advisors []config.AdvisorConfig, dataPrompt string) []AdvisorResult { + prompt := AdvisorPreamble + "\n\n" + dataPrompt + results := make([]AdvisorResult, len(advisors)) + + var wg sync.WaitGroup + for i, adv := range advisors { + wg.Add(1) + go func(i int, adv config.AdvisorConfig) { + defer wg.Done() + out, err := runAdvisorCLI(ctx, adv, prompt) + results[i] = AdvisorResult{Name: adv.Name, Output: out, Err: err} + }(i, adv) + } + wg.Wait() + + return results +} + +func runAdvisorCLI(ctx context.Context, adv config.AdvisorConfig, prompt string) (string, error) { + if _, err := exec.LookPath(adv.Command); err != nil { + return "", fmt.Errorf("command %q not found on PATH: %w", adv.Command, err) + } + + cmd := exec.CommandContext(ctx, adv.Command, adv.Args...) + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%s failed: %w (stderr: %s)", adv.Command, err, lastLine(stderr.String())) + } + + out := strings.TrimSpace(stdout.String()) + if out == "" { + return "", fmt.Errorf("%s produced no output (stderr: %s)", adv.Command, lastLine(stderr.String())) + } + return out, nil +} + +// lastLine returns the last non-empty line of s, for a compact one-line +// error summary out of what can be many lines of CLI log noise. +func lastLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if line := strings.TrimSpace(lines[i]); line != "" { + return line + } + } + return "" +} diff --git a/internal/analyze/analyzer.go b/internal/analyze/analyzer.go new file mode 100644 index 0000000..a55e83c --- /dev/null +++ b/internal/analyze/analyzer.go @@ -0,0 +1,40 @@ +// Package analyze turns fetched market data + news into an investment-advice +// report via Claude, using one of two interchangeable modes: "subscription" +// (shells out to the `claude` CLI, billed against a Claude Pro/Max +// subscription) or "api" (calls the Messages API directly, billed per token). +package analyze + +import ( + "context" + "fmt" + + "github.com/ytshih/stonks/internal/config" +) + +// Analyzer sends a prompt to Claude and returns the response text. +type Analyzer interface { + Analyze(ctx context.Context, prompt string) (string, error) +} + +// Constructor builds an Analyzer from the analyzer config block. +type Constructor func(cfg config.AnalyzerConfig) (Analyzer, error) + +var registry = map[string]Constructor{} + +// Register makes an analyzer constructor available under mode. Called from +// each mode implementation's init(). +func Register(mode string, ctor Constructor) { + if _, exists := registry[mode]; exists { + panic(fmt.Sprintf("analyze: constructor already registered for mode %q", mode)) + } + registry[mode] = ctor +} + +// Build looks up the constructor for cfg.Mode and invokes it. +func Build(cfg config.AnalyzerConfig) (Analyzer, error) { + ctor, ok := registry[cfg.Mode] + if !ok { + return nil, fmt.Errorf("analyze: no analyzer registered for mode %q", cfg.Mode) + } + return ctor(cfg) +} diff --git a/internal/analyze/api.go b/internal/analyze/api.go new file mode 100644 index 0000000..5db62db --- /dev/null +++ b/internal/analyze/api.go @@ -0,0 +1,77 @@ +package analyze + +import ( + "context" + "fmt" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + + "github.com/ytshih/stonks/internal/config" +) + +func init() { + Register("api", newAPIAnalyzer) +} + +const defaultAPIModel = anthropic.ModelClaudeOpus5 + +// apiAnalyzer calls the Claude Messages API directly, billed per token. +// Reads credentials the standard way (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN +// / an `ant auth login` profile) via the zero-arg client constructor. +type apiAnalyzer struct { + client anthropic.Client + model anthropic.Model + effort anthropic.OutputConfigEffort +} + +func newAPIAnalyzer(cfg config.AnalyzerConfig) (Analyzer, error) { + model := anthropic.Model(cfg.Model) + if cfg.Model == "" { + model = defaultAPIModel + } + + effort := anthropic.OutputConfigEffortMedium + if cfg.Effort != "" { + effort = anthropic.OutputConfigEffort(cfg.Effort) + } + + return &apiAnalyzer{ + client: anthropic.NewClient(), + model: model, + effort: effort, + }, nil +} + +func (a *apiAnalyzer) Analyze(ctx context.Context, prompt string) (string, error) { + resp, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: a.model, + MaxTokens: 4096, + System: []anthropic.TextBlockParam{{Text: SystemPrompt}}, + OutputConfig: anthropic.OutputConfigParam{ + Effort: a.effort, + }, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)), + }, + }) + if err != nil { + return "", fmt.Errorf("analyze (api mode): %w", err) + } + + if resp.StopReason == anthropic.StopReasonRefusal { + return "", fmt.Errorf("analyze (api mode): request refused by safety classifiers") + } + + var out strings.Builder + for _, block := range resp.Content { + if text, ok := block.AsAny().(anthropic.TextBlock); ok { + out.WriteString(text.Text) + } + } + if out.Len() == 0 { + return "", fmt.Errorf("analyze (api mode): response contained no text content (stop_reason=%s)", resp.StopReason) + } + + return out.String(), nil +} diff --git a/internal/analyze/prompt.go b/internal/analyze/prompt.go new file mode 100644 index 0000000..a12feef --- /dev/null +++ b/internal/analyze/prompt.go @@ -0,0 +1,121 @@ +package analyze + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/ytshih/stonks/internal/config" + "github.com/ytshih/stonks/internal/fetch" +) + +// SystemPrompt instructs Claude how to analyze the fetched data and format +// its response for posting to Discord. +const SystemPrompt = `You are a financial analyst producing a concise daily briefing for a personal Discord channel. + +You will be given today's prices (with percent change) and recent news headlines for a fixed watchlist of cryptocurrencies, NASDAQ equities, futures (metals, crude oil), and forex pairs. + +Structure your response in Markdown, formatted for Discord (short lines, ` + "`**bold**`" + ` section headers, no HTML, no huge tables): + +1. A one-paragraph market-wide summary. +2. A "Crypto" section: brief commentary per asset grounded in the given price/news data. +3. A "NASDAQ Equities" section: same, grouped sensibly (e.g. by sector) if useful. +4. A "Futures" section (gold, silver, crude oil). +5. A "Forex" section covering the given currency pairs. +6. A short "Watch" section flagging anything time-sensitive (earnings, macro events implied by the news) for the next day or two. + +Keep the whole message under roughly 1500 words. Ground every claim in the provided data — do not fabricate news or prices. If data for an asset is missing, say so briefly rather than inventing a view. End with a one-line disclaimer that this is not financial advice. + +If the user turn includes a section titled "Other AI Analysts' Independent Takes", those are additional perspectives from other AI models given the same data — not ground truth and not your own reasoning. Weigh them alongside the price/news data, note explicitly where you agree or disagree and why, but reach your own independent conclusion. Do not simply restate their conclusions. + +After the full briefing above, on its own line, output exactly: +===RECOMMENDATIONS_JSON=== +followed immediately by a single JSON object (no markdown code fence, no commentary before or after it) with this exact shape: +{"recommendations": [{"symbol": "AAPL", "action": "buy", "reason": "one short sentence"}]} +"action" must be exactly "buy" or "sell". Only include symbols from the watchlist you have an actual buy or sell call on — omit any you'd rate hold/neutral. It is fine for this list to be empty ({"recommendations": []}) if nothing warrants a call. This JSON must be the very last thing in your response.` + +// BuildUserPrompt renders the fetched result into the user-turn text sent +// alongside SystemPrompt. +func BuildUserPrompt(targets config.Targets, result fetch.Result) string { + var b strings.Builder + + fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02")) + + b.WriteString("## Prices\n\n") + writeQuoteSection(&b, "Crypto", targets.Crypto, result.Quotes) + writeQuoteSection(&b, "NASDAQ Equities", targets.Equities, result.Quotes) + + futureSymbols := make([]string, 0, len(targets.Futures)) + for _, symbol := range targets.Futures { + futureSymbols = append(futureSymbols, symbol) + } + sort.Strings(futureSymbols) + writeQuoteSection(&b, "Futures", futureSymbols, result.Quotes) + + writeQuoteSection(&b, "Forex", targets.Forex, result.Quotes) + + b.WriteString("\n## Recent News\n\n") + if len(result.News) == 0 { + b.WriteString("(no news items fetched)\n") + } + for _, n := range result.News { + fmt.Fprintf(&b, "- [%s] %s", n.Source, n.Title) + if !n.Published.IsZero() { + fmt.Fprintf(&b, " (%s)", n.Published.Format("2006-01-02")) + } + b.WriteString("\n") + if n.Summary != "" { + fmt.Fprintf(&b, " %s\n", truncate(n.Summary, 300)) + } + } + + return b.String() +} + +// AppendAdvisorSection appends successful advisor outputs (see +// RunAdvisors) to prompt as an "Other AI Analysts' Independent Takes" +// section, one subsection per advisor. Advisors that errored are skipped — +// callers should log those separately. Returns prompt unchanged if there +// are no successful results, so it's safe to call unconditionally. +func AppendAdvisorSection(prompt string, results []AdvisorResult) string { + var b strings.Builder + for _, r := range results { + if r.Err != nil || r.Output == "" { + continue + } + fmt.Fprintf(&b, "### %s\n\n%s\n\n", r.Name, r.Output) + } + if b.Len() == 0 { + return prompt + } + return prompt + "\n## Other AI Analysts' Independent Takes\n\n" + b.String() +} + +func writeQuoteSection(b *strings.Builder, heading string, symbols []string, quotes []fetch.Quote) { + fmt.Fprintf(b, "### %s\n\n", heading) + bySymbol := map[string][]fetch.Quote{} + for _, q := range quotes { + bySymbol[q.Symbol] = append(bySymbol[q.Symbol], q) + } + + for _, sym := range symbols { + qs, ok := bySymbol[sym] + if !ok || len(qs) == 0 { + fmt.Fprintf(b, "- %s: no data available\n", sym) + continue + } + for _, q := range qs { + fmt.Fprintf(b, "- %s (%s) [%s]: %.2f %s (%+.2f%%)\n", + sym, q.Label, q.Source, q.Price, q.Currency, q.ChangePercent) + } + } + b.WriteString("\n") +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/analyze/split.go b/internal/analyze/split.go new file mode 100644 index 0000000..2e2befb --- /dev/null +++ b/internal/analyze/split.go @@ -0,0 +1,65 @@ +package analyze + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ytshih/stonks/internal/report" +) + +// recommendationsMarker must match the marker text SystemPrompt instructs +// Claude to emit before the trailing recommendations JSON block. +const recommendationsMarker = "===RECOMMENDATIONS_JSON===" + +type recommendationsPayload struct { + Recommendations []report.Recommendation `json:"recommendations"` +} + +// SplitReport separates Claude's raw response into the long-form narrative +// text and the short buy/sell recommendations list appended after +// recommendationsMarker (see SystemPrompt). +// +// If the marker is missing or the trailing JSON fails to parse, the full raw +// text is returned as-is with a nil recommendations slice, plus a non-nil +// error — callers should log it as a warning and still send the text-only +// report rather than treat this as fatal. +func SplitReport(raw string) (report.Report, error) { + idx := strings.Index(raw, recommendationsMarker) + if idx == -1 { + return report.Report{Text: strings.TrimSpace(raw)}, + fmt.Errorf("recommendations marker not found in response") + } + + text := strings.TrimSpace(raw[:idx]) + jsonPart := extractJSONObject(raw[idx+len(recommendationsMarker):]) + + var payload recommendationsPayload + if err := json.Unmarshal([]byte(jsonPart), &payload); err != nil { + return report.Report{Text: text}, fmt.Errorf("parse recommendations json: %w", err) + } + + for _, r := range payload.Recommendations { + if r.Action != "buy" && r.Action != "sell" { + return report.Report{Text: text}, fmt.Errorf("recommendation for %q has invalid action %q", r.Symbol, r.Action) + } + } + + return report.Report{Text: text, Recommendations: payload.Recommendations}, nil +} + +// extractJSONObject pulls out the substring from the first "{" to the last +// "}" in s. This tolerates any decoration Claude adds around the JSON +// despite being asked not to — a ```json fence, a stray leading backtick, a +// trailing sentence — since none of that can contain a brace that comes +// before the real opening one or after the real closing one. Returns s +// trimmed, unchanged, if no braces are found (json.Unmarshal will then fail +// with a clear error). +func extractJSONObject(s string) string { + start := strings.IndexByte(s, '{') + end := strings.LastIndexByte(s, '}') + if start == -1 || end == -1 || end < start { + return strings.TrimSpace(s) + } + return s[start : end+1] +} diff --git a/internal/analyze/subscription.go b/internal/analyze/subscription.go new file mode 100644 index 0000000..a39b1c2 --- /dev/null +++ b/internal/analyze/subscription.go @@ -0,0 +1,53 @@ +package analyze + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/ytshih/stonks/internal/config" +) + +func init() { + Register("subscription", newSubscriptionAnalyzer) +} + +// subscriptionAnalyzer shells out to the `claude` CLI in headless/print mode +// (`claude -p`), using whatever session `claude login` established on this +// host. Usage is billed against that Claude Pro/Max subscription's +// rate limits rather than a separate per-token API bill. +// +// The CLI has no separate "system prompt" flag documented for scripting, so +// the system and user prompts are concatenated and piped in over stdin — +// the most robust interface regardless of CLI version. +type subscriptionAnalyzer struct{} + +func newSubscriptionAnalyzer(cfg config.AnalyzerConfig) (Analyzer, error) { + if _, err := exec.LookPath("claude"); err != nil { + return nil, fmt.Errorf("analyze (subscription mode): `claude` CLI not found on PATH — install Claude Code and run `claude login`: %w", err) + } + return &subscriptionAnalyzer{}, nil +} + +func (a *subscriptionAnalyzer) Analyze(ctx context.Context, prompt string) (string, error) { + fullPrompt := SystemPrompt + "\n\n---\n\n" + prompt + + cmd := exec.CommandContext(ctx, "claude", "-p") + cmd.Stdin = strings.NewReader(fullPrompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("analyze (subscription mode): claude CLI failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String())) + } + + out := strings.TrimSpace(stdout.String()) + if out == "" { + return "", fmt.Errorf("analyze (subscription mode): claude CLI produced no output (stderr: %s)", strings.TrimSpace(stderr.String())) + } + return out, nil +} diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..73099fe --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,86 @@ +// Package archive optionally saves a local copy of each run's fetched data +// and analysis, for debugging prompt/parsing issues without reproducing a +// live run. +package archive + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ytshih/stonks/internal/config" + "github.com/ytshih/stonks/internal/fetch" + "github.com/ytshih/stonks/internal/report" +) + +const defaultDir = "~/.local/state/stonks/runs" + +// Run holds everything one pipeline run produced, for archiving. +type Run struct { + Result fetch.Result + Prompt string // final prompt sent to the analyzer, including any advisor section + RawResponse string // analyzer's raw, unparsed response + Report report.Report +} + +// Save writes Run's artifacts to a new timestamped subdirectory under +// cfg.Dir (or defaultDir if cfg.Dir is empty) and returns that directory's +// path. Callers should treat a non-nil error as non-fatal — archiving is a +// debugging aid, not part of the report pipeline. +func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) { + base := cfg.Dir + if base == "" { + base = defaultDir + } + base, err := expandHome(base) + if err != nil { + return "", fmt.Errorf("archive: %w", err) + } + + dir := filepath.Join(base, now.UTC().Format("20060102T150405Z")) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("archive: create %q: %w", dir, err) + } + + fetchedJSON, err := json.MarshalIndent(run.Result, "", " ") + if err != nil { + return "", fmt.Errorf("archive: marshal fetched data: %w", err) + } + recsJSON, err := json.MarshalIndent(run.Report.Recommendations, "", " ") + if err != nil { + return "", fmt.Errorf("archive: marshal recommendations: %w", err) + } + + files := map[string][]byte{ + "fetched.json": fetchedJSON, + "prompt.txt": []byte(run.Prompt), + "analysis_raw.md": []byte(run.RawResponse), + "report.md": []byte(run.Report.Text), + "recommendations.json": recsJSON, + } + for name, data := range files { + if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil { + return dir, fmt.Errorf("archive: write %q: %w", name, err) + } + } + + return dir, nil +} + +// expandHome replaces a leading "~" with the current user's home directory. +func expandHome(path string) (string, error) { + if path != "~" && !strings.HasPrefix(path, "~/") { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + if path == "~" { + return home, nil + } + return filepath.Join(home, path[2:]), nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7bea195 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,132 @@ +// Package config loads and represents stonks' YAML configuration. +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Targets lists the assets to fetch data for, grouped by asset class. +type Targets struct { + Crypto []string `yaml:"crypto"` + Equities []string `yaml:"equities"` + Futures map[string]string `yaml:"futures"` + // Forex holds currency pairs as bare base+quote codes (e.g. "USDTWD"), + // without any provider-specific suffix — fetchers append whatever + // suffix their API needs (Yahoo Finance uses "=X"). + Forex []string `yaml:"forex"` +} + +// PluginConfig is the config for one named, registered fetcher or output. +// Params is decoded lazily via DecodeParams so each plugin owns its own +// params shape without the top-level config needing to know it. +type PluginConfig struct { + Name string `yaml:"name"` + Params map[string]any `yaml:"params"` +} + +// DecodeParams re-marshals Params into out, which should be a pointer to a +// plugin-specific params struct with `yaml` tags. +func (p PluginConfig) DecodeParams(out any) error { + if p.Params == nil { + return nil + } + raw, err := yaml.Marshal(p.Params) + if err != nil { + return fmt.Errorf("marshal params for %q: %w", p.Name, err) + } + if err := yaml.Unmarshal(raw, out); err != nil { + return fmt.Errorf("decode params for %q: %w", p.Name, err) + } + return nil +} + +// AnalyzerConfig configures which Claude invocation mode to use. +type AnalyzerConfig struct { + // Mode is "subscription" (shells out to the `claude` CLI, billed + // against a Claude Pro/Max subscription) or "api" (uses ANTHROPIC_API_KEY + // via the Messages API, billed per token). + Mode string `yaml:"mode"` + // Model is only used in "api" mode. + Model string `yaml:"model"` + // Effort is only used in "api" mode: low|medium|high|xhigh|max. + Effort string `yaml:"effort"` + // Advisors are additional CLI-based AI analysts (e.g. gemini, codex) + // consulted before the final Claude call — multi-agent mode. Their + // output is appended to Claude's prompt as extra context; Claude still + // produces the one final report and recommendations. Empty/omitted + // disables multi-agent mode entirely. + Advisors []AdvisorConfig `yaml:"advisors"` +} + +// AdvisorConfig defines one additional CLI-based AI analyst consulted in +// multi-agent mode. The same fetched-data prompt is piped to its stdin; +// Command must be a non-interactive/scriptable invocation (e.g. `gemini` +// reads a piped prompt from stdin one-shot; `codex exec -` explicitly reads +// from stdin). +type AdvisorConfig struct { + // Name labels this advisor's section in Claude's final prompt (e.g. "Gemini"). + Name string `yaml:"name"` + // Command is the CLI binary to invoke (looked up on PATH). + Command string `yaml:"command"` + // Args are extra arguments passed before the prompt is piped via stdin. + Args []string `yaml:"args"` +} + +// ArchiveConfig controls saving a local copy of each run's fetched data and +// analysis — useful for debugging prompt/parsing issues without needing to +// reproduce a live run. +type ArchiveConfig struct { + Enabled bool `yaml:"enabled"` + // Dir is the directory each run's files are written under, one + // timestamped subdirectory per run. Supports a leading "~". Defaults to + // "~/.local/state/stonks/runs" if enabled with Dir left empty. + Dir string `yaml:"dir"` +} + +// Config is the full stonks configuration. +type Config struct { + Targets Targets `yaml:"targets"` + Fetchers []PluginConfig `yaml:"fetchers"` + Analyzer AnalyzerConfig `yaml:"analyzer"` + Outputs []PluginConfig `yaml:"outputs"` + Archive ArchiveConfig `yaml:"archive"` +} + +// Load reads and parses the config file at path. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %q: %w", path, err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config %q: %w", path, err) + } + + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("invalid config %q: %w", path, err) + } + + return &cfg, nil +} + +func (c *Config) validate() error { + switch c.Analyzer.Mode { + case "subscription", "api": + case "": + return fmt.Errorf("analyzer.mode is required (\"subscription\" or \"api\")") + default: + return fmt.Errorf("analyzer.mode %q must be \"subscription\" or \"api\"", c.Analyzer.Mode) + } + if len(c.Fetchers) == 0 { + return fmt.Errorf("at least one fetcher must be configured") + } + if len(c.Outputs) == 0 { + return fmt.Errorf("at least one output must be configured") + } + return nil +} diff --git a/internal/fetch/fetcher.go b/internal/fetch/fetcher.go new file mode 100644 index 0000000..6285924 --- /dev/null +++ b/internal/fetch/fetcher.go @@ -0,0 +1,75 @@ +// Package fetch defines the pluggable data-source interface and its +// implementations (yfinance-equivalent prices, Finnhub, RSS news). +package fetch + +import ( + "context" + "fmt" + "time" + + "github.com/ytshih/stonks/internal/config" +) + +// Quote is a single point-in-time price observation for one symbol. +type Quote struct { + Symbol string // ticker as passed to the fetcher, e.g. "AAPL", "BTC-USD", "GC=F" + Label string // human-friendly name, e.g. "Apple Inc." + Price float64 + ChangePercent float64 // percent change over the source's default window (usually 1 day) + Currency string + Source string // which fetcher produced this, e.g. "yfinance", "finnhub" +} + +// NewsItem is a single headline/article. +type NewsItem struct { + Title string + Source string // publication or fetcher name + URL string + Published time.Time + Summary string +} + +// Result is what one fetcher call returns. The orchestrator merges Results +// from every configured fetcher before handing them to the analyzer. +type Result struct { + Quotes []Quote + News []NewsItem +} + +// Merge appends other's quotes and news onto r. +func (r *Result) Merge(other Result) { + r.Quotes = append(r.Quotes, other.Quotes...) + r.News = append(r.News, other.News...) +} + +// Fetcher fetches market data and/or news for the given targets. +// Implementations should return a partial Result plus an error when only +// some data could be retrieved, rather than nothing at all — the caller +// decides whether a partial result is still worth reporting. +type Fetcher interface { + Name() string + Fetch(ctx context.Context, targets config.Targets) (Result, error) +} + +// Constructor builds a Fetcher from its PluginConfig (name + params). +type Constructor func(cfg config.PluginConfig) (Fetcher, error) + +var registry = map[string]Constructor{} + +// Register makes a fetcher constructor available under name. Called from +// each fetcher implementation's init(). +func Register(name string, ctor Constructor) { + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("fetch: constructor already registered for %q", name)) + } + registry[name] = ctor +} + +// Build looks up the constructor for cfg.Name and invokes it. +func Build(cfg config.PluginConfig) (Fetcher, error) { + ctor, ok := registry[cfg.Name] + if !ok { + return nil, fmt.Errorf("fetch: no fetcher registered for %q", cfg.Name) + } + return ctor(cfg) +} diff --git a/internal/fetch/finnhub.go b/internal/fetch/finnhub.go new file mode 100644 index 0000000..fbb054b --- /dev/null +++ b/internal/fetch/finnhub.go @@ -0,0 +1,178 @@ +package fetch + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "time" + + "github.com/ytshih/stonks/internal/config" +) + +func init() { + Register("finnhub", newFinnhubFetcher) +} + +const finnhubBaseURL = "https://finnhub.io/api/v1" + +// finnhubParams is the plugin-specific config for the finnhub fetcher. +type finnhubParams struct { + // APIKeyEnvVar names the environment variable holding the Finnhub API + // key. Defaults to FINNHUB_API_KEY. + APIKeyEnvVar string `yaml:"api_key_env_var"` + // NewsLookbackDays controls how far back company-news headlines are + // pulled. Defaults to 1 (yesterday through today). + NewsLookbackDays int `yaml:"news_lookback_days"` +} + +// finnhubFetcher fetches equity quotes and per-ticker company news from +// Finnhub's free tier (documented, key-based — a more stable second price +// source than the unofficial yfinance-equivalent fetcher). Finnhub's free +// tier does not cover crypto or futures symbols, so this fetcher only +// covers targets.Equities. +type finnhubFetcher struct { + apiKey string + newsLookbackDays int + client *http.Client +} + +func newFinnhubFetcher(cfg config.PluginConfig) (Fetcher, error) { + params := finnhubParams{ + APIKeyEnvVar: "FINNHUB_API_KEY", + NewsLookbackDays: 1, + } + if err := cfg.DecodeParams(¶ms); err != nil { + return nil, err + } + + apiKey := os.Getenv(params.APIKeyEnvVar) + if apiKey == "" { + return nil, fmt.Errorf("finnhub: environment variable %q is not set", params.APIKeyEnvVar) + } + + return &finnhubFetcher{ + apiKey: apiKey, + newsLookbackDays: params.NewsLookbackDays, + client: &http.Client{Timeout: 15 * time.Second}, + }, nil +} + +func (f *finnhubFetcher) Name() string { return "finnhub" } + +func (f *finnhubFetcher) Fetch(ctx context.Context, targets config.Targets) (Result, error) { + var result Result + var errs []error + + for _, symbol := range targets.Equities { + q, err := f.fetchQuote(ctx, symbol) + if err != nil { + errs = append(errs, fmt.Errorf("finnhub quote %s: %w", symbol, err)) + continue + } + result.Quotes = append(result.Quotes, q) + + news, err := f.fetchCompanyNews(ctx, symbol) + if err != nil { + errs = append(errs, fmt.Errorf("finnhub news %s: %w", symbol, err)) + continue + } + result.News = append(result.News, news...) + } + + return result, joinErrs(errs) +} + +type finnhubQuoteResponse struct { + Current float64 `json:"c"` + Change float64 `json:"d"` + PercentChange float64 `json:"dp"` +} + +func (f *finnhubFetcher) fetchQuote(ctx context.Context, symbol string) (Quote, error) { + var resp finnhubQuoteResponse + if err := f.get(ctx, "/quote", url.Values{"symbol": {symbol}}, &resp); err != nil { + return Quote{}, err + } + if resp.Current == 0 { + return Quote{}, fmt.Errorf("no data returned (symbol may be invalid or unsupported on this tier)") + } + return Quote{ + Symbol: symbol, + Label: symbol, + Price: resp.Current, + ChangePercent: resp.PercentChange, + Currency: "USD", + Source: "finnhub", + }, nil +} + +type finnhubNewsItem struct { + Datetime int64 `json:"datetime"` + Headline string `json:"headline"` + Source string `json:"source"` + Summary string `json:"summary"` + URL string `json:"url"` +} + +func (f *finnhubFetcher) fetchCompanyNews(ctx context.Context, symbol string) ([]NewsItem, error) { + to := time.Now().UTC() + from := to.AddDate(0, 0, -f.newsLookbackDays) + + var resp []finnhubNewsItem + params := url.Values{ + "symbol": {symbol}, + "from": {from.Format("2006-01-02")}, + "to": {to.Format("2006-01-02")}, + } + if err := f.get(ctx, "/company-news", params, &resp); err != nil { + return nil, err + } + + items := make([]NewsItem, 0, len(resp)) + for _, n := range resp { + items = append(items, NewsItem{ + Title: fmt.Sprintf("[%s] %s", symbol, n.Headline), + Source: n.Source, + URL: n.URL, + Published: time.Unix(n.Datetime, 0).UTC(), + Summary: n.Summary, + }) + } + return items, nil +} + +func (f *finnhubFetcher) get(ctx context.Context, path string, params url.Values, out any) error { + params.Set("token", f.apiKey) + reqURL := finnhubBaseURL + path + "?" + params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return err + } + + resp, err := f.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// joinErrs combines multiple errors into one, or returns nil if errs is empty. +func joinErrs(errs []error) error { + if len(errs) == 0 { + return nil + } + msg := fmt.Sprintf("%d error(s) occurred:", len(errs)) + for _, e := range errs { + msg += "\n - " + e.Error() + } + return fmt.Errorf("%s", msg) +} diff --git a/internal/fetch/rss.go b/internal/fetch/rss.go new file mode 100644 index 0000000..82e1cb0 --- /dev/null +++ b/internal/fetch/rss.go @@ -0,0 +1,97 @@ +package fetch + +import ( + "context" + "fmt" + "time" + + "github.com/mmcdole/gofeed" + + "github.com/ytshih/stonks/internal/config" +) + +func init() { + Register("rss", newRSSFetcher) +} + +// rssParams is the plugin-specific config for the rss fetcher. +type rssParams struct { + Feeds []string `yaml:"feeds"` + // MaxItemsPerFeed caps how many of the newest entries are kept per feed. + // Defaults to 10. + MaxItemsPerFeed int `yaml:"max_items_per_feed"` + // MaxAgeHours drops entries older than this many hours. Defaults to 48. + // Feeds with entries lacking a published date are kept regardless. + MaxAgeHours int `yaml:"max_age_hours"` +} + +// rssFetcher pulls general market news headlines from a configured list of +// RSS feeds. Free, no API key. +type rssFetcher struct { + feeds []string + maxItemsPerFeed int + maxAge time.Duration + parser *gofeed.Parser +} + +func newRSSFetcher(cfg config.PluginConfig) (Fetcher, error) { + params := rssParams{ + MaxItemsPerFeed: 10, + MaxAgeHours: 48, + } + if err := cfg.DecodeParams(¶ms); err != nil { + return nil, err + } + if len(params.Feeds) == 0 { + return nil, fmt.Errorf("rss: at least one feed URL must be configured") + } + + return &rssFetcher{ + feeds: params.Feeds, + maxItemsPerFeed: params.MaxItemsPerFeed, + maxAge: time.Duration(params.MaxAgeHours) * time.Hour, + parser: gofeed.NewParser(), + }, nil +} + +func (f *rssFetcher) Name() string { return "rss" } + +func (f *rssFetcher) Fetch(ctx context.Context, _ config.Targets) (Result, error) { + var result Result + var errs []error + cutoff := time.Now().Add(-f.maxAge) + + for _, feedURL := range f.feeds { + feed, err := f.parser.ParseURLWithContext(feedURL, ctx) + if err != nil { + errs = append(errs, fmt.Errorf("rss %s: %w", feedURL, err)) + continue + } + + count := 0 + for _, item := range feed.Items { + if count >= f.maxItemsPerFeed { + break + } + if item.PublishedParsed != nil && item.PublishedParsed.Before(cutoff) { + continue + } + + var published time.Time + if item.PublishedParsed != nil { + published = *item.PublishedParsed + } + + result.News = append(result.News, NewsItem{ + Title: item.Title, + Source: feed.Title, + URL: item.Link, + Published: published, + Summary: item.Description, + }) + count++ + } + } + + return result, joinErrs(errs) +} diff --git a/internal/fetch/yfinance.go b/internal/fetch/yfinance.go new file mode 100644 index 0000000..3c36364 --- /dev/null +++ b/internal/fetch/yfinance.go @@ -0,0 +1,157 @@ +package fetch + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/ytshih/stonks/internal/config" +) + +func init() { + Register("yfinance", newYFinanceFetcher) +} + +// Yahoo's older batch quote endpoint (/v6/finance/quote, used by the +// unmaintained piquette/finance-go library) has since been locked behind +// auth (401/404 without a session crumb). The per-symbol chart endpoint +// below is still open and unauthenticated as of writing, and covers +// crypto, equities, and futures alike via the same shape — so this fetcher +// talks to it directly instead of depending on a library hitting a dead +// endpoint. +const yfinanceChartURLTemplate = "https://query1.finance.yahoo.com/v8/finance/chart/%s" + +// yfinanceFetcher fetches prices from Yahoo Finance's public chart API — +// the same unofficial endpoint Python's yfinance scrapes. No API key +// required, but it does require a browser-like User-Agent or Yahoo +// returns 429. +type yfinanceFetcher struct { + client *http.Client +} + +func newYFinanceFetcher(cfg config.PluginConfig) (Fetcher, error) { + return &yfinanceFetcher{ + client: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +func (f *yfinanceFetcher) Name() string { return "yfinance" } + +// yfinanceTask pairs the symbol used to query Yahoo (query) with the plain +// symbol shown in the report and matched against config.Targets (display). +// These differ for forex, where Yahoo requires a "=X" suffix (e.g. +// "USDTWD=X") that config and the rest of the pipeline never see. +type yfinanceTask struct { + query string + display string +} + +func (f *yfinanceFetcher) Fetch(ctx context.Context, targets config.Targets) (Result, error) { + tasks := make([]yfinanceTask, 0, len(targets.Crypto)+len(targets.Equities)+len(targets.Futures)+len(targets.Forex)) + for _, s := range targets.Crypto { + tasks = append(tasks, yfinanceTask{query: s, display: s}) + } + for _, s := range targets.Equities { + tasks = append(tasks, yfinanceTask{query: s, display: s}) + } + for _, ticker := range targets.Futures { + tasks = append(tasks, yfinanceTask{query: ticker, display: ticker}) + } + for _, s := range targets.Forex { + tasks = append(tasks, yfinanceTask{query: s + "=X", display: s}) + } + if len(tasks) == 0 { + return Result{}, nil + } + + type outcome struct { + quote Quote + err error + } + outcomes := make([]outcome, len(tasks)) + + var wg sync.WaitGroup + for i, task := range tasks { + wg.Add(1) + go func(i int, task yfinanceTask) { + defer wg.Done() + q, err := f.fetchOne(ctx, task) + outcomes[i] = outcome{quote: q, err: err} + }(i, task) + } + wg.Wait() + + var result Result + var errs []error + for i, o := range outcomes { + if o.err != nil { + errs = append(errs, fmt.Errorf("%s: %w", tasks[i].display, o.err)) + continue + } + result.Quotes = append(result.Quotes, o.quote) + } + + return result, joinErrs(errs) +} + +type yfinanceChartResponse struct { + Chart struct { + Result []struct { + Meta struct { + Symbol string `json:"symbol"` + Currency string `json:"currency"` + RegularMarketPrice float64 `json:"regularMarketPrice"` + RegularMarketChangePercent float64 `json:"regularMarketChangePercent"` + } `json:"meta"` + } `json:"result"` + Error *struct { + Description string `json:"description"` + } `json:"error"` + } `json:"chart"` +} + +func (f *yfinanceFetcher) fetchOne(ctx context.Context, task yfinanceTask) (Quote, error) { + reqURL := fmt.Sprintf(yfinanceChartURLTemplate, url.PathEscape(task.query)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return Quote{}, err + } + // Yahoo returns 429 for requests with no browser-like User-Agent. + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; stonks/1.0)") + + resp, err := f.client.Do(req) + if err != nil { + return Quote{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return Quote{}, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var parsed yfinanceChartResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return Quote{}, fmt.Errorf("decode response: %w", err) + } + if parsed.Chart.Error != nil { + return Quote{}, fmt.Errorf("yahoo error: %s", parsed.Chart.Error.Description) + } + if len(parsed.Chart.Result) == 0 { + return Quote{}, fmt.Errorf("no data returned") + } + + meta := parsed.Chart.Result[0].Meta + return Quote{ + Symbol: task.display, + Label: task.display, + Price: meta.RegularMarketPrice, + ChangePercent: meta.RegularMarketChangePercent, + Currency: meta.Currency, + Source: "yfinance", + }, nil +} diff --git a/internal/output/discord.go b/internal/output/discord.go new file mode 100644 index 0000000..d54087b --- /dev/null +++ b/internal/output/discord.go @@ -0,0 +1,191 @@ +package output + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/ytshih/stonks/internal/config" + "github.com/ytshih/stonks/internal/report" +) + +func init() { + Register("discord", newDiscordOutput) +} + +// discordMessageLimit is Discord's max character count for a single +// webhook message's `content` field. +const discordMessageLimit = 2000 + +// discordParams is the plugin-specific config for the discord output. +type discordParams struct { + // WebhookEnvVar names the environment variable holding the Discord + // webhook URL. Defaults to DISCORD_WEBHOOK_URL. + WebhookEnvVar string `yaml:"webhook_env_var"` +} + +// discordOutput posts the report to a Discord incoming webhook, splitting it +// into multiple messages if it exceeds Discord's 2000-character limit. +type discordOutput struct { + webhookURL string + client *http.Client +} + +func newDiscordOutput(cfg config.PluginConfig) (Output, error) { + params := discordParams{WebhookEnvVar: "DISCORD_WEBHOOK_URL"} + if err := cfg.DecodeParams(¶ms); err != nil { + return nil, err + } + + url := os.Getenv(params.WebhookEnvVar) + if url == "" { + return nil, fmt.Errorf("discord: environment variable %q is not set", params.WebhookEnvVar) + } + + return &discordOutput{ + webhookURL: url, + client: &http.Client{Timeout: 15 * time.Second}, + }, nil +} + +func (d *discordOutput) Name() string { return "discord" } + +func (d *discordOutput) Send(ctx context.Context, rep report.Report) error { + for i, chunk := range splitForDiscord(rep.Text, discordMessageLimit) { + if err := d.post(ctx, chunk); err != nil { + return fmt.Errorf("discord: send chunk %d: %w", i+1, err) + } + } + + embeds := buildRecommendationEmbeds(rep.Recommendations) + if err := d.postEmbeds(ctx, embeds); err != nil { + return fmt.Errorf("discord: send recommendation embeds: %w", err) + } + return nil +} + +func (d *discordOutput) post(ctx context.Context, content string) error { + return d.postJSON(ctx, map[string]any{"content": content}) +} + +func (d *discordOutput) postEmbeds(ctx context.Context, embeds []map[string]any) error { + return d.postJSON(ctx, map[string]any{"embeds": embeds}) +} + +func (d *discordOutput) postJSON(ctx context.Context, payload map[string]any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.webhookURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// Discord embed colors (decimal RGB). +const ( + colorBuy = 0x57F287 // green + colorSell = 0xED4245 // red +) + +// discordDescriptionLimit is Discord's max character count for a single +// embed's `description`. +const discordDescriptionLimit = 4096 + +// buildRecommendationEmbeds renders the buy and sell recommendations as two +// separate Discord embeds (always both, even when one or both lists are +// empty, so "nothing to report" is stated explicitly rather than omitted). +func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any { + var buyLines, sellLines []string + for _, r := range recs { + line := fmt.Sprintf("**%s** — %s", r.Symbol, r.Reason) + if r.Action == "sell" { + sellLines = append(sellLines, line) + } else { + buyLines = append(buyLines, line) + } + } + + return []map[string]any{ + recommendationEmbed("🟢 Buy", colorBuy, buyLines, "No buy recommendations today."), + recommendationEmbed("🔴 Sell", colorSell, sellLines, "No sell recommendations today."), + } +} + +func recommendationEmbed(title string, color int, lines []string, emptyText string) map[string]any { + description := emptyText + if len(lines) > 0 { + description = truncateForDescription(strings.Join(lines, "\n")) + } + return map[string]any{ + "title": title, + "color": color, + "description": description, + } +} + +func truncateForDescription(s string) string { + if len(s) <= discordDescriptionLimit { + return s + } + return s[:discordDescriptionLimit-3] + "..." +} + +// splitForDiscord breaks text into chunks of at most limit characters, +// preferring to split on paragraph/line boundaries so Markdown formatting +// stays intact across messages. +func splitForDiscord(text string, limit int) []string { + if len(text) <= limit { + return []string{text} + } + + var chunks []string + var current strings.Builder + + flush := func() { + if current.Len() > 0 { + chunks = append(chunks, strings.TrimSpace(current.String())) + current.Reset() + } + } + + for _, line := range strings.Split(text, "\n") { + // A single line longer than the limit must be hard-split. + for len(line) > limit { + if current.Len() > 0 { + flush() + } + chunks = append(chunks, line[:limit]) + line = line[limit:] + } + if current.Len()+len(line)+1 > limit { + flush() + } + current.WriteString(line) + current.WriteString("\n") + } + flush() + + return chunks +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..e32fd50 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,43 @@ +// Package output defines the pluggable destination interface and its +// implementations (currently Discord webhook). +package output + +import ( + "context" + "fmt" + + "github.com/ytshih/stonks/internal/config" + "github.com/ytshih/stonks/internal/report" +) + +// Output delivers the final report somewhere (Discord, Slack, email, ...). +// Implementations decide how to render report.Text and the optional +// report.Recommendations (e.g. Discord sends the text as chunked messages +// followed by a short recommendations embed). +type Output interface { + Name() string + Send(ctx context.Context, rep report.Report) error +} + +// Constructor builds an Output from its PluginConfig (name + params). +type Constructor func(cfg config.PluginConfig) (Output, error) + +var registry = map[string]Constructor{} + +// Register makes an output constructor available under name. Called from +// each output implementation's init(). +func Register(name string, ctor Constructor) { + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("output: constructor already registered for %q", name)) + } + registry[name] = ctor +} + +// Build looks up the constructor for cfg.Name and invokes it. +func Build(cfg config.PluginConfig) (Output, error) { + ctor, ok := registry[cfg.Name] + if !ok { + return nil, fmt.Errorf("output: no output registered for %q", cfg.Name) + } + return ctor(cfg) +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..196d896 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,18 @@ +// Package report defines the shared shape produced by internal/analyze and +// consumed by internal/output, decoupling the two from each other. +package report + +// Recommendation is a single buy/sell call with a short reason, surfaced +// separately from the main narrative report (e.g. as a Discord embed). +type Recommendation struct { + Symbol string `json:"symbol"` + Action string `json:"action"` // "buy" or "sell" + Reason string `json:"reason"` +} + +// Report is the full output of one analysis run: the long-form narrative +// text plus an optional short list of buy/sell recommendations. +type Report struct { + Text string + Recommendations []Recommendation +} diff --git a/systemd/stonks.service b/systemd/stonks.service new file mode 100644 index 0000000..f423578 --- /dev/null +++ b/systemd/stonks.service @@ -0,0 +1,15 @@ +# User-scope systemd unit. Install with: +# mkdir -p ~/.config/systemd/user +# cp systemd/stonks.service systemd/stonks.timer ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now stonks.timer +# loginctl enable-linger "$USER" # so it still runs while logged out +[Unit] +Description=Daily financial news & market analysis +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +EnvironmentFile=%h/.config/stonks/secrets.env +ExecStart=%h/.local/bin/stonks --config %h/.config/stonks/config.yaml diff --git a/systemd/stonks.timer b/systemd/stonks.timer new file mode 100644 index 0000000..724bd01 --- /dev/null +++ b/systemd/stonks.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run stonks every 5 hours + +[Timer] +# Fires at 00:00, 05:00, 10:00, 15:00, 20:00 (wall-clock aligned, so +# Persistent=true has a predictable catch-up time if the machine was off). +OnCalendar=*-*-* 0/5:00:00 +Persistent=true + +[Install] +WantedBy=timers.target