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