price pipeline

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-06-13 12:33:39 +02:00
commit d9581d7f7e
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
16 changed files with 790 additions and 10 deletions

View file

@ -0,0 +1,88 @@
package pipeline
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// ResolveCryptoID résout un ticker crypto (ex: "BTC") en ID CoinGecko (ex: "bitcoin").
func ResolveCryptoID(ctx context.Context, apiKey, ticker string) (string, error) {
url := fmt.Sprintf("https://api.coingecko.com/api/v3/search?query=%s", ticker)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
if apiKey != "" {
req.Header.Set("x-cg-pro-api-key", apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Coins []struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
} `json:"coins"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode coingecko search: %w", err)
}
// Le premier résultat dont le symbol correspond exactement est le bon
upper := strings.ToUpper(ticker)
for _, coin := range result.Coins {
if strings.ToUpper(coin.Symbol) == upper {
return coin.ID, nil
}
}
return "", fmt.Errorf("no coingecko match for ticker %s", ticker)
}
// FetchCoinGeckoPrices récupère les prix EUR pour une liste d'IDs CoinGecko.
// Le ticker stocké dans instrument_ticker_cache doit être l'ID CoinGecko (ex: "bitcoin", "ethereum").
func FetchCoinGeckoPrices(ctx context.Context, apiKey string, coinIDs []string) (map[string]float64, error) {
if len(coinIDs) == 0 {
return map[string]float64{}, nil
}
baseURL := "https://api.coingecko.com/api/v3/simple/price"
if apiKey != "" {
baseURL = "https://pro-api.coingecko.com/api/v3/simple/price"
}
url := fmt.Sprintf("%s?ids=%s&vs_currencies=eur", baseURL, strings.Join(coinIDs, ","))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if apiKey != "" {
req.Header.Set("x-cg-pro-api-key", apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Réponse : {"bitcoin": {"eur": 45000.0}, "ethereum": {"eur": 2500.0}}
var raw map[string]map[string]float64
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode coingecko response: %w", err)
}
prices := make(map[string]float64, len(raw))
for id, currencies := range raw {
prices[id] = currencies["eur"]
}
return prices, nil
}

View file

@ -0,0 +1,75 @@
package pipeline
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
// exchSuffix mappe le code bourse OpenFIGI vers le suffixe Yahoo Finance
var exchSuffix = map[string]string{
"FP": ".PA", // Euronext Paris
"GY": ".DE", // Xetra
"LN": ".L", // London
"SM": ".MC", // Madrid
"IM": ".MI", // Milan
"NA": ".AS", // Amsterdam
"BB": ".BR", // Bruxelles
"UN": "", // NYSE
"UQ": "", // NASDAQ
"UW": "", // NASDAQ (alt)
}
type openFIGIRequest struct {
IDType string `json:"idType"`
IDValue string `json:"idValue"`
}
type openFIGIData struct {
Ticker string `json:"ticker"`
ExchCode string `json:"exchCode"`
Name string `json:"name"`
}
type openFIGIResult struct {
Data []openFIGIData `json:"data"`
Error string `json:"error"`
}
// ResolveISIN résout un ISIN en ticker Yahoo Finance via OpenFIGI.
func ResolveISIN(ctx context.Context, apiKey, isin string) (string, error) {
body, _ := json.Marshal([]openFIGIRequest{{IDType: "ID_ISIN", IDValue: isin}})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.openfigi.com/v3/mapping", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if apiKey != "" {
req.Header.Set("X-OPENFIGI-APIKEY", apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var results []openFIGIResult
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
return "", fmt.Errorf("decode openfigi response: %w", err)
}
if len(results) == 0 || len(results[0].Data) == 0 {
return "", fmt.Errorf("no result for ISIN %s", isin)
}
if results[0].Error != "" {
return "", fmt.Errorf("openfigi error: %s", results[0].Error)
}
d := results[0].Data[0]
suffix := exchSuffix[d.ExchCode]
return d.Ticker + suffix, nil
}

View file

@ -0,0 +1,133 @@
package pipeline
import (
"context"
"log/slog"
"time"
"git.g3e.fr/H6N/account/internal/store"
)
type Config struct {
OpenFIGIKey string
CoinGeckoKey string
}
type Pipeline struct {
store *store.Store
cfg Config
logger *slog.Logger
}
func New(s *store.Store, cfg Config, logger *slog.Logger) *Pipeline {
return &Pipeline{store: s, cfg: cfg, logger: logger}
}
// FetchAll récupère les prix de tous les instruments et les écrit dans price_history.
func (p *Pipeline) FetchAll(ctx context.Context) error {
instruments, err := p.store.ListInstruments(ctx)
if err != nil {
return err
}
now := time.Now().UTC()
// Séparer les instruments par type pour batch CoinGecko
var cryptoIDs []string
cryptoByID := make(map[string]store.Instrument)
for _, inst := range instruments {
switch inst.Type {
case "devise":
if err := p.store.UpsertPrice(ctx, inst.ID, now, 1.0); err != nil {
p.logger.Error("upsert devise price", "code", inst.Code, "error", err)
}
case "action", "etf":
ticker, err := p.resolveTicker(ctx, inst)
if err != nil {
p.logger.Warn("could not resolve ticker", "instrument", inst.Code, "error", err)
continue
}
price, err := FetchYahooPrice(ctx, ticker)
if err != nil {
p.logger.Warn("yahoo fetch failed", "ticker", ticker, "error", err)
continue
}
if err := p.store.UpsertPrice(ctx, inst.ID, now, price); err != nil {
p.logger.Error("upsert price", "instrument", inst.Code, "error", err)
}
p.logger.Info("price fetched", "instrument", inst.Code, "ticker", ticker, "price", price)
case "crypto":
coinID, err := p.resolveCoinID(ctx, inst)
if err != nil {
p.logger.Warn("could not resolve coingecko id", "instrument", inst.Code, "error", err)
continue
}
cryptoIDs = append(cryptoIDs, coinID)
cryptoByID[coinID] = inst
}
}
// Batch fetch crypto
if len(cryptoIDs) > 0 {
prices, err := FetchCoinGeckoPrices(ctx, p.cfg.CoinGeckoKey, cryptoIDs)
if err != nil {
p.logger.Error("coingecko fetch failed", "error", err)
} else {
for coinID, price := range prices {
inst := cryptoByID[coinID]
if err := p.store.UpsertPrice(ctx, inst.ID, now, price); err != nil {
p.logger.Error("upsert crypto price", "instrument", inst.Code, "error", err)
continue
}
p.logger.Info("price fetched", "instrument", inst.Code, "coin_id", coinID, "price", price)
}
}
}
return nil
}
// CleanHistory supprime les prix intraday des jours passés, ne gardant que le dernier par instrument.
func (p *Pipeline) CleanHistory(ctx context.Context) error {
if err := p.store.CleanPastDays(ctx); err != nil {
p.logger.Error("clean price history failed", "error", err)
return err
}
p.logger.Info("price history cleaned")
return nil
}
// resolveTicker retourne le ticker Yahoo Finance, en le résolvant via OpenFIGI si nécessaire.
func (p *Pipeline) resolveTicker(ctx context.Context, inst store.Instrument) (string, error) {
ticker, err := p.store.GetTicker(ctx, inst.ID)
if err == nil {
return ticker, nil
}
resolved, err := ResolveISIN(ctx, p.cfg.OpenFIGIKey, inst.Code)
if err != nil {
return "", err
}
_ = p.store.UpsertTicker(ctx, inst.ID, resolved)
return resolved, nil
}
// resolveCoinID retourne l'ID CoinGecko, en le résolvant via l'API search si nécessaire.
func (p *Pipeline) resolveCoinID(ctx context.Context, inst store.Instrument) (string, error) {
coinID, err := p.store.GetTicker(ctx, inst.ID)
if err == nil {
return coinID, nil
}
resolved, err := ResolveCryptoID(ctx, p.cfg.CoinGeckoKey, inst.Code)
if err != nil {
return "", err
}
_ = p.store.UpsertTicker(ctx, inst.ID, resolved)
return resolved, nil
}

View file

@ -0,0 +1,55 @@
package pipeline
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type yahooChart struct {
Chart struct {
Result []struct {
Meta struct {
RegularMarketPrice float64 `json:"regularMarketPrice"`
Currency string `json:"currency"`
} `json:"meta"`
} `json:"result"`
Error *struct{ Description string } `json:"error"`
} `json:"chart"`
}
// FetchYahooPrice récupère le dernier prix connu pour un ticker Yahoo Finance.
func FetchYahooPrice(ctx context.Context, ticker string) (float64, error) {
url := fmt.Sprintf(
"https://query1.finance.yahoo.com/v8/finance/chart/%s?interval=1d&range=1d", ticker)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var chart yahooChart
if err := json.NewDecoder(resp.Body).Decode(&chart); err != nil {
return 0, fmt.Errorf("decode yahoo response: %w", err)
}
if chart.Chart.Error != nil {
return 0, fmt.Errorf("yahoo error: %s", chart.Chart.Error.Description)
}
if len(chart.Chart.Result) == 0 {
return 0, fmt.Errorf("no result for ticker %s", ticker)
}
price := chart.Chart.Result[0].Meta.RegularMarketPrice
if price == 0 {
return 0, fmt.Errorf("zero price for ticker %s", ticker)
}
return price, nil
}