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

achannarasappa / ticker / 20557567248

28 Dec 2025 06:04PM UTC coverage: 88.126% (+0.2%) from 87.916%
20557567248

push

github

achannarasappa
refactor: replace references to holdings with positions to represent not fully owned or spot instruments

84 of 95 new or added lines in 7 files covered. (88.42%)

1 existing line in 1 file now uncovered.

2954 of 3352 relevant lines covered (88.13%)

8.92 hits per line

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

97.4
/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 // Deprecated: use ShowPositions instead, kept for backwards compatibility
32
        ShowPositions         bool // Preferred field name
33
        Sort                  string
34
}
35

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

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

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

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

58
        if lot.Quantity == 0 {
14✔
59
                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
4✔
60
        }
4✔
61

62
        if lot.UnitCost < 0 {
7✔
63
                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✔
64
        }
1✔
65

66
        if lot.FixedCost < 0 {
6✔
67
                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✔
68
        }
1✔
69

70
        return nil
4✔
71
}
72

73
// Validate checks whether config is valid and returns an error if invalid or if an error was generated earlier
74
func Validate(config *c.Config, options *Options, prevErr *error) func(*cobra.Command, []string) error {
14✔
75
        return func(_ *cobra.Command, _ []string) error {
28✔
76

14✔
77
                if prevErr != nil && *prevErr != nil {
15✔
78
                        return *prevErr
1✔
79
                }
1✔
80

81
                if len(config.Watchlist) == 0 && len(options.Watchlist) == 0 && len(config.Lots) == 0 && len(config.AssetGroup) == 0 {
14✔
82
                        return errors.New("invalid config: No watchlist provided") //nolint:goerr113
1✔
83
                }
1✔
84

85
                // Validate lots in config.Lots (default group)
86
                for i, lot := range config.Lots {
21✔
87
                        if err := validateLot(lot, "default", i); err != nil {
14✔
88
                                return err
5✔
89
                        }
5✔
90
                }
91

92
                // Validate lots in config.AssetGroup
93
                for _, assetGroup := range config.AssetGroup {
9✔
94
                        groupName := assetGroup.Name
2✔
95
                        if groupName == "" {
3✔
96
                                groupName = "unnamed"
1✔
97
                        }
1✔
98
                        // Use Lots (preferred), otherwise fall back to Holdings for backwards compatibility
99
                        lots := assetGroup.Lots
2✔
100
                        if len(lots) == 0 {
4✔
101
                                lots = assetGroup.Holdings
2✔
102
                        }
2✔
103
                        for i, lot := range lots {
4✔
104
                                if err := validateLot(lot, groupName, i); err != nil {
4✔
105
                                        return err
2✔
106
                                }
2✔
107
                        }
108
                }
109

110
                return nil
5✔
111
        }
112
}
113

114
func GetDependencies() c.Dependencies {
1✔
115
        return c.Dependencies{
1✔
116
                Fs:                               afero.NewOsFs(),
1✔
117
                SymbolsURL:                       "https://raw.githubusercontent.com/achannarasappa/ticker-static/master/symbols.csv",
1✔
118
                MonitorYahooBaseURL:              "https://query1.finance.yahoo.com",
1✔
119
                MonitorYahooSessionRootURL:       "https://finance.yahoo.com",
1✔
120
                MonitorYahooSessionCrumbURL:      "https://query2.finance.yahoo.com",
1✔
121
                MonitorYahooSessionConsentURL:    "https://consent.yahoo.com",
1✔
122
                MonitorPriceCoinbaseBaseURL:      "https://api.coinbase.com",
1✔
123
                MonitorPriceCoinbaseStreamingURL: "wss://ws-feed.exchange.coinbase.com",
1✔
124
        }
1✔
125
}
1✔
126

