first commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExchangeRateResponse represents the response from the exchange rate API
|
||||
type ExchangeRateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Base string `json:"base"`
|
||||
Date string `json:"date"`
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
}
|
||||
|
||||
// CachedRate stores exchange rate with timestamp
|
||||
type CachedRate struct {
|
||||
Rate float64
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// Currency conversion cache
|
||||
var (
|
||||
exchangeRateCache = make(map[string]CachedRate)
|
||||
cacheMutex sync.RWMutex
|
||||
cacheValidDuration = 1 * time.Hour // Cache rates for 1 hour
|
||||
)
|
||||
|
||||
// GetExchangeRate fetches the exchange rate from fromCurrency to toCurrency
|
||||
func GetExchangeRate(fromCurrency, toCurrency string) (float64, error) {
|
||||
// If currencies are the same, return 1.0
|
||||
if fromCurrency == toCurrency {
|
||||
return 1.0, nil
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_%s", fromCurrency, toCurrency)
|
||||
|
||||
// Check cache first
|
||||
cacheMutex.RLock()
|
||||
if cached, exists := exchangeRateCache[cacheKey]; exists {
|
||||
if time.Since(cached.Timestamp) < cacheValidDuration {
|
||||
cacheMutex.RUnlock()
|
||||
return cached.Rate, nil
|
||||
}
|
||||
}
|
||||
cacheMutex.RUnlock()
|
||||
|
||||
// Fetch from API if not in cache or cache is expired
|
||||
rate, err := fetchExchangeRateFromAPI(fromCurrency, toCurrency)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cacheMutex.Lock()
|
||||
exchangeRateCache[cacheKey] = CachedRate{
|
||||
Rate: rate,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
cacheMutex.Unlock()
|
||||
|
||||
return rate, nil
|
||||
}
|
||||
|
||||
// fetchExchangeRateFromAPI fetches exchange rate from a free API
|
||||
func fetchExchangeRateFromAPI(fromCurrency, toCurrency string) (float64, error) {
|
||||
// Using exchangerate-api.com (completely free, no API key required)
|
||||
url := fmt.Sprintf("https://api.exchangerate-api.com/v4/latest/%s", fromCurrency)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error creating request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Portfolio-Tracker/1.0")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error fetching exchange rate: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
// Parse exchangerate-api.com response format
|
||||
var apiResp struct {
|
||||
Base string `json:"base"`
|
||||
Date string `json:"date"`
|
||||
Rates map[string]float64 `json:"rates"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return 0, fmt.Errorf("error parsing JSON: %v", err)
|
||||
}
|
||||
|
||||
rate, exists := apiResp.Rates[toCurrency]
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("currency %s not found in response", toCurrency)
|
||||
}
|
||||
|
||||
return rate, nil
|
||||
}
|
||||
|
||||
// ConvertCurrency converts an amount from one currency to another
|
||||
func ConvertCurrency(amount float64, fromCurrency, toCurrency string) (float64, error) {
|
||||
if fromCurrency == toCurrency {
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
rate, err := GetExchangeRate(fromCurrency, toCurrency)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return amount * rate, nil
|
||||
}
|
||||
|
||||
// FormatCurrencyAmount formats a currency amount with the currency symbol
|
||||
func FormatCurrencyAmount(amount float64, currency string) string {
|
||||
return fmt.Sprintf("%.2f %s", amount, currency)
|
||||
}
|
||||
|
||||
// GetCurrencySymbol returns the symbol for common currencies
|
||||
func GetCurrencySymbol(currency string) string {
|
||||
symbols := map[string]string{
|
||||
"USD": "$",
|
||||
"EUR": "€",
|
||||
"GBP": "£",
|
||||
"JPY": "¥",
|
||||
"CHF": "CHF",
|
||||
"CAD": "C$",
|
||||
"AUD": "A$",
|
||||
"CNY": "¥",
|
||||
"INR": "₹",
|
||||
"KRW": "₩",
|
||||
"SEK": "kr",
|
||||
"NOK": "kr",
|
||||
"DKK": "kr",
|
||||
"PLN": "zł",
|
||||
"CZK": "Kč",
|
||||
"HUF": "Ft",
|
||||
}
|
||||
|
||||
if symbol, exists := symbols[currency]; exists {
|
||||
return symbol
|
||||
}
|
||||
return currency // Return currency code if symbol not found
|
||||
}
|
||||
|
||||
// FormatCurrencyWithSymbol formats amount with currency symbol
|
||||
func FormatCurrencyWithSymbol(amount float64, currency string) string {
|
||||
symbol := GetCurrencySymbol(currency)
|
||||
if symbol == currency {
|
||||
// If no symbol found, put currency after amount
|
||||
return fmt.Sprintf("%.2f %s", amount, currency)
|
||||
}
|
||||
|
||||
// For most currencies, put symbol before amount
|
||||
switch currency {
|
||||
case "EUR":
|
||||
return fmt.Sprintf("%.2f %s", amount, symbol)
|
||||
default:
|
||||
return fmt.Sprintf("%s%.2f", symbol, amount)
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertAndFormat converts currency and formats it nicely
|
||||
func ConvertAndFormat(amount float64, fromCurrency, toCurrency string) string {
|
||||
if fromCurrency == toCurrency {
|
||||
return FormatCurrencyWithSymbol(amount, toCurrency)
|
||||
}
|
||||
|
||||
convertedAmount, err := ConvertCurrency(amount, fromCurrency, toCurrency)
|
||||
if err != nil {
|
||||
// If conversion fails, return original amount with note
|
||||
return fmt.Sprintf("%.2f %s (conv. error)", amount, fromCurrency)
|
||||
}
|
||||
|
||||
return FormatCurrencyWithSymbol(convertedAmount, toCurrency)
|
||||
}
|
||||
|
||||
// BatchConvertCurrency converts multiple amounts in one API call for efficiency
|
||||
func BatchConvertCurrency(amounts []float64, fromCurrency, toCurrency string) ([]float64, error) {
|
||||
if fromCurrency == toCurrency {
|
||||
return amounts, nil
|
||||
}
|
||||
|
||||
rate, err := GetExchangeRate(fromCurrency, toCurrency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
converted := make([]float64, len(amounts))
|
||||
for i, amount := range amounts {
|
||||
converted[i] = amount * rate
|
||||
}
|
||||
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
// ClearCache clears the exchange rate cache (useful for testing or manual refresh)
|
||||
func ClearCache() {
|
||||
cacheMutex.Lock()
|
||||
defer cacheMutex.Unlock()
|
||||
exchangeRateCache = make(map[string]CachedRate)
|
||||
}
|
||||
|
||||
// GetCacheInfo returns information about cached exchange rates
|
||||
func GetCacheInfo() map[string]time.Time {
|
||||
cacheMutex.RLock()
|
||||
defer cacheMutex.RUnlock()
|
||||
|
||||
info := make(map[string]time.Time)
|
||||
for key, cached := range exchangeRateCache {
|
||||
info[key] = cached.Timestamp
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetExchangeRate_SameCurrency(t *testing.T) {
|
||||
rate, err := GetExchangeRate("USD", "USD")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for same currency, got %v", err)
|
||||
}
|
||||
if rate != 1.0 {
|
||||
t.Errorf("Expected rate 1.0 for same currency, got %f", rate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCurrency_SameCurrency(t *testing.T) {
|
||||
amount := 100.0
|
||||
converted, err := ConvertCurrency(amount, "EUR", "EUR")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for same currency conversion, got %v", err)
|
||||
}
|
||||
if converted != amount {
|
||||
t.Errorf("Expected %f for same currency conversion, got %f", amount, converted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCurrencyAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
amount float64
|
||||
currency string
|
||||
expected string
|
||||
}{
|
||||
{100.5, "USD", "100.50 USD"},
|
||||
{1234.567, "EUR", "1234.57 EUR"},
|
||||
{0.0, "GBP", "0.00 GBP"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := FormatCurrencyAmount(test.amount, test.currency)
|
||||
if result != test.expected {
|
||||
t.Errorf("FormatCurrencyAmount(%.2f, %s) = %s, expected %s",
|
||||
test.amount, test.currency, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrencySymbol(t *testing.T) {
|
||||
tests := []struct {
|
||||
currency string
|
||||
expected string
|
||||
}{
|
||||
{"USD", "$"},
|
||||
{"EUR", "€"},
|
||||
{"GBP", "£"},
|
||||
{"JPY", "¥"},
|
||||
{"CHF", "CHF"},
|
||||
{"UNKNOWN", "UNKNOWN"}, // Should return currency code if symbol not found
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := GetCurrencySymbol(test.currency)
|
||||
if result != test.expected {
|
||||
t.Errorf("GetCurrencySymbol(%s) = %s, expected %s",
|
||||
test.currency, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCurrencyWithSymbol(t *testing.T) {
|
||||
tests := []struct {
|
||||
amount float64
|
||||
currency string
|
||||
expected string
|
||||
}{
|
||||
{100.5, "USD", "$100.50"},
|
||||
{1234.56, "EUR", "1234.56 €"},
|
||||
{999.99, "GBP", "£999.99"},
|
||||
{500.0, "UNKNOWN", "500.00 UNKNOWN"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := FormatCurrencyWithSymbol(test.amount, test.currency)
|
||||
if result != test.expected {
|
||||
t.Errorf("FormatCurrencyWithSymbol(%.2f, %s) = %s, expected %s",
|
||||
test.amount, test.currency, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAndFormat_SameCurrency(t *testing.T) {
|
||||
amount := 100.0
|
||||
currency := "USD"
|
||||
result := ConvertAndFormat(amount, currency, currency)
|
||||
expected := FormatCurrencyWithSymbol(amount, currency)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("ConvertAndFormat(%f, %s, %s) = %s, expected %s",
|
||||
amount, currency, currency, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchConvertCurrency_SameCurrency(t *testing.T) {
|
||||
amounts := []float64{100.0, 200.0, 300.0}
|
||||
currency := "EUR"
|
||||
|
||||
converted, err := BatchConvertCurrency(amounts, currency, currency)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for same currency batch conversion, got %v", err)
|
||||
}
|
||||
|
||||
if len(converted) != len(amounts) {
|
||||
t.Errorf("Expected %d converted amounts, got %d", len(amounts), len(converted))
|
||||
}
|
||||
|
||||
for i, amount := range amounts {
|
||||
if converted[i] != amount {
|
||||
t.Errorf("Expected converted[%d] = %f, got %f", i, amount, converted[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCache(t *testing.T) {
|
||||
// Add something to cache first
|
||||
cacheMutex.Lock()
|
||||
exchangeRateCache["TEST_USD"] = CachedRate{
|
||||
Rate: 1.5,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
cacheMutex.Unlock()
|
||||
|
||||
// Verify cache has content
|
||||
info := GetCacheInfo()
|
||||
if len(info) == 0 {
|
||||
t.Error("Expected cache to have content before clearing")
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
ClearCache()
|
||||
|
||||
// Verify cache is empty
|
||||
info = GetCacheInfo()
|
||||
if len(info) != 0 {
|
||||
t.Errorf("Expected empty cache after clearing, got %d items", len(info))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheInfo(t *testing.T) {
|
||||
// Clear cache first
|
||||
ClearCache()
|
||||
|
||||
// Add test data to cache
|
||||
testTime := time.Now()
|
||||
cacheMutex.Lock()
|
||||
exchangeRateCache["EUR_USD"] = CachedRate{
|
||||
Rate: 1.2,
|
||||
Timestamp: testTime,
|
||||
}
|
||||
exchangeRateCache["GBP_EUR"] = CachedRate{
|
||||
Rate: 1.15,
|
||||
Timestamp: testTime,
|
||||
}
|
||||
cacheMutex.Unlock()
|
||||
|
||||
info := GetCacheInfo()
|
||||
|
||||
if len(info) != 2 {
|
||||
t.Errorf("Expected 2 cache entries, got %d", len(info))
|
||||
}
|
||||
|
||||
if timestamp, exists := info["EUR_USD"]; !exists {
|
||||
t.Error("Expected EUR_USD entry in cache info")
|
||||
} else if !timestamp.Equal(testTime) {
|
||||
t.Error("Expected timestamp to match test time")
|
||||
}
|
||||
|
||||
if timestamp, exists := info["GBP_EUR"]; !exists {
|
||||
t.Error("Expected GBP_EUR entry in cache info")
|
||||
} else if !timestamp.Equal(testTime) {
|
||||
t.Error("Expected timestamp to match test time")
|
||||
}
|
||||
}
|
||||
|
||||
// Test cache expiration logic
|
||||
func TestCacheExpiration(t *testing.T) {
|
||||
ClearCache()
|
||||
|
||||
// Add expired entry to cache
|
||||
expiredTime := time.Now().Add(-2 * time.Hour) // 2 hours ago
|
||||
cacheMutex.Lock()
|
||||
exchangeRateCache["TEST_EXPIRED"] = CachedRate{
|
||||
Rate: 1.0,
|
||||
Timestamp: expiredTime,
|
||||
}
|
||||
cacheMutex.Unlock()
|
||||
|
||||
// This should not use the expired cache (but will fail API call)
|
||||
// We're mainly testing that it doesn't return the cached expired value
|
||||
_, err := GetExchangeRate("TEST", "EXPIRED")
|
||||
if err == nil {
|
||||
t.Error("Expected error due to invalid currency pair, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkGetCurrencySymbol(b *testing.B) {
|
||||
currencies := []string{"USD", "EUR", "GBP", "JPY", "CHF", "UNKNOWN"}
|
||||
for i := 0; i < b.N; i++ {
|
||||
currency := currencies[i%len(currencies)]
|
||||
GetCurrencySymbol(currency)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFormatCurrencyWithSymbol(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
FormatCurrencyWithSymbol(1234.56, "USD")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type YahooChartResponse struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Timestamp []int64 `json:"timestamp"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
Close []float64 `json:"close"`
|
||||
} `json:"quote"`
|
||||
} `json:"indicators"`
|
||||
} `json:"result"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
func StockSearch(query string) (string, error) {
|
||||
url := fmt.Sprintf("https://query2.finance.yahoo.com/v1/finance/search?q=%s", query)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Erstellen der Anfrage: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Safari/605.1.15")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func FetchYahooFinanceData(stock string) (string, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://query2.finance.yahoo.com/v8/finance/chart/%s?range=1y&interval=1d&events=history",
|
||||
stock,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Erstellen der Anfrage: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Safari/605.1.15")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func FetchYahooFinanceDataMax(stock string) (string, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://query2.finance.yahoo.com/v8/finance/chart/%s?range=max&interval=1mo&events=div",
|
||||
stock,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Erstellen der Anfrage: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Safari/605.1.15")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// Neu: Daten für das Chart extrahieren
|
||||
func ExtractChartData(jsonStr string) ([]string, []float64, error) {
|
||||
var data YahooChartResponse
|
||||
err := json.Unmarshal([]byte(jsonStr), &data)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Fehler beim Parsen des JSON: %v", err)
|
||||
}
|
||||
if len(data.Chart.Result) == 0 {
|
||||
return nil, nil, fmt.Errorf("Keine Daten gefunden")
|
||||
}
|
||||
timestamps := data.Chart.Result[0].Timestamp
|
||||
closes := data.Chart.Result[0].Indicators.Quote[0].Close
|
||||
|
||||
labels := make([]string, len(timestamps))
|
||||
for i, ts := range timestamps {
|
||||
labels[i] = time.Unix(ts, 0).Format("2006-01-02")
|
||||
}
|
||||
return labels, closes, nil
|
||||
}
|
||||
Reference in New Issue
Block a user