feat: done vibing

This commit is contained in:
ytshih committed 2026-09-05 09:55:24 +08:00
1 parent 3de31a57c3
commit 4352fd1ad1
27 files changed
+1922 -13

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/stonks
config/secrets.env
+82
View File
@@ -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`.
+21 -12
View File
@@ -5,7 +5,7 @@ Daily financial news + market data analysis service. Fetches free-tier market da
## Architecture ## 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. 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`) ## 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. 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 ```ini
# ~/.config/systemd/user/stonks.timer # ~/.config/systemd/user/stonks.timer
[Unit] [Unit]
Description=Run stonks daily Description=Run stonks every 5 hours
[Timer] [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 Persistent=true
[Install] [Install]
@@ -145,7 +152,7 @@ WantedBy=timers.target
systemctl --user enable --now stonks.timer 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 ```sh
loginctl enable-linger "$USER" loginctl enable-linger "$USER"
@@ -169,18 +176,20 @@ Estimated build time: a working v1 (one fetcher, one output, hardcoded targets)
## Cost ## 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 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.050 | $0.031 | ~$0.94 | | 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.075 | $0.047 | ~$1.40 | | Claude Sonnet 5 (standard) | $3.00 | $15.00 | $0.375 | $0.235 | ~$7.05 |
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.025 | $0.016 | ~$0.47 | | 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 ## Open questions / next steps
+64
View File
@@ -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.
-1
View File
@@ -20,4 +20,3 @@ Daily
- AAPL, GOOGL, TSLA, AVGO, NVDA, INTC, AMD, AMZN - AAPL, GOOGL, TSLA, AVGO, NVDA, INTC, AMD, AMZN
- Futures - Futures
- XAU, XAG, Crude Oil - XAU, XAG, Crude Oil
+162
View File
@@ -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)
}
+60
View File
@@ -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
+16
View File
@@ -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=
+26
View File
@@ -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
)
+48
View File
@@ -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=
+82
View File
@@ -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 ""
}
+40
View File
@@ -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)
}
+77
View File
@@ -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
}
+121
View File
@@ -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] + "..."
}
+65
View File
@@ -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]
}
+53
View File
@@ -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
}
+86
View File
@@ -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
}
+132
View File
@@ -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
}
+75
View File
@@ -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)
}
+178
View File
@@ -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(&params); 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)
}
+97
View File
@@ -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(&params); 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)
}
+157
View File
@@ -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
}
+191
View File
@@ -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(&params); 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
}
+43
View File
@@ -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)
}
+18
View File
@@ -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
}
+15
View File
@@ -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
+11
View File
@@ -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