feat: done vibing
This commit is contained in:
27 files changed
+1922
-13
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
/stonks
|
||||||
|
config/secrets.env
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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] + "..."
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user