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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions data/pension-prices.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
{
"symbol": "AEGON_DEFAULT_EB_LIFESTYLE",
"price": 267.38,
"currency": "GBP",
"as_of": "2026-05-06",
"source": "aegon-manual"
}
]
21 changes: 21 additions & 0 deletions db/query/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,24 @@ ORDER BY t.symbol ASC;
DELETE FROM portfolio_targets
WHERE id = ?
AND portfolio_id = ?;


-- name: GetLatestConsumerPrice :one
SELECT *
FROM consumed_prices
WHERE symbol = ?
ORDER BY as_of DESC
LIMIT 1;

-- name: UpsertConsumedPrice :exec
INSERT INTO consumed_prices (
symbol,
source,
price,
currency,
as_of
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(symbol, source, as_of)
DO UPDATE SET
price = excluded.price,
currency = excluded.currency;
13 changes: 12 additions & 1 deletion db/schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,15 @@ CREATE UNIQUE INDEX idx_portfolio_targets_unique_active

CREATE INDEX idx_portfolio_targets_portfolio_id
ON portfolio_targets (portfolio_id);


CREATE TABLE consumed_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
source TEXT NOT NULL,
price REAL NOT NULL,
currency TEXT NOT NULL,
as_of TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

UNIQUE(symbol, source, as_of)
);
28 changes: 23 additions & 5 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Config struct {
CommodityPriceProviders []string
FXProviders []string
NewsProviders []string
FundPriceProviders []string

FinnhubAPIKey string
NewsAPIOrgAPIKey string
Expand All @@ -24,6 +25,8 @@ type Config struct {
PriceCacheTTL time.Duration
FXCacheTTL time.Duration

ConsumedPriceMaxAge time.Duration

LLMEnabled bool
LLMProvider string
LLMBaseURL string
Expand All @@ -48,9 +51,11 @@ func LoadConfig() (Config, error) {
CommodityPriceProviders: splitEnvDefault("COMMODITY_PRICE_PROVIDERS", "static"),
FXProviders: splitEnvDefault("FX_PROVIDERS", "static"),
NewsProviders: splitEnvDefault("NEWS_PROVIDERS", "static"),
FinnhubAPIKey: os.Getenv("FINNHUB_API_KEY"),
NewsAPIOrgAPIKey: os.Getenv("NEWSAPIORG_API_KEY"),
CacheEnabled: getenvDefault("CACHE_ENABLED", "true") == "true",
FundPriceProviders: splitEnvDefault("FUND_PRICES_PROVIDERS", "static"),

FinnhubAPIKey: os.Getenv("FINNHUB_API_KEY"),
NewsAPIOrgAPIKey: os.Getenv("NEWSAPIORG_API_KEY"),
CacheEnabled: getenvDefault("CACHE_ENABLED", "true") == "true",

LLMEnabled: getenvDefault("LLM_ENABLED", "false") == "true",
LLMProvider: getenvDefault("LLM_PROVIDER", "ollama"),
Expand All @@ -68,6 +73,11 @@ func LoadConfig() (Config, error) {
return Config{}, fmt.Errorf("CACHE_FX_TTL: %w", err)
}

cfg.ConsumedPriceMaxAge, err = durationEnv("CONSUMED_PRICE_MAX_AGE", 720*time.Hour)
if err != nil {
return Config{}, fmt.Errorf("CONSUMED_PRICE_MAX_AGE: %w", err)
}

return cfg, nil
}

Expand Down Expand Up @@ -102,9 +112,17 @@ func (c Config) Validate() error {

for _, provider := range c.CommodityPriceProviders {
switch provider {
case "static", "yahoo", "goldapi":
case "static", "yahoo":
default:
return fmt.Errorf("unsupported FX_PROVIDER %q", provider)
return fmt.Errorf("unsupported COMMODITY_PRICE_PROVIDERS %q", provider)
}
}

for _, provider := range c.FundPriceProviders {
switch provider {
case "static", "consumed":
default:
return fmt.Errorf("unsupported FUND_PRICE_PROVIDERS %q", provider)
}
}

Expand Down
47 changes: 47 additions & 0 deletions internal/app/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ type (
Get(ctx context.Context, baseCurrency, quoteCurrency string) (repository.CachedFXRate, error)
Upsert(ctx context.Context, cached repository.CachedFXRate) error
}
ConsumedPriceStore interface {
Create(ctx context.Context, price repository.ConsumedPrice) error
GetLatest(ctx context.Context, symbol string) (repository.ConsumedPrice, error)
}

NewsProvider interface {
GetNews(ctx context.Context, symbol string, limit int) (domain.NewsSummary, error)
}
Expand Down Expand Up @@ -264,3 +269,45 @@ func buildSingleCommodityPriceProvider(

return provider, nil
}

func BuildFundPriceProvider(
cfg Config,
consumedPriceStore ConsumedPriceStore,
symbolResolver SymbolResolver,
) (PriceProvider, error) {
providers := make([]market.NamedPriceProvider, 0, len(cfg.FundPriceProviders))

for _, name := range cfg.FundPriceProviders {
provider, err := buildSingleFundPriceProvider(cfg, name, consumedPriceStore)
if err != nil {
return nil, err
}

providers = append(providers, market.NamedPriceProvider{
Name: name,
Provider: provider,
})
}

return market.NewChainPriceProvider(providers, symbolResolver), nil
}

func buildSingleFundPriceProvider(
cfg Config,
name string,
consumedPriceStore ConsumedPriceStore,
) (PriceProvider, error) {
switch name {
case "consumed":
return market.NewConsumedPriceProvider(
consumedPriceStore,
cfg.ConsumedPriceMaxAge,
), nil

// case "static":
// return market.NewStaticFundPriceProvider(), nil

default:
return nil, fmt.Errorf("unsupported FUND_PRICE_PROVIDER %q", name)
}
}
19 changes: 14 additions & 5 deletions internal/app/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Runtime struct {
SetTarget *usecase.SetTargetUseCase
ListTargets *usecase.ListTargetsUseCase
RemoveTarget *usecase.RemoveTargetUsecase
ConsumePrices *usecase.ConsumePriceUsecase
}

func BuildRuntime(dbPath string) (*Runtime, error) {
Expand All @@ -45,7 +46,8 @@ func BuildRuntime(dbPath string) (*Runtime, error) {
positionRepo := repository.NewPositionRepository(database)
instrumentRepo := repository.NewInstrumentRepository(database)
snapshotRepo := repository.NewSnapshotRepository(database)
targetRespository := repository.NewTargetRepository(database)
targetRepo := repository.NewTargetRepository(database)
internalPricesRepo := repository.NewConsumedPriceRepository(database)

// Caching
priceCacher := repository.NewPriceCacheRepository(database)
Expand All @@ -69,6 +71,11 @@ func BuildRuntime(dbPath string) (*Runtime, error) {
return nil, err
}

internalPricesProviders, err := BuildFundPriceProvider(cfg, internalPricesRepo, symbolResolver)
if err != nil {
return nil, err
}

fxProvider, err := BuildFXProvider(cfg, fxCacher)
if err != nil {
return nil, err
Expand All @@ -89,6 +96,7 @@ func BuildRuntime(dbPath string) (*Runtime, error) {
string(domain.InstrumentTypeEquity): equityPriceProviders,
string(domain.InstrumentTypeCrypto): cryptoPriceProviders,
string(domain.InstrumentTypeCommodity): commodityPriceProviders,
string(domain.InstrumentTypeFund): internalPricesProviders,
}, fxProvider)

portfolioAnalyser := analysis.NewPortfolioAnalyzer(pricingSvc)
Expand All @@ -99,7 +107,7 @@ func BuildRuntime(dbPath string) (*Runtime, error) {
portfolioInsights := service.NewInsightsSvc()
newsSvc := service.NewNewsService(newsProvider)
snapshotSvc := service.NewSnapshotService(snapshotRepo)
targetSvc := service.NewTargetSvc(portfolioRepo, targetRespository)
targetSvc := service.NewTargetSvc(portfolioRepo, targetRepo)

instrumentResolver, err := instruments.NewStaticResolver()
if err != nil {
Expand Down Expand Up @@ -128,8 +136,9 @@ func BuildRuntime(dbPath string) (*Runtime, error) {
ImportPortfolio: usecase.NewImportPortfolioUseCase(positionRepo, portfolioRepo, instrumentRepo),
GetTickerNews: usecase.NewGetTickerNewsUseCase(newsSvc),
GetMorningBrief: usecase.NewGetMorningBriefUsecase(reportingBuilder),
SetTarget: usecase.NewSetTargetUseCase(portfolioRepo, targetRespository),
ListTargets: usecase.NewListTargetsUseCase(portfolioRepo, targetRespository),
RemoveTarget: usecase.NewRemoveTargetUsecase(portfolioRepo, targetRespository),
SetTarget: usecase.NewSetTargetUseCase(portfolioRepo, targetRepo),
ListTargets: usecase.NewListTargetsUseCase(portfolioRepo, targetRepo),
RemoveTarget: usecase.NewRemoveTargetUsecase(portfolioRepo, targetRepo),
ConsumePrices: usecase.NewConsumePriceUsecase(internalPricesRepo),
}, nil
}
107 changes: 107 additions & 0 deletions internal/cli/prices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package cli

import (
"context"
"encoding/json"
"fmt"
"os"
"time"

"github.com/spf13/cobra"

"github.com/squeakycheese75/tick/internal/domain"
)

func newPricesCommand(runtimeBuilder RuntimeBuilder) *cobra.Command {
cmd := &cobra.Command{
Use: "prices",
Short: "Manage consumed prices",
}

cmd.AddCommand(newPricesConsumeCmd(runtimeBuilder))

return cmd
}

type consumedPriceInput struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Currency string `json:"currency"`
AsOf string `json:"as_of"`
Source string `json:"source"`
}

func newPricesConsumeCmd(runtimeBuilder RuntimeBuilder) *cobra.Command {
var filePath string

cmd := &cobra.Command{
Use: "consume --file <file>",
Short: "Consume prices from a JSON file",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()

b, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read prices file: %w", err)
}

var prices []consumedPriceInput
if err := json.Unmarshal(b, &prices); err != nil {
return fmt.Errorf("unmarshal prices file: %w", err)
}

if len(prices) == 0 {
return fmt.Errorf("no prices to consume")
}

app, err := runtimeBuilder()
if err != nil {
return err
}

for _, p := range prices {
asOf, err := parseAsOf(p.AsOf)
if err != nil {
return fmt.Errorf("parse as_of for %s: %w", p.Symbol, err)
}

if err := app.ConsumePrices.Execute(ctx, domain.ConsumePriceUsecaseInput{
Symbol: p.Symbol,
Price: p.Price,
Currency: p.Currency,
AsOf: asOf,
Source: p.Source,
}); err != nil {
return fmt.Errorf("consume price for %s: %w", p.Symbol, err)
}

fmt.Printf(
"Consumed %s %.4f %s (%s)\n",
p.Symbol,
p.Price,
p.Currency,
asOf,
)
}

return nil
},
}

cmd.Flags().StringVar(&filePath, "file", "", "Path to prices JSON file")
_ = cmd.MarkFlagRequired("file")

return cmd
}

func parseAsOf(s string) (time.Time, error) {
if s == "" {
return time.Now(), nil
}

if t, err := time.Parse("2006-01-02", s); err == nil {
return t, nil
}

return time.Parse(time.RFC3339, s)
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func NewRootCmd(runtimeBuilder RuntimeBuilder) *cobra.Command {
rootCmd.AddCommand(newConfigCmd())
rootCmd.AddCommand(newShellCmd(rootCmd))
rootCmd.AddCommand(newTargetCmd(runtimeBuilder))
rootCmd.AddCommand(newPricesCommand(runtimeBuilder))

return rootCmd
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,3 @@ CREATE TABLE IF NOT EXISTS fx_cache (
DROP TABLE IF EXISTS fx_cache;
DROP TABLE IF EXISTS price_cache;
-- +goose StatementEnd

Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@ CREATE INDEX idx_portfolio_targets_portfolio_id
-- +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
DROP TABLE IF EXISTS portfolio_targets;
-- +goose StatementEnd
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE consumed_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
source TEXT NOT NULL,
price REAL NOT NULL,
currency TEXT NOT NULL,
as_of TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

UNIQUE(symbol, source, as_of)
);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS consumed_prices;
-- +goose StatementEnd
Loading