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