feat: done vibing

This commit is contained in:
ytshih committed 2026-09-05 09:55:24 +08:00
1 parent 3de31a57c3
commit 4352fd1ad1
27 files changed
+1922 -13

No files matched your search

+157
View File
@@ -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
}