feat: done vibing
This commit is contained in:
27 files changed
+1922
-13
No files matched your search
@@ -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(¶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)
|
||||
}
|
||||
Reference in new issue
Block a user