diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 474ff19..5a8582d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -60,6 +60,10 @@ func Validate(config *c.Config, options *Options, prevErr *error) func(*cobra.Co return errors.New("invalid config: No watchlist provided") //nolint:goerr113 } + if len(config.Currency) > 0 && (strings.ToUpper(config.Currency) != config.Currency || len(config.Currency) != 3) { + return errors.New("invalid config: Display currency may only be an ISO 4217 major currency or blank (eg GBP not GBp; default: USD)") //nolint:goerr113 + } + return nil } } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 52b0513..67b2076 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -610,5 +610,62 @@ var _ = Describe("Cli", func() { }) }) + Describe("currency", func() { + When("a mixed-case currency is specified in the config file", func() { + It("should return an error (even if a valid minor currency)", func() { + options.Watchlist = "SEIT.L" + config = c.Config{ + Currency: "USd", + } + outputErr := Validate(&config, &options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(MatchError("invalid config: Display currency may only be an ISO 4217 major currency or blank (eg GBP not GBp; default: USD)")) + }) + }) + + When("a blank currency is specified in the config file", func() { + It("should not return an error", func() { + options.Watchlist = "SEIT.L" + config := c.Config{ + Currency: "", + } + outputErr := Validate(&config, &options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).NotTo(HaveOccurred()) + }) + }) + + When("a short currency (len < 3) is specified in the config file", func() { + It("should return an error", func() { + options.Watchlist = "SEIT.L" + config := c.Config{ + Currency: "US", + } + outputErr := Validate(&config, &options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(MatchError("invalid config: Display currency may only be an ISO 4217 major currency or blank (eg GBP not GBp; default: USD)")) + }) + }) + + When("a long currency (len > 3) is specified in the config file", func() { + It("should return an error", func() { + options.Watchlist = "SEIT.L" + config := c.Config{ + Currency: "USD2", + } + outputErr := Validate(&config, &options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(MatchError("invalid config: Display currency may only be an ISO 4217 major currency or blank (eg GBP not GBp; default: USD)")) + }) + }) + + PWhen("a non-ISO 4217 currency is specified in the config file", func() { + PIt("should return an error", func() { + options.Watchlist = "SEIT.L" + config = c.Config{ + Currency: "XXX", + } + outputErr := Validate(&config, &options, nil)(&cobra.Command{}, []string{}) + Expect(outputErr).To(MatchError("invalid config: Display currency may only be an ISO 4217 major currency or blank (eg GBP not GBp; default: USD)")) + }) + }) + }) + }) }) diff --git a/internal/monitor/yahoo/monitor-currency-rates/monitor_currency_rates_test.go b/internal/monitor/yahoo/monitor-currency-rates/monitor_currency_rates_test.go index a5583db..4cacf31 100644 --- a/internal/monitor/yahoo/monitor-currency-rates/monitor_currency_rates_test.go +++ b/internal/monitor/yahoo/monitor-currency-rates/monitor_currency_rates_test.go @@ -1,6 +1,7 @@ package monitorCurrencyRate_test import ( + "math" "net/http" "time" @@ -18,8 +19,9 @@ import ( var _ = Describe("MonitorCurrencyRates", func() { var ( - server *ghttp.Server - client *unary.UnaryAPI + server *ghttp.Server + client *unary.UnaryAPI + float64EqualityTolerance = 1e-9 ) BeforeEach(func() { @@ -81,7 +83,7 @@ var _ = Describe("MonitorCurrencyRates", func() { When("a request for currency rates is received", func() { - It("should return currency rates requested and all previous currency rates", func() { + It("should return currency rates requested, and minor units, and all previous currency rates", func() { // Setup server to respond to EURUSD=X server.RouteToHandler("GET", "/v7/finance/quote", ghttp.CombineHandlers( @@ -115,6 +117,11 @@ var _ = Describe("MonitorCurrencyRates", func() { Expect(rates["EUR"].ToCurrency).To(Equal("USD")) Expect(rates["EUR"].Rate).To(Equal(1.1)) + Expect(rates).To(HaveKey("EUr")) + Expect(rates["EUr"].FromCurrency).To(Equal("EUr")) + Expect(rates["EUr"].ToCurrency).To(Equal("USD")) + Expect(float64ValuesAreEqual(rates["EUr"].Rate, 0.011, float64EqualityTolerance)).To(BeTrue()) + // Replace API to only return quotes for GBPUSD=X server.RouteToHandler("GET", "/v7/finance/quote", ghttp.CombineHandlers( @@ -141,6 +148,47 @@ var _ = Describe("MonitorCurrencyRates", func() { Expect(rates).To(HaveKey("GBP")) Expect(rates["EUR"].Rate).To(Equal(1.1)) Expect(rates["GBP"].Rate).To(Equal(1.3)) + + Expect(rates).To(HaveKey("EUr")) + Expect(rates).To(HaveKey("GBp")) + Expect(float64ValuesAreEqual(rates["EUr"].Rate, 0.011, float64EqualityTolerance)).To(BeTrue()) + Expect(float64ValuesAreEqual(rates["GBp"].Rate, 0.013, float64EqualityTolerance)).To(BeTrue()) + + // Replace API to only return quotes for JPNUSD=X (no minor currency) + server.RouteToHandler("GET", "/v7/finance/quote", + ghttp.CombineHandlers( + verifyRequest(server, "GET", "/v7/finance/quote", "symbols", "JPYUSD=X"), + ghttp.RespondWithJSONEncoded(http.StatusOK, unary.Response{ + QuoteResponse: unary.ResponseQuoteResponse{ + Quotes: []unary.ResponseQuote{{ + Symbol: "JPYUSD=X", + RegularMarketPrice: unary.ResponseFieldFloat{Raw: 0.0068, Fmt: "0.0068"}, + Currency: "USD", + }}, + Error: nil, + }, + }), + ), + ) + + // Request EUR, GBP & JPY + requestCh <- []string{"EUR", "GBP", "JPY"} + + Eventually(updateCh, 500*time.Millisecond).Should(Receive(&rates)) + + Expect(rates).To(HaveKey("EUR")) + Expect(rates).To(HaveKey("GBP")) + Expect(rates).To(HaveKey("JPY")) + Expect(rates["EUR"].Rate).To(Equal(1.1)) + Expect(rates["GBP"].Rate).To(Equal(1.3)) + Expect(rates["JPY"].Rate).To(Equal(0.0068)) + + Expect(rates).To(HaveKey("EUr")) + Expect(rates).To(HaveKey("GBp")) + Expect(rates).NotTo(HaveKey("JPy")) + Expect(float64ValuesAreEqual(rates["EUr"].Rate, 0.011, float64EqualityTolerance)).To(BeTrue()) + Expect(float64ValuesAreEqual(rates["GBp"].Rate, 0.013, float64EqualityTolerance)).To(BeTrue()) + }) When("the currency request is empty", func() { @@ -380,6 +428,10 @@ var _ = Describe("MonitorCurrencyRates", func() { }) +func float64ValuesAreEqual(f1 float64, f2 float64, tolerance float64) bool { + return math.Abs(f1-f2) < tolerance +} + func verifyRequest(server *ghttp.Server, method, path string, queryKey, queryValue string) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { Expect(r.Method).To(Equal(method)) diff --git a/internal/monitor/yahoo/unary/helpers-currency.go b/internal/monitor/yahoo/unary/helpers-currency.go new file mode 100644 index 0000000..970838d --- /dev/null +++ b/internal/monitor/yahoo/unary/helpers-currency.go @@ -0,0 +1,191 @@ +package unary + +// minorCurrency represents the scaling ('minor unit') for a minor currency. +// Convert major to minor by multiplying by 10^n and minor to major by dividing by 10^n. +type minorCurrency struct { + MajorCurrencyCode string + MinorCurrencyCode string + MinorUnit float64 +} + +// Just consider the currencies traded on major exchanges +func MinorUnitForCurrencyCode(majorCurrency string) (bool, string, float64) { + + var minorCurrencyCodeByMajorCurrencyCode = map[string]minorCurrency{ + "AUD": {"AUD", "AUd", 2}, + "CAD": {"CAD", "CAd", 2}, + "CHF": {"CHF", "CHf", 2}, + "CNY": {"CNY", "CNy", 2}, + "EUR": {"EUR", "EUr", 2}, + "GBP": {"GBP", "GBp", 2}, + "HKD": {"HKD", "HKd", 2}, + "INR": {"INR", "INr", 2}, + "TWD": {"TWD", "TWd", 2}, + "USD": {"USD", "USd", 2}, + "ZAR": {"ZAR", "ZAr", 2}, + } + + if mc, ok := minorCurrencyCodeByMajorCurrencyCode[majorCurrency]; ok { + + return true, mc.MinorCurrencyCode, mc.MinorUnit + } + + return false, "", 0 + +} + +/* +// This map is derived from https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xls +// Retaining this as a reference to the complete list as at October 2025. +var _minorCurrencyCodeByMajorCurrencyCode = map[string]minorCurrency{ + "AED": {"AED", "AEd", 2}, + "AFN": {"AFN", "AFn", 2}, + "ALL": {"ALL", "ALl", 2}, + "AMD": {"AMD", "AMd", 2}, + "AOA": {"AOA", "AOa", 2}, + "ARS": {"ARS", "ARs", 2}, + "AUD": {"AUD", "AUd", 2}, + "AWG": {"AWG", "AWg", 2}, + "AZN": {"AZN", "AZn", 2}, + "BAM": {"BAM", "BAm", 2}, + "BBD": {"BBD", "BBd", 2}, + "BDT": {"BDT", "BDt", 2}, + "BGN": {"BGN", "BGn", 2}, + "BHD": {"BHD", "BHd", 3}, + "BMD": {"BMD", "BMd", 2}, + "BND": {"BND", "BNd", 2}, + "BOB": {"BOB", "BOb", 2}, + "BOV": {"BOV", "BOv", 2}, + "BRL": {"BRL", "BRl", 2}, + "BSD": {"BSD", "BSd", 2}, + "BTN": {"BTN", "BTn", 2}, + "BWP": {"BWP", "BWp", 2}, + "BYN": {"BYN", "BYn", 2}, + "BZD": {"BZD", "BZd", 2}, + "CAD": {"CAD", "CAd", 2}, + "CDF": {"CDF", "CDf", 2}, + "CHE": {"CHE", "CHe", 2}, + "CHF": {"CHF", "CHf", 2}, + "CHW": {"CHW", "CHw", 2}, + "CLF": {"CLF", "CLf", 4}, + "CNY": {"CNY", "CNy", 2}, + "COP": {"COP", "COp", 2}, + "COU": {"COU", "COu", 2}, + "CRC": {"CRC", "CRc", 2}, + "CUP": {"CUP", "CUp", 2}, + "CVE": {"CVE", "CVe", 2}, + "CZK": {"CZK", "CZk", 2}, + "DKK": {"DKK", "DKk", 2}, + "DOP": {"DOP", "DOp", 2}, + "DZD": {"DZD", "DZd", 2}, + "EGP": {"EGP", "EGp", 2}, + "ERN": {"ERN", "ERn", 2}, + "ETB": {"ETB", "ETb", 2}, + "EUR": {"EUR", "EUr", 2}, + "FJD": {"FJD", "FJd", 2}, + "FKP": {"FKP", "FKp", 2}, + "GBP": {"GBP", "GBp", 2}, + "GEL": {"GEL", "GEl", 2}, + "GHS": {"GHS", "GHs", 2}, + "GIP": {"GIP", "GIp", 2}, + "GMD": {"GMD", "GMd", 2}, + "GTQ": {"GTQ", "GTq", 2}, + "GYD": {"GYD", "GYd", 2}, + "HKD": {"HKD", "HKd", 2}, + "HNL": {"HNL", "HNl", 2}, + "HTG": {"HTG", "HTg", 2}, + "HUF": {"HUF", "HUf", 2}, + "IDR": {"IDR", "IDr", 2}, + "ILS": {"ILS", "ILs", 2}, + "INR": {"INR", "INr", 2}, + "IQD": {"IQD", "IQd", 3}, + "IRR": {"IRR", "IRr", 2}, + "JMD": {"JMD", "JMd", 2}, + "JOD": {"JOD", "JOd", 3}, + "KES": {"KES", "KEs", 2}, + "KGS": {"KGS", "KGs", 2}, + "KHR": {"KHR", "KHr", 2}, + "KPW": {"KPW", "KPw", 2}, + "KWD": {"KWD", "KWd", 3}, + "KYD": {"KYD", "KYd", 2}, + "KZT": {"KZT", "KZt", 2}, + "LAK": {"LAK", "LAk", 2}, + "LBP": {"LBP", "LBp", 2}, + "LKR": {"LKR", "LKr", 2}, + "LRD": {"LRD", "LRd", 2}, + "LSL": {"LSL", "LSl", 2}, + "LYD": {"LYD", "LYd", 3}, + "MAD": {"MAD", "MAd", 2}, + "MDL": {"MDL", "MDl", 2}, + "MGA": {"MGA", "MGa", 2}, + "MKD": {"MKD", "MKd", 2}, + "MMK": {"MMK", "MMk", 2}, + "MNT": {"MNT", "MNt", 2}, + "MOP": {"MOP", "MOp", 2}, + "MRU": {"MRU", "MRu", 2}, + "MUR": {"MUR", "MUr", 2}, + "MVR": {"MVR", "MVr", 2}, + "MWK": {"MWK", "MWk", 2}, + "MXN": {"MXN", "MXn", 2}, + "MXV": {"MXV", "MXv", 2}, + "MYR": {"MYR", "MYr", 2}, + "MZN": {"MZN", "MZn", 2}, + "NAD": {"NAD", "NAd", 2}, + "NGN": {"NGN", "NGn", 2}, + "NIO": {"NIO", "NIo", 2}, + "NOK": {"NOK", "NOk", 2}, + "NPR": {"NPR", "NPr", 2}, + "NZD": {"NZD", "NZd", 2}, + "OMR": {"OMR", "OMr", 3}, + "PAB": {"PAB", "PAb", 2}, + "PEN": {"PEN", "PEn", 2}, + "PGK": {"PGK", "PGk", 2}, + "PHP": {"PHP", "PHp", 2}, + "PKR": {"PKR", "PKr", 2}, + "PLN": {"PLN", "PLn", 2}, + "QAR": {"QAR", "QAr", 2}, + "RON": {"RON", "ROn", 2}, + "RSD": {"RSD", "RSd", 2}, + "RUB": {"RUB", "RUb", 2}, + "SAR": {"SAR", "SAr", 2}, + "SBD": {"SBD", "SBd", 2}, + "SCR": {"SCR", "SCr", 2}, + "SDG": {"SDG", "SDg", 2}, + "SEK": {"SEK", "SEk", 2}, + "SGD": {"SGD", "SGd", 2}, + "SHP": {"SHP", "SHp", 2}, + "SLE": {"SLE", "SLe", 2}, + "SOS": {"SOS", "SOs", 2}, + "SRD": {"SRD", "SRd", 2}, + "SSP": {"SSP", "SSp", 2}, + "STN": {"STN", "STn", 2}, + "SVC": {"SVC", "SVc", 2}, + "SYP": {"SYP", "SYp", 2}, + "SZL": {"SZL", "SZl", 2}, + "THB": {"THB", "THb", 2}, + "TJS": {"TJS", "TJs", 2}, + "TMT": {"TMT", "TMt", 2}, + "TND": {"TND", "TNd", 3}, + "TOP": {"TOP", "TOp", 2}, + "TRY": {"TRY", "TRy", 2}, + "TTD": {"TTD", "TTd", 2}, + "TWD": {"TWD", "TWd", 2}, + "TZS": {"TZS", "TZs", 2}, + "UAH": {"UAH", "UAh", 2}, + "USD": {"USD", "USd", 2}, + "USN": {"USN", "USn", 2}, + "UYU": {"UYU", "UYu", 2}, + "UYW": {"UYW", "UYw", 4}, + "UZS": {"UZS", "UZs", 2}, + "VED": {"VED", "VEd", 2}, + "VES": {"VES", "VEs", 2}, + "WST": {"WST", "WSt", 2}, + "XAD": {"XAD", "XAd", 2}, + "XCD": {"XCD", "XCd", 2}, + "XCG": {"XCG", "XCg", 2}, + "YER": {"YER", "YEr", 2}, + "ZAR": {"ZAR", "ZAr", 2}, + "ZMW": {"ZMW", "ZMw", 2}, + "ZWG": {"ZWG", "ZWg", 2}, +} +*/ diff --git a/internal/monitor/yahoo/unary/helpers-quote.go b/internal/monitor/yahoo/unary/helpers-quote.go index 4bb3629..98e62fb 100644 --- a/internal/monitor/yahoo/unary/helpers-quote.go +++ b/internal/monitor/yahoo/unary/helpers-quote.go @@ -1,8 +1,6 @@ package unary import ( - "strings" - c "github.com/achannarasappa/ticker/v5/internal/common" ) @@ -22,7 +20,7 @@ func transformResponseQuote(responseQuote ResponseQuote) c.AssetQuote { Symbol: responseQuote.Symbol, Class: assetClass, Currency: c.Currency{ - FromCurrencyCode: strings.ToUpper(responseQuote.Currency), + FromCurrencyCode: responseQuote.Currency, }, QuotePrice: c.QuotePrice{ Price: responseQuote.RegularMarketPrice.Raw, diff --git a/internal/monitor/yahoo/unary/unary.go b/internal/monitor/yahoo/unary/unary.go index e4577a7..b180baf 100644 --- a/internal/monitor/yahoo/unary/unary.go +++ b/internal/monitor/yahoo/unary/unary.go @@ -3,6 +3,7 @@ package unary import ( "encoding/json" "fmt" + "math" "net/http" "net/url" "strings" @@ -90,7 +91,7 @@ func (u *UnaryAPI) GetCurrencyMap(symbols []string) (map[string]SymbolToCurrency for _, quote := range result.QuoteResponse.Quotes { symbolToCurrency[quote.Symbol] = SymbolToCurrency{ Symbol: quote.Symbol, - FromCurrency: strings.ToUpper(quote.Currency), + FromCurrency: quote.Currency, } } @@ -142,6 +143,8 @@ func (u *UnaryAPI) GetCurrencyRates(fromCurrencies []string, toCurrency string) // Transform result to currency rates currencyRates := make(map[string]c.CurrencyRate) + // The Yahoo API forces uppercase and so, even though the currency symbol GBpGBP=x, for example, is submitted, + // it is interpreted and returned as GBPGBP=X with Rate = 1.0. for _, quote := range result.QuoteResponse.Quotes { fromCurrency := strings.TrimSuffix(strings.TrimSuffix(quote.Symbol, "=X"), toCurrency) currencyRates[fromCurrency] = c.CurrencyRate{ @@ -149,6 +152,16 @@ func (u *UnaryAPI) GetCurrencyRates(fromCurrencies []string, toCurrency string) ToCurrency: toCurrency, Rate: quote.RegularMarketPrice.Raw, } + + // If fromCurrency has a minor form as well then add it to the map as well (with a modified rate). + if ok, minorCurrencyCode, minorUnit := MinorUnitForCurrencyCode(fromCurrency); ok { + currencyRates[minorCurrencyCode] = c.CurrencyRate{ + FromCurrency: minorCurrencyCode, + ToCurrency: toCurrency, + Rate: quote.RegularMarketPrice.Raw * math.Pow(10, -minorUnit), + } + } + } return currencyRates, nil