price pipeline
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
d7ec1fb355
commit
d9581d7f7e
16 changed files with 790 additions and 10 deletions
88
internal/pipeline/coingecko.go
Normal file
88
internal/pipeline/coingecko.go
Normal 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
|
||||
}
|
||||
75
internal/pipeline/openfigi.go
Normal file
75
internal/pipeline/openfigi.go
Normal 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
|
||||
}
|
||||
133
internal/pipeline/pipeline.go
Normal file
133
internal/pipeline/pipeline.go
Normal 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
|
||||
}
|
||||
55
internal/pipeline/yahoo.go
Normal file
55
internal/pipeline/yahoo.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue