179 lines
4.7 KiB
Go
179 lines
4.7 KiB
Go
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)
|
|
}
|