package plugin import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) // ── Crypto support (CoinGecko, keyless) ────────────────────────────────────── // // CoinGecko's public simple/price endpoint needs no API key and no usage tier, // matching Frankfurter's keyless model. Crypto is wired into the *conversion* // path only (`!fx 100 USD to BTC`). The rate/report/alert analysis stays // fiat-only on purpose: a daily-snapshot "buy signal" or 52-week range is // meaningless for 24/7 crypto markets. type fxCryptoInfo struct { CoinGeckoID string Emoji string Label string } // fxCryptoMeta maps a ticker to its CoinGecko coin ID and display metadata. // Add a row here to support another asset. var fxCryptoMeta = map[string]fxCryptoInfo{ "BTC": {"bitcoin", "₿", "Bitcoin"}, "ETH": {"ethereum", "Ξ", "Ethereum"}, "SOL": {"solana", "◎", "Solana"}, "XRP": {"ripple", "✕", "XRP"}, "DOGE": {"dogecoin", "🐕", "Dogecoin"}, "ADA": {"cardano", "₳", "Cardano"}, "LTC": {"litecoin", "Ł", "Litecoin"}, } // fxIsCrypto reports whether sym is a supported crypto asset. func fxIsCrypto(sym string) bool { _, ok := fxCryptoMeta[strings.ToUpper(sym)] return ok } // fxIsConvertible reports whether a symbol can appear in a conversion — // USD, a tracked fiat currency, or a supported crypto asset. func fxIsConvertible(cur string) bool { return fxIsSupported(cur) || fxIsCrypto(cur) } const coinGeckoBaseURL = "https://api.coingecko.com/api/v3" // Short-TTL price cache to stay well under CoinGecko's public rate limit and // keep repeated conversions snappy. const cgCacheTTL = 60 * time.Second var ( cgMu sync.Mutex cgCache = map[string]cgCacheEntry{} ) type cgCacheEntry struct { priceUSD float64 at time.Time } // cgSpotUSD returns the USD spot price for one unit of the given crypto symbol. func (p *ForexPlugin) cgSpotUSD(sym string) (float64, error) { sym = strings.ToUpper(sym) info, ok := fxCryptoMeta[sym] if !ok { return 0, fmt.Errorf("unsupported crypto %s", sym) } cgMu.Lock() if e, ok := cgCache[sym]; ok && time.Since(e.at) < cgCacheTTL { cgMu.Unlock() return e.priceUSD, nil } cgMu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() q := url.Values{} q.Set("ids", info.CoinGeckoID) q.Set("vs_currencies", "usd") reqURL := coinGeckoBaseURL + "/simple/price?" + q.Encode() req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return 0, err } resp, err := p.httpClient.Do(req) if err != nil { return 0, fmt.Errorf("coingecko API error: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("coingecko API returned %d", resp.StatusCode) } var data map[string]map[string]float64 if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&data); err != nil { return 0, fmt.Errorf("coingecko decode error: %w", err) } entry, ok := data[info.CoinGeckoID] if !ok { return 0, fmt.Errorf("no price for %s", sym) } price, ok := entry["usd"] if !ok || price <= 0 { return 0, fmt.Errorf("invalid price for %s", sym) } cgMu.Lock() cgCache[sym] = cgCacheEntry{priceUSD: price, at: time.Now()} cgMu.Unlock() return price, nil }