• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

achannarasappa / ticker / 18699200784

21 Oct 2025 10:14PM UTC coverage: 87.887% (+0.05%) from 87.837%
18699200784

Pull #332

github

web-flow
Merge 0df72d0fc into c895a0fd9
Pull Request #332: feat: Compose groups via include-groups for reusable tabs and summaries

82 of 85 new or added lines in 1 file covered. (96.47%)

3 existing lines in 2 files now uncovered.

2953 of 3360 relevant lines covered (87.89%)

8.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.44
/internal/cli/cli.go
1
package cli
2

3
import (
4
        "errors"
5
        "fmt"
6
        "log"
7
        "os"
8
        "strings"
9
        "time"
10

11
        "github.com/achannarasappa/ticker/v5/internal/cli/symbol"
12
        c "github.com/achannarasappa/ticker/v5/internal/common"
13
        "github.com/achannarasappa/ticker/v5/internal/ui/util"
14

15
        "github.com/adrg/xdg"
16
        "github.com/mitchellh/go-homedir"
17
        "github.com/spf13/afero"
18
        "github.com/spf13/cobra"
19
        "github.com/spf13/viper"
20
        "gopkg.in/yaml.v2"
21
)
22

23
// Options to configured ticker behavior
24
type Options struct {
25
        RefreshInterval       int
26
        Watchlist             string
27
        Separate              bool
28
        ExtraInfoExchange     bool
29
        ExtraInfoFundamentals bool
30
        ShowSummary           bool
31
        ShowHoldings          bool
32
        Sort                  string
33
}
34

35
type symbolSource struct {
36
        symbol string
37
        source c.QuoteSource
38
}
39

40
// Run starts the ticker UI
41
func Run(uiStartFn func() error) func(*cobra.Command, []string) {
2✔
42
        return func(_ *cobra.Command, _ []string) {
4✔
43
                err := uiStartFn()
2✔
44

2✔
45
                if err != nil {
3✔
46
                        fmt.Println(fmt.Errorf("unable to start UI: %w", err).Error())
1✔
47
                }
1✔
48
        }
49
}
50

51
// Validate checks whether config is valid and returns an error if invalid or if an error was generated earlier
52
func Validate(config *c.Config, options *Options, prevErr *error) func(*cobra.Command, []string) error {
4✔
53
        return func(_ *cobra.Command, _ []string) error {
8✔
54

4✔
55
                if prevErr != nil && *prevErr != nil {
5✔
56
                        return *prevErr
1✔
57
                }
1✔
58

59
                if len(config.Watchlist) == 0 && len(options.Watchlist) == 0 && len(config.Lots) == 0 && len(config.AssetGroup) == 0 {
4✔
60
                        return errors.New("invalid config: No watchlist provided") //nolint:goerr113
1✔
61
                }
1✔
62

63
                return nil
2✔
64
        }
65
}
66

67
func GetDependencies() c.Dependencies {
1✔
68
        return c.Dependencies{
1✔
69
                Fs:                               afero.NewOsFs(),
1✔
70
                SymbolsURL:                       "https://raw.githubusercontent.com/achannarasappa/ticker-static/master/symbols.csv",
1✔
71
                MonitorYahooBaseURL:              "https://query1.finance.yahoo.com",
1✔
72
                MonitorYahooSessionRootURL:       "https://finance.yahoo.com",
1✔
73
                MonitorYahooSessionCrumbURL:      "https://query2.finance.yahoo.com",
1✔
74
                MonitorYahooSessionConsentURL:    "https://consent.yahoo.com",
1✔
75
                MonitorPriceCoinbaseBaseURL:      "https://api.coinbase.com",
1✔
76
                MonitorPriceCoinbaseStreamingURL: "wss://ws-feed.exchange.coinbase.com",
1✔
77
        }
1✔
78
}
1✔
79

