From 0e0e3b91faeac16d93eecc928ca4a0e02ea693c3 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 11 Jun 2026 22:11:30 +0700 Subject: [PATCH] feat(gold): add multi-provider gold price fallback chain Replace goldprice.org (403s datacenter IPs) with a provider chain: gold-api.com -> Swissquote public quotes -> NBP daily fixing (PLN/gram converted via the shared FX table). Per-provider failures are logged; a full-chain outage surfaces as a retryable error instead of the silent no-price reply. --- internal/modules/gold/price_providers.go | 134 ++++++++++++++++++++++ internal/modules/gold/prices.go | 76 +++---------- internal/modules/gold/prices_test.go | 139 +++++++++++++++++++---- 3 files changed, 266 insertions(+), 83 deletions(-) create mode 100644 internal/modules/gold/price_providers.go diff --git a/internal/modules/gold/price_providers.go b/internal/modules/gold/price_providers.go new file mode 100644 index 0000000..71720de --- /dev/null +++ b/internal/modules/gold/price_providers.go @@ -0,0 +1,134 @@ +package gold + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/tiennm99/miti99bot/internal/log" +) + +// XAU/USD provider defaults. All three are free, keyless, and verified to +// answer datacenter IPs (goldprice.org was dropped: it 403s AWS/cloud IPs). +const ( + goldAPIDefaultURL = "https://api.gold-api.com/price/XAU" + swissquoteDefaultURL = "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD" + nbpDefaultURL = "https://api.nbp.pl/api/cenyzlota?format=json" +) + +type xauProvider struct { + name string + url string + fetch func(ctx context.Context, url string) (float64, error) +} + +func (c *GoldPriceClient) providers() []xauProvider { + pick := func(override, def string) string { + if s := strings.TrimSpace(override); s != "" { + return s + } + return def + } + return []xauProvider{ + {name: "gold-api.com", url: pick(c.GoldURL, goldAPIDefaultURL), fetch: c.fetchGoldAPI}, + {name: "swissquote", url: pick(c.SwissquoteURL, swissquoteDefaultURL), fetch: c.fetchSwissquote}, + {name: "nbp", url: pick(c.NBPURL, nbpDefaultURL), fetch: c.fetchNBP}, + } +} + +// fetchXAUUSD walks the provider chain and returns the first USD/oz price. +// Per-provider failures are logged; if every provider fails the joined error +// deliberately does NOT wrap ErrNoGoldPrice so callers treat it as a +// retryable fetch failure, not an empty-data reply. +func (c *GoldPriceClient) fetchXAUUSD(ctx context.Context) (float64, error) { + var failures []string + for _, p := range c.providers() { + price, err := p.fetch(ctx, p.url) + if err == nil { + return price, nil + } + log.Warn("gold_price_provider_failed", "provider", p.name, "err", err) + failures = append(failures, fmt.Sprintf("%s: %v", p.name, err)) + } + return 0, fmt.Errorf("gold: all price providers failed: %s", strings.Join(failures, "; ")) +} + +func (c *GoldPriceClient) providerGet(ctx context.Context, url string, dst any) error { + if err := validateEndpoint(url); err != nil { + return err + } + resp, err := c.getJSON(ctx, url) + if err != nil { + return fmt.Errorf("request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("status %d", resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("decode: %w", err) + } + return nil +} + +// fetchGoldAPI parses gold-api.com: {"price": 4073.6, "currency": "USD"}. +func (c *GoldPriceClient) fetchGoldAPI(ctx context.Context, url string) (float64, error) { + var body struct { + Currency string `json:"currency"` + Price float64 `json:"price"` + } + if err := c.providerGet(ctx, url, &body); err != nil { + return 0, err + } + if body.Currency != "" && body.Currency != "USD" { + return 0, fmt.Errorf("unexpected currency %q", body.Currency) + } + if body.Price <= 0 { + return 0, ErrNoGoldPrice + } + return body.Price, nil +} + +// fetchSwissquote parses the public best-bid/offer feed: +// [{"spreadProfilePrices":[{"bid":4074.41,"ask":4075.10}, ...]}, ...] +// and returns the mid price of the first quoted profile. +func (c *GoldPriceClient) fetchSwissquote(ctx context.Context, url string) (float64, error) { + var body []struct { + SpreadProfilePrices []struct { + Bid float64 `json:"bid"` + Ask float64 `json:"ask"` + } `json:"spreadProfilePrices"` + } + if err := c.providerGet(ctx, url, &body); err != nil { + return 0, err + } + for _, platform := range body { + for _, q := range platform.SpreadProfilePrices { + if q.Bid > 0 && q.Ask > 0 { + return (q.Bid + q.Ask) / 2, nil + } + } + } + return 0, ErrNoGoldPrice +} + +// fetchNBP parses the Polish central bank daily fixing +// [{"data":"2026-06-11","cena":492.71}] (PLN per gram) and converts to USD/oz +// using the shared FX table. Daily granularity — last-resort fallback only. +func (c *GoldPriceClient) fetchNBP(ctx context.Context, url string) (float64, error) { + var body []struct { + PLNPerGram float64 `json:"cena"` + } + if err := c.providerGet(ctx, url, &body); err != nil { + return 0, err + } + if len(body) == 0 || body[0].PLNPerGram <= 0 { + return 0, ErrNoGoldPrice + } + plnPerUSD, err := c.fetchFXRate(ctx, "PLN") + if err != nil { + return 0, fmt.Errorf("PLN rate: %w", err) + } + return body[0].PLNPerGram * gramsPerTroyOunce / plnPerUSD, nil +} diff --git a/internal/modules/gold/prices.go b/internal/modules/gold/prices.go index 2e8e29a..e374c2f 100644 --- a/internal/modules/gold/prices.go +++ b/internal/modules/gold/prices.go @@ -14,7 +14,6 @@ import ( ) const ( - goldDefaultURL = "https://data-asg.goldprice.org/dbXRates/USD" fxDefaultURL = "https://open.er-api.com/v6/latest/USD" goldHTTPTimeout = 10 * time.Second fxFallbackCacheTTL = time.Hour @@ -30,17 +29,23 @@ type GoldPrice struct { VNDPerLuong float64 } +// GoldPriceClient fetches XAU/USD through a chain of free providers (see +// price_providers.go) and converts to VND via a cached USD FX-rate table. type GoldPriceClient struct { - HTTP *http.Client - GoldURL string - FXURL string + HTTP *http.Client + + // Per-provider URL overrides; empty means the provider default. + GoldURL string // primary: gold-api.com + SwissquoteURL string + NBPURL string + FXURL string defaultOnce sync.Once defaultClient *http.Client nowFn func() time.Time mu sync.Mutex - fxRate float64 + fxRates map[string]float64 fxExpiry time.Time } @@ -56,7 +61,7 @@ func (c *GoldPriceClient) FetchPrice(ctx context.Context) (GoldPrice, error) { if err != nil { return GoldPrice{}, err } - usdToVND, err := c.fetchUSDVND(ctx) + usdToVND, err := c.fetchFXRate(ctx, "VND") if err != nil { return GoldPrice{}, err } @@ -92,13 +97,6 @@ func (c *GoldPriceClient) now() time.Time { return time.Now() } -func (c *GoldPriceClient) goldURL() string { - if strings.TrimSpace(c.GoldURL) != "" { - return strings.TrimSpace(c.GoldURL) - } - return goldDefaultURL -} - func (c *GoldPriceClient) fxURL() string { if strings.TrimSpace(c.FXURL) != "" { return strings.TrimSpace(c.FXURL) @@ -106,56 +104,18 @@ func (c *GoldPriceClient) fxURL() string { return fxDefaultURL } -type goldResponse struct { - Items []goldItem `json:"items"` -} - -type goldItem struct { - Currency string `json:"curr"` - XAUPrice float64 `json:"xauPrice"` -} - type fxResponse struct { Result string `json:"result"` Rates map[string]float64 `json:"rates"` TimeNextUpdateUnix int64 `json:"time_next_update_unix"` } -func (c *GoldPriceClient) fetchXAUUSD(ctx context.Context) (float64, error) { - endpoint := c.goldURL() - if err := validateEndpoint(endpoint); err != nil { - return 0, err - } - resp, err := c.getJSON(ctx, endpoint) - if err != nil { - return 0, fmt.Errorf("gold: GoldPrice request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return 0, ErrNoGoldPrice - } - var body goldResponse - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - return 0, fmt.Errorf("gold: GoldPrice decode: %w", err) - } - if len(body.Items) == 0 { - return 0, ErrNoGoldPrice - } - item := body.Items[0] - if item.Currency != "" && item.Currency != "USD" { - return 0, ErrNoGoldPrice - } - if item.XAUPrice <= 0 { - return 0, ErrNoGoldPrice - } - return item.XAUPrice, nil -} - -func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) { +// fetchFXRate returns the USD→code rate from a cached full rate table so one +// FX call serves both the VND conversion and the NBP fallback (PLN). +func (c *GoldPriceClient) fetchFXRate(ctx context.Context, code string) (float64, error) { c.mu.Lock() now := c.now() - if c.fxRate > 0 && now.Before(c.fxExpiry) { - rate := c.fxRate + if rate := c.fxRates[code]; rate > 0 && now.Before(c.fxExpiry) { c.mu.Unlock() return rate, nil } @@ -174,7 +134,7 @@ func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) { return 0, errors.New("gold: FX rate limited") } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return 0, ErrNoGoldPrice + return 0, fmt.Errorf("gold: FX status %d: %w", resp.StatusCode, ErrNoGoldPrice) } var body fxResponse if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { @@ -183,7 +143,7 @@ func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) { if body.Result != "" && body.Result != "success" { return 0, ErrNoGoldPrice } - rate := body.Rates["VND"] + rate := body.Rates[code] if rate <= 0 { return 0, ErrNoGoldPrice } @@ -192,7 +152,7 @@ func (c *GoldPriceClient) fetchUSDVND(ctx context.Context) (float64, error) { expiry = time.Unix(body.TimeNextUpdateUnix, 0) } c.mu.Lock() - c.fxRate = rate + c.fxRates = body.Rates c.fxExpiry = expiry c.mu.Unlock() return rate, nil diff --git a/internal/modules/gold/prices_test.go b/internal/modules/gold/prices_test.go index 110317e..5c0b61a 100644 --- a/internal/modules/gold/prices_test.go +++ b/internal/modules/gold/prices_test.go @@ -11,13 +11,24 @@ import ( "time" ) +// newChainTestClient pins every provider URL to the test server so no test +// ever falls through to a real network endpoint. +func newChainTestClient(srv *httptest.Server) *GoldPriceClient { + return &GoldPriceClient{ + GoldURL: srv.URL + "/gold", + SwissquoteURL: srv.URL + "/swissquote", + NBPURL: srv.URL + "/nbp", + FXURL: srv.URL + "/fx", + } +} + func TestGoldPriceClient_FetchLuongPrice(t *testing.T) { now := time.Unix(100, 0) var fxHits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/gold": - _, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":2000}]}`)) + _, _ = w.Write([]byte(`{"currency":"USD","price":2000}`)) case "/fx": atomic.AddInt32(&fxHits, 1) _, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000},"time_next_update_unix":1000}`)) @@ -26,7 +37,8 @@ func TestGoldPriceClient_FetchLuongPrice(t *testing.T) { } })) defer srv.Close() - c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx", nowFn: func() time.Time { return now }} + c := newChainTestClient(srv) + c.nowFn = func() time.Time { return now } got, err := c.FetchLuongPrice(context.Background()) if err != nil { @@ -44,46 +56,125 @@ func TestGoldPriceClient_FetchLuongPrice(t *testing.T) { } } -func TestGoldPriceClient_InvalidResponses(t *testing.T) { +func TestGoldPriceClient_FallbackToSwissquote(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/gold": + w.WriteHeader(http.StatusForbidden) // primary blocked, like goldprice.org was + case "/swissquote": + _, _ = w.Write([]byte(`[{"spreadProfilePrices":[{"bid":4000,"ask":4010}]}]`)) + case "/fx": + _, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + p, err := newChainTestClient(srv).FetchPrice(context.Background()) + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + if p.XAUUSD != 4005 { // mid of bid/ask + t.Errorf("XAUUSD: got %v, want 4005", p.XAUUSD) + } +} + +func TestGoldPriceClient_FallbackToNBP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/gold", "/swissquote": + w.WriteHeader(http.StatusInternalServerError) + case "/nbp": + _, _ = w.Write([]byte(`[{"data":"2026-06-11","cena":500}]`)) + case "/fx": + _, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000,"PLN":4}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + p, err := newChainTestClient(srv).FetchPrice(context.Background()) + if err != nil { + t.Fatalf("FetchPrice: %v", err) + } + want := 500 * gramsPerTroyOunce / 4 // PLN/gram → USD/oz via USD→PLN rate + if math.Abs(p.XAUUSD-want) > 0.01 { + t.Errorf("XAUUSD: got %v, want %v", p.XAUUSD, want) + } +} + +func TestGoldPriceClient_AllProvidersFailIsRetryable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + _, err := newChainTestClient(srv).FetchPrice(context.Background()) + if err == nil { + t.Fatal("want error when every provider fails") + } + // A full-chain outage is a fetch failure, not a benign "no data" reply. + if errors.Is(err, ErrNoGoldPrice) { + t.Fatalf("got ErrNoGoldPrice, want retryable error: %v", err) + } +} + +func TestGoldPriceClient_InvalidProviderResponses(t *testing.T) { cases := []struct { - name string - gold string - fx string + name string + gold string + swissquote string + nbp string }{ - {name: "missing gold", gold: `{"items":[]}`, fx: `{"result":"success","rates":{"VND":25000}}`}, - {name: "wrong currency", gold: `{"items":[{"curr":"EUR","xauPrice":2000}]}`, fx: `{"result":"success","rates":{"VND":25000}}`}, - {name: "missing fx", gold: `{"items":[{"curr":"USD","xauPrice":2000}]}`, fx: `{"result":"success","rates":{}}`}, + {name: "empty bodies", gold: `{}`, swissquote: `[]`, nbp: `[]`}, + {name: "wrong currency and zero quotes", gold: `{"currency":"EUR","price":2000}`, swissquote: `[{"spreadProfilePrices":[{"bid":0,"ask":0}]}]`, nbp: `[{"cena":0}]`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/gold" { + switch r.URL.Path { + case "/gold": _, _ = w.Write([]byte(tc.gold)) - return + case "/swissquote": + _, _ = w.Write([]byte(tc.swissquote)) + case "/nbp": + _, _ = w.Write([]byte(tc.nbp)) + case "/fx": + _, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000,"PLN":4}}`)) } - _, _ = w.Write([]byte(tc.fx)) })) defer srv.Close() - c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"} - _, err := c.FetchLuongPrice(context.Background()) - if !errors.Is(err, ErrNoGoldPrice) { - t.Errorf("got %v, want ErrNoGoldPrice", err) + if _, err := newChainTestClient(srv).FetchPrice(context.Background()); err == nil { + t.Fatal("want error for invalid provider data") } }) } } +func TestGoldPriceClient_MissingFXRateReturnsNoPrice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/gold" { + _, _ = w.Write([]byte(`{"currency":"USD","price":2000}`)) + return + } + _, _ = w.Write([]byte(`{"result":"success","rates":{}}`)) + })) + defer srv.Close() + _, err := newChainTestClient(srv).FetchPrice(context.Background()) + if !errors.Is(err, ErrNoGoldPrice) { + t.Fatalf("got %v, want ErrNoGoldPrice", err) + } +} + func TestGoldPriceClient_OverflowPriceReturnsNoPrice(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/gold" { - _, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":1e308}]}`)) + _, _ = w.Write([]byte(`{"currency":"USD","price":1e308}`)) return } _, _ = w.Write([]byte(`{"result":"success","rates":{"VND":1e308}}`)) })) defer srv.Close() - c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"} - _, err := c.FetchLuongPrice(context.Background()) + _, err := newChainTestClient(srv).FetchLuongPrice(context.Background()) if !errors.Is(err, ErrNoGoldPrice) { t.Fatalf("got %v, want ErrNoGoldPrice", err) } @@ -92,14 +183,13 @@ func TestGoldPriceClient_OverflowPriceReturnsNoPrice(t *testing.T) { func TestGoldPriceClient_FXRateLimitedIsRetryable(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/gold" { - _, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":2000}]}`)) + _, _ = w.Write([]byte(`{"currency":"USD","price":2000}`)) return } w.WriteHeader(http.StatusTooManyRequests) })) defer srv.Close() - c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"} - _, err := c.FetchLuongPrice(context.Background()) + _, err := newChainTestClient(srv).FetchLuongPrice(context.Background()) if err == nil || errors.Is(err, ErrNoGoldPrice) { t.Fatalf("got %v, want retryable non-ErrNoGoldPrice", err) } @@ -109,7 +199,7 @@ func TestGoldPriceClient_FetchPrice(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/gold": - _, _ = w.Write([]byte(`{"items":[{"curr":"USD","xauPrice":3000}]}`)) + _, _ = w.Write([]byte(`{"currency":"USD","price":3000}`)) case "/fx": _, _ = w.Write([]byte(`{"result":"success","rates":{"VND":25000}}`)) default: @@ -117,8 +207,7 @@ func TestGoldPriceClient_FetchPrice(t *testing.T) { } })) defer srv.Close() - c := &GoldPriceClient{GoldURL: srv.URL + "/gold", FXURL: srv.URL + "/fx"} - p, err := c.FetchPrice(context.Background()) + p, err := newChainTestClient(srv).FetchPrice(context.Background()) if err != nil { t.Fatalf("FetchPrice: %v", err) }