299 lines
9.2 KiB
Go
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)
|
|
}
|