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

achannarasappa / ticker / 28337084122

28 Jun 2026 09:45PM UTC coverage: 88.29% (+0.04%) from 88.247%
28337084122

Pull #376

github

achannarasappa
feat: implement caching for reference data

- Introduced a caching layer to store ticker symbols and currency rates,
  reducing network requests and improving performance.
- Updated the CLI to support cache configuration via command-line options and
  configuration files.
- Enhanced the symbol retrieval process to utilize cached data when available.
- Implemented cache expiration policies for currency rates and symbol mappings.
- Added tests to verify cache functionality and ensure correct behavior when
  data is served from the cache.
- Refactored updater logic to use the cache for checking the latest version,
  simplifying the code and improving efficiency.
Pull Request #376: feat: implement caching for reference data

225 of 252 new or added lines in 9 files covered. (89.29%)

3 existing lines in 2 files now uncovered.

3257 of 3689 relevant lines covered (88.29%)

0.98 hits per line

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

96.36
/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/cache"
12
        "github.com/achannarasappa/ticker/v5/internal/cli/symbol"
13
        c "github.com/achannarasappa/ticker/v5/internal/common"
14
        "github.com/achannarasappa/ticker/v5/internal/ui/util"
15

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

24
// Options to configured ticker behavior
25
type Options struct {
26
        RefreshInterval       int
27
        Watchlist             string
28
        Separate              bool
29
        ExtraInfoExchange     bool
30
        ExtraInfoFundamentals bool
31
        ShowSummary           bool
32
        ShowHoldings          bool // Deprecated: use ShowPositions instead, kept for backwards compatibility
33
        ShowPositions         bool // Preferred field name
34
        Sort                  string
35
        NoCache               bool
36
        Debug                 bool
37
}
38

39
type symbolSource struct {
40
        symbol string
41
        source c.QuoteSource
42
}
43

44
// Run starts the ticker UI
45
func Run(uiStartFn func() error) func(*cobra.Command, []string) {
1✔
46
        return func(_ *cobra.Command, _ []string) {
2✔
47
                err := uiStartFn()
1✔
48

1✔
49
                if err != nil {
2✔
50
                        fmt.Println(fmt.Errorf("unable to start UI: %w", err).Error())
1✔
51
                }
1✔
52
        }
53
}
54

55
// validateLot validates a single lot and returns an error if invalid
56
func validateLot(lot c.Lot, groupName string, lotIndex int) error {
1✔
57
        if lot.Symbol == "" {
2✔
58
                return fmt.Errorf("invalid config: lot #%d in group '%s' has empty symbol", lotIndex+1, groupName) //nolint:goerr113
1✔
59
        }
1✔
60

61
        if lot.Quantity == 0 {
2✔
62
                return fmt.Errorf("invalid config: lot #%d for symbol '%s' in group '%s' has invalid quantity (cannot be zero, got %f)", lotIndex+1, lot.Symbol, groupName, lot.Quantity) //nolint:goerr113
1✔
63
        }
1✔
64

65
        if lot.UnitCost < 0 {
2✔
66
                return fmt.Errorf("invalid config: lot #%d for symbol '%s' in group '%s' has invalid unit_cost (must be zero or positive, got %f)", lotIndex+1, lot.Symbol, groupName, lot.UnitCost) //nolint:goerr113
1✔
67
        }
1✔
68

69
        if lot.FixedCost < 0 {
2✔
70
                return fmt.Errorf("invalid config: lot #%d for symbol '%s' in group '%s' has invalid fixed_cost (must be zero or positive, got %f)", lotIndex+1, lot.Symbol, groupName, lot.FixedCost) //nolint:goerr113
1✔
71
        }
1✔
72

73
        return nil
1✔
74
}
75