127
// GetContext builds the context from the config and reference data
128
func GetContext(d c.Dependencies, config c.Config) (c.Context, error) {
9✔
129
        var (
9✔
130
                reference c.Reference
9✔
131
                groups    []c.AssetGroup
9✔
132
                err       error
9✔
133
        )
9✔
134

9✔
135
        groups, err = getGroups(config, d)
9✔
136

9✔
137
        if err != nil {
10✔
138
                return c.Context{}, err
1✔
139
        }
1✔
140

141
        reference, err = getReference(config)
8✔
142

8✔
143
        if err != nil {
8✔
144
                return c.Context{}, err
×
145
        }
×
146

147
        var logger *log.Logger
8✔
148

8✔
149
        if config.Debug {
9✔
150
                logger, err = getLogger(d)
1✔
151

1✔
152
                if err != nil {
2✔
153
                        return c.Context{}, err
1✔
154
                }
1✔
155
        }
156

157
        context := c.Context{
7✔
158
                Reference: reference,
7✔
159
                Config:    config,
7✔
160
                Groups:    groups,
7✔
161
                Logger:    logger,
7✔
162
        }
7✔
163

7✔
164
        return context, err
7✔
165
}
166

167
func readConfig(fs afero.Fs, configPathOption string) (c.Config, error) {
21✔
168
        var config c.Config
21✔
169
        configPath, err := getConfigPath(fs, configPathOption)
21✔
170

21✔
171
        if err != nil {
26✔
172
                return config, nil //nolint:nilerr
5✔
173
        }
5✔
174
        handle, err := fs.Open(configPath)
16✔
175

16✔
176
        if err != nil {
17✔
177
                return config, fmt.Errorf("invalid config: %w", err)
1✔
178
        }
1✔
179

180
        defer handle.Close()
15✔
181
        err = yaml.NewDecoder(handle).Decode(&config)
15✔
182

15✔
183
        if err != nil {
16✔
184
                return config, fmt.Errorf("invalid config: %w", err)
1✔
185
        }
1✔
186

187
        return config, nil
14✔
188
}
189

190
func getReference(config c.Config) (c.Reference, error) {
8✔
191
        styles := util.GetColorScheme(config.ColorScheme)
8✔
192

8✔
193
        return c.Reference{
8✔
194
                Styles: styles,
8✔
195
        }, nil
8✔
196
}
8✔
197

198
func GetConfig(dep c.Dependencies, configPath string, options Options) (c.Config, error) {
21✔
199

21✔
200
        config, err := readConfig(dep.Fs, configPath)
21✔
201

21✔
202
        if err != nil {
23✔
203
                return c.Config{}, err
2✔
204
        }
2✔
205

206
        if len(options.Watchlist) != 0 {
21✔
207
                config.Watchlist = strings.Split(strings.ReplaceAll(options.Watchlist, " ", ""), ",")
2✔
208
        }
2✔
209

210
        config.RefreshInterval = getRefreshInterval(options.RefreshInterval, config.RefreshInterval)
19✔
211
        config.Separate = getBoolOption(options.Separate, config.Separate)
19✔
212
        config.ExtraInfoExchange = getBoolOption(options.ExtraInfoExchange, config.ExtraInfoExchange)
19✔
213
        config.ExtraInfoFundamentals = getBoolOption(options.ExtraInfoFundamentals, config.ExtraInfoFundamentals)
19✔
214
        config.ShowSummary = getBoolOption(options.ShowSummary, config.ShowSummary)
19✔
215
        // Merge ShowHoldings into ShowPositions with positions taking precedence
19✔
216
        // First check if Positions is set (CLI or config), then fall back to Holdings if not
19✔
217
        showPositionsFromCLI := options.ShowPositions
19✔
218
        showPositionsFromConfig := config.ShowPositions
19✔
219
        showHoldingsFromCLI := options.ShowHoldings
19✔
220
        showHoldingsFromConfig := config.ShowHoldings
19✔
221

19✔
222
        // Positions takes precedence: use it if set in CLI or config
19✔
223
        if showPositionsFromCLI || showPositionsFromConfig {
19✔
NEW
224
                config.ShowPositions = true
×
225
        } else {
19✔
226
                // Otherwise, fall back to Holdings
19✔
227
                config.ShowPositions = showHoldingsFromCLI || showHoldingsFromConfig
19✔
228
        }
19✔
229
        config.Sort = getStringOption(options.Sort, config.Sort)
19✔
230

19✔
231
        return config, nil
19✔
232
}
233

234
func getConfigPath(fs afero.Fs, configPathOption string) (string, error) {
21✔
235
        var err error
21✔
236
        if configPathOption != "" {
24✔
237
                return configPathOption, nil
3✔
238
        }
3✔
239

240
        home, _ := homedir.Dir()
18✔
241

18✔
242
        v := viper.New()
18✔
243
        v.SetFs(fs)
18✔
244
        v.SetConfigType("yaml")
18✔
245
        v.AddConfigPath(home)
18✔
246
        v.AddConfigPath(".")
18✔
247
        v.AddConfigPath(xdg.ConfigHome)
18✔
248
        v.AddConfigPath(xdg.ConfigHome + "/ticker")
18✔
249
        v.SetConfigName(".ticker")
18✔
250
        err = v.ReadInConfig()
18✔
251

18✔
252
        if err != nil {
23✔
253
                return "", fmt.Errorf("invalid config: %w", err)
5✔
254
        }
5✔
255

256
        return v.ConfigFileUsed(), nil
13✔
257
}
258

259
func getRefreshInterval(optionsRefreshInterval int, configRefreshInterval int) int {
19✔
260

19✔
261
        if optionsRefreshInterval > 0 {
21✔
262
                return optionsRefreshInterval
2✔
263
        }
2✔
264

265
        if configRefreshInterval > 0 {
18✔
266
                return configRefreshInterval
1✔
267
        }
1✔
268

269
        return 5
16✔
270
}
271

272
func getBoolOption(cliValue bool, configValue bool) bool {
76✔
273

76✔
274
        if cliValue {
77✔
275
                return cliValue
1✔
276
        }
1✔
277

278
        if configValue {
77✔
279
                return configValue
2✔
280
        }
2✔
281

282
        return false
73✔
283
}
284

285
func getStringOption(cliValue string, configValue string) string {
19✔
286

19✔
287
        if cliValue != "" {
19✔
288
                return cliValue
×
289
        }
×
290

291
        if configValue != "" {
19✔
292
                return configValue
×
293
        }
×
294

295
        return ""
19✔
296
}
297

298
func getGroups(config c.Config, d c.Dependencies) ([]c.AssetGroup, error) {
9✔
299

9✔
300
        groups := make([]c.AssetGroup, 0)
9✔
301
        var configAssetGroups []c.ConfigAssetGroup
9✔
302

9✔
303
        tickerSymbolToSourceSymbol, err := symbol.GetTickerSymbols(d.SymbolsURL)
9✔
304

9✔
305
        if err != nil {
10✔
306
                return []c.AssetGroup{}, err
1✔
307
        }
1✔
308

309
        if len(config.Watchlist) > 0 || len(config.Lots) > 0 {
13✔
310
                configAssetGroups = append(configAssetGroups, c.ConfigAssetGroup{
5✔
311
                        Name:      "default",
5✔
312
                        Watchlist: config.Watchlist,
5✔
313
                        Lots:      config.Lots,
5✔
314
                })
5✔
315
        }
5✔
316

317
        configAssetGroups = append(configAssetGroups, config.AssetGroup...)
8✔
318

8✔
319
        for _, configAssetGroup := range configAssetGroups {
16✔
320

8✔
321
                symbols := make(map[string]bool)
8✔
322
                symbolsUnique := make(map[c.QuoteSource]c.AssetGroupSymbolsBySource)
8✔
323
                var assetGroupSymbolsBySource []c.AssetGroupSymbolsBySource
8✔
324

8✔
325
                for _, symbol := range configAssetGroup.Watchlist {
23✔
326
                        if !symbols[symbol] {
30✔
327
                                symbols[symbol] = true
15✔
328
                                symbolAndSource := getSymbolAndSource(symbol, tickerSymbolToSourceSymbol)
15✔
329
                                symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource)
15✔
330
                        }
15✔
331
                }
332

333
                lots := configAssetGroup.Lots
8✔
334
                mergedConfigAssetGroup := configAssetGroup
8✔
335
                if len(lots) == 0 {
16✔
336
                        lots = configAssetGroup.Holdings
8✔
337
                        mergedConfigAssetGroup.Lots = lots
8✔
338
                }
8✔
339

340
                for _, lot := range lots {
10✔
341
                        if !symbols[lot.Symbol] {
4✔
342
                                symbols[lot.Symbol] = true
2✔
343
                                symbolAndSource := getSymbolAndSource(lot.Symbol, tickerSymbolToSourceSymbol)
2✔
344
                                symbolsUnique = appendSymbol(symbolsUnique, symbolAndSource)
2✔
345
                        }
2✔
346
                }
347

348
                for _, symbolsBySource := range symbolsUnique {
17✔
349
                        assetGroupSymbolsBySource = append(assetGroupSymbolsBySource, symbolsBySource)
9✔
350
                }
9✔
351

352
                groups = append(groups, c.AssetGroup{
8✔
353
                        ConfigAssetGroup: mergedConfigAssetGroup,
8✔
354
                        SymbolsBySource:  assetGroupSymbolsBySource,
8✔
355
                })
8✔
356

357
        }
358

359
        return groups, nil
8✔
360

361
}
362

363
func getLogger(d c.Dependencies) (*log.Logger, error) {
1✔
364
        // Create log file with current date
1✔
365
        currentTime := time.Now()
1✔
366
        logFileName := fmt.Sprintf("ticker-log-%s.log", currentTime.Format("2006-01-02"))
1✔
367
        logFile, err := d.Fs.OpenFile(logFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
1✔
368
        if err != nil {
2✔
369
                return nil, fmt.Errorf("failed to create log file: %w", err)
1✔
370
        }
1✔
371

372
        return log.New(logFile, "", log.LstdFlags), nil
×
373
}
374

375
func getSymbolAndSource(symbol string, tickerSymbolToSourceSymbol symbol.TickerSymbolToSourceSymbol) symbolSource {
17✔
376

17✔
377
        symbolUppercase := strings.ToUpper(symbol)
17✔
378

17✔
379
        if strings.HasSuffix(symbolUppercase, ".CB") {
19✔
380

2✔
381
                symbol = strings.ToUpper(symbol)[:len(symbol)-3]
2✔
382

2✔
383
                // Futures contracts on Coinbase Derivatives Exchange are implicitly USD-denominated
2✔
384
                if strings.HasSuffix(symbol, "-CDE") {
3✔
385
                        return symbolSource{
1✔
386
                                source: c.QuoteSourceCoinbase,
1✔
387
                                symbol: symbol,
1✔
388
                        }
1✔
389
                }
1✔
390

391
                return symbolSource{
1✔
392
                        source: c.QuoteSourceCoinbase,
1✔
393
                        symbol: symbol + "-USD",
1✔
394
                }
1✔
395
        }
396

397
        if strings.HasSuffix(symbolUppercase, ".X") {
16✔
398

1✔
399
                if tickerSymbolToSource, exists := tickerSymbolToSourceSymbol[symbolUppercase]; exists {
2✔
400

1✔
401
                        return symbolSource{
1✔
402
                                source: tickerSymbolToSource.Source,
1✔
403
                                symbol: tickerSymbolToSource.SourceSymbol,
1✔
404
                        }
1✔
405

1✔
406
                }
1✔
407

408
        }
409

410
        return symbolSource{
14✔
411
                source: c.QuoteSourceYahoo,
14✔
412
                symbol: symbolUppercase,
14✔
413
        }
14✔
414

415
}
416

417
func appendSymbol(symbolsUnique map[c.QuoteSource]c.AssetGroupSymbolsBySource, symbolAndSource symbolSource) map[c.QuoteSource]c.AssetGroupSymbolsBySource {
17✔
418

17✔
419
        if symbolsBySource, ok := symbolsUnique[symbolAndSource.source]; ok {
25✔
420

8✔
421
                symbolsBySource.Symbols = append(symbolsBySource.Symbols, symbolAndSource.symbol)
8✔
422

8✔
423
                symbolsUnique[symbolAndSource.source] = symbolsBySource
8✔
424

8✔
425
                return symbolsUnique
8✔
426
        }
8✔
427

428
        symbolsUnique[symbolAndSource.source] = c.AssetGroupSymbolsBySource{
9✔
429
                Source:  symbolAndSource.source,
9✔
430
                Symbols: []string{symbolAndSource.symbol},
9✔
431
        }
9✔
432

9✔
433
        return symbolsUnique
9✔
434

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