added first implementation of position list
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user