76
// Validate checks whether config is valid and returns an error if invalid or if an error was generated earlier
77
func Validate(config *c.Config, options *Options, prevErr *error) func(*cobra.Command, []string) error {
1✔
78
        return func(_ *cobra.Command, _ []string) error {
2✔
79

1✔
80
                if prevErr != nil && *prevErr != nil {
2✔
81
                        return *prevErr
1✔
82
                }
1✔
83

84
                if len(config.Watchlist) == 0 && len(options.Watchlist) == 0 && len(config.Lots) == 0 && len(config.AssetGroup) == 0 {
2✔
85
                        return errors.New("invalid config: No watchlist provided") //nolint:goerr113
1✔
86
                }
1✔
87

88
                if len(config.Currency) > 0 && (strings.ToUpper(config.Currency) != config.Currency || len(config.Currency) != 3) {
2✔
89
                        return errors.New("invalid config: Display currency may only be an ISO 4217 major currency or blank (eg GBP not GBp; default: USD)") //nolint:goerr113
1✔
90
                }
1✔
91

92
                // Validate lots in config.Lots (default group)
93
                for i, lot := range config.Lots {
2✔
94
                        if err := validateLot(lot, "default", i); err != nil {
2✔
95
                                return err
1✔
96
                        }
1✔
97
                }
98

99
                // Validate lots in config.AssetGroup
100
                for _, assetGroup := range config.AssetGroup {
2✔
101
                        groupName := assetGroup.Name
1✔
102
                        if groupName == "" {
2✔
103
                                groupName = "unnamed"
1✔
104
                        }
1✔
105
                        // Use Lots (preferred), otherwise fall back to Holdings for backwards compatibility
106
                        lots := assetGroup.Lots
1✔
107
                        if len(lots) == 0 {
2✔
108
                                lots = assetGroup.Holdings
1✔
109
                        }
1✔
110
                        for i, lot := range lots {
2✔
111
                                if err := validateLot(lot, groupName, i); err != nil {
2✔
112
                                        return err
1✔
113
                                }
1✔
114
                        }
115
                }
116

117
                return nil
1✔
118
        }
119
}
120

121
func GetDependencies() c.Dependencies {
1✔
122
        return c.Dependencies{
1✔
123
                Fs:                               afero.NewOsFs(),
1✔
124
                SymbolsURL:                       "https://raw.githubusercontent.com/achannarasappa/ticker-static/master/symbols.csv",
1✔
125
                GitHubReleasesURL:                "https://api.github.com/repos/achannarasappa/ticker/releases/latest",
1✔
126
                MonitorYahooBaseURL:              "https://query1.finance.yahoo.com",
1✔
127
                MonitorYahooSessionRootURL:       "https://finance.yahoo.com",
1✔
128
                MonitorYahooSessionCrumbURL:      "https://query2.finance.yahoo.com",
1✔
129
                MonitorYahooSessionConsentURL:    "https://consent.yahoo.com",
1✔
130
                MonitorPriceCoinbaseBaseURL:      "https://api.coinbase.com",
1✔
131
                MonitorPriceCoinbaseStreamingURL: "wss://ws-feed.exchange.coinbase.com",
1✔
132
        }
1✔
133
}
1✔
134

135
// GetContext builds the context from the config and reference data
136
func GetContext(d c.Dependencies, config c.Config) (c.Context, error) {
1✔
137
        var (
1✔
138
                reference c.Reference
1✔
139
                groups    []c.AssetGroup
1✔
140
                err       error
1✔
141
        )
1✔
142

1✔
143
        var logger *log.Logger
1✔
144

1✔
145
        if config.Debug {
2✔
146
                logger, err = getLogger(d)
1✔
147

1✔
148
                if err != nil {
2✔
149
                        return c.Context{}, err
1✔
150
                }
1✔
151
        }
152

153
        cache := cache.New(d.Fs, cache.FilePath(), cacheEnabled(config.Cache))
1✔
154

1✔
155
        groups, err = getGroups(config, d, cache)
1✔
156

1✔
157
        if err != nil {
2✔
158
                return c.Context{}, err
1✔
159
        }
1✔
160

161
        reference, err = getReference(config)
1✔
162

1✔
163
        if err != nil {
1✔
NEW
164
                return c.Context{}, err
×
UNCOV
165
        }
×
166

167
        context := c.Context{
1✔
168
                Reference: reference,
1✔
169
                Config:    config,
1✔
170
                Groups:    groups,
1✔
171
                Logger:    logger,
1✔
172
                Cache:     cache,
1✔
173
        }
1✔
174

1✔
175
        return context, err
1✔
176
}
177

