Files
2026-09-05 10:25:39 +08:00

179 lines
4.7 KiB
Go

package fetch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
"gitea.konchin.com/service/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)
}