From 72f4ef3b27abe48532fcb8949c27f1f55bb36f2a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 21 May 2026 18:05:37 -0700 Subject: [PATCH] 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. --- internal/plugin/forex.go | 81 +++++++++++++-------- internal/plugin/forex_api.go | 6 +- internal/plugin/forex_convert.go | 31 ++++++-- internal/plugin/forex_crypto.go | 119 +++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 internal/plugin/forex_crypto.go diff --git a/internal/plugin/forex.go b/internal/plugin/forex.go index 5af66b0..301bd4a 100644 --- a/internal/plugin/forex.go +++ b/internal/plugin/forex.go @@ -113,6 +113,7 @@ const fxHelpText = "**Forex Commands**\n\n" + "`!fx rate EUR/USD` — cross-pair rate from first currency's perspective\n" + "`!fx report [EUR|JPY|CAD]` — full analysis (averages, 52w range, buy score)\n" + "`!fx 1500 USD to EUR` — convert an amount (also: `!fx convert 1500 USD EUR`)\n" + + "`!fx 100 USD to BTC` — convert to/from crypto (BTC, ETH, SOL, XRP, DOGE, ADA, LTC)\n" + "`!fx setalert ` — alert when USD/currency reaches threshold\n" + "`!fx alerts` — list active alerts in this room\n" + "`!fx delalert ` — remove an alert\n" + @@ -122,7 +123,7 @@ const fxHelpText = "**Forex Commands**\n\n" + func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error { // Check for cross-pair syntax like EUR/USD or USD/JPY - if pair := fxParsePair(args); pair != nil { + if pair := fxParsePair(args, false); pair != nil { return p.cmdPairRateOrReport(ctx, pair, false) } @@ -156,8 +157,10 @@ type fxPair struct { } // fxParsePair detects "XXX/YYY" syntax in args. Both sides must be USD or a -// tracked currency. Returns nil if no pair is found. -func fxParsePair(args []string) *fxPair { +// tracked currency; when allowCrypto is set, supported crypto assets are +// accepted too (used by the conversion path, not by analysis). Returns nil if +// no pair is found. +func fxParsePair(args []string, allowCrypto bool) *fxPair { if len(args) == 0 { return nil } @@ -174,7 +177,11 @@ func fxParsePair(args []string) *fxPair { return nil } base, quote := parts[0], parts[1] - if !fxIsSupported(base) || !fxIsSupported(quote) { + ok := fxIsSupported + if allowCrypto { + ok = fxIsConvertible + } + if !ok(base) || !ok(quote) { return nil } if base == quote { @@ -215,46 +222,62 @@ func (p *ForexPlugin) cmdPairRateOrReport(ctx MessageContext, pair *fxPair, full return p.SendReply(ctx.RoomID, ctx.EventID, sig.FormatQuick()) } -// fxLivePairRate computes the live cross-pair rate from USD-base rates. +// fxLivePairRate computes the live cross-pair rate (how many Quote per 1 Base). +// Each side is resolved to a "units per USD" figure, then crossed; this handles +// USD, fiat (Frankfurter), and crypto (CoinGecko) sides uniformly. func (p *ForexPlugin) fxLivePairRate(pair *fxPair) (float64, error) { - var currencies []string - if pair.Base != "USD" { - currencies = append(currencies, pair.Base) + // Batch the fiat sides into a single Frankfurter call. + var fiat []string + for _, c := range []string{pair.Base, pair.Quote} { + if c != "USD" && !fxIsCrypto(c) { + fiat = append(fiat, c) + } } - if pair.Quote != "USD" { - currencies = append(currencies, pair.Quote) + var fiatRates map[string]float64 + if len(fiat) > 0 { + var err error + if fiatRates, err = p.fxLiveRates(fiat); err != nil { + return 0, err + } } - rates, err := p.fxLiveRates(currencies) + basePer, err := p.fxPerUSD(pair.Base, fiatRates) if err != nil { return 0, err } + quotePer, err := p.fxPerUSD(pair.Quote, fiatRates) + if err != nil { + return 0, err + } + if basePer == 0 { + return 0, fmt.Errorf("missing rate data for cross-pair") + } + return quotePer / basePer, nil +} +// fxPerUSD returns how many units of cur equal 1 USD. Fiat rates are taken from +// the pre-fetched fiatRates map; crypto is fetched live from CoinGecko. +func (p *ForexPlugin) fxPerUSD(cur string, fiatRates map[string]float64) (float64, error) { switch { - case pair.Base == "USD": - r, ok := rates[pair.Quote] - if !ok { - return 0, fmt.Errorf("no rate data for %s", pair.Quote) + case cur == "USD": + return 1.0, nil + case fxIsCrypto(cur): + price, err := p.cgSpotUSD(cur) + if err != nil { + return 0, err + } + return 1.0 / price, nil + default: + r, ok := fiatRates[cur] + if !ok || r == 0 { + return 0, fmt.Errorf("no rate data for %s", cur) } return r, nil - case pair.Quote == "USD": - r, ok := rates[pair.Base] - if !ok || r == 0 { - return 0, fmt.Errorf("no rate data for %s", pair.Base) - } - return 1.0 / r, nil - default: - br, ok1 := rates[pair.Base] - qr, ok2 := rates[pair.Quote] - if !ok1 || !ok2 || br == 0 { - return 0, fmt.Errorf("missing rate data for cross-pair") - } - return qr / br, nil } } func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error { - if pair := fxParsePair(args); pair != nil { + if pair := fxParsePair(args, false); pair != nil { return p.cmdPairRateOrReport(ctx, pair, true) } currencies := fxParseCurrencies(args) diff --git a/internal/plugin/forex_api.go b/internal/plugin/forex_api.go index 2b20df1..1dcba99 100644 --- a/internal/plugin/forex_api.go +++ b/internal/plugin/forex_api.go @@ -34,8 +34,12 @@ func fxIsTracked(cur string) bool { return false } -// fxFormatRate formats a rate: JPY uses 2 decimal places, others use 4. +// fxFormatRate formats a rate: crypto uses up to 8 decimals (trailing zeros +// trimmed) for tiny per-USD figures, JPY uses 2, others use 4. func fxFormatRate(currency string, rate float64) string { + if fxIsCrypto(currency) { + return fxTrimZeros(fmt.Sprintf("%.8f", rate)) + } if currency == "JPY" { return fmt.Sprintf("%.2f", rate) } diff --git a/internal/plugin/forex_convert.go b/internal/plugin/forex_convert.go index 024c813..d7d8e9d 100644 --- a/internal/plugin/forex_convert.go +++ b/internal/plugin/forex_convert.go @@ -58,7 +58,7 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro for _, tok := range tokens { // Pair form: USD/EUR if strings.ContainsAny(tok, "/|-") { - pair := fxParsePair([]string{tok}) + pair := fxParsePair([]string{tok}, true) if pair != nil { if base != "" { return 0, "", "", fmt.Errorf("multiple currency pairs") @@ -76,9 +76,9 @@ func fxParseConvert(args []string) (amount float64, base, quote string, err erro amountSet = true continue } - // Currency code? + // Currency or crypto code? up := strings.ToUpper(tok) - if fxIsSupported(up) { + if fxIsConvertible(up) { currencies = append(currencies, up) continue } @@ -127,12 +127,15 @@ func (p *ForexPlugin) cmdConvert(ctx MessageContext, args []string) error { return p.SendReply(ctx.RoomID, ctx.EventID, msg) } -// fxEmoji returns the flag emoji for a supported currency, falling back -// to USD's flag. +// fxEmoji returns the flag/symbol emoji for a supported currency or crypto +// asset, falling back to USD's flag. func fxEmoji(cur string) string { if m, ok := fxMeta[cur]; ok && m.Emoji != "" { return m.Emoji } + if c, ok := fxCryptoMeta[strings.ToUpper(cur)]; ok && c.Emoji != "" { + return c.Emoji + } if cur == "USD" { return "🇺🇸" } @@ -140,9 +143,13 @@ func fxEmoji(cur string) string { } // fxFormatAmount formats a converted amount with thousands separators. -// JPY uses 0 decimal places (yen are not subdivided in practice for display), -// other currencies use 2. +// Crypto uses up to 8 decimals (trailing zeros trimmed) since amounts are +// often fractional; JPY uses 0 (yen are not subdivided in practice for +// display); other currencies use 2. func fxFormatAmount(currency string, amount float64) string { + if fxIsCrypto(currency) { + return fxTrimZeros(fxAddCommas(amount, 8)) + } decimals := 2 if currency == "JPY" { decimals = 0 @@ -150,6 +157,16 @@ func fxFormatAmount(currency string, amount float64) string { return fxAddCommas(amount, decimals) } +// fxTrimZeros drops trailing fractional zeros (and a bare trailing dot) from a +// formatted decimal string, leaving integers untouched. +func fxTrimZeros(s string) string { + if !strings.Contains(s, ".") { + return s + } + s = strings.TrimRight(s, "0") + return strings.TrimRight(s, ".") +} + // fxAddCommas formats a float with N decimal places and thousands separators. func fxAddCommas(v float64, decimals int) string { s := strconv.FormatFloat(v, 'f', decimals, 64) diff --git a/internal/plugin/forex_crypto.go b/internal/plugin/forex_crypto.go new file mode 100644 index 0000000..e71c094 --- /dev/null +++ b/internal/plugin/forex_crypto.go @@ -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 +}