Merge branch 'feature/price-pipe'
This commit is contained in:
commit
7c62edda39
16 changed files with 790 additions and 10 deletions
|
|
@ -11,7 +11,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.g3e.fr/H6N/account/internal/config"
|
"git.g3e.fr/H6N/account/internal/config"
|
||||||
|
"git.g3e.fr/H6N/account/internal/pipeline"
|
||||||
|
"git.g3e.fr/H6N/account/internal/scheduler"
|
||||||
"git.g3e.fr/H6N/account/internal/server"
|
"git.g3e.fr/H6N/account/internal/server"
|
||||||
|
"git.g3e.fr/H6N/account/internal/store"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -20,7 +23,9 @@ func main() {
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to create db pool", "error", err)
|
logger.Error("failed to create db pool", "error", err)
|
||||||
|
|
@ -34,6 +39,25 @@ func main() {
|
||||||
}
|
}
|
||||||
logger.Info("database connected")
|
logger.Info("database connected")
|
||||||
|
|
||||||
|
st := store.New(pool)
|
||||||
|
pl := pipeline.New(st, pipeline.Config{
|
||||||
|
OpenFIGIKey: cfg.OpenFIGIKey,
|
||||||
|
CoinGeckoKey: cfg.CoinGeckoKey,
|
||||||
|
}, logger)
|
||||||
|
|
||||||
|
sched := scheduler.New(logger)
|
||||||
|
sched.Add(scheduler.Job{
|
||||||
|
Name: "fetch-prices",
|
||||||
|
Interval: cfg.PriceFetchInterval,
|
||||||
|
Fn: pl.FetchAll,
|
||||||
|
})
|
||||||
|
sched.Add(scheduler.Job{
|
||||||
|
Name: "clean-price-history",
|
||||||
|
Interval: cfg.PriceCleanInterval,
|
||||||
|
Fn: pl.CleanHistory,
|
||||||
|
})
|
||||||
|
sched.Start(ctx)
|
||||||
|
|
||||||
srv := server.New(cfg, pool, logger)
|
srv := server.New(cfg, pool, logger)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
|
|
@ -57,7 +81,8 @@ func main() {
|
||||||
<-quit
|
<-quit
|
||||||
|
|
||||||
logger.Info("shutting down")
|
logger.Info("shutting down")
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
cancel()
|
||||||
defer cancel()
|
shutdownCtx, stop := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer stop()
|
||||||
httpServer.Shutdown(shutdownCtx)
|
httpServer.Shutdown(shutdownCtx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,34 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
Port string
|
Port string
|
||||||
Env string
|
Env string
|
||||||
|
OpenFIGIKey string
|
||||||
|
CoinGeckoKey string
|
||||||
|
PriceFetchInterval time.Duration
|
||||||
|
PriceCleanInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
|
fetchInterval, err := time.ParseDuration(getenv("PRICE_FETCH_INTERVAL", "1h"))
|
||||||
|
if err != nil {
|
||||||
|
fetchInterval = time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
DatabaseURL: getenv("DATABASE_URL", "postgres://account:account@localhost:5432/account?sslmode=disable"),
|
DatabaseURL: getenv("DATABASE_URL", "postgres://account:account@localhost:5432/account?sslmode=disable"),
|
||||||
Port: getenv("PORT", "8080"),
|
Port: getenv("PORT", "8080"),
|
||||||
Env: getenv("ENV", "development"),
|
Env: getenv("ENV", "development"),
|
||||||
|
OpenFIGIKey: os.Getenv("OPENFIGI_API_KEY"),
|
||||||
|
CoinGeckoKey: os.Getenv("COINGECKO_API_KEY"),
|
||||||
|
PriceFetchInterval: fetchInterval,
|
||||||
|
PriceCleanInterval: 24 * time.Hour,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
116
internal/handler/instrument.go
Normal file
116
internal/handler/instrument.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.g3e.fr/H6N/account/internal/store"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstrumentHandler struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInstrumentHandler(s *store.Store) *InstrumentHandler {
|
||||||
|
return &InstrumentHandler{store: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InstrumentHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /instruments", h.list)
|
||||||
|
mux.HandleFunc("POST /instruments", h.create)
|
||||||
|
mux.HandleFunc("GET /instruments/{id}", h.get)
|
||||||
|
mux.HandleFunc("PUT /instruments/{id}", h.update)
|
||||||
|
mux.HandleFunc("DELETE /instruments/{id}", h.delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InstrumentHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
instruments, err := h.store.ListInstruments(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list instruments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, instruments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InstrumentHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instrument, err := h.store.GetInstrument(r.Context(), id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "instrument not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to get instrument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, instrument)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InstrumentHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p store.CreateInstrumentParams
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Type == "" || p.Code == "" || p.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "type, code and name are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.DeviseCotation == "" {
|
||||||
|
p.DeviseCotation = "EUR"
|
||||||
|
}
|
||||||
|
instrument, err := h.store.CreateInstrument(r.Context(), p)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create instrument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, instrument)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InstrumentHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p store.CreateInstrumentParams
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instrument, err := h.store.UpdateInstrument(r.Context(), id, p)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "instrument not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update instrument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, instrument)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InstrumentHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.DeleteInstrument(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete instrument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseID(r *http.Request) (int32, error) {
|
||||||
|
v, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
return int32(v), err
|
||||||
|
}
|
||||||
16
internal/handler/response.go
Normal file
16
internal/handler/response.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
60
internal/scheduler/scheduler.go
Normal file
60
internal/scheduler/scheduler.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
Name string
|
||||||
|
Interval time.Duration
|
||||||
|
Fn func(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scheduler struct {
|
||||||
|
jobs []Job
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger *slog.Logger) *Scheduler {
|
||||||
|
return &Scheduler{logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Add(job Job) {
|
||||||
|
s.jobs = append(s.jobs, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start lance tous les jobs en arrière-plan. Chaque job est exécuté immédiatement
|
||||||
|
// puis répété selon son intervalle. S'arrête proprement à l'annulation du contexte.
|
||||||
|
func (s *Scheduler) Start(ctx context.Context) {
|
||||||
|
for _, job := range s.jobs {
|
||||||
|
go s.run(ctx, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) run(ctx context.Context, job Job) {
|
||||||
|
s.logger.Info("scheduler: starting job", "name", job.Name, "interval", job.Interval)
|
||||||
|
|
||||||
|
s.execute(ctx, job)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(job.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.logger.Info("scheduler: job stopped", "name", job.Name)
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.execute(ctx, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) execute(ctx context.Context, job Job) {
|
||||||
|
s.logger.Info("scheduler: running job", "name", job.Name)
|
||||||
|
if err := job.Fn(ctx); err != nil {
|
||||||
|
s.logger.Error("scheduler: job failed", "name", job.Name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.g3e.fr/H6N/account/internal/config"
|
"git.g3e.fr/H6N/account/internal/config"
|
||||||
|
"git.g3e.fr/H6N/account/internal/handler"
|
||||||
|
"git.g3e.fr/H6N/account/internal/store"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,6 +34,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) routes() {
|
func (s *Server) routes() {
|
||||||
|
st := store.New(s.pool)
|
||||||
|
|
||||||
|
handler.NewInstrumentHandler(st).RegisterRoutes(s.mux)
|
||||||
|
|
||||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
78
internal/store/instrument.go
Normal file
78
internal/store/instrument.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Instrument struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DeviseCotation string `json:"devise_cotation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInstrumentParams struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DeviseCotation string `json:"devise_cotation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListInstruments(ctx context.Context) ([]Instrument, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, type, code, name, devise_cotation FROM instrument ORDER BY type, code`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pgx.CollectRows(rows, pgx.RowToStructByName[Instrument])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetInstrument(ctx context.Context, id int32) (Instrument, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, type, code, name, devise_cotation FROM instrument WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return Instrument{}, err
|
||||||
|
}
|
||||||
|
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Instrument])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetInstrumentByCode(ctx context.Context, code string) (Instrument, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, type, code, name, devise_cotation FROM instrument WHERE code = $1`, code)
|
||||||
|
if err != nil {
|
||||||
|
return Instrument{}, err
|
||||||
|
}
|
||||||
|
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Instrument])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateInstrument(ctx context.Context, p CreateInstrumentParams) (Instrument, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`INSERT INTO instrument (type, code, name, devise_cotation)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, type, code, name, devise_cotation`,
|
||||||
|
p.Type, p.Code, p.Name, p.DeviseCotation)
|
||||||
|
if err != nil {
|
||||||
|
return Instrument{}, err
|
||||||
|
}
|
||||||
|
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Instrument])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateInstrument(ctx context.Context, id int32, p CreateInstrumentParams) (Instrument, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`UPDATE instrument SET type = $2, code = $3, name = $4, devise_cotation = $5
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, type, code, name, devise_cotation`,
|
||||||
|
id, p.Type, p.Code, p.Name, p.DeviseCotation)
|
||||||
|
if err != nil {
|
||||||
|
return Instrument{}, err
|
||||||
|
}
|
||||||
|
return pgx.CollectOneRow(rows, pgx.RowToStructByName[Instrument])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteInstrument(ctx context.Context, id int32) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `DELETE FROM instrument WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
41
internal/store/price_history.go
Normal file
41
internal/store/price_history.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) UpsertPrice(ctx context.Context, instrumentID int32, fetchedAt time.Time, prix float64) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO price_history (instrument_id, fetched_at, prix)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (instrument_id, fetched_at) DO UPDATE SET prix = EXCLUDED.prix`,
|
||||||
|
instrumentID, fetchedAt, prix)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastPrice retourne le dernier prix connu pour un instrument.
|
||||||
|
func (s *Store) GetLastPrice(ctx context.Context, instrumentID int32) (float64, time.Time, error) {
|
||||||
|
var prix float64
|
||||||
|
var fetchedAt time.Time
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT prix, fetched_at FROM price_history
|
||||||
|
WHERE instrument_id = $1
|
||||||
|
ORDER BY fetched_at DESC LIMIT 1`,
|
||||||
|
instrumentID).Scan(&prix, &fetchedAt)
|
||||||
|
return prix, fetchedAt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanPastDays supprime pour chaque jour passé toutes les entrées sauf la dernière par instrument.
|
||||||
|
func (s *Store) CleanPastDays(ctx context.Context) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
DELETE FROM price_history ph
|
||||||
|
WHERE ph.fetched_at::date < CURRENT_DATE
|
||||||
|
AND ph.fetched_at < (
|
||||||
|
SELECT MAX(ph2.fetched_at)
|
||||||
|
FROM price_history ph2
|
||||||
|
WHERE ph2.instrument_id = ph.instrument_id
|
||||||
|
AND ph2.fetched_at::date = ph.fetched_at::date
|
||||||
|
)`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
11
internal/store/store.go
Normal file
11
internal/store/store.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import "github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
31
internal/store/ticker_cache.go
Normal file
31
internal/store/ticker_cache.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTicker retourne le ticker (ou CoinGecko ID) mis en cache pour un instrument.
|
||||||
|
// Retourne pgx.ErrNoRows si absent.
|
||||||
|
func (s *Store) GetTicker(ctx context.Context, instrumentID int32) (string, error) {
|
||||||
|
var ticker string
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT ticker FROM instrument_ticker_cache WHERE instrument_id = $1`,
|
||||||
|
instrumentID).Scan(&ticker)
|
||||||
|
return ticker, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertTicker met à jour ou insère le ticker pour un instrument.
|
||||||
|
func (s *Store) UpsertTicker(ctx context.Context, instrumentID int32, ticker string) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO instrument_ticker_cache (instrument_id, ticker, fetched_at)
|
||||||
|
VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (instrument_id) DO UPDATE SET ticker = EXCLUDED.ticker, fetched_at = NOW()`,
|
||||||
|
instrumentID, ticker)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
return err == pgx.ErrNoRows
|
||||||
|
}
|
||||||
10
migrations/000002_price_history_intraday.down.sql
Normal file
10
migrations/000002_price_history_intraday.down.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
DROP TABLE IF EXISTS instrument_ticker_cache;
|
||||||
|
DROP TABLE IF EXISTS price_history;
|
||||||
|
|
||||||
|
CREATE TABLE price_history (
|
||||||
|
instrument_id INTEGER NOT NULL REFERENCES instrument(id),
|
||||||
|
date DATE NOT NULL,
|
||||||
|
prix NUMERIC(24, 8) NOT NULL,
|
||||||
|
PRIMARY KEY (instrument_id, date)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('price_history', by_range('date'));
|
||||||
19
migrations/000002_price_history_intraday.up.sql
Normal file
19
migrations/000002_price_history_intraday.up.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- Recréation de price_history avec support intraday (plusieurs prix par jour)
|
||||||
|
-- Le job de nettoyage consolide à J-1 en gardant uniquement le dernier fetch
|
||||||
|
DROP TABLE IF EXISTS price_history;
|
||||||
|
|
||||||
|
CREATE TABLE price_history (
|
||||||
|
instrument_id INTEGER NOT NULL REFERENCES instrument(id),
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
prix NUMERIC(24, 8) NOT NULL,
|
||||||
|
PRIMARY KEY (instrument_id, fetched_at)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('price_history', by_range('fetched_at'));
|
||||||
|
CREATE INDEX idx_price_history_instrument ON price_history(instrument_id, fetched_at DESC);
|
||||||
|
|
||||||
|
-- Cache OpenFIGI (ISIN → ticker Yahoo Finance) et CoinGecko (ticker → coin ID)
|
||||||
|
CREATE TABLE instrument_ticker_cache (
|
||||||
|
instrument_id INTEGER PRIMARY KEY REFERENCES instrument(id) ON DELETE CASCADE,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue