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

+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
}