diff --git a/README.md b/README.md index 5f1b6a2..3277354 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,41 @@ Watchlists and holdings can be grouped in `.ticker.yml` under the `groups` prope * If top level `watchlist` or `lots` properties are defined in the configuration file, the entries there will be added to a group named `default` which will always be shown first * Ordering is defined by order in the configuration file * The `holdings` property replaces `lots` under `groups` but serves the same purpose +* New: `include-groups` allows composing a group from other groups without duplicating entries. The effective watchlist is the concatenation of included groups’ watchlists (then the group’s own), de-duplicated by first occurrence; holdings are concatenated (lots aggregate by symbol automatically in summaries) + +Example composite group: + +```yaml +groups: + - name: crypto + watchlist: [BTC-USD, ETH-USD] + holdings: + - symbol: BTC-USD + quantity: 0.75 + unit_cost: 35000 + + - name: personal stocks + watchlist: [AAPL, MSFT] + holdings: + - symbol: AAPL + quantity: 20 + unit_cost: 130 + + - name: personal holding + include-groups: [crypto, personal stocks] + # Optional extras specific to this composite + watchlist: [SOL.X] + holdings: + - symbol: MSFT + quantity: 3 + unit_cost: 310 +``` + +Notes: +- Includes are resolved recursively; cyclic includes are invalid. +- `default` (created from top-level `watchlist`/`lots`) may be referenced. +- Forward references are allowed: a composite group can include groups + declared later in the file. ### Data Sources & Symbols diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 474ff19..c8c2dd7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -268,37 +268,44 @@ func getGroups(config c.Config, d c.Dependencies) ([]c.AssetGroup, error) { configAssetGroups = append(configAssetGroups, config.AssetGroup...) - for _, configAssetGroup := range configAssetGroups { - - symbols := make(map[string]bool) - symbolsUnique := make(map[c.QuoteSource]c.AssetGroupSymbolsBySource) - var assetGroupSymbolsBySource []c.AssetGroupSymbolsBySource - - for _, symbol := range configAssetGroup.Watchlist { - if !symbols[symbol] { - symbols[symbol] = true - symbolAndSource := getSymbolAndSource(symbol, tickerSymbolToSourceSymbol) - symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource) - } + // Index groups by name for include resolution and validate duplicate names + groupsByName := make(map[string]c.ConfigAssetGroup) + for _, g := range configAssetGroups { + if g.Name == "" { + // unnamed groups are allowed unless referenced by include-groups + continue } - - for _, lot := range configAssetGroup.Holdings { - if !symbols[lot.Symbol] { - symbols[lot.Symbol] = true - symbolAndSource := getSymbolAndSource(lot.Symbol, tickerSymbolToSourceSymbol) - symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource) - } + if _, exists := groupsByName[g.Name]; exists { + return nil, fmt.Errorf("invalid config: duplicate group name: %s", g.Name) } + groupsByName[g.Name] = g + } - for _, symbolsBySource := range symbolsUnique { - assetGroupSymbolsBySource = append(assetGroupSymbolsBySource, symbolsBySource) + // Build final groups in declaration order using flattened config + for _, configAssetGroup := range configAssetGroups { + // compute effective watchlist/holdings + effWatchlist := configAssetGroup.Watchlist + effHoldings := configAssetGroup.Holdings + if len(configAssetGroup.IncludeGroups) > 0 { + wl, hl, err := resolveGroupIncludes(groupsByName, configAssetGroup, map[string]bool{}) + if err != nil { + return nil, err + } + // de-duplicate watchlist preserving order + effWatchlist = dedupePreserveOrder(wl) + // lots are intentionally not de-duplicated here; aggregation is handled downstream + effHoldings = hl } - + assetGroupSymbolsBySource := symbolsBySource(effWatchlist, effHoldings, tickerSymbolToSourceSymbol) groups = append(groups, c.AssetGroup{ - ConfigAssetGroup: configAssetGroup, - SymbolsBySource: assetGroupSymbolsBySource, + ConfigAssetGroup: c.ConfigAssetGroup{ + Name: configAssetGroup.Name, + Watchlist: effWatchlist, + Holdings: effHoldings, + IncludeGroups: configAssetGroup.IncludeGroups, + }, + SymbolsBySource: assetGroupSymbolsBySource, }) - } return groups, nil @@ -317,6 +324,82 @@ func getLogger(d c.Dependencies) (*log.Logger, error) { return log.New(logFile, "", log.LstdFlags), nil } +// resolveGroupIncludes flattens include-groups recursively preserving order and detecting cycles +func resolveGroupIncludes(groupsByName map[string]c.ConfigAssetGroup, cur c.ConfigAssetGroup, visiting map[string]bool) ([]string, []c.Lot, error) { + + wl := make([]string, 0) + hl := make([]c.Lot, 0) + + if cur.Name != "" { + if visiting[cur.Name] { + return nil, nil, fmt.Errorf("invalid config: cyclic include-groups involving %s", cur.Name) + } + visiting[cur.Name] = true + defer delete(visiting, cur.Name) + } + + for _, name := range cur.IncludeGroups { + inc, ok := groupsByName[name] + if !ok { + return nil, nil, fmt.Errorf("invalid config: include-groups references unknown group: %s", name) + } + iw, ih, err := resolveGroupIncludes(groupsByName, inc, visiting) + if err != nil { + return nil, nil, err + } + wl = append(wl, iw...) + hl = append(hl, ih...) + } + + wl = append(wl, cur.Watchlist...) + hl = append(hl, cur.Holdings...) + + return wl, hl, nil +} + +// dedupePreserveOrder removes duplicates from a list while preserving first-seen order +func dedupePreserveOrder(xs []string) []string { + seen := make(map[string]bool) + out := make([]string, 0, len(xs)) + for _, s := range xs { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + + return out +} + +// symbolsBySource builds the list of symbols partitioned by quote source +func symbolsBySource(watchlist []string, holdings []c.Lot, tickerSymbolToSourceSymbol symbol.TickerSymbolToSourceSymbol) []c.AssetGroupSymbolsBySource { + symbols := make(map[string]bool) + symbolsUnique := make(map[c.QuoteSource]c.AssetGroupSymbolsBySource) + + for _, sym := range watchlist { + if !symbols[sym] { + symbols[sym] = true + symSrc := getSymbolAndSource(sym, tickerSymbolToSourceSymbol) + symbolsUnique = appendSymbol(symbolsUnique, symSrc) + } + } + + for _, lot := range holdings { + if !symbols[lot.Symbol] { + symbols[lot.Symbol] = true + symSrc := getSymbolAndSource(lot.Symbol, tickerSymbolToSourceSymbol) + symbolsUnique = appendSymbol(symbolsUnique, symSrc) + } + } + + res := make([]c.AssetGroupSymbolsBySource, 0, len(symbolsUnique)) + for _, s := range symbolsUnique { + res = append(res, s) + } + + return res +} + func getSymbolAndSource(symbol string, tickerSymbolToSourceSymbol symbol.TickerSymbolToSourceSymbol) symbolSource { symbolUppercase := strings.ToUpper(symbol) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 52b0513..41dd1dc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -165,6 +165,156 @@ var _ = Describe("Cli", func() { }), }), + // include-groups: composite group merges watchlist and holdings + Entry("when include-groups composes groups", Case{ + InputOptions: cli.Options{}, + InputConfigFileContents: strings.Join([]string{ + "groups:", + " - name: crypto", + " watchlist:", + " - BTC-USD", + " - ETH-USD", + " holdings:", + " - symbol: BTC-USD", + " quantity: 1", + " unit_cost: 30000", + " - name: personal", + " watchlist:", + " - AAPL", + " holdings:", + " - symbol: AAPL", + " quantity: 2", + " unit_cost: 120", + " - name: combined", + " include-groups:", + " - crypto", + " - personal", + " watchlist:", + " - ETH-USD", + " holdings:", + " - symbol: AAPL", + " quantity: 1", + " unit_cost: 130", + }, "\n"), + AssertionErr: BeNil(), + AssertionCtx: g.MatchFields(g.IgnoreExtras, g.Fields{ + "Groups": g.MatchAllElementsWithIndex(g.IndexIdentity, g.Elements{ + "0": g.MatchFields(g.IgnoreExtras, g.Fields{ + "ConfigAssetGroup": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Name": Equal("crypto"), + }), + }), + "1": g.MatchFields(g.IgnoreExtras, g.Fields{ + "ConfigAssetGroup": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Name": Equal("personal"), + }), + }), + "2": g.MatchFields(g.IgnoreExtras, g.Fields{ + "ConfigAssetGroup": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Name": Equal("combined"), + "Watchlist": Equal([]string{"BTC-USD", "ETH-USD", "AAPL"}), + "Holdings": g.MatchAllElementsWithIndex(g.IndexIdentity, g.Elements{ + "0": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Symbol": Equal("BTC-USD"), + }), + "1": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Symbol": Equal("AAPL"), + }), + "2": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Symbol": Equal("AAPL"), + }), + }), + }), + }), + }), + }), + }), + + // include-groups: composite group declared before referenced groups + Entry("when include-groups composes groups declared later", Case{ + InputOptions: cli.Options{}, + InputConfigFileContents: strings.Join([]string{ + "groups:", + " - name: combined", + " include-groups:", + " - crypto", + " - personal", + " watchlist:", + " - ETH-USD", + " holdings:", + " - symbol: AAPL", + " quantity: 1", + " unit_cost: 130", + " - name: crypto", + " watchlist:", + " - BTC-USD", + " - ETH-USD", + " holdings:", + " - symbol: BTC-USD", + " quantity: 1", + " unit_cost: 30000", + " - name: personal", + " watchlist:", + " - AAPL", + " holdings:", + " - symbol: AAPL", + " quantity: 2", + " unit_cost: 120", + }, "\n"), + AssertionErr: BeNil(), + AssertionCtx: g.MatchFields(g.IgnoreExtras, g.Fields{ + "Groups": g.MatchAllElementsWithIndex(g.IndexIdentity, g.Elements{ + "0": g.MatchFields(g.IgnoreExtras, g.Fields{ + "ConfigAssetGroup": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Name": Equal("combined"), + "Watchlist": Equal([]string{"BTC-USD", "ETH-USD", "AAPL"}), + "Holdings": g.MatchAllElementsWithIndex(g.IndexIdentity, g.Elements{ + "0": g.MatchFields(g.IgnoreExtras, g.Fields{"Symbol": Equal("BTC-USD")}), + "1": g.MatchFields(g.IgnoreExtras, g.Fields{"Symbol": Equal("AAPL")}), + "2": g.MatchFields(g.IgnoreExtras, g.Fields{"Symbol": Equal("AAPL")}), + }), + }), + }), + "1": g.MatchFields(g.IgnoreExtras, g.Fields{ + "ConfigAssetGroup": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Name": Equal("crypto"), + }), + }), + "2": g.MatchFields(g.IgnoreExtras, g.Fields{ + "ConfigAssetGroup": g.MatchFields(g.IgnoreExtras, g.Fields{ + "Name": Equal("personal"), + }), + }), + }), + }), + }), + + // include-groups: unknown target should error + Entry("when include-groups references unknown group", Case{ + InputOptions: cli.Options{}, + InputConfigFileContents: strings.Join([]string{ + "groups:", + " - name: test", + " include-groups:", + " - missing", + }, "\n"), + AssertionErr: Not(BeNil()), + AssertionCtx: BeZero(), + }), + + // include-groups: cyclic include should error + Entry("when include-groups is cyclic", Case{ + InputOptions: cli.Options{}, + InputConfigFileContents: strings.Join([]string{ + "groups:", + " - name: A", + " include-groups: [B]", + " - name: B", + " include-groups: [A]", + }, "\n"), + AssertionErr: Not(BeNil()), + AssertionCtx: BeZero(), + }), Entry("when watchlist is set in options", Case{ InputOptions: cli.Options{Watchlist: "BIO,BB"}, InputConfigFileContents: "", diff --git a/internal/common/common.go b/internal/common/common.go index c7e4005..bce9887 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -44,9 +44,10 @@ type ConfigColorScheme struct { } type ConfigAssetGroup struct { - Name string `yaml:"name"` - Watchlist []string `yaml:"watchlist"` - Holdings []Lot `yaml:"holdings"` + Name string `yaml:"name"` + Watchlist []string `yaml:"watchlist"` + Holdings []Lot `yaml:"holdings"` + IncludeGroups []string `yaml:"include-groups"` } type AssetGroup struct {