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