158 lines
4.5 KiB
Go
158 lines
4.5 KiB
Go
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
|
|
}
|