Files
portfolio-tracker/internal/service/position_calculator.go
T
2025-07-05 05:10:42 +02:00

299 lines
9.2 KiB
Go

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