added first implementation of position list

This commit is contained in:
Matthias Hinrichs
2025-07-05 05:10:42 +02:00
parent aef9342cc5
commit 3a1ddd88d9
9 changed files with 1348 additions and 452 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ func getStockCurrency(stock string) (string, error) {
}
// Parse the JSON response
var resp model.YahooChartResponse
var resp util.YahooChartResponse
err = json.Unmarshal([]byte(data), &resp)
if err != nil {
return "", err
+38 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"portfolio-tracker/internal/model"
"portfolio-tracker/internal/service"
"portfolio-tracker/internal/web/templates"
"strconv"
"strings"
@@ -53,10 +54,30 @@ func PortfolioDetailHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Calculate positions
fmt.Printf("DEBUG: Starting position calculation for portfolio %d with %d activities\n", portfolio.ID, len(portfolio.Activities))
positionCalculator := service.NewPositionCalculator()
positions, positionSummary, err := positionCalculator.CalculatePositions(portfolio)
if err != nil {
fmt.Printf("Error calculating positions: %v\n", err)
// Continue with empty positions rather than failing
positions = []model.Position{}
positionSummary = model.PositionSummary{
Currency: portfolio.BaseCurrency,
LastUpdated: time.Now(),
}
}
fmt.Printf("DEBUG: Position calculation completed. Found %d positions\n", len(positions))
for i, pos := range positions {
fmt.Printf("DEBUG: Position %d: %s - Shares: %.4f, Open: %v\n", i+1, pos.Stock, pos.Shares, pos.IsOpen())
}
fmt.Printf("DEBUG: Position summary - Total Value: %.2f, Total Cost: %.2f\n", positionSummary.TotalValue, positionSummary.TotalCostBasis)
// Get all user portfolios for navigation
portfolios := getUserPortfolios(username)
component := templates.PortfolioDetail(auth, username, portfolio, portfolios)
component := templates.PortfolioDetail(auth, username, portfolio, portfolios, positions, positionSummary)
templ.Handler(component).ServeHTTP(w, r)
}
@@ -196,6 +217,14 @@ func PortfolioTransactionHandler(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Stock %s currency: %s\n", stock, stockCurrency)
// Handle GBp (pence) to GBP (pounds) conversion
// If the stock is quoted in pence, convert price to pounds
if stockCurrency == "GBp" {
price = price / 100.0
stockCurrency = "GBP"
fmt.Printf("Converted GBp price %.2f to GBP price %.2f\n", price*100, price)
}
// Create activity
activity := model.Activity{
PortfolioID: uint(portfolioID),
@@ -317,6 +346,14 @@ func EditTransactionHandler(w http.ResponseWriter, r *http.Request) {
stockCurrency = portfolio.BaseCurrency
}
// Handle GBp (pence) to GBP (pounds) conversion
// If the stock is quoted in pence, convert price to pounds
if stockCurrency == "GBp" {
price = price / 100.0
stockCurrency = "GBP"
fmt.Printf("Converted GBp price %.2f to GBP price %.2f\n", price*100, price)
}
// Update activity fields
activity.Type = model.ActivityType(transactionType)
activity.Stock = stock
+117
View File
@@ -0,0 +1,117 @@
package model
import (
"fmt"
"time"
)
// Position represents a calculated position for a specific stock in a portfolio
type Position struct {
Stock string `json:"stock"` // Stock symbol/ISIN
Shares float64 `json:"shares"` // Total shares held (can be negative if oversold)
AverageCostPrice float64 `json:"average_cost_price"` // Average cost per share in portfolio base currency
TotalCostBasis float64 `json:"total_cost_basis"` // Total amount invested in portfolio base currency
CurrentPrice float64 `json:"current_price"` // Current market price per share in portfolio base currency
CurrentValue float64 `json:"current_value"` // Current total value (shares * current_price)
UnrealizedPL float64 `json:"unrealized_pl"` // Unrealized profit/loss (current_value - total_cost_basis)
UnrealizedPLPct float64 `json:"unrealized_pl_pct"` // Unrealized profit/loss percentage
Currency string `json:"currency"` // Stock's native currency
BaseCurrency string `json:"base_currency"` // Portfolio's base currency
LastUpdated time.Time `json:"last_updated"` // When position was last calculated
// Dividend information
TotalDividends float64 `json:"total_dividends"` // Total dividends received in base currency
// Transaction summary
TotalBought float64 `json:"total_bought"` // Total shares bought
TotalSold float64 `json:"total_sold"` // Total shares sold
RealizedPL float64 `json:"realized_pl"` // Realized profit/loss from sales
}
// PositionSummary represents aggregated portfolio position data
type PositionSummary struct {
TotalValue float64 `json:"total_value"` // Total portfolio value
TotalCostBasis float64 `json:"total_cost_basis"` // Total amount invested
TotalUnrealizedPL float64 `json:"total_unrealized_pl"` // Total unrealized profit/loss
TotalRealizedPL float64 `json:"total_realized_pl"` // Total realized profit/loss
TotalDividends float64 `json:"total_dividends"` // Total dividends received
TotalReturn float64 `json:"total_return"` // Total return (realized + unrealized + dividends)
TotalReturnPct float64 `json:"total_return_pct"` // Total return percentage
Currency string `json:"currency"` // Portfolio base currency
LastUpdated time.Time `json:"last_updated"` // When summary was calculated
}
// IsOpen returns true if the position has shares (positive or negative)
func (p *Position) IsOpen() bool {
return p.Shares != 0
}
// IsLong returns true if the position has positive shares
func (p *Position) IsLong() bool {
return p.Shares > 0
}
// IsShort returns true if the position has negative shares
func (p *Position) IsShort() bool {
return p.Shares < 0
}
// GetDisplayValue returns the absolute value for display purposes
func (p *Position) GetDisplayValue() float64 {
if p.CurrentValue < 0 {
return -p.CurrentValue
}
return p.CurrentValue
}
// GetUnrealizedPLColor returns CSS class for profit/loss color
func (p *Position) GetUnrealizedPLColor() string {
if p.UnrealizedPL > 0 {
return "text-green"
} else if p.UnrealizedPL < 0 {
return "text-red"
}
return "text-muted"
}
// GetUnrealizedPLIcon returns icon for profit/loss
func (p *Position) GetUnrealizedPLIcon() string {
if p.UnrealizedPL > 0 {
return "trending-up"
} else if p.UnrealizedPL < 0 {
return "trending-down"
}
return "minus"
}
// FormatCurrency formats a value with currency symbol
func (p *Position) FormatCurrency(value float64) string {
switch p.BaseCurrency {
case "EUR":
return fmt.Sprintf("€%.2f", value)
case "USD":
return fmt.Sprintf("$%.2f", value)
case "GBP":
return fmt.Sprintf("£%.2f", value)
case "CHF":
return fmt.Sprintf("%.2f CHF", value)
default:
return fmt.Sprintf("%.2f %s", value, p.BaseCurrency)
}
}
// FormatShares formats share count with appropriate decimal places
func (p *Position) FormatShares() string {
if p.Shares == float64(int(p.Shares)) {
return fmt.Sprintf("%.0f", p.Shares)
}
return fmt.Sprintf("%.4f", p.Shares)
}
// FormatPercentage formats percentage with sign and color
func (p *Position) FormatPercentage() string {
if p.UnrealizedPLPct > 0 {
return fmt.Sprintf("+%.2f%%", p.UnrealizedPLPct)
}
return fmt.Sprintf("%.2f%%", p.UnrealizedPLPct)
}
+298
View File
@@ -0,0 +1,298 @@
package service
import (
"encoding/json"
"fmt"
"sort"
"time"
"portfolio-tracker/internal/model"
"portfolio-tracker/internal/util"
)
// PositionCalculator calculates positions from activities
type PositionCalculator struct{}
// NewPositionCalculator creates a new position calculator
func NewPositionCalculator() *PositionCalculator {
return &PositionCalculator{}
}
// CalculatePositions calculates all positions for a portfolio
func (pc *PositionCalculator) CalculatePositions(portfolio model.Portfolio) ([]model.Position, model.PositionSummary, error) {
// Group activities by stock
stockActivities := pc.groupActivitiesByStock(portfolio.Activities)
var positions []model.Position
var totalValue, totalCostBasis, totalUnrealizedPL, totalRealizedPL, totalDividends float64
for stock, activities := range stockActivities {
position, err := pc.calculateStockPosition(stock, activities, portfolio.BaseCurrency)
if err != nil {
fmt.Printf("Error calculating position for %s: %v\n", stock, err)
continue
}
// Only include positions that have shares or have had activity
if position.IsOpen() || position.TotalBought > 0 || position.TotalSold > 0 {
positions = append(positions, position)
// Add to portfolio totals
totalValue += position.CurrentValue
totalCostBasis += position.TotalCostBasis
totalUnrealizedPL += position.UnrealizedPL
totalRealizedPL += position.RealizedPL
totalDividends += position.TotalDividends
}
}
// Calculate total return
totalReturn := totalUnrealizedPL + totalRealizedPL + totalDividends
totalReturnPct := 0.0
if totalCostBasis > 0 {
totalReturnPct = (totalReturn / totalCostBasis) * 100
}
summary := model.PositionSummary{
TotalValue: totalValue,
TotalCostBasis: totalCostBasis,
TotalUnrealizedPL: totalUnrealizedPL,
TotalRealizedPL: totalRealizedPL,
TotalDividends: totalDividends,
TotalReturn: totalReturn,
TotalReturnPct: totalReturnPct,
Currency: portfolio.BaseCurrency,
LastUpdated: time.Now(),
}
// Sort positions by current value (descending)
sort.Slice(positions, func(i, j int) bool {
return positions[i].CurrentValue > positions[j].CurrentValue
})
return positions, summary, nil
}
// groupActivitiesByStock groups activities by stock symbol
func (pc *PositionCalculator) groupActivitiesByStock(activities []model.Activity) map[string][]model.Activity {
stockActivities := make(map[string][]model.Activity)
for _, activity := range activities {
stockActivities[activity.Stock] = append(stockActivities[activity.Stock], activity)
}
// Sort activities by date within each stock
for stock := range stockActivities {
sort.Slice(stockActivities[stock], func(i, j int) bool {
return stockActivities[stock][i].Date.Before(stockActivities[stock][j].Date)
})
}
return stockActivities
}
// calculateStockPosition calculates position for a single stock
func (pc *PositionCalculator) calculateStockPosition(stock string, activities []model.Activity, baseCurrency string) (model.Position, error) {
position := model.Position{
Stock: stock,
BaseCurrency: baseCurrency,
LastUpdated: time.Now(),
}
var totalShares float64
var totalCostBasis float64
var totalDividends float64
var totalBought float64
var totalSold float64
var realizedPL float64
// Process each activity
for _, activity := range activities {
// Handle GBp (pence) to GBP (pounds) conversion for historical transactions
activityPrice := activity.Price
activityCurrency := activity.Currency
if activity.Currency == "GBp" {
activityPrice = activity.Price / 100.0
activityCurrency = "GBP"
}
convertedAmount, err := pc.convertToBaseCurrency(activity.Amount, activityCurrency, baseCurrency, activity.Date)
if err != nil {
fmt.Printf("Warning: Could not convert currency for %s: %v\n", stock, err)
convertedAmount = activity.Amount // Use original amount as fallback
}
convertedPrice, err := pc.convertToBaseCurrency(activityPrice, activityCurrency, baseCurrency, activity.Date)
if err != nil {
fmt.Printf("Warning: Could not convert price for %s: %v\n", stock, err)
convertedPrice = activity.Price // Use original price as fallback
}
convertedFee, err := pc.convertToBaseCurrency(activity.Fee, activityCurrency, baseCurrency, activity.Date)
if err != nil {
convertedFee = activity.Fee // Use original fee as fallback
}
convertedTaxes, err := pc.convertToBaseCurrency(activity.Taxes, activityCurrency, baseCurrency, activity.Date)
if err != nil {
convertedTaxes = activity.Taxes // Use original taxes as fallback
}
switch activity.Type {
case model.Buy:
totalShares += activity.Amount
totalBought += activity.Amount
totalCostBasis += (convertedPrice * activity.Amount) + convertedFee + convertedTaxes
position.Currency = activityCurrency
case model.Sell:
if totalShares > 0 {
// Calculate realized P&L using average cost method
avgCostPerShare := totalCostBasis / totalShares
soldValue := (convertedPrice * activity.Amount) - convertedFee - convertedTaxes
soldCostBasis := avgCostPerShare * activity.Amount
realizedPL += soldValue - soldCostBasis
// Update totals
totalShares -= activity.Amount
totalSold += activity.Amount
totalCostBasis -= soldCostBasis
}
case model.Dividend:
totalDividends += convertedAmount
case model.Fee:
// Fees reduce the total return
totalDividends -= convertedAmount
case model.Tax:
// Taxes reduce the total return
totalDividends -= convertedAmount
}
}
// Calculate average cost price
averageCostPrice := 0.0
if totalShares > 0 {
averageCostPrice = totalCostBasis / totalShares
}
// Get current market price
currentPrice, err := pc.getCurrentPrice(stock, baseCurrency)
if err != nil {
fmt.Printf("Warning: Could not get current price for %s: %v\n", stock, err)
currentPrice = averageCostPrice // Use average cost as fallback
}
// Calculate current value and unrealized P&L
currentValue := totalShares * currentPrice
unrealizedPL := currentValue - totalCostBasis
unrealizedPLPct := 0.0
if totalCostBasis > 0 {
unrealizedPLPct = (unrealizedPL / totalCostBasis) * 100
}
position.Shares = totalShares
position.AverageCostPrice = averageCostPrice
position.TotalCostBasis = totalCostBasis
position.CurrentPrice = currentPrice
position.CurrentValue = currentValue
position.UnrealizedPL = unrealizedPL
position.UnrealizedPLPct = unrealizedPLPct
position.TotalDividends = totalDividends
position.TotalBought = totalBought
position.TotalSold = totalSold
position.RealizedPL = realizedPL
return position, nil
}
// convertToBaseCurrency converts amount to base currency
func (pc *PositionCalculator) convertToBaseCurrency(amount float64, fromCurrency, toCurrency string, date time.Time) (float64, error) {
if fromCurrency == toCurrency {
return amount, nil
}
return util.ConvertCurrencyHistorical(amount, fromCurrency, toCurrency, date)
}
// getCurrentPrice gets current market price for a stock
func (pc *PositionCalculator) getCurrentPrice(stock, baseCurrency string) (float64, error) {
// Try to get current price from Yahoo Finance
data, err := util.FetchYahooFinanceData(stock)
if err != nil {
return 0, fmt.Errorf("failed to fetch stock data: %v", err)
}
// Parse the JSON response
var resp util.YahooChartResponse
err = json.Unmarshal([]byte(data), &resp)
if err != nil {
return 0, fmt.Errorf("failed to parse stock data: %v", err)
}
if len(resp.Chart.Result) == 0 {
return 0, fmt.Errorf("no data found for stock %s", stock)
}
result := resp.Chart.Result[0]
// Get the latest price
var latestPrice float64
if len(result.Indicators.Quote) > 0 && len(result.Indicators.Quote[0].Close) > 0 {
prices := result.Indicators.Quote[0].Close
// Find the last non-null price
for i := len(prices) - 1; i >= 0; i-- {
if prices[i] != 0 {
latestPrice = prices[i]
break
}
}
}
if latestPrice == 0 {
return 0, fmt.Errorf("no valid price found for stock %s", stock)
}
// Convert to base currency if needed
stockCurrency := "USD" // Default to USD if not specified
if result.Meta.Currency != "" {
stockCurrency = result.Meta.Currency
}
// Handle GBp (pence) to GBP (pounds) conversion
if stockCurrency == "GBp" {
latestPrice = latestPrice / 100.0
stockCurrency = "GBP"
}
if stockCurrency != baseCurrency {
convertedPrice, err := util.ConvertCurrency(latestPrice, stockCurrency, baseCurrency)
if err != nil {
fmt.Printf("Warning: Could not convert price from %s to %s: %v\n", stockCurrency, baseCurrency, err)
return latestPrice, nil // Return original price if conversion fails
}
return convertedPrice, nil
}
return latestPrice, nil
}
// CalculatePositionForStock calculates position for a specific stock
func (pc *PositionCalculator) CalculatePositionForStock(stock string, activities []model.Activity, baseCurrency string) (model.Position, error) {
// Filter activities for this stock
var stockActivities []model.Activity
for _, activity := range activities {
if activity.Stock == stock {
stockActivities = append(stockActivities, activity)
}
}
// Sort by date
sort.Slice(stockActivities, func(i, j int) bool {
return stockActivities[i].Date.Before(stockActivities[j].Date)
})
return pc.calculateStockPosition(stock, stockActivities, baseCurrency)
}
+137 -92
View File
@@ -9,127 +9,172 @@ import (
)
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"`
Chart struct {
Result []struct {
Meta struct {
Symbol string `json:"symbol"`
RegularMarketPrice float64 `json:"regularMarketPrice"`
Exchange string `json:"exchangeName"`
Currency string `json:"currency"`
Instrument string `json:"instrumentType"`
FirstTrade int64 `json:"firstTradeDate"`
GMTOffset int64 `json:"gmtoffset"`
Timezone string `json:"exchangeTimezoneName"`
ShortName string `json:"shortName"`
LongName string `json:"longName"`
Range string `json:"range"`
DataGranularity string `json:"dataGranularity"`
PriceHint int `json:"priceHint"`
PreviousClose float64 `json:"previousClose"`
Scale int `json:"scale"`
HasPrePostMarket bool `json:"hasPrePostMarketData"`
TradingPeriods [][]struct {
Timezone string `json:"timezone"`
Start int64 `json:"start"`
End int64 `json:"end"`
GMTOffset int64 `json:"gmtoffset"`
} `json:"tradingPeriods"`
ValidRanges []string `json:"validRanges"`
} `json:"meta"`
Timestamp []int64 `json:"timestamp"`
Indicators struct {
Quote []struct {
Open []float64 `json:"open"`
High []float64 `json:"high"`
Low []float64 `json:"low"`
Close []float64 `json:"close"`
Volume []int64 `json:"volume"`
} `json:"quote"`
Adjclose []struct {
Adjclose []float64 `json:"adjclose"`
} `json:"adjclose,omitempty"`
} `json:"indicators"`
Events struct {
Dividends map[string]struct {
Amount float64 `json:"amount"`
Date int64 `json:"date"`
} `json:"dividends,omitempty"`
Splits map[string]struct {
Date int64 `json:"date"`
Numerator float64 `json:"numerator"`
Denominator float64 `json:"denominator"`
SplitRatio string `json:"splitRatio"`
} `json:"splits,omitempty"`
} `json:"events,omitempty"`
} `json:"result"`
Error interface{} `json:"error"`
} `json:"chart"`
}
func StockSearch(query string) (string, error) {
url := fmt.Sprintf("https://query2.finance.yahoo.com/v1/finance/search?q=%s", query)
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, 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")
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()
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)
}
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)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
}
return string(body), nil
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,
)
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, 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")
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()
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)
}
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)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
}
return string(body), nil
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,
)
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, 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")
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()
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)
}
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)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
}
return string(body), nil
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
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
}
labels := make([]string, len(timestamps))
for i, ts := range timestamps {
labels[i] = time.Unix(ts, 0).Format("2006-01-02")
}
return labels, closes, nil
}
+101 -20
View File
@@ -6,7 +6,7 @@ import (
"portfolio-tracker/internal/web/templates/components"
)
templ PortfolioDetailContent(portfolio model.Portfolio) {
templ PortfolioDetailContent(portfolio model.Portfolio, positions []model.Position, positionSummary model.PositionSummary) {
<div class="container-fluid mt-4">
<div class="page-header">
<div class="row g-2 align-items-center">
@@ -55,12 +55,40 @@ templ PortfolioDetailContent(portfolio model.Portfolio) {
</tr>
</thead>
<tbody>
if len(portfolio.Activities) > 0 {
<tr>
<td colspan="6" class="text-center text-muted py-4">
Position calculation will be implemented here
</td>
</tr>
if len(positions) > 0 {
for _, position := range positions {
if position.IsOpen() {
<tr>
<td>
<div class="fw-bold">{ position.Stock }</div>
<div class="text-muted small">{ position.Currency }</div>
</td>
<td>
<span class="fw-bold">{ position.FormatShares() }</span>
</td>
<td>
<span>{ position.FormatCurrency(position.AverageCostPrice) }</span>
</td>
<td>
<div>{ position.FormatCurrency(position.CurrentPrice) }</div>
<div class="text-muted small">{ position.FormatCurrency(position.CurrentValue) }</div>
</td>
<td>
<div class={ position.GetUnrealizedPLColor() }>
<strong>{ position.FormatCurrency(position.UnrealizedPL) }</strong>
</div>
<div class={ position.GetUnrealizedPLColor() + " small" }>
{ position.FormatPercentage() }
</div>
</td>
<td>
<a href={ templ.URL("/details?stock=" + position.Stock) } class="btn btn-sm btn-outline-primary">
Details
</a>
</td>
</tr>
}
}
} else {
<tr>
<td colspan="6" class="text-center text-muted py-4">
@@ -208,7 +236,7 @@ templ PortfolioDetailContent(portfolio model.Portfolio) {
<h3 class="card-title">Portfolio-Zusammenfassung</h3>
</div>
<div class="card-body">
@PortfolioSummary(portfolio.Activities, portfolio.BaseCurrency)
@PortfolioSummary(positionSummary, len(portfolio.Activities))
</div>
</div>
<div class="card mt-3">
@@ -396,32 +424,85 @@ templ PortfolioDetailContent(portfolio model.Portfolio) {
}
// Separate component for portfolio summary
templ PortfolioSummary(activities []model.Activity, currency string) {
templ PortfolioSummary(summary model.PositionSummary, transactionCount int) {
<div class="row">
<div class="col-12">
<div class="mb-3">
<div class="text-muted">Anzahl Transaktionen</div>
<div class="h3 mb-0">{ fmt.Sprintf("%d", len(activities)) }</div>
<div class="h3 mb-0">{ fmt.Sprintf("%d", transactionCount) }</div>
</div>
<div class="mb-3">
<div class="text-muted">Aktueller Portfoliowert</div>
<div class="h2 mb-0">
{ formatCurrency(summary.TotalValue, summary.Currency) }
</div>
</div>
<div class="mb-3">
<div class="text-muted">Gesamtwert investiert</div>
<div class="h2 mb-0">
{ formatTotalInvestedWithConversion(activities, currency) }
</div>
<div class="text-muted small">
* = Aktueller Kurs verwendet (historischer Kurs nicht verfügbar)
<div class="h3 mb-0">
{ formatCurrency(summary.TotalCostBasis, summary.Currency) }
</div>
</div>
<div class="mb-3">
<div class="text-muted">Letzter Kauf</div>
<div class="h4 mb-0 text-muted">
{ getLastBuyDate(activities) }
<div class="text-muted">Unrealisierte Gewinne/Verluste</div>
<div class={ getColorClass(summary.TotalUnrealizedPL) + " h3 mb-0" }>
{ formatCurrency(summary.TotalUnrealizedPL, summary.Currency) }
if summary.TotalCostBasis > 0 {
<small class="ms-2">({ formatPercentage(summary.TotalUnrealizedPL / summary.TotalCostBasis * 100) })</small>
}
</div>
</div>
<div class="mb-3">
<div class="text-muted">Dividenden erhalten</div>
<div class="h4 mb-0 text-success">
{ formatCurrency(summary.TotalDividends, summary.Currency) }
</div>
</div>
<div class="mb-3">
<div class="text-muted">Gesamtrendite</div>
<div class={ getColorClass(summary.TotalReturn) + " h3 mb-0" }>
{ formatCurrency(summary.TotalReturn, summary.Currency) }
if summary.TotalCostBasis > 0 {
<small class="ms-2">({ formatPercentage(summary.TotalReturnPct) })</small>
}
</div>
</div>
</div>
</div>
}
templ PortfolioDetail(authenticated bool, username string, portfolio model.Portfolio, portfolios []model.Portfolio) {
@components.PageLayout(authenticated, username, portfolio.Name, PortfolioDetailContent(portfolio), portfolios)
// Helper functions for formatting
func formatCurrency(value float64, currency string) string {
switch currency {
case "EUR":
return fmt.Sprintf("€%.2f", value)
case "USD":
return fmt.Sprintf("$%.2f", value)
case "GBP":
return fmt.Sprintf("£%.2f", value)
case "CHF":
return fmt.Sprintf("%.2f CHF", value)
default:
return fmt.Sprintf("%.2f %s", value, currency)
}
}
func formatPercentage(value float64) string {
if value > 0 {
return fmt.Sprintf("+%.2f%%", value)
}
return fmt.Sprintf("%.2f%%", value)
}
func getColorClass(value float64) string {
if value > 0 {
return "text-success"
} else if value < 0 {
return "text-danger"
}
return "text-muted"
}
templ PortfolioDetail(authenticated bool, username string, portfolio model.Portfolio, portfolios []model.Portfolio, positions []model.Position, positionSummary model.PositionSummary) {
@components.PageLayout(authenticated, username, portfolio.Name, PortfolioDetailContent(portfolio, positions, positionSummary), portfolios)
}
File diff suppressed because it is too large Load Diff