mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Forex: convert to/from crypto via CoinGecko (keyless)
Wire a curated set of crypto assets (BTC, ETH, SOL, XRP, DOGE, ADA, LTC) into the !fx conversion path only. fxLivePairRate now resolves each side to units-per-USD and crosses them, so USD/fiat/crypto mix uniformly; fiat sides still batch into one Frankfurter call, crypto hits CoinGecko's keyless simple/price with a 60s cache. Analysis (rate/report/setalert) stays fiat-only on purpose -- a daily snapshot buy-signal/52w range is meaningless for 24/7 crypto.
This commit is contained in:
119
internal/plugin/forex_crypto.go
Normal file
119
internal/plugin/forex_crypto.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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()
|
||||
|
||||
url := fmt.Sprintf("%s/simple/price?ids=%s&vs_currencies=usd", coinGeckoBaseURL, info.CoinGeckoID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, 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(resp.Body).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
|
||||
}
|
||||
Reference in New Issue
Block a user