package yahoo import ( "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "strings" "sync" "time" ) const baseURL = "https://query1.finance.yahoo.com/v8/finance/chart" const summaryURL = "https://query1.finance.yahoo.com/v10/finance/quoteSummary" type Client struct { http *http.Client mu sync.Mutex crumb string } type Bar struct { Date time.Time Open float64 High float64 Low float64 Close float64 Volume int64 } type Quote struct { Symbol string Price float64 PrevClose float64 ChangePct float64 } type chartResponse struct { Chart struct { Result []struct { Meta struct { Symbol string `json:"symbol"` RegularMarketPrice float64 `json:"regularMarketPrice"` PreviousClose float64 `json:"previousClose"` } `json:"meta"` Timestamps []int64 `json:"timestamp"` Indicators struct { Quote []struct { Open []float64 `json:"open"` High []float64 `json:"high"` Low []float64 `json:"low"` Close []float64 `json:"close"` Volume []int64 `json:"volume"` } `json:"quote"` } `json:"indicators"` } `json:"result"` Error *struct { Code string `json:"code"` Description string `json:"description"` } `json:"error"` } `json:"chart"` } func New() *Client { jar, _ := cookiejar.New(nil) return &Client{ http: &http.Client{Timeout: 10 * time.Second, Jar: jar}, } } // initCrumb obtient un cookie de session Yahoo Finance puis récupère le crumb. func (c *Client) initCrumb() error { // 1. Visite Yahoo Finance pour obtenir les cookies req, _ := http.NewRequest("GET", "https://finance.yahoo.com", nil) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") req.Header.Set("Accept", "text/html") resp, err := c.http.Do(req) if err != nil { return err } io.Copy(io.Discard, resp.Body) resp.Body.Close() // 2. Récupère le crumb req2, _ := http.NewRequest("GET", "https://query1.finance.yahoo.com/v1/test/getcrumb", nil) req2.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") resp2, err := c.http.Do(req2) if err != nil { return err } defer resp2.Body.Close() body, _ := io.ReadAll(resp2.Body) crumb := strings.TrimSpace(string(body)) if crumb == "" || strings.Contains(crumb, "Unauthorized") { return fmt.Errorf("yahoo: crumb invalide") } c.crumb = crumb return nil } func (c *Client) getCrumb() string { c.mu.Lock() defer c.mu.Unlock() if c.crumb == "" { c.initCrumb() } return c.crumb } func (c *Client) History(symbol string, days int) ([]Bar, error) { rangeStr := "3mo" if days > 90 { rangeStr = "6mo" } url := fmt.Sprintf("%s/%s?interval=1d&range=%s", baseURL, symbol, rangeStr) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("yahoo: HTTP %d for %s", resp.StatusCode, symbol) } var data chartResponse if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } if data.Chart.Error != nil { return nil, fmt.Errorf("yahoo: %s — %s", data.Chart.Error.Code, data.Chart.Error.Description) } if len(data.Chart.Result) == 0 { return nil, fmt.Errorf("yahoo: no data for %s", symbol) } result := data.Chart.Result[0] quotes := result.Indicators.Quote if len(quotes) == 0 { return nil, fmt.Errorf("yahoo: empty quotes for %s", symbol) } q := quotes[0] bars := make([]Bar, 0, len(result.Timestamps)) for i, ts := range result.Timestamps { if i >= len(q.Close) || q.Close[i] == 0 { continue } bar := Bar{ Date: time.Unix(ts, 0).UTC(), Close: safeFloat(q.Close, i), Open: safeFloat(q.Open, i), High: safeFloat(q.High, i), Low: safeFloat(q.Low, i), Volume: safeInt(q.Volume, i), } bars = append(bars, bar) } return bars, nil } func (c *Client) GetQuote(symbol string) (*Quote, error) { url := fmt.Sprintf("%s/%s?interval=1d&range=5d", baseURL, symbol) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var data chartResponse if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } if len(data.Chart.Result) == 0 { return nil, fmt.Errorf("yahoo: no result for %s", symbol) } meta := data.Chart.Result[0].Meta changePct := 0.0 if meta.PreviousClose > 0 { changePct = (meta.RegularMarketPrice - meta.PreviousClose) / meta.PreviousClose * 100 } return &Quote{ Symbol: meta.Symbol, Price: meta.RegularMarketPrice, PrevClose: meta.PreviousClose, ChangePct: changePct, }, nil } // MarketCapInfo contient les données fondamentales clés. type MarketCapInfo struct { MarketCap int64 // en USD FloatShares int64 ShortRatio float64 ForwardPE float64 } type quoteSummaryResponse struct { QuoteSummary struct { Result []struct { SummaryDetail struct { MarketCap struct { Raw int64 `json:"raw"` } `json:"marketCap"` ForwardPE struct { Raw float64 `json:"raw"` } `json:"forwardPE"` } `json:"summaryDetail"` DefaultKeyStatistics struct { FloatShares struct { Raw int64 `json:"raw"` } `json:"floatShares"` ShortRatio struct { Raw float64 `json:"raw"` } `json:"shortRatio"` } `json:"defaultKeyStatistics"` } `json:"result"` } `json:"quoteSummary"` } // GetMarketCap retourne les données fondamentales d'un ticker. func (c *Client) GetMarketCap(symbol string) (*MarketCapInfo, error) { crumb := c.getCrumb() url := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics&crumb=%s", summaryURL, symbol, crumb) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == 401 { // Crumb expiré — on le renouvelle et on réessaie une fois resp.Body.Close() c.mu.Lock() c.crumb = "" c.mu.Unlock() newCrumb := c.getCrumb() url2 := fmt.Sprintf("%s/%s?modules=summaryDetail,defaultKeyStatistics&crumb=%s", summaryURL, symbol, newCrumb) req2, _ := http.NewRequest("GET", url2, nil) req2.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") resp, err = c.http.Do(req2) if err != nil { return nil, err } } if resp.StatusCode != 200 { return nil, fmt.Errorf("yahoo quoteSummary: HTTP %d for %s", resp.StatusCode, symbol) } var data quoteSummaryResponse if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } results := data.QuoteSummary.Result if len(results) == 0 { return nil, fmt.Errorf("yahoo: no summary for %s", symbol) } r := results[0] return &MarketCapInfo{ MarketCap: r.SummaryDetail.MarketCap.Raw, FloatShares: r.DefaultKeyStatistics.FloatShares.Raw, ShortRatio: r.DefaultKeyStatistics.ShortRatio.Raw, ForwardPE: r.SummaryDetail.ForwardPE.Raw, }, nil } func safeFloat(s []float64, i int) float64 { if i < len(s) { return s[i] } return 0 } func safeInt(s []int64, i int) int64 { if i < len(s) { return s[i] } return 0 }