178
func readConfig(fs afero.Fs, configPathOption string) (c.Config, error) {
1✔
179
        var config c.Config
1✔
180
        configPath, err := getConfigPath(fs, configPathOption)
1✔
181

1✔
182
        if err != nil {
2✔
183
                return config, nil //nolint:nilerr
1✔
184
        }
1✔
185
        handle, err := fs.Open(configPath)
1✔
186

1✔
187
        if err != nil {
2✔
188
                return config, fmt.Errorf("invalid config: %w", err)
1✔
189
        }
1✔
190

191
        defer handle.Close()
1✔
192
        err = yaml.NewDecoder(handle).Decode(&config)
1✔
193

1✔
194
        if err != nil {
2✔
195
                return config, fmt.Errorf("invalid config: %w", err)
1✔
196
        }
1✔
197

198
        return config, nil
1✔
199
}
200

201
func getReference(config c.Config) (c.Reference, error) {
1✔
202
        styles := util.GetColorScheme(config.ColorScheme)
1✔
203

1✔
204
        return c.Reference{
1✔
205
                Styles: styles,
1✔
206
        }, nil
1✔
207
}
1✔
208

209
func GetConfig(dep c.Dependencies, configPath string, options Options) (c.Config, error) {
1✔
210

1✔
211
        config, err := readConfig(dep.Fs, configPath)
1✔
212

1✔
213
        if err != nil {
2✔
214
                return c.Config{}, err
1✔
215
        }
1✔
216

217
        if len(options.Watchlist) != 0 {
2✔
218
                config.Watchlist = strings.Split(strings.ReplaceAll(options.Watchlist, " ", ""), ",")
1✔
219
        }
1✔
220

221
        config.RefreshInterval = getRefreshInterval(options.RefreshInterval, config.RefreshInterval)
1✔
222
        config.Separate = getBoolOption(options.Separate, config.Separate)
1✔
223
        config.ExtraInfoExchange = getBoolOption(options.ExtraInfoExchange, config.ExtraInfoExchange)
1✔
224
        config.ExtraInfoFundamentals = getBoolOption(options.ExtraInfoFundamentals, config.ExtraInfoFundamentals)
1✔
225
        config.ShowSummary = getBoolOption(options.ShowSummary, config.ShowSummary)
1✔
226
        // Merge ShowHoldings into ShowPositions with positions taking precedence
1✔
227
        // First check if Positions is set (CLI or config), then fall back to Holdings if not
1✔
228
        showPositionsFromCLI := options.ShowPositions
1✔
229
        showPositionsFromConfig := config.ShowPositions
1✔
230
        showHoldingsFromCLI := options.ShowHoldings
1✔
231
        showHoldingsFromConfig := config.ShowHoldings
1✔
232

1✔
233
        // Positions takes precedence: use it if set in CLI or config
1✔
234
        if showPositionsFromCLI || showPositionsFromConfig {
1✔
235
                config.ShowPositions = true
×
236
        } else {
1✔
237
                // Otherwise, fall back to Holdings
1✔
238
                config.ShowPositions = showHoldingsFromCLI || showHoldingsFromConfig
1✔
239
        }
1✔
240
        config.Sort = getStringOption(options.Sort, config.Sort)
1✔
241
        config.Cache = getCacheOption(options.NoCache, config.Cache)
1✔
242
        config.Debug = getBoolOption(options.Debug, config.Debug)
1✔
243

1✔
244
        return config, nil
1✔
245
}
246

247
// cacheEnabled reports whether the cache is enabled, defaulting to on when the
248
// config value is unset (nil).
249
func cacheEnabled(configValue *bool) bool {
1✔
250
        return configValue == nil || *configValue
1✔
251
}
1✔
252

253
// getCacheOption resolves whether the cache is enabled. The cache defaults to on
254
// and is disabled only by an explicit `cache: false` in config or the
255
// --no-cache flag, with the flag taking precedence.
256
func getCacheOption(noCacheFlag bool, configValue *bool) *bool {
1✔
257
        enabled := true
1✔
258

1✔
259
        if configValue != nil {
1✔
NEW
260
                enabled = *configValue
×
NEW
261
        }
×
262

263
        if noCacheFlag {
1✔
NEW
264
                enabled = false
×
NEW
265
        }
×
266

267
        return &enabled
1✔
268
}
269

270
func getConfigPath(fs afero.Fs, configPathOption string) (string, error) {
1✔
271
        var err error
1✔
272
        if configPathOption != "" {
2✔
273
                return configPathOption, nil
1✔
274
        }
1✔
275

276
        home, _ := homedir.Dir()
1✔
277

1✔
278
        v := viper.New()
1✔
279
        v.SetFs(fs)
1✔
280
        v.SetConfigType("yaml")
1✔
281
        v.AddConfigPath(home)
1✔
282
        v.AddConfigPath(".")
1✔
283
        v.AddConfigPath(xdg.ConfigHome)
1✔
284
        v.AddConfigPath(xdg.ConfigHome + "/ticker")
1✔
285
        v.SetConfigName(".ticker")
1✔
286
        err = v.ReadInConfig()
1✔
287

1✔
288
        if err != nil {
2✔
289
                return "", fmt.Errorf("invalid config: %w", err)
1✔
290
        }
1✔
291

292
        return v.ConfigFileUsed(), nil
1✔
293
}
294

295
func getRefreshInterval(optionsRefreshInterval int, configRefreshInterval int) int {
1✔
296

1✔
297
        if optionsRefreshInterval > 0 {
2✔
298
                return optionsRefreshInterval
1✔
299
        }
1✔
300

301
        if configRefreshInterval > 0 {
2✔
302
                return configRefreshInterval
1✔
303
        }
1✔
304

305
        return 5
1✔
306
}
307

308
func getBoolOption(cliValue bool, configValue bool) bool {
1✔
309

1✔
310
        if cliValue {
2✔
311
                return cliValue
1✔
312
        }
1✔
313

314
        if configValue {
2✔
315
                return configValue
1✔
316
        }
1✔
317

318
        return false
1✔
319
}
320

321
func getStringOption(cliValue string, configValue string) string {
1✔
322

1✔
323
        if cliValue != "" {
1✔
324
                return cliValue
×
325
        }
×
326

327
        if configValue != "" {
1✔
328
                return configValue
×
329
        }
×
330

331
        return ""
1✔
332
}
333

334
func getGroups(config c.Config, d c.Dependencies, cache c.Cache) ([]c.AssetGroup, error) {
1✔
335

1✔
336
        groups := make([]c.AssetGroup, 0)
1✔
337
        var configAssetGroups []c.ConfigAssetGroup
1✔
338

1✔
339
        tickerSymbolToSourceSymbol, err := symbol.GetTickerSymbols(d.SymbolsURL, cache)
1✔
340

1✔
341
        if err != nil {
2✔
342
                return []c.AssetGroup{}, err
1✔
343
        }
1✔
344

345
        if len(config.Watchlist) > 0 || len(config.Lots) > 0 {
2✔
346
                configAssetGroups = append(configAssetGroups, c.ConfigAssetGroup{
1✔
347
                        Name:      "default",
1✔
348
                        Watchlist: config.Watchlist,
1✔
349
                        Lots:      config.Lots,
1✔
350
                })
1✔
351
        }
1✔
352

353
        configAssetGroups = append(configAssetGroups, config.AssetGroup...)
1✔
354

1✔
355
        for _, configAssetGroup := range configAssetGroups {
2✔
356

1✔
357
                symbols := make(map[string]bool)
1✔
358
                symbolsUnique := make(map[c.QuoteSource]c.AssetGroupSymbolsBySource)
1✔
359
                var assetGroupSymbolsBySource []c.AssetGroupSymbolsBySource
1✔
360

1✔
361
                for _, symbol := range configAssetGroup.Watchlist {
2✔
362
                        if !symbols[symbol] {
2✔
363
                                symbols[symbol] = true
1✔
364
                                symbolAndSource := getSymbolAndSource(symbol, tickerSymbolToSourceSymbol)
1✔
365
                                symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource)
1✔
366
                        }
1✔
367
                }
368

369
                lots := configAssetGroup.Lots
1✔
370
                mergedConfigAssetGroup := configAssetGroup
1✔
371
                if len(lots) == 0 {
2✔
372
                        lots = configAssetGroup.Holdings
1✔
373
                        mergedConfigAssetGroup.Lots = lots
1✔
374
                }
1✔
375

376
                for _, lot := range lots {
2✔
377
                        if !symbols[lot.Symbol] {
2✔
378
                                symbols[lot.Symbol] = true
1✔
379
                                symbolAndSource := getSymbolAndSource(lot.Symbol, tickerSymbolToSourceSymbol)
1✔
380
                                symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource)
1✔
381
                        }
1✔
382
                }
383

384
                for _, symbolsBySource := range symbolsUnique {
2✔
385
                        assetGroupSymbolsBySource = append(assetGroupSymbolsBySource, symbolsBySource)
1✔
386
                }
1✔
387

388
                groups = append(groups, c.AssetGroup{
1✔
389
                        ConfigAssetGroup: mergedConfigAssetGroup,
1✔
390
                        SymbolsBySource:  assetGroupSymbolsBySource,
1✔
391
                })
1✔
392

393
        }
394

395
        return groups, nil
1✔
396

397
}
398

399
func getLogger(d c.Dependencies) (*log.Logger, error) {
1✔
400
        // Create log file with current date
1✔
401
        currentTime := time.Now()
1✔
402
        logFileName := fmt.Sprintf("ticker-log-%s.log", currentTime.Format("2006-01-02"))
1✔
403
        logFile, err := d.Fs.OpenFile(logFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
1✔
404
        if err != nil {
2✔
405
                return nil, fmt.Errorf("failed to create log file: %w", err)
1✔
406
        }
1✔
407

408
        return log.New(logFile, "", log.LstdFlags), nil
×
409
}
410

411
func getSymbolAndSource(symbol string, tickerSymbolToSourceSymbol symbol.TickerSymbolToSourceSymbol) symbolSource {
1✔
412

1✔
413
        symbolUppercase := strings.ToUpper(symbol)
1✔
414

1✔
415
        if strings.HasSuffix(symbolUppercase, ".CB") {
2✔
416

1✔
417
                symbol = strings.ToUpper(symbol)[:len(symbol)-3]
1✔
418

1✔
419
                // Futures contracts on Coinbase Derivatives Exchange are implicitly USD-denominated
1✔
420
                if strings.HasSuffix(symbol, "-CDE") {
2✔
421
                        return symbolSource{
1✔
422
                                source: c.QuoteSourceCoinbase,
1✔
423
                                symbol: symbol,
1✔
424
                        }
1✔
425
                }
1✔
426

427
                return symbolSource{
1✔
428
                        source: c.QuoteSourceCoinbase,
1✔
429
                        symbol: symbol + "-USD",
1✔
430
                }
1✔
431
        }
432

433
        if strings.HasSuffix(symbolUppercase, ".X") {
2✔
434

1✔
435
                if tickerSymbolToSource, exists := tickerSymbolToSourceSymbol[symbolUppercase]; exists {
2✔
436

1✔
437
                        return symbolSource{
1✔
438
                                source: tickerSymbolToSource.Source,
1✔
439
                                symbol: tickerSymbolToSource.SourceSymbol,
1✔
440
                        }
1✔
441

1✔
442
                }
1✔
443

444
        }
445

446
        return symbolSource{
1✔
447
                source: c.QuoteSourceYahoo,
1✔
448
                symbol: symbolUppercase,
1✔
449
        }
1✔
450

451
}
452

453
func appendSymbol(symbolsUnique map[c.QuoteSource]c.AssetGroupSymbolsBySource, symbolAndSource symbolSource) map[c.QuoteSource]c.AssetGroupSymbolsBySource {
1✔
454

1✔
455
        if symbolsBySource, ok := symbolsUnique[symbolAndSource.source]; ok {
2✔
456

1✔
457
                symbolsBySource.Symbols = append(symbolsBySource.Symbols, symbolAndSource.symbol)
1✔
458

1✔
459
                symbolsUnique[symbolAndSource.source] = symbolsBySource
1✔
460

1✔
461
                return symbolsUnique
1✔
462
        }
1✔
463

464
        symbolsUnique[symbolAndSource.source] = c.AssetGroupSymbolsBySource{
1✔
465
                Source:  symbolAndSource.source,
1✔
466
                Symbols: []string{symbolAndSource.symbol},
1✔
467
        }
1✔
468

1✔
469
        return symbolsUnique
1✔
470

471
}
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