diff --git a/internal/analysis/portfolio_analysis.go b/internal/analysis/portfolio_analysis.go index 608146c..16fc69b 100644 --- a/internal/analysis/portfolio_analysis.go +++ b/internal/analysis/portfolio_analysis.go @@ -2,7 +2,7 @@ package analysis import ( "context" - "fmt" + "errors" "sort" "github.com/squeakycheese75/tick/internal/domain" @@ -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 @@ -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 " + } + + return "" +} + +func cleanValuationMessage(err error) string { + if errors.Is(err, domain.ErrConsumedPriceNotFound) { + return "Missing consumed price" + } + + return "Missing price" +} diff --git a/internal/domain/analysis.go b/internal/domain/analysis.go index 257906a..a552f54 100644 --- a/internal/domain/analysis.go +++ b/internal/domain/analysis.go @@ -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 } diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 56397d4..ee9aae0 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -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") ) diff --git a/internal/domain/report.go b/internal/domain/report.go index 21b713b..5187f85 100644 --- a/internal/domain/report.go +++ b/internal/domain/report.go @@ -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 { diff --git a/internal/market/cached_provider.go b/internal/market/cached_provider.go index cde36a6..c1a8c77 100644 --- a/internal/market/cached_provider.go +++ b/internal/market/cached_provider.go @@ -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) } diff --git a/internal/market/consumed.go b/internal/market/consumed.go index 073e737..34dc176 100644 --- a/internal/market/consumed.go +++ b/internal/market/consumed.go @@ -2,6 +2,8 @@ package market import ( "context" + "errors" + "fmt" "time" "github.com/squeakycheese75/tick/internal/domain" @@ -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 `", + in.Symbol, + ) + } + return domain.Quote{}, err } diff --git a/internal/render/daily.go b/internal/render/daily.go index b7f73b0..18ed3f9 100644 --- a/internal/render/daily.go +++ b/internal/render/daily.go @@ -13,67 +13,22 @@ func DailyReport(w io.Writer, s domain.GetDailyReportOutput, opts DailyReportOpt r := s.DailyReport renderPortfolioSummary(out, r.Portfolio, opts.Summary) - out.println("") - renderHoldingSummary(out, r.TopHoldings, r.Portfolio.BaseCurrency, opts.Holdings) - out.println("") - + renderValuationIssuesSummary(out, r.ValuationIssues) renderRiskSummary(out, r.Risk, opts.Risk) - out.println("") - renderNewsSummary(out, r.News, opts.News) renderTargets(out, r.Targets) - - if opts.ShowAttention && len(r.Attention) > 0 { - out.println("") - out.println("Attention") - for _, item := range r.Attention { - out.printf("- %s\n", item) - } - } - - if opts.AI.Show && s.AISummary != "" { - out.println("") - out.println("AI Summary") - for _, line := range strings.Split(s.AISummary, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - out.println(line) - } - } + renderAttentionSummary(out, r.Attention) + renderAISummary(out, s.AISummary, opts.AI) return out.err } -func renderTargets(out *writer, targets []domain.TargetStatus) { - if len(targets) == 0 { - return - } - - out.printf("\nTargets\n") - - for _, t := range targets { - marker := "" - if t.Hit { - marker = " ! hit" - } - - out.printf( - "%-6s %-11s target %12.2f %s current %12.2f %s%s\n", - t.Symbol, - t.Type, - t.TargetPrice, - t.Currency, - t.CurrentPrice, - t.Currency, - marker, - ) - } -} - -func renderPortfolioSummary(out *writer, r domain.PortfolioSummary, opts SummaryOptions) { +func renderPortfolioSummary( + out *writer, + r domain.PortfolioSummary, + opts SummaryOptions, +) { out.printf("%s %s", r.Name, formatMoney(r.TotalValue, r.BaseCurrency)) if opts.ShowSnapshotDelta && @@ -82,12 +37,23 @@ func renderPortfolioSummary(out *writer, r domain.PortfolioSummary, opts Summary r.Change.Absolute, r.Change.Percent, )) { - out.printf( - " Δ %s (%s)", - formatSignedMoney(r.Change.Absolute, r.BaseCurrency), - formatSignedPercentFromRatio(r.Change.Percent), - ) + + // Likely first snapshot / no baseline. + if r.Change.Absolute != 0 && r.Change.Percent == 0 { + out.printf( + " new snapshot (%s)", + formatSignedMoney(r.Change.Absolute, r.BaseCurrency), + ) + } else { + out.printf( + " Δ %s (%s)", + formatSignedMoney(r.Change.Absolute, r.BaseCurrency), + formatSignedPercentFromRatio(r.Change.Percent), + ) + } } + + out.println("") } func renderHoldingSummary( @@ -115,6 +81,8 @@ func renderHoldingRows( opts HoldingsOptions, showAbsChange bool, ) { + out.println("") + title := opts.Title if title == "" { if opts.ShowTop > 0 { @@ -126,7 +94,7 @@ func renderHoldingRows( out.println(title) if len(r.Holdings) == 0 { - out.println("No positions") + out.println("No priced positions") return } @@ -167,7 +135,28 @@ func renderHoldingRows( } } +func renderValuationIssuesSummary(out *writer, issues []domain.ValuationIssue) { + if len(issues) == 0 { + return + } + + out.println("") + out.println("Unpriced") + + for _, issue := range issues { + out.printf( + "%-28s %12s %-8s %s\n", + issue.Symbol, + formatQuantity(issue.Quantity), + issue.InstrumentType, + issue.Message, + ) + } +} + func renderRiskSummary(out *writer, r domain.RiskSummary, opts RiskOptions) { + out.println("") + if r.LargestPosition == "" { out.println("Risk No data") return @@ -184,6 +173,7 @@ func renderRiskSummary(out *writer, r domain.RiskSummary, opts RiskOptions) { if label := riskLabel(r); label != "" { out.printf(" ! %s", label) } + out.println("") return } @@ -199,18 +189,8 @@ func renderRiskSummary(out *writer, r domain.RiskSummary, opts RiskOptions) { } } -func riskLabel(r domain.RiskSummary) string { - switch { - case r.LargestWeight >= 0.80: - return "High concentration" - case r.Top3Concentration >= 0.80: - return "Concentrated portfolio" - default: - return "" - } -} - func renderNewsSummary(out *writer, groups []domain.NewsSummary, opts NewsOptions) { + out.println("") out.println("News") if len(groups) == 0 { @@ -255,3 +235,72 @@ func renderNewsSummary(out *writer, groups []domain.NewsSummary, opts NewsOption out.println("No news") } } + +func renderTargets(out *writer, targets []domain.TargetStatus) { + if len(targets) == 0 { + return + } + + out.println("") + out.println("Targets") + + for _, t := range targets { + marker := "" + if t.Hit { + marker = " ! hit" + } + + out.printf( + "%-6s %-11s target %12.2f %s current %12.2f %s%s\n", + t.Symbol, + t.Type, + t.TargetPrice, + t.Currency, + t.CurrentPrice, + t.Currency, + marker, + ) + } +} + +func renderAttentionSummary(out *writer, attention []string) { + if len(attention) == 0 { + return + } + + out.println("") + out.println("Attention") + + for _, item := range attention { + out.printf("- %s\n", item) + } +} + +func renderAISummary(out *writer, summary string, opts AIOptions) { + if !opts.Show || strings.TrimSpace(summary) == "" { + return + } + + out.println("") + out.println("AI Summary") + + for _, line := range strings.Split(summary, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + out.println(line) + } +} + +func riskLabel(r domain.RiskSummary) string { + switch { + case r.LargestWeight >= 0.80: + return "High concentration" + case r.Top3Concentration >= 0.80: + return "Concentrated portfolio" + default: + return "" + } +} diff --git a/internal/report/daily.go b/internal/report/daily.go index b58a050..77fd3fe 100644 --- a/internal/report/daily.go +++ b/internal/report/daily.go @@ -60,9 +60,10 @@ func (s *ReportBuilder) buildDailyReportFromAnalysis( topPositions := s.insights.TopHoldings(analysis, 10) return domain.DailyReport{ - Portfolio: assemblePortfolioSummary(analysis), - TopHoldings: assembleHoldingSummary(topPositions), - Risk: assembleRiskSummary(risk), - Attention: s.insights.AttentionSignals(analysis, risk), + Portfolio: assemblePortfolioSummary(analysis), + TopHoldings: assembleHoldingSummary(topPositions), + Risk: assembleRiskSummary(risk), + Attention: s.insights.AttentionSignals(analysis, risk), + ValuationIssues: analysis.ValuationIssues, } } diff --git a/internal/repository/consumed_price_repo.go b/internal/repository/consumed_price_repo.go index 2e8e3d7..8567a77 100644 --- a/internal/repository/consumed_price_repo.go +++ b/internal/repository/consumed_price_repo.go @@ -44,7 +44,7 @@ func (r *ConsumedPriceRepository) GetLatest( ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return ConsumedPrice{}, domain.ErrPriceCacheNotFound + return ConsumedPrice{}, domain.ErrConsumedPriceNotFound } return ConsumedPrice{}, err diff --git a/internal/service/insights.go b/internal/service/insights.go index 716c512..e70a92d 100644 --- a/internal/service/insights.go +++ b/internal/service/insights.go @@ -38,15 +38,26 @@ func (b *InsightsSvc) AttentionSignals( ) []string { attention := make([]string, 0) - if len(portfolioAnalysis.AnalyzedPositions) == 0 { + if len(portfolioAnalysis.AnalyzedPositions) == 0 && + len(portfolioAnalysis.ValuationIssues) == 0 { return append(attention, "Portfolio is empty") } + if len(portfolioAnalysis.ValuationIssues) > 0 { + attention = append( + attention, + fmt.Sprintf( + "%d position(s) could not be valued", + len(portfolioAnalysis.ValuationIssues), + ), + ) + } + if portfolioRisk.LargestWeight >= 0.20 { attention = append( attention, fmt.Sprintf( - "%s is %.2f%% of the portfolio", + "%s is %.2f%% of the priced portfolio", portfolioRisk.LargestPosition, portfolioRisk.LargestWeight*100, ), @@ -57,14 +68,14 @@ func (b *InsightsSvc) AttentionSignals( attention = append( attention, fmt.Sprintf( - "Top 3 positions are %.2f%% of the portfolio", + "Top 3 positions are %.2f%% of the priced portfolio", portfolioRisk.Top3Concentration*100, ), ) } if len(attention) == 0 { - attention = append(attention, "No major portfolio concentration issues detected") + attention = append(attention, "No major portfolio issues detected") } return attention