Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions internal/analysis/portfolio_analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package analysis

import (
"context"
"fmt"
"errors"
"sort"

"github.com/squeakycheese75/tick/internal/domain"
Expand All @@ -28,12 +28,21 @@ func (a *PortfolioAnalyzer) Analyze(ctx context.Context, in AnalyzePortfolioInpu
PortfolioName: in.Portfolio.Name,
BaseCurrency: in.Portfolio.BaseCurrency,
AnalyzedPositions: make([]domain.AnalyzedPosition, 0, len(in.Positions)),
ValuationIssues: make([]domain.ValuationIssue, 0),
}

for _, pos := range in.Positions {
valuationQuote, err := a.pricingSvc.GetValuationQuote(ctx, pos.Instrument.Symbol, pos.Instrument.ProviderSymbol, in.Portfolio.BaseCurrency, pos.Instrument.QuoteCurrency, string(pos.Instrument.InstrumentType))
if err != nil {
return domain.PortfolioAnalysis{}, fmt.Errorf("get valuation quote for %s: %w", pos.Instrument.Symbol, err)
result.ValuationIssues = append(result.ValuationIssues, domain.ValuationIssue{
Symbol: pos.Instrument.Symbol,
InstrumentType: string(pos.Instrument.InstrumentType),
Quantity: pos.Quantity,
Type: classifyValuationIssue(err),
Message: cleanValuationMessage(err),
Hint: valuationHint(pos),
})
continue
}

marketValueBase := pos.Quantity * valuationQuote.ConvertedPrice
Expand Down Expand Up @@ -67,3 +76,30 @@ func (a *PortfolioAnalyzer) Analyze(ctx context.Context, in AnalyzePortfolioInpu

return result, nil
}

func classifyValuationIssue(err error) domain.ValuationIssueType {
switch {
case errors.Is(err, domain.ErrConsumedPriceNotFound):
return domain.ValuationIssueMissingPrice
case errors.Is(err, domain.ErrFXRateNotFound):
return domain.ValuationIssueMissingFX
default:
return domain.ValuationIssueProvider
}
}

func valuationHint(pos domain.Position) string {
if pos.Instrument.InstrumentType == domain.InstrumentTypeFund {
return "tick prices consume --file <file>"
}

return ""
}

func cleanValuationMessage(err error) string {
if errors.Is(err, domain.ErrConsumedPriceNotFound) {
return "Missing consumed price"
}

return "Missing price"
}
18 changes: 18 additions & 0 deletions internal/domain/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,22 @@ type PortfolioAnalysis struct {
BaseCurrency string
AnalyzedPositions []AnalyzedPosition
TotalValue float64
ValuationIssues []ValuationIssue
}

type ValuationIssueType string

const (
ValuationIssueMissingPrice ValuationIssueType = "missing_price"
ValuationIssueMissingFX ValuationIssueType = "missing_fx"
ValuationIssueProvider ValuationIssueType = "provider_error"
)

type ValuationIssue struct {
Symbol string
InstrumentType string
Quantity float64
Type ValuationIssueType
Message string
Hint string
}
2 changes: 2 additions & 0 deletions internal/domain/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ var (
ErrPositionAlreadyExists = errors.New("position already exists")
ErrInstrumentAlreadyExists = errors.New("instrument already exists")
ErrPriceCacheNotFound = errors.New("price cache not found")
ErrConsumedPriceNotFound = errors.New("consumed price not found")
ErrFXCacheNotFound = errors.New("fx cache not found")
ErrPortfolioSnapshotNotFound = errors.New("portfolio snapshot not found")
ErrTargetNotFound = errors.New("target not found")
ErrFXRateNotFound = errors.New("fx rate not found")
)
13 changes: 7 additions & 6 deletions internal/domain/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ type DailyNews struct {
}

type DailyReport struct {
Portfolio PortfolioSummary
TopHoldings HoldingSummary
Risk RiskSummary
News []NewsSummary
Attention []string
Targets []TargetStatus
Portfolio PortfolioSummary
TopHoldings HoldingSummary
Risk RiskSummary
News []NewsSummary
Attention []string
Targets []TargetStatus
ValuationIssues []ValuationIssue
}

type TargetStatus struct {
Expand Down
2 changes: 1 addition & 1 deletion internal/market/cached_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func (p *CachedFXProvider) GetRate(ctx context.Context, baseCurrency, quoteCurre
return toDomainFXRate(cached), nil
}

case !errors.Is(err, domain.ErrFXCacheNotFound):
case !errors.Is(err, domain.ErrFXRateNotFound):
return domain.FXRate{}, fmt.Errorf("get cached fx rate for %s/%s: %w", base, quote, err)
}

Expand Down
9 changes: 9 additions & 0 deletions internal/market/consumed.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package market

import (
"context"
"errors"
"fmt"
"time"

"github.com/squeakycheese75/tick/internal/domain"
Expand Down Expand Up @@ -32,6 +34,13 @@ func (p *ConsumedPriceProvider) GetQuote(
) (domain.Quote, error) {
price, err := p.repo.GetLatest(ctx, in.Symbol)
if err != nil {
if errors.Is(err, domain.ErrConsumedPriceNotFound) {
return domain.Quote{}, fmt.Errorf(
"no consumed price found for %s; run `tick prices consume --file <file>`",
in.Symbol,
)
}

return domain.Quote{}, err
}

Expand Down
Loading