first commit

This commit is contained in:
Matthias Hinrichs
2025-07-05 03:10:41 +02:00
commit 9b7bdcbc53
39 changed files with 5109 additions and 0 deletions
+230
View File
@@ -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
}
+219
View File
@@ -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")
}
}
+135
View File
@@ -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
}