diff --git a/README.md b/README.md index 2f5681d..a8d78eb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Terminal stock & crypto price watcher and position tracker * Track value of your stock positions * Support for multiple cost basis lots * Support for pre and post market price quotes +* Optional Adanos market sentiment overlay for watchlists ## Install @@ -82,10 +83,11 @@ ticker -w NET,AAPL,TSLA |`watchlist` |-w|--watchlist | |comma separated list of symbols to watch| |`show-tags` | |--show-tags | |display currency, exchange name, and quote delay for each quote | |`show-fundamentals`| |--show-fundamentals| |display open price, previous close, and day range | +|`show-sentiment` | |--show-sentiment | |display optional Adanos market sentiment when `ADANOS_API_KEY` or `sentiment-api-key` is configured | |`show-separator` | |--show-separator | |layout with separators between each quote| |`show-summary` | |--show-summary | |show total day change, total value, and total value change| |`show-positions` | |--show-positions | |show positions including weight, average cost, and quantity| -|`sort` | |--sort | |sort quotes on the UI - options are change percent (default), `alpha`, `value`, and `user`| +|`sort` | |--sort | |sort quotes on the UI - options are change percent (default), `alpha`, `value`, `user`, and `sentiment`| |`version` | |--version | |print the current version number| |`debug` | | | |enable debug logging to `./ticker-log-.log`| @@ -98,11 +100,13 @@ Configuration is not required to watch stock price but is helpful when always wa show-summary: true show-tags: true show-fundamentals: true +show-sentiment: true show-separator: true show-positions: true interval: 5 currency: USD currency-summary-only: false +sentiment-api-key: "" # optional, or set ADANOS_API_KEY in the environment watchlist: - NET - TEAM @@ -133,6 +137,7 @@ groups: ``` * All properties in `.ticker.yaml` are optional +* Adanos sentiment is fully optional and remains disabled unless `show-sentiment` is enabled and an API key is configured * Symbols not on the watchlist that exists in `lots` are implicitly added to the watchlist * To add multiple cost basis lots (`quantity`, `unit_cost`) for the same `symbol`, include two or more entries - see `ARKW` example above * `.ticker.yaml` can be set in user home directory, the current directory, or [XDG config home](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) @@ -140,7 +145,7 @@ groups: ### Display Options -With `--show-summary`, `--show-tags`, `--show-fundamentals`, `--show-positions`, and `--show-separator` options set, the layout and information displayed expands: +With `--show-summary`, `--show-tags`, `--show-fundamentals`, `--show-sentiment`, `--show-positions`, and `--show-separator` options set, the layout and information displayed expands: @@ -152,6 +157,7 @@ It's possible to set a custom sort order with the `--sort` flag or `sort:` confi * `alpha` to sort alphabetically by symbol * `value` to sort by position value * `user` to sort by the order defined in configuration with positions on first then watched symbols +* `sentiment` to sort by Adanos average buzz score when sentiment is available ### Groups diff --git a/cmd/root.go b/cmd/root.go index 2d86deb..09e80f8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,10 +64,11 @@ func init() { //nolint: gochecknoinits rootCmd.Flags().BoolVar(&options.Separate, "show-separator", false, "layout with separators between each quote") rootCmd.Flags().BoolVar(&options.ExtraInfoExchange, "show-tags", false, "display currency, exchange name, and quote delay for each quote") rootCmd.Flags().BoolVar(&options.ExtraInfoFundamentals, "show-fundamentals", false, "display open price, high, low, and volume for each quote") + rootCmd.Flags().BoolVar(&options.ShowSentiment, "show-sentiment", false, "display Adanos market sentiment when ADANOS_API_KEY or sentiment-api-key is configured") rootCmd.Flags().BoolVar(&options.ShowSummary, "show-summary", false, "display summary of total gain and loss for positions") rootCmd.Flags().BoolVar(&options.ShowPositions, "show-positions", false, "display average unit cost, quantity, portfolio weight") rootCmd.Flags().BoolVar(&options.ShowHoldings, "show-holdings", false, "display average unit cost, quantity, portfolio weight (deprecated: use --show-positions)") - rootCmd.Flags().StringVar(&options.Sort, "sort", "", "sort quotes on the UI. Set \"alpha\" to sort by ticker name. Set \"value\" to sort by position value. Keep empty to sort according to change percent") + rootCmd.Flags().StringVar(&options.Sort, "sort", "", "sort quotes on the UI. Set \"alpha\" to sort by ticker name. Set \"value\" to sort by position value. Set \"sentiment\" to sort by Adanos buzz. Keep empty to sort according to change percent") printCmd.PersistentFlags().StringVar(&optionsPrint.Format, "format", "", "output format for printing holdings. Set \"csv\" to print as a CSV or \"json\" for JSON. Defaults to JSON.") printCmd.PersistentFlags().StringVar(&configPath, "config", "", "config file (default is $HOME/.ticker.yaml)") diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 153ece1..16ed1d2 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -27,6 +27,7 @@ type Options struct { Separate bool ExtraInfoExchange bool ExtraInfoFundamentals bool + ShowSentiment bool ShowSummary bool ShowHoldings bool // Deprecated: use ShowPositions instead, kept for backwards compatibility ShowPositions bool // Preferred field name @@ -121,6 +122,7 @@ func GetDependencies() c.Dependencies { MonitorYahooSessionConsentURL: "https://consent.yahoo.com", MonitorPriceCoinbaseBaseURL: "https://api.coinbase.com", MonitorPriceCoinbaseStreamingURL: "wss://ws-feed.exchange.coinbase.com", + SentimentAdanosBaseURL: "https://api.adanos.org", } } @@ -211,6 +213,7 @@ func GetConfig(dep c.Dependencies, configPath string, options Options) (c.Config config.Separate = getBoolOption(options.Separate, config.Separate) config.ExtraInfoExchange = getBoolOption(options.ExtraInfoExchange, config.ExtraInfoExchange) config.ExtraInfoFundamentals = getBoolOption(options.ExtraInfoFundamentals, config.ExtraInfoFundamentals) + config.ShowSentiment = getBoolOption(options.ShowSentiment, config.ShowSentiment) config.ShowSummary = getBoolOption(options.ShowSummary, config.ShowSummary) // Merge ShowHoldings into ShowPositions with positions taking precedence // First check if Positions is set (CLI or config), then fall back to Holdings if not @@ -227,6 +230,9 @@ func GetConfig(dep c.Dependencies, configPath string, options Options) (c.Config config.ShowPositions = showHoldingsFromCLI || showHoldingsFromConfig } config.Sort = getStringOption(options.Sort, config.Sort) + if config.SentimentAPIKey == "" { + config.SentimentAPIKey = strings.TrimSpace(os.Getenv("ADANOS_API_KEY")) + } return config, nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ffe560c..15bc2f6 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -456,6 +456,24 @@ var _ = Describe("Cli", func() { }), }), + Entry("when show-sentiment is set in config file", Case{ + InputOptions: cli.Options{}, + InputConfigFileContents: "show-sentiment: true", + AssertionErr: BeNil(), + AssertionConfig: g.MatchFields(g.IgnoreExtras, g.Fields{ + "ShowSentiment": Equal(true), + }), + }), + + Entry("when show-sentiment is set in options", Case{ + InputOptions: cli.Options{ShowSentiment: true}, + InputConfigFileContents: "", + AssertionErr: BeNil(), + AssertionConfig: g.MatchFields(g.IgnoreExtras, g.Fields{ + "ShowSentiment": Equal(true), + }), + }), + // option: debug Entry("when debug is set in config file", Case{ InputOptions: cli.Options{}, @@ -467,6 +485,17 @@ var _ = Describe("Cli", func() { }), ) + It("should use ADANOS_API_KEY when sentiment-api-key is not set", func() { + Expect(os.Setenv("ADANOS_API_KEY", "sk_test_adanos")).To(Succeed()) + DeferCleanup(func() { + Expect(os.Unsetenv("ADANOS_API_KEY")).To(Succeed()) + }) + + outputConfig, outputErr := cli.GetConfig(dep, "", cli.Options{}) + Expect(outputErr).To(BeNil()) + Expect(outputConfig.SentimentAPIKey).To(Equal("sk_test_adanos")) + }) + }) //nolint:errcheck diff --git a/internal/common/common.go b/internal/common/common.go index 9da5641..45385a7 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -22,10 +22,12 @@ type Config struct { Separate bool `yaml:"show-separator"` ExtraInfoExchange bool `yaml:"show-tags"` ExtraInfoFundamentals bool `yaml:"show-fundamentals"` + ShowSentiment bool `yaml:"show-sentiment"` ShowSummary bool `yaml:"show-summary"` ShowHoldings bool `yaml:"show-holdings"` // Deprecated: use ShowPositions instead, kept for backwards compatibility ShowPositions bool `yaml:"show-positions"` // Preferred field name Sort string `yaml:"sort"` + SentimentAPIKey string `yaml:"sentiment-api-key"` Currency string `yaml:"currency"` CurrencyConvertSummaryOnly bool `yaml:"currency-summary-only"` CurrencyDisableUnitCostConversion bool `yaml:"currency-disable-unit-cost-conversion"` @@ -81,6 +83,7 @@ type Dependencies struct { MonitorYahooSessionRootURL string MonitorYahooSessionCrumbURL string MonitorYahooSessionConsentURL string + SentimentAdanosBaseURL string } type Monitor interface { @@ -217,6 +220,15 @@ type Asset struct { QuoteSource QuoteSource Exchange Exchange Meta Meta + Sentiment MarketSentiment +} + +type MarketSentiment struct { + Available bool + AverageBuzz float64 + BullishPercent float64 + Coverage int + SourceAlignment string } type AssetClass int diff --git a/internal/sentiment/adanos/client.go b/internal/sentiment/adanos/client.go new file mode 100644 index 0000000..faf5d26 --- /dev/null +++ b/internal/sentiment/adanos/client.go @@ -0,0 +1,290 @@ +package adanos + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + common "github.com/achannarasappa/ticker/v5/internal/common" +) + +var sourceIDs = []string{"reddit", "x", "news", "polymarket"} //nolint:gochecknoglobals + +type Client struct { + baseURL string + apiKey string + httpClient *http.Client + nowFn func() time.Time + ttl time.Duration + mu sync.RWMutex + cache map[string]cacheEntry +} + +type cacheEntry struct { + snapshot common.MarketSentiment + expiresAt time.Time +} + +type sourceSnapshot struct { + buzzScore float64 + bullishPercent float64 + activityValue float64 + available bool +} + +func NewClient(baseURL, apiKey string, httpClient *http.Client, ttl time.Duration) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 5 * time.Second} + } + if ttl <= 0 { + ttl = 5 * time.Minute + } + + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: strings.TrimSpace(apiKey), + httpClient: httpClient, + nowFn: time.Now, + ttl: ttl, + cache: make(map[string]cacheEntry), + } +} + +func (client *Client) Enabled() bool { + return client != nil && client.apiKey != "" +} + +func (client *Client) FetchSnapshots(ctx context.Context, symbols []string) (map[string]common.MarketSentiment, error) { + results := make(map[string]common.MarketSentiment) + if !client.Enabled() { + return results, nil + } + + uniqueSymbols := normalizeSymbols(symbols) + missing := make([]string, 0, len(uniqueSymbols)) + + client.mu.RLock() + now := client.nowFn() + for _, symbol := range uniqueSymbols { + entry, ok := client.cache[symbol] + if ok && now.Before(entry.expiresAt) { + results[symbol] = entry.snapshot + continue + } + missing = append(missing, symbol) + } + client.mu.RUnlock() + + if len(missing) == 0 { + return results, nil + } + + sourceValuesBySymbol := make(map[string][]sourceSnapshot, len(missing)) + var firstErr error + for _, sourceID := range sourceIDs { + sourceResults, err := client.fetchSource(ctx, sourceID, missing) + if err != nil && firstErr == nil { + firstErr = err + } + + for _, symbol := range missing { + if snapshot, ok := sourceResults[symbol]; ok && snapshot.available { + sourceValuesBySymbol[symbol] = append(sourceValuesBySymbol[symbol], snapshot) + } + } + } + + client.mu.Lock() + defer client.mu.Unlock() + expiresAt := client.nowFn().Add(client.ttl) + for _, symbol := range missing { + snapshot := aggregateSnapshots(sourceValuesBySymbol[symbol]) + if snapshot.Available { + client.cache[symbol] = cacheEntry{snapshot: snapshot, expiresAt: expiresAt} + results[symbol] = snapshot + } + } + + return results, firstErr +} + +func (client *Client) fetchSource(ctx context.Context, sourceID string, symbols []string) (map[string]sourceSnapshot, error) { + queryURL, err := url.Parse(client.baseURL + "/" + sourceID + "/stocks/v1/compare") + if err != nil { + return nil, err + } + + values := queryURL.Query() + values.Set("tickers", strings.Join(symbols, ",")) + values.Set("days", "7") + queryURL.RawQuery = values.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-API-Key", client.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("adanos %s compare returned status %d", sourceID, resp.StatusCode) //nolint:goerr113 + } + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + return parseComparePayload(payload), nil +} + +func normalizeSymbols(symbols []string) []string { + seen := make(map[string]struct{}, len(symbols)) + uniqueSymbols := make([]string, 0, len(symbols)) + for _, symbol := range symbols { + normalized := strings.ToUpper(strings.TrimSpace(symbol)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + uniqueSymbols = append(uniqueSymbols, normalized) + } + + return uniqueSymbols +} + +func parseComparePayload(payload map[string]any) map[string]sourceSnapshot { + if data, ok := payload["data"].(map[string]any); ok { + payload = data + } + + stocks, _ := payload["stocks"].([]any) + results := make(map[string]sourceSnapshot, len(stocks)) + for _, item := range stocks { + stock, ok := item.(map[string]any) + if !ok { + continue + } + symbol := stringValue(stock, "ticker", "symbol") + if symbol == "" { + continue + } + results[symbol] = sourceSnapshot{ + buzzScore: floatValue(stock, "buzz_score"), + bullishPercent: floatValue(stock, "bullish_pct"), + activityValue: floatValue(stock, "mentions", "trade_count", "tradeCount"), + } + results[symbol] = markAvailability(results[symbol]) + } + + return results +} + +func aggregateSnapshots(snapshots []sourceSnapshot) common.MarketSentiment { + if len(snapshots) == 0 { + return common.MarketSentiment{} + } + + var totalBuzz float64 + var totalBullish float64 + bullishValues := make([]float64, 0, len(snapshots)) + for _, snapshot := range snapshots { + totalBuzz += snapshot.buzzScore + totalBullish += snapshot.bullishPercent + bullishValues = append(bullishValues, snapshot.bullishPercent) + } + + return common.MarketSentiment{ + Available: true, + AverageBuzz: totalBuzz / float64(len(snapshots)), + BullishPercent: totalBullish / float64(len(snapshots)), + Coverage: len(snapshots), + SourceAlignment: sourceAlignment(bullishValues), + } +} + +func sourceAlignment(values []float64) string { + if len(values) == 0 { + return "unavailable" + } + if len(values) == 1 { + return "single_source" + } + + min := values[0] + max := values[0] + for _, value := range values[1:] { + if value < min { + min = value + } + if value > max { + max = value + } + } + + spread := max - min + if spread <= 12 { + return "aligned" + } + if spread <= 25 { + return "mixed" + } + + return "divergent" +} + +func floatValue(values map[string]any, keys ...string) float64 { + for _, key := range keys { + value, ok := values[key] + if !ok { + continue + } + switch typed := value.(type) { + case float64: + return typed + case int: + return float64(typed) + case string: + parsed, _ := strconv.ParseFloat(typed, 64) + return parsed + } + } + + return 0 +} + +func stringValue(values map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := values[key] + if !ok { + continue + } + if typed, ok := value.(string); ok { + return strings.ToUpper(strings.TrimSpace(typed)) + } + } + return "" +} + +func markAvailability(snapshot sourceSnapshot) sourceSnapshot { + if snapshot.buzzScore > 0 || snapshot.activityValue > 0 { + snapshot.available = true + } + + return snapshot +} diff --git a/internal/sentiment/adanos/client_test.go b/internal/sentiment/adanos/client_test.go new file mode 100644 index 0000000..b4c0264 --- /dev/null +++ b/internal/sentiment/adanos/client_test.go @@ -0,0 +1,116 @@ +package adanos + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestFetchSnapshotsAggregatesAcrossSourcesAndCaches(t *testing.T) { + t.Helper() + + requestsByPath := map[string]int{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestsByPath[r.URL.Path]++ + + w.Header().Set("Content-Type", "application/json") + + var payload map[string]any + switch r.URL.Path { + case "/reddit/stocks/v1/compare": + payload = map[string]any{ + "stocks": []map[string]any{ + {"ticker": "AAPL", "buzz_score": 40.0, "bullish_pct": 70.0, "mentions": 12.0}, + }, + } + case "/x/stocks/v1/compare": + payload = map[string]any{ + "data": map[string]any{ + "stocks": []map[string]any{ + {"ticker": "AAPL", "buzz_score": 30.0, "bullish_pct": 65.0, "mentions": 8.0}, + }, + }, + } + case "/news/stocks/v1/compare": + payload = map[string]any{ + "stocks": []map[string]any{ + {"ticker": "AAPL", "buzz_score": 0.0, "bullish_pct": 0.0, "mentions": 0.0}, + }, + } + case "/polymarket/stocks/v1/compare": + payload = map[string]any{ + "stocks": []map[string]any{ + {"ticker": "AAPL", "buzz_score": 25.0, "bullish_pct": 55.0, "trade_count": 4.0}, + }, + } + default: + http.NotFound(w, r) + return + } + + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("encode payload: %v", err) + } + })) + defer server.Close() + + client := NewClient(server.URL, "sk_test", server.Client(), time.Hour) + + snapshots, err := client.FetchSnapshots(context.Background(), []string{"aapl", "AAPL"}) + if err != nil { + t.Fatalf("fetch snapshots: %v", err) + } + + snapshot, ok := snapshots["AAPL"] + if !ok { + t.Fatalf("expected AAPL snapshot to exist") + } + + if !snapshot.Available { + t.Fatalf("expected AAPL snapshot to be available") + } + if snapshot.Coverage != 3 { + t.Fatalf("expected coverage 3, got %d", snapshot.Coverage) + } + if snapshot.SourceAlignment != "mixed" { + t.Fatalf("expected mixed alignment, got %q", snapshot.SourceAlignment) + } + if snapshot.AverageBuzz <= 31.6 || snapshot.AverageBuzz >= 31.7 { + t.Fatalf("expected average buzz around 31.67, got %.2f", snapshot.AverageBuzz) + } + if snapshot.BullishPercent <= 63.3 || snapshot.BullishPercent >= 63.4 { + t.Fatalf("expected bullish percent around 63.33, got %.2f", snapshot.BullishPercent) + } + + _, err = client.FetchSnapshots(context.Background(), []string{"AAPL"}) + if err != nil { + t.Fatalf("fetch snapshots from cache: %v", err) + } + + for _, path := range []string{ + "/reddit/stocks/v1/compare", + "/x/stocks/v1/compare", + "/news/stocks/v1/compare", + "/polymarket/stocks/v1/compare", + } { + if requestsByPath[path] != 1 { + t.Fatalf("expected %s to be called once, got %d", path, requestsByPath[path]) + } + } +} + +func TestFetchSnapshotsReturnsEmptyWhenDisabled(t *testing.T) { + t.Helper() + + client := NewClient("https://api.adanos.org", "", nil, time.Minute) + snapshots, err := client.FetchSnapshots(context.Background(), []string{"AAPL"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(snapshots) != 0 { + t.Fatalf("expected no snapshots, got %d", len(snapshots)) + } +} diff --git a/internal/sorter/sorter.go b/internal/sorter/sorter.go index 76ca06d..9c916db 100644 --- a/internal/sorter/sorter.go +++ b/internal/sorter/sorter.go @@ -12,9 +12,10 @@ type Sorter func([]*c.Asset) []*c.Asset // NewSorter creates a sorting function func NewSorter(sort string) Sorter { var sortDict = map[string]Sorter{ - "alpha": sortByAlpha, - "value": sortByValue, - "user": sortByUser, + "alpha": sortByAlpha, + "value": sortByValue, + "user": sortByUser, + "sentiment": sortBySentiment, } if sorter, ok := sortDict[sort]; ok { return sorter @@ -106,6 +107,37 @@ func sortByChange(assetsIn []*c.Asset) []*c.Asset { } +func sortBySentiment(assetsIn []*c.Asset) []*c.Asset { + assetCount := len(assetsIn) + + if assetCount <= 0 { + return assetsIn + } + + assets := make([]*c.Asset, assetCount) + copy(assets, assetsIn) + + activeAssets, inactiveAssets := splitActiveAssets(assets) + + sort.SliceStable(activeAssets, func(i, j int) bool { + return sentimentLess(activeAssets[i], activeAssets[j]) + }) + + sort.SliceStable(inactiveAssets, func(i, j int) bool { + return sentimentLess(inactiveAssets[i], inactiveAssets[j]) + }) + + return append(activeAssets, inactiveAssets...) +} + +func sentimentLess(left *c.Asset, right *c.Asset) bool { + if left.Sentiment.Available != right.Sentiment.Available { + return left.Sentiment.Available + } + + return right.Sentiment.AverageBuzz < left.Sentiment.AverageBuzz +} + func splitActiveAssets(assets []*c.Asset) ([]*c.Asset, []*c.Asset) { activeAssets := make([]*c.Asset, 0) diff --git a/internal/sorter/sorter_test.go b/internal/sorter/sorter_test.go index 1a67232..39f249e 100644 --- a/internal/sorter/sorter_test.go +++ b/internal/sorter/sorter_test.go @@ -208,6 +208,35 @@ var _ = Describe("Sorter", func() { Expect(sortedQuotes).To(Equal(expected)) }) }) + When("providing \"sentiment\" as a sort parameter", func() { + It("should sort by Adanos buzz with available sentiment first", func() { + sorter := NewSorter("sentiment") + + bitcoinWithSentiment := bitcoinQuote + bitcoinWithSentiment.Sentiment = c.MarketSentiment{Available: true, AverageBuzz: 20.0} + googleWithSentiment := googleQuote + googleWithSentiment.Sentiment = c.MarketSentiment{Available: true, AverageBuzz: 80.0} + msftWithSentiment := msftQuote + msftWithSentiment.Sentiment = c.MarketSentiment{Available: true, AverageBuzz: 10.0} + + assets := []*c.Asset{ + &bitcoinWithSentiment, + &twQuote, + &googleWithSentiment, + &msftWithSentiment, + } + + sortedQuotes := sorter(assets) + expected := []*c.Asset{ + &googleWithSentiment, + &bitcoinWithSentiment, + &twQuote, + &msftWithSentiment, + } + + Expect(sortedQuotes).To(Equal(expected)) + }) + }) When("providing no quotes", func() { When("default sorter", func() { It("should return no quotes", func() { @@ -240,6 +269,15 @@ var _ = Describe("Sorter", func() { It("should return no quotes", func() { sorter := NewSorter("user") + sortedQuotes := sorter([]*c.Asset{}) + expected := []*c.Asset{} + Expect(sortedQuotes).To(Equal(expected)) + }) + }) + When("sentiment sorter", func() { + It("should return no quotes", func() { + sorter := NewSorter("sentiment") + sortedQuotes := sorter([]*c.Asset{}) expected := []*c.Asset{} Expect(sortedQuotes).To(Equal(expected)) diff --git a/internal/ui/component/watchlist/row/row.go b/internal/ui/component/watchlist/row/row.go index 90293ff..25932e7 100644 --- a/internal/ui/component/watchlist/row/row.go +++ b/internal/ui/component/watchlist/row/row.go @@ -48,6 +48,7 @@ type Config struct { ShowPositions bool ExtraInfoExchange bool ExtraInfoFundamentals bool + ShowSentiment bool Styles c.Styles Asset *c.Asset } @@ -221,6 +222,17 @@ func (m *Model) View() string { }) } + if m.config.ShowSentiment && m.config.Asset.Sentiment.Available { + rows = append( + rows, + grid.Row{ + Width: m.width, + Cells: []grid.Cell{ + {Text: textSentiment(m.config.Asset, m.config.Styles)}, + }, + }) + } + if m.config.Separate { rows = append( rows, @@ -562,6 +574,15 @@ func textTags(asset *c.Asset, styles c.Styles) string { return formatTag(currencyText, styles) + " " + formatTag(exchangeDelayText(asset.Exchange.Delay, asset.Exchange.DelayText), styles) + " " + formatTag(asset.Exchange.Name, styles) } +func textSentiment(asset *c.Asset, styles c.Styles) string { + sentiment := asset.Sentiment + + return formatTag("Buzz "+u.ConvertFloatToString(sentiment.AverageBuzz, false), styles) + " " + + formatTag("Bullish "+u.ConvertFloatToString(sentiment.BullishPercent, false)+"%", styles) + " " + + formatTag("Coverage "+strconv.Itoa(sentiment.Coverage)+"/4", styles) + " " + + formatTag(strings.ReplaceAll(sentiment.SourceAlignment, "_", " "), styles) +} + func exchangeDelayText(delay float64, delayText string) string { if delayText != "" { diff --git a/internal/ui/component/watchlist/row/row_test.go b/internal/ui/component/watchlist/row/row_test.go index d384d40..f50b072 100644 --- a/internal/ui/component/watchlist/row/row_test.go +++ b/internal/ui/component/watchlist/row/row_test.go @@ -293,4 +293,32 @@ var _ = Describe("Row", func() { }) + Describe("View", func() { + It("should render a sentiment line when configured and available", func() { + inputRow := row.New(row.Config{ + ShowSentiment: true, + Styles: styles, + Asset: &c.Asset{ + Symbol: "AAPL", + QuotePrice: c.QuotePrice{ + Price: 150.00, + }, + Sentiment: c.MarketSentiment{ + Available: true, + AverageBuzz: 42.5, + BullishPercent: 68.0, + Coverage: 3, + SourceAlignment: "aligned", + }, + }, + }) + + view := strings.ReplaceAll(inputRow.View(), "\n", " ") + Expect(view).To(ContainSubstring("Buzz 42.50")) + Expect(view).To(ContainSubstring("Bullish 68.00%")) + Expect(view).To(ContainSubstring("Coverage 3/4")) + Expect(view).To(ContainSubstring("aligned")) + }) + }) + }) diff --git a/internal/ui/component/watchlist/watchlist.go b/internal/ui/component/watchlist/watchlist.go index 92725eb..3338213 100644 --- a/internal/ui/component/watchlist/watchlist.go +++ b/internal/ui/component/watchlist/watchlist.go @@ -18,6 +18,7 @@ type Config struct { ShowPositions bool ExtraInfoExchange bool ExtraInfoFundamentals bool + ShowSentiment bool Sort string Styles c.Styles } @@ -89,6 +90,7 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { Separate: m.config.Separate, ExtraInfoExchange: m.config.ExtraInfoExchange, ExtraInfoFundamentals: m.config.ExtraInfoFundamentals, + ShowSentiment: m.config.ShowSentiment, ShowPositions: m.config.ShowPositions, Styles: m.config.Styles, Asset: asset, diff --git a/internal/ui/ui.go b/internal/ui/ui.go index d325ad8..7ae61d7 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,7 +1,9 @@ package ui import ( + "context" "fmt" + "strings" "sync" "time" @@ -9,6 +11,7 @@ import ( "github.com/achannarasappa/ticker/v5/internal/asset" c "github.com/achannarasappa/ticker/v5/internal/common" mon "github.com/achannarasappa/ticker/v5/internal/monitor" + "github.com/achannarasappa/ticker/v5/internal/sentiment/adanos" "github.com/achannarasappa/ticker/v5/internal/ui/component/summary" "github.com/achannarasappa/ticker/v5/internal/ui/component/watchlist" "github.com/achannarasappa/ticker/v5/internal/ui/component/watchlist/row" @@ -50,6 +53,8 @@ type Model struct { groupSelectedName string currentSort string monitors *mon.Monitor + sentimentClient *adanos.Client + sentimentBySymbol map[string]c.MarketSentiment mu sync.RWMutex } @@ -68,6 +73,11 @@ type SetAssetGroupQuoteMsg struct { versionVector int } +type SetSentimentMsg struct { + snapshots map[string]c.MarketSentiment + versionVector int +} + // NewModel is the constructor for UI model func NewModel(dep c.Dependencies, ctx c.Context, monitors *mon.Monitor) *Model { @@ -89,6 +99,7 @@ func NewModel(dep c.Dependencies, ctx c.Context, monitors *mon.Monitor) *Model { ShowPositions: ctx.Config.ShowPositions, ExtraInfoExchange: ctx.Config.ExtraInfoExchange, ExtraInfoFundamentals: ctx.Config.ExtraInfoFundamentals, + ShowSentiment: ctx.Config.ShowSentiment, Styles: ctx.Reference.Styles, }), summary: summary.NewModel(ctx), @@ -97,6 +108,8 @@ func NewModel(dep c.Dependencies, ctx c.Context, monitors *mon.Monitor) *Model { groupSelectedName: " ", currentSort: ctx.Config.Sort, monitors: monitors, + sentimentClient: newSentimentClient(dep, ctx.Config), + sentimentBySymbol: make(map[string]c.MarketSentiment), } } @@ -174,7 +187,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mu.Lock() // Cycle through sort options: default -> alpha -> value -> user -> default - sortOptions := []string{"", "alpha", "value", "user"} + sortOptions := []string{"", "alpha", "value", "user", "sentiment"} currentIndex := -1 for i, sortOpt := range sortOptions { if m.currentSort == sortOpt { @@ -264,6 +277,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } assets, positionSummary := asset.GetAssets(m.ctx, msg.assetGroupQuote) + assets = applySentiment(assets, m.sentimentBySymbol) m.assets = assets m.positionSummary = positionSummary @@ -275,7 +289,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.groupSelectedName = m.ctx.Groups[m.groupSelectedIndex].Name - return m, nil + return m, requestSentiment(m.sentimentClient, assetSymbols(assets), m.versionVector) case SetAssetQuoteMsg: @@ -313,12 +327,27 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } assets, positionSummary := asset.GetAssets(m.ctx, assetGroupQuote) + assets = applySentiment(assets, m.sentimentBySymbol) m.assets = assets m.positionSummary = positionSummary return m, nil + case SetSentimentMsg: + + m.mu.Lock() + defer m.mu.Unlock() + + if msg.versionVector != m.versionVector { + return m, nil + } + + m.sentimentBySymbol = msg.snapshots + m.assets = applySentiment(m.assets, m.sentimentBySymbol) + + return m, tickImmediate(msg.versionVector) + case row.FrameMsg: var cmd tea.Cmd m.watchlist, cmd = m.watchlist.Update(msg) @@ -371,15 +400,16 @@ func footer(width int, time string, groupSelectedName string, currentSort string sortDisplayName = "value" case "user": sortDisplayName = "user" + case "sentiment": + sortDisplayName = "sentiment" } baseHelpText := " q: exit ↑: scroll up ↓: scroll down ⭾: change group" sortHelpText := " s: change sort (" + sortDisplayName + ")" // Calculate minimum width for sort help text to appear - // Longest sort text is "s: change sort (change)" = 24 characters - // Minimum width needed: logo(8) + max group(14) + base help(52) + sort help(24) + time(12) = 110 - const sortHelpMinWidth = 114 + // Longest sort text is "s: change sort (sentiment)" = 27 characters. + const sortHelpMinWidth = 117 return grid.Render(grid.Grid{ Rows: []grid.Row{ @@ -430,3 +460,71 @@ func getTime() string { return fmt.Sprintf("%s %02d:%02d:%02d", t.Weekday().String(), t.Hour(), t.Minute(), t.Second()) } + +func newSentimentClient(dep c.Dependencies, config c.Config) *adanos.Client { + if config.SentimentAPIKey == "" { + return nil + } + + return adanos.NewClient(dep.SentimentAdanosBaseURL, config.SentimentAPIKey, nil, 5*time.Minute) +} + +func requestSentiment(client *adanos.Client, symbols []string, versionVector int) tea.Cmd { + if client == nil || !client.Enabled() || len(symbols) == 0 { + return nil + } + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + snapshots, err := client.FetchSnapshots(ctx, symbols) + if err != nil { + return nil + } + + return SetSentimentMsg{ + snapshots: snapshots, + versionVector: versionVector, + } + } +} + +func applySentiment(assets []c.Asset, snapshots map[string]c.MarketSentiment) []c.Asset { + if len(assets) == 0 { + return assets + } + + enriched := make([]c.Asset, len(assets)) + copy(enriched, assets) + + for i := range enriched { + snapshot, ok := snapshots[strings.ToUpper(enriched[i].Symbol)] + if ok { + enriched[i].Sentiment = snapshot + } else { + enriched[i].Sentiment = c.MarketSentiment{} + } + } + + return enriched +} + +func assetSymbols(assets []c.Asset) []string { + symbols := make([]string, 0, len(assets)) + seen := make(map[string]struct{}, len(assets)) + + for _, asset := range assets { + symbol := strings.ToUpper(strings.TrimSpace(asset.Symbol)) + if symbol == "" { + continue + } + if _, ok := seen[symbol]; ok { + continue + } + seen[symbol] = struct{}{} + symbols = append(symbols, symbol) + } + + return symbols +}