80
// GetContext builds the context from the config and reference data
81
func GetContext(d c.Dependencies, config c.Config) (c.Context, error) {
12✔
82
        var (
12✔
83
                reference c.Reference
12✔
84
                groups    []c.AssetGroup
12✔
85
                err       error
12✔
86
        )
12✔
87

12✔
88
        if err != nil {
12✔
89
                return c.Context{}, err
×
90
        }
×
91

92
        groups, err = getGroups(config, d)
12✔
93

12✔
94
        if err != nil {
15✔
95
                return c.Context{}, err
3✔
96
        }
3✔
97

98
        reference, err = getReference(config, groups)
9✔
99

9✔
100
        if err != nil {
9✔
101
                return c.Context{}, err
×
102
        }
×
103

104
        var logger *log.Logger
9✔
105

9✔
106
        if config.Debug {
10✔
107
                logger, err = getLogger(d)
1✔
108

1✔
109
                if err != nil {
2✔
110
                        return c.Context{}, err
1✔
111
                }
1✔
112
        }
113

114
        context := c.Context{
8✔
115
                Reference: reference,
8✔
116
                Config:    config,
8✔
117
                Groups:    groups,
8✔
118
                Logger:    logger,
8✔
119
        }
8✔
120

8✔
121
        return context, err
8✔
122
}
123

124
func readConfig(fs afero.Fs, configPathOption string) (c.Config, error) {
24✔
125
        var config c.Config
24✔
126
        configPath, err := getConfigPath(fs, configPathOption)
24✔
127

24✔
128
        if err != nil {
29✔
129
                return config, nil //nolint:nilerr
5✔
130
        }
5✔
131
        handle, err := fs.Open(configPath)
19✔
132

19✔
133
        if err != nil {
20✔
134
                return config, fmt.Errorf("invalid config: %w", err)
1✔
135
        }
1✔
136

137
        defer handle.Close()
18✔
138
        err = yaml.NewDecoder(handle).Decode(&config)
18✔
139

18✔
140
        if err != nil {
19✔
141
                return config, fmt.Errorf("invalid config: %w", err)
1✔
142
        }
1✔
143

144
        return config, nil
17✔
145
}
146

147
func getReference(config c.Config, assetGroups []c.AssetGroup) (c.Reference, error) {
9✔
148

9✔
149
        var err error
9✔
150

9✔
151
        styles := util.GetColorScheme(config.ColorScheme)
9✔
152

9✔
153
        if err != nil {
9✔
154
                return c.Reference{}, err
×
155
        }
×
156

157
        return c.Reference{
9✔
158
                Styles: styles,
9✔
159
        }, err
9✔
160

161
}
162

163
func GetConfig(dep c.Dependencies, configPath string, options Options) (c.Config, error) {
24✔
164

24✔
165
        config, err := readConfig(dep.Fs, configPath)
24✔
166

24✔
167
        if err != nil {
26✔
168
                return c.Config{}, err
2✔
169
        }
2✔
170

171
        if len(options.Watchlist) != 0 {
24✔
172
                config.Watchlist = strings.Split(strings.ReplaceAll(options.Watchlist, " ", ""), ",")
2✔
173
        }
2✔
174

175
        config.RefreshInterval = getRefreshInterval(options.RefreshInterval, config.RefreshInterval)
22✔
176
        config.Separate = getBoolOption(options.Separate, config.Separate)
22✔
177
        config.ExtraInfoExchange = getBoolOption(options.ExtraInfoExchange, config.ExtraInfoExchange)
22✔
178
        config.ExtraInfoFundamentals = getBoolOption(options.ExtraInfoFundamentals, config.ExtraInfoFundamentals)
22✔
179
        config.ShowSummary = getBoolOption(options.ShowSummary, config.ShowSummary)
22✔
180
        config.ShowHoldings = getBoolOption(options.ShowHoldings, config.ShowHoldings)
22✔
181
        config.Sort = getStringOption(options.Sort, config.Sort)
22✔
182

22✔
183
        return config, nil
22✔
184
}
185

186
func getConfigPath(fs afero.Fs, configPathOption string) (string, error) {
24✔
187
        var err error
24✔
188
        if configPathOption != "" {
27✔
189
                return configPathOption, nil
3✔
190
        }
3✔
191

192
        home, _ := homedir.Dir()
21✔
193

21✔
194
        v := viper.New()
21✔
195
        v.SetFs(fs)
21✔
196
        v.SetConfigType("yaml")
21✔
197
        v.AddConfigPath(home)
21✔
198
        v.AddConfigPath(".")
21✔
199
        v.AddConfigPath(xdg.ConfigHome)
21✔
200
        v.AddConfigPath(xdg.ConfigHome + "/ticker")
21✔
201
        v.SetConfigName(".ticker")
21✔
202
        err = v.ReadInConfig()
21✔
203

21✔
204
        if err != nil {
26✔
205
                return "", fmt.Errorf("invalid config: %w", err)
5✔
206
        }
5✔
207

208
        return v.ConfigFileUsed(), nil
16✔
209
}
210

211
func getRefreshInterval(optionsRefreshInterval int, configRefreshInterval int) int {
22✔
212

22✔
213
        if optionsRefreshInterval > 0 {
24✔
214
                return optionsRefreshInterval
2✔
215
        }
2✔
216

217
        if configRefreshInterval > 0 {
21✔
218
                return configRefreshInterval
1✔
219
        }
1✔
220

221
        return 5
19✔
222
}
223

224
func getBoolOption(cliValue bool, configValue bool) bool {
110✔
225

110✔
226
        if cliValue {
111✔
227
                return cliValue
1✔
228
        }
1✔
229

230
        if configValue {
111✔
231
                return configValue
2✔
232
        }
2✔
233

234
        return false
107✔
235
}
236

237
func getStringOption(cliValue string, configValue string) string {
22✔
238

22✔
239
        if cliValue != "" {
22✔
240
                return cliValue
×
241
        }
×
242

243
        if configValue != "" {
22✔
244
                return configValue
×
245
        }
×
246

247
        return ""
22✔
248
}
249

250
func getGroups(config c.Config, d c.Dependencies) ([]c.AssetGroup, error) {
12✔
251

12✔
252
        groups := make([]c.AssetGroup, 0)
12✔
253
        var configAssetGroups []c.ConfigAssetGroup
12✔
254

12✔
255
        tickerSymbolToSourceSymbol, err := symbol.GetTickerSymbols(d.SymbolsURL)
12✔
256

12✔
257
        if err != nil {
13✔
258
                return []c.AssetGroup{}, err
1✔
259
        }
1✔
260

261
        if len(config.Watchlist) > 0 || len(config.Lots) > 0 {
16✔
262
                configAssetGroups = append(configAssetGroups, c.ConfigAssetGroup{
5✔
263
                        Name:      "default",
5✔
264
                        Watchlist: config.Watchlist,
5✔
265
                        Holdings:  config.Lots,
5✔
266
                })
5✔
267
        }
5✔
268

269
        configAssetGroups = append(configAssetGroups, config.AssetGroup...)
11✔
270

11✔
271
        // Index groups by name for include resolution and validate duplicate names
11✔
272
        groupsByName := make(map[string]c.ConfigAssetGroup)
11✔
273
        for _, g := range configAssetGroups {
27✔
274
                if g.Name == "" {
16✔
NEW
275
                        // unnamed groups are allowed unless referenced by include-groups
×
NEW
276
                        continue
×
277
                }
278
                if _, exists := groupsByName[g.Name]; exists {
16✔
NEW
279
                        return nil, fmt.Errorf("invalid config: duplicate group name: %s", g.Name)
×
UNCOV
280
                }
×
281
                groupsByName[g.Name] = g
16✔
282
        }
283

284
        // Build final groups in declaration order using flattened config
285
        for _, configAssetGroup := range configAssetGroups {
26✔
286
                // compute effective watchlist/holdings
15✔
287
                effWatchlist := configAssetGroup.Watchlist
15✔
288
                effHoldings := configAssetGroup.Holdings
15✔
289
                if len(configAssetGroup.IncludeGroups) > 0 {
19✔
290
                        wl, hl, err := resolveGroupIncludes(groupsByName, configAssetGroup, map[string]bool{})
4✔
291
                        if err != nil {
6✔
292
                                return nil, err
2✔
293
                        }
2✔
294
                        // de-duplicate watchlist preserving order
295
                        effWatchlist = dedupePreserveOrder(wl)
2✔
296
                        // lots are intentionally not de-duplicated here; aggregation is handled downstream
2✔
297
                        effHoldings = hl
2✔
298
                }
299
                assetGroupSymbolsBySource := symbolsBySource(effWatchlist, effHoldings, tickerSymbolToSourceSymbol)
13✔
300
                groups = append(groups, c.AssetGroup{
13✔
301
                        ConfigAssetGroup: c.ConfigAssetGroup{
13✔
302
                                Name:          configAssetGroup.Name,
13✔
303
                                Watchlist:     effWatchlist,
13✔
304
                                Holdings:      effHoldings,
13✔
305
                                IncludeGroups: configAssetGroup.IncludeGroups,
13✔
306
                        },
13✔
307
                        SymbolsBySource: assetGroupSymbolsBySource,
13✔
308
                })
13✔
309
        }
310

311
        return groups, nil
9✔
312

313
}
314

315
func getLogger(d c.Dependencies) (*log.Logger, error) {
1✔
316
        // Create log file with current date
1✔
317
        currentTime := time.Now()
1✔
318
        logFileName := fmt.Sprintf("ticker-log-%s.log", currentTime.Format("2006-01-02"))
1✔
319
        logFile, err := d.Fs.OpenFile(logFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
1✔
320
        if err != nil {
2✔
321
                return nil, fmt.Errorf("failed to create log file: %w", err)
1✔
322
        }
1✔
323

324
        return log.New(logFile, "", log.LstdFlags), nil
×
325
}
326

327
// resolveGroupIncludes flattens include-groups recursively preserving order and detecting cycles
328
func resolveGroupIncludes(groupsByName map[string]c.ConfigAssetGroup, cur c.ConfigAssetGroup, visiting map[string]bool) ([]string, []c.Lot, error) {
10✔
329

10✔
330
        wl := make([]string, 0)
10✔
331
        hl := make([]c.Lot, 0)
10✔
332

10✔
333
        if cur.Name != "" {
20✔
334
                if visiting[cur.Name] {
11✔
335
                        return nil, nil, fmt.Errorf("invalid config: cyclic include-groups involving %s", cur.Name)
1✔
336
                }
1✔
337
                visiting[cur.Name] = true
9✔
338
                defer delete(visiting, cur.Name)
9✔
339
        }
340

341
        for _, name := range cur.IncludeGroups {
16✔
342
                inc, ok := groupsByName[name]
7✔
343
                if !ok {
8✔
344
                        return nil, nil, fmt.Errorf("invalid config: include-groups references unknown group: %s", name)
1✔
345
                }
1✔
346
                iw, ih, err := resolveGroupIncludes(groupsByName, inc, visiting)
6✔
347
                if err != nil {
8✔
348
                        return nil, nil, err
2✔
349
                }
2✔
350
                wl = append(wl, iw...)
4✔
351
                hl = append(hl, ih...)
4✔
352
        }
353

354
        wl = append(wl, cur.Watchlist...)
6✔
355
        hl = append(hl, cur.Holdings...)
6✔
356

6✔
357
        return wl, hl, nil
6✔
358
}
359

360
// dedupePreserveOrder removes duplicates from a list while preserving first-seen order
361
func dedupePreserveOrder(xs []string) []string {
2✔
362
        seen := make(map[string]bool)
2✔
363
        out := make([]string, 0, len(xs))
2✔
364
        for _, s := range xs {
10✔
365
                if !seen[s] {
14✔
366
                        seen[s] = true
6✔
367
                        out = append(out, s)
6✔
368
                }
6✔
369
        }
370

371
        return out
2✔
372
}
373

374
// symbolsBySource builds the list of symbols partitioned by quote source
375
func symbolsBySource(watchlist []string, holdings []c.Lot, tickerSymbolToSourceSymbol symbol.TickerSymbolToSourceSymbol) []c.AssetGroupSymbolsBySource {
13✔
376
        symbols := make(map[string]bool)
13✔
377
        symbolsUnique := make(map[c.QuoteSource]c.AssetGroupSymbolsBySource)
13✔
378

13✔
379
        for _, sym := range watchlist {
39✔
380
                if !symbols[sym] {
52✔
381
                        symbols[sym] = true
26✔
382
                        symSrc := getSymbolAndSource(sym, tickerSymbolToSourceSymbol)
26✔
383
                        symbolsUnique = appendSymbol(symbolsUnique, symSrc)
26✔
384
                }
26✔
385
        }
386

387
        for _, lot := range holdings {
24✔
388
                if !symbols[lot.Symbol] {
12✔
389
                        symbols[lot.Symbol] = true
1✔
390
                        symSrc := getSymbolAndSource(lot.Symbol, tickerSymbolToSourceSymbol)
1✔
391
                        symbolsUnique = appendSymbol(symbolsUnique, symSrc)
1✔
392
                }
1✔
393
        }
394

395
        res := make([]c.AssetGroupSymbolsBySource, 0, len(symbolsUnique))
13✔
396
        for _, s := range symbolsUnique {
27✔
397
                res = append(res, s)
14✔
398
        }
14✔
399

400
        return res
13✔
401
}
402

403
func getSymbolAndSource(symbol string, tickerSymbolToSourceSymbol symbol.TickerSymbolToSourceSymbol) symbolSource {
27✔
404

27✔
405
        symbolUppercase := strings.ToUpper(symbol)
27✔
406

27✔
407
        if strings.HasSuffix(symbolUppercase, ".CB") {
29✔
408

2✔
409
                symbol = strings.ToUpper(symbol)[:len(symbol)-3]
2✔
410

2✔
411
                // Futures contracts on Coinbase Derivatives Exchange are implicitly USD-denominated
2✔
412
                if strings.HasSuffix(symbol, "-CDE") {
3✔
413
                        return symbolSource{
1✔
414
                                source: c.QuoteSourceCoinbase,
1✔
415
                                symbol: symbol,
1✔
416
                        }
1✔
417
                }
1✔
418

419
                return symbolSource{
1✔
420
                        source: c.QuoteSourceCoinbase,
1✔
421
                        symbol: symbol + "-USD",
1✔
422
                }
1✔
423
        }
424

425
        if strings.HasSuffix(symbolUppercase, ".X") {
26✔
426

1✔
427
                if tickerSymbolToSource, exists := tickerSymbolToSourceSymbol[symbolUppercase]; exists {
2✔
428

1✔
429
                        return symbolSource{
1✔
430
                                source: tickerSymbolToSource.Source,
1✔
431
                                symbol: tickerSymbolToSource.SourceSymbol,
1✔
432
                        }
1✔
433

1✔
434
                }
1✔
435

436
        }
437

438
        return symbolSource{
24✔
439
                source: c.QuoteSourceYahoo,
24✔
440
                symbol: symbolUppercase,
24✔
441
        }
24✔
442

443
}
444

445
func appendSymbol(symbolsUnique map[c.QuoteSource]c.AssetGroupSymbolsBySource, symbolAndSource symbolSource) map[c.QuoteSource]c.AssetGroupSymbolsBySource {
27✔
446

27✔
447
        if symbolsBySource, ok := symbolsUnique[symbolAndSource.source]; ok {
40✔
448

13✔
449
                symbolsBySource.Symbols = append(symbolsBySource.Symbols, symbolAndSource.symbol)
13✔
450

13✔
451
                symbolsUnique[symbolAndSource.source] = symbolsBySource
13✔
452

13✔
453
                return symbolsUnique
13✔
454
        }
13✔
455

456
        symbolsUnique[symbolAndSource.source] = c.AssetGroupSymbolsBySource{
14✔
457
                Source:  symbolAndSource.source,
14✔
458
                Symbols: []string{symbolAndSource.symbol},
14✔
459
        }
14✔
460

14✔
461
        return symbolsUnique
14✔
462

463
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc