Skip to content
Open
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed to call out this is new and it may not be new as time goes on.


Example composite group:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be nice to include this in a collapsible secion since this likely may not be used by the majority of users:

https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections


```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

Expand Down
133 changes: 108 additions & 25 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: full word names

// 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
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: full word names

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
Expand All @@ -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)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: full word names

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)
Expand Down
150 changes: 150 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
7 changes: 4 additions & 3 deletions internal/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest changing the name to just includes with the group aspect being implied

}

type AssetGroup struct {
Expand Down
Loading