From 46ba33bf1d43f286c5948575d9f707310e218e9d Mon Sep 17 00:00:00 2001 From: squeakycheese75 Date: Mon, 27 Apr 2026 12:30:03 +0200 Subject: [PATCH 1/4] feat: added brief report, refactoring report builder --- internal/adapters/news/newsapi_provider.go | 8 +- internal/adapters/news/static_provider.go | 4 +- internal/app/config.go | 4 +- internal/app/factory.go | 2 +- internal/app/runtime.go | 9 +- internal/cli/brief.go | 29 ++++ internal/cli/root.go | 1 + internal/domain/report.go | 62 +++++-- internal/domain/usecase.go | 25 +++ internal/render/brief.go | 20 +++ internal/render/daily.go | 44 ++--- internal/render/daily_test.go | 65 +++++++ internal/report/builder.go | 52 ++++++ internal/report/daily_report.go | 158 ++++-------------- internal/report/morning_report.go | 44 +++++ internal/report/shared.go | 117 +++++++++++++ internal/repository/portfolio_repo.go | 54 ------ .../repository/portfolio_snapshot_repo.go | 68 -------- internal/repository/snapshot_repo.go | 124 ++++++++++++++ internal/service/daily_report_prompts.go | 12 +- internal/service/news.go | 6 +- internal/service/snaphot.go | 110 ++++++++++++ internal/usecase/daily_report_get.go | 158 +++++++++++------- internal/usecase/mocks/mock_interfaces.go | 75 +++++++-- internal/usecase/morning_brief.go | 33 ++++ internal/usecase/news_ticker_get.go | 4 +- .../usecase/portfolio_add_position_test.go | 55 ------ internal/usecase/portfolio_add_positon.go | 7 - internal/usecase/usecase.go | 9 +- 29 files changed, 915 insertions(+), 444 deletions(-) create mode 100644 internal/cli/brief.go create mode 100644 internal/render/brief.go create mode 100644 internal/render/daily_test.go create mode 100644 internal/report/builder.go create mode 100644 internal/report/morning_report.go create mode 100644 internal/report/shared.go delete mode 100644 internal/repository/portfolio_snapshot_repo.go create mode 100644 internal/repository/snapshot_repo.go create mode 100644 internal/service/snaphot.go create mode 100644 internal/usecase/morning_brief.go diff --git a/internal/adapters/news/newsapi_provider.go b/internal/adapters/news/newsapi_provider.go index b2b6b0c..02f55df 100644 --- a/internal/adapters/news/newsapi_provider.go +++ b/internal/adapters/news/newsapi_provider.go @@ -32,7 +32,7 @@ func (p *NewsAPIProvider) GetNews( ctx context.Context, ticker string, limit int, -) (domain.TickerNewsReport, error) { +) (domain.NewsSummary, error) { query := buildQuery(ticker) @@ -49,7 +49,7 @@ func (p *NewsAPIProvider) GetNews( resp, err := p.httpClient.Do(req) if err != nil { - return domain.TickerNewsReport{}, err + return domain.NewsSummary{}, err } defer func() { _ = resp.Body.Close() @@ -62,10 +62,10 @@ func (p *NewsAPIProvider) GetNews( } if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return domain.TickerNewsReport{}, err + return domain.NewsSummary{}, err } - out := domain.TickerNewsReport{ + out := domain.NewsSummary{ Ticker: ticker, } diff --git a/internal/adapters/news/static_provider.go b/internal/adapters/news/static_provider.go index 8d57509..b49d44d 100644 --- a/internal/adapters/news/static_provider.go +++ b/internal/adapters/news/static_provider.go @@ -36,8 +36,8 @@ func (p *StaticProvider) GetNews( _ context.Context, symbol string, limit int, -) (domain.TickerNewsReport, error) { - report := domain.TickerNewsReport{ +) (domain.NewsSummary, error) { + report := domain.NewsSummary{ Ticker: symbol, } diff --git a/internal/app/config.go b/internal/app/config.go index 8e09047..66143c7 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -142,8 +142,9 @@ func (c Config) String() string { } rows := []kv{ - {"PRICE_PROVIDER", c.EquityPriceProvider}, + {"EQUITY_PRICE_PROVIDER", c.EquityPriceProvider}, {"FX_PROVIDER", c.FXProvider}, + {"CRYPTO_PRICE_PROVIDER", c.CryptoPriceProvider}, {"CACHE_ENABLED", fmt.Sprintf("%t", c.CacheEnabled)}, {"CACHE_PRICE_TTL", c.PriceCacheTTL.String()}, {"CACHE_FX_TTL", c.FXCacheTTL.String()}, @@ -158,6 +159,7 @@ func (c Config) String() string { {"", ""}, // spacer {"FINNHUB_API_KEY", mask(c.FinnhubAPIKey)}, + {"NEWSAPIORG_API_KEY", mask(c.NewsAPIOrgAPIKey)}, } // find max key length diff --git a/internal/app/factory.go b/internal/app/factory.go index f93c2fe..13439d8 100644 --- a/internal/app/factory.go +++ b/internal/app/factory.go @@ -29,7 +29,7 @@ type ( Upsert(ctx context.Context, cached repository.CachedFXRate) error } NewsProvider interface { - GetNews(ctx context.Context, symbol string, limit int) (domain.TickerNewsReport, error) + GetNews(ctx context.Context, symbol string, limit int) (domain.NewsSummary, error) } LLMProvider interface { Complete(ctx context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 261433c..8b495f2 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -18,6 +18,7 @@ type Runtime struct { GetDailyReport *usecase.GetDailyReportUseCase ImportPortfolio *usecase.ImportPortfolioUseCase GetTickerNews *usecase.GetTickerNewsUseCase + GetMorningBrief *usecase.GetMorningBriefUsecase } func BuildRuntime(dbPath string) (*Runtime, error) { @@ -38,7 +39,7 @@ func BuildRuntime(dbPath string) (*Runtime, error) { portfolioRepo := repository.NewPortfolioRepository(database) positionRepo := repository.NewPositionRepository(database) instrumentRepo := repository.NewInstrumentRepository(database) - portfolioSnapshotRepo := repository.NewPortfolioSnapshotRepository(database) + snapshotRepo := repository.NewSnapshotRepository(database) // Caching priceCacher := repository.NewPriceCacheRepository(database) @@ -79,13 +80,14 @@ func BuildRuntime(dbPath string) (*Runtime, error) { portfolioSvc := service.NewPortfolioService(portfolioRepo, positionRepo, portfolioAnalyser, riskAnalyser) portfolioInsights := service.NewPortfolioInsights() newsSvc := service.NewNewsService(newsProvider) + snapshotSvc := service.NewSnapshotService(snapshotRepo) instrumentResolver, err := instruments.NewStaticResolver() if err != nil { return nil, err } - reportingBuilder := report.NewReportBuilder(portfolioSvc, newsSvc, portfolioInsights) + reportingBuilder := report.NewReportBuilder(portfolioSvc, pricingSvc, newsSvc, portfolioInsights, snapshotSvc) var summarizer usecase.DailyReportSummarizer = service.NoopSummarizer{} @@ -102,8 +104,9 @@ func BuildRuntime(dbPath string) (*Runtime, error) { GetPortfolioRisk: usecase.NewGetPortfolioRiskUseCase( portfolioSvc, ), - GetDailyReport: usecase.NewGetDailyReportUseCase(reportingBuilder, summarizer, portfolioSnapshotRepo), + GetDailyReport: usecase.NewGetDailyReportUseCase(reportingBuilder, summarizer, snapshotRepo), ImportPortfolio: usecase.NewImportPortfolioUseCase(positionRepo, portfolioRepo, instrumentRepo), GetTickerNews: usecase.NewGetTickerNewsUseCase(newsSvc), + GetMorningBrief: usecase.NewGetMorningBriefUsecase(reportingBuilder), }, nil } diff --git a/internal/cli/brief.go b/internal/cli/brief.go new file mode 100644 index 0000000..c7fe815 --- /dev/null +++ b/internal/cli/brief.go @@ -0,0 +1,29 @@ +package cli + +import ( + "github.com/spf13/cobra" + "github.com/squeakycheese75/tick/internal/domain" + "github.com/squeakycheese75/tick/internal/render" +) + +func newBriefCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { + return &cobra.Command{ + Use: "brief", + Short: "Show your morning market and portfolio brief", + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := runtimeBuilder() + if err != nil { + return err + } + + out, err := rt.GetMorningBrief.Execute(cmd.Context(), domain.GetMorningBriefUsecaseInput{ + PortfolioName: "main", + }) + if err != nil { + return err + } + + return render.RenderBriefReport(cmd.OutOrStdout(), out.Report) + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 187bca7..186124b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ func NewRootCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newDailyCmd(runtimeBuilder)) + rootCmd.AddCommand(newBriefCmd(runtimeBuilder)) rootCmd.AddCommand(newPortfolioCmd(runtimeBuilder)) rootCmd.AddCommand(newAddPositionCmd(runtimeBuilder)) rootCmd.AddCommand(newInfoCmd()) diff --git a/internal/domain/report.go b/internal/domain/report.go index 92b133a..ac20ec0 100644 --- a/internal/domain/report.go +++ b/internal/domain/report.go @@ -37,6 +37,24 @@ type DailyHolding struct { ChangePercent float64 } +type Holding struct { + Symbol string + Quantity float64 + Weight float64 + MarketValueBase float64 + QuotedPrice float64 + PriceCurrency string + ChangeAbsolute float64 + ChangePercent float64 + SinceLastSnapshot *ValueChangeSummary +} + +type HoldingSummary struct { + Holdings []Holding + TotalValue float64 + Change *ValueChangeSummary +} + type DailyRisk struct { LargestPosition string LargestWeight float64 @@ -50,31 +68,53 @@ type DailyNews struct { } type DailyReport struct { - PortfolioName string - BaseCurrency string - TotalValue float64 + Portfolio PortfolioSummary + TopHoldings HoldingSummary + Risk RiskSummary + News []NewsSummary + Attention []string +} - ChangeSinceLastSnapshot *ValueChangeReport +type BriefReport struct { + Greeting string + Portfolio PortfolioSummary + Movers HoldingSummary + Markets []MarketSummary + News []NewsSummary +} - TopHoldings []TopHoldingReport - Risk RiskReport - News []TickerNewsReport - Attention []string +type PortfolioSummary struct { + Name string + BaseCurrency string + TotalValue float64 + Change *ValueChangeSummary } -type TickerNewsReport struct { +type MarketSummary struct { + Symbol string + Price float64 + Change float64 + ChangePercent float64 + Currency string +} + +type NewsSummary struct { Ticker string Headlines []NewsHeadline Summary string } -type RiskReport struct { +type RiskSummary struct { LargestPosition string LargestWeight float64 Top3Concentration float64 Observations []string } +type AttentionSummary struct { + Signal string +} + type TopHoldingReport struct { Symbol string Weight float64 @@ -86,7 +126,7 @@ type TopHoldingReport struct { SinceLastSnapshot *HoldingSnapshotChangeReport } -type ValueChangeReport struct { +type ValueChangeSummary struct { Absolute float64 Percent float64 } diff --git a/internal/domain/usecase.go b/internal/domain/usecase.go index 8c407f7..c0ec98b 100644 --- a/internal/domain/usecase.go +++ b/internal/domain/usecase.go @@ -184,7 +184,32 @@ type GetDailyReportInput struct { WithAI bool } +func (i *GetDailyReportInput) ApplyDefaults() { + if i.PortfolioName == "" { + i.PortfolioName = "main" + } + + if i.NewsLimit <= 0 { + i.NewsLimit = 2 + } +} + type GetDailyReportOutput struct { DailyReport DailyReport AISummary string } + +type GetMorningBriefUsecaseInput struct { + PortfolioName string + NewsLimit int +} + +func (i *GetMorningBriefUsecaseInput) ApplyDefaults() { + if i.PortfolioName == "" { + i.PortfolioName = "main" + } +} + +type GetMorningBriefUsecaseOutput struct { + Report BriefReport +} diff --git a/internal/render/brief.go b/internal/render/brief.go new file mode 100644 index 0000000..4acc42d --- /dev/null +++ b/internal/render/brief.go @@ -0,0 +1,20 @@ +package render + +import ( + "io" + + "github.com/squeakycheese75/tick/internal/domain" +) + +func RenderBriefReport(w io.Writer, r domain.BriefReport) error { + out := &writer{w: w} + + renderPortfolioSummary(out, r.Portfolio, SummaryOptions{}) + out.println("") + renderHoldingSummary(out, r.Movers, HoldingsOptions{}) + out.println("") + + renderNewsSummary(out, r.News, NewsOptions{}) + + return out.err +} diff --git a/internal/render/daily.go b/internal/render/daily.go index a2214f8..207be65 100644 --- a/internal/render/daily.go +++ b/internal/render/daily.go @@ -11,13 +11,13 @@ func DailyReport(w io.Writer, s domain.GetDailyReportOutput, opts DailyReportOpt out := &writer{w: w} r := s.DailyReport - renderSummary(out, r, opts.Summary) + renderPortfolioSummary(out, r.Portfolio, opts.Summary) out.println("") - renderHoldings(out, r, opts.Holdings) + renderHoldingSummary(out, r.TopHoldings, opts.Holdings) out.println("") - renderRisk(out, r.Risk, opts.Risk) + renderRiskSummary(out, r.Risk, opts.Risk) out.println("") renderNewsSummary(out, r.News, opts.News) @@ -45,36 +45,36 @@ func DailyReport(w io.Writer, s domain.GetDailyReportOutput, opts DailyReportOpt return out.err } -func renderSummary(out *writer, r domain.DailyReport, opts SummaryOptions) { - out.printf("%s %s", r.PortfolioName, formatMoney(r.TotalValue, r.BaseCurrency)) +func renderPortfolioSummary(out *writer, r domain.PortfolioSummary, opts SummaryOptions) { + out.printf("%s %s", r.Name, formatMoney(r.TotalValue, r.BaseCurrency)) if opts.ShowSnapshotDelta && - r.ChangeSinceLastSnapshot != nil && + r.Change != nil && (!opts.HideZeroDelta || shouldShowChange( - r.ChangeSinceLastSnapshot.Absolute, - r.ChangeSinceLastSnapshot.Percent, + r.Change.Absolute, + r.Change.Percent, )) { out.printf( " Δ %s (%s)", - formatSignedMoney(r.ChangeSinceLastSnapshot.Absolute, r.BaseCurrency), - formatSignedPercentFromRatio(r.ChangeSinceLastSnapshot.Percent), + formatSignedMoney(r.Change.Absolute, r.BaseCurrency), + formatSignedPercentFromRatio(r.Change.Percent), ) } } -func renderHoldings(out *writer, r domain.DailyReport, opts HoldingsOptions) { +func renderHoldingSummary(out *writer, r domain.HoldingSummary, opts HoldingsOptions) { out.println("Holdings") - if len(r.TopHoldings) == 0 { + if len(r.Holdings) == 0 { out.println("No positions") return } - for _, h := range r.TopHoldings { + for _, h := range r.Holdings { out.printf( "%-5s %7.2f%% %16s @ %16s %s", h.Symbol, h.Weight*100, - formatMoney(h.MarketValueBase, r.BaseCurrency), + formatMoney(h.MarketValueBase, h.PriceCurrency), formatMoney(h.QuotedPrice, h.PriceCurrency), formatChangePercent(h.ChangePercent, opts.Color), ) @@ -82,13 +82,13 @@ func renderHoldings(out *writer, r domain.DailyReport, opts HoldingsOptions) { if opts.ShowSnapshotDelta && h.SinceLastSnapshot != nil && (!opts.HideZeroDelta || shouldShowChange( - h.SinceLastSnapshot.ValueAbsolute, - h.SinceLastSnapshot.ValuePercent, + h.SinceLastSnapshot.Absolute, + h.SinceLastSnapshot.Percent, )) { out.printf( " Δsnap %s (%s)", - formatSignedMoneyColored(h.SinceLastSnapshot.ValueAbsolute, r.BaseCurrency, opts.Color), - formatSignedPercentColored(h.SinceLastSnapshot.ValuePercent, opts.Color), + formatSignedMoneyColored(h.SinceLastSnapshot.Absolute, h.PriceCurrency, opts.Color), + formatSignedPercentColored(h.SinceLastSnapshot.Percent, opts.Color), ) } @@ -96,7 +96,7 @@ func renderHoldings(out *writer, r domain.DailyReport, opts HoldingsOptions) { } } -func renderRisk(out *writer, r domain.RiskReport, opts RiskOptions) { +func renderRiskSummary(out *writer, r domain.RiskSummary, opts RiskOptions) { if r.LargestPosition == "" { out.println("Risk No data") return @@ -128,7 +128,7 @@ func renderRisk(out *writer, r domain.RiskReport, opts RiskOptions) { } } -func riskLabel(r domain.RiskReport) string { +func riskLabel(r domain.RiskSummary) string { switch { case r.LargestWeight >= 0.80: return "High concentration" @@ -139,7 +139,7 @@ func riskLabel(r domain.RiskReport) string { } } -func renderNewsSummary(out *writer, groups []domain.TickerNewsReport, opts NewsOptions) { +func renderNewsSummary(out *writer, groups []domain.NewsSummary, opts NewsOptions) { out.println("News") if len(groups) == 0 { @@ -185,7 +185,7 @@ func renderNewsSummary(out *writer, groups []domain.TickerNewsReport, opts NewsO } } -func RenderNewsItem(w io.Writer, r domain.TickerNewsReport, opts NewsOptions) error { +func RenderNewsItem(w io.Writer, r domain.NewsSummary, opts NewsOptions) error { out := &writer{w: w} out.printf("News for %s\n\n", r.Ticker) diff --git a/internal/render/daily_test.go b/internal/render/daily_test.go new file mode 100644 index 0000000..6a4cc5d --- /dev/null +++ b/internal/render/daily_test.go @@ -0,0 +1,65 @@ +package render + +// func TestDailyReport(t *testing.T) { +// t.Parallel() + +// var buf bytes.Buffer + +// out := domain.GetDailyReportOutput{ +// DailyReport: domain.DailyReport{ +// PortfolioName: "main", +// BaseCurrency: "EUR", +// TotalValue: 36369.31, +// TopHoldings: []domain.TopHoldingReport{ +// { +// Symbol: "BTC", +// Weight: 0.9106, +// MarketValueBase: 33118.84, +// QuotedPrice: 77815.00, +// PriceCurrency: "USD", +// ChangePercent: 0, +// }, +// }, +// Risk: domain.RiskReport{ +// LargestPosition: "BTC", +// LargestWeight: 0.9106, +// Top3Concentration: 1.0, +// }, +// News: []domain.TickerNewsReport{ +// { +// Ticker: "BTC", +// Headlines: []domain.NewsHeadline{ +// { +// Title: "Bitcoin and crypto stocks fall after hearing", +// URL: "https://example.com", +// }, +// }, +// }, +// }, +// }, +// } + +// opts := DefaultDailyReportOptions() +// opts.Holdings.Color = false + +// err := DailyReport(&buf, out, opts) +// if err != nil { +// t.Fatalf("DailyReport() error = %v", err) +// } + +// got := buf.String() +// want := `main 36,369.31 EUR + +// Holdings +// BTC 91.06% 33,118.84 EUR @ 77,815.00 USD → +0.00% + +// Risk Largest: BTC (91.06%) Top 3: 100.00% ! High concentration + +// News +// BTC: Bitcoin and crypto stocks fall after hearing +// ` + +// if got != want { +// t.Fatalf("unexpected output\n\nwant:\n%s\n\ngot:\n%s", want, got) +// } +// } diff --git a/internal/report/builder.go b/internal/report/builder.go new file mode 100644 index 0000000..07937fa --- /dev/null +++ b/internal/report/builder.go @@ -0,0 +1,52 @@ +package report + +import ( + "context" + + "github.com/squeakycheese75/tick/internal/domain" +) + +type ( + PortfolioSvc interface { + GetAnalysis(ctx context.Context, portfolioName string) (domain.PortfolioAnalysis, error) + GetRisk(ctx context.Context, portfolioName string) (domain.PortfolioRisk, error) + } + NewsSvc interface { + GetNews(ctx context.Context, ticker string, newsLimit int) (domain.NewsSummary, error) + } + PricingSvc interface { + GetValuationQuote(ctx context.Context, symbol string, targetCurrency string, instrumentCurrency string, instrumentType string) (domain.ValuationQuote, error) + } + PortfolioInsights interface { + TopHoldings(portfolioAnalysis domain.PortfolioAnalysis, limit int) []domain.AnalyzedPosition + AttentionSignals(portfolioAnalysis domain.PortfolioAnalysis, portfolioRisk domain.PortfolioRisk) []string + } + SnapshotSvc interface { + SaveAndEnrichDailyReport(ctx context.Context, dailyReport domain.DailyReport, analysis domain.PortfolioAnalysis) (domain.DailyReport, error) + } +) + +type ReportBuilder struct { + portfolioSvc PortfolioSvc + pricingSvc PricingSvc + newsSvc NewsSvc + insights PortfolioInsights + snapshotSvc SnapshotSvc +} + +func NewReportBuilder( + portfolioSvc PortfolioSvc, + pricingSvc PricingSvc, + newsSvc NewsSvc, + insights PortfolioInsights, + snapshotSvc SnapshotSvc, + +) *ReportBuilder { + return &ReportBuilder{ + portfolioSvc: portfolioSvc, + newsSvc: newsSvc, + insights: insights, + pricingSvc: pricingSvc, + snapshotSvc: snapshotSvc, + } +} diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go index 8705eff..c5ff96f 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily_report.go @@ -3,158 +3,60 @@ package report import ( "context" "fmt" - "sync" "github.com/squeakycheese75/tick/internal/domain" ) -type ( - PortfolioService interface { - GetAnalysis(ctx context.Context, portfolioName string) (domain.PortfolioAnalysis, error) - GetRisk(ctx context.Context, portfolioName string) (domain.PortfolioRisk, error) - } - NewsService interface { - GetNews(ctx context.Context, ticker string, newsLimit int) (domain.TickerNewsReport, error) - } - PortfolioInsights interface { - TopHoldings(portfolioAnalysis domain.PortfolioAnalysis, limit int) []domain.AnalyzedPosition - AttentionSignals(portfolioAnalysis domain.PortfolioAnalysis, portfolioRisk domain.PortfolioRisk) []string - } -) - -type ReportBuilder struct { - portfolioSvc PortfolioService - newsSvc NewsService - insights PortfolioInsights -} - -func NewReportBuilder(portfolioSvc PortfolioService, newsSvc NewsService, insights PortfolioInsights) *ReportBuilder { - return &ReportBuilder{ - portfolioSvc: portfolioSvc, - newsSvc: newsSvc, - insights: insights, - } +type BuildDailyReportParams struct { + PortfolioName string + NewsLimit int + SaveSnapshot bool } func (s *ReportBuilder) BuildDailyReport( ctx context.Context, - portfolioName string, - newsLimit int, -) (domain.DailyReportResult, error) { - portfolioAnalysis, err := s.portfolioSvc.GetAnalysis(ctx, portfolioName) + in BuildDailyReportParams, +) (domain.DailyReport, error) { + analysis, err := s.portfolioSvc.GetAnalysis(ctx, in.PortfolioName) if err != nil { - return domain.DailyReportResult{}, fmt.Errorf("get portfolio analysis: %w", err) + return domain.DailyReport{}, fmt.Errorf("get portfolio analysis: %w", err) } - portfolioRisk, err := s.portfolioSvc.GetRisk(ctx, portfolioName) + risk, err := s.portfolioSvc.GetRisk(ctx, in.PortfolioName) if err != nil { - return domain.DailyReportResult{}, fmt.Errorf("get portfolio risk: %w", err) + return domain.DailyReport{}, fmt.Errorf("get portfolio risk: %w", err) } - topPositions := s.insights.TopHoldings(portfolioAnalysis, 3) - topHoldings := make([]domain.TopHoldingReport, 0, len(topPositions)) - for _, pos := range topPositions { - topHoldings = append(topHoldings, domain.TopHoldingReport{ - Symbol: pos.Symbol, - Weight: pos.Weight, - MarketValueBase: pos.MarketValueBase, - QuotedPrice: pos.QuotedPrice, - PriceCurrency: pos.PriceCurrency, - ChangePercent: pos.QuotedChangePct, - }) - } + report := s.buildDailyReportFromAnalysis(analysis, risk) - out := domain.DailyReportResult{ - Report: domain.DailyReport{ - PortfolioName: portfolioAnalysis.PortfolioName, - BaseCurrency: portfolioAnalysis.BaseCurrency, - TotalValue: portfolioAnalysis.TotalValue, - TopHoldings: topHoldings, - Risk: domain.RiskReport{ - LargestPosition: portfolioRisk.LargestPosition, - LargestWeight: portfolioRisk.LargestWeight, - Top3Concentration: portfolioRisk.Top3Concentration, - Observations: append([]string(nil), portfolioRisk.Observations...), - }, - Attention: s.insights.AttentionSignals(portfolioAnalysis, portfolioRisk), - News: make([]domain.TickerNewsReport, 0, len(topHoldings)), - }, - Analysis: portfolioAnalysis, - Risk: portfolioRisk, + news, err := s.getNewsSummaries(ctx, report.TopHoldings, in.NewsLimit) + if err != nil { + return domain.DailyReport{}, err } + report.News = news - var wg sync.WaitGroup - news := make([]domain.TickerNewsReport, len(out.Report.TopHoldings)) - errCh := make(chan error, len(out.Report.TopHoldings)) - - for i, holding := range out.Report.TopHoldings { - wg.Add(1) - - go func(i int, symbol string) { - defer wg.Done() - - n, err := s.newsSvc.GetNews(ctx, symbol, newsLimit) - if err != nil { - errCh <- err - return - } - - news[i] = n - }(i, holding.Symbol) + if !in.SaveSnapshot { + return report, nil } - wg.Wait() - close(errCh) - - if err := <-errCh; err != nil { - return domain.DailyReportResult{}, err + report, err = s.snapshotSvc.SaveAndEnrichDailyReport(ctx, report, analysis) + if err != nil { + return domain.DailyReport{}, fmt.Errorf("save and enrich daily report snapshot: %w", err) } - out.Report.News = news - return out, nil + return report, nil } -func EnrichDailyReportWithSnapshot( - dailyReport domain.DailyReport, - previousSnapshot domain.PortfolioSnapshot, - previousPositions []domain.PortfolioSnapshotPosition, +func (s *ReportBuilder) buildDailyReportFromAnalysis( + analysis domain.PortfolioAnalysis, + risk domain.PortfolioRisk, ) domain.DailyReport { - delta := dailyReport.TotalValue - previousSnapshot.TotalValue - - var pct float64 - if previousSnapshot.TotalValue != 0 { - pct = delta / previousSnapshot.TotalValue - } - - dailyReport.ChangeSinceLastSnapshot = &domain.ValueChangeReport{ - Absolute: delta, - Percent: pct, - } + topPositions := s.insights.TopHoldings(analysis, 3) - previousBySymbol := make(map[string]domain.PortfolioSnapshotPosition, len(previousPositions)) - for _, p := range previousPositions { - previousBySymbol[p.Symbol] = p + return domain.DailyReport{ + Portfolio: assemblePortfolioSummary(analysis), + TopHoldings: assembleHoldingSummary(topPositions), + Risk: assembleRiskSummary(risk), + Attention: s.insights.AttentionSignals(analysis, risk), } - - for i := range dailyReport.TopHoldings { - current := dailyReport.TopHoldings[i] - previous, ok := previousBySymbol[current.Symbol] - if !ok { - continue - } - - valueDelta := current.MarketValueBase - previous.MarketValueBase - - var valuePct float64 - if previous.MarketValueBase != 0 { - valuePct = valueDelta / previous.MarketValueBase - } - - dailyReport.TopHoldings[i].SinceLastSnapshot = &domain.HoldingSnapshotChangeReport{ - ValueAbsolute: valueDelta, - ValuePercent: valuePct, - } - } - - return dailyReport } diff --git a/internal/report/morning_report.go b/internal/report/morning_report.go new file mode 100644 index 0000000..b488237 --- /dev/null +++ b/internal/report/morning_report.go @@ -0,0 +1,44 @@ +package report + +import ( + "context" + "fmt" + + "github.com/squeakycheese75/tick/internal/domain" +) + +type BuildMorningBriefReportParams struct { + PortfolioName string +} + +func (s *ReportBuilder) BuildMorningBriefReport(ctx context.Context, in BuildMorningBriefReportParams) (domain.BriefReport, error) { + analysis, err := s.portfolioSvc.GetAnalysis(ctx, in.PortfolioName) + if err != nil { + return domain.BriefReport{}, fmt.Errorf("get portfolio analysis: %w", err) + } + + out := domain.BriefReport{ + Greeting: assembleGreeting(), + Portfolio: assemblePortfolioSummary(analysis), + Movers: assembleHoldingSummary(analysis.AnalyzedPositions), + } + + for _, pos := range analysis.AnalyzedPositions { + newsItems, err := s.newsSvc.GetNews(ctx, pos.Symbol, 1) + if err != nil { + continue + } + out.News = append(out.News, newsItems) + } + + if out.Movers.TotalValue != out.Movers.Change.Absolute { + previousValue := out.Movers.TotalValue - out.Movers.Change.Absolute + if previousValue > 0 { + out.Movers.Change.Percent = (out.Movers.Change.Percent / previousValue) * 100 + } + } + + sortHoldingsByAbsValueChange(out.Movers.Holdings) + + return out, nil +} diff --git a/internal/report/shared.go b/internal/report/shared.go new file mode 100644 index 0000000..3b84ab4 --- /dev/null +++ b/internal/report/shared.go @@ -0,0 +1,117 @@ +package report + +import ( + "context" + "math" + "sort" + "sync" + + "github.com/squeakycheese75/tick/internal/domain" +) + +func positionValue(quantity, price float64) float64 { + return quantity * price +} + +func positionValueChange(quantity, priceChange float64) float64 { + return quantity * priceChange +} + +// func changePercent(change, currentValue float64) float64 { +// previousValue := currentValue - change +// if previousValue <= 0 { +// return 0 +// } + +// return (change / previousValue) * 100 +// } + +func sortHoldingsByAbsValueChange(holding []domain.Holding) { + sort.Slice(holding, func(i, j int) bool { + return math.Abs(holding[i].ChangeAbsolute) > math.Abs(holding[j].ChangeAbsolute) + }) +} + +func (s *ReportBuilder) getNewsSummaries( + ctx context.Context, + holdings domain.HoldingSummary, + limit int, +) ([]domain.NewsSummary, error) { + + var wg sync.WaitGroup + news := make([]domain.NewsSummary, len(holdings.Holdings)) + errCh := make(chan error, len(holdings.Holdings)) + + for i, h := range holdings.Holdings { + wg.Add(1) + + go func(i int, symbol string) { + defer wg.Done() + + n, err := s.newsSvc.GetNews(ctx, symbol, limit) + if err != nil { + errCh <- err + return + } + + news[i] = n + }(i, h.Symbol) + } + + wg.Wait() + close(errCh) + + if err := <-errCh; err != nil { + return nil, err + } + + return news, nil +} + +func assemblePortfolioSummary(analysis domain.PortfolioAnalysis) domain.PortfolioSummary { + return domain.PortfolioSummary{ + Name: analysis.PortfolioName, + BaseCurrency: analysis.BaseCurrency, + TotalValue: analysis.TotalValue, + } +} + +func assembleGreeting() string { + return "Good morning" +} + +func assembleRiskSummary(risk domain.PortfolioRisk) domain.RiskSummary { + return domain.RiskSummary{ + LargestPosition: risk.LargestPosition, + LargestWeight: risk.LargestWeight, + Top3Concentration: risk.Top3Concentration, + Observations: append([]string(nil), risk.Observations...), + } +} + +// func assembleAttentionSumary() domain.AttentionSummary { +// return domain.AttentionSummary{} +// } + +func assembleHoldingSummary(postions []domain.AnalyzedPosition) (out domain.HoldingSummary) { + for _, pos := range postions { + value := positionValue(pos.Quantity, pos.QuotedPrice) + change := positionValueChange(pos.Quantity, pos.QuotedChange) + + out.TotalValue += value + out.Change = &domain.ValueChangeSummary{ + Absolute: change, + } + + out.Holdings = append(out.Holdings, domain.Holding{ + Symbol: pos.Symbol, + Quantity: pos.Quantity, + Weight: pos.Weight, + ChangePercent: pos.QuotedChangePct, + ChangeAbsolute: change, + PriceCurrency: pos.PriceCurrency, + }) + + } + return out +} diff --git a/internal/repository/portfolio_repo.go b/internal/repository/portfolio_repo.go index 397d686..6206f10 100644 --- a/internal/repository/portfolio_repo.go +++ b/internal/repository/portfolio_repo.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "time" "github.com/squeakycheese75/tick/internal/db" "github.com/squeakycheese75/tick/internal/domain" @@ -47,56 +46,3 @@ func (r *PortfolioRepository) Create(ctx context.Context, p Portfolio) error { return nil } - -func (r *PortfolioSnapshotRepository) GetLatestBefore( - ctx context.Context, - portfolioName string, - before time.Time, -) (PortfolioSnapshot, error) { - row, err := r.q.GetLatestPortfolioSnapshotBefore(ctx, db.GetLatestPortfolioSnapshotBeforeParams{ - PortfolioName: portfolioName, - CapturedAt: before, - }) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return PortfolioSnapshot{}, domain.ErrPortfolioSnapshotNotFound - } - return PortfolioSnapshot{}, fmt.Errorf("get latest portfolio snapshot before: %w", err) - } - - return PortfolioSnapshot{ - ID: row.ID, - PortfolioName: row.PortfolioName, - BaseCurrency: row.BaseCurrency, - TotalValue: row.TotalValue, - CapturedAt: row.CapturedAt, - }, nil -} - -func (r *PortfolioSnapshotRepository) ListPositionsBySnapshotID( - ctx context.Context, - snapshotID int64, -) ([]PortfolioSnapshotPosition, error) { - - rows, err := r.q.ListPortfolioSnapshotPositionsBySnapshotID(ctx, snapshotID) - if err != nil { - return nil, fmt.Errorf("list snapshot positions: %w", err) - } - - out := make([]PortfolioSnapshotPosition, 0, len(rows)) - - for _, row := range rows { - out = append(out, PortfolioSnapshotPosition{ - SnapshotID: row.SnapshotID, - Symbol: row.Symbol, - Quantity: row.Quantity, - InstrumentCurrency: row.InstrumentCurrency, - QuotedPrice: row.QuotedPrice, - FXRate: row.FxRate, - MarketValueBase: row.MarketValueBase, - Weight: row.Weight, - }) - } - - return out, nil -} diff --git a/internal/repository/portfolio_snapshot_repo.go b/internal/repository/portfolio_snapshot_repo.go deleted file mode 100644 index f8f23fe..0000000 --- a/internal/repository/portfolio_snapshot_repo.go +++ /dev/null @@ -1,68 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "fmt" - - "github.com/squeakycheese75/tick/internal/db" -) - -type PortfolioSnapshotRepository struct { - db *sql.DB - q *db.Queries -} - -func NewPortfolioSnapshotRepository(database *db.DB) *PortfolioSnapshotRepository { - return &PortfolioSnapshotRepository{ - q: db.New(database.SqlDB), - db: database.SqlDB} -} - -func (r *PortfolioSnapshotRepository) Create( - ctx context.Context, - in PortfolioSnapshot, - positions []PortfolioSnapshotPosition, -) (int64, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return 0, fmt.Errorf("begin tx: %w", err) - } - defer func() { - _ = tx.Rollback() - }() - - q := r.q.WithTx(tx) - - row, err := q.CreatePortfolioSnapshot(ctx, db.CreatePortfolioSnapshotParams{ - PortfolioName: in.PortfolioName, - BaseCurrency: in.BaseCurrency, - TotalValue: in.TotalValue, - CapturedAt: in.CapturedAt, - }) - if err != nil { - return 0, fmt.Errorf("create portfolio snapshot: %w", err) - } - - for _, p := range positions { - _, err := q.CreatePortfolioSnapshotPosition(ctx, db.CreatePortfolioSnapshotPositionParams{ - SnapshotID: row.ID, - Symbol: p.Symbol, - Quantity: p.Quantity, - InstrumentCurrency: p.InstrumentCurrency, - QuotedPrice: p.QuotedPrice, - FxRate: p.FXRate, - MarketValueBase: p.MarketValueBase, - Weight: p.Weight, - }) - if err != nil { - return 0, fmt.Errorf("create portfolio snapshot position for %s: %w", p.Symbol, err) - } - } - - if err := tx.Commit(); err != nil { - return 0, fmt.Errorf("commit tx: %w", err) - } - - return row.ID, nil -} diff --git a/internal/repository/snapshot_repo.go b/internal/repository/snapshot_repo.go new file mode 100644 index 0000000..3e39fa5 --- /dev/null +++ b/internal/repository/snapshot_repo.go @@ -0,0 +1,124 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/squeakycheese75/tick/internal/db" + "github.com/squeakycheese75/tick/internal/domain" +) + +type SnapshotRepository struct { + db *sql.DB + q *db.Queries +} + +func NewSnapshotRepository(database *db.DB) *SnapshotRepository { + return &SnapshotRepository{ + q: db.New(database.SqlDB), + db: database.SqlDB} +} + +func (r *SnapshotRepository) Create( + ctx context.Context, + in PortfolioSnapshot, + positions []PortfolioSnapshotPosition, +) (int64, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin tx: %w", err) + } + defer func() { + _ = tx.Rollback() + }() + + q := r.q.WithTx(tx) + + row, err := q.CreatePortfolioSnapshot(ctx, db.CreatePortfolioSnapshotParams{ + PortfolioName: in.PortfolioName, + BaseCurrency: in.BaseCurrency, + TotalValue: in.TotalValue, + CapturedAt: in.CapturedAt, + }) + if err != nil { + return 0, fmt.Errorf("create portfolio snapshot: %w", err) + } + + for _, p := range positions { + _, err := q.CreatePortfolioSnapshotPosition(ctx, db.CreatePortfolioSnapshotPositionParams{ + SnapshotID: row.ID, + Symbol: p.Symbol, + Quantity: p.Quantity, + InstrumentCurrency: p.InstrumentCurrency, + QuotedPrice: p.QuotedPrice, + FxRate: p.FXRate, + MarketValueBase: p.MarketValueBase, + Weight: p.Weight, + }) + if err != nil { + return 0, fmt.Errorf("create portfolio snapshot position for %s: %w", p.Symbol, err) + } + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit tx: %w", err) + } + + return row.ID, nil +} + +func (r *SnapshotRepository) GetLatestBefore( + ctx context.Context, + portfolioName string, + before time.Time, +) (PortfolioSnapshot, error) { + row, err := r.q.GetLatestPortfolioSnapshotBefore(ctx, db.GetLatestPortfolioSnapshotBeforeParams{ + PortfolioName: portfolioName, + CapturedAt: before, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PortfolioSnapshot{}, domain.ErrPortfolioSnapshotNotFound + } + return PortfolioSnapshot{}, fmt.Errorf("get latest portfolio snapshot before: %w", err) + } + + return PortfolioSnapshot{ + ID: row.ID, + PortfolioName: row.PortfolioName, + BaseCurrency: row.BaseCurrency, + TotalValue: row.TotalValue, + CapturedAt: row.CapturedAt, + }, nil +} + +func (r *SnapshotRepository) ListPositionsBySnapshotID( + ctx context.Context, + snapshotID int64, +) ([]PortfolioSnapshotPosition, error) { + + rows, err := r.q.ListPortfolioSnapshotPositionsBySnapshotID(ctx, snapshotID) + if err != nil { + return nil, fmt.Errorf("list snapshot positions: %w", err) + } + + out := make([]PortfolioSnapshotPosition, 0, len(rows)) + + for _, row := range rows { + out = append(out, PortfolioSnapshotPosition{ + SnapshotID: row.SnapshotID, + Symbol: row.Symbol, + Quantity: row.Quantity, + InstrumentCurrency: row.InstrumentCurrency, + QuotedPrice: row.QuotedPrice, + FXRate: row.FxRate, + MarketValueBase: row.MarketValueBase, + Weight: row.Weight, + }) + } + + return out, nil +} diff --git a/internal/service/daily_report_prompts.go b/internal/service/daily_report_prompts.go index c78c2e1..9a33b42 100644 --- a/internal/service/daily_report_prompts.go +++ b/internal/service/daily_report_prompts.go @@ -25,21 +25,21 @@ func buildDailyReportUserPrompt(dailyReport domain.DailyReport) string { b.WriteString("Portfolio daily brief\n\n") - b.WriteString(fmt.Sprintf("Portfolio: %s\n", dailyReport.PortfolioName)) - b.WriteString(fmt.Sprintf("Base currency: %s\n", dailyReport.BaseCurrency)) - b.WriteString(fmt.Sprintf("Total value: %.2f %s\n\n", dailyReport.TotalValue, dailyReport.BaseCurrency)) + b.WriteString(fmt.Sprintf("Portfolio: %s\n", dailyReport.Portfolio.Name)) + b.WriteString(fmt.Sprintf("Base currency: %s\n", dailyReport.Portfolio.BaseCurrency)) + b.WriteString(fmt.Sprintf("Total value: %.2f %s\n\n", dailyReport.Portfolio.TotalValue, dailyReport.Portfolio.BaseCurrency)) b.WriteString("Top holdings:\n") - if len(dailyReport.TopHoldings) == 0 { + if len(dailyReport.TopHoldings.Holdings) == 0 { b.WriteString("- No positions\n") } else { - for _, h := range dailyReport.TopHoldings { + for _, h := range dailyReport.TopHoldings.Holdings { b.WriteString(fmt.Sprintf( "- %s: weight %.2f%%, value %.2f %s, quoted price %.2f %s, daily move %+.2f%%\n", h.Symbol, h.Weight*100, h.MarketValueBase, - dailyReport.BaseCurrency, + dailyReport.Portfolio.BaseCurrency, h.QuotedPrice, h.PriceCurrency, h.ChangePercent, diff --git a/internal/service/news.go b/internal/service/news.go index 071e50c..5739e0f 100644 --- a/internal/service/news.go +++ b/internal/service/news.go @@ -8,7 +8,7 @@ import ( ) type NewsProvider interface { - GetNews(ctx context.Context, ticker string, limit int) (domain.TickerNewsReport, error) + GetNews(ctx context.Context, ticker string, limit int) (domain.NewsSummary, error) } type NewsService struct { @@ -25,10 +25,10 @@ func (s *NewsService) GetNews( ctx context.Context, ticker string, newsLimit int, -) (domain.TickerNewsReport, error) { +) (domain.NewsSummary, error) { headlines, err := s.provider.GetNews(ctx, ticker, newsLimit) if err != nil { - return domain.TickerNewsReport{}, fmt.Errorf("get news for %s: %w", ticker, err) + return domain.NewsSummary{}, fmt.Errorf("get news for %s: %w", ticker, err) } return headlines, nil diff --git a/internal/service/snaphot.go b/internal/service/snaphot.go new file mode 100644 index 0000000..df2dfa2 --- /dev/null +++ b/internal/service/snaphot.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/squeakycheese75/tick/internal/domain" + "github.com/squeakycheese75/tick/internal/domain/snapshot" + "github.com/squeakycheese75/tick/internal/repository" +) + +type SnapRepository interface { + Create(ctx context.Context, in repository.PortfolioSnapshot, positions []repository.PortfolioSnapshotPosition) (int64, error) + GetLatestBefore(ctx context.Context, portfolioName string, before time.Time) (repository.PortfolioSnapshot, error) + ListPositionsBySnapshotID(ctx context.Context, snapshotID int64) ([]repository.PortfolioSnapshotPosition, error) +} + +type SnapshotService struct { + snapshots SnapRepository +} + +func NewSnapshotService(repo SnapRepository) *SnapshotService { + return &SnapshotService{ + snapshots: repo, + } +} + +func (s *SnapshotService) SaveAndEnrichDailyReport( + ctx context.Context, + dailyReport domain.DailyReport, + analysis domain.PortfolioAnalysis, +) (domain.DailyReport, error) { + currentSnapshot, currentPositions := snapshot.MapAnalysisToSnapshot(analysis, time.Now()) + + _, err := s.snapshots.Create(ctx, currentSnapshot, currentPositions) + if err != nil { + return domain.DailyReport{}, fmt.Errorf("save portfolio snapshot: %w", err) + } + + previousSnapshot, err := s.snapshots.GetLatestBefore( + ctx, + currentSnapshot.PortfolioName, + currentSnapshot.CapturedAt, + ) + if err != nil { + if errors.Is(err, domain.ErrPortfolioSnapshotNotFound) { + return dailyReport, nil + } + + return domain.DailyReport{}, fmt.Errorf("get previous snapshot: %w", err) + } + + previousPositions, err := s.snapshots.ListPositionsBySnapshotID(ctx, previousSnapshot.ID) + if err != nil { + return domain.DailyReport{}, fmt.Errorf("list previous snapshot positions: %w", err) + } + + return EnrichDailyReportWithSnapshot( + dailyReport, + snapshot.MapSnapshotToDomain(previousSnapshot), + snapshot.MapSnapshotPositionsToDomain(previousPositions), + ), nil +} + +func EnrichDailyReportWithSnapshot( + dailyReport domain.DailyReport, + previousSnapshot domain.PortfolioSnapshot, + previousPositions []domain.PortfolioSnapshotPosition, +) domain.DailyReport { + delta := dailyReport.Portfolio.TotalValue - previousSnapshot.TotalValue + + var pct float64 + if previousSnapshot.TotalValue != 0 { + pct = delta / previousSnapshot.TotalValue + } + + dailyReport.Portfolio.Change = &domain.ValueChangeSummary{ + Absolute: delta, + Percent: pct, + } + + previousBySymbol := make(map[string]domain.PortfolioSnapshotPosition, len(previousPositions)) + for _, p := range previousPositions { + previousBySymbol[p.Symbol] = p + } + + for i := range dailyReport.TopHoldings.Holdings { + current := dailyReport.TopHoldings.Holdings[i] + previous, ok := previousBySymbol[current.Symbol] + if !ok { + continue + } + + valueDelta := current.MarketValueBase - previous.MarketValueBase + + var valuePct float64 + if previous.MarketValueBase != 0 { + valuePct = valueDelta / previous.MarketValueBase + } + + dailyReport.TopHoldings.Holdings[i].SinceLastSnapshot = &domain.ValueChangeSummary{ + Absolute: valueDelta, + Percent: valuePct, + } + } + + return dailyReport +} diff --git a/internal/usecase/daily_report_get.go b/internal/usecase/daily_report_get.go index 13fc037..60cfc10 100644 --- a/internal/usecase/daily_report_get.go +++ b/internal/usecase/daily_report_get.go @@ -2,12 +2,9 @@ package usecase import ( "context" - "errors" "fmt" - "time" "github.com/squeakycheese75/tick/internal/domain" - "github.com/squeakycheese75/tick/internal/domain/snapshot" "github.com/squeakycheese75/tick/internal/report" ) @@ -29,86 +26,117 @@ func (uc *GetDailyReportUseCase) Execute( ctx context.Context, in domain.GetDailyReportInput, ) (domain.GetDailyReportOutput, error) { + in.ApplyDefaults() - if in.PortfolioName == "" { - in.PortfolioName = "main" - } - - if in.NewsLimit <= 0 { - in.NewsLimit = 2 - } - - dailyReport, err := uc.reportBuilder.BuildDailyReport( - ctx, - in.PortfolioName, - in.NewsLimit, - ) + dailyReport, err := uc.reportBuilder.BuildDailyReport(ctx, report.BuildDailyReportParams{ + PortfolioName: in.PortfolioName, + NewsLimit: in.NewsLimit, + SaveSnapshot: true, + }) if err != nil { return domain.GetDailyReportOutput{}, err } - snapshot, positions := snapshot.MapAnalysisToSnapshot(dailyReport.Analysis, time.Now()) - - _, err = uc.snapshotRepo.Create(ctx, snapshot, positions) - if err != nil { - return domain.GetDailyReportOutput{}, fmt.Errorf("save portfolio snapshot: %w", err) - } - out := domain.GetDailyReportOutput{ - DailyReport: dailyReport.Report, + DailyReport: dailyReport, } - out.DailyReport, err = uc.enrichWithPreviousSnapshot( - ctx, - out.DailyReport, - snapshot.PortfolioName, - snapshot.CapturedAt, - ) - if err != nil { - return domain.GetDailyReportOutput{}, err + if !in.WithAI { + return out, nil } - if in.WithAI && !uc.summarizer.Enabled() { + if !uc.summarizer.Enabled() { return domain.GetDailyReportOutput{}, fmt.Errorf("ai not configured") } - if !in.WithAI { - return out, nil - } - - summary, err := uc.summarizer.Summarize(ctx, dailyReport.Report) + summary, err := uc.summarizer.Summarize(ctx, dailyReport) if err != nil { return domain.GetDailyReportOutput{}, err } out.AISummary = summary - return out, nil } -func (uc *GetDailyReportUseCase) enrichWithPreviousSnapshot( - ctx context.Context, - dailyReport domain.DailyReport, - portfolioName string, - capturedAt time.Time, -) (domain.DailyReport, error) { - prev, err := uc.snapshotRepo.GetLatestBefore(ctx, portfolioName, capturedAt) - if err != nil { - if errors.Is(err, domain.ErrPortfolioSnapshotNotFound) { - return dailyReport, nil - } - - return domain.DailyReport{}, fmt.Errorf("get previous snapshot: %w", err) - } - - prevPositions, err := uc.snapshotRepo.ListPositionsBySnapshotID(ctx, prev.ID) - if err != nil { - return domain.DailyReport{}, fmt.Errorf("list previous snapshot positions: %w", err) - } - - return report.EnrichDailyReportWithSnapshot( - dailyReport, - snapshot.MapSnapshotToDomain(prev), - snapshot.MapSnapshotPositionsToDomain(prevPositions), - ), nil -} +// func (uc *GetDailyReportUseCase) Execute( +// ctx context.Context, +// in domain.GetDailyReportInput, +// ) (domain.GetDailyReportOutput, error) { +// in.ApplyDefaults() + +// dailyReport, err := uc.reportBuilder.BuildDailyReport( +// ctx, +// report.BuildDailyReportParams{ +// PortfolioName: in.PortfolioName, +// NewsLimit: in.NewsLimit, +// }, +// ) +// if err != nil { +// return domain.GetDailyReportOutput{}, err +// } + +// snapshot, positions := snapshot.MapAnalysisToSnapshot(dailyReport.Analysis, time.Now()) + +// _, err = uc.snapshotRepo.Create(ctx, snapshot, positions) +// if err != nil { +// return domain.GetDailyReportOutput{}, fmt.Errorf("save portfolio snapshot: %w", err) +// } + +// out := domain.GetDailyReportOutput{ +// DailyReport: dailyReport.Report, +// } + +// out.DailyReport, err = uc.enrichWithPreviousSnapshot( +// ctx, +// out.DailyReport, +// snapshot.PortfolioName, +// snapshot.CapturedAt, +// ) +// if err != nil { +// return domain.GetDailyReportOutput{}, err +// } + +// if in.WithAI && !uc.summarizer.Enabled() { +// return domain.GetDailyReportOutput{}, fmt.Errorf("ai not configured") +// } + +// if !in.WithAI { +// return out, nil +// } + +// summary, err := uc.summarizer.Summarize(ctx, dailyReport.Report) +// if err != nil { +// return domain.GetDailyReportOutput{}, err +// } + +// out.AISummary = summary + +// return out, nil +// } + +// func (uc *GetDailyReportUseCase) enrichWithPreviousSnapshot( +// ctx context.Context, +// dailyReport domain.DailyReport, +// portfolioName string, +// capturedAt time.Time, +// ) (domain.DailyReport, error) { +// prev, err := uc.snapshotRepo.GetLatestBefore(ctx, portfolioName, capturedAt) +// if err != nil { +// if errors.Is(err, domain.ErrPortfolioSnapshotNotFound) { +// return dailyReport, nil +// } + +// return domain.DailyReport{}, fmt.Errorf("get previous snapshot: %w", err) +// } + +// prevPositions, err := uc.snapshotRepo.ListPositionsBySnapshotID(ctx, prev.ID) +// if err != nil { +// return domain.DailyReport{}, fmt.Errorf("list previous snapshot positions: %w", err) +// } + +// return report.EnrichDailyReportWithSnapshot( +// dailyReport, +// snapshot.MapSnapshotToDomain(prev), +// snapshot.MapSnapshotPositionsToDomain(prevPositions), +// ), nil +// } diff --git a/internal/usecase/mocks/mock_interfaces.go b/internal/usecase/mocks/mock_interfaces.go index d3ee196..83facb8 100644 --- a/internal/usecase/mocks/mock_interfaces.go +++ b/internal/usecase/mocks/mock_interfaces.go @@ -16,6 +16,7 @@ import ( domain "github.com/squeakycheese75/tick/internal/domain" instruments "github.com/squeakycheese75/tick/internal/instruments" + report "github.com/squeakycheese75/tick/internal/report" repository "github.com/squeakycheese75/tick/internal/repository" gomock "go.uber.org/mock/gomock" ) @@ -328,10 +329,10 @@ func (m *MockNewsSvc) EXPECT() *MockNewsSvcMockRecorder { } // GetNews mocks base method. -func (m *MockNewsSvc) GetNews(ctx context.Context, ticker string, limit int) (domain.TickerNewsReport, error) { +func (m *MockNewsSvc) GetNews(ctx context.Context, ticker string, limit int) (domain.NewsSummary, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNews", ctx, ticker, limit) - ret0, _ := ret[0].(domain.TickerNewsReport) + ret0, _ := ret[0].(domain.NewsSummary) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -435,18 +436,18 @@ func (mr *MockDailyReportSummarizerMockRecorder) Enabled() *gomock.Call { } // Summarize mocks base method. -func (m *MockDailyReportSummarizer) Summarize(ctx context.Context, report domain.DailyReport) (string, error) { +func (m *MockDailyReportSummarizer) Summarize(ctx context.Context, arg1 domain.DailyReport) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Summarize", ctx, report) + ret := m.ctrl.Call(m, "Summarize", ctx, arg1) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // Summarize indicates an expected call of Summarize. -func (mr *MockDailyReportSummarizerMockRecorder) Summarize(ctx, report any) *gomock.Call { +func (mr *MockDailyReportSummarizerMockRecorder) Summarize(ctx, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Summarize", reflect.TypeOf((*MockDailyReportSummarizer)(nil).Summarize), ctx, report) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Summarize", reflect.TypeOf((*MockDailyReportSummarizer)(nil).Summarize), ctx, arg1) } // MockReportBuilder is a mock of ReportBuilder interface. @@ -474,16 +475,70 @@ func (m *MockReportBuilder) EXPECT() *MockReportBuilderMockRecorder { } // BuildDailyReport mocks base method. -func (m *MockReportBuilder) BuildDailyReport(ctx context.Context, portfolioName string, newsLimit int) (domain.DailyReportResult, error) { +func (m *MockReportBuilder) BuildDailyReport(ctx context.Context, in report.BuildDailyReportParams) (domain.DailyReportResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BuildDailyReport", ctx, portfolioName, newsLimit) + ret := m.ctrl.Call(m, "BuildDailyReport", ctx, in) ret0, _ := ret[0].(domain.DailyReportResult) ret1, _ := ret[1].(error) return ret0, ret1 } // BuildDailyReport indicates an expected call of BuildDailyReport. -func (mr *MockReportBuilderMockRecorder) BuildDailyReport(ctx, portfolioName, newsLimit any) *gomock.Call { +func (mr *MockReportBuilderMockRecorder) BuildDailyReport(ctx, in any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildDailyReport", reflect.TypeOf((*MockReportBuilder)(nil).BuildDailyReport), ctx, portfolioName, newsLimit) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildDailyReport", reflect.TypeOf((*MockReportBuilder)(nil).BuildDailyReport), ctx, in) +} + +// BuildMorningBriefReport mocks base method. +func (m *MockReportBuilder) BuildMorningBriefReport(ctx context.Context, in report.BuildMorningBriefReportParams) (domain.BriefReport, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BuildMorningBriefReport", ctx, in) + ret0, _ := ret[0].(domain.BriefReport) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BuildMorningBriefReport indicates an expected call of BuildMorningBriefReport. +func (mr *MockReportBuilderMockRecorder) BuildMorningBriefReport(ctx, in any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildMorningBriefReport", reflect.TypeOf((*MockReportBuilder)(nil).BuildMorningBriefReport), ctx, in) +} + +// MockPricingSvc is a mock of PricingSvc interface. +type MockPricingSvc struct { + ctrl *gomock.Controller + recorder *MockPricingSvcMockRecorder + isgomock struct{} +} + +// MockPricingSvcMockRecorder is the mock recorder for MockPricingSvc. +type MockPricingSvcMockRecorder struct { + mock *MockPricingSvc +} + +// NewMockPricingSvc creates a new mock instance. +func NewMockPricingSvc(ctrl *gomock.Controller) *MockPricingSvc { + mock := &MockPricingSvc{ctrl: ctrl} + mock.recorder = &MockPricingSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPricingSvc) EXPECT() *MockPricingSvcMockRecorder { + return m.recorder +} + +// GetValuationQuote mocks base method. +func (m *MockPricingSvc) GetValuationQuote(ctx context.Context, symbol, targetCurrency, instrumentCurrency, instrumentType string) (domain.ValuationQuote, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetValuationQuote", ctx, symbol, targetCurrency, instrumentCurrency, instrumentType) + ret0, _ := ret[0].(domain.ValuationQuote) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetValuationQuote indicates an expected call of GetValuationQuote. +func (mr *MockPricingSvcMockRecorder) GetValuationQuote(ctx, symbol, targetCurrency, instrumentCurrency, instrumentType any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValuationQuote", reflect.TypeOf((*MockPricingSvc)(nil).GetValuationQuote), ctx, symbol, targetCurrency, instrumentCurrency, instrumentType) } diff --git a/internal/usecase/morning_brief.go b/internal/usecase/morning_brief.go new file mode 100644 index 0000000..aae402e --- /dev/null +++ b/internal/usecase/morning_brief.go @@ -0,0 +1,33 @@ +package usecase + +import ( + "context" + + "github.com/squeakycheese75/tick/internal/domain" + "github.com/squeakycheese75/tick/internal/report" +) + +type GetMorningBriefUsecase struct { + reportBuilder ReportBuilder +} + +func NewGetMorningBriefUsecase(reportBuilder ReportBuilder) *GetMorningBriefUsecase { + return &GetMorningBriefUsecase{ + reportBuilder: reportBuilder, + } +} + +func (uc *GetMorningBriefUsecase) Execute(ctx context.Context, in domain.GetMorningBriefUsecaseInput) (domain.GetMorningBriefUsecaseOutput, error) { + in.ApplyDefaults() + + report, err := uc.reportBuilder.BuildMorningBriefReport(ctx, report.BuildMorningBriefReportParams{ + PortfolioName: in.PortfolioName, + }) + if err != nil { + return domain.GetMorningBriefUsecaseOutput{}, err + } + + return domain.GetMorningBriefUsecaseOutput{ + Report: report, + }, nil +} diff --git a/internal/usecase/news_ticker_get.go b/internal/usecase/news_ticker_get.go index f4cc3f4..dc28348 100644 --- a/internal/usecase/news_ticker_get.go +++ b/internal/usecase/news_ticker_get.go @@ -19,10 +19,10 @@ func (uc *GetTickerNewsUseCase) Execute( ctx context.Context, ticker string, limit int, -) (domain.TickerNewsReport, error) { +) (domain.NewsSummary, error) { if ticker == "" { - return domain.TickerNewsReport{}, fmt.Errorf("ticker required") + return domain.NewsSummary{}, fmt.Errorf("ticker required") } if limit <= 0 { diff --git a/internal/usecase/portfolio_add_position_test.go b/internal/usecase/portfolio_add_position_test.go index 247db48..7e09c40 100644 --- a/internal/usecase/portfolio_add_position_test.go +++ b/internal/usecase/portfolio_add_position_test.go @@ -83,58 +83,3 @@ func TestAddPositionToPortfolioUseCase_Execute_CreatesPosition(t *testing.T) { t.Fatalf("unexpected symbol: %q", out.Position.Instrument.Symbol) } } - -func TestAddPositionToPortfolioUseCase_Execute_ReturnsErrorWhenPositionExists(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - portfolios := mocks.NewMockPortfolioRepository(ctrl) - instruments := mocks.NewMockInstrumentRepository(ctrl) - positions := mocks.NewMockPositionRepository(ctrl) - instrumentResolver := mocks.NewMockInstrumentResolver(ctrl) - - uc := NewAddPositionToPortfolioUseCase(positions, portfolios, instruments, instrumentResolver) - - in := domain.AddPositionToPortfolioInput{ - PortfolioName: "main", - Symbol: "NVDA", - InstrumentType: "equity", - Exchange: "NASDAQ", - QuoteCurrency: "USD", - Qty: 10, - AvgCost: 400, - } - - portfolios.EXPECT(). - GetByName(gomock.Any(), "main"). - Return(repository.Portfolio{ - ID: 1, - Name: "main", - }, nil) - - instruments.EXPECT(). - GetOrCreate(gomock.Any(), gomock.Any()). - Return(repository.Instrument{ - ID: 42, - Symbol: "NVDA", - }, nil) - - positions.EXPECT(). - Create(gomock.Any(), repository.CreatePositionParams{ - InstrumentID: 42, - PortfolioID: 1, - Quantity: 10, - AvgCost: 400, - Currency: "USD", - }). - Return(domain.ErrPositionAlreadyExists) - - out, err := uc.Execute(context.Background(), in) - if err == nil { - t.Fatal("expected error, got nil") - } - - if out != nil { - t.Fatalf("expected nil output, got %#v", out) - } -} diff --git a/internal/usecase/portfolio_add_positon.go b/internal/usecase/portfolio_add_positon.go index b77273b..224e995 100644 --- a/internal/usecase/portfolio_add_positon.go +++ b/internal/usecase/portfolio_add_positon.go @@ -91,13 +91,6 @@ func (uc *AddPositionToPortfolioUseCase) Execute( Currency: in.QuoteCurrency, }) if err != nil { - if errors.Is(err, domain.ErrPortfolioAlreadyExists) { - return nil, fmt.Errorf( - "position for %q already exists in portfolio %q", - instrument.Symbol, - portfolio.Name, - ) - } return nil, fmt.Errorf("create position: %w", err) } diff --git a/internal/usecase/usecase.go b/internal/usecase/usecase.go index ef155ae..cf02a58 100644 --- a/internal/usecase/usecase.go +++ b/internal/usecase/usecase.go @@ -6,6 +6,7 @@ import ( "github.com/squeakycheese75/tick/internal/domain" "github.com/squeakycheese75/tick/internal/instruments" + "github.com/squeakycheese75/tick/internal/report" "github.com/squeakycheese75/tick/internal/repository" ) @@ -37,7 +38,7 @@ type ( type ( NewsSvc interface { - GetNews(ctx context.Context, ticker string, limit int) (domain.TickerNewsReport, error) + GetNews(ctx context.Context, ticker string, limit int) (domain.NewsSummary, error) } PortfolioSvc interface { GetAnalysis(ctx context.Context, portfolioName string) (domain.PortfolioAnalysis, error) @@ -48,6 +49,10 @@ type ( Enabled() bool } ReportBuilder interface { - BuildDailyReport(ctx context.Context, portfolioName string, newsLimit int) (domain.DailyReportResult, error) + BuildDailyReport(ctx context.Context, in report.BuildDailyReportParams) (domain.DailyReport, error) + BuildMorningBriefReport(ctx context.Context, in report.BuildMorningBriefReportParams) (domain.BriefReport, error) + } + PricingSvc interface { + GetValuationQuote(ctx context.Context, symbol string, targetCurrency string, instrumentCurrency string, instrumentType string) (domain.ValuationQuote, error) } ) From 8ec25ca7b4ddf46395e016d9480560d87dd4bf7a Mon Sep 17 00:00:00 2001 From: squeakycheese75 Date: Mon, 27 Apr 2026 13:02:30 +0200 Subject: [PATCH 2/4] fix: resolved issues with holidings reportin incorrectly --- internal/render/brief.go | 2 +- internal/render/daily.go | 17 ++++++++++--- internal/report/morning_report.go | 16 +++--------- internal/report/shared.go | 41 ++++++++++++++++++++----------- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/internal/render/brief.go b/internal/render/brief.go index 4acc42d..8902918 100644 --- a/internal/render/brief.go +++ b/internal/render/brief.go @@ -11,7 +11,7 @@ func RenderBriefReport(w io.Writer, r domain.BriefReport) error { renderPortfolioSummary(out, r.Portfolio, SummaryOptions{}) out.println("") - renderHoldingSummary(out, r.Movers, HoldingsOptions{}) + renderHoldingSummary(out, r.Movers, r.Portfolio.BaseCurrency, HoldingsOptions{}) out.println("") renderNewsSummary(out, r.News, NewsOptions{}) diff --git a/internal/render/daily.go b/internal/render/daily.go index 207be65..58e9b8d 100644 --- a/internal/render/daily.go +++ b/internal/render/daily.go @@ -14,7 +14,7 @@ func DailyReport(w io.Writer, s domain.GetDailyReportOutput, opts DailyReportOpt renderPortfolioSummary(out, r.Portfolio, opts.Summary) out.println("") - renderHoldingSummary(out, r.TopHoldings, opts.Holdings) + renderHoldingSummary(out, r.TopHoldings, r.Portfolio.BaseCurrency, opts.Holdings) out.println("") renderRiskSummary(out, r.Risk, opts.Risk) @@ -62,7 +62,12 @@ func renderPortfolioSummary(out *writer, r domain.PortfolioSummary, opts Summary } } -func renderHoldingSummary(out *writer, r domain.HoldingSummary, opts HoldingsOptions) { +func renderHoldingSummary( + out *writer, + r domain.HoldingSummary, + baseCurrency string, + opts HoldingsOptions, +) { out.println("Holdings") if len(r.Holdings) == 0 { out.println("No positions") @@ -74,7 +79,7 @@ func renderHoldingSummary(out *writer, r domain.HoldingSummary, opts HoldingsOpt "%-5s %7.2f%% %16s @ %16s %s", h.Symbol, h.Weight*100, - formatMoney(h.MarketValueBase, h.PriceCurrency), + formatMoney(h.MarketValueBase, baseCurrency), formatMoney(h.QuotedPrice, h.PriceCurrency), formatChangePercent(h.ChangePercent, opts.Color), ) @@ -87,7 +92,11 @@ func renderHoldingSummary(out *writer, r domain.HoldingSummary, opts HoldingsOpt )) { out.printf( " Δsnap %s (%s)", - formatSignedMoneyColored(h.SinceLastSnapshot.Absolute, h.PriceCurrency, opts.Color), + formatSignedMoneyColored( + h.SinceLastSnapshot.Absolute, + baseCurrency, + opts.Color, + ), formatSignedPercentColored(h.SinceLastSnapshot.Percent, opts.Color), ) } diff --git a/internal/report/morning_report.go b/internal/report/morning_report.go index b488237..442d692 100644 --- a/internal/report/morning_report.go +++ b/internal/report/morning_report.go @@ -23,20 +23,12 @@ func (s *ReportBuilder) BuildMorningBriefReport(ctx context.Context, in BuildMor Movers: assembleHoldingSummary(analysis.AnalyzedPositions), } - for _, pos := range analysis.AnalyzedPositions { - newsItems, err := s.newsSvc.GetNews(ctx, pos.Symbol, 1) - if err != nil { - continue - } - out.News = append(out.News, newsItems) + news, err := s.getNewsSummaries(ctx, out.Movers, 1) + if err != nil { + return domain.BriefReport{}, fmt.Errorf("get news summaries: %w", err) } - if out.Movers.TotalValue != out.Movers.Change.Absolute { - previousValue := out.Movers.TotalValue - out.Movers.Change.Absolute - if previousValue > 0 { - out.Movers.Change.Percent = (out.Movers.Change.Percent / previousValue) * 100 - } - } + out.News = news sortHoldingsByAbsValueChange(out.Movers.Holdings) diff --git a/internal/report/shared.go b/internal/report/shared.go index 3b84ab4..bf96495 100644 --- a/internal/report/shared.go +++ b/internal/report/shared.go @@ -61,8 +61,10 @@ func (s *ReportBuilder) getNewsSummaries( wg.Wait() close(errCh) - if err := <-errCh; err != nil { - return nil, err + for err := range errCh { + if err != nil { + return nil, err + } } return news, nil @@ -93,25 +95,36 @@ func assembleRiskSummary(risk domain.PortfolioRisk) domain.RiskSummary { // return domain.AttentionSummary{} // } -func assembleHoldingSummary(postions []domain.AnalyzedPosition) (out domain.HoldingSummary) { - for _, pos := range postions { - value := positionValue(pos.Quantity, pos.QuotedPrice) +func assembleHoldingSummary(positions []domain.AnalyzedPosition) (out domain.HoldingSummary) { + var totalChange float64 + + for _, pos := range positions { + value := pos.MarketValueBase change := positionValueChange(pos.Quantity, pos.QuotedChange) out.TotalValue += value - out.Change = &domain.ValueChangeSummary{ - Absolute: change, - } + totalChange += change out.Holdings = append(out.Holdings, domain.Holding{ - Symbol: pos.Symbol, - Quantity: pos.Quantity, - Weight: pos.Weight, - ChangePercent: pos.QuotedChangePct, - ChangeAbsolute: change, - PriceCurrency: pos.PriceCurrency, + Symbol: pos.Symbol, + Quantity: pos.Quantity, + MarketValueBase: value, + Weight: pos.Weight, + QuotedPrice: pos.QuotedPrice, + ChangePercent: pos.QuotedChangePct, + ChangeAbsolute: change, + PriceCurrency: pos.PriceCurrency, }) + } + out.Change = &domain.ValueChangeSummary{ + Absolute: totalChange, } + + previousValue := out.TotalValue - totalChange + if previousValue > 0 { + out.Change.Percent = totalChange / previousValue + } + return out } From f2b2e84d9f5da966d07333b09dd3596c13c7e6ae Mon Sep 17 00:00:00 2001 From: squeakycheese75 Date: Mon, 27 Apr 2026 13:24:33 +0200 Subject: [PATCH 3/4] fix: resolved bug with crypto prices missing the previous price --- .../market/crypto_price_provider_coingecko.go | 11 ++++++ internal/cli/brief.go | 4 ++- internal/render/brief.go | 8 ++--- internal/render/daily.go | 34 +++++++++++++++---- internal/render/options.go | 30 ++++++++++++++++ internal/report/shared.go | 13 ------- 6 files changed, 76 insertions(+), 24 deletions(-) diff --git a/internal/adapters/market/crypto_price_provider_coingecko.go b/internal/adapters/market/crypto_price_provider_coingecko.go index b1f2c79..b2e727d 100644 --- a/internal/adapters/market/crypto_price_provider_coingecko.go +++ b/internal/adapters/market/crypto_price_provider_coingecko.go @@ -38,6 +38,7 @@ func (p *CoinGeckoProvider) GetQuote(ctx context.Context, ticker string) (domain q := u.Query() q.Set("ids", coinID) q.Set("vs_currencies", "usd") + q.Set("include_24hr_change", "true") u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -72,10 +73,20 @@ func (p *CoinGeckoProvider) GetQuote(ctx context.Context, ticker string) (domain return domain.Quote{}, fmt.Errorf("coingecko returned no usd price for %q", coinID) } + changePctPercent := coinData["usd_24h_change"] + changePctRatio := changePctPercent / 100 + + previous := price / (1 + changePctRatio) + change := price - previous + return domain.Quote{ Ticker: strings.ToUpper(strings.TrimSpace(ticker)), Price: price, PriceCurrency: "USD", + PreviousClose: previous, + Change: change, + ChangePercent: changePctPercent, + Source: "coingecko", }, nil } diff --git a/internal/cli/brief.go b/internal/cli/brief.go index c7fe815..8abea24 100644 --- a/internal/cli/brief.go +++ b/internal/cli/brief.go @@ -23,7 +23,9 @@ func newBriefCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { return err } - return render.RenderBriefReport(cmd.OutOrStdout(), out.Report) + opts := render.DefaultBriefReportOptions() + + return render.RenderBriefReport(cmd.OutOrStdout(), out.Report, opts) }, } } diff --git a/internal/render/brief.go b/internal/render/brief.go index 8902918..87ed9db 100644 --- a/internal/render/brief.go +++ b/internal/render/brief.go @@ -6,15 +6,15 @@ import ( "github.com/squeakycheese75/tick/internal/domain" ) -func RenderBriefReport(w io.Writer, r domain.BriefReport) error { +func RenderBriefReport(w io.Writer, r domain.BriefReport, opts BriefReportOptions) error { out := &writer{w: w} - renderPortfolioSummary(out, r.Portfolio, SummaryOptions{}) + renderPortfolioSummary(out, r.Portfolio, opts.Summary) out.println("") - renderHoldingSummary(out, r.Movers, r.Portfolio.BaseCurrency, HoldingsOptions{}) + renderMoversSummary(out, r.Movers, r.Portfolio.BaseCurrency, opts.Holdings) out.println("") - renderNewsSummary(out, r.News, NewsOptions{}) + renderNewsSummary(out, r.News, opts.News) return out.err } diff --git a/internal/render/daily.go b/internal/render/daily.go index 58e9b8d..6320bd6 100644 --- a/internal/render/daily.go +++ b/internal/render/daily.go @@ -68,7 +68,26 @@ func renderHoldingSummary( baseCurrency string, opts HoldingsOptions, ) { - out.println("Holdings") + renderHoldingRows(out, r, baseCurrency, opts, false) +} + +func renderMoversSummary( + out *writer, + r domain.HoldingSummary, + baseCurrency string, + opts HoldingsOptions, +) { + renderHoldingRows(out, r, baseCurrency, opts, true) +} + +func renderHoldingRows( + out *writer, + r domain.HoldingSummary, + baseCurrency string, + opts HoldingsOptions, + showAbsChange bool, +) { + out.println(opts.Title) if len(r.Holdings) == 0 { out.println("No positions") return @@ -84,6 +103,13 @@ func renderHoldingSummary( formatChangePercent(h.ChangePercent, opts.Color), ) + if showAbsChange { + out.printf( + " %s", + formatSignedMoneyColored(h.ChangeAbsolute, baseCurrency, opts.Color), + ) + } + if opts.ShowSnapshotDelta && h.SinceLastSnapshot != nil && (!opts.HideZeroDelta || shouldShowChange( @@ -92,11 +118,7 @@ func renderHoldingSummary( )) { out.printf( " Δsnap %s (%s)", - formatSignedMoneyColored( - h.SinceLastSnapshot.Absolute, - baseCurrency, - opts.Color, - ), + formatSignedMoneyColored(h.SinceLastSnapshot.Absolute, baseCurrency, opts.Color), formatSignedPercentColored(h.SinceLastSnapshot.Percent, opts.Color), ) } diff --git a/internal/render/options.go b/internal/render/options.go index a1a0873..c32f28b 100644 --- a/internal/render/options.go +++ b/internal/render/options.go @@ -20,6 +20,7 @@ type SummaryOptions struct { } type HoldingsOptions struct { + Title string ShowSnapshotDelta bool HideZeroDelta bool Color bool @@ -95,3 +96,32 @@ func DefaultPortfolioRiskOptions() PortfolioRiskOptions { ShowObservations: true, } } + +type BriefReportOptions struct { + Summary SummaryOptions + Holdings HoldingsOptions + News NewsOptions +} + +func DefaultBriefReportOptions() BriefReportOptions { + color := isatty.IsTerminal(os.Stdout.Fd()) + + return BriefReportOptions{ + Summary: SummaryOptions{ + ShowSnapshotDelta: false, + HideZeroDelta: true, + }, + Holdings: HoldingsOptions{ + Title: "Movers", + ShowSnapshotDelta: true, + HideZeroDelta: true, + Color: color, + }, + News: NewsOptions{ + MaxHeadlines: 1, + ShowLinks: false, + TruncateTitles: true, + HeadlineMaxLen: 100, + }, + } +} diff --git a/internal/report/shared.go b/internal/report/shared.go index bf96495..d086ea1 100644 --- a/internal/report/shared.go +++ b/internal/report/shared.go @@ -9,23 +9,10 @@ import ( "github.com/squeakycheese75/tick/internal/domain" ) -func positionValue(quantity, price float64) float64 { - return quantity * price -} - func positionValueChange(quantity, priceChange float64) float64 { return quantity * priceChange } -// func changePercent(change, currentValue float64) float64 { -// previousValue := currentValue - change -// if previousValue <= 0 { -// return 0 -// } - -// return (change / previousValue) * 100 -// } - func sortHoldingsByAbsValueChange(holding []domain.Holding) { sort.Slice(holding, func(i, j int) bool { return math.Abs(holding[i].ChangeAbsolute) > math.Abs(holding[j].ChangeAbsolute) From 5d24d56b36ef02f5ab1297070b16f711c4509693 Mon Sep 17 00:00:00 2001 From: squeakycheese75 Date: Mon, 27 Apr 2026 13:32:45 +0200 Subject: [PATCH 4/4] fix: minor formatting fix --- internal/render/brief.go | 1 + internal/render/daily.go | 7 ++++++- internal/render/options.go | 1 + internal/render/writer.go | 9 +++++++-- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/render/brief.go b/internal/render/brief.go index 87ed9db..c91adca 100644 --- a/internal/render/brief.go +++ b/internal/render/brief.go @@ -11,6 +11,7 @@ func RenderBriefReport(w io.Writer, r domain.BriefReport, opts BriefReportOption renderPortfolioSummary(out, r.Portfolio, opts.Summary) out.println("") + renderMoversSummary(out, r.Movers, r.Portfolio.BaseCurrency, opts.Holdings) out.println("") diff --git a/internal/render/daily.go b/internal/render/daily.go index 6320bd6..08b7f4a 100644 --- a/internal/render/daily.go +++ b/internal/render/daily.go @@ -87,7 +87,12 @@ func renderHoldingRows( opts HoldingsOptions, showAbsChange bool, ) { - out.println(opts.Title) + title := opts.Title + if title == "" { + title = "Holdings" + } + + out.println(title) if len(r.Holdings) == 0 { out.println("No positions") return diff --git a/internal/render/options.go b/internal/render/options.go index c32f28b..8d71f48 100644 --- a/internal/render/options.go +++ b/internal/render/options.go @@ -50,6 +50,7 @@ func DefaultDailyReportOptions() DailyReportOptions { HideZeroDelta: true, }, Holdings: HoldingsOptions{ + Title: "Holdings", ShowSnapshotDelta: true, HideZeroDelta: true, Color: true, diff --git a/internal/render/writer.go b/internal/render/writer.go index 4f159ce..c5a1b84 100644 --- a/internal/render/writer.go +++ b/internal/render/writer.go @@ -14,9 +14,14 @@ func (w *writer) printf(format string, args ...any) { if w.err != nil { return } + _, w.err = fmt.Fprintf(w.w, format, args...) } -func (w *writer) println(s string) { - w.printf("%s\n", s) +func (w *writer) println(args ...any) { + if w.err != nil { + return + } + + _, w.err = fmt.Fprintln(w.w, args...) }