From 80d548e8bd9f8d566a83639811a711385f7e177c Mon Sep 17 00:00:00 2001 From: MaysWind Date: Wed, 13 Nov 2024 01:46:03 +0800 Subject: [PATCH] add Swiss National Bank exchange rates data source --- conf/ezbookkeeping.ini | 1 + .../exchange_rates_datasource_container.go | 3 + .../swiss_national_bank_datasource.go | 208 +++++++++++ .../swiss_national_bank_datasource_test.go | 333 ++++++++++++++++++ pkg/settings/setting.go | 3 + src/views/desktop/ExchangeRatesPage.vue | 2 +- 6 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 pkg/exchangerates/swiss_national_bank_datasource.go create mode 100644 pkg/exchangerates/swiss_national_bank_datasource_test.go diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index ddd14edc..50443b68 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -346,6 +346,7 @@ custom_map_tile_server_default_zoom_level = 14 # "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/ # "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/ # "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/ +# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates # "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx data_source = euro_central_bank diff --git a/pkg/exchangerates/exchange_rates_datasource_container.go b/pkg/exchangerates/exchange_rates_datasource_container.go index 6d42092d..6a5a8d30 100644 --- a/pkg/exchangerates/exchange_rates_datasource_container.go +++ b/pkg/exchangerates/exchange_rates_datasource_container.go @@ -35,6 +35,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error { } else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource { Container.Current = &BankOfIsraelDataSource{} return nil + } else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource { + Container.Current = &SwissNationalBankDataSource{} + return nil } else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource { Container.Current = &InternationalMonetaryFundDataSource{} return nil diff --git a/pkg/exchangerates/swiss_national_bank_datasource.go b/pkg/exchangerates/swiss_national_bank_datasource.go new file mode 100644 index 00000000..44c511fe --- /dev/null +++ b/pkg/exchangerates/swiss_national_bank_datasource.go @@ -0,0 +1,208 @@ +package exchangerates + +import ( + "encoding/xml" + "math" + "time" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" + "github.com/mayswind/ezbookkeeping/pkg/validators" +) + +const swissNationalBankExchangeRateUrl = "https://www.snb.ch/public/en/rss/exchangeRates" +const swissNationalBankExchangeRateReferenceUrl = "https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates" +const swissNationalBankDataSource = "Swiss National Bank" +const swissNationalBankBaseCurrency = "CHF" + +const swissNationalBankDataUpdateDateFormat = "Mon, 02 Jan 2006 15:04:05 MST" +const swissNationalBankExchangeRatePeriodDateFormat = "2006-01-02" + +// SwissNationalBankDataSource defines the structure of exchange rates data source of the reserve Swiss National Bank +type SwissNationalBankDataSource struct { + ExchangeRatesDataSource +} + +// SwissNationalBankData represents the whole data from the reserve Swiss National Bank +type SwissNationalBankData struct { + XMLName xml.Name `xml:"rss"` + Channel *SwissNationalBankRssChannel `xml:"channel"` +} + +// SwissNationalBankRssChannel represents the rss channel from the reserve Swiss National Bank +type SwissNationalBankRssChannel struct { + PublishDate string `xml:"pubDate"` + Items []*SwissNationalBankChannelItem `xml:"item"` +} + +// SwissNationalBankChannelItem represents the channel item from the reserve Swiss National Bank +type SwissNationalBankChannelItem struct { + Statistics *SwissNationalBankItemStatistics `xml:"statistics"` +} + +// SwissNationalBankItemStatistics represents the item statistics from the reserve Swiss National Bank +type SwissNationalBankItemStatistics struct { + ExchangeRate *SwissNationalBankExchangeRate `xml:"exchangeRate"` +} + +// SwissNationalBankExchangeRate represents the exchange rate from the reserve Swiss National Bank +type SwissNationalBankExchangeRate struct { + BaseCurrency string `xml:"baseCurrency"` + TargetCurrency string `xml:"targetCurrency"` + Observation *SwissNationalBankExchangeRateObservation `xml:"observation"` + ObservationPeriod *SwissNationalBankExchangeRateObservationPeriod `xml:"observationPeriod"` +} + +// SwissNationalBankExchangeRateObservation represents the exchange rate data from the reserve Swiss National Bank +type SwissNationalBankExchangeRateObservation struct { + Value string `xml:"value"` + Unit string `xml:"unit"` + UnitExponent string `xml:"unit_mult"` +} + +// SwissNationalBankExchangeRateObservationPeriod represents the exchange rate period data from the reserve Swiss National Bank +type SwissNationalBankExchangeRateObservationPeriod struct { + Period string `xml:"period"` +} + +// ToLatestExchangeRateResponse returns a view-object according to original data from the reserve Swiss National Bank +func (e *SwissNationalBankData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse { + if e.Channel == nil { + log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] rss channel does not exist") + return nil + } + + if len(e.Channel.Items) < 1 { + log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] channel items is empty") + return nil + } + + latestCurrencyExchangeRateDate := make(map[string]int64) + latestExchangeRates := make(map[string]*models.LatestExchangeRate) + + for i := 0; i < len(e.Channel.Items); i++ { + item := e.Channel.Items[i] + + if item.Statistics == nil || item.Statistics.ExchangeRate == nil || item.Statistics.ExchangeRate.Observation == nil || item.Statistics.ExchangeRate.ObservationPeriod == nil { + continue + } + + if item.Statistics.ExchangeRate.BaseCurrency != swissNationalBankBaseCurrency || item.Statistics.ExchangeRate.Observation.Unit != swissNationalBankBaseCurrency { + continue + } + + if _, exists := validators.AllCurrencyNames[item.Statistics.ExchangeRate.TargetCurrency]; !exists { + continue + } + + date, err := time.Parse(swissNationalBankExchangeRatePeriodDateFormat, item.Statistics.ExchangeRate.ObservationPeriod.Period) + + if err != nil { + log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse exchange rate period date, period is %s", item.Statistics.ExchangeRate.ObservationPeriod.Period) + continue + } + + currency := item.Statistics.ExchangeRate.TargetCurrency + latestDate, exists := latestCurrencyExchangeRateDate[currency] + + if !exists || date.Unix() > latestDate { + finalExchangeRate := item.Statistics.ExchangeRate.ToLatestExchangeRate(c) + + if finalExchangeRate != nil { + latestCurrencyExchangeRateDate[currency] = date.Unix() + latestExchangeRates[currency] = finalExchangeRate + } + } + } + + exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Channel.Items)) + + for _, exchangeRate := range latestExchangeRates { + exchangeRates = append(exchangeRates, exchangeRate) + } + + updateDateTime := e.Channel.PublishDate + updateTime, err := time.Parse(swissNationalBankDataUpdateDateFormat, updateDateTime) + + if err != nil { + log.Errorf(c, "[swiss_national_bank_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime) + return nil + } + + latestExchangeRateResp := &models.LatestExchangeRateResponse{ + DataSource: swissNationalBankDataSource, + ReferenceUrl: swissNationalBankExchangeRateReferenceUrl, + UpdateTime: updateTime.Unix(), + BaseCurrency: swissNationalBankBaseCurrency, + ExchangeRates: exchangeRates, + } + + return latestExchangeRateResp +} + +// ToLatestExchangeRate returns a data pair according to original data from the reserve Swiss National Bank +func (e *SwissNationalBankExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate { + rate, err := utils.StringToFloat64(e.Observation.Value) + + if err != nil { + log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.TargetCurrency, e.Observation.Value) + return nil + } + + if rate <= 0 { + log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.TargetCurrency, e.Observation.Value) + return nil + } + + unitExponent, err := utils.StringToInt(e.Observation.UnitExponent) + + if err != nil { + log.Warnf(c, "[swiss_national_bank_datasource.ToLatestExchangeRate] failed to parse unit, currency is %s, unit exponent is %s", e.TargetCurrency, e.Observation.UnitExponent) + return nil + } + + finalRate := 1 / rate + + if unitExponent > 1 { + finalRate = finalRate / math.Pow10(unitExponent-1) + } else if unitExponent < 0 { + finalRate = finalRate * math.Pow10(-unitExponent) + } + + if math.IsInf(finalRate, 0) { + return nil + } + + return &models.LatestExchangeRate{ + Currency: e.TargetCurrency, + Rate: utils.Float64ToString(finalRate), + } +} + +// GetRequestUrls returns the the reserve Swiss National Bank data source urls +func (e *SwissNationalBankDataSource) GetRequestUrls() []string { + return []string{swissNationalBankExchangeRateUrl} +} + +// Parse returns the common response entity according to the the reserve Swiss National Bank data source raw response +func (e *SwissNationalBankDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) { + swissNationalBankData := &SwissNationalBankData{} + err := xml.Unmarshal(content, swissNationalBankData) + + if err != nil { + log.Errorf(c, "[swiss_national_bank_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + latestExchangeRateResponse := swissNationalBankData.ToLatestExchangeRateResponse(c) + + if latestExchangeRateResponse == nil { + log.Errorf(c, "[swiss_national_bank_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content)) + return nil, errs.ErrFailedToRequestRemoteApi + } + + return latestExchangeRateResponse, nil +} diff --git a/pkg/exchangerates/swiss_national_bank_datasource_test.go b/pkg/exchangerates/swiss_national_bank_datasource_test.go new file mode 100644 index 00000000..35ae8cbd --- /dev/null +++ b/pkg/exchangerates/swiss_national_bank_datasource_test.go @@ -0,0 +1,333 @@ +package exchangerates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +const SwissNationalBankMinimumRequiredContent = "\n" + + "\n" + + " \n" + + " Tue, 12 Nov 2024 11:00:50 GMT\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " 0.9378\n" + + " CHF\n" + + " 1\n" + + " \n" + + " CHF\n" + + " EUR\n" + + " \n" + + " 2024-11-12\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " 0.5727\n" + + " CHF\n" + + " -2\n" + + " \n" + + " CHF\n" + + " JPY\n" + + " \n" + + " 2024-11-12\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "" + +func TestSwissNationalBankDataSource_StandardDataExtractBaseCurrency(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, "CHF", actualLatestExchangeRateResponse.BaseCurrency) +} + +func TestSwissNationalBankDataSource_StandardDataExtractUpdateTime(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Equal(t, int64(1731409250), actualLatestExchangeRateResponse.UpdateTime) +} + +func TestSwissNationalBankDataSource_StandardDataExtractExchangeRates(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(SwissNationalBankMinimumRequiredContent)) + assert.Equal(t, nil, err) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "EUR", + Rate: "1.0663254425250588", + }) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "JPY", + Rate: "174.6114894360049", + }) +} + +func TestSwissNationalBankDataSource_MultipleDateExchanges(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " Tue, 12 Nov 2024 11:00:50 GMT\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " 0.9378\n"+ + " CHF\n"+ + " 1\n"+ + " \n"+ + " CHF\n"+ + " EUR\n"+ + " \n"+ + " 2024-11-12\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " 0.9381\n"+ + " CHF\n"+ + " 1\n"+ + " \n"+ + " CHF\n"+ + " EUR\n"+ + " \n"+ + " 2024-11-11\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{ + Currency: "EUR", + Rate: "1.0663254425250588", + }) +} + +func TestSwissNationalBankDataSource_BlankContent(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("")) + assert.NotEqual(t, nil, err) +} + +func TestSwissNationalBankDataSource_OnlyXMLHeader(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("")) + assert.NotEqual(t, nil, err) +} + +func TestSwissNationalBankDataSource_EmptyRDFContent(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + "")) + assert.NotEqual(t, nil, err) +} + +func TestSwissNationalBankDataSource_EmptyChannelContent(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " \n"+ + "")) + assert.NotEqual(t, nil, err) +} + +func TestSwissNationalBankDataSource_NoItem(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + _, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " Tue, 12 Nov 2024 11:00:50 GMT\n"+ + " \n"+ + "")) + assert.NotEqual(t, nil, err) +} + +func TestSwissNationalBankDataSource_BaseCurrencyNotEqualPreset(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " Tue, 12 Nov 2024 11:00:50 GMT\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " 0.9378\n"+ + " CHF\n"+ + " 1\n"+ + " \n"+ + " EUR\n"+ + " CHF\n"+ + " \n"+ + " 2024-11-12\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestSwissNationalBankDataSource_UnitCurrencyNotEqualPreset(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " Tue, 12 Nov 2024 11:00:50 GMT\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " 0.9378\n"+ + " EUR\n"+ + " 1\n"+ + " \n"+ + " CHF\n"+ + " EUR\n"+ + " \n"+ + " 2024-11-12\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestSwissNationalBankDataSource_InvalidCurrency(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " Tue, 12 Nov 2024 11:00:50 GMT\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " 0.9378\n"+ + " CHF\n"+ + " 1\n"+ + " \n"+ + " CHF\n"+ + " XXX\n"+ + " \n"+ + " 2024-11-12\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestSwissNationalBankDataSource_EmptyRate(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " Tue, 12 Nov 2024 11:00:50 GMT\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " CHF\n"+ + " 1\n"+ + " \n"+ + " CHF\n"+ + " EUR\n"+ + " \n"+ + " 2024-11-12\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} + +func TestSwissNationalBankDataSource_InvalidRate(t *testing.T) { + dataSource := &SwissNationalBankDataSource{} + context := core.NewNullContext() + + actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("\n"+ + "\n"+ + " \n"+ + " Tue, 12 Nov 2024 11:00:50 GMT\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " null\n"+ + " CHF\n"+ + " 1\n"+ + " \n"+ + " CHF\n"+ + " EUR\n"+ + " \n"+ + " 2024-11-12\n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + " \n"+ + "")) + assert.Equal(t, nil, err) + assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0) +} diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index ac562a45..24fc1026 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -106,6 +106,7 @@ const ( CzechNationalBankDataSource string = "czech_national_bank" NationalBankOfPolandDataSource string = "national_bank_of_poland" BankOfIsraelDataSource string = "bank_of_israel" + SwissNationalBankDataSource string = "swiss_national_bank" InternationalMonetaryFundDataSource string = "international_monetary_fund" ) @@ -890,6 +891,8 @@ func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectio config.ExchangeRatesDataSource = NationalBankOfPolandDataSource } else if dataSource == BankOfIsraelDataSource { config.ExchangeRatesDataSource = BankOfIsraelDataSource + } else if dataSource == SwissNationalBankDataSource { + config.ExchangeRatesDataSource = SwissNationalBankDataSource } else if dataSource == InternationalMonetaryFundDataSource { config.ExchangeRatesDataSource = InternationalMonetaryFundDataSource } else { diff --git a/src/views/desktop/ExchangeRatesPage.vue b/src/views/desktop/ExchangeRatesPage.vue index f5a78c87..0b05b430 100644 --- a/src/views/desktop/ExchangeRatesPage.vue +++ b/src/views/desktop/ExchangeRatesPage.vue @@ -56,7 +56,7 @@ - +