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

achannarasappa / ticker / 28332786797

28 Jun 2026 07:03PM UTC coverage: 88.28% (+0.03%) from 88.247%
28332786797

push

github

achannarasappa
feat: add opt-in reference data cache shared between instances

Add a file-based, inter-process cache for the reference data fetched once
at startup so multiple ticker instances on the same machine can share it,
speeding up startup and reducing provider request throttling (#369).

Enabled with --cache or `cache: true`. A single JSON file at
${XDG_CACHE_HOME}/ticker/reference-cache.json (0600) caches the symbol
source map, Yahoo session (cookies + crumb), per-symbol currency, currency
conversion rates, and Coinbase futures underlying mappings, each reused for
up to one hour. Live price quotes are never cached. A stale Yahoo session
is detected and re-established automatically.

174 of 192 new or added lines in 6 files covered. (90.63%)

3 existing lines in 2 files now uncovered.

3239 of 3669 relevant lines covered (88.28%)

0.98 hits per line

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

97.48
/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/referencecache"
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
        Cache                 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 := referencecache.New(d.Fs, referencecache.FilePath(), config.Cache, referencecache.DefaultTTL)
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 = getBoolOption(options.Cache, config.Cache)
1✔
242
        config.Debug = getBoolOption(options.Debug, config.Debug)
1✔
243

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

247
func getConfigPath(fs afero.Fs, configPathOption string) (string, error) {
1✔
248
        var err error
1✔
249
        if configPathOption != "" {
2✔
250
                return configPathOption, nil
1✔
251
        }
1✔
252

253
        home, _ := homedir.Dir()
1✔
254

1✔
255
        v := viper.New()
1✔
256
        v.SetFs(fs)
1✔
257
        v.SetConfigType("yaml")
1✔
258
        v.AddConfigPath(home)
1✔
259
        v.AddConfigPath(".")
1✔
260
        v.AddConfigPath(xdg.ConfigHome)
1✔
261
        v.AddConfigPath(xdg.ConfigHome + "/ticker")
1✔
262
        v.SetConfigName(".ticker")
1✔
263
        err = v.ReadInConfig()
1✔
264

1✔
265
        if err != nil {
2✔
266
                return "", fmt.Errorf("invalid config: %w", err)
1✔
267
        }
1✔
268

269
        return v.ConfigFileUsed(), nil
1✔
270
}
271

272
func getRefreshInterval(optionsRefreshInterval int, configRefreshInterval int) int {
1✔
273

1✔
274
        if optionsRefreshInterval > 0 {
2✔
275
                return optionsRefreshInterval
1✔
276
        }
1✔
277

278
        if configRefreshInterval > 0 {
2✔
279
                return configRefreshInterval
1✔
280
        }
1✔
281

282
        return 5
1✔
283
}
284

285
func getBoolOption(cliValue bool, configValue bool) bool {
1✔
286

1✔
287
        if cliValue {
2✔
288
                return cliValue
1✔
289
        }
1✔
290

291
        if configValue {
2✔
292
                return configValue
1✔
293
        }
1✔
294

295
        return false
1✔
296
}
297

298
func getStringOption(cliValue string, configValue string) string {
1✔
299

1✔
300
        if cliValue != "" {
1✔
301
                return cliValue
×
302
        }
×
303

304
        if configValue != "" {
1✔
305
                return configValue
×
306
        }
×
307

308
        return ""
1✔
309
}
310

311
func getGroups(config c.Config, d c.Dependencies, cache c.Cacher) ([]c.AssetGroup, error) {
1✔
312

1✔
313
        groups := make([]c.AssetGroup, 0)
1✔
314
        var configAssetGroups []c.ConfigAssetGroup
1✔
315

1✔
316
        tickerSymbolToSourceSymbol, err := symbol.GetTickerSymbols(d.SymbolsURL, cache)
1✔
317

1✔
318
        if err != nil {
2✔
319
                return []c.AssetGroup{}, err
1✔
320
        }
1✔
321

322
        if len(config.Watchlist) > 0 || len(config.Lots) > 0 {
2✔
323
                configAssetGroups = append(configAssetGroups, c.ConfigAssetGroup{
1✔
324
                        Name:      "default",
1✔
325
                        Watchlist: config.Watchlist,
1✔
326
                        Lots:      config.Lots,
1✔
327
                })
1✔
328
        }
1✔
329

330
        configAssetGroups = append(configAssetGroups, config.AssetGroup...)
1✔
331

1✔
332
        for _, configAssetGroup := range configAssetGroups {
2✔
333

1✔
334
                symbols := make(map[string]bool)
1✔
335
                symbolsUnique := make(map[c.QuoteSource]c.AssetGroupSymbolsBySource)
1✔
336
                var assetGroupSymbolsBySource []c.AssetGroupSymbolsBySource
1✔
337

1✔
338
                for _, symbol := range configAssetGroup.Watchlist {
2✔
339
                        if !symbols[symbol] {
2✔
340
                                symbols[symbol] = true
1✔
341
                                symbolAndSource := getSymbolAndSource(symbol, tickerSymbolToSourceSymbol)
1✔
342
                                symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource)
1✔
343
                        }
1✔
344
                }
345

346
                lots := configAssetGroup.Lots
1✔
347
                mergedConfigAssetGroup := configAssetGroup
1✔
348
                if len(lots) == 0 {
2✔
349
                        lots = configAssetGroup.Holdings
1✔
350
                        mergedConfigAssetGroup.Lots = lots
1✔
351
                }
1✔
352

353
                for _, lot := range lots {
2✔
354
                        if !symbols[lot.Symbol] {
2✔
355
                                symbols[lot.Symbol] = true
1✔
356
                                symbolAndSource := getSymbolAndSource(lot.Symbol, tickerSymbolToSourceSymbol)
1✔
357
                                symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource)
1✔
358
                        }
1✔
359
                }
360

361
                for _, symbolsBySource := range symbolsUnique {
2✔
362
                        assetGroupSymbolsBySource = append(assetGroupSymbolsBySource, symbolsBySource)
1✔
363
                }
1✔
364

365
                groups = append(groups, c.AssetGroup{
1✔
366
                        ConfigAssetGroup: mergedConfigAssetGroup,
1✔
367
                        SymbolsBySource:  assetGroupSymbolsBySource,
1✔
368
                })
1✔
369

370
        }
