feat: done vibing
This commit is contained in:
27 files changed
+1922
-13
No files matched your search
@@ -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