feat: done vibing

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

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
package analyze
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"sync"
"github.com/ytshih/stonks/internal/config"
)
// AdvisorPreamble is prepended to the shared data prompt when consulting an
// advisor CLI (multi-agent mode) — advisors get a simpler ask than Claude's
// SystemPrompt: an independent take, no recommendations-JSON contract.
const AdvisorPreamble = `You are a financial analyst. Given the following market prices and recent news for a fixed watchlist (crypto, NASDAQ equities, futures, forex), give a concise independent investment analysis: what stands out, and any assets you'd flag as a buy or sell with a one-sentence reason each. Plain text, under 400 words.`
// AdvisorResult is one advisor CLI's outcome. Err is set (and Output empty)
// on failure — callers should log it and simply omit this advisor from the
// final prompt, not fail the run.
type AdvisorResult struct {
Name string
Output string
Err error
}
// RunAdvisors runs every configured advisor CLI concurrently, piping
// AdvisorPreamble + dataPrompt to each one's stdin, and returns one result
// per advisor in the same order as cfg.
func RunAdvisors(ctx context.Context, advisors []config.AdvisorConfig, dataPrompt string) []AdvisorResult {
prompt := AdvisorPreamble + "\n\n" + dataPrompt
results := make([]AdvisorResult, len(advisors))
var wg sync.WaitGroup
for i, adv := range advisors {
wg.Add(1)
go func(i int, adv config.AdvisorConfig) {
defer wg.Done()
out, err := runAdvisorCLI(ctx, adv, prompt)
results[i] = AdvisorResult{Name: adv.Name, Output: out, Err: err}
}(i, adv)
}
wg.Wait()
return results
}
func runAdvisorCLI(ctx context.Context, adv config.AdvisorConfig, prompt string) (string, error) {
if _, err := exec.LookPath(adv.Command); err != nil {
return "", fmt.Errorf("command %q not found on PATH: %w", adv.Command, err)
}
cmd := exec.CommandContext(ctx, adv.Command, adv.Args...)
cmd.Stdin = strings.NewReader(prompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%s failed: %w (stderr: %s)", adv.Command, err, lastLine(stderr.String()))
}
out := strings.TrimSpace(stdout.String())
if out == "" {
return "", fmt.Errorf("%s produced no output (stderr: %s)", adv.Command, lastLine(stderr.String()))
}
return out, nil
}
// lastLine returns the last non-empty line of s, for a compact one-line
// error summary out of what can be many lines of CLI log noise.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return line
}
}
return ""
}
+40
View File
@@ -0,0 +1,40 @@
// Package analyze turns fetched market data + news into an investment-advice
// report via Claude, using one of two interchangeable modes: "subscription"
// (shells out to the `claude` CLI, billed against a Claude Pro/Max
// subscription) or "api" (calls the Messages API directly, billed per token).
package analyze
import (
"context"
"fmt"
"github.com/ytshih/stonks/internal/config"
)
// Analyzer sends a prompt to Claude and returns the response text.
type Analyzer interface {
Analyze(ctx context.Context, prompt string) (string, error)
}
// Constructor builds an Analyzer from the analyzer config block.
type Constructor func(cfg config.AnalyzerConfig) (Analyzer, error)
var registry = map[string]Constructor{}
// Register makes an analyzer constructor available under mode. Called from
// each mode implementation's init().
func Register(mode string, ctor Constructor) {
if _, exists := registry[mode]; exists {
panic(fmt.Sprintf("analyze: constructor already registered for mode %q", mode))
}
registry[mode] = ctor
}
// Build looks up the constructor for cfg.Mode and invokes it.
func Build(cfg config.AnalyzerConfig) (Analyzer, error) {
ctor, ok := registry[cfg.Mode]
if !ok {
return nil, fmt.Errorf("analyze: no analyzer registered for mode %q", cfg.Mode)
}
return ctor(cfg)
}
+77
View File
@@ -0,0 +1,77 @@
package analyze
import (
"context"
"fmt"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/ytshih/stonks/internal/config"
)
func init() {
Register("api", newAPIAnalyzer)
}
const defaultAPIModel = anthropic.ModelClaudeOpus5
// apiAnalyzer calls the Claude Messages API directly, billed per token.
// Reads credentials the standard way (ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
// / an `ant auth login` profile) via the zero-arg client constructor.
type apiAnalyzer struct {
client anthropic.Client
model anthropic.Model
effort anthropic.OutputConfigEffort
}
func newAPIAnalyzer(cfg config.AnalyzerConfig) (Analyzer, error) {
model := anthropic.Model(cfg.Model)
if cfg.Model == "" {
model = defaultAPIModel
}
effort := anthropic.OutputConfigEffortMedium
if cfg.Effort != "" {
effort = anthropic.OutputConfigEffort(cfg.Effort)
}
return &apiAnalyzer{
client: anthropic.NewClient(),
model: model,
effort: effort,
}, nil
}
func (a *apiAnalyzer) Analyze(ctx context.Context, prompt string) (string, error) {
resp, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: a.model,
MaxTokens: 4096,
System: []anthropic.TextBlockParam{{Text: SystemPrompt}},
OutputConfig: anthropic.OutputConfigParam{
Effort: a.effort,
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
})
if err != nil {
return "", fmt.Errorf("analyze (api mode): %w", err)
}
if resp.StopReason == anthropic.StopReasonRefusal {
return "", fmt.Errorf("analyze (api mode): request refused by safety classifiers")
}
var out strings.Builder
for _, block := range resp.Content {
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
out.WriteString(text.Text)
}
}
if out.Len() == 0 {
return "", fmt.Errorf("analyze (api mode): response contained no text content (stop_reason=%s)", resp.StopReason)
}
return out.String(), nil
}
+121
View File
@@ -0,0 +1,121 @@
package analyze
import (
"fmt"
"sort"
"strings"
"time"
"github.com/ytshih/stonks/internal/config"
"github.com/ytshih/stonks/internal/fetch"
)
// SystemPrompt instructs Claude how to analyze the fetched data and format
// its response for posting to Discord.
const SystemPrompt = `You are a financial analyst producing a concise daily briefing for a personal Discord channel.
You will be given today's prices (with percent change) and recent news headlines for a fixed watchlist of cryptocurrencies, NASDAQ equities, futures (metals, crude oil), and forex pairs.
Structure your response in Markdown, formatted for Discord (short lines, ` + "`**bold**`" + ` section headers, no HTML, no huge tables):
1. A one-paragraph market-wide summary.
2. A "Crypto" section: brief commentary per asset grounded in the given price/news data.
3. A "NASDAQ Equities" section: same, grouped sensibly (e.g. by sector) if useful.
4. A "Futures" section (gold, silver, crude oil).
5. A "Forex" section covering the given currency pairs.
6. A short "Watch" section flagging anything time-sensitive (earnings, macro events implied by the news) for the next day or two.
Keep the whole message under roughly 1500 words. Ground every claim in the provided data — do not fabricate news or prices. If data for an asset is missing, say so briefly rather than inventing a view. End with a one-line disclaimer that this is not financial advice.
If the user turn includes a section titled "Other AI Analysts' Independent Takes", those are additional perspectives from other AI models given the same data — not ground truth and not your own reasoning. Weigh them alongside the price/news data, note explicitly where you agree or disagree and why, but reach your own independent conclusion. Do not simply restate their conclusions.
After the full briefing above, on its own line, output exactly:
===RECOMMENDATIONS_JSON===
followed immediately by a single JSON object (no markdown code fence, no commentary before or after it) with this exact shape:
{"recommendations": [{"symbol": "AAPL", "action": "buy", "reason": "one short sentence"}]}
"action" must be exactly "buy" or "sell". Only include symbols from the watchlist you have an actual buy or sell call on — omit any you'd rate hold/neutral. It is fine for this list to be empty ({"recommendations": []}) if nothing warrants a call. This JSON must be the very last thing in your response.`
// BuildUserPrompt renders the fetched result into the user-turn text sent
// alongside SystemPrompt.
func BuildUserPrompt(targets config.Targets, result fetch.Result) string {
var b strings.Builder
fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02"))
b.WriteString("## Prices\n\n")
writeQuoteSection(&b, "Crypto", targets.Crypto, result.Quotes)
writeQuoteSection(&b, "NASDAQ Equities", targets.Equities, result.Quotes)
futureSymbols := make([]string, 0, len(targets.Futures))
for _, symbol := range targets.Futures {
futureSymbols = append(futureSymbols, symbol)
}
sort.Strings(futureSymbols)
writeQuoteSection(&b, "Futures", futureSymbols, result.Quotes)
writeQuoteSection(&b, "Forex", targets.Forex, result.Quotes)
b.WriteString("\n## Recent News\n\n")
if len(result.News) == 0 {
b.WriteString("(no news items fetched)\n")
}
for _, n := range result.News {
fmt.Fprintf(&b, "- [%s] %s", n.Source, n.Title)
if !n.Published.IsZero() {
fmt.Fprintf(&b, " (%s)", n.Published.Format("2006-01-02"))
}
b.WriteString("\n")
if n.Summary != "" {
fmt.Fprintf(&b, " %s\n", truncate(n.Summary, 300))
}
}
return b.String()
}
// AppendAdvisorSection appends successful advisor outputs (see
// RunAdvisors) to prompt as an "Other AI Analysts' Independent Takes"
// section, one subsection per advisor. Advisors that errored are skipped —
// callers should log those separately. Returns prompt unchanged if there
// are no successful results, so it's safe to call unconditionally.
func AppendAdvisorSection(prompt string, results []AdvisorResult) string {
var b strings.Builder
for _, r := range results {
if r.Err != nil || r.Output == "" {
continue
}
fmt.Fprintf(&b, "### %s\n\n%s\n\n", r.Name, r.Output)
}
if b.Len() == 0 {
return prompt
}
return prompt + "\n## Other AI Analysts' Independent Takes\n\n" + b.String()
}
func writeQuoteSection(b *strings.Builder, heading string, symbols []string, quotes []fetch.Quote) {
fmt.Fprintf(b, "### %s\n\n", heading)
bySymbol := map[string][]fetch.Quote{}
for _, q := range quotes {
bySymbol[q.Symbol] = append(bySymbol[q.Symbol], q)
}
for _, sym := range symbols {
qs, ok := bySymbol[sym]
if !ok || len(qs) == 0 {
fmt.Fprintf(b, "- %s: no data available\n", sym)
continue
}
for _, q := range qs {
fmt.Fprintf(b, "- %s (%s) [%s]: %.2f %s (%+.2f%%)\n",
sym, q.Label, q.Source, q.Price, q.Currency, q.ChangePercent)
}
}
b.WriteString("\n")
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+65
View File
@@ -0,0 +1,65 @@
package analyze
import (
"encoding/json"
"fmt"
"strings"
"github.com/ytshih/stonks/internal/report"
)
// recommendationsMarker must match the marker text SystemPrompt instructs
// Claude to emit before the trailing recommendations JSON block.
const recommendationsMarker = "===RECOMMENDATIONS_JSON==="
type recommendationsPayload struct {
Recommendations []report.Recommendation `json:"recommendations"`
}
// SplitReport separates Claude's raw response into the long-form narrative
// text and the short buy/sell recommendations list appended after
// recommendationsMarker (see SystemPrompt).
//
// If the marker is missing or the trailing JSON fails to parse, the full raw
// text is returned as-is with a nil recommendations slice, plus a non-nil
// error — callers should log it as a warning and still send the text-only
// report rather than treat this as fatal.
func SplitReport(raw string) (report.Report, error) {
idx := strings.Index(raw, recommendationsMarker)
if idx == -1 {
return report.Report{Text: strings.TrimSpace(raw)},
fmt.Errorf("recommendations marker not found in response")
}
text := strings.TrimSpace(raw[:idx])
jsonPart := extractJSONObject(raw[idx+len(recommendationsMarker):])
var payload recommendationsPayload
if err := json.Unmarshal([]byte(jsonPart), &payload); err != nil {
return report.Report{Text: text}, fmt.Errorf("parse recommendations json: %w", err)
}
for _, r := range payload.Recommendations {
if r.Action != "buy" && r.Action != "sell" {
return report.Report{Text: text}, fmt.Errorf("recommendation for %q has invalid action %q", r.Symbol, r.Action)
}
}
return report.Report{Text: text, Recommendations: payload.Recommendations}, nil
}
// extractJSONObject pulls out the substring from the first "{" to the last
// "}" in s. This tolerates any decoration Claude adds around the JSON
// despite being asked not to — a ```json fence, a stray leading backtick, a
// trailing sentence — since none of that can contain a brace that comes
// before the real opening one or after the real closing one. Returns s
// trimmed, unchanged, if no braces are found (json.Unmarshal will then fail
// with a clear error).
func extractJSONObject(s string) string {
start := strings.IndexByte(s, '{')
end := strings.LastIndexByte(s, '}')
if start == -1 || end == -1 || end < start {
return strings.TrimSpace(s)
}
return s[start : end+1]
}
+53
View File
@@ -0,0 +1,53 @@
package analyze
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/ytshih/stonks/internal/config"
)
func init() {
Register("subscription", newSubscriptionAnalyzer)
}
// subscriptionAnalyzer shells out to the `claude` CLI in headless/print mode
// (`claude -p`), using whatever session `claude login` established on this
// host. Usage is billed against that Claude Pro/Max subscription's
// rate limits rather than a separate per-token API bill.
//
// The CLI has no separate "system prompt" flag documented for scripting, so
// the system and user prompts are concatenated and piped in over stdin —
// the most robust interface regardless of CLI version.
type subscriptionAnalyzer struct{}
func newSubscriptionAnalyzer(cfg config.AnalyzerConfig) (Analyzer, error) {
if _, err := exec.LookPath("claude"); err != nil {
return nil, fmt.Errorf("analyze (subscription mode): `claude` CLI not found on PATH — install Claude Code and run `claude login`: %w", err)
}
return &subscriptionAnalyzer{}, nil
}
func (a *subscriptionAnalyzer) Analyze(ctx context.Context, prompt string) (string, error) {
fullPrompt := SystemPrompt + "\n\n---\n\n" + prompt
cmd := exec.CommandContext(ctx, "claude", "-p")
cmd.Stdin = strings.NewReader(fullPrompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("analyze (subscription mode): claude CLI failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
}
out := strings.TrimSpace(stdout.String())
if out == "" {
return "", fmt.Errorf("analyze (subscription mode): claude CLI produced no output (stderr: %s)", strings.TrimSpace(stderr.String()))
}
return out, nil
}
+86
View File
@@ -0,0 +1,86 @@
// Package archive optionally saves a local copy of each run's fetched data
// and analysis, for debugging prompt/parsing issues without reproducing a
// live run.
package archive
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ytshih/stonks/internal/config"
"github.com/ytshih/stonks/internal/fetch"
"github.com/ytshih/stonks/internal/report"
)
const defaultDir = "~/.local/state/stonks/runs"
// Run holds everything one pipeline run produced, for archiving.
type Run struct {
Result fetch.Result
Prompt string // final prompt sent to the analyzer, including any advisor section
RawResponse string // analyzer's raw, unparsed response
Report report.Report
}
// Save writes Run's artifacts to a new timestamped subdirectory under
// cfg.Dir (or defaultDir if cfg.Dir is empty) and returns that directory's
// path. Callers should treat a non-nil error as non-fatal — archiving is a
// debugging aid, not part of the report pipeline.
func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) {
base := cfg.Dir
if base == "" {
base = defaultDir
}
base, err := expandHome(base)
if err != nil {
return "", fmt.Errorf("archive: %w", err)
}
dir := filepath.Join(base, now.UTC().Format("20060102T150405Z"))
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("archive: create %q: %w", dir, err)
}
fetchedJSON, err := json.MarshalIndent(run.Result, "", " ")
if err != nil {
return "", fmt.Errorf("archive: marshal fetched data: %w", err)
}
recsJSON, err := json.MarshalIndent(run.Report.Recommendations, "", " ")
if err != nil {
return "", fmt.Errorf("archive: marshal recommendations: %w", err)
}
files := map[string][]byte{
"fetched.json": fetchedJSON,
"prompt.txt": []byte(run.Prompt),
"analysis_raw.md": []byte(run.RawResponse),
"report.md": []byte(run.Report.Text),
"recommendations.json": recsJSON,
}
for name, data := range files {
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
return dir, fmt.Errorf("archive: write %q: %w", name, err)
}
}
return dir, nil
}
// expandHome replaces a leading "~" with the current user's home directory.
func expandHome(path string) (string, error) {
if path != "~" && !strings.HasPrefix(path, "~/") {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
if path == "~" {
return home, nil
}
return filepath.Join(home, path[2:]), nil
}
+132
View File
@@ -0,0 +1,132 @@
// Package config loads and represents stonks' YAML configuration.
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Targets lists the assets to fetch data for, grouped by asset class.
type Targets struct {
Crypto []string `yaml:"crypto"`
Equities []string `yaml:"equities"`
Futures map[string]string `yaml:"futures"`
// Forex holds currency pairs as bare base+quote codes (e.g. "USDTWD"),
// without any provider-specific suffix — fetchers append whatever
// suffix their API needs (Yahoo Finance uses "=X").
Forex []string `yaml:"forex"`
}
// PluginConfig is the config for one named, registered fetcher or output.
// Params is decoded lazily via DecodeParams so each plugin owns its own
// params shape without the top-level config needing to know it.
type PluginConfig struct {
Name string `yaml:"name"`
Params map[string]any `yaml:"params"`
}
// DecodeParams re-marshals Params into out, which should be a pointer to a
// plugin-specific params struct with `yaml` tags.
func (p PluginConfig) DecodeParams(out any) error {
if p.Params == nil {
return nil
}
raw, err := yaml.Marshal(p.Params)
if err != nil {
return fmt.Errorf("marshal params for %q: %w", p.Name, err)
}
if err := yaml.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decode params for %q: %w", p.Name, err)
}
return nil
}
// AnalyzerConfig configures which Claude invocation mode to use.
type AnalyzerConfig struct {
// Mode is "subscription" (shells out to the `claude` CLI, billed
// against a Claude Pro/Max subscription) or "api" (uses ANTHROPIC_API_KEY
// via the Messages API, billed per token).
Mode string `yaml:"mode"`
// Model is only used in "api" mode.
Model string `yaml:"model"`
// Effort is only used in "api" mode: low|medium|high|xhigh|max.
Effort string `yaml:"effort"`
// Advisors are additional CLI-based AI analysts (e.g. gemini, codex)
// consulted before the final Claude call — multi-agent mode. Their
// output is appended to Claude's prompt as extra context; Claude still
// produces the one final report and recommendations. Empty/omitted
// disables multi-agent mode entirely.
Advisors []AdvisorConfig `yaml:"advisors"`
}
// AdvisorConfig defines one additional CLI-based AI analyst consulted in
// multi-agent mode. The same fetched-data prompt is piped to its stdin;
// Command must be a non-interactive/scriptable invocation (e.g. `gemini`
// reads a piped prompt from stdin one-shot; `codex exec -` explicitly reads
// from stdin).
type AdvisorConfig struct {
// Name labels this advisor's section in Claude's final prompt (e.g. "Gemini").
Name string `yaml:"name"`
// Command is the CLI binary to invoke (looked up on PATH).
Command string `yaml:"command"`
// Args are extra arguments passed before the prompt is piped via stdin.
Args []string `yaml:"args"`
}
// ArchiveConfig controls saving a local copy of each run's fetched data and
// analysis — useful for debugging prompt/parsing issues without needing to
// reproduce a live run.
type ArchiveConfig struct {
Enabled bool `yaml:"enabled"`
// Dir is the directory each run's files are written under, one
// timestamped subdirectory per run. Supports a leading "~". Defaults to
// "~/.local/state/stonks/runs" if enabled with Dir left empty.
Dir string `yaml:"dir"`
}
// Config is the full stonks configuration.
type Config struct {
Targets Targets `yaml:"targets"`
Fetchers []PluginConfig `yaml:"fetchers"`
Analyzer AnalyzerConfig `yaml:"analyzer"`
Outputs []PluginConfig `yaml:"outputs"`
Archive ArchiveConfig `yaml:"archive"`
}
// Load reads and parses the config file at path.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid config %q: %w", path, err)
}
return &cfg, nil
}
func (c *Config) validate() error {
switch c.Analyzer.Mode {
case "subscription", "api":
case "":
return fmt.Errorf("analyzer.mode is required (\"subscription\" or \"api\")")
default:
return fmt.Errorf("analyzer.mode %q must be \"subscription\" or \"api\"", c.Analyzer.Mode)
}
if len(c.Fetchers) == 0 {
return fmt.Errorf("at least one fetcher must be configured")
}
if len(c.Outputs) == 0 {
return fmt.Errorf("at least one output must be configured")
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
// Package fetch defines the pluggable data-source interface and its
// implementations (yfinance-equivalent prices, Finnhub, RSS news).
package fetch
import (
"context"
"fmt"
"time"
"github.com/ytshih/stonks/internal/config"
)
// Quote is a single point-in-time price observation for one symbol.
type Quote struct {
Symbol string // ticker as passed to the fetcher, e.g. "AAPL", "BTC-USD", "GC=F"
Label string // human-friendly name, e.g. "Apple Inc."
Price float64
ChangePercent float64 // percent change over the source's default window (usually 1 day)
Currency string
Source string // which fetcher produced this, e.g. "yfinance", "finnhub"
}
// NewsItem is a single headline/article.
type NewsItem struct {
Title string
Source string // publication or fetcher name
URL string
Published time.Time
Summary string
}
// Result is what one fetcher call returns. The orchestrator merges Results
// from every configured fetcher before handing them to the analyzer.
type Result struct {
Quotes []Quote
News []NewsItem
}
// Merge appends other's quotes and news onto r.
func (r *Result) Merge(other Result) {
r.Quotes = append(r.Quotes, other.Quotes...)
r.News = append(r.News, other.News...)
}
// Fetcher fetches market data and/or news for the given targets.
// Implementations should return a partial Result plus an error when only
// some data could be retrieved, rather than nothing at all — the caller
// decides whether a partial result is still worth reporting.
type Fetcher interface {
Name() string
Fetch(ctx context.Context, targets config.Targets) (Result, error)
}
// Constructor builds a Fetcher from its PluginConfig (name + params).
type Constructor func(cfg config.PluginConfig) (Fetcher, error)
var registry = map[string]Constructor{}
// Register makes a fetcher constructor available under name. Called from
// each fetcher implementation's init().
func Register(name string, ctor Constructor) {
if _, exists := registry[name]; exists {
panic(fmt.Sprintf("fetch: constructor already registered for %q", name))
}
registry[name] = ctor
}
// Build looks up the constructor for cfg.Name and invokes it.
func Build(cfg config.PluginConfig) (Fetcher, error) {
ctor, ok := registry[cfg.Name]
if !ok {
return nil, fmt.Errorf("fetch: no fetcher registered for %q", cfg.Name)
}
return ctor(cfg)
}
+178
View File
@@ -0,0 +1,178 @@
package fetch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
"github.com/ytshih/stonks/internal/config"
)
func init() {
Register("finnhub", newFinnhubFetcher)
}
const finnhubBaseURL = "https://finnhub.io/api/v1"
// finnhubParams is the plugin-specific config for the finnhub fetcher.
type finnhubParams struct {
// APIKeyEnvVar names the environment variable holding the Finnhub API
// key. Defaults to FINNHUB_API_KEY.
APIKeyEnvVar string `yaml:"api_key_env_var"`
// NewsLookbackDays controls how far back company-news headlines are
// pulled. Defaults to 1 (yesterday through today).
NewsLookbackDays int `yaml:"news_lookback_days"`
}
// finnhubFetcher fetches equity quotes and per-ticker company news from
// Finnhub's free tier (documented, key-based — a more stable second price
// source than the unofficial yfinance-equivalent fetcher). Finnhub's free
// tier does not cover crypto or futures symbols, so this fetcher only
// covers targets.Equities.
type finnhubFetcher struct {
apiKey string
newsLookbackDays int
client *http.Client
}
func newFinnhubFetcher(cfg config.PluginConfig) (Fetcher, error) {
params := finnhubParams{
APIKeyEnvVar: "FINNHUB_API_KEY",
NewsLookbackDays: 1,
}
if err := cfg.DecodeParams(&params); err != nil {
return nil, err
}
apiKey := os.Getenv(params.APIKeyEnvVar)
if apiKey == "" {
return nil, fmt.Errorf("finnhub: environment variable %q is not set", params.APIKeyEnvVar)
}
return &finnhubFetcher{
apiKey: apiKey,
newsLookbackDays: params.NewsLookbackDays,
client: &http.Client{Timeout: 15 * time.Second},
}, nil
}
func (f *finnhubFetcher) Name() string { return "finnhub" }
func (f *finnhubFetcher) Fetch(ctx context.Context, targets config.Targets) (Result, error) {
var result Result
var errs []error
for _, symbol := range targets.Equities {
q, err := f.fetchQuote(ctx, symbol)
if err != nil {
errs = append(errs, fmt.Errorf("finnhub quote %s: %w", symbol, err))
continue
}
result.Quotes = append(result.Quotes, q)
news, err := f.fetchCompanyNews(ctx, symbol)
if err != nil {
errs = append(errs, fmt.Errorf("finnhub news %s: %w", symbol, err))
continue
}
result.News = append(result.News, news...)
}
return result, joinErrs(errs)
}
type finnhubQuoteResponse struct {
Current float64 `json:"c"`
Change float64 `json:"d"`
PercentChange float64 `json:"dp"`
}
func (f *finnhubFetcher) fetchQuote(ctx context.Context, symbol string) (Quote, error) {
var resp finnhubQuoteResponse
if err := f.get(ctx, "/quote", url.Values{"symbol": {symbol}}, &resp); err != nil {
return Quote{}, err
}
if resp.Current == 0 {
return Quote{}, fmt.Errorf("no data returned (symbol may be invalid or unsupported on this tier)")
}
return Quote{
Symbol: symbol,
Label: symbol,
Price: resp.Current,
ChangePercent: resp.PercentChange,
Currency: "USD",
Source: "finnhub",
}, nil
}
type finnhubNewsItem struct {
Datetime int64 `json:"datetime"`
Headline string `json:"headline"`
Source string `json:"source"`
Summary string `json:"summary"`
URL string `json:"url"`
}
func (f *finnhubFetcher) fetchCompanyNews(ctx context.Context, symbol string) ([]NewsItem, error) {
to := time.Now().UTC()
from := to.AddDate(0, 0, -f.newsLookbackDays)
var resp []finnhubNewsItem
params := url.Values{
"symbol": {symbol},
"from": {from.Format("2006-01-02")},
"to": {to.Format("2006-01-02")},
}
if err := f.get(ctx, "/company-news", params, &resp); err != nil {
return nil, err
}
items := make([]NewsItem, 0, len(resp))
for _, n := range resp {
items = append(items, NewsItem{
Title: fmt.Sprintf("[%s] %s", symbol, n.Headline),
Source: n.Source,
URL: n.URL,
Published: time.Unix(n.Datetime, 0).UTC(),
Summary: n.Summary,
})
}
return items, nil
}
func (f *finnhubFetcher) get(ctx context.Context, path string, params url.Values, out any) error {
params.Set("token", f.apiKey)
reqURL := finnhubBaseURL + path + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return err
}
resp, err := f.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// joinErrs combines multiple errors into one, or returns nil if errs is empty.
func joinErrs(errs []error) error {
if len(errs) == 0 {
return nil
}
msg := fmt.Sprintf("%d error(s) occurred:", len(errs))
for _, e := range errs {
msg += "\n - " + e.Error()
}
return fmt.Errorf("%s", msg)
}
+97
View File
@@ -0,0 +1,97 @@
package fetch
import (
"context"
"fmt"
"time"
"github.com/mmcdole/gofeed"
"github.com/ytshih/stonks/internal/config"
)
func init() {
Register("rss", newRSSFetcher)
}
// rssParams is the plugin-specific config for the rss fetcher.
type rssParams struct {
Feeds []string `yaml:"feeds"`
// MaxItemsPerFeed caps how many of the newest entries are kept per feed.
// Defaults to 10.
MaxItemsPerFeed int `yaml:"max_items_per_feed"`
// MaxAgeHours drops entries older than this many hours. Defaults to 48.
// Feeds with entries lacking a published date are kept regardless.
MaxAgeHours int `yaml:"max_age_hours"`
}
// rssFetcher pulls general market news headlines from a configured list of
// RSS feeds. Free, no API key.
type rssFetcher struct {
feeds []string
maxItemsPerFeed int
maxAge time.Duration
parser *gofeed.Parser
}
func newRSSFetcher(cfg config.PluginConfig) (Fetcher, error) {
params := rssParams{
MaxItemsPerFeed: 10,
MaxAgeHours: 48,
}
if err := cfg.DecodeParams(&params); err != nil {
return nil, err
}
if len(params.Feeds) == 0 {
return nil, fmt.Errorf("rss: at least one feed URL must be configured")
}
return &rssFetcher{
feeds: params.Feeds,
maxItemsPerFeed: params.MaxItemsPerFeed,
maxAge: time.Duration(params.MaxAgeHours) * time.Hour,
parser: gofeed.NewParser(),
}, nil
}
func (f *rssFetcher) Name() string { return "rss" }
func (f *rssFetcher) Fetch(ctx context.Context, _ config.Targets) (Result, error) {
var result Result
var errs []error
cutoff := time.Now().Add(-f.maxAge)
for _, feedURL := range f.feeds {
feed, err := f.parser.ParseURLWithContext(feedURL, ctx)
if err != nil {
errs = append(errs, fmt.Errorf("rss %s: %w", feedURL, err))
continue
}
count := 0
for _, item := range feed.Items {
if count >= f.maxItemsPerFeed {
break
}
if item.PublishedParsed != nil && item.PublishedParsed.Before(cutoff) {
continue
}
var published time.Time
if item.PublishedParsed != nil {
published = *item.PublishedParsed
}
result.News = append(result.News, NewsItem{
Title: item.Title,
Source: feed.Title,
URL: item.Link,
Published: published,
Summary: item.Description,
})
count++
}
}
return result, joinErrs(errs)
}
+157
View File
@@ -0,0 +1,157 @@
package fetch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/ytshih/stonks/internal/config"
)
func init() {
Register("yfinance", newYFinanceFetcher)
}
// Yahoo's older batch quote endpoint (/v6/finance/quote, used by the
// unmaintained piquette/finance-go library) has since been locked behind
// auth (401/404 without a session crumb). The per-symbol chart endpoint
// below is still open and unauthenticated as of writing, and covers
// crypto, equities, and futures alike via the same shape — so this fetcher
// talks to it directly instead of depending on a library hitting a dead
// endpoint.
const yfinanceChartURLTemplate = "https://query1.finance.yahoo.com/v8/finance/chart/%s"
// yfinanceFetcher fetches prices from Yahoo Finance's public chart API —
// the same unofficial endpoint Python's yfinance scrapes. No API key
// required, but it does require a browser-like User-Agent or Yahoo
// returns 429.
type yfinanceFetcher struct {
client *http.Client
}
func newYFinanceFetcher(cfg config.PluginConfig) (Fetcher, error) {
return &yfinanceFetcher{
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}
func (f *yfinanceFetcher) Name() string { return "yfinance" }
// yfinanceTask pairs the symbol used to query Yahoo (query) with the plain
// symbol shown in the report and matched against config.Targets (display).
// These differ for forex, where Yahoo requires a "=X" suffix (e.g.
// "USDTWD=X") that config and the rest of the pipeline never see.
type yfinanceTask struct {
query string
display string
}
func (f *yfinanceFetcher) Fetch(ctx context.Context, targets config.Targets) (Result, error) {
tasks := make([]yfinanceTask, 0, len(targets.Crypto)+len(targets.Equities)+len(targets.Futures)+len(targets.Forex))
for _, s := range targets.Crypto {
tasks = append(tasks, yfinanceTask{query: s, display: s})
}
for _, s := range targets.Equities {
tasks = append(tasks, yfinanceTask{query: s, display: s})
}
for _, ticker := range targets.Futures {
tasks = append(tasks, yfinanceTask{query: ticker, display: ticker})
}
for _, s := range targets.Forex {
tasks = append(tasks, yfinanceTask{query: s + "=X", display: s})
}
if len(tasks) == 0 {
return Result{}, nil
}
type outcome struct {
quote Quote
err error
}
outcomes := make([]outcome, len(tasks))
var wg sync.WaitGroup
for i, task := range tasks {
wg.Add(1)
go func(i int, task yfinanceTask) {
defer wg.Done()
q, err := f.fetchOne(ctx, task)
outcomes[i] = outcome{quote: q, err: err}
}(i, task)
}
wg.Wait()
var result Result
var errs []error
for i, o := range outcomes {
if o.err != nil {
errs = append(errs, fmt.Errorf("%s: %w", tasks[i].display, o.err))
continue
}
result.Quotes = append(result.Quotes, o.quote)
}
return result, joinErrs(errs)
}
type yfinanceChartResponse struct {
Chart struct {
Result []struct {
Meta struct {
Symbol string `json:"symbol"`
Currency string `json:"currency"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
RegularMarketChangePercent float64 `json:"regularMarketChangePercent"`
} `json:"meta"`
} `json:"result"`
Error *struct {
Description string `json:"description"`
} `json:"error"`
} `json:"chart"`
}
func (f *yfinanceFetcher) fetchOne(ctx context.Context, task yfinanceTask) (Quote, error) {
reqURL := fmt.Sprintf(yfinanceChartURLTemplate, url.PathEscape(task.query))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return Quote{}, err
}
// Yahoo returns 429 for requests with no browser-like User-Agent.
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; stonks/1.0)")
resp, err := f.client.Do(req)
if err != nil {
return Quote{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Quote{}, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
var parsed yfinanceChartResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return Quote{}, fmt.Errorf("decode response: %w", err)
}
if parsed.Chart.Error != nil {
return Quote{}, fmt.Errorf("yahoo error: %s", parsed.Chart.Error.Description)
}
if len(parsed.Chart.Result) == 0 {
return Quote{}, fmt.Errorf("no data returned")
}
meta := parsed.Chart.Result[0].Meta
return Quote{
Symbol: task.display,
Label: task.display,
Price: meta.RegularMarketPrice,
ChangePercent: meta.RegularMarketChangePercent,
Currency: meta.Currency,
Source: "yfinance",
}, nil
}
+191
View File
@@ -0,0 +1,191 @@
package output
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/ytshih/stonks/internal/config"
"github.com/ytshih/stonks/internal/report"
)
func init() {
Register("discord", newDiscordOutput)
}
// discordMessageLimit is Discord's max character count for a single
// webhook message's `content` field.
const discordMessageLimit = 2000
// discordParams is the plugin-specific config for the discord output.
type discordParams struct {
// WebhookEnvVar names the environment variable holding the Discord
// webhook URL. Defaults to DISCORD_WEBHOOK_URL.
WebhookEnvVar string `yaml:"webhook_env_var"`
}
// discordOutput posts the report to a Discord incoming webhook, splitting it
// into multiple messages if it exceeds Discord's 2000-character limit.
type discordOutput struct {
webhookURL string
client *http.Client
}
func newDiscordOutput(cfg config.PluginConfig) (Output, error) {
params := discordParams{WebhookEnvVar: "DISCORD_WEBHOOK_URL"}
if err := cfg.DecodeParams(&params); err != nil {
return nil, err
}
url := os.Getenv(params.WebhookEnvVar)
if url == "" {
return nil, fmt.Errorf("discord: environment variable %q is not set", params.WebhookEnvVar)
}
return &discordOutput{
webhookURL: url,
client: &http.Client{Timeout: 15 * time.Second},
}, nil
}
func (d *discordOutput) Name() string { return "discord" }
func (d *discordOutput) Send(ctx context.Context, rep report.Report) error {
for i, chunk := range splitForDiscord(rep.Text, discordMessageLimit) {
if err := d.post(ctx, chunk); err != nil {
return fmt.Errorf("discord: send chunk %d: %w", i+1, err)
}
}
embeds := buildRecommendationEmbeds(rep.Recommendations)
if err := d.postEmbeds(ctx, embeds); err != nil {
return fmt.Errorf("discord: send recommendation embeds: %w", err)
}
return nil
}
func (d *discordOutput) post(ctx context.Context, content string) error {
return d.postJSON(ctx, map[string]any{"content": content})
}
func (d *discordOutput) postEmbeds(ctx context.Context, embeds []map[string]any) error {
return d.postJSON(ctx, map[string]any{"embeds": embeds})
}
func (d *discordOutput) postJSON(ctx context.Context, payload map[string]any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.webhookURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// Discord embed colors (decimal RGB).
const (
colorBuy = 0x57F287 // green
colorSell = 0xED4245 // red
)
// discordDescriptionLimit is Discord's max character count for a single
// embed's `description`.
const discordDescriptionLimit = 4096
// buildRecommendationEmbeds renders the buy and sell recommendations as two
// separate Discord embeds (always both, even when one or both lists are
// empty, so "nothing to report" is stated explicitly rather than omitted).
func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
var buyLines, sellLines []string
for _, r := range recs {
line := fmt.Sprintf("**%s** — %s", r.Symbol, r.Reason)
if r.Action == "sell" {
sellLines = append(sellLines, line)
} else {
buyLines = append(buyLines, line)
}
}
return []map[string]any{
recommendationEmbed("🟢 Buy", colorBuy, buyLines, "No buy recommendations today."),
recommendationEmbed("🔴 Sell", colorSell, sellLines, "No sell recommendations today."),
}
}
func recommendationEmbed(title string, color int, lines []string, emptyText string) map[string]any {
description := emptyText
if len(lines) > 0 {
description = truncateForDescription(strings.Join(lines, "\n"))
}
return map[string]any{
"title": title,
"color": color,
"description": description,
}
}
func truncateForDescription(s string) string {
if len(s) <= discordDescriptionLimit {
return s
}
return s[:discordDescriptionLimit-3] + "..."
}
// splitForDiscord breaks text into chunks of at most limit characters,
// preferring to split on paragraph/line boundaries so Markdown formatting
// stays intact across messages.
func splitForDiscord(text string, limit int) []string {
if len(text) <= limit {
return []string{text}
}
var chunks []string
var current strings.Builder
flush := func() {
if current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
current.Reset()
}
}
for _, line := range strings.Split(text, "\n") {
// A single line longer than the limit must be hard-split.
for len(line) > limit {
if current.Len() > 0 {
flush()
}
chunks = append(chunks, line[:limit])
line = line[limit:]
}
if current.Len()+len(line)+1 > limit {
flush()
}
current.WriteString(line)
current.WriteString("\n")
}
flush()
return chunks
}
+43
View File
@@ -0,0 +1,43 @@
// Package output defines the pluggable destination interface and its
// implementations (currently Discord webhook).
package output
import (
"context"
"fmt"
"github.com/ytshih/stonks/internal/config"
"github.com/ytshih/stonks/internal/report"
)
// Output delivers the final report somewhere (Discord, Slack, email, ...).
// Implementations decide how to render report.Text and the optional
// report.Recommendations (e.g. Discord sends the text as chunked messages
// followed by a short recommendations embed).
type Output interface {
Name() string
Send(ctx context.Context, rep report.Report) error
}
// Constructor builds an Output from its PluginConfig (name + params).
type Constructor func(cfg config.PluginConfig) (Output, error)
var registry = map[string]Constructor{}
// Register makes an output constructor available under name. Called from
// each output implementation's init().
func Register(name string, ctor Constructor) {
if _, exists := registry[name]; exists {
panic(fmt.Sprintf("output: constructor already registered for %q", name))
}
registry[name] = ctor
}
// Build looks up the constructor for cfg.Name and invokes it.
func Build(cfg config.PluginConfig) (Output, error) {
ctor, ok := registry[cfg.Name]
if !ok {
return nil, fmt.Errorf("output: no output registered for %q", cfg.Name)
}
return ctor(cfg)
}
+18
View File
@@ -0,0 +1,18 @@
// Package report defines the shared shape produced by internal/analyze and
// consumed by internal/output, decoupling the two from each other.
package report
// Recommendation is a single buy/sell call with a short reason, surfaced
// separately from the main narrative report (e.g. as a Discord embed).
type Recommendation struct {
Symbol string `json:"symbol"`
Action string `json:"action"` // "buy" or "sell"
Reason string `json:"reason"`
}
// Report is the full output of one analysis run: the long-form narrative
// text plus an optional short list of buy/sell recommendations.
type Report struct {
Text string
Recommendations []Recommendation
}