371

372
        return groups, nil
1✔
373

374
}
375

376
func getLogger(d c.Dependencies) (*log.Logger, error) {
1✔
377
        // Create log file with current date
1✔
378
        currentTime := time.Now()
1✔
379
        logFileName := fmt.Sprintf("ticker-log-%s.log", currentTime.Format("2006-01-02"))
1✔
380
        logFile, err := d.Fs.OpenFile(logFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
1✔
381
        if err != nil {
2✔
382
                return nil, fmt.Errorf("failed to create log file: %w", err)
1✔
383
        }
1✔
384

385
        return log.New(logFile, "", log.LstdFlags), nil
×
386
}
387

388
func getSymbolAndSource(symbol string, tickerSymbolToSourceSymbol symbol.TickerSymbolToSourceSymbol) symbolSource {
1✔
389

1✔
390
        symbolUppercase := strings.ToUpper(symbol)
1✔
391

1✔
392
        if strings.HasSuffix(symbolUppercase, ".CB") {
2✔
393

1✔
394
                symbol = strings.ToUpper(symbol)[:len(symbol)-3]
1✔
395

1✔
396
                // Futures contracts on Coinbase Derivatives Exchange are implicitly USD-denominated
1✔
397
                if strings.HasSuffix(symbol, "-CDE") {
2✔
398
                        return symbolSource{
1✔
399
                                source: c.QuoteSourceCoinbase,
1✔
400
                                symbol: symbol,
1✔
401
                        }
1✔
402
                }
1✔
403

404
                return symbolSource{
1✔
405
                        source: c.QuoteSourceCoinbase,
1✔
406
                        symbol: symbol + "-USD",
1✔
407
                }
1✔
408
        }
409

410
        if strings.HasSuffix(symbolUppercase, ".X") {
2✔
411

1✔
412
                if tickerSymbolToSource, exists := tickerSymbolToSourceSymbol[symbolUppercase]; exists {
2✔
413

1✔
414
                        return symbolSource{
1✔
415
                                source: tickerSymbolToSource.Source,
1✔
416
                                symbol: tickerSymbolToSource.SourceSymbol,
1✔
417
                        }
1✔
418

1✔
419
                }
1✔
420

421
        }
422

423
        return symbolSource{
1✔
424
                source: c.QuoteSourceYahoo,
1✔
425
                symbol: symbolUppercase,
1✔
426
        }
1✔
427

428
}
429

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

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

1✔
434
                symbolsBySource.Symbols = append(symbolsBySource.Symbols, symbolAndSource.symbol)
1✔
435

1✔
436
                symbolsUnique[symbolAndSource.source] = symbolsBySource
1✔
437

1✔
438
                return symbolsUnique
1✔
439
        }
1✔
440

441
        symbolsUnique[symbolAndSource.source] = c.AssetGroupSymbolsBySource{
1✔
442
                Source:  symbolAndSource.source,
1✔
443
                Symbols: []string{symbolAndSource.symbol},
1✔
444
        }
1✔
445

1✔
446
        return symbolsUnique
1✔
447

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