added first implementation of position list
This commit is contained in:
Binary file not shown.
@@ -83,7 +83,7 @@ func getStockCurrency(stock string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse the JSON response
|
// Parse the JSON response
|
||||||
var resp model.YahooChartResponse
|
var resp util.YahooChartResponse
|
||||||
err = json.Unmarshal([]byte(data), &resp)
|
err = json.Unmarshal([]byte(data), &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"portfolio-tracker/internal/model"
|
"portfolio-tracker/internal/model"
|
||||||
|
"portfolio-tracker/internal/service"
|
||||||
"portfolio-tracker/internal/web/templates"
|
"portfolio-tracker/internal/web/templates"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -53,10 +54,30 @@ func PortfolioDetailHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Get all user portfolios for navigation
|
||||||
portfolios := getUserPortfolios(username)
|
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)
|
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)
|
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
|
// Create activity
|
||||||
activity := model.Activity{
|
activity := model.Activity{
|
||||||
PortfolioID: uint(portfolioID),
|
PortfolioID: uint(portfolioID),
|
||||||
@@ -317,6 +346,14 @@ func EditTransactionHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
stockCurrency = portfolio.BaseCurrency
|
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
|
// Update activity fields
|
||||||
activity.Type = model.ActivityType(transactionType)
|
activity.Type = model.ActivityType(transactionType)
|
||||||
activity.Stock = stock
|
activity.Stock = stock
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -9,127 +9,172 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type YahooChartResponse struct {
|
type YahooChartResponse struct {
|
||||||
Chart struct {
|
Chart struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
Timestamp []int64 `json:"timestamp"`
|
Meta struct {
|
||||||
Indicators struct {
|
Symbol string `json:"symbol"`
|
||||||
Quote []struct {
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||||
Close []float64 `json:"close"`
|
Exchange string `json:"exchangeName"`
|
||||||
} `json:"quote"`
|
Currency string `json:"currency"`
|
||||||
} `json:"indicators"`
|
Instrument string `json:"instrumentType"`
|
||||||
} `json:"result"`
|
FirstTrade int64 `json:"firstTradeDate"`
|
||||||
} `json:"chart"`
|
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) {
|
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)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Erstellen der Anfrage: %v", err)
|
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{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(body), nil
|
return string(body), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchYahooFinanceData(stock string) (string, error) {
|
func FetchYahooFinanceData(stock string) (string, error) {
|
||||||
url := fmt.Sprintf(
|
url := fmt.Sprintf(
|
||||||
"https://query2.finance.yahoo.com/v8/finance/chart/%s?range=1y&interval=1d&events=history",
|
"https://query2.finance.yahoo.com/v8/finance/chart/%s?range=1y&interval=1d&events=history",
|
||||||
stock,
|
stock,
|
||||||
)
|
)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Erstellen der Anfrage: %v", err)
|
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{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(body), nil
|
return string(body), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchYahooFinanceDataMax(stock string) (string, error) {
|
func FetchYahooFinanceDataMax(stock string) (string, error) {
|
||||||
url := fmt.Sprintf(
|
url := fmt.Sprintf(
|
||||||
"https://query2.finance.yahoo.com/v8/finance/chart/%s?range=max&interval=1mo&events=div",
|
"https://query2.finance.yahoo.com/v8/finance/chart/%s?range=max&interval=1mo&events=div",
|
||||||
stock,
|
stock,
|
||||||
)
|
)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Erstellen der Anfrage: %v", err)
|
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{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
return "", fmt.Errorf("Fehler beim Abrufen der URL: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
return "", fmt.Errorf("Fehler: HTTP Status %s", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("Fehler beim Lesen der Antwort: %v", err)
|
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
|
// Neu: Daten für das Chart extrahieren
|
||||||
func ExtractChartData(jsonStr string) ([]string, []float64, error) {
|
func ExtractChartData(jsonStr string) ([]string, []float64, error) {
|
||||||
var data YahooChartResponse
|
var data YahooChartResponse
|
||||||
err := json.Unmarshal([]byte(jsonStr), &data)
|
err := json.Unmarshal([]byte(jsonStr), &data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("Fehler beim Parsen des JSON: %v", err)
|
return nil, nil, fmt.Errorf("Fehler beim Parsen des JSON: %v", err)
|
||||||
}
|
}
|
||||||
if len(data.Chart.Result) == 0 {
|
if len(data.Chart.Result) == 0 {
|
||||||
return nil, nil, fmt.Errorf("Keine Daten gefunden")
|
return nil, nil, fmt.Errorf("Keine Daten gefunden")
|
||||||
}
|
}
|
||||||
timestamps := data.Chart.Result[0].Timestamp
|
timestamps := data.Chart.Result[0].Timestamp
|
||||||
closes := data.Chart.Result[0].Indicators.Quote[0].Close
|
closes := data.Chart.Result[0].Indicators.Quote[0].Close
|
||||||
|
|
||||||
labels := make([]string, len(timestamps))
|
labels := make([]string, len(timestamps))
|
||||||
for i, ts := range timestamps {
|
for i, ts := range timestamps {
|
||||||
labels[i] = time.Unix(ts, 0).Format("2006-01-02")
|
labels[i] = time.Unix(ts, 0).Format("2006-01-02")
|
||||||
}
|
}
|
||||||
return labels, closes, nil
|
return labels, closes, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"portfolio-tracker/internal/web/templates/components"
|
"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="container-fluid mt-4">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="row g-2 align-items-center">
|
<div class="row g-2 align-items-center">
|
||||||
@@ -55,12 +55,40 @@ templ PortfolioDetailContent(portfolio model.Portfolio) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
if len(portfolio.Activities) > 0 {
|
if len(positions) > 0 {
|
||||||
<tr>
|
for _, position := range positions {
|
||||||
<td colspan="6" class="text-center text-muted py-4">
|
if position.IsOpen() {
|
||||||
Position calculation will be implemented here
|
<tr>
|
||||||
</td>
|
<td>
|
||||||
</tr>
|
<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 {
|
} else {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center text-muted py-4">
|
<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>
|
<h3 class="card-title">Portfolio-Zusammenfassung</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@PortfolioSummary(portfolio.Activities, portfolio.BaseCurrency)
|
@PortfolioSummary(positionSummary, len(portfolio.Activities))
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
@@ -396,32 +424,85 @@ templ PortfolioDetailContent(portfolio model.Portfolio) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Separate component for portfolio summary
|
// Separate component for portfolio summary
|
||||||
templ PortfolioSummary(activities []model.Activity, currency string) {
|
templ PortfolioSummary(summary model.PositionSummary, transactionCount int) {
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-muted">Anzahl Transaktionen</div>
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-muted">Gesamtwert investiert</div>
|
<div class="text-muted">Gesamtwert investiert</div>
|
||||||
<div class="h2 mb-0">
|
<div class="h3 mb-0">
|
||||||
{ formatTotalInvestedWithConversion(activities, currency) }
|
{ formatCurrency(summary.TotalCostBasis, summary.Currency) }
|
||||||
</div>
|
|
||||||
<div class="text-muted small">
|
|
||||||
* = Aktueller Kurs verwendet (historischer Kurs nicht verfügbar)
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-muted">Letzter Kauf</div>
|
<div class="text-muted">Unrealisierte Gewinne/Verluste</div>
|
||||||
<div class="h4 mb-0 text-muted">
|
<div class={ getColorClass(summary.TotalUnrealizedPL) + " h3 mb-0" }>
|
||||||
{ getLastBuyDate(activities) }
|
{ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ PortfolioDetail(authenticated bool, username string, portfolio model.Portfolio, portfolios []model.Portfolio) {
|
// Helper functions for formatting
|
||||||
@components.PageLayout(authenticated, username, portfolio.Name, PortfolioDetailContent(portfolio), portfolios)
|
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
Reference in New Issue
Block a user