From fd2d22a0f691e3c494fc901ab88380feaa6777e3 Mon Sep 17 00:00:00 2001 From: squeakycheese75 Date: Thu, 30 Apr 2026 10:38:45 +0200 Subject: [PATCH 1/2] feat: added portfolio targets --- db/query/query.sql | 24 ++++ db/schema/schema.sql | 32 ++++- go.mod | 4 +- go.sum | 12 +- .../market/crypto_price_provider_coingecko.go | 2 +- .../market/crypto_price_provider_static.go | 2 +- .../market/equity_price_provider_cached.go | 4 +- .../market/equity_price_provider_finnhub.go | 2 +- .../market/equity_price_provider_static.go | 2 +- internal/{domain => }/analysis/analysis.go | 0 .../analysis/mocks/mock_interfaces.go | 0 .../analysis/portfolio_analysis.go | 0 .../analysis/portfolio_analysis_test.go | 2 +- .../{domain => }/analysis/portfolio_risk.go | 0 internal/app/runtime.go | 20 ++- internal/cli/root.go | 1 + internal/cli/target.go | 78 ++++++++++ ...nal_db_migrations_sqlite.initial_setup.sql | 7 +- ..._db_migrations_added_target_tables_sql.sql | 31 ++++ internal/db/models.go | 43 ++++-- internal/db/query.sql.go | 136 +++++++++++++++--- internal/domain/pricing.go | 2 +- internal/domain/report.go | 11 ++ internal/domain/target.go | 23 +++ internal/domain/usecase.go | 16 +++ .../snapshot/mapper.go => mappers/snaphot.go} | 2 +- internal/render/daily.go | 61 ++++---- internal/render/news.go | 41 ++++++ internal/render/target.go | 21 +++ internal/report/builder.go | 38 +++-- internal/report/{daily_report.go => daily.go} | 10 +- internal/report/morning_report.go | 2 +- internal/report/target_evaluator.go | 33 +++++ internal/repository/instrument_repo.go | 4 +- internal/repository/position_repo.go | 2 +- internal/repository/target_repository.go | 60 ++++++++ internal/service/insights.go | 10 +- .../{portfolio.go => portfolio_analysis.go} | 57 ++------ internal/service/portfolio_risk.go | 43 ++++++ internal/service/service.go | 30 ++++ internal/service/snaphot.go | 2 +- internal/service/target.go | 93 ++++++++++++ internal/usecase/portfolio_get_risk.go | 15 +- internal/usecase/portfolio_get_summary.go | 8 +- internal/usecase/target_set.go | 43 ++++++ internal/usecase/usecase.go | 11 +- 46 files changed, 863 insertions(+), 177 deletions(-) rename internal/{domain => }/analysis/analysis.go (100%) rename internal/{domain => }/analysis/mocks/mock_interfaces.go (100%) rename internal/{domain => }/analysis/portfolio_analysis.go (100%) rename internal/{domain => }/analysis/portfolio_analysis_test.go (99%) rename internal/{domain => }/analysis/portfolio_risk.go (100%) create mode 100644 internal/cli/target.go create mode 100644 internal/db/migrations/20260429081813_internal_db_migrations_added_target_tables_sql.sql create mode 100644 internal/domain/target.go rename internal/{domain/snapshot/mapper.go => mappers/snaphot.go} (99%) create mode 100644 internal/render/news.go create mode 100644 internal/render/target.go rename internal/report/{daily_report.go => daily.go} (81%) create mode 100644 internal/report/target_evaluator.go create mode 100644 internal/repository/target_repository.go rename internal/service/{portfolio.go => portfolio_analysis.go} (52%) create mode 100644 internal/service/portfolio_risk.go create mode 100644 internal/service/service.go create mode 100644 internal/service/target.go create mode 100644 internal/usecase/target_set.go diff --git a/db/query/query.sql b/db/query/query.sql index 09b4789..6292849 100644 --- a/db/query/query.sql +++ b/db/query/query.sql @@ -157,3 +157,27 @@ SELECT * FROM portfolio_snapshot_positions WHERE snapshot_id = ? ORDER BY symbol ASC; + + +-- name: CreateTarget :one +INSERT INTO portfolio_targets ( + portfolio_id, + symbol, + type, + target_price, + quote_currency +) VALUES (?, ?, ?, ?, ?) +RETURNING id; + +-- name: ListTargetsByPortfolio :many +SELECT + p.name, + t.symbol, + t.target_price, + t.type, + t.quote_currency +FROM portfolio_targets AS t +JOIN portfolios AS p ON t.portfolio_id = p.id +WHERE t.portfolio_id = ? +AND t.deleted_at IS NULL +ORDER BY t.symbol ASC; diff --git a/db/schema/schema.sql b/db/schema/schema.sql index 79121e2..9c44dbc 100644 --- a/db/schema/schema.sql +++ b/db/schema/schema.sql @@ -6,7 +6,8 @@ CREATE TABLE IF NOT EXISTS instruments ( exchange TEXT, quote_currency TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS portfolios ( @@ -14,7 +15,8 @@ CREATE TABLE IF NOT EXISTS portfolios ( name TEXT NOT NULL UNIQUE, base_currency TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS positions ( @@ -26,6 +28,7 @@ CREATE TABLE IF NOT EXISTS positions ( currency TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, FOREIGN KEY (instrument_id) REFERENCES instruments(id), FOREIGN KEY (portfolio_id) REFERENCES portfolios(id), UNIQUE (portfolio_id, instrument_id) @@ -75,6 +78,29 @@ CREATE TABLE portfolio_snapshot_positions ( fx_rate REAL NOT NULL, market_value_base REAL NOT NULL, weight REAL NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (snapshot_id) REFERENCES portfolio_snapshots(id) ON DELETE CASCADE ); + +CREATE TABLE portfolio_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + portfolio_id INTEGER NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, + type TEXT NOT NULL, + target_price REAL NOT NULL, + quote_currency TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + CONSTRAINT portfolio_targets_type_check + CHECK (type IN ('take-profit', 'stop-loss')) +); + +CREATE UNIQUE INDEX idx_portfolio_targets_unique_active + ON portfolio_targets (portfolio_id, symbol, type) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_portfolio_targets_portfolio_id + ON portfolio_targets (portfolio_id); + \ No newline at end of file diff --git a/go.mod b/go.mod index fb10944..1df837f 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,11 @@ go 1.25.1 require ( github.com/joho/godotenv v1.5.1 + github.com/mattn/go-isatty v0.0.20 github.com/pressly/goose/v3 v3.27.0 github.com/spf13/cobra v1.10.2 go.uber.org/mock v0.6.0 + golang.org/x/text v0.36.0 modernc.org/sqlite v1.48.1 ) @@ -15,7 +17,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -24,7 +25,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.36.0 modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index b7edb5b..2ae1f62 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -43,10 +45,8 @@ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -55,8 +55,8 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapters/market/crypto_price_provider_coingecko.go b/internal/adapters/market/crypto_price_provider_coingecko.go index b2e727d..4af5742 100644 --- a/internal/adapters/market/crypto_price_provider_coingecko.go +++ b/internal/adapters/market/crypto_price_provider_coingecko.go @@ -80,7 +80,7 @@ func (p *CoinGeckoProvider) GetQuote(ctx context.Context, ticker string) (domain change := price - previous return domain.Quote{ - Ticker: strings.ToUpper(strings.TrimSpace(ticker)), + Symbol: strings.ToUpper(strings.TrimSpace(ticker)), Price: price, PriceCurrency: "USD", PreviousClose: previous, diff --git a/internal/adapters/market/crypto_price_provider_static.go b/internal/adapters/market/crypto_price_provider_static.go index d79e2e7..5c8c75f 100644 --- a/internal/adapters/market/crypto_price_provider_static.go +++ b/internal/adapters/market/crypto_price_provider_static.go @@ -44,7 +44,7 @@ func (p *StaticCryptoPriceProvider) GetQuote(_ context.Context, ticker string) ( } return domain.Quote{ - Ticker: ticker, + Symbol: ticker, Price: v.Price, PriceCurrency: v.Currency, PreviousClose: v.PreviousClose, diff --git a/internal/adapters/market/equity_price_provider_cached.go b/internal/adapters/market/equity_price_provider_cached.go index 19a26c5..5faf9c5 100644 --- a/internal/adapters/market/equity_price_provider_cached.go +++ b/internal/adapters/market/equity_price_provider_cached.go @@ -65,7 +65,7 @@ func (p *CachedPriceProvider) GetQuote(ctx context.Context, ticker string) (doma func toDomainQuote(c repository.CachedPriceQuote) domain.Quote { return domain.Quote{ - Ticker: c.PriceQuote.Ticker, + Symbol: c.PriceQuote.Ticker, Price: c.PriceQuote.Price, PriceCurrency: c.PriceQuote.PriceCurrency, PreviousClose: c.PriceQuote.PreviousClose, @@ -77,7 +77,7 @@ func toDomainQuote(c repository.CachedPriceQuote) domain.Quote { func toRepositoryQuote(q domain.Quote) repository.PriceQuote { return repository.PriceQuote{ - Ticker: q.Ticker, + Ticker: q.Symbol, Price: q.Price, PriceCurrency: q.PriceCurrency, PreviousClose: q.PreviousClose, diff --git a/internal/adapters/market/equity_price_provider_finnhub.go b/internal/adapters/market/equity_price_provider_finnhub.go index b129eec..265ffb6 100644 --- a/internal/adapters/market/equity_price_provider_finnhub.go +++ b/internal/adapters/market/equity_price_provider_finnhub.go @@ -63,7 +63,7 @@ func (p *FinnhubPriceProvider) GetQuote(ctx context.Context, ticker string) (dom } return domain.Quote{ - Ticker: ticker, + Symbol: ticker, Price: data.C, PriceCurrency: "USD", PreviousClose: data.PC, diff --git a/internal/adapters/market/equity_price_provider_static.go b/internal/adapters/market/equity_price_provider_static.go index 9ba6ed7..78bc16f 100644 --- a/internal/adapters/market/equity_price_provider_static.go +++ b/internal/adapters/market/equity_price_provider_static.go @@ -45,7 +45,7 @@ func (p *StaticPriceProvider) GetQuote(_ context.Context, ticker string) (domain } return domain.Quote{ - Ticker: ticker, + Symbol: ticker, Price: v.Price, PriceCurrency: v.Currency, PreviousClose: v.PreviousClose, diff --git a/internal/domain/analysis/analysis.go b/internal/analysis/analysis.go similarity index 100% rename from internal/domain/analysis/analysis.go rename to internal/analysis/analysis.go diff --git a/internal/domain/analysis/mocks/mock_interfaces.go b/internal/analysis/mocks/mock_interfaces.go similarity index 100% rename from internal/domain/analysis/mocks/mock_interfaces.go rename to internal/analysis/mocks/mock_interfaces.go diff --git a/internal/domain/analysis/portfolio_analysis.go b/internal/analysis/portfolio_analysis.go similarity index 100% rename from internal/domain/analysis/portfolio_analysis.go rename to internal/analysis/portfolio_analysis.go diff --git a/internal/domain/analysis/portfolio_analysis_test.go b/internal/analysis/portfolio_analysis_test.go similarity index 99% rename from internal/domain/analysis/portfolio_analysis_test.go rename to internal/analysis/portfolio_analysis_test.go index 937a13e..cf91761 100644 --- a/internal/domain/analysis/portfolio_analysis_test.go +++ b/internal/analysis/portfolio_analysis_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" + "github.com/squeakycheese75/tick/internal/analysis/mocks" "github.com/squeakycheese75/tick/internal/domain" - "github.com/squeakycheese75/tick/internal/domain/analysis/mocks" "go.uber.org/mock/gomock" ) diff --git a/internal/domain/analysis/portfolio_risk.go b/internal/analysis/portfolio_risk.go similarity index 100% rename from internal/domain/analysis/portfolio_risk.go rename to internal/analysis/portfolio_risk.go diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 8b495f2..fdfff69 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -1,8 +1,8 @@ package app import ( + "github.com/squeakycheese75/tick/internal/analysis" "github.com/squeakycheese75/tick/internal/db" - "github.com/squeakycheese75/tick/internal/domain/analysis" "github.com/squeakycheese75/tick/internal/instruments" "github.com/squeakycheese75/tick/internal/report" "github.com/squeakycheese75/tick/internal/repository" @@ -19,6 +19,7 @@ type Runtime struct { ImportPortfolio *usecase.ImportPortfolioUseCase GetTickerNews *usecase.GetTickerNewsUseCase GetMorningBrief *usecase.GetMorningBriefUsecase + SetTarget *usecase.SetTargetUseCase } func BuildRuntime(dbPath string) (*Runtime, error) { @@ -40,12 +41,13 @@ func BuildRuntime(dbPath string) (*Runtime, error) { positionRepo := repository.NewPositionRepository(database) instrumentRepo := repository.NewInstrumentRepository(database) snapshotRepo := repository.NewSnapshotRepository(database) + targetRespository := repository.NewTargetRepository(database) // Caching priceCacher := repository.NewPriceCacheRepository(database) fxCacher := repository.NewFXCacheRepository(database) - // Providers + // Adapters/Providers equityPriceProvider, err := BuildEquityPriceProvider(cfg, priceCacher) if err != nil { return nil, err @@ -76,18 +78,20 @@ func BuildRuntime(dbPath string) (*Runtime, error) { portfolioAnalyser := analysis.NewPortfolioAnalyzer(pricingSvc) riskAnalyser := analysis.NewRiskAnalyzer() + analysisSvc := service.NewPortfolioAnalysisSvc(portfolioRepo, positionRepo, portfolioAnalyser) + riskSvc := service.NewPortfolioRiskSvc(portfolioRepo, positionRepo, riskAnalyser) - portfolioSvc := service.NewPortfolioService(portfolioRepo, positionRepo, portfolioAnalyser, riskAnalyser) - portfolioInsights := service.NewPortfolioInsights() + portfolioInsights := service.NewInsightsSvc() newsSvc := service.NewNewsService(newsProvider) snapshotSvc := service.NewSnapshotService(snapshotRepo) + targetSvc := service.NewTargetSvc(portfolioRepo, targetRespository) instrumentResolver, err := instruments.NewStaticResolver() if err != nil { return nil, err } - reportingBuilder := report.NewReportBuilder(portfolioSvc, pricingSvc, newsSvc, portfolioInsights, snapshotSvc) + reportingBuilder := report.NewReportBuilder(analysisSvc, riskSvc, pricingSvc, newsSvc, portfolioInsights, snapshotSvc, targetSvc) var summarizer usecase.DailyReportSummarizer = service.NoopSummarizer{} @@ -97,16 +101,18 @@ func BuildRuntime(dbPath string) (*Runtime, error) { return &Runtime{ GetPortfolioSummary: usecase.NewGetPortfolioSummaryUseCase( - portfolioSvc, + analysisSvc, ), CreatePortfolio: usecase.NewCreatePortfolioUseCase(portfolioRepo), AddPosition: usecase.NewAddPositionToPortfolioUseCase(positionRepo, portfolioRepo, instrumentRepo, instrumentResolver), GetPortfolioRisk: usecase.NewGetPortfolioRiskUseCase( - portfolioSvc, + analysisSvc, + riskSvc, ), GetDailyReport: usecase.NewGetDailyReportUseCase(reportingBuilder, summarizer, snapshotRepo), ImportPortfolio: usecase.NewImportPortfolioUseCase(positionRepo, portfolioRepo, instrumentRepo), GetTickerNews: usecase.NewGetTickerNewsUseCase(newsSvc), GetMorningBrief: usecase.NewGetMorningBriefUsecase(reportingBuilder), + SetTarget: usecase.NewSetTargetUseCase(portfolioRepo, targetRespository), }, nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index 186124b..33d5958 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -24,6 +24,7 @@ func NewRootCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { rootCmd.AddCommand(newNewsCmd(runtimeBuilder)) rootCmd.AddCommand(newConfigCmd()) rootCmd.AddCommand(newShellCmd(rootCmd)) + rootCmd.AddCommand(newTargetCmd(runtimeBuilder)) return rootCmd } diff --git a/internal/cli/target.go b/internal/cli/target.go new file mode 100644 index 0000000..b3dec6b --- /dev/null +++ b/internal/cli/target.go @@ -0,0 +1,78 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/squeakycheese75/tick/internal/domain" + "github.com/squeakycheese75/tick/internal/render" +) + +func newTargetCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { + cmd := &cobra.Command{ + Use: "target", + Short: "Manage portfolio price targets", + } + + cmd.AddCommand(newTargetSetCmd(runtimeBuilder)) + // cmd.AddCommand(newTargetListCmd(runtimeBuilder)) + // cmd.AddCommand(newTargetDeleteCmd(runtimeBuilder)) + + return cmd +} + +func newTargetSetCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { + var portfolioName string + var takeProfit float64 + var stopLoss float64 + var quoteCurrency string + + cmd := &cobra.Command{ + Use: "set SYMBOL", + Short: "Set a portfolio price target", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if takeProfit == 0 && stopLoss == 0 { + return fmt.Errorf("provide either --take-profit or --stop-loss") + } + + if takeProfit > 0 && stopLoss > 0 { + return fmt.Errorf("provide only one of --take-profit or --stop-loss") + } + + targetType := domain.TargetTypeTakeProfit + targetPrice := takeProfit + + if stopLoss > 0 { + targetType = domain.TargetTypeStopLoss + targetPrice = stopLoss + } + + rt, err := runtimeBuilder() + if err != nil { + return err + } + + out, err := rt.SetTarget.Execute(cmd.Context(), domain.SetTargetUsecaseInput{ + PortfolioName: portfolioName, + Symbol: strings.ToUpper(args[0]), + Type: targetType, + TargetPrice: targetPrice, + QuoteCurrency: strings.ToUpper(quoteCurrency), + }) + if err != nil { + return err + } + + return render.RenderSetTarget(cmd.OutOrStdout(), *out) + }, + } + + cmd.Flags().StringVar(&portfolioName, "portfolio", "main", "Portfolio name") + cmd.Flags().Float64Var(&takeProfit, "take-profit", 0, "Take-profit price") + cmd.Flags().Float64Var(&stopLoss, "stop-loss", 0, "Stop-loss price") + cmd.Flags().StringVar("eCurrency, "currency", "USD", "Quote currency") + + return cmd +} diff --git a/internal/db/migrations/20260411114506_internal_db_migrations_sqlite.initial_setup.sql b/internal/db/migrations/20260411114506_internal_db_migrations_sqlite.initial_setup.sql index 7124d7b..221e954 100644 --- a/internal/db/migrations/20260411114506_internal_db_migrations_sqlite.initial_setup.sql +++ b/internal/db/migrations/20260411114506_internal_db_migrations_sqlite.initial_setup.sql @@ -8,7 +8,8 @@ CREATE TABLE IF NOT EXISTS instruments ( exchange TEXT, quote_currency TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS portfolios ( @@ -16,7 +17,8 @@ CREATE TABLE IF NOT EXISTS portfolios ( name TEXT NOT NULL UNIQUE, base_currency TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS positions ( @@ -28,6 +30,7 @@ CREATE TABLE IF NOT EXISTS positions ( currency TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, FOREIGN KEY (instrument_id) REFERENCES instruments(id), FOREIGN KEY (portfolio_id) REFERENCES portfolios(id), UNIQUE (portfolio_id, instrument_id) diff --git a/internal/db/migrations/20260429081813_internal_db_migrations_added_target_tables_sql.sql b/internal/db/migrations/20260429081813_internal_db_migrations_added_target_tables_sql.sql new file mode 100644 index 0000000..7dedb50 --- /dev/null +++ b/internal/db/migrations/20260429081813_internal_db_migrations_added_target_tables_sql.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE portfolio_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + portfolio_id INTEGER NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, + type TEXT NOT NULL, + target_price REAL NOT NULL, + quote_currency TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + + CONSTRAINT portfolio_targets_type_check + CHECK (type IN ('take-profit', 'stop-loss')) +); + +CREATE UNIQUE INDEX idx_portfolio_targets_unique_active + ON portfolio_targets (portfolio_id, symbol, type) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_portfolio_targets_portfolio_id + ON portfolio_targets (portfolio_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_portfolio_targets_portfolio_id; +DROP INDEX IF EXISTS idx_portfolio_targets_unique_active; +DROP TABLE IF EXISTS portfolio_targetss; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/db/models.go b/internal/db/models.go index b3f39ae..92f7468 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -21,19 +21,21 @@ type Instrument struct { ID int64 `json:"id"` Symbol string `json:"symbol"` ProviderSymbol string `json:"provider_symbol"` - InstrumentType string `json:"asset_type"` + AssetType string `json:"asset_type"` Exchange sql.NullString `json:"exchange"` QuoteCurrency string `json:"quote_currency"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` } type Portfolio struct { - ID int64 `json:"id"` - Name string `json:"name"` - BaseCurrency string `json:"base_currency"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + BaseCurrency string `json:"base_currency"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` } type PortfolioSnapshot struct { @@ -57,15 +59,28 @@ type PortfolioSnapshotPosition struct { CreatedAt time.Time `json:"created_at"` } +type PortfolioTarget struct { + ID int64 `json:"id"` + PortfolioID int64 `json:"portfolio_id"` + Symbol string `json:"symbol"` + Type string `json:"type"` + TargetPrice float64 `json:"target_price"` + QuoteCurrency string `json:"quote_currency"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + type Position struct { - ID int64 `json:"id"` - InstrumentID int64 `json:"instrument_id"` - PortfolioID int64 `json:"portfolio_id"` - Quantity float64 `json:"quantity"` - AvgCost float64 `json:"avg_cost"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + InstrumentID int64 `json:"instrument_id"` + PortfolioID int64 `json:"portfolio_id"` + Quantity float64 `json:"quantity"` + AvgCost float64 `json:"avg_cost"` + Currency string `json:"currency"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` } type PriceCache struct { diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 3647bca..21174e0 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -27,7 +27,7 @@ RETURNING id type CreateInstrumentParams struct { Symbol string `json:"symbol"` ProviderSymbol string `json:"provider_symbol"` - InstrumentType string `json:"asset_type"` + AssetType string `json:"asset_type"` Exchange sql.NullString `json:"exchange"` QuoteCurrency string `json:"quote_currency"` } @@ -36,7 +36,7 @@ func (q *Queries) CreateInstrument(ctx context.Context, arg CreateInstrumentPara row := q.db.QueryRowContext(ctx, createInstrument, arg.Symbol, arg.ProviderSymbol, - arg.InstrumentType, + arg.AssetType, arg.Exchange, arg.QuoteCurrency, ) @@ -184,6 +184,38 @@ func (q *Queries) CreatePosition(ctx context.Context, arg CreatePositionParams) return err } +const createTarget = `-- name: CreateTarget :one +INSERT INTO portfolio_targets ( + portfolio_id, + symbol, + type, + target_price, + quote_currency +) VALUES (?, ?, ?, ?, ?) +RETURNING id +` + +type CreateTargetParams struct { + PortfolioID int64 `json:"portfolio_id"` + Symbol string `json:"symbol"` + Type string `json:"type"` + TargetPrice float64 `json:"target_price"` + QuoteCurrency string `json:"quote_currency"` +} + +func (q *Queries) CreateTarget(ctx context.Context, arg CreateTargetParams) (int64, error) { + row := q.db.QueryRowContext(ctx, createTarget, + arg.PortfolioID, + arg.Symbol, + arg.Type, + arg.TargetPrice, + arg.QuoteCurrency, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + const getFXCacheByPair = `-- name: GetFXCacheByPair :one SELECT base_currency, @@ -224,14 +256,25 @@ type GetInstrumentBySymbolAndExchangeParams struct { Exchange sql.NullString `json:"exchange"` } -func (q *Queries) GetInstrumentBySymbolAndExchange(ctx context.Context, arg GetInstrumentBySymbolAndExchangeParams) (Instrument, error) { +type GetInstrumentBySymbolAndExchangeRow struct { + ID int64 `json:"id"` + Symbol string `json:"symbol"` + ProviderSymbol string `json:"provider_symbol"` + AssetType string `json:"asset_type"` + Exchange sql.NullString `json:"exchange"` + QuoteCurrency string `json:"quote_currency"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) GetInstrumentBySymbolAndExchange(ctx context.Context, arg GetInstrumentBySymbolAndExchangeParams) (GetInstrumentBySymbolAndExchangeRow, error) { row := q.db.QueryRowContext(ctx, getInstrumentBySymbolAndExchange, arg.Symbol, arg.Exchange) - var i Instrument + var i GetInstrumentBySymbolAndExchangeRow err := row.Scan( &i.ID, &i.Symbol, &i.ProviderSymbol, - &i.InstrumentType, + &i.AssetType, &i.Exchange, &i.QuoteCurrency, &i.CreatedAt, @@ -296,9 +339,17 @@ FROM portfolios WHERE name = ? ` -func (q *Queries) GetPortfolioByName(ctx context.Context, name string) (Portfolio, error) { +type GetPortfolioByNameRow struct { + ID int64 `json:"id"` + Name string `json:"name"` + BaseCurrency string `json:"base_currency"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) GetPortfolioByName(ctx context.Context, name string) (GetPortfolioByNameRow, error) { row := q.db.QueryRowContext(ctx, getPortfolioByName, name) - var i Portfolio + var i GetPortfolioByNameRow err := row.Scan( &i.ID, &i.Name, @@ -399,15 +450,15 @@ ORDER BY i.symbol ASC ` type ListPositionsByPortfolioRow struct { - InstrumentID int64 `json:"instrument_id"` - Name string `json:"name"` - Symbol string `json:"symbol"` - Quantity float64 `json:"quantity"` - AvgCost float64 `json:"avg_cost"` - Currency string `json:"currency"` - InstrumentType string `json:"asset_type"` - Exchange sql.NullString `json:"exchange"` - QuoteCurrency string `json:"quote_currency"` + InstrumentID int64 `json:"instrument_id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Quantity float64 `json:"quantity"` + AvgCost float64 `json:"avg_cost"` + Currency string `json:"currency"` + AssetType string `json:"asset_type"` + Exchange sql.NullString `json:"exchange"` + QuoteCurrency string `json:"quote_currency"` } func (q *Queries) ListPositionsByPortfolio(ctx context.Context, portfolioID int64) ([]ListPositionsByPortfolioRow, error) { @@ -426,7 +477,7 @@ func (q *Queries) ListPositionsByPortfolio(ctx context.Context, portfolioID int6 &i.Quantity, &i.AvgCost, &i.Currency, - &i.InstrumentType, + &i.AssetType, &i.Exchange, &i.QuoteCurrency, ); err != nil { @@ -443,6 +494,57 @@ func (q *Queries) ListPositionsByPortfolio(ctx context.Context, portfolioID int6 return items, nil } +const listTargetsByPortfolio = `-- name: ListTargetsByPortfolio :many +SELECT + p.name, + t.symbol, + t.target_price, + t.type, + t.quote_currency +FROM portfolio_targets AS t +JOIN portfolios AS p ON t.portfolio_id = p.id +WHERE t.portfolio_id = ? +AND t.deleted_at IS NULL +ORDER BY t.symbol ASC +` + +type ListTargetsByPortfolioRow struct { + Name string `json:"name"` + Symbol string `json:"symbol"` + TargetPrice float64 `json:"target_price"` + Type string `json:"type"` + QuoteCurrency string `json:"quote_currency"` +} + +func (q *Queries) ListTargetsByPortfolio(ctx context.Context, portfolioID int64) ([]ListTargetsByPortfolioRow, error) { + rows, err := q.db.QueryContext(ctx, listTargetsByPortfolio, portfolioID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTargetsByPortfolioRow + for rows.Next() { + var i ListTargetsByPortfolioRow + if err := rows.Scan( + &i.Name, + &i.Symbol, + &i.TargetPrice, + &i.Type, + &i.QuoteCurrency, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertFXCache = `-- name: UpsertFXCache :exec INSERT INTO fx_cache ( base_currency, diff --git a/internal/domain/pricing.go b/internal/domain/pricing.go index 3f89bee..255b35e 100644 --- a/internal/domain/pricing.go +++ b/internal/domain/pricing.go @@ -1,7 +1,7 @@ package domain type Quote struct { - Ticker string + Symbol string Price float64 PriceCurrency string PreviousClose float64 diff --git a/internal/domain/report.go b/internal/domain/report.go index ac20ec0..21b713b 100644 --- a/internal/domain/report.go +++ b/internal/domain/report.go @@ -73,6 +73,17 @@ type DailyReport struct { Risk RiskSummary News []NewsSummary Attention []string + Targets []TargetStatus +} + +type TargetStatus struct { + Symbol string + Type TargetType + CurrentPrice float64 + TargetPrice float64 + Currency string + Hit bool + DistancePct float64 } type BriefReport struct { diff --git a/internal/domain/target.go b/internal/domain/target.go new file mode 100644 index 0000000..9ab495c --- /dev/null +++ b/internal/domain/target.go @@ -0,0 +1,23 @@ +package domain + +import ( + "time" +) + +type TargetType string + +const ( + TargetTypeTakeProfit TargetType = "take-profit" + TargetTypeStopLoss TargetType = "stop-loss" +) + +type Target struct { + ID int64 + PortfolioID int64 + Symbol string + Type TargetType + TargetPrice float64 + QuoteCurrency string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/domain/usecase.go b/internal/domain/usecase.go index c0ec98b..bb98deb 100644 --- a/internal/domain/usecase.go +++ b/internal/domain/usecase.go @@ -213,3 +213,19 @@ func (i *GetMorningBriefUsecaseInput) ApplyDefaults() { type GetMorningBriefUsecaseOutput struct { Report BriefReport } + +type SetTargetUsecaseInput struct { + PortfolioName string + Symbol string + Type TargetType + TargetPrice float64 + QuoteCurrency string +} + +type SetTargetUsecaseOutput struct { + PortfolioName string + Symbol string + Type TargetType + TargetPrice float64 + QuoteCurrency string +} diff --git a/internal/domain/snapshot/mapper.go b/internal/mappers/snaphot.go similarity index 99% rename from internal/domain/snapshot/mapper.go rename to internal/mappers/snaphot.go index 6a1b99a..8880d85 100644 --- a/internal/domain/snapshot/mapper.go +++ b/internal/mappers/snaphot.go @@ -1,4 +1,4 @@ -package snapshot +package mappers import ( "time" diff --git a/internal/render/daily.go b/internal/render/daily.go index 08b7f4a..01c66ed 100644 --- a/internal/render/daily.go +++ b/internal/render/daily.go @@ -21,6 +21,7 @@ func DailyReport(w io.Writer, s domain.GetDailyReportOutput, opts DailyReportOpt out.println("") renderNewsSummary(out, r.News, opts.News) + renderTargets(out, r.Targets) if opts.ShowAttention && len(r.Attention) > 0 { out.println("") @@ -45,6 +46,32 @@ func DailyReport(w io.Writer, s domain.GetDailyReportOutput, opts DailyReportOpt 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) { out.printf("%s %s", r.Name, formatMoney(r.TotalValue, r.BaseCurrency)) @@ -220,37 +247,3 @@ func renderNewsSummary(out *writer, groups []domain.NewsSummary, opts NewsOption out.println("No news") } } - -func RenderNewsItem(w io.Writer, r domain.NewsSummary, opts NewsOptions) error { - out := &writer{w: w} - - out.printf("News for %s\n\n", r.Ticker) - - if len(r.Headlines) == 0 { - out.println("No recent headlines") - return out.err - } - - limit := opts.MaxHeadlines - if limit <= 0 || limit > len(r.Headlines) { - limit = len(r.Headlines) - } - - for i := 0; i < limit; i++ { - h := r.Headlines[i] - title := h.Title - if opts.TruncateTitles { - title = truncate(title, opts.HeadlineMaxLen) - } - - out.printf("- %s\n", title) - if opts.ShowLinks && h.URL != "" { - out.printf(" 🔗 %s\n", h.URL) - } - if i < limit-1 { - out.println("") - } - } - - return out.err -} diff --git a/internal/render/news.go b/internal/render/news.go new file mode 100644 index 0000000..f11b446 --- /dev/null +++ b/internal/render/news.go @@ -0,0 +1,41 @@ +package render + +import ( + "io" + + "github.com/squeakycheese75/tick/internal/domain" +) + +func RenderNewsItem(w io.Writer, r domain.NewsSummary, opts NewsOptions) error { + out := &writer{w: w} + + out.printf("News for %s\n\n", r.Ticker) + + if len(r.Headlines) == 0 { + out.println("No recent headlines") + return out.err + } + + limit := opts.MaxHeadlines + if limit <= 0 || limit > len(r.Headlines) { + limit = len(r.Headlines) + } + + for i := 0; i < limit; i++ { + h := r.Headlines[i] + title := h.Title + if opts.TruncateTitles { + title = truncate(title, opts.HeadlineMaxLen) + } + + out.printf("- %s\n", title) + if opts.ShowLinks && h.URL != "" { + out.printf(" 🔗 %s\n", h.URL) + } + if i < limit-1 { + out.println("") + } + } + + return out.err +} diff --git a/internal/render/target.go b/internal/render/target.go new file mode 100644 index 0000000..56fe89a --- /dev/null +++ b/internal/render/target.go @@ -0,0 +1,21 @@ +package render + +import ( + "fmt" + "io" + + "github.com/squeakycheese75/tick/internal/domain" +) + +func RenderSetTarget(w io.Writer, out domain.SetTargetUsecaseOutput) error { + _, err := fmt.Fprintf( + w, + "Set %s target for %s in portfolio %s: %.2f %s\n", + out.Type, + out.Symbol, + out.PortfolioName, + out.TargetPrice, + out.QuoteCurrency, + ) + return err +} diff --git a/internal/report/builder.go b/internal/report/builder.go index 07937fa..66406c7 100644 --- a/internal/report/builder.go +++ b/internal/report/builder.go @@ -7,9 +7,12 @@ import ( ) type ( - PortfolioSvc interface { + AnalysisSvc interface { GetAnalysis(ctx context.Context, portfolioName string) (domain.PortfolioAnalysis, error) - GetRisk(ctx context.Context, portfolioName string) (domain.PortfolioRisk, error) + } + + RiskSvc interface { + GetRisk(ctx context.Context, portfolioAnlaysis domain.PortfolioAnalysis) (domain.PortfolioRisk, error) } NewsSvc interface { GetNews(ctx context.Context, ticker string, newsLimit int) (domain.NewsSummary, error) @@ -24,29 +27,38 @@ type ( SnapshotSvc interface { SaveAndEnrichDailyReport(ctx context.Context, dailyReport domain.DailyReport, analysis domain.PortfolioAnalysis) (domain.DailyReport, error) } + TargetSvc interface { + EvaluateTargets(ctx context.Context, portfolioName string, analysis domain.PortfolioAnalysis) ([]domain.TargetStatus, error) + } ) type ReportBuilder struct { - portfolioSvc PortfolioSvc - pricingSvc PricingSvc - newsSvc NewsSvc - insights PortfolioInsights - snapshotSvc SnapshotSvc + analysisSvc AnalysisSvc + riskSvc RiskSvc + pricingSvc PricingSvc + newsSvc NewsSvc + insights PortfolioInsights + snapshotSvc SnapshotSvc + targetSvc TargetSvc } func NewReportBuilder( - portfolioSvc PortfolioSvc, + analysisSvc AnalysisSvc, + portfolioRiskSvc RiskSvc, pricingSvc PricingSvc, newsSvc NewsSvc, insights PortfolioInsights, snapshotSvc SnapshotSvc, + targetSvc TargetSvc, ) *ReportBuilder { return &ReportBuilder{ - portfolioSvc: portfolioSvc, - newsSvc: newsSvc, - insights: insights, - pricingSvc: pricingSvc, - snapshotSvc: snapshotSvc, + analysisSvc: analysisSvc, + riskSvc: portfolioRiskSvc, + newsSvc: newsSvc, + insights: insights, + pricingSvc: pricingSvc, + snapshotSvc: snapshotSvc, + targetSvc: targetSvc, } } diff --git a/internal/report/daily_report.go b/internal/report/daily.go similarity index 81% rename from internal/report/daily_report.go rename to internal/report/daily.go index c5ff96f..15bfb66 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily.go @@ -17,17 +17,23 @@ func (s *ReportBuilder) BuildDailyReport( ctx context.Context, in BuildDailyReportParams, ) (domain.DailyReport, error) { - analysis, err := s.portfolioSvc.GetAnalysis(ctx, in.PortfolioName) + analysis, err := s.analysisSvc.GetAnalysis(ctx, in.PortfolioName) if err != nil { return domain.DailyReport{}, fmt.Errorf("get portfolio analysis: %w", err) } - risk, err := s.portfolioSvc.GetRisk(ctx, in.PortfolioName) + risk, err := s.riskSvc.GetRisk(ctx, analysis) if err != nil { return domain.DailyReport{}, fmt.Errorf("get portfolio risk: %w", err) } + targets, err := s.targetSvc.EvaluateTargets(ctx, in.PortfolioName, analysis) + if err != nil { + return domain.DailyReport{}, fmt.Errorf("get target evaluation: %w", err) + } + report := s.buildDailyReportFromAnalysis(analysis, risk) + report.Targets = targets news, err := s.getNewsSummaries(ctx, report.TopHoldings, in.NewsLimit) if err != nil { diff --git a/internal/report/morning_report.go b/internal/report/morning_report.go index 442d692..bdd1c25 100644 --- a/internal/report/morning_report.go +++ b/internal/report/morning_report.go @@ -12,7 +12,7 @@ type BuildMorningBriefReportParams struct { } func (s *ReportBuilder) BuildMorningBriefReport(ctx context.Context, in BuildMorningBriefReportParams) (domain.BriefReport, error) { - analysis, err := s.portfolioSvc.GetAnalysis(ctx, in.PortfolioName) + analysis, err := s.analysisSvc.GetAnalysis(ctx, in.PortfolioName) if err != nil { return domain.BriefReport{}, fmt.Errorf("get portfolio analysis: %w", err) } diff --git a/internal/report/target_evaluator.go b/internal/report/target_evaluator.go new file mode 100644 index 0000000..e250a81 --- /dev/null +++ b/internal/report/target_evaluator.go @@ -0,0 +1,33 @@ +package report + +import "github.com/squeakycheese75/tick/internal/domain" + +type TargetEvaluator struct{} + +func NewTargetEvaluator() *TargetEvaluator { + return &TargetEvaluator{} +} + +func (e *TargetEvaluator) Evaluate(target domain.Target, quote domain.Quote) domain.TargetStatus { + distancePct := ((target.TargetPrice - quote.Price) / quote.Price) * 100 + + hit := false + + if target.Type == domain.TargetTypeTakeProfit { + hit = quote.Price >= target.TargetPrice + } + + if target.Type == domain.TargetTypeStopLoss { + hit = quote.Price <= target.TargetPrice + } + + return domain.TargetStatus{ + Symbol: target.Symbol, + Type: target.Type, + CurrentPrice: quote.Price, + TargetPrice: target.TargetPrice, + Currency: target.QuoteCurrency, + Hit: hit, + DistancePct: distancePct, + } +} diff --git a/internal/repository/instrument_repo.go b/internal/repository/instrument_repo.go index 10713a9..5230179 100644 --- a/internal/repository/instrument_repo.go +++ b/internal/repository/instrument_repo.go @@ -53,7 +53,7 @@ func (r *InstrumentRepository) GetBySymbolAndExchange( Symbol: row.Symbol, ProviderSymbol: row.ProviderSymbol, Exchange: row.Exchange.String, - InstrumentType: row.InstrumentType, + InstrumentType: row.AssetType, QuoteCurrency: row.QuoteCurrency, }, nil } @@ -62,7 +62,7 @@ func (r *InstrumentRepository) Create(ctx context.Context, in Instrument) (Instr id, err := r.q.CreateInstrument(ctx, db.CreateInstrumentParams{ Symbol: in.Symbol, ProviderSymbol: in.ProviderSymbol, - InstrumentType: in.InstrumentType, + AssetType: in.InstrumentType, Exchange: sql.NullString{ String: in.Exchange, Valid: in.Exchange != "", diff --git a/internal/repository/position_repo.go b/internal/repository/position_repo.go index 36d5385..6c5d8e4 100644 --- a/internal/repository/position_repo.go +++ b/internal/repository/position_repo.go @@ -29,7 +29,7 @@ func (r *PositionRepository) ListByPortfolioID(ctx context.Context, portfolioID Currency: row.Currency, Instrument: Instrument{ Symbol: row.Symbol, - InstrumentType: row.InstrumentType, + InstrumentType: row.AssetType, QuoteCurrency: row.QuoteCurrency, Exchange: row.Exchange.String, }, diff --git a/internal/repository/target_repository.go b/internal/repository/target_repository.go new file mode 100644 index 0000000..3306cce --- /dev/null +++ b/internal/repository/target_repository.go @@ -0,0 +1,60 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "github.com/squeakycheese75/tick/internal/db" + "github.com/squeakycheese75/tick/internal/domain" +) + +type TargetRepository struct { + db *sql.DB + q *db.Queries +} + +func NewTargetRepository(database *db.DB) *TargetRepository { + return &TargetRepository{ + q: db.New(database.SqlDB), + db: database.SqlDB} +} + +func (r *TargetRepository) Save(ctx context.Context, t domain.Target) error { + _, err := r.q.CreateTarget(ctx, db.CreateTargetParams{ + PortfolioID: t.PortfolioID, + Symbol: t.Symbol, + Type: string(t.Type), + TargetPrice: t.TargetPrice, + QuoteCurrency: t.QuoteCurrency, + }) + if err != nil { + return fmt.Errorf( + "create target for portfolio id %d and symbol %v: %w", + t.PortfolioID, + t.Symbol, + err, + ) + } + + return nil +} + +func (r *TargetRepository) ListByPortfolioID(ctx context.Context, portfolioID int64) ([]domain.Target, error) { + rows, err := r.q.ListTargetsByPortfolio(ctx, portfolioID) + if err != nil { + return nil, fmt.Errorf("list positions by portfolio id %d: %w", portfolioID, err) + } + + targets := make([]domain.Target, 0, len(rows)) + for _, row := range rows { + targets = append(targets, domain.Target{ + Symbol: row.Symbol, + Type: domain.TargetType(row.Type), + QuoteCurrency: row.QuoteCurrency, + TargetPrice: row.TargetPrice, + }) + } + + return targets, nil +} diff --git a/internal/service/insights.go b/internal/service/insights.go index 142ac70..716c512 100644 --- a/internal/service/insights.go +++ b/internal/service/insights.go @@ -6,13 +6,13 @@ import ( "github.com/squeakycheese75/tick/internal/domain" ) -type PortfolioInsights struct{} +type InsightsSvc struct{} -func NewPortfolioInsights() *PortfolioInsights { - return &PortfolioInsights{} +func NewInsightsSvc() *InsightsSvc { + return &InsightsSvc{} } -func (b *PortfolioInsights) TopHoldings( +func (b *InsightsSvc) TopHoldings( portfolioAnalysis domain.PortfolioAnalysis, limit int, ) []domain.AnalyzedPosition { @@ -32,7 +32,7 @@ func (b *PortfolioInsights) TopHoldings( return result } -func (b *PortfolioInsights) AttentionSignals( +func (b *InsightsSvc) AttentionSignals( portfolioAnalysis domain.PortfolioAnalysis, portfolioRisk domain.PortfolioRisk, ) []string { diff --git a/internal/service/portfolio.go b/internal/service/portfolio_analysis.go similarity index 52% rename from internal/service/portfolio.go rename to internal/service/portfolio_analysis.go index 8dc5c3d..9efcbac 100644 --- a/internal/service/portfolio.go +++ b/internal/service/portfolio_analysis.go @@ -5,49 +5,32 @@ import ( "errors" "fmt" + "github.com/squeakycheese75/tick/internal/analysis" "github.com/squeakycheese75/tick/internal/domain" - "github.com/squeakycheese75/tick/internal/domain/analysis" - "github.com/squeakycheese75/tick/internal/repository" ) -type PortfolioRepository interface { - GetByName(ctx context.Context, name string) (repository.Portfolio, error) -} - -type PositionRepository interface { - ListByPortfolioID(ctx context.Context, portfolioID int64) ([]repository.Position, error) -} - -type PortfolioAnalyzer interface { - Analyze(ctx context.Context, in analysis.AnalyzePortfolioInput) (domain.PortfolioAnalysis, error) -} - -type RiskAnalyzer interface { - Analyze(in domain.PortfolioAnalysis) (analysis.PortfolioRisk, error) -} - -type PortfolioService struct { +type PortfolioAnalysisSvc struct { portfolios PortfolioRepository positions PositionRepository portfolioAnalyzer PortfolioAnalyzer - riskAnalyzer RiskAnalyzer + // riskAnalyzer RiskAnalyzer } -func NewPortfolioService( +func NewPortfolioAnalysisSvc( portfolios PortfolioRepository, positions PositionRepository, portfolioAnalyzer PortfolioAnalyzer, - riskAnalyzer RiskAnalyzer, -) *PortfolioService { - return &PortfolioService{ + // riskAnalyzer RiskAnalyzer, +) *PortfolioAnalysisSvc { + return &PortfolioAnalysisSvc{ portfolios: portfolios, positions: positions, portfolioAnalyzer: portfolioAnalyzer, - riskAnalyzer: riskAnalyzer, + // riskAnalyzer: riskAnalyzer, } } -func (s *PortfolioService) GetAnalysis(ctx context.Context, portfolioName string) (domain.PortfolioAnalysis, error) { +func (s *PortfolioAnalysisSvc) GetAnalysis(ctx context.Context, portfolioName string) (domain.PortfolioAnalysis, error) { pf, err := s.portfolios.GetByName(ctx, portfolioName) if err != nil { if errors.Is(err, domain.ErrPortfolioNotFound) { @@ -94,25 +77,3 @@ func (s *PortfolioService) GetAnalysis(ctx context.Context, portfolioName string return result, nil } - -func (s *PortfolioService) GetRisk(ctx context.Context, portfolioName string) (domain.PortfolioRisk, error) { - portfolioAnalysis, err := s.GetAnalysis(ctx, portfolioName) - if err != nil { - return domain.PortfolioRisk{}, err - } - - portfolioRisk, err := s.riskAnalyzer.Analyze(portfolioAnalysis) - if err != nil { - return domain.PortfolioRisk{}, fmt.Errorf("analyze risk: %w", err) - } - - return domain.PortfolioRisk{ - PortfolioName: portfolioRisk.PortfolioName, - BaseCurrency: portfolioRisk.BaseCurrency, - LargestPosition: portfolioRisk.LargestPosition, - PositionCount: portfolioRisk.PositionCount, - LargestWeight: portfolioRisk.LargestWeight, - Top3Concentration: portfolioRisk.Top3Concentration, - Observations: portfolioRisk.Observations, - }, nil -} diff --git a/internal/service/portfolio_risk.go b/internal/service/portfolio_risk.go new file mode 100644 index 0000000..6d0d950 --- /dev/null +++ b/internal/service/portfolio_risk.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "fmt" + + "github.com/squeakycheese75/tick/internal/domain" +) + +type PortfolioRiskSvc struct { + portfolios PortfolioRepository + positions PositionRepository + riskAnalyzer RiskAnalyzer +} + +func NewPortfolioRiskSvc( + portfolios PortfolioRepository, + positions PositionRepository, + riskAnalyzer RiskAnalyzer, +) *PortfolioRiskSvc { + return &PortfolioRiskSvc{ + portfolios: portfolios, + positions: positions, + riskAnalyzer: riskAnalyzer, + } +} + +func (s *PortfolioRiskSvc) GetRisk(ctx context.Context, portfolioAnalysis domain.PortfolioAnalysis) (domain.PortfolioRisk, error) { + portfolioRisk, err := s.riskAnalyzer.Analyze(portfolioAnalysis) + if err != nil { + return domain.PortfolioRisk{}, fmt.Errorf("analyze risk: %w", err) + } + + return domain.PortfolioRisk{ + PortfolioName: portfolioRisk.PortfolioName, + BaseCurrency: portfolioRisk.BaseCurrency, + LargestPosition: portfolioRisk.LargestPosition, + PositionCount: portfolioRisk.PositionCount, + LargestWeight: portfolioRisk.LargestWeight, + Top3Concentration: portfolioRisk.Top3Concentration, + Observations: portfolioRisk.Observations, + }, nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..9204548 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,30 @@ +package service + +import ( + "context" + + "github.com/squeakycheese75/tick/internal/analysis" + "github.com/squeakycheese75/tick/internal/domain" + "github.com/squeakycheese75/tick/internal/repository" +) + +type ( + PortfolioRepository interface { + GetByName(ctx context.Context, name string) (repository.Portfolio, error) + } + PositionRepository interface { + ListByPortfolioID(ctx context.Context, portfolioID int64) ([]repository.Position, error) + } + TargetRepository interface { + ListByPortfolioID(ctx context.Context, portfolioID int64) ([]domain.Target, error) + } +) + +type ( + PortfolioAnalyzer interface { + Analyze(ctx context.Context, in analysis.AnalyzePortfolioInput) (domain.PortfolioAnalysis, error) + } + RiskAnalyzer interface { + Analyze(in domain.PortfolioAnalysis) (analysis.PortfolioRisk, error) + } +) diff --git a/internal/service/snaphot.go b/internal/service/snaphot.go index df2dfa2..830c7fc 100644 --- a/internal/service/snaphot.go +++ b/internal/service/snaphot.go @@ -7,7 +7,7 @@ import ( "time" "github.com/squeakycheese75/tick/internal/domain" - "github.com/squeakycheese75/tick/internal/domain/snapshot" + snapshot "github.com/squeakycheese75/tick/internal/mappers" "github.com/squeakycheese75/tick/internal/repository" ) diff --git a/internal/service/target.go b/internal/service/target.go new file mode 100644 index 0000000..e2f63c8 --- /dev/null +++ b/internal/service/target.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/squeakycheese75/tick/internal/domain" +) + +type TargetSvc struct { + portfolios PortfolioRepository + targets TargetRepository +} + +func NewTargetSvc( + portfolios PortfolioRepository, + targets TargetRepository, +) *TargetSvc { + return &TargetSvc{ + portfolios: portfolios, + targets: targets, + } +} + +func (s *TargetSvc) EvaluateTargets( + ctx context.Context, + portfolioName string, + analysis domain.PortfolioAnalysis, +) ([]domain.TargetStatus, error) { + portfolio, err := s.portfolios.GetByName(ctx, portfolioName) + if err != nil { + return nil, fmt.Errorf("get portfolio: %w", err) + } + + targets, err := s.targets.ListByPortfolioID(ctx, portfolio.ID) + if err != nil { + return nil, fmt.Errorf("list targets: %w", err) + } + + quotes := quotesBySymbol(analysis) + + statuses := make([]domain.TargetStatus, 0, len(targets)) + + for _, target := range targets { + quote, ok := quotes[strings.ToUpper(target.Symbol)] + if !ok { + continue + } + + statuses = append(statuses, evaluateTarget(target, quote)) + } + + return statuses, nil +} + +func evaluateTarget(target domain.Target, quote domain.Quote) domain.TargetStatus { + distancePct := ((target.TargetPrice - quote.Price) / quote.Price) * 100 + + hit := false + + if target.Type == domain.TargetTypeTakeProfit { + hit = quote.Price >= target.TargetPrice + } + + if target.Type == domain.TargetTypeStopLoss { + hit = quote.Price <= target.TargetPrice + } + + return domain.TargetStatus{ + Symbol: target.Symbol, + Type: target.Type, + CurrentPrice: quote.Price, + TargetPrice: target.TargetPrice, + Currency: target.QuoteCurrency, + Hit: hit, + DistancePct: distancePct, + } +} + +func quotesBySymbol(analysis domain.PortfolioAnalysis) map[string]domain.Quote { + out := make(map[string]domain.Quote) + + for _, holding := range analysis.AnalyzedPositions { + out[strings.ToUpper(holding.Symbol)] = domain.Quote{ + Symbol: holding.Symbol, + Price: holding.QuotedPrice, + PriceCurrency: holding.PriceCurrency, + } + } + + return out +} diff --git a/internal/usecase/portfolio_get_risk.go b/internal/usecase/portfolio_get_risk.go index 171e319..942834f 100644 --- a/internal/usecase/portfolio_get_risk.go +++ b/internal/usecase/portfolio_get_risk.go @@ -8,17 +8,24 @@ import ( ) type GetPortfolioRiskUseCase struct { - portfolioSvc PortfolioSvc + anaysisSvc AnaysisSvc + riskSvc RiskSvc } -func NewGetPortfolioRiskUseCase(portfolioSvc PortfolioSvc) *GetPortfolioRiskUseCase { +func NewGetPortfolioRiskUseCase(anaysisSvc AnaysisSvc, riskSvc RiskSvc) *GetPortfolioRiskUseCase { return &GetPortfolioRiskUseCase{ - portfolioSvc: portfolioSvc, + anaysisSvc: anaysisSvc, + riskSvc: riskSvc, } } func (uc *GetPortfolioRiskUseCase) Execute(ctx context.Context, in domain.GetPortfolioRiskInput) (domain.GetPortfolioRiskOutput, error) { - result, err := uc.portfolioSvc.GetRisk(ctx, in.PortfolioName) + analysis, err := uc.anaysisSvc.GetAnalysis(ctx, in.PortfolioName) + if err != nil { + return domain.GetPortfolioRiskOutput{}, fmt.Errorf("analyze portfolio: %w", err) + } + + result, err := uc.riskSvc.GetRisk(ctx, analysis) if err != nil { return domain.GetPortfolioRiskOutput{}, fmt.Errorf("analyze portfolio: %w", err) } diff --git a/internal/usecase/portfolio_get_summary.go b/internal/usecase/portfolio_get_summary.go index 6623719..0f53c9b 100644 --- a/internal/usecase/portfolio_get_summary.go +++ b/internal/usecase/portfolio_get_summary.go @@ -8,17 +8,17 @@ import ( ) type GetPortfolioSummaryUseCase struct { - portfolioSvc PortfolioSvc + anaysisSvc AnaysisSvc } -func NewGetPortfolioSummaryUseCase(portfolioSvc PortfolioSvc) *GetPortfolioSummaryUseCase { +func NewGetPortfolioSummaryUseCase(anaysisSvc AnaysisSvc) *GetPortfolioSummaryUseCase { return &GetPortfolioSummaryUseCase{ - portfolioSvc: portfolioSvc, + anaysisSvc: anaysisSvc, } } func (uc *GetPortfolioSummaryUseCase) Execute(ctx context.Context, in domain.GetPortfolioSummaryUsecaseInput) (domain.GetPortfolioSummaryUsecaseOutput, error) { - result, err := uc.portfolioSvc.GetAnalysis(ctx, in.PortfolioName) + result, err := uc.anaysisSvc.GetAnalysis(ctx, in.PortfolioName) if err != nil { return domain.GetPortfolioSummaryUsecaseOutput{}, fmt.Errorf("analyze portfolio: %w", err) } diff --git a/internal/usecase/target_set.go b/internal/usecase/target_set.go new file mode 100644 index 0000000..6a6488e --- /dev/null +++ b/internal/usecase/target_set.go @@ -0,0 +1,43 @@ +package usecase + +import ( + "context" + "strings" + + "github.com/squeakycheese75/tick/internal/domain" +) + +type SetTargetUseCase struct { + portfolios PortfolioRepository + targets TargetRepository +} + +func NewSetTargetUseCase(portfolios PortfolioRepository, targets TargetRepository) *SetTargetUseCase { + return &SetTargetUseCase{ + portfolios: portfolios, + targets: targets, + } +} + +func (uc *SetTargetUseCase) Execute(ctx context.Context, in domain.SetTargetUsecaseInput) (*domain.SetTargetUsecaseOutput, error) { + portfolio, err := uc.portfolios.GetByName(ctx, in.PortfolioName) + if err != nil { + return nil, err + } + + target := domain.Target{ + PortfolioID: portfolio.ID, + Symbol: strings.ToUpper(in.Symbol), + Type: in.Type, + TargetPrice: in.TargetPrice, + QuoteCurrency: strings.ToUpper(in.QuoteCurrency), + } + + return &domain.SetTargetUsecaseOutput{ + PortfolioName: in.PortfolioName, + Symbol: in.Symbol, + QuoteCurrency: in.QuoteCurrency, + Type: in.Type, + TargetPrice: in.TargetPrice, + }, uc.targets.Save(ctx, target) +} diff --git a/internal/usecase/usecase.go b/internal/usecase/usecase.go index cf02a58..0d44aa1 100644 --- a/internal/usecase/usecase.go +++ b/internal/usecase/usecase.go @@ -34,15 +34,22 @@ type ( GetLatestBefore(ctx context.Context, portfolioName string, before time.Time) (repository.PortfolioSnapshot, error) ListPositionsBySnapshotID(ctx context.Context, snapshotID int64) ([]repository.PortfolioSnapshotPosition, error) } + TargetRepository interface { + Save(ctx context.Context, target domain.Target) error + // ListByPortfolio(ctx context.Context, portfolioID int64) ([]domain.Target, error) + // Delete(ctx context.Context, portfolioID int64, symbol string, targetType domain.TargetType) error + } ) type ( NewsSvc interface { GetNews(ctx context.Context, ticker string, limit int) (domain.NewsSummary, error) } - PortfolioSvc interface { + AnaysisSvc interface { GetAnalysis(ctx context.Context, portfolioName string) (domain.PortfolioAnalysis, error) - GetRisk(ctx context.Context, portfolioName string) (domain.PortfolioRisk, error) + } + RiskSvc interface { + GetRisk(ctx context.Context, analysis domain.PortfolioAnalysis) (domain.PortfolioRisk, error) } DailyReportSummarizer interface { Summarize(ctx context.Context, report domain.DailyReport) (string, error) From 87f7537ee1fb1f2bb72fed317652f79bf0353b50 Mon Sep 17 00:00:00 2001 From: squeakycheese75 Date: Thu, 30 Apr 2026 11:00:21 +0200 Subject: [PATCH 2/2] feat: added list targets --- .gitignore | 2 ++ internal/app/runtime.go | 2 ++ internal/cli/target.go | 32 +++++++++++++++-- internal/domain/usecase.go | 13 +++++-- internal/render/target.go | 30 +++++++++++++++- internal/repository/target_repository.go | 2 +- internal/service/service.go | 2 +- internal/service/target.go | 2 +- internal/usecase/target_list.go | 44 ++++++++++++++++++++++++ internal/usecase/target_set.go | 4 +-- internal/usecase/usecase.go | 2 +- 11 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 internal/usecase/target_list.go diff --git a/.gitignore b/.gitignore index 5c0c7e1..b890c85 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ internal/adapters/market/finnhub_test.go PLAN.md .DS_Store /dist +docs/demo +tick2 diff --git a/internal/app/runtime.go b/internal/app/runtime.go index fdfff69..ff223b1 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -20,6 +20,7 @@ type Runtime struct { GetTickerNews *usecase.GetTickerNewsUseCase GetMorningBrief *usecase.GetMorningBriefUsecase SetTarget *usecase.SetTargetUseCase + ListTargets *usecase.ListTargetsUseCase } func BuildRuntime(dbPath string) (*Runtime, error) { @@ -114,5 +115,6 @@ func BuildRuntime(dbPath string) (*Runtime, error) { GetTickerNews: usecase.NewGetTickerNewsUseCase(newsSvc), GetMorningBrief: usecase.NewGetMorningBriefUsecase(reportingBuilder), SetTarget: usecase.NewSetTargetUseCase(portfolioRepo, targetRespository), + ListTargets: usecase.NewListTargetsUseCase(portfolioRepo, targetRespository), }, nil } diff --git a/internal/cli/target.go b/internal/cli/target.go index b3dec6b..de8e65f 100644 --- a/internal/cli/target.go +++ b/internal/cli/target.go @@ -16,7 +16,7 @@ func newTargetCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { } cmd.AddCommand(newTargetSetCmd(runtimeBuilder)) - // cmd.AddCommand(newTargetListCmd(runtimeBuilder)) + cmd.AddCommand(newTargetListCmd(runtimeBuilder)) // cmd.AddCommand(newTargetDeleteCmd(runtimeBuilder)) return cmd @@ -54,7 +54,7 @@ func newTargetSetCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { return err } - out, err := rt.SetTarget.Execute(cmd.Context(), domain.SetTargetUsecaseInput{ + out, err := rt.SetTarget.Execute(cmd.Context(), domain.SetTargetUseCaseInput{ PortfolioName: portfolioName, Symbol: strings.ToUpper(args[0]), Type: targetType, @@ -76,3 +76,31 @@ func newTargetSetCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { return cmd } + +func newTargetListCmd(runtimeBuilder RuntimeBuilder) *cobra.Command { + var portfolioName string + + cmd := &cobra.Command{ + Use: "list", + Short: "List portfolio targets", + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := runtimeBuilder() + if err != nil { + return err + } + + out, err := rt.ListTargets.Execute(cmd.Context(), domain.ListTargetsUseCaseInput{ + PortfolioName: portfolioName, + }) + if err != nil { + return err + } + + return render.RenderListTargets(cmd.OutOrStdout(), out) + }, + } + + cmd.Flags().StringVar(&portfolioName, "portfolio", "main", "Portfolio name") + + return cmd +} diff --git a/internal/domain/usecase.go b/internal/domain/usecase.go index bb98deb..30f1948 100644 --- a/internal/domain/usecase.go +++ b/internal/domain/usecase.go @@ -214,7 +214,7 @@ type GetMorningBriefUsecaseOutput struct { Report BriefReport } -type SetTargetUsecaseInput struct { +type SetTargetUseCaseInput struct { PortfolioName string Symbol string Type TargetType @@ -222,10 +222,19 @@ type SetTargetUsecaseInput struct { QuoteCurrency string } -type SetTargetUsecaseOutput struct { +type SetTargetUseCaseOutput struct { PortfolioName string Symbol string Type TargetType TargetPrice float64 QuoteCurrency string } + +type ListTargetsUseCaseInput struct { + PortfolioName string +} + +type ListTargetsUseCaseOutput struct { + PortfolioName string + Targets []Target +} diff --git a/internal/render/target.go b/internal/render/target.go index 56fe89a..b666d51 100644 --- a/internal/render/target.go +++ b/internal/render/target.go @@ -7,7 +7,7 @@ import ( "github.com/squeakycheese75/tick/internal/domain" ) -func RenderSetTarget(w io.Writer, out domain.SetTargetUsecaseOutput) error { +func RenderSetTarget(w io.Writer, out domain.SetTargetUseCaseOutput) error { _, err := fmt.Fprintf( w, "Set %s target for %s in portfolio %s: %.2f %s\n", @@ -19,3 +19,31 @@ func RenderSetTarget(w io.Writer, out domain.SetTargetUsecaseOutput) error { ) return err } + +func RenderListTargets(w io.Writer, out domain.ListTargetsUseCaseOutput) error { + if len(out.Targets) == 0 { + _, err := fmt.Fprintf(w, "No targets set for portfolio %q\n", out.PortfolioName) + return err + } + + _, err := fmt.Fprintf(w, "Targets for %s\n", out.PortfolioName) + if err != nil { + return err + } + + for _, t := range out.Targets { + _, err := fmt.Fprintf( + w, + "%-6s %-11s %12.2f %s\n", + t.Symbol, + t.Type, + t.TargetPrice, + t.QuoteCurrency, + ) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/repository/target_repository.go b/internal/repository/target_repository.go index 3306cce..595dfdf 100644 --- a/internal/repository/target_repository.go +++ b/internal/repository/target_repository.go @@ -40,7 +40,7 @@ func (r *TargetRepository) Save(ctx context.Context, t domain.Target) error { return nil } -func (r *TargetRepository) ListByPortfolioID(ctx context.Context, portfolioID int64) ([]domain.Target, error) { +func (r *TargetRepository) ListByPortfolio(ctx context.Context, portfolioID int64) ([]domain.Target, error) { rows, err := r.q.ListTargetsByPortfolio(ctx, portfolioID) if err != nil { return nil, fmt.Errorf("list positions by portfolio id %d: %w", portfolioID, err) diff --git a/internal/service/service.go b/internal/service/service.go index 9204548..b0944fe 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -16,7 +16,7 @@ type ( ListByPortfolioID(ctx context.Context, portfolioID int64) ([]repository.Position, error) } TargetRepository interface { - ListByPortfolioID(ctx context.Context, portfolioID int64) ([]domain.Target, error) + ListByPortfolio(ctx context.Context, portfolioID int64) ([]domain.Target, error) } ) diff --git a/internal/service/target.go b/internal/service/target.go index e2f63c8..401a89d 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -33,7 +33,7 @@ func (s *TargetSvc) EvaluateTargets( return nil, fmt.Errorf("get portfolio: %w", err) } - targets, err := s.targets.ListByPortfolioID(ctx, portfolio.ID) + targets, err := s.targets.ListByPortfolio(ctx, portfolio.ID) if err != nil { return nil, fmt.Errorf("list targets: %w", err) } diff --git a/internal/usecase/target_list.go b/internal/usecase/target_list.go new file mode 100644 index 0000000..177c107 --- /dev/null +++ b/internal/usecase/target_list.go @@ -0,0 +1,44 @@ +package usecase + +import ( + "context" + "fmt" + + "github.com/squeakycheese75/tick/internal/domain" +) + +type ListTargetsUseCase struct { + portfolios PortfolioRepository + targets TargetRepository +} + +func NewListTargetsUseCase( + portfolios PortfolioRepository, + targets TargetRepository, +) *ListTargetsUseCase { + return &ListTargetsUseCase{ + portfolios: portfolios, + targets: targets, + } +} + +func (uc *ListTargetsUseCase) Execute( + ctx context.Context, + in domain.ListTargetsUseCaseInput, +) (domain.ListTargetsUseCaseOutput, error) { + + portfolio, err := uc.portfolios.GetByName(ctx, in.PortfolioName) + if err != nil { + return domain.ListTargetsUseCaseOutput{}, fmt.Errorf("get portfolio: %w", err) + } + + targets, err := uc.targets.ListByPortfolio(ctx, portfolio.ID) + if err != nil { + return domain.ListTargetsUseCaseOutput{}, fmt.Errorf("list targets: %w", err) + } + + return domain.ListTargetsUseCaseOutput{ + PortfolioName: in.PortfolioName, + Targets: targets, + }, nil +} diff --git a/internal/usecase/target_set.go b/internal/usecase/target_set.go index 6a6488e..fed1064 100644 --- a/internal/usecase/target_set.go +++ b/internal/usecase/target_set.go @@ -19,7 +19,7 @@ func NewSetTargetUseCase(portfolios PortfolioRepository, targets TargetRepositor } } -func (uc *SetTargetUseCase) Execute(ctx context.Context, in domain.SetTargetUsecaseInput) (*domain.SetTargetUsecaseOutput, error) { +func (uc *SetTargetUseCase) Execute(ctx context.Context, in domain.SetTargetUseCaseInput) (*domain.SetTargetUseCaseOutput, error) { portfolio, err := uc.portfolios.GetByName(ctx, in.PortfolioName) if err != nil { return nil, err @@ -33,7 +33,7 @@ func (uc *SetTargetUseCase) Execute(ctx context.Context, in domain.SetTargetUsec QuoteCurrency: strings.ToUpper(in.QuoteCurrency), } - return &domain.SetTargetUsecaseOutput{ + return &domain.SetTargetUseCaseOutput{ PortfolioName: in.PortfolioName, Symbol: in.Symbol, QuoteCurrency: in.QuoteCurrency, diff --git a/internal/usecase/usecase.go b/internal/usecase/usecase.go index 0d44aa1..250d10c 100644 --- a/internal/usecase/usecase.go +++ b/internal/usecase/usecase.go @@ -36,7 +36,7 @@ type ( } TargetRepository interface { Save(ctx context.Context, target domain.Target) error - // ListByPortfolio(ctx context.Context, portfolioID int64) ([]domain.Target, error) + ListByPortfolio(ctx context.Context, portfolioID int64) ([]domain.Target, error) // Delete(ctx context.Context, portfolioID int64, symbol string, targetType domain.TargetType) error } )