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) }