├── .github
├── FUNDING.yml
├── workflows
│ ├── lint-commit.yml
│ ├── lint.yml
│ ├── test-contract.yml
│ ├── close-stale-issue.yaml
│ ├── test.yml
│ └── release.yml
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── docs
├── ticker.gif
├── ticker-currency.png
├── ticker-all-options.png
└── debug-guide.md
├── main.go
├── Dockerfile
├── internal
├── cli
│ ├── cli_suite_test.go
│ └── symbol
│ │ ├── symbol_suite_test.go
│ │ ├── symbol.go
│ │ └── symbol_test.go
├── asset
│ ├── asset_suite_test.go
│ ├── currency.go
│ ├── asset_fixture_test.go
│ └── asset.go
├── ui
│ ├── util
│ │ ├── util_suite_test.go
│ │ ├── format.go
│ │ ├── style.go
│ │ └── util_test.go
│ ├── component
│ │ ├── watchlist
│ │ │ ├── row
│ │ │ │ ├── row_suite_test.go
│ │ │ │ └── row_test.go
│ │ │ ├── watchlist_suite_test.go
│ │ │ ├── snapshots
│ │ │ │ └── watchlist-all-options.snap
│ │ │ └── watchlist.go
│ │ └── summary
│ │ │ ├── summary_suite_test.go
│ │ │ ├── summary_test.go
│ │ │ └── summary.go
│ ├── start.go
│ └── ui.go
├── sorter
│ ├── sorter_suite_test.go
│ ├── sorter.go
│ └── sorter_test.go
├── monitor
│ ├── monitor_suite_test.go
│ ├── coinbase
│ │ ├── unary
│ │ │ ├── unary_suite_test.go
│ │ │ ├── unary.go
│ │ │ └── unary_test.go
│ │ └── monitor-price
│ │ │ ├── poller
│ │ │ ├── poller_suite_test.go
│ │ │ ├── poller.go
│ │ │ └── poller_test.go
│ │ │ ├── streamer
│ │ │ ├── streamer_suite_test.go
│ │ │ ├── streamer.go
│ │ │ └── streamer_test.go
│ │ │ └── monitor_suite_test.go
│ └── yahoo
│ │ ├── unary
│ │ ├── unary_suite_test.go
│ │ ├── fixtures_test.go
│ │ ├── models.go
│ │ ├── helpers-quote.go
│ │ └── unary.go
│ │ ├── monitor-price
│ │ ├── monitor_suite_test.go
│ │ ├── poller
│ │ │ ├── poller_suite_test.go
│ │ │ ├── fixtures_test.go
│ │ │ ├── poller.go
│ │ │ └── poller_test.go
│ │ └── fixtures_test.go
│ │ └── monitor-currency-rates
│ │ ├── monitor_currency_rates_suite_test.go
│ │ └── monitor.go
├── print
│ ├── print_suite_test.go
│ ├── print.go
│ └── print_test.go
└── common
│ └── common.go
├── test
├── contract
│ └── yahoo
│ │ ├── api_suite_test.go
│ │ ├── schema.json
│ │ └── api_test.go
└── websocket
│ └── websocket.go
├── .gitignore
├── .golangci.yml
├── go.mod
├── cmd
└── root.go
└── .goreleaser.yml
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [achannarasappa]
2 |
--------------------------------------------------------------------------------
/docs/ticker.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/achannarasappa/ticker/HEAD/docs/ticker.gif
--------------------------------------------------------------------------------
/docs/ticker-currency.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/achannarasappa/ticker/HEAD/docs/ticker-currency.png
--------------------------------------------------------------------------------
/docs/ticker-all-options.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/achannarasappa/ticker/HEAD/docs/ticker-all-options.png
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "github.com/achannarasappa/ticker/v5/cmd"
4 |
5 | func main() {
6 | cmd.Execute()
7 | }
8 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:3
2 |
3 | ENV TERM=xterm-256color
4 |
5 | COPY ticker /ticker
6 |
7 | VOLUME ["/.ticker.yaml"]
8 |
9 | ENTRYPOINT ["/ticker"]
--------------------------------------------------------------------------------
/internal/cli/cli_suite_test.go:
--------------------------------------------------------------------------------
1 | package cli_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestCli(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "CLI Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/asset/asset_suite_test.go:
--------------------------------------------------------------------------------
1 | package asset_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestAsset(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Asset Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/ui/util/util_suite_test.go:
--------------------------------------------------------------------------------
1 | package util_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestUtil(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Util Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/sorter/sorter_suite_test.go:
--------------------------------------------------------------------------------
1 | package sorter_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestSorter(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Sorter Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/.github/workflows/lint-commit.yml:
--------------------------------------------------------------------------------
1 | name: lint-commit-message
2 | on: [push]
3 |
4 | jobs:
5 | commitlint:
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/checkout@v4
9 | with:
10 | fetch-depth: 0
11 | - uses: wagoid/commitlint-github-action@v2
--------------------------------------------------------------------------------
/internal/cli/symbol/symbol_suite_test.go:
--------------------------------------------------------------------------------
1 | package symbol_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestSymbol(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Symbol Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/monitor/monitor_suite_test.go:
--------------------------------------------------------------------------------
1 | package monitor_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestMonitor(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Monitor Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/unary/unary_suite_test.go:
--------------------------------------------------------------------------------
1 | package unary_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestUnary(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Unary Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/unary/unary_suite_test.go:
--------------------------------------------------------------------------------
1 | package unary_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestUnary(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Unary Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/ui/component/watchlist/row/row_suite_test.go:
--------------------------------------------------------------------------------
1 | package row_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestRow(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Row Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/test/contract/yahoo/api_suite_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestYahooAPI(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Yahoo API Contract Test Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !cmd
3 | !cmd/*
4 | !internal
5 | !internal/**/*
6 | !test
7 | !test/**/*
8 | !docs
9 | !docs/*
10 | !main.go
11 | !README.md
12 | !LICENSE
13 | !go.mod
14 | !go.sum
15 | !Dockerfile
16 | !.gitignore
17 | !.goreleaser.yml
18 | !.golangci.yml
19 | !.github
20 | !.github/**/*
21 | *.coverprofile
--------------------------------------------------------------------------------
/internal/monitor/coinbase/monitor-price/poller/poller_suite_test.go:
--------------------------------------------------------------------------------
1 | package poller_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestPoller(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Poller Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-price/monitor_suite_test.go:
--------------------------------------------------------------------------------
1 | package monitorPriceYahoo_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestYahoo(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Yahoo Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-price/poller/poller_suite_test.go:
--------------------------------------------------------------------------------
1 | package poller_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestPoller(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Poller Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/monitor-price/streamer/streamer_suite_test.go:
--------------------------------------------------------------------------------
1 | package streamer_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestStreamer(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Streamer Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/monitor-price/monitor_suite_test.go:
--------------------------------------------------------------------------------
1 | package monitorPriceCoinbase_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestCoinbase(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "Coinbase Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/print/print_suite_test.go:
--------------------------------------------------------------------------------
1 | package print_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | "github.com/onsi/gomega/format"
9 | )
10 |
11 | func TestPrint(t *testing.T) {
12 | format.TruncatedDiff = false
13 | RegisterFailHandler(Fail)
14 | RunSpecs(t, "Print Suite")
15 | }
16 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-currency-rates/monitor_currency_rates_suite_test.go:
--------------------------------------------------------------------------------
1 | package monitorCurrencyRate_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | )
9 |
10 | func TestMonitorCurrencyRates(t *testing.T) {
11 | RegisterFailHandler(Fail)
12 | RunSpecs(t, "MonitorCurrencyRates Suite")
13 | }
14 |
--------------------------------------------------------------------------------
/internal/ui/component/summary/summary_suite_test.go:
--------------------------------------------------------------------------------
1 | package summary_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | "github.com/onsi/gomega/format"
9 | )
10 |
11 | func TestSummary(t *testing.T) {
12 | format.TruncatedDiff = false
13 | RegisterFailHandler(Fail)
14 | RunSpecs(t, "Summary Suite")
15 | }
16 |
--------------------------------------------------------------------------------
/internal/ui/component/watchlist/watchlist_suite_test.go:
--------------------------------------------------------------------------------
1 | package watchlist_test
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/ginkgo/v2"
7 | . "github.com/onsi/gomega"
8 | "github.com/onsi/gomega/format"
9 | )
10 |
11 | func TestWatchlist(t *testing.T) {
12 | format.TruncatedDiff = false
13 | RegisterFailHandler(Fail)
14 | RunSpecs(t, "Watchlist Suite")
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: lint
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | lint:
6 | name: lint
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - uses: actions/setup-go@v5
11 | with:
12 | go-version: '1.23'
13 | - name: lint
14 | uses: golangci/golangci-lint-action@v5
15 | with:
16 | version: latest
--------------------------------------------------------------------------------
/.github/workflows/test-contract.yml:
--------------------------------------------------------------------------------
1 | name: test-contract
2 |
3 | on:
4 | schedule:
5 | - cron: '0 8 * * *'
6 | push:
7 | branches: [ master ]
8 | jobs:
9 | test:
10 | strategy:
11 | matrix:
12 | go-version: [1.23.x]
13 | platform: [ubuntu-latest]
14 | runs-on: ${{ matrix.platform }}
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 | - name: Install Go
19 | uses: actions/setup-go@v5
20 | with:
21 | go-version: ${{ matrix.go-version }}
22 | - name: Test Quote API
23 | run: go run github.com/onsi/ginkgo/ginkgo@v1.16.5 -r -v ./test/contract/yahoo/ -focus "GetAssetQuotes Response"
24 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | linters:
2 | presets:
3 | - bugs
4 | - complexity
5 | - format
6 | - performance
7 | - unused
8 | - style
9 | disable:
10 | - paralleltest
11 | - wrapcheck
12 | - whitespace
13 | - wsl
14 | - godot
15 | - lll
16 | - funlen
17 | - gofumpt
18 | - unparam
19 | - tagliatelle
20 | - forbidigo
21 | - gci
22 | - varnamelen
23 | - exhaustruct
24 | - depguard
25 | - nolintlint
26 | - mnd
27 | - copyloopvar
28 | - revive
29 | - gocognit
30 | - nestif
31 | - stylecheck
32 | - godox
33 | - cyclop
34 | - nonamedreturns
35 | - ireturn
36 | - noctx
37 | - containedctx
38 | - err113
39 | run:
40 | tests: false
41 | issues:
42 | fix: false
43 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Problem Statement**
11 | Is your feature request related to a problem? Please describe.
12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
13 |
14 | **Proposed Solution**
15 | A clear and concise description of what you want to happen.
16 |
17 | **Alternatives**
18 | A clear and concise description of any alternative solutions or features you've considered.
19 |
20 | **Use Cases**
21 | A clear and concise description of any alternative solutions or features you've considered.
22 |
23 | **Additional context**
24 | Add any other context or screenshots about the feature request here.
25 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Start with options '...'
16 | 3. Scroll down to '....'
17 | 4. See error
18 |
19 | **Expected behavior**
20 | A clear and concise description of what you expected to happen.
21 |
22 | **Screenshots**
23 | If applicable, add screenshots to help explain your problem.
24 |
25 | **Environment (please complete the following information):**
26 | - OS: [e.g. Mac, Windows, Linux]
27 | - Terminal: [e.g. iTerm, ConEmu, Guake]
28 | - Terminal Version: [e.g. 22]
29 | - Font: (Optional) [e.g. Powerline]
30 | - ticker Version: [e.g. v2.2.0]
31 |
32 | **Additional context**
33 | Add any other context about the problem here.
34 |
--------------------------------------------------------------------------------
/test/websocket/websocket.go:
--------------------------------------------------------------------------------
1 | package websocket
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 |
7 | "github.com/gorilla/websocket"
8 | )
9 |
10 | var upgrader = websocket.Upgrader{ //nolint:gochecknoglobals
11 | ReadBufferSize: 1024,
12 | WriteBufferSize: 1024,
13 | CheckOrigin: func(r *http.Request) bool {
14 | return true
15 | },
16 | }
17 |
18 | // NewTestServer creates a new test WebSocket server that sends the provided messages
19 | // after receiving an initial message from the client
20 | func NewTestServer(messages []string) *httptest.Server {
21 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22 | conn, err := upgrader.Upgrade(w, r, nil)
23 | if err != nil {
24 | return
25 | }
26 | defer conn.Close()
27 |
28 | // Send each message in sequence
29 | for _, msg := range messages {
30 | if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil {
31 | return
32 | }
33 | }
34 | }))
35 | }
36 |
--------------------------------------------------------------------------------
/.github/workflows/close-stale-issue.yaml:
--------------------------------------------------------------------------------
1 | name: 'github-close-stale'
2 | on:
3 | schedule:
4 | - cron: '30 1 * * *'
5 |
6 | jobs:
7 | stale:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/stale@v3
11 | with:
12 | repo-token: ${{ secrets.GITHUB_TOKEN }}
13 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
14 | stale-pr-message: 'This pr is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
15 | close-issue-message: 'This issue was closed because it has been stalled for 60 days with no activity.'
16 | close-pr-message: 'This pr was closed because it has been stalled for 60 days with no activity.'
17 | days-before-stale: 180
18 | days-before-close: 60
19 | exempt-issue-milestones: 'future-release'
20 | exempt-pr-milestones: 'future-release'
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | test:
6 | strategy:
7 | matrix:
8 | go-version: [1.23.x]
9 | platform: [ubuntu-latest, macos-latest]
10 | runs-on: ${{ matrix.platform }}
11 | env:
12 | TERM: xterm-256color
13 | steps:
14 | - name: Install Go
15 | uses: actions/setup-go@v5
16 | with:
17 | go-version: ${{ matrix.go-version }}
18 | - name: Checkout code
19 | uses: actions/checkout@v4
20 | - name: Test
21 | run: go run github.com/onsi/ginkgo/ginkgo@v1.16.5 -skip="GetQuotes Response" -cover ./...
22 | coverage:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - name: Install Go
26 | if: success()
27 | uses: actions/setup-go@v5
28 | with:
29 | go-version: 1.23.x
30 | - name: Checkout code
31 | uses: actions/checkout@v4
32 | - name: Generate coverage
33 | run: go run github.com/onsi/ginkgo/ginkgo@v1.16.5 -skip="GetQuotes Response" -cover -outputdir=./ -coverprofile=coverage.out ./...
34 | - name: Coveralls
35 | uses: coverallsapp/github-action@v2
36 | with:
37 | file: ./coverage.out
38 | format: golang
--------------------------------------------------------------------------------
/docs/debug-guide.md:
--------------------------------------------------------------------------------
1 | # Debug Guide
2 |
3 | ## Visual Studio Code
4 |
5 | ### Setup
6 |
7 | 1. Install delve:
8 | ```sh
9 | go install github.com/go-delve/delve/cmd/dlv@latest
10 | ```
11 |
12 | 2. Setup vscode:
13 |
14 | In vscode, open command palette with CTRL+SHIFT+P, select `Open launch.json`, and add debug config block below to `launch.json`
15 |
16 | ```json
17 | {
18 | "version": "0.2.0",
19 | "configurations": [
20 | {
21 | "name": "Attach",
22 | "type": "go",
23 | "request": "attach",
24 | "mode": "remote",
25 | "remotePath": "",
26 | "port": 2345,
27 | "host": "127.0.0.1",
28 | "showLog": true,
29 | "trace": "log",
30 | "logOutput": "rpc"
31 | }
32 | ]
33 | }
34 | ```
35 |
36 | ### Debug
37 |
38 | 1. Start `ticker` with remote debugger using this command:
39 | ```sh
40 | dlv --listen=:2345 --api-version 2 --log=true --log-output=debugger,debuglineerr,gdbwire,lldbout,rpc --log-dest=./debugger.log --headless --accept-multiclient debug main.go
41 | ```
42 |
43 | 2. Open debug menu in vscode with CTRL+SHIFT+ALT+D
44 |
45 | 3. Select command `Attach` and press the adjacent `▷` button to start `ticker` with the debugger attached
--------------------------------------------------------------------------------
/internal/ui/util/format.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "math"
5 | "strconv"
6 |
7 | c "github.com/achannarasappa/ticker/v5/internal/common"
8 | )
9 |
10 | func getPrecision(f float64) int {
11 |
12 | v := math.Abs(f)
13 |
14 | if v == 0.0 {
15 | return 2
16 | }
17 |
18 | if v >= 1000000 {
19 | return 0
20 | }
21 |
22 | if v < 10 {
23 | return 4
24 | }
25 |
26 | if v < 100 {
27 | return 3
28 | }
29 |
30 | if v >= 1000 && f < 0 {
31 | return 1
32 | }
33 |
34 | return 2
35 | }
36 |
37 | // ConvertFloatToString formats a float as a string including handling large or small numbers
38 | func ConvertFloatToString(f float64, isVariablePrecision bool) string {
39 |
40 | var unit string
41 |
42 | if !isVariablePrecision {
43 | return strconv.FormatFloat(f, 'f', 2, 64)
44 | }
45 |
46 | if f > 1000000000000 {
47 | f /= 1000000000000
48 | unit = " T"
49 | }
50 |
51 | if f > 1000000000 {
52 | f /= 1000000000
53 | unit = " B"
54 | }
55 |
56 | if f > 1000000 {
57 | f /= 1000000
58 | unit = " M"
59 | }
60 |
61 | prec := getPrecision(f)
62 |
63 | return strconv.FormatFloat(f, 'f', prec, 64) + unit
64 | }
65 |
66 | // ValueText formats a float as a styled string
67 | func ValueText(value float64, styles c.Styles) string {
68 | if value <= 0.0 {
69 | return ""
70 | }
71 |
72 | return styles.Text(ConvertFloatToString(value, false))
73 | }
74 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | release:
11 | runs-on: ubuntu-24.04
12 | env:
13 | DOCKER_CLI_EXPERIMENTAL: "enabled"
14 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_PAT }}
15 | steps:
16 | - if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
17 | run: echo "flags=--snapshot" >> $GITHUB_ENV
18 | -
19 | name: Checkout
20 | uses: actions/checkout@v4
21 | with:
22 | fetch-depth: 0
23 | -
24 | name: Set up QEMU
25 | uses: docker/setup-qemu-action@v1
26 | -
27 | name: Set up Docker Buildx
28 | uses: docker/setup-buildx-action@v3
29 | -
30 | name: Docker Login
31 | uses: docker/login-action@v3
32 | with:
33 | username: ${{ github.repository_owner }}
34 | password: ${{ secrets.DOCKER_HUB_PAT }}
35 | - name: Snapcraft Login
36 | uses: samuelmeuli/action-snapcraft@v2
37 | -
38 | name: Install upx
39 | run: |
40 | sudo apt-get update
41 | sudo apt-get install upx -y
42 | -
43 | name: Set up Go
44 | uses: actions/setup-go@v5
45 | with:
46 | go-version: 1.23
47 | -
48 | name: Run GoReleaser
49 | uses: goreleaser/goreleaser-action@v5
50 | with:
51 | version: 1.25.1
52 | args: release --clean ${{ env.flags }}
53 | env:
54 | GITHUB_TOKEN: ${{ secrets.GH_PAT }}
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-price/poller/fixtures_test.go:
--------------------------------------------------------------------------------
1 | package poller_test
2 |
3 | import "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
4 |
5 | var (
6 | responseQuote1Fixture = unary.Response{
7 | QuoteResponse: unary.ResponseQuoteResponse{
8 | Quotes: []unary.ResponseQuote{
9 | {
10 | MarketState: "REGULAR",
11 | ShortName: "Cloudflare, Inc.",
12 | PreMarketChange: unary.ResponseFieldFloat{Raw: 1.0399933, Fmt: "1.0399933"},
13 | PreMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.2238094, Fmt: "1.2238094"},
14 | PreMarketPrice: unary.ResponseFieldFloat{Raw: 86.03, Fmt: "86.03"},
15 | RegularMarketChange: unary.ResponseFieldFloat{Raw: 3.0800018, Fmt: "3.0800018"},
16 | RegularMarketChangePercent: unary.ResponseFieldFloat{Raw: 3.7606857, Fmt: "3.7606857"},
17 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 84.98, Fmt: "84.98"},
18 | RegularMarketPreviousClose: unary.ResponseFieldFloat{Raw: 84.00, Fmt: "84.00"},
19 | RegularMarketOpen: unary.ResponseFieldFloat{Raw: 85.22, Fmt: "85.22"},
20 | RegularMarketDayHigh: unary.ResponseFieldFloat{Raw: 90.00, Fmt: "90.00"},
21 | RegularMarketDayLow: unary.ResponseFieldFloat{Raw: 80.00, Fmt: "80.00"},
22 | PostMarketChange: unary.ResponseFieldFloat{Raw: 1.37627, Fmt: "1.37627"},
23 | PostMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.35735, Fmt: "1.35735"},
24 | PostMarketPrice: unary.ResponseFieldFloat{Raw: 86.56, Fmt: "86.56"},
25 | Symbol: "NET",
26 | },
27 | },
28 | Error: nil,
29 | },
30 | }
31 | )
32 |
--------------------------------------------------------------------------------
/internal/ui/start.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | c "github.com/achannarasappa/ticker/v5/internal/common"
5 | mon "github.com/achannarasappa/ticker/v5/internal/monitor"
6 | tea "github.com/charmbracelet/bubbletea"
7 | )
8 |
9 | // Start launches the command line interface and starts capturing input
10 | func Start(dep *c.Dependencies, ctx *c.Context) func() error {
11 | return func() error {
12 |
13 | monitors, _ := mon.NewMonitor(mon.ConfigMonitor{
14 | RefreshInterval: ctx.Config.RefreshInterval,
15 | TargetCurrency: ctx.Config.Currency,
16 | Logger: ctx.Logger,
17 | ConfigMonitorsYahoo: mon.ConfigMonitorsYahoo{
18 | BaseURL: dep.MonitorYahooBaseURL,
19 | SessionRootURL: dep.MonitorYahooSessionRootURL,
20 | SessionCrumbURL: dep.MonitorYahooSessionCrumbURL,
21 | SessionConsentURL: dep.MonitorYahooSessionConsentURL,
22 | },
23 | ConfigMonitorPriceCoinbase: mon.ConfigMonitorPriceCoinbase{
24 | BaseURL: dep.MonitorPriceCoinbaseBaseURL,
25 | StreamingURL: dep.MonitorPriceCoinbaseStreamingURL,
26 | },
27 | })
28 |
29 | p := tea.NewProgram(
30 | NewModel(*dep, *ctx, monitors),
31 | tea.WithMouseCellMotion(),
32 | tea.WithAltScreen(),
33 | )
34 |
35 | var err error
36 |
37 | err = monitors.SetOnUpdate(mon.ConfigUpdateFns{
38 | OnUpdateAssetQuote: func(symbol string, assetQuote c.AssetQuote, versionVector int) {
39 | p.Send(SetAssetQuoteMsg{
40 | symbol: symbol,
41 | assetQuote: assetQuote,
42 | versionVector: versionVector,
43 | })
44 | },
45 | OnUpdateAssetGroupQuote: func(assetGroupQuote c.AssetGroupQuote, versionVector int) {
46 | p.Send(SetAssetGroupQuoteMsg{
47 | assetGroupQuote: assetGroupQuote,
48 | versionVector: versionVector,
49 | })
50 | },
51 | })
52 |
53 | if err != nil {
54 |
55 | return err
56 | }
57 |
58 | _, err = p.Run()
59 |
60 | return err
61 | }
62 |
63 | }
64 |
--------------------------------------------------------------------------------
/internal/cli/symbol/symbol.go:
--------------------------------------------------------------------------------
1 | package symbol
2 |
3 | import (
4 | "encoding/csv"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "net/http"
9 |
10 | c "github.com/achannarasappa/ticker/v5/internal/common"
11 | )
12 |
13 | type SymbolSourceMap struct { //nolint:golint,revive
14 | TickerSymbol string
15 | SourceSymbol string
16 | Source c.QuoteSource
17 | }
18 |
19 | type TickerSymbolToSourceSymbol map[string]SymbolSourceMap
20 |
21 | func parseQuoteSource(id string) c.QuoteSource {
22 |
23 | if id == "cb" {
24 | return c.QuoteSourceCoinbase
25 | }
26 |
27 | return c.QuoteSourceUnknown
28 | }
29 |
30 | func parseTickerSymbolToSourceSymbol(body io.ReadCloser) (TickerSymbolToSourceSymbol, error) {
31 |
32 | out := TickerSymbolToSourceSymbol{}
33 | reader := csv.NewReader(body)
34 | reader.LazyQuotes = true
35 | for {
36 |
37 | row, err := reader.Read()
38 |
39 | if errors.Is(err, io.EOF) {
40 | body.Close()
41 |
42 | break
43 | }
44 |
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | if _, exists := out[row[0]]; !exists {
50 | out[row[0]] = SymbolSourceMap{
51 | TickerSymbol: row[0],
52 | SourceSymbol: row[1],
53 | Source: parseQuoteSource(row[2]),
54 | }
55 |
56 | }
57 | }
58 |
59 | return out, nil
60 | }
61 |
62 | // GetTickerSymbols retrieves a list of ticker specific symbols and their data source
63 | func GetTickerSymbols(url string) (TickerSymbolToSourceSymbol, error) {
64 | resp, err := http.Get(url) //nolint:gosec
65 | if err != nil {
66 | return TickerSymbolToSourceSymbol{}, err
67 | }
68 | defer resp.Body.Close()
69 |
70 | if resp.StatusCode != http.StatusOK {
71 | return TickerSymbolToSourceSymbol{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
72 | }
73 |
74 | tickerSymbolToSourceSymbol, err := parseTickerSymbolToSourceSymbol(resp.Body)
75 | if err != nil {
76 | return TickerSymbolToSourceSymbol{}, err
77 | }
78 |
79 | return tickerSymbolToSourceSymbol, nil
80 | }
81 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/unary/fixtures_test.go:
--------------------------------------------------------------------------------
1 | package unary_test
2 |
3 | import (
4 | "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
5 | )
6 |
7 | var (
8 | responseQuote1Fixture = unary.Response{
9 | QuoteResponse: unary.ResponseQuoteResponse{
10 | Quotes: []unary.ResponseQuote{
11 | {
12 | MarketState: "REGULAR",
13 | ShortName: "Cloudflare, Inc.",
14 | PreMarketChange: unary.ResponseFieldFloat{Raw: 1.0399933, Fmt: "1.0399933"},
15 | PreMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.2238094, Fmt: "1.2238094"},
16 | PreMarketPrice: unary.ResponseFieldFloat{Raw: 86.03, Fmt: "86.03"},
17 | RegularMarketChange: unary.ResponseFieldFloat{Raw: 3.0800018, Fmt: "3.0800018"},
18 | RegularMarketChangePercent: unary.ResponseFieldFloat{Raw: 3.7606857, Fmt: "3.7606857"},
19 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 84.98, Fmt: "84.98"},
20 | RegularMarketPreviousClose: unary.ResponseFieldFloat{Raw: 84.00, Fmt: "84.00"},
21 | RegularMarketOpen: unary.ResponseFieldFloat{Raw: 85.22, Fmt: "85.22"},
22 | RegularMarketDayHigh: unary.ResponseFieldFloat{Raw: 90.00, Fmt: "90.00"},
23 | RegularMarketDayLow: unary.ResponseFieldFloat{Raw: 80.00, Fmt: "80.00"},
24 | PostMarketChange: unary.ResponseFieldFloat{Raw: 1.37627, Fmt: "1.37627"},
25 | PostMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.35735, Fmt: "1.35735"},
26 | PostMarketPrice: unary.ResponseFieldFloat{Raw: 86.56, Fmt: "86.56"},
27 | Symbol: "NET",
28 | },
29 | },
30 | Error: nil,
31 | },
32 | }
33 | responseQuoteForCurrencyMap1Fixture = unary.Response{
34 | QuoteResponse: unary.ResponseQuoteResponse{
35 | Quotes: []unary.ResponseQuote{
36 | {
37 | ShortName: "Cloudflare, Inc.",
38 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 84.98, Fmt: "84.98"},
39 | Symbol: "NET",
40 | Currency: "USD",
41 | },
42 | },
43 | Error: nil,
44 | },
45 | }
46 | responseQuoteForCurrencyRates1Fixture = unary.Response{
47 | QuoteResponse: unary.ResponseQuoteResponse{
48 | Quotes: []unary.ResponseQuote{
49 | {
50 | Symbol: "EURUSD=X",
51 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 1.1, Fmt: "1.1"},
52 | Currency: "USD",
53 | },
54 | },
55 | Error: nil,
56 | },
57 | }
58 | )
59 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/unary/models.go:
--------------------------------------------------------------------------------
1 | package unary
2 |
3 | // Response represents the container object from the API response
4 | type Response struct {
5 | QuoteResponse ResponseQuoteResponse `json:"quoteResponse"`
6 | }
7 |
8 | type ResponseQuoteResponse struct {
9 | Quotes []ResponseQuote `json:"result"`
10 | Error interface{} `json:"error"`
11 | }
12 |
13 | // ResponseQuote represents a quote of a single security from the API response
14 | type ResponseQuote struct {
15 | ShortName string `json:"shortName"`
16 | Symbol string `json:"symbol"`
17 | MarketState string `json:"marketState"`
18 | Currency string `json:"currency"`
19 | ExchangeName string `json:"fullExchangeName"`
20 | ExchangeDelay float64 `json:"exchangeDataDelayedBy"`
21 | RegularMarketChange ResponseFieldFloat `json:"regularMarketChange"`
22 | RegularMarketChangePercent ResponseFieldFloat `json:"regularMarketChangePercent"`
23 | RegularMarketPrice ResponseFieldFloat `json:"regularMarketPrice"`
24 | RegularMarketPreviousClose ResponseFieldFloat `json:"regularMarketPreviousClose"`
25 | RegularMarketOpen ResponseFieldFloat `json:"regularMarketOpen"`
26 | RegularMarketDayRange ResponseFieldString `json:"regularMarketDayRange"`
27 | RegularMarketDayHigh ResponseFieldFloat `json:"regularMarketDayHigh"`
28 | RegularMarketDayLow ResponseFieldFloat `json:"regularMarketDayLow"`
29 | RegularMarketVolume ResponseFieldFloat `json:"regularMarketVolume"`
30 | PostMarketChange ResponseFieldFloat `json:"postMarketChange"`
31 | PostMarketChangePercent ResponseFieldFloat `json:"postMarketChangePercent"`
32 | PostMarketPrice ResponseFieldFloat `json:"postMarketPrice"`
33 | PreMarketChange ResponseFieldFloat `json:"preMarketChange"`
34 | PreMarketChangePercent ResponseFieldFloat `json:"preMarketChangePercent"`
35 | PreMarketPrice ResponseFieldFloat `json:"preMarketPrice"`
36 | FiftyTwoWeekHigh ResponseFieldFloat `json:"fiftyTwoWeekHigh"`
37 | FiftyTwoWeekLow ResponseFieldFloat `json:"fiftyTwoWeekLow"`
38 | QuoteType string `json:"quoteType"`
39 | MarketCap ResponseFieldFloat `json:"marketCap"`
40 | }
41 |
42 | type ResponseFieldFloat struct {
43 | Raw float64 `json:"raw"`
44 | Fmt string `json:"fmt"`
45 | }
46 |
47 | type ResponseFieldString struct {
48 | Raw string `json:"raw"`
49 | Fmt string `json:"fmt"`
50 | }
51 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/achannarasappa/ticker/v5
2 |
3 | go 1.24.3
4 |
5 | require (
6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
7 | github.com/achannarasappa/term-grid v0.2.4
8 | github.com/adrg/xdg v0.5.3
9 | github.com/charmbracelet/bubbles v0.21.0
10 | github.com/charmbracelet/bubbletea v1.3.6
11 | github.com/charmbracelet/lipgloss v1.1.0
12 | github.com/gorilla/websocket v1.5.3
13 | github.com/lucasb-eyer/go-colorful v1.2.0
14 | github.com/mitchellh/go-homedir v1.1.0
15 | github.com/muesli/reflow v0.3.0
16 | github.com/muesli/termenv v0.16.0
17 | github.com/onsi/ginkgo/v2 v2.23.4
18 | github.com/onsi/gomega v1.37.0
19 | github.com/spf13/afero v1.14.0
20 | github.com/spf13/cobra v1.9.1
21 | github.com/spf13/viper v1.20.1
22 | gopkg.in/yaml.v2 v2.4.0
23 | )
24 |
25 | require (
26 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
27 | github.com/charmbracelet/colorprofile v0.3.1 // indirect
28 | github.com/charmbracelet/x/ansi v0.9.3 // indirect
29 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
30 | github.com/charmbracelet/x/term v0.2.1 // indirect
31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32 | github.com/fsnotify/fsnotify v1.9.0 // indirect
33 | github.com/go-logr/logr v1.4.3 // indirect
34 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
35 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
36 | github.com/google/go-cmp v0.7.0 // indirect
37 | github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
38 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
39 | github.com/mattn/go-isatty v0.0.20 // indirect
40 | github.com/mattn/go-localereader v0.0.1 // indirect
41 | github.com/mattn/go-runewidth v0.0.16 // indirect
42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
43 | github.com/muesli/cancelreader v0.2.2 // indirect
44 | github.com/onsi/ginkgo v1.16.5 // indirect
45 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect
46 | github.com/rivo/uniseg v0.4.7 // indirect
47 | github.com/sagikazarmark/locafero v0.10.0 // indirect
48 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
49 | github.com/spf13/cast v1.9.2 // indirect
50 | github.com/spf13/pflag v1.0.7 // indirect
51 | github.com/subosito/gotenv v1.6.0 // indirect
52 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
53 | go.uber.org/automaxprocs v1.6.0 // indirect
54 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
55 | golang.org/x/net v0.41.0 // indirect
56 | golang.org/x/sync v0.16.0 // indirect
57 | golang.org/x/sys v0.34.0 // indirect
58 | golang.org/x/text v0.27.0 // indirect
59 | golang.org/x/tools v0.34.0 // indirect
60 | google.golang.org/protobuf v1.36.6 // indirect
61 | gopkg.in/yaml.v3 v3.0.1 // indirect
62 | )
63 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/monitor-price/poller/poller.go:
--------------------------------------------------------------------------------
1 | package poller
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "time"
7 |
8 | c "github.com/achannarasappa/ticker/v5/internal/common"
9 | "github.com/achannarasappa/ticker/v5/internal/monitor/coinbase/unary"
10 | )
11 |
12 | type Poller struct {
13 | refreshInterval time.Duration
14 | symbols []string
15 | isStarted bool
16 | ctx context.Context
17 | cancel context.CancelFunc
18 | unaryAPI *unary.UnaryAPI
19 | chanUpdateAssetQuote chan c.MessageUpdate[c.AssetQuote]
20 | chanError chan error
21 | versionVector int
22 | }
23 |
24 | type PollerConfig struct {
25 | UnaryAPI *unary.UnaryAPI
26 | ChanUpdateAssetQuote chan c.MessageUpdate[c.AssetQuote]
27 | ChanError chan error
28 | }
29 |
30 | func NewPoller(ctx context.Context, config PollerConfig) *Poller {
31 | ctx, cancel := context.WithCancel(ctx)
32 |
33 | return &Poller{
34 | refreshInterval: 0,
35 | isStarted: false,
36 | ctx: ctx,
37 | cancel: cancel,
38 | unaryAPI: config.UnaryAPI,
39 | chanUpdateAssetQuote: config.ChanUpdateAssetQuote,
40 | chanError: config.ChanError,
41 | versionVector: 0,
42 | }
43 | }
44 |
45 | func (p *Poller) SetSymbols(symbols []string, versionVector int) {
46 | p.symbols = symbols
47 | p.versionVector = versionVector
48 | }
49 |
50 | func (p *Poller) SetRefreshInterval(interval time.Duration) error {
51 |
52 | if p.isStarted {
53 | return errors.New("cannot set refresh interval while poller is started")
54 | }
55 |
56 | p.refreshInterval = interval
57 |
58 | return nil
59 | }
60 |
61 | func (p *Poller) Start() error {
62 | if p.isStarted {
63 | return errors.New("poller already started")
64 | }
65 |
66 | if p.refreshInterval <= 0 {
67 | return errors.New("refresh interval is not set")
68 | }
69 |
70 | p.isStarted = true
71 |
72 | // Start polling goroutine
73 | go func() {
74 | ticker := time.NewTicker(p.refreshInterval)
75 | defer ticker.Stop()
76 |
77 | for {
78 | select {
79 | case <-p.ctx.Done():
80 | return
81 | case <-ticker.C:
82 | if len(p.symbols) == 0 {
83 |
84 | continue
85 | }
86 | versionVector := p.versionVector
87 | assetQuotes, _, err := p.unaryAPI.GetAssetQuotes(p.symbols)
88 | if err != nil {
89 | p.chanError <- err
90 |
91 | continue
92 | }
93 |
94 | for _, assetQuote := range assetQuotes {
95 | p.chanUpdateAssetQuote <- c.MessageUpdate[c.AssetQuote]{
96 | ID: assetQuote.Meta.SymbolInSourceAPI,
97 | Data: assetQuote,
98 | VersionVector: versionVector,
99 | }
100 | }
101 | }
102 | }
103 | }()
104 |
105 | return nil
106 | }
107 |
--------------------------------------------------------------------------------
/internal/asset/currency.go:
--------------------------------------------------------------------------------
1 | package asset
2 |
3 | import (
4 | c "github.com/achannarasappa/ticker/v5/internal/common"
5 | )
6 |
7 | // currencyRateByUse represents the currency conversion rate for each use case
8 | type currencyRateByUse struct { //nolint:golint,revive
9 | ToCurrencyCode string
10 | QuotePrice float64
11 | PositionCost float64
12 | SummaryValue float64
13 | SummaryCost float64
14 | }
15 |
16 | // getCurrencyRateByUse reads currency rates from the context and sets the conversion rate for each use case
17 | func getCurrencyRateByUse(ctx c.Context, fromCurrency string, toCurrency string, rate float64) currencyRateByUse {
18 |
19 | if rate == 0 {
20 | return currencyRateByUse{
21 | ToCurrencyCode: fromCurrency,
22 | QuotePrice: 1.0,
23 | PositionCost: 1.0,
24 | SummaryValue: 1.0,
25 | SummaryCost: 1.0,
26 | }
27 | }
28 |
29 | currencyRateCost := rate
30 |
31 | if ctx.Config.CurrencyDisableUnitCostConversion {
32 | currencyRateCost = 1.0
33 | }
34 |
35 | // Convert only the summary currency to the configured currency
36 | if ctx.Config.Currency != "" && ctx.Config.CurrencyConvertSummaryOnly {
37 | return currencyRateByUse{
38 | ToCurrencyCode: fromCurrency,
39 | QuotePrice: 1.0,
40 | PositionCost: 1.0,
41 | SummaryValue: rate,
42 | SummaryCost: currencyRateCost,
43 | }
44 | }
45 |
46 | // Convert all quotes and positions to target currency and implicitly convert summary currency (i.e. no conversion since underlying values are already converted)
47 | if ctx.Config.Currency != "" {
48 | return currencyRateByUse{
49 | ToCurrencyCode: toCurrency,
50 | QuotePrice: rate,
51 | PositionCost: currencyRateCost,
52 | SummaryValue: 1.0,
53 | SummaryCost: 1.0,
54 | }
55 | }
56 |
57 | // Convert only the summary currency to the default currency (USD) when currency conversion is not enabled
58 | return currencyRateByUse{
59 | ToCurrencyCode: toCurrency,
60 | QuotePrice: 1.0,
61 | PositionCost: 1.0,
62 | SummaryValue: rate,
63 | SummaryCost: currencyRateCost,
64 | }
65 | }
66 |
67 | func convertAssetQuotePriceCurrency(currencyRateByUse currencyRateByUse, quotePrice c.QuotePrice) c.QuotePrice {
68 | return c.QuotePrice{
69 | Price: quotePrice.Price * currencyRateByUse.QuotePrice,
70 | PricePrevClose: quotePrice.PricePrevClose * currencyRateByUse.QuotePrice,
71 | PriceOpen: quotePrice.PriceOpen * currencyRateByUse.QuotePrice,
72 | PriceDayHigh: quotePrice.PriceDayHigh * currencyRateByUse.QuotePrice,
73 | PriceDayLow: quotePrice.PriceDayLow * currencyRateByUse.QuotePrice,
74 | Change: quotePrice.Change * currencyRateByUse.QuotePrice,
75 | ChangePercent: quotePrice.ChangePercent,
76 | }
77 | }
78 |
79 | func convertAssetQuoteExtendedCurrency(currencyRateByUse currencyRateByUse, quoteExtended c.QuoteExtended) c.QuoteExtended {
80 | return c.QuoteExtended{
81 | FiftyTwoWeekHigh: quoteExtended.FiftyTwoWeekHigh * currencyRateByUse.QuotePrice,
82 | FiftyTwoWeekLow: quoteExtended.FiftyTwoWeekLow * currencyRateByUse.QuotePrice,
83 | MarketCap: quoteExtended.MarketCap * currencyRateByUse.QuotePrice,
84 | Volume: quoteExtended.Volume,
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/internal/sorter/sorter.go:
--------------------------------------------------------------------------------
1 | package sorter
2 |
3 | import (
4 | "sort"
5 |
6 | c "github.com/achannarasappa/ticker/v5/internal/common"
7 | )
8 |
9 | // Sorter represents a function that sorts quotes
10 | type Sorter func([]*c.Asset) []*c.Asset
11 |
12 | // NewSorter creates a sorting function
13 | func NewSorter(sort string) Sorter {
14 | var sortDict = map[string]Sorter{
15 | "alpha": sortByAlpha,
16 | "value": sortByValue,
17 | "user": sortByUser,
18 | }
19 | if sorter, ok := sortDict[sort]; ok {
20 | return sorter
21 | }
22 |
23 | return sortByChange
24 | }
25 |
26 | func sortByUser(assets []*c.Asset) []*c.Asset {
27 |
28 | assetCount := len(assets)
29 |
30 | if assetCount <= 0 {
31 | return assets
32 | }
33 |
34 | sort.SliceStable(assets, func(i, j int) bool {
35 | return assets[j].Meta.OrderIndex > assets[i].Meta.OrderIndex
36 | })
37 |
38 | return assets
39 |
40 | }
41 |
42 | func sortByAlpha(assetsIn []*c.Asset) []*c.Asset {
43 |
44 | assetCount := len(assetsIn)
45 |
46 | if assetCount <= 0 {
47 | return assetsIn
48 | }
49 |
50 | assets := make([]*c.Asset, assetCount)
51 | copy(assets, assetsIn)
52 |
53 | sort.SliceStable(assets, func(i, j int) bool {
54 | return assets[j].Symbol > assets[i].Symbol
55 | })
56 |
57 | return assets
58 | }
59 |
60 | func sortByValue(assetsIn []*c.Asset) []*c.Asset {
61 |
62 | assetCount := len(assetsIn)
63 |
64 | if assetCount <= 0 {
65 | return assetsIn
66 | }
67 |
68 | assets := make([]*c.Asset, assetCount)
69 | copy(assets, assetsIn)
70 |
71 | activeAssets, inactiveAssets := splitActiveAssets(assets)
72 |
73 | sort.SliceStable(inactiveAssets, func(i, j int) bool {
74 | return inactiveAssets[j].Holding.Value < inactiveAssets[i].Holding.Value
75 | })
76 |
77 | sort.SliceStable(activeAssets, func(i, j int) bool {
78 | return activeAssets[j].Holding.Value < activeAssets[i].Holding.Value
79 | })
80 |
81 | return append(activeAssets, inactiveAssets...)
82 | }
83 |
84 | func sortByChange(assetsIn []*c.Asset) []*c.Asset {
85 |
86 | assetCount := len(assetsIn)
87 |
88 | if assetCount <= 0 {
89 | return assetsIn
90 | }
91 |
92 | assets := make([]*c.Asset, assetCount)
93 | copy(assets, assetsIn)
94 |
95 | activeAssets, inactiveAssets := splitActiveAssets(assets)
96 |
97 | sort.SliceStable(activeAssets, func(i, j int) bool {
98 | return activeAssets[j].QuotePrice.ChangePercent < activeAssets[i].QuotePrice.ChangePercent
99 | })
100 |
101 | sort.SliceStable(inactiveAssets, func(i, j int) bool {
102 | return inactiveAssets[j].QuotePrice.ChangePercent < inactiveAssets[i].QuotePrice.ChangePercent
103 | })
104 |
105 | return append(activeAssets, inactiveAssets...)
106 |
107 | }
108 |
109 | func splitActiveAssets(assets []*c.Asset) ([]*c.Asset, []*c.Asset) {
110 |
111 | activeAssets := make([]*c.Asset, 0)
112 | inactiveAssets := make([]*c.Asset, 0)
113 |
114 | for _, asset := range assets {
115 | if asset.Exchange.IsActive {
116 | activeAssets = append(activeAssets, asset)
117 | } else {
118 | inactiveAssets = append(inactiveAssets, asset)
119 | }
120 | }
121 |
122 | return activeAssets, inactiveAssets
123 | }
124 |
--------------------------------------------------------------------------------
/test/contract/yahoo/schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "properties": {
3 | "quoteResponse": {
4 | "type": "object",
5 | "properties": {
6 | "result": {
7 | "$ref": "#/definitions/result"
8 | },
9 | "error": {
10 | "type": "null"
11 | }
12 | }
13 | }
14 | },
15 | "definitions": {
16 | "result": {
17 | "type": "array",
18 | "items": {
19 | "$ref": "#/definitions/quote"
20 | }
21 | },
22 | "quote": {
23 | "properties": {
24 | "marketState": {
25 | "type": "string"
26 | },
27 | "shortName": {
28 | "type": "string"
29 | },
30 | "regularMarketChange": {
31 | "$ref": "#/definitions/fieldNumber"
32 | },
33 | "regularMarketChangePercent": {
34 | "$ref": "#/definitions/fieldNumber"
35 | },
36 | "regularMarketPrice": {
37 | "$ref": "#/definitions/fieldNumber"
38 | },
39 | "regularMarketTime": {
40 | "$ref": "#/definitions/fieldInteger"
41 | },
42 | "regularMarketPreviousClose": {
43 | "$ref": "#/definitions/fieldNumber"
44 | },
45 | "regularMarketOpen": {
46 | "$ref": "#/definitions/fieldNumber"
47 | },
48 | "regularMarketDayRange": {
49 | "$ref": "#/definitions/fieldString"
50 | },
51 | "regularMarketDayHigh": {
52 | "$ref": "#/definitions/fieldNumber"
53 | },
54 | "regularMarketDayLow": {
55 | "$ref": "#/definitions/fieldNumber"
56 | },
57 | "regularMarketVolume": {
58 | "$ref": "#/definitions/fieldNumber"
59 | },
60 | "postMarketChange": {
61 | "$ref": "#/definitions/fieldNumber"
62 | },
63 | "postMarketChangePercent": {
64 | "$ref": "#/definitions/fieldNumber"
65 | },
66 | "postMarketPrice": {
67 | "$ref": "#/definitions/fieldNumber"
68 | },
69 | "preMarketChange": {
70 | "$ref": "#/definitions/fieldNumber"
71 | },
72 | "preMarketChangePercent": {
73 | "$ref": "#/definitions/fieldNumber"
74 | },
75 | "preMarketPrice": {
76 | "$ref": "#/definitions/fieldNumber"
77 | },
78 | "fiftyTwoWeekHigh": {
79 | "$ref": "#/definitions/fieldNumber"
80 | },
81 | "fiftyTwoWeekLow": {
82 | "$ref": "#/definitions/fieldNumber"
83 | },
84 | "symbol": {
85 | "type": "string"
86 | },
87 | "fullExchangeName": {
88 | "type": "string"
89 | },
90 | "exchangeDataDelayedBy": {
91 | "type": "number"
92 | },
93 | "marketCap": {
94 | "$ref": "#/definitions/fieldNumber"
95 | },
96 | "quoteType": {
97 | "type": "string"
98 | }
99 | }
100 | },
101 | "fieldNumber": {
102 | "properties": {
103 | "raw": {
104 | "type": "number"
105 | },
106 | "fmt": {
107 | "type": "string"
108 | }
109 | }
110 | },
111 | "fieldInteger": {
112 | "properties": {
113 | "raw": {
114 | "type": "integer"
115 | },
116 | "fmt": {
117 | "type": "string"
118 | }
119 | }
120 | },
121 | "fieldString": {
122 | "properties": {
123 | "raw": {
124 | "type": "string"
125 | },
126 | "fmt": {
127 | "type": "string"
128 | }
129 | }
130 | }
131 | },
132 | "required": [
133 | "quoteResponse"
134 | ]
135 | }
136 |
--------------------------------------------------------------------------------
/internal/ui/component/summary/summary_test.go:
--------------------------------------------------------------------------------
1 | package summary_test
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/achannarasappa/ticker/v5/internal/asset"
7 | c "github.com/achannarasappa/ticker/v5/internal/common"
8 | . "github.com/achannarasappa/ticker/v5/internal/ui/component/summary"
9 |
10 | "github.com/acarl005/stripansi"
11 | tea "github.com/charmbracelet/bubbletea"
12 | . "github.com/onsi/ginkgo/v2"
13 | . "github.com/onsi/gomega"
14 | )
15 |
16 | func removeFormatting(text string) string {
17 | return stripansi.Strip(text)
18 | }
19 |
20 | var _ = Describe("Summary", func() {
21 |
22 | ctxFixture := c.Context{Reference: c.Reference{Styles: c.Styles{
23 | Text: func(v string) string { return v },
24 | TextLight: func(v string) string { return v },
25 | TextLabel: func(v string) string { return v },
26 | TextBold: func(v string) string { return v },
27 | TextLine: func(v string) string { return v },
28 | TextPrice: func(percent float64, text string) string { return text },
29 | Tag: func(v string) string { return v },
30 | }}}
31 |
32 | When("the change is positive", func() {
33 | It("should render a summary with up arrow", func() {
34 | m := NewModel(ctxFixture)
35 | m, _ = m.Update(tea.WindowSizeMsg{Width: 120})
36 | m, _ = m.Update(SetSummaryMsg(asset.HoldingSummary{
37 | Value: 10000,
38 | Cost: 1000,
39 | DayChange: c.HoldingChange{
40 | Amount: 100.0,
41 | Percent: 10.0,
42 | },
43 | TotalChange: c.HoldingChange{
44 | Amount: 9000,
45 | Percent: 1000.0,
46 | },
47 | }))
48 | Expect(removeFormatting(m.View())).To(Equal(strings.Join([]string{
49 | "Day Change: ↑ 100.00 (10.00%) • Change: ↑ 9000.00 (1000.00%) • Value: 10000.00 • Cost: 1000.00 ",
50 | "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
51 | }, "\n")))
52 | })
53 | })
54 |
55 | When("the change is negative", func() {
56 | It("should render a summary with down arrow", func() {
57 | m := NewModel(ctxFixture)
58 | m, _ = m.Update(tea.WindowSizeMsg{Width: 120})
59 | m, _ = m.Update(SetSummaryMsg(asset.HoldingSummary{
60 | Value: 1000,
61 | Cost: 10000,
62 | DayChange: c.HoldingChange{
63 | Amount: -100.0,
64 | Percent: -10.0,
65 | },
66 | TotalChange: c.HoldingChange{
67 | Amount: -9000,
68 | Percent: -1000.0,
69 | },
70 | }))
71 | Expect(removeFormatting(m.View())).To(Equal(strings.Join([]string{
72 | "Day Change: ↓ -100.00 (-10.00%) • Change: ↓ -9000.00 (-1000.00%) • Value: 1000.00 • Cost: 10000.00",
73 | "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
74 | }, "\n")))
75 | })
76 | })
77 |
78 | When("no quotes are set", func() {
79 | It("should render an empty summary", func() {
80 | m := NewModel(ctxFixture)
81 | Expect(removeFormatting(m.View())).To(Equal(strings.Join([]string{
82 | "Day Change: 0.00 (0.00%) • Change: 0.00 (0.00%) • Value: 0.00 • Cost: 0.00 ",
83 | "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
84 | }, "\n")))
85 | })
86 | })
87 |
88 | When("the window width is less than the minimum", func() {
89 | It("should render an empty summary", func() {
90 | m := NewModel(ctxFixture)
91 | m, _ = m.Update(tea.WindowSizeMsg{Width: 10})
92 | Expect(m.View()).To(Equal(""))
93 | })
94 | })
95 | })
96 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-price/poller/poller.go:
--------------------------------------------------------------------------------
1 | package poller
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "time"
7 |
8 | c "github.com/achannarasappa/ticker/v5/internal/common"
9 | "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
10 | )
11 |
12 | // Poller represents a poller for Yahoo Finance
13 | type Poller struct {
14 | refreshInterval time.Duration
15 | symbols []string
16 | isStarted bool
17 | ctx context.Context
18 | cancel context.CancelFunc
19 | unaryAPI *unary.UnaryAPI
20 | chanUpdateAssetQuote chan c.MessageUpdate[c.AssetQuote]
21 | chanError chan error
22 | versionVector int
23 | }
24 |
25 | // PollerConfig represents the configuration for the poller
26 | type PollerConfig struct {
27 | UnaryAPI *unary.UnaryAPI
28 | ChanUpdateAssetQuote chan c.MessageUpdate[c.AssetQuote]
29 | ChanError chan error
30 | }
31 |
32 | // NewPoller creates a new poller
33 | func NewPoller(ctx context.Context, config PollerConfig) *Poller {
34 | ctx, cancel := context.WithCancel(ctx)
35 |
36 | return &Poller{
37 | refreshInterval: 0,
38 | isStarted: false,
39 | ctx: ctx,
40 | cancel: cancel,
41 | unaryAPI: config.UnaryAPI,
42 | chanUpdateAssetQuote: config.ChanUpdateAssetQuote,
43 | chanError: config.ChanError,
44 | versionVector: 0,
45 | }
46 | }
47 |
48 | // SetSymbols sets the symbols to poll
49 | func (p *Poller) SetSymbols(symbols []string, versionVector int) {
50 | p.symbols = symbols
51 | p.versionVector = versionVector
52 | }
53 |
54 | // SetRefreshInterval sets the refresh interval for the poller
55 | func (p *Poller) SetRefreshInterval(interval time.Duration) error {
56 |
57 | if p.isStarted {
58 | return errors.New("cannot set refresh interval while poller is started")
59 | }
60 |
61 | p.refreshInterval = interval
62 |
63 | return nil
64 | }
65 |
66 | // Start starts the poller
67 | func (p *Poller) Start() error {
68 | if p.isStarted {
69 | return errors.New("poller already started")
70 | }
71 |
72 | if p.refreshInterval <= 0 {
73 | return errors.New("refresh interval is not set")
74 | }
75 |
76 | p.isStarted = true
77 |
78 | // Start polling goroutine
79 | go func() {
80 | ticker := time.NewTicker(p.refreshInterval)
81 | defer ticker.Stop()
82 |
83 | for {
84 | select {
85 | case <-p.ctx.Done():
86 |
87 | return
88 | case <-ticker.C:
89 | // Skip making a HTTP request if no symbols are set
90 | if len(p.symbols) == 0 {
91 |
92 | continue
93 | }
94 |
95 | versionVector := p.versionVector
96 |
97 | // Make a HTTP request to get the asset quotes
98 | assetQuotes, _, err := p.unaryAPI.GetAssetQuotes(p.symbols)
99 |
100 | if err != nil {
101 | p.chanError <- err
102 |
103 | continue
104 | }
105 |
106 | // Send the asset quotes to the update channel
107 | for _, assetQuote := range assetQuotes {
108 | p.chanUpdateAssetQuote <- c.MessageUpdate[c.AssetQuote]{
109 | ID: assetQuote.Meta.SymbolInSourceAPI,
110 | Data: assetQuote,
111 | VersionVector: versionVector,
112 | }
113 | }
114 | }
115 | }
116 | }()
117 |
118 | return nil
119 | }
120 |
121 | // Stop stops the poller
122 | func (p *Poller) Stop() error {
123 | p.cancel()
124 |
125 | return nil
126 | }
127 |
--------------------------------------------------------------------------------
/internal/ui/component/summary/summary.go:
--------------------------------------------------------------------------------
1 | package summary
2 |
3 | import (
4 | "strings"
5 |
6 | grid "github.com/achannarasappa/term-grid"
7 | "github.com/achannarasappa/ticker/v5/internal/asset"
8 | c "github.com/achannarasappa/ticker/v5/internal/common"
9 | tea "github.com/charmbracelet/bubbletea"
10 |
11 | u "github.com/achannarasappa/ticker/v5/internal/ui/util"
12 | "github.com/muesli/reflow/ansi"
13 | )
14 |
15 | // Model for summary section
16 | type Model struct {
17 | width int
18 | summary asset.HoldingSummary
19 | styles c.Styles
20 | }
21 |
22 | type SetSummaryMsg asset.HoldingSummary
23 |
24 | // NewModel returns a model with default values
25 | func NewModel(ctx c.Context) *Model {
26 | return &Model{
27 | width: 80,
28 | styles: ctx.Reference.Styles,
29 | }
30 | }
31 |
32 | // Init initializes the summary component
33 | func (m *Model) Init() tea.Cmd {
34 | return nil
35 | }
36 |
37 | // Update handles messages for the summary component
38 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) {
39 | switch msg := msg.(type) {
40 | case tea.WindowSizeMsg:
41 | m.width = msg.Width
42 |
43 | return m, nil
44 | case SetSummaryMsg:
45 | m.summary = asset.HoldingSummary(msg)
46 |
47 | return m, nil
48 | }
49 |
50 | return m, nil
51 | }
52 |
53 | // View rendering hook for bubbletea
54 | func (m *Model) View() string {
55 |
56 | if m.width < 80 {
57 | return ""
58 | }
59 |
60 | textChange := m.styles.TextLabel("Day Change: ") + quoteChangeText(m.summary.DayChange.Amount, m.summary.DayChange.Percent, m.styles) +
61 | m.styles.TextLabel(" • ") +
62 | m.styles.TextLabel("Change: ") + quoteChangeText(m.summary.TotalChange.Amount, m.summary.TotalChange.Percent, m.styles)
63 | widthChange := ansi.PrintableRuneWidth(textChange)
64 | textValue := m.styles.TextLabel(" • ") +
65 | m.styles.TextLabel("Value: ") + m.styles.TextLabel(u.ConvertFloatToString(m.summary.Value, false))
66 | widthValue := ansi.PrintableRuneWidth(textValue)
67 | textCost := m.styles.TextLabel(" • ") +
68 | m.styles.TextLabel("Cost: ") + m.styles.TextLabel(u.ConvertFloatToString(m.summary.Cost, false))
69 | widthCost := ansi.PrintableRuneWidth(textValue)
70 |
71 | return grid.Render(grid.Grid{
72 | Rows: []grid.Row{
73 | {
74 | Width: m.width,
75 | Cells: []grid.Cell{
76 | {
77 | Text: textChange,
78 | Width: widthChange,
79 | },
80 | {
81 | Text: textValue,
82 | Width: widthValue,
83 | VisibleMinWidth: widthChange + widthValue,
84 | },
85 | {
86 | Text: textCost,
87 | Width: widthCost,
88 | VisibleMinWidth: widthChange + widthValue + widthCost,
89 | },
90 | },
91 | },
92 | {
93 | Width: m.width,
94 | Cells: []grid.Cell{
95 | {Text: m.styles.TextLine(strings.Repeat("━", m.width))},
96 | },
97 | },
98 | },
99 | GutterHorizontal: 1,
100 | })
101 |
102 | }
103 |
104 | func quoteChangeText(change float64, changePercent float64, styles c.Styles) string {
105 | if change == 0.0 {
106 | return styles.TextLabel(u.ConvertFloatToString(change, false) + " (" + u.ConvertFloatToString(changePercent, false) + "%)")
107 | }
108 |
109 | if change > 0.0 {
110 | return styles.TextPrice(changePercent, "↑ "+u.ConvertFloatToString(change, false)+" ("+u.ConvertFloatToString(changePercent, false)+"%)")
111 | }
112 |
113 | return styles.TextPrice(changePercent, "↓ "+u.ConvertFloatToString(change, false)+" ("+u.ConvertFloatToString(changePercent, false)+"%)")
114 | }
115 |
--------------------------------------------------------------------------------
/test/contract/yahoo/api_test.go:
--------------------------------------------------------------------------------
1 | package api_test
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 |
7 | c "github.com/achannarasappa/ticker/v5/internal/common"
8 | unary "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
9 |
10 | . "github.com/onsi/ginkgo/v2"
11 | . "github.com/onsi/gomega"
12 | )
13 |
14 | // Define GetAssetQuotesResponseSchema by loading it from schema.json
15 | var GetAssetQuotesResponseSchema string
16 |
17 | func init() {
18 | // Load the schema file
19 | schemaBytes, err := os.ReadFile(filepath.Join("schema.json"))
20 | if err != nil {
21 | panic("Failed to load schema file: " + err.Error())
22 | }
23 | GetAssetQuotesResponseSchema = string(schemaBytes)
24 | }
25 |
26 | var _ = Describe("Yahoo API", func() {
27 | Describe("GetAssetQuotes Response", func() {
28 | It("should have expected fields in the response", func() {
29 | // Setup API client
30 | client := unary.NewUnaryAPI(unary.Config{
31 | BaseURL: "https://query1.finance.yahoo.com",
32 | SessionRootURL: "https://finance.yahoo.com",
33 | SessionCrumbURL: "https://query2.finance.yahoo.com",
34 | SessionConsentURL: "https://consent.yahoo.com",
35 | })
36 |
37 | // Get quotes using the client API
38 | quotes, _, err := client.GetAssetQuotes([]string{"AAPL"})
39 | Expect(err).NotTo(HaveOccurred())
40 | Expect(quotes).NotTo(BeEmpty())
41 |
42 | // Get the first quote for AAPL
43 | quote := quotes[0]
44 |
45 | // Validate the quote structure matches the AssetQuote properties
46 | // Basic properties
47 | Expect(quote.Symbol).To(Equal("AAPL"))
48 | Expect(quote.Name).To(ContainSubstring("Apple"))
49 |
50 | // Asset class
51 | Expect(quote.Class).To(BeNumerically(">=", 0))
52 |
53 | // Currency
54 | Expect(quote.Currency.FromCurrencyCode).NotTo(BeEmpty())
55 |
56 | // QuotePrice properties
57 | Expect(quote.QuotePrice.Price).NotTo(BeZero())
58 | Expect(quote.QuotePrice.PricePrevClose).NotTo(BeZero())
59 | Expect(quote.QuotePrice.PriceOpen).NotTo(BeZero())
60 | Expect(quote.QuotePrice.PriceDayHigh).NotTo(BeZero())
61 | Expect(quote.QuotePrice.PriceDayLow).NotTo(BeZero())
62 | Expect(quote.QuotePrice.Change).To(BeAssignableToTypeOf(0.0))
63 | Expect(quote.QuotePrice.ChangePercent).To(BeAssignableToTypeOf(0.0))
64 |
65 | // QuoteExtended properties
66 | Expect(quote.QuoteExtended.FiftyTwoWeekHigh).NotTo(BeZero())
67 | Expect(quote.QuoteExtended.FiftyTwoWeekLow).NotTo(BeZero())
68 | Expect(quote.QuoteExtended.MarketCap).NotTo(BeZero())
69 | Expect(quote.QuoteExtended.Volume).NotTo(BeZero())
70 |
71 | // Exchange properties
72 | Expect(quote.Exchange.Name).NotTo(BeEmpty())
73 | Expect(quote.Exchange.Delay).To(BeNumerically(">=", 0))
74 | Expect(quote.Exchange.State).To(BeNumerically(">=", 0))
75 |
76 | // QuoteSource
77 | Expect(quote.QuoteSource).To(Equal(c.QuoteSourceYahoo))
78 |
79 | // Meta
80 | Expect(quote.Meta.SymbolInSourceAPI).To(Equal("AAPL"))
81 |
82 | // Check that PostMarket and PreMarket are conditionally present
83 | // We only validate them if they're present, as they depend on market times
84 | if quote.Exchange.State == 2 { // PostMarket
85 | Expect(quote.QuotePrice.Change).NotTo(BeZero())
86 | Expect(quote.QuotePrice.ChangePercent).NotTo(BeZero())
87 | Expect(quote.QuotePrice.Price).NotTo(BeZero())
88 | }
89 |
90 | if quote.Exchange.State == 1 { // PreMarket
91 | Expect(quote.QuotePrice.Change).NotTo(BeZero())
92 | Expect(quote.QuotePrice.ChangePercent).NotTo(BeZero())
93 | Expect(quote.QuotePrice.Price).NotTo(BeZero())
94 | }
95 | })
96 | })
97 | })
98 |
--------------------------------------------------------------------------------
/internal/asset/asset_fixture_test.go:
--------------------------------------------------------------------------------
1 | package asset_test
2 |
3 | import (
4 | c "github.com/achannarasappa/ticker/v5/internal/common"
5 | )
6 |
7 | var fixtureAssetGroupQuote = c.AssetGroupQuote{
8 | AssetGroup: c.AssetGroup{
9 | ConfigAssetGroup: c.ConfigAssetGroup{
10 | Name: "default",
11 | Watchlist: []string{
12 | "TWKS",
13 | "MSFT",
14 | "SOL1-USD",
15 | },
16 | },
17 | },
18 | AssetQuotes: []c.AssetQuote{
19 | {
20 | Name: "ThoughtWorks",
21 | Symbol: "TWKS",
22 | Class: c.AssetClassStock,
23 | Currency: c.Currency{FromCurrencyCode: "USD"},
24 | QuotePrice: c.QuotePrice{
25 | Price: 110.0,
26 | PricePrevClose: 100.0,
27 | PriceOpen: 100.0,
28 | PriceDayHigh: 110.0,
29 | PriceDayLow: 90.0,
30 | Change: 10.0,
31 | ChangePercent: 10.0,
32 | },
33 | QuoteExtended: c.QuoteExtended{
34 | FiftyTwoWeekHigh: 150,
35 | FiftyTwoWeekLow: 50,
36 | MarketCap: 1000000,
37 | },
38 | },
39 | {
40 | Name: "Microsoft Inc",
41 | Symbol: "MSFT",
42 | Class: c.AssetClassStock,
43 | Currency: c.Currency{FromCurrencyCode: "USD"},
44 | QuotePrice: c.QuotePrice{
45 | Price: 220.0,
46 | PricePrevClose: 200.0,
47 | PriceOpen: 200.0,
48 | PriceDayHigh: 220.0,
49 | PriceDayLow: 180.0,
50 | Change: 20.0,
51 | ChangePercent: 10.0,
52 | },
53 | },
54 | {
55 | Name: "Solana USD",
56 | Symbol: "SOL1-USD",
57 | Class: c.AssetClassCryptocurrency,
58 | Currency: c.Currency{FromCurrencyCode: "USD"},
59 | QuotePrice: c.QuotePrice{
60 | Price: 11.0,
61 | PricePrevClose: 10.0,
62 | PriceOpen: 10.0,
63 | PriceDayHigh: 11.0,
64 | PriceDayLow: 9.0,
65 | Change: 1.0,
66 | ChangePercent: 10.0,
67 | },
68 | Meta: c.Meta{
69 | IsVariablePrecision: true,
70 | },
71 | },
72 | },
73 | }
74 | var fixtureAssets = []c.Asset{
75 | {
76 | Name: "ThoughtWorks",
77 | Symbol: "TWKS",
78 | Class: c.AssetClassStock,
79 | Currency: c.Currency{FromCurrencyCode: "USD", ToCurrencyCode: "USD"},
80 | QuotePrice: c.QuotePrice{
81 | Price: 110.0,
82 | PricePrevClose: 100.0,
83 | PriceOpen: 100.0,
84 | PriceDayHigh: 110.0,
85 | PriceDayLow: 90.0,
86 | Change: 10.0,
87 | ChangePercent: 10.0,
88 | },
89 | QuoteExtended: c.QuoteExtended{
90 | FiftyTwoWeekHigh: 150,
91 | FiftyTwoWeekLow: 50,
92 | MarketCap: 1000000,
93 | },
94 | Meta: c.Meta{
95 | OrderIndex: 0,
96 | },
97 | },
98 | {
99 | Name: "Microsoft Inc",
100 | Symbol: "MSFT",
101 | Class: c.AssetClassStock,
102 | Currency: c.Currency{FromCurrencyCode: "USD", ToCurrencyCode: "USD"},
103 | QuotePrice: c.QuotePrice{
104 | Price: 220.0,
105 | PricePrevClose: 200.0,
106 | PriceOpen: 200.0,
107 | PriceDayHigh: 220.0,
108 | PriceDayLow: 180.0,
109 | Change: 20.0,
110 | ChangePercent: 10.0,
111 | },
112 | Meta: c.Meta{
113 | OrderIndex: 1,
114 | },
115 | },
116 | {
117 | Name: "Solana USD",
118 | Symbol: "SOL1-USD",
119 | Class: c.AssetClassCryptocurrency,
120 | Currency: c.Currency{FromCurrencyCode: "USD", ToCurrencyCode: "USD"},
121 | QuotePrice: c.QuotePrice{
122 | Price: 11.0,
123 | PricePrevClose: 10.0,
124 | PriceOpen: 10.0,
125 | PriceDayHigh: 11.0,
126 | PriceDayLow: 9.0,
127 | Change: 1.0,
128 | ChangePercent: 10.0,
129 | },
130 | Meta: c.Meta{
131 | OrderIndex: 2,
132 | IsVariablePrecision: true,
133 | },
134 | },
135 | }
136 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/spf13/cobra"
8 |
9 | "github.com/achannarasappa/ticker/v5/internal/cli"
10 | c "github.com/achannarasappa/ticker/v5/internal/common"
11 | "github.com/achannarasappa/ticker/v5/internal/print"
12 | "github.com/achannarasappa/ticker/v5/internal/ui"
13 | )
14 |
15 | //nolint:gochecknoglobals
16 | var (
17 | // Version is a placeholder that is replaced at build time with a linker flag (-ldflags)
18 | Version = "v0.0.0"
19 | configPath string
20 | dep c.Dependencies
21 | ctx c.Context
22 | config c.Config
23 | options cli.Options
24 | optionsPrint print.Options
25 | err error
26 | rootCmd = &cobra.Command{
27 | Version: Version,
28 | Use: "ticker",
29 | Short: "Terminal stock ticker and stock gain/loss tracker",
30 | PreRun: initContext,
31 | Args: cli.Validate(&config, &options, &err),
32 | Run: cli.Run(ui.Start(&dep, &ctx)),
33 | }
34 | printCmd = &cobra.Command{
35 | Use: "print",
36 | Short: "Prints holdings",
37 | PreRun: initContext,
38 | Args: cli.Validate(&config, &options, &err),
39 | Run: print.Run(&dep, &ctx, &optionsPrint),
40 | }
41 | summaryCmd = &cobra.Command{
42 | Use: "summary",
43 | Short: "Prints holdings summary for the default group",
44 | PreRun: initContext,
45 | Args: cli.Validate(&config, &options, &err),
46 | Run: print.RunSummary(&dep, &ctx, &optionsPrint),
47 | }
48 | )
49 |
50 | // Execute starts the CLI or prints an error is there is one
51 | func Execute() {
52 | if err := rootCmd.Execute(); err != nil {
53 | fmt.Println(err)
54 | os.Exit(1)
55 | }
56 | }
57 |
58 | func init() { //nolint: gochecknoinits
59 | cobra.OnInitialize(initConfig)
60 |
61 | rootCmd.Flags().StringVar(&configPath, "config", "", "config file (default is $HOME/.ticker.yaml)")
62 | rootCmd.Flags().StringVarP(&options.Watchlist, "watchlist", "w", "", "comma separated list of symbols to watch")
63 | rootCmd.Flags().IntVarP(&options.RefreshInterval, "interval", "i", 0, "refresh interval in seconds")
64 | rootCmd.Flags().BoolVar(&options.Separate, "show-separator", false, "layout with separators between each quote")
65 | rootCmd.Flags().BoolVar(&options.ExtraInfoExchange, "show-tags", false, "display currency, exchange name, and quote delay for each quote")
66 | rootCmd.Flags().BoolVar(&options.ExtraInfoFundamentals, "show-fundamentals", false, "display open price, high, low, and volume for each quote")
67 | rootCmd.Flags().BoolVar(&options.ShowSummary, "show-summary", false, "display summary of total gain and loss for positions")
68 | rootCmd.Flags().BoolVar(&options.ShowHoldings, "show-holdings", false, "display average unit cost, quantity, portfolio weight")
69 | 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")
70 |
71 | 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.")
72 | printCmd.PersistentFlags().StringVar(&configPath, "config", "", "config file (default is $HOME/.ticker.yaml)")
73 | printCmd.AddCommand(summaryCmd)
74 |
75 | rootCmd.AddCommand(printCmd)
76 | }
77 |
78 | func initConfig() {
79 |
80 | dep = cli.GetDependencies()
81 |
82 | config, err = cli.GetConfig(dep, configPath, options)
83 |
84 | if err != nil {
85 | fmt.Println(err)
86 | os.Exit(1)
87 | }
88 |
89 | }
90 |
91 | func initContext(_ *cobra.Command, _ []string) {
92 |
93 | ctx, err = cli.GetContext(dep, config)
94 |
95 | if err != nil {
96 | fmt.Println(err)
97 | os.Exit(1)
98 | }
99 |
100 | }
101 |
--------------------------------------------------------------------------------
/internal/ui/util/style.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "math"
5 | "regexp"
6 |
7 | c "github.com/achannarasappa/ticker/v5/internal/common"
8 | "github.com/lucasb-eyer/go-colorful"
9 | te "github.com/muesli/termenv"
10 | )
11 |
12 | const (
13 | maxPercentChangeColorGradient = 10
14 | )
15 |
16 | //nolint:gochecknoglobals
17 | var (
18 | p = te.ColorProfile()
19 | stylePricePositive = newStyleFromGradient("#C6FF40", "#779929")
20 | stylePriceNegative = newStyleFromGradient("#FF7940", "#994926")
21 | )
22 |
23 | // NewStyle creates a new predefined style function
24 | func NewStyle(fg string, bg string, bold bool) func(string) string {
25 | s := te.Style{}.Foreground(p.Color(fg)).Background(p.Color(bg))
26 | if bold {
27 | s = s.Bold()
28 | }
29 |
30 | return s.Styled
31 | }
32 |
33 | func stylePrice(percent float64, text string) string { //nolint:cyclop
34 |
35 | out := te.String(text)
36 |
37 | if percent == 0.0 {
38 | return out.Foreground(p.Color("241")).String()
39 | }
40 |
41 | if p == te.TrueColor && percent > 0.0 {
42 | return stylePricePositive(percent, text)
43 | }
44 |
45 | if p == te.TrueColor && percent < 0.0 {
46 | return stylePriceNegative(percent, text)
47 | }
48 |
49 | if percent > 10.0 {
50 | return out.Foreground(p.Color("70")).String()
51 | }
52 |
53 | if percent > 5 {
54 | return out.Foreground(p.Color("76")).String()
55 | }
56 |
57 | if percent > 0.0 {
58 | return out.Foreground(p.Color("82")).String()
59 | }
60 |
61 | if percent < -10.0 {
62 | return out.Foreground(p.Color("124")).String()
63 | }
64 |
65 | if percent < -5.0 {
66 | return out.Foreground(p.Color("160")).String()
67 | }
68 |
69 | return out.Foreground(p.Color("196")).String()
70 |
71 | }
72 |
73 | func newStyleFromGradient(startColorHex string, endColorHex string) func(float64, string) string {
74 | c1, _ := colorful.Hex(startColorHex)
75 | c2, _ := colorful.Hex(endColorHex)
76 |
77 | return func(percent float64, text string) string {
78 |
79 | normalizedPercent := getNormalizedPercentWithMax(percent, maxPercentChangeColorGradient)
80 | textColor := p.Color(c1.BlendHsv(c2, normalizedPercent).Hex())
81 |
82 | return te.String(text).Foreground(textColor).String()
83 |
84 | }
85 | }
86 |
87 | // Normalize 0-100 percent with a maximum percent value
88 | func getNormalizedPercentWithMax(percent float64, maxPercent float64) float64 {
89 |
90 | absolutePercent := math.Abs(percent)
91 | if absolutePercent >= maxPercent {
92 | return 1.0
93 | }
94 |
95 | return math.Abs(percent / maxPercent)
96 |
97 | }
98 |
99 | // GetColorScheme generates a color scheme based on user defined colors or defaults
100 | func GetColorScheme(colorScheme c.ConfigColorScheme) c.Styles {
101 |
102 | return c.Styles{
103 | Text: NewStyle(
104 | getColorOrDefault(colorScheme.Text, "#d0d0d0"),
105 | "",
106 | false,
107 | ),
108 | TextLight: NewStyle(
109 | getColorOrDefault(colorScheme.TextLight, "#8a8a8a"),
110 | "",
111 | false,
112 | ),
113 | TextBold: NewStyle(
114 | getColorOrDefault(colorScheme.Text, "#d0d0d0"),
115 | "",
116 | true,
117 | ),
118 | TextLabel: NewStyle(
119 | getColorOrDefault(colorScheme.TextLabel, "#626262"),
120 | "",
121 | false,
122 | ),
123 | TextLine: NewStyle(
124 | getColorOrDefault(colorScheme.TextLine, "#3a3a3a"),
125 | "",
126 | false,
127 | ),
128 | TextPrice: stylePrice,
129 | Tag: NewStyle(
130 | getColorOrDefault(colorScheme.TextTag, "#8a8a8a"),
131 | getColorOrDefault(colorScheme.BackgroundTag, "#303030"),
132 | false,
133 | ),
134 | }
135 |
136 | }
137 |
138 | func getColorOrDefault(colorConfig string, colorDefault string) string {
139 | re := regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}){1,2}$`)
140 |
141 | if len(re.FindStringIndex(colorConfig)) > 0 {
142 | return colorConfig
143 | }
144 |
145 | return colorDefault
146 | }
147 |
--------------------------------------------------------------------------------
/internal/ui/component/watchlist/snapshots/watchlist-all-options.snap:
--------------------------------------------------------------------------------
1 | STOCK1 ● Market Cap: 23.468 M Day Range: 90.00 - 120.00 Prev. Close: 100.00 105.00
2 | Stock 1 Inc. (gain) Volume: 4.2398 B 52wk Range: 0.00 - 0.00 Open: 110.00 ↑ 5.00 (5.00%)
3 | Live
4 | STOCK2 ● Market Cap: 0.00 Day Range: 90.00 - 120.00 Prev. Close: 100.00 95.00
5 | Stock 2 Inc. (loss) Volume: 0.00 52wk Range: 0.00 - 150.00 Open: 110.00 ↓ -5.00 (-5.00%)
6 | Live
7 | STOCK3 ○ Market Cap: 0.00 Day Range: 90.00 - 120.00 Prev. Close: 100.00 105.00
8 | Stock 3 Inc. (gain, Volume: 0.00 52wk Range: 0.00 - 150.00 Open: 110.00 ↑ 5.00 (5.00%)
9 | Live
10 | STOCK4 ● Market Cap: 0.00 Day Range: 90.00 - 120.00 Prev. Close: 100.00 Avg. Cost: 0.00 105.00 (0.00%) 105.00
11 | Stock 4 Inc. (positi Volume: 0.00 52wk Range: 0.00 - 150.00 Open: 110.00 Quantity: 100.00 ↑ 55.00 (110.00%) ↑ 5.00 (5.00%)
12 | Live
13 | STOCK5 ● Market Cap: 0.00 Day Range: 90.00 - 120.00 Prev. Close: 100.00 Avg. Cost: 0.00 105.00 (0.00%) 105.00
14 | Stock 5 Inc. (positi Volume: 0.00 52wk Range: 0.00 - 150.00 Open: 110.00 Quantity: 100.00 ↓ -45.00 (-30.00%) ↑ 5.00 (5.00%)
15 | Live
16 | STOCK6 ● Market Cap: 0.00 Day Range: 90.00 - 120.00 Prev. Close: 100.00 Avg. Cost: 0.00 95.00 (0.00%) 95.00
17 | Stock 6 Inc. (positi Volume: 0.00 52wk Range: 0.00 - 150.00 Open: 110.00 Quantity: 100.00 ↓ -55.00 (-36.67%) ↓ -5.00 (-5.00%)
18 | Live
19 | STOCK7 ● Market Cap: 0.00 Day Range: 90.00 - 120.00 Prev. Close: 100.00 Avg. Cost: 0.00 95.00 (0.00%) 95.00
20 | Stock 7 Inc. (positi Volume: 0.00 52wk Range: 0.00 - 0.00 Open: 110.00 Quantity: 100.00 ↑ 45.00 (90.00%) ↓ -5.00 (-5.00%)
21 | Live
22 | STOCK8 Market Cap: 0.00 Day Range: 90.00 - 120.00 Prev. Close: 100.00 Avg. Cost: 0.00 95.00 (0.00%) 95.00
23 | Stock 8 Inc. (positi Volume: 0.00 52wk Range: 0.00 - 0.00 Open: 110.00 Quantity: 100.00 ↑ 45.00 (90.00%) 0.00 (0.00%)
24 | Live
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-currency-rates/monitor.go:
--------------------------------------------------------------------------------
1 | package monitorCurrencyRate
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "sync"
7 |
8 | c "github.com/achannarasappa/ticker/v5/internal/common"
9 | "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
10 | )
11 |
12 | // MonitorCurrencyRatesYahoo represents a Yahoo Finance monitor
13 | type MonitorCurrencyRateYahoo struct {
14 | unaryAPI *unary.UnaryAPI
15 | ctx context.Context
16 | cancel context.CancelFunc
17 | currencyRateCache map[string]c.CurrencyRate
18 | targetCurrency string
19 | mu sync.RWMutex
20 | isStarted bool
21 | chanUpdateCurrencyRates chan c.CurrencyRates
22 | chanRequestCurrencyRates chan []string
23 | chanError chan error
24 | }
25 |
26 | // Config contains the required configuration for the Yahoo monitor
27 | type Config struct {
28 | Ctx context.Context
29 | UnaryAPI *unary.UnaryAPI
30 | ChanUpdateCurrencyRates chan c.CurrencyRates
31 | ChanRequestCurrencyRates chan []string
32 | ChanError chan error
33 | }
34 |
35 | // NewMonitorCurrencyRateYahoo creates a new MonitorCurrencyRateYahoo
36 | func NewMonitorCurrencyRateYahoo(config Config) *MonitorCurrencyRateYahoo {
37 |
38 | ctx, cancel := context.WithCancel(config.Ctx)
39 |
40 | monitor := &MonitorCurrencyRateYahoo{
41 | unaryAPI: config.UnaryAPI,
42 | ctx: ctx,
43 | cancel: cancel,
44 | chanUpdateCurrencyRates: config.ChanUpdateCurrencyRates,
45 | chanRequestCurrencyRates: config.ChanRequestCurrencyRates,
46 | chanError: config.ChanError,
47 | currencyRateCache: make(map[string]c.CurrencyRate),
48 | }
49 |
50 | return monitor
51 | }
52 |
53 | func (m *MonitorCurrencyRateYahoo) Start() error {
54 |
55 | if m.isStarted {
56 | return errors.New("monitor already started")
57 | }
58 |
59 | if m.targetCurrency == "" {
60 | m.targetCurrency = "USD"
61 | }
62 |
63 | go m.handleRequestCurrencyRates()
64 |
65 | m.isStarted = true
66 |
67 | return nil
68 | }
69 |
70 | func (m *MonitorCurrencyRateYahoo) Stop() error {
71 |
72 | if !m.isStarted {
73 | return errors.New("monitor not started")
74 | }
75 |
76 | m.cancel()
77 | m.isStarted = false
78 |
79 | return nil
80 |
81 | }
82 |
83 | func (m *MonitorCurrencyRateYahoo) SetTargetCurrency(targetCurrency string) {
84 |
85 | fromCurrencies := make([]string, 0)
86 |
87 | m.mu.RLock()
88 | for currency := range m.currencyRateCache {
89 | fromCurrencies = append(fromCurrencies, currency)
90 | }
91 | m.mu.RUnlock()
92 |
93 | rates, err := m.unaryAPI.GetCurrencyRates(fromCurrencies, targetCurrency)
94 |
95 | if err != nil {
96 | m.chanError <- err
97 |
98 | return
99 | }
100 |
101 | m.mu.Lock()
102 | m.targetCurrency = targetCurrency
103 | m.currencyRateCache = rates
104 | m.mu.Unlock()
105 |
106 | }
107 |
108 | func (m *MonitorCurrencyRateYahoo) handleRequestCurrencyRates() {
109 | for {
110 | select {
111 | case <-m.ctx.Done():
112 | return
113 | case fromCurrencies := <-m.chanRequestCurrencyRates:
114 |
115 | // Skip if no fromCurrencies
116 | if len(fromCurrencies) == 0 {
117 | continue
118 | }
119 |
120 | // Filter out currencies that are already in the cache
121 | fromCurrenciesToRequest := make([]string, 0)
122 | m.mu.RLock()
123 | for _, currency := range fromCurrencies {
124 | if _, exists := m.currencyRateCache[currency]; !exists {
125 | fromCurrenciesToRequest = append(fromCurrenciesToRequest, currency)
126 | }
127 | }
128 | m.mu.RUnlock()
129 |
130 | // Skip if no new currencies to fetch
131 | if len(fromCurrenciesToRequest) == 0 {
132 |
133 | continue
134 | }
135 |
136 | // Get currency rates from Yahoo unary API
137 | rates, err := m.unaryAPI.GetCurrencyRates(fromCurrenciesToRequest, m.targetCurrency)
138 | if err != nil {
139 | m.chanError <- err
140 |
141 | continue
142 | }
143 |
144 | // Update the cache
145 | m.mu.Lock()
146 | for currency, rate := range rates {
147 | m.currencyRateCache[currency] = rate
148 | }
149 | m.mu.Unlock()
150 | m.chanUpdateCurrencyRates <- m.currencyRateCache
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/internal/cli/symbol/symbol_test.go:
--------------------------------------------------------------------------------
1 | package symbol_test
2 |
3 | import (
4 | . "github.com/onsi/ginkgo/v2"
5 | . "github.com/onsi/gomega"
6 |
7 | "net/http"
8 |
9 | "github.com/achannarasappa/ticker/v5/internal/cli/symbol"
10 | c "github.com/achannarasappa/ticker/v5/internal/common"
11 | "github.com/onsi/gomega/ghttp"
12 | )
13 |
14 | var _ = Describe("Symbol", func() {
15 | var (
16 | server *ghttp.Server
17 | )
18 |
19 | BeforeEach(func() {
20 | server = ghttp.NewServer()
21 | })
22 |
23 | AfterEach(func() {
24 | server.Close()
25 | })
26 |
27 | Describe("GetTickerSymbols", func() {
28 |
29 | It("should get ticker symbols", func() {
30 | // Set up mock response
31 | responseFixture := `"BTC.X","BTC-USDC","cb"
32 | "XRP.X","XRP-USDC","cb"
33 | "ETH.X","ETH-USD","cb"
34 | "SOL.X","SOL-USD","cb"
35 | "SUI.X","SUI-USD","cb"
36 | `
37 | server.RouteToHandler("GET", "/symbols.csv",
38 | ghttp.CombineHandlers(
39 | ghttp.VerifyRequest("GET", "/symbols.csv"),
40 | ghttp.RespondWith(http.StatusOK, responseFixture, http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}}),
41 | ),
42 | )
43 |
44 | expectedSymbols := symbol.TickerSymbolToSourceSymbol{
45 | "BTC.X": symbol.SymbolSourceMap{
46 | TickerSymbol: "BTC.X",
47 | SourceSymbol: "BTC-USDC",
48 | Source: c.QuoteSourceCoinbase,
49 | },
50 | "XRP.X": symbol.SymbolSourceMap{
51 | TickerSymbol: "XRP.X",
52 | SourceSymbol: "XRP-USDC",
53 | Source: c.QuoteSourceCoinbase,
54 | },
55 | "ETH.X": symbol.SymbolSourceMap{
56 | TickerSymbol: "ETH.X",
57 | SourceSymbol: "ETH-USD",
58 | Source: c.QuoteSourceCoinbase,
59 | },
60 | "SOL.X": symbol.SymbolSourceMap{
61 | TickerSymbol: "SOL.X",
62 | SourceSymbol: "SOL-USD",
63 | Source: c.QuoteSourceCoinbase,
64 | },
65 | "SUI.X": symbol.SymbolSourceMap{
66 | TickerSymbol: "SUI.X",
67 | SourceSymbol: "SUI-USD",
68 | Source: c.QuoteSourceCoinbase,
69 | },
70 | }
71 |
72 | outputSymbols, outputErr := symbol.GetTickerSymbols(server.URL() + "/symbols.csv")
73 |
74 | Expect(outputSymbols).To(Equal(expectedSymbols))
75 | Expect(outputErr).To(BeNil())
76 | })
77 |
78 | When("a ticker symbol has an unknown source", func() {
79 |
80 | It("should get ticker symbols", func() {
81 | // Set up mock response
82 | responseFixture := `"SOMESYMBOL.X","some-symbol","uk"
83 | `
84 | server.RouteToHandler("GET", "/symbols.csv",
85 | ghttp.CombineHandlers(
86 | ghttp.RespondWith(http.StatusOK, responseFixture, http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}}),
87 | ),
88 | )
89 |
90 | expectedSymbols := symbol.TickerSymbolToSourceSymbol{
91 | "SOMESYMBOL.X": symbol.SymbolSourceMap{
92 | TickerSymbol: "SOMESYMBOL.X",
93 | SourceSymbol: "some-symbol",
94 | Source: c.QuoteSourceUnknown,
95 | },
96 | }
97 |
98 | outputSymbols, outputErr := symbol.GetTickerSymbols(server.URL() + "/symbols.csv")
99 |
100 | Expect(outputSymbols).To(Equal(expectedSymbols))
101 | Expect(outputErr).To(BeNil())
102 | })
103 |
104 | })
105 |
106 | When("a malformed CSV is returned", func() {
107 |
108 | It("should get ticker symbols", func() {
109 | // Set up mock response
110 | responseFixture := `"SOMESYMBOL.X","some-symbol","uk"
111 | "SOMESYMBOL.X","some-symbol","uk", "abc"
112 | "test"
113 | `
114 | server.RouteToHandler("GET", "/symbols.csv",
115 | ghttp.CombineHandlers(
116 | ghttp.RespondWith(http.StatusOK, responseFixture, http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}}),
117 | ),
118 | )
119 |
120 | _, outputErr := symbol.GetTickerSymbols(server.URL() + "/symbols.csv")
121 |
122 | Expect(outputErr).ToNot(BeNil())
123 | })
124 |
125 | })
126 |
127 | When("there is a server error", func() {
128 |
129 | It("returns the error", func() {
130 | // Set up mock response for server error
131 | server.RouteToHandler("GET", "/symbols.csv",
132 | ghttp.CombineHandlers(
133 | ghttp.RespondWith(http.StatusInternalServerError, "", nil),
134 | ),
135 | )
136 |
137 | _, outputErr := symbol.GetTickerSymbols(server.URL() + "/symbols.csv")
138 |
139 | Expect(outputErr).ToNot(BeNil())
140 | })
141 |
142 | })
143 |
144 | })
145 |
146 | })
147 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-price/fixtures_test.go:
--------------------------------------------------------------------------------
1 | package monitorPriceYahoo_test
2 |
3 | import (
4 | "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
5 | )
6 |
7 | var (
8 | responseQuote1Fixture = unary.Response{
9 | QuoteResponse: unary.ResponseQuoteResponse{
10 | Quotes: []unary.ResponseQuote{
11 | quoteCloudflareFixture,
12 | quoteGoogleFixture,
13 | },
14 | Error: nil,
15 | },
16 | }
17 | currencyResponseFixture = unary.Response{
18 | QuoteResponse: unary.ResponseQuoteResponse{
19 | Quotes: []unary.ResponseQuote{
20 | {
21 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 1.25, Fmt: "1.25"},
22 | Currency: "EUR",
23 | Symbol: "EURUSD=X",
24 | },
25 | {
26 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 0.92, Fmt: "0.92"},
27 | Currency: "USD",
28 | Symbol: "USDEUR=X",
29 | },
30 | },
31 | Error: nil,
32 | },
33 | }
34 | quoteCloudflareFixture = unary.ResponseQuote{
35 | MarketState: "REGULAR",
36 | ShortName: "Cloudflare, Inc.",
37 | PreMarketChange: unary.ResponseFieldFloat{Raw: 1.0399933, Fmt: "1.0399933"},
38 | PreMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.2238094, Fmt: "1.2238094"},
39 | PreMarketPrice: unary.ResponseFieldFloat{Raw: 86.03, Fmt: "86.03"},
40 | RegularMarketChange: unary.ResponseFieldFloat{Raw: 3.0800018, Fmt: "3.0800018"},
41 | RegularMarketChangePercent: unary.ResponseFieldFloat{Raw: 3.7606857, Fmt: "3.7606857"},
42 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 84.98, Fmt: "84.98"},
43 | RegularMarketPreviousClose: unary.ResponseFieldFloat{Raw: 84.00, Fmt: "84.00"},
44 | RegularMarketOpen: unary.ResponseFieldFloat{Raw: 85.22, Fmt: "85.22"},
45 | RegularMarketDayHigh: unary.ResponseFieldFloat{Raw: 90.00, Fmt: "90.00"},
46 | RegularMarketDayLow: unary.ResponseFieldFloat{Raw: 80.00, Fmt: "80.00"},
47 | PostMarketChange: unary.ResponseFieldFloat{Raw: 1.37627, Fmt: "1.37627"},
48 | PostMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.35735, Fmt: "1.35735"},
49 | PostMarketPrice: unary.ResponseFieldFloat{Raw: 86.56, Fmt: "86.56"},
50 | Symbol: "NET",
51 | }
52 | quoteGoogleFixture = unary.ResponseQuote{
53 | MarketState: "REGULAR",
54 | ShortName: "Google Inc.",
55 | PreMarketChange: unary.ResponseFieldFloat{Raw: 1.0399933, Fmt: "1.0399933"},
56 | PreMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.2238094, Fmt: "1.2238094"},
57 | PreMarketPrice: unary.ResponseFieldFloat{Raw: 166.03, Fmt: "166.03"},
58 | RegularMarketChange: unary.ResponseFieldFloat{Raw: 3.0800018, Fmt: "3.0800018"},
59 | RegularMarketChangePercent: unary.ResponseFieldFloat{Raw: 3.7606857, Fmt: "3.7606857"},
60 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 166.25, Fmt: "166.25"},
61 | RegularMarketPreviousClose: unary.ResponseFieldFloat{Raw: 165.00, Fmt: "165.00"},
62 | RegularMarketOpen: unary.ResponseFieldFloat{Raw: 165.00, Fmt: "165.00"},
63 | RegularMarketDayHigh: unary.ResponseFieldFloat{Raw: 167.00, Fmt: "167.00"},
64 | RegularMarketDayLow: unary.ResponseFieldFloat{Raw: 164.00, Fmt: "164.00"},
65 | PostMarketChange: unary.ResponseFieldFloat{Raw: 1.37627, Fmt: "1.37627"},
66 | PostMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.35735, Fmt: "1.35735"},
67 | PostMarketPrice: unary.ResponseFieldFloat{Raw: 167.62, Fmt: "167.62"},
68 | Symbol: "GOOG",
69 | }
70 | quoteMetaFixture = unary.ResponseQuote{
71 | MarketState: "REGULAR",
72 | ShortName: "Meta Platforms Inc.",
73 | PreMarketChange: unary.ResponseFieldFloat{Raw: 2, Fmt: "2"},
74 | PreMarketChangePercent: unary.ResponseFieldFloat{Raw: 0.6666667, Fmt: "0.6666667"},
75 | PreMarketPrice: unary.ResponseFieldFloat{Raw: 300.00, Fmt: "300.00"},
76 | RegularMarketChange: unary.ResponseFieldFloat{Raw: 3.00, Fmt: "3.00"},
77 | RegularMarketChangePercent: unary.ResponseFieldFloat{Raw: 1.00, Fmt: "1.00"},
78 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 303.00, Fmt: "303.00"},
79 | RegularMarketPreviousClose: unary.ResponseFieldFloat{Raw: 300.00, Fmt: "300.00"},
80 | RegularMarketOpen: unary.ResponseFieldFloat{Raw: 300.00, Fmt: "300.00"},
81 | RegularMarketDayHigh: unary.ResponseFieldFloat{Raw: 305.00, Fmt: "305.00"},
82 | RegularMarketDayLow: unary.ResponseFieldFloat{Raw: 295.00, Fmt: "295.00"},
83 | PostMarketChange: unary.ResponseFieldFloat{Raw: 1.00, Fmt: "1.00"},
84 | PostMarketChangePercent: unary.ResponseFieldFloat{Raw: 0.3333333, Fmt: "0.3333333"},
85 | PostMarketPrice: unary.ResponseFieldFloat{Raw: 304.37, Fmt: "304.37"},
86 | Symbol: "FB",
87 | }
88 | )
89 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/unary/helpers-quote.go:
--------------------------------------------------------------------------------
1 | package unary
2 |
3 | import (
4 | "strings"
5 |
6 | c "github.com/achannarasappa/ticker/v5/internal/common"
7 | )
8 |
9 | //nolint:gochecknoglobals
10 | var (
11 | postMarketStatuses = map[string]bool{"POST": true, "POSTPOST": true}
12 | )
13 |
14 | // transformResponseQuote transforms a single quote returned by the API into an AssetQuote
15 | func transformResponseQuote(responseQuote ResponseQuote) c.AssetQuote {
16 |
17 | assetClass := getAssetClass(responseQuote.QuoteType)
18 | isVariablePrecision := (assetClass == c.AssetClassCryptocurrency)
19 |
20 | assetQuote := c.AssetQuote{
21 | Name: responseQuote.ShortName,
22 | Symbol: responseQuote.Symbol,
23 | Class: assetClass,
24 | Currency: c.Currency{
25 | FromCurrencyCode: strings.ToUpper(responseQuote.Currency),
26 | },
27 | QuotePrice: c.QuotePrice{
28 | Price: responseQuote.RegularMarketPrice.Raw,
29 | PricePrevClose: responseQuote.RegularMarketPreviousClose.Raw,
30 | PriceOpen: responseQuote.RegularMarketOpen.Raw,
31 | PriceDayHigh: responseQuote.RegularMarketDayHigh.Raw,
32 | PriceDayLow: responseQuote.RegularMarketDayLow.Raw,
33 | Change: responseQuote.RegularMarketChange.Raw,
34 | ChangePercent: responseQuote.RegularMarketChangePercent.Raw,
35 | },
36 | QuoteExtended: c.QuoteExtended{
37 | FiftyTwoWeekHigh: responseQuote.FiftyTwoWeekHigh.Raw,
38 | FiftyTwoWeekLow: responseQuote.FiftyTwoWeekLow.Raw,
39 | MarketCap: responseQuote.MarketCap.Raw,
40 | Volume: responseQuote.RegularMarketVolume.Raw,
41 | },
42 | QuoteSource: c.QuoteSourceYahoo,
43 | Exchange: c.Exchange{
44 | Name: responseQuote.ExchangeName,
45 | Delay: responseQuote.ExchangeDelay,
46 | State: c.ExchangeStateOpen,
47 | IsActive: true,
48 | IsRegularTradingSession: true,
49 | },
50 | Meta: c.Meta{
51 | IsVariablePrecision: isVariablePrecision,
52 | SymbolInSourceAPI: responseQuote.Symbol,
53 | },
54 | }
55 |
56 | if responseQuote.MarketState == "REGULAR" {
57 | return assetQuote
58 | }
59 |
60 | if _, exists := postMarketStatuses[responseQuote.MarketState]; exists && responseQuote.PostMarketPrice.Raw == 0.0 {
61 | assetQuote.Exchange.IsRegularTradingSession = false
62 |
63 | return assetQuote
64 | }
65 |
66 | if responseQuote.MarketState == "PRE" && responseQuote.PreMarketPrice.Raw == 0.0 {
67 | assetQuote.Exchange.IsActive = false
68 | assetQuote.Exchange.IsRegularTradingSession = false
69 |
70 | return assetQuote
71 | }
72 |
73 | if _, exists := postMarketStatuses[responseQuote.MarketState]; exists {
74 | assetQuote.QuotePrice.Price = responseQuote.PostMarketPrice.Raw
75 | assetQuote.QuotePrice.Change = (responseQuote.PostMarketChange.Raw + responseQuote.RegularMarketChange.Raw)
76 | assetQuote.QuotePrice.ChangePercent = responseQuote.PostMarketChangePercent.Raw + responseQuote.RegularMarketChangePercent.Raw
77 | assetQuote.Exchange.IsRegularTradingSession = false
78 |
79 | return assetQuote
80 | }
81 |
82 | if responseQuote.MarketState == "PRE" {
83 | assetQuote.QuotePrice.Price = responseQuote.PreMarketPrice.Raw
84 | assetQuote.QuotePrice.Change = responseQuote.PreMarketChange.Raw
85 | assetQuote.QuotePrice.ChangePercent = responseQuote.PreMarketChangePercent.Raw
86 | assetQuote.Exchange.IsRegularTradingSession = false
87 |
88 | return assetQuote
89 | }
90 |
91 | if responseQuote.PostMarketPrice.Raw != 0.0 {
92 | assetQuote.QuotePrice.Price = responseQuote.PostMarketPrice.Raw
93 | assetQuote.QuotePrice.Change = (responseQuote.PostMarketChange.Raw + responseQuote.RegularMarketChange.Raw)
94 | assetQuote.QuotePrice.ChangePercent = responseQuote.PostMarketChangePercent.Raw + responseQuote.RegularMarketChangePercent.Raw
95 | assetQuote.Exchange.IsActive = false
96 | assetQuote.Exchange.IsRegularTradingSession = false
97 |
98 | return assetQuote
99 | }
100 |
101 | assetQuote.Exchange.IsActive = false
102 | assetQuote.Exchange.IsRegularTradingSession = false
103 |
104 | return assetQuote
105 |
106 | }
107 |
108 | // transformResponseQuotes transforms the quotes returned by the API into a slice of AssetQuote
109 | func transformResponseQuotes(responseQuotes []ResponseQuote) ([]c.AssetQuote, map[string]*c.AssetQuote) {
110 | quotes := make([]c.AssetQuote, 0, len(responseQuotes))
111 | quotesBySymbol := make(map[string]*c.AssetQuote, len(responseQuotes))
112 |
113 | for _, responseQuote := range responseQuotes {
114 | quote := transformResponseQuote(responseQuote)
115 | quotes = append(quotes, quote)
116 | quotesBySymbol[quote.Symbol] = "e
117 | }
118 |
119 | return quotes, quotesBySymbol
120 | }
121 |
122 | // getAssetClass determines the asset class based on the quote type returned by the API
123 | func getAssetClass(assetClass string) c.AssetClass {
124 |
125 | if assetClass == "CRYPTOCURRENCY" {
126 | return c.AssetClassCryptocurrency
127 | }
128 |
129 | return c.AssetClassStock
130 |
131 | }
132 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | project_name: ticker
2 | before:
3 | hooks:
4 | - go mod download
5 | builds:
6 | - id: build-linux-64
7 | main: ./main.go
8 | env:
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | goarch:
13 | - amd64
14 | hooks:
15 | post:
16 | - upx "{{ .Path }}"
17 | ldflags:
18 | - -s -w -X 'github.com/achannarasappa/ticker/v5/cmd.Version={{.Version}}'
19 | - id: build-linux
20 | main: ./main.go
21 | env:
22 | - CGO_ENABLED=0
23 | goos:
24 | - linux
25 | goarch:
26 | - 386
27 | - arm
28 | - arm64
29 | hooks:
30 | post:
31 | - upx "{{ .Path }}"
32 | ldflags:
33 | - -s -w -X 'github.com/achannarasappa/ticker/v5/cmd.Version={{.Version}}'
34 | - id: build-mac
35 | main: ./main.go
36 | env:
37 | - CGO_ENABLED=0
38 | goos:
39 | - darwin
40 | ldflags:
41 | - -s -w -X 'github.com/achannarasappa/ticker/v5/cmd.Version={{.Version}}'
42 | - id: build-windows-64
43 | main: ./main.go
44 | env:
45 | - CGO_ENABLED=0
46 | goarch:
47 | - amd64
48 | goos:
49 | - windows
50 | ldflags:
51 | - -s -w -X 'github.com/achannarasappa/ticker/v5/cmd.Version={{.Version}}'
52 | - id: build-windows-32
53 | main: ./main.go
54 | env:
55 | - CGO_ENABLED=0
56 | goarch:
57 | - 386
58 | goos:
59 | - windows
60 | ldflags:
61 | - -s -w -X 'github.com/achannarasappa/ticker/v5/cmd.Version={{.Version}}'
62 | archives:
63 | - id: release-winget
64 | format: zip
65 | builds:
66 | - build-windows-64
67 | name_template: >-
68 | {{- .ProjectName }}-
69 | {{- .Version }}-
70 | {{- if eq .Os "darwin" }}mac
71 | {{- else}}{{ .Os }}{{ end }}-
72 | {{- .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}
73 | {{- if .Mips }}-{{ .Mips }}{{ end }}
74 | - id: release
75 | format: tar.gz
76 | name_template: >-
77 | {{- .ProjectName }}-
78 | {{- .Version }}-
79 | {{- if eq .Os "darwin" }}mac
80 | {{- else}}{{ .Os }}{{ end }}-
81 | {{- .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}
82 | {{- if .Mips }}-{{ .Mips }}{{ end }}
83 | snapshot:
84 | name_template: "{{ .Tag }}-next"
85 | checksum:
86 | name_template: "{{ .ProjectName }}-{{ .Version }}-checksums.txt"
87 | release:
88 | github:
89 | owner: achannarasappa
90 | name: ticker
91 | brews:
92 | -
93 | name: ticker
94 | tap:
95 | owner: achannarasappa
96 | name: tap
97 | commit_author:
98 | name: achannarasappa
99 | email: git@ani.dev
100 | homepage: "https://github.com/achannarasappa/ticker"
101 | description: "Terminal stock ticker with live updates and position tracking"
102 | license: "GPLv3"
103 | dockers:
104 | - image_templates: ["achannarasappa/ticker:{{ .Version }}-amd64"]
105 | dockerfile: Dockerfile
106 | use: buildx
107 | build_flag_templates:
108 | - "--platform=linux/amd64"
109 | - "--pull"
110 | - "--label=org.opencontainers.image.created={{.Date}}"
111 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
112 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
113 | - "--label=org.opencontainers.image.version={{.Version}}"
114 | - "--label=org.opencontainers.image.source={{.GitURL}}"
115 | - image_templates: ["achannarasappa/ticker:{{ .Version }}-arm64v8"]
116 | goarch: arm64
117 | dockerfile: Dockerfile
118 | use: buildx
119 | build_flag_templates:
120 | - "--platform=linux/arm64/v8"
121 | - "--pull"
122 | - "--label=org.opencontainers.image.created={{.Date}}"
123 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
124 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
125 | - "--label=org.opencontainers.image.version={{.Version}}"
126 | - "--label=org.opencontainers.image.source={{.GitURL}}"
127 | docker_manifests:
128 | - name_template: achannarasappa/ticker:{{ .Version }}
129 | image_templates:
130 | - achannarasappa/ticker:{{ .Version }}-amd64
131 | - achannarasappa/ticker:{{ .Version }}-arm64v8
132 | - name_template: achannarasappa/ticker:latest
133 | image_templates:
134 | - achannarasappa/ticker:{{ .Version }}-amd64
135 | - achannarasappa/ticker:{{ .Version }}-arm64v8
136 | - name_template: achannarasappa/ticker:{{ .Major }}
137 | image_templates:
138 | - achannarasappa/ticker:{{ .Version }}-amd64
139 | - achannarasappa/ticker:{{ .Version }}-arm64v8
140 | - name_template: achannarasappa/ticker:{{ .Major }}.{{ .Minor }}
141 | image_templates:
142 | - achannarasappa/ticker:{{ .Version }}-amd64
143 | - achannarasappa/ticker:{{ .Version }}-arm64v8
144 | - name_template: achannarasappa/ticker:{{ .Major }}.{{ .Minor }}.{{ .Patch }}
145 | image_templates:
146 | - achannarasappa/ticker:{{ .Version }}-amd64
147 | - achannarasappa/ticker:{{ .Version }}-arm64v8
148 | nfpms:
149 | -
150 | file_name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}-{{ .Mips }}{{ end }}"
151 | builds:
152 | - build-linux-64
153 | homepage: https://github.com/achannarasappa/ticker
154 | maintainer: Ani Channarasappa
155 | description: Terminal stock ticker with live updates and position tracking
156 | license: GPLv3
157 | formats:
158 | - deb
159 | - rpm
160 | snapcrafts:
161 | -
162 | id: ticker
163 | name: ticker
164 | builds:
165 | - build-linux-64
166 | publish: true
167 | summary: Terminal stock watcher and stock position tracker
168 | description: |
169 | Features:
170 | Live stock price quotes
171 | Track value of your stock positions
172 | Support for multiple cost basis lots
173 | Support for pre and post market price quotes
174 | grade: stable
175 | confinement: strict
176 | channel_templates:
177 | - edge
178 | - beta
179 | - candidate
180 | - stable
181 | apps:
182 | ticker:
183 | plugs: ["network", "home"]
184 | environment:
185 | PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH
--------------------------------------------------------------------------------
/internal/print/print.go:
--------------------------------------------------------------------------------
1 | package print //nolint:predeclared
2 |
3 | import (
4 | "bytes"
5 | "encoding/csv"
6 | "encoding/json"
7 | "fmt"
8 |
9 | "github.com/achannarasappa/ticker/v5/internal/asset"
10 | c "github.com/achannarasappa/ticker/v5/internal/common"
11 | mon "github.com/achannarasappa/ticker/v5/internal/monitor"
12 | "github.com/achannarasappa/ticker/v5/internal/ui/util"
13 |
14 | "github.com/spf13/cobra"
15 | )
16 |
17 | // Options to configure print behavior
18 | type Options struct {
19 | Format string
20 | }
21 |
22 | type jsonRow struct {
23 | Name string `json:"name"`
24 | Symbol string `json:"symbol"`
25 | Price string `json:"price"`
26 | Value string `json:"value"`
27 | Cost string `json:"cost"`
28 | Quantity string `json:"quantity"`
29 | Weight string `json:"weight"`
30 | }
31 |
32 | type jsonSummary struct {
33 | TotalValue string `json:"total_value"`
34 | TotalCost string `json:"total_cost"`
35 | DayChangeAmount string `json:"day_change_amount"`
36 | DayChangePercent string `json:"day_change_percent"`
37 | TotalChangeAmount string `json:"total_change_amount"`
38 | TotalChangePercent string `json:"total_change_percent"`
39 | }
40 |
41 | func convertAssetsToCSV(assets []c.Asset) string {
42 | rows := [][]string{
43 | {"name", "symbol", "price", "value", "cost", "quantity", "weight"},
44 | }
45 |
46 | for _, asset := range assets {
47 | if asset.Holding.Quantity > 0 {
48 | rows = append(rows, []string{
49 | asset.Name,
50 | asset.Symbol,
51 | util.ConvertFloatToString(asset.QuotePrice.Price, true),
52 | util.ConvertFloatToString(asset.Holding.Value, true),
53 | util.ConvertFloatToString(asset.Holding.Cost, true),
54 | util.ConvertFloatToString(asset.Holding.Quantity, true),
55 | util.ConvertFloatToString(asset.Holding.Weight, true),
56 | })
57 | }
58 | }
59 |
60 | b := new(bytes.Buffer)
61 | w := csv.NewWriter(b)
62 | //nolint:errcheck
63 | w.WriteAll(rows)
64 |
65 | return b.String()
66 |
67 | }
68 |
69 | func convertAssetsToJSON(assets []c.Asset) string {
70 | var rows []jsonRow
71 |
72 | for _, asset := range assets {
73 | if asset.Holding.Quantity > 0 {
74 | rows = append(rows, jsonRow{
75 | Name: asset.Name,
76 | Symbol: asset.Symbol,
77 | Price: fmt.Sprintf("%f", asset.QuotePrice.Price),
78 | Value: fmt.Sprintf("%f", asset.Holding.Value),
79 | Cost: fmt.Sprintf("%f", asset.Holding.Cost),
80 | Quantity: fmt.Sprintf("%f", asset.Holding.Quantity),
81 | Weight: fmt.Sprintf("%f", asset.Holding.Weight),
82 | })
83 | }
84 | }
85 |
86 | if len(rows) == 0 {
87 | return "[]"
88 | }
89 |
90 | out, err := json.Marshal(rows)
91 |
92 | if err != nil {
93 | return err.Error()
94 | }
95 |
96 | return string(out)
97 |
98 | }
99 |
100 | func convertSummaryToJSON(summary asset.HoldingSummary) string {
101 | row := jsonSummary{
102 | TotalValue: fmt.Sprintf("%f", summary.Value),
103 | TotalCost: fmt.Sprintf("%f", summary.Cost),
104 | DayChangeAmount: fmt.Sprintf("%f", summary.DayChange.Amount),
105 | DayChangePercent: fmt.Sprintf("%f", summary.DayChange.Percent),
106 | TotalChangeAmount: fmt.Sprintf("%f", summary.TotalChange.Amount),
107 | TotalChangePercent: fmt.Sprintf("%f", summary.TotalChange.Percent),
108 | }
109 |
110 | out, err := json.Marshal(row)
111 |
112 | if err != nil {
113 | return err.Error()
114 | }
115 |
116 | return string(out)
117 | }
118 |
119 | func convertSummaryToCSV(summary asset.HoldingSummary) string {
120 | rows := [][]string{
121 | {"total_value", "total_cost", "day_change_amount", "day_change_percent", "total_change_amount", "total_change_percent"},
122 | {
123 | fmt.Sprintf("%f", summary.Value),
124 | fmt.Sprintf("%f", summary.Cost),
125 | fmt.Sprintf("%f", summary.DayChange.Amount),
126 | fmt.Sprintf("%f", summary.DayChange.Percent),
127 | fmt.Sprintf("%f", summary.TotalChange.Amount),
128 | fmt.Sprintf("%f", summary.TotalChange.Percent),
129 | },
130 | }
131 |
132 | b := new(bytes.Buffer)
133 | w := csv.NewWriter(b)
134 | //nolint:errcheck
135 | w.WriteAll(rows)
136 |
137 | return b.String()
138 | }
139 |
140 | // Run prints holdings to the terminal
141 | func Run(dep *c.Dependencies, ctx *c.Context, options *Options) func(*cobra.Command, []string) {
142 | return func(_ *cobra.Command, _ []string) {
143 |
144 | monitors, _ := mon.NewMonitor(mon.ConfigMonitor{
145 | RefreshInterval: ctx.Config.RefreshInterval,
146 | ConfigMonitorsYahoo: mon.ConfigMonitorsYahoo{
147 | BaseURL: dep.MonitorYahooBaseURL,
148 | SessionRootURL: dep.MonitorYahooSessionRootURL,
149 | SessionCrumbURL: dep.MonitorYahooSessionCrumbURL,
150 | SessionConsentURL: dep.MonitorYahooSessionConsentURL,
151 | },
152 | ConfigMonitorPriceCoinbase: mon.ConfigMonitorPriceCoinbase{
153 | BaseURL: dep.MonitorPriceCoinbaseBaseURL,
154 | StreamingURL: dep.MonitorPriceCoinbaseStreamingURL,
155 | },
156 | })
157 | monitors.SetAssetGroup(ctx.Groups[0], 0) //nolint:errcheck
158 | assetGroupQuote := monitors.GetAssetGroupQuote()
159 | assets, _ := asset.GetAssets(*ctx, assetGroupQuote)
160 |
161 | if options.Format == "csv" {
162 | fmt.Println(convertAssetsToCSV(assets))
163 |
164 | return
165 | }
166 |
167 | fmt.Println(convertAssetsToJSON(assets))
168 | }
169 | }
170 |
171 | // RunSummary handles the print summary command
172 | func RunSummary(dep *c.Dependencies, ctx *c.Context, options *Options) func(cmd *cobra.Command, args []string) {
173 | return func(_ *cobra.Command, _ []string) {
174 |
175 | monitors, _ := mon.NewMonitor(mon.ConfigMonitor{
176 | RefreshInterval: ctx.Config.RefreshInterval,
177 | ConfigMonitorsYahoo: mon.ConfigMonitorsYahoo{
178 | BaseURL: dep.MonitorYahooBaseURL,
179 | SessionRootURL: dep.MonitorYahooSessionRootURL,
180 | SessionCrumbURL: dep.MonitorYahooSessionCrumbURL,
181 | SessionConsentURL: dep.MonitorYahooSessionConsentURL,
182 | },
183 | })
184 | monitors.SetAssetGroup(ctx.Groups[0], 0) //nolint:errcheck
185 | assetGroupQuote := monitors.GetAssetGroupQuote()
186 | _, holdingSummary := asset.GetAssets(*ctx, assetGroupQuote)
187 |
188 | if options.Format == "csv" {
189 | fmt.Println(convertSummaryToCSV(holdingSummary))
190 |
191 | return
192 | }
193 |
194 | fmt.Println(convertSummaryToJSON(holdingSummary))
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/internal/asset/asset.go:
--------------------------------------------------------------------------------
1 | package asset
2 |
3 | import (
4 | "strings"
5 |
6 | c "github.com/achannarasappa/ticker/v5/internal/common"
7 | )
8 |
9 | // AggregatedLot represents a cost basis lot of an asset grouped by symbol
10 | type AggregatedLot struct {
11 | Symbol string
12 | Cost float64
13 | Quantity float64
14 | OrderIndex int
15 | }
16 |
17 | // HoldingSummary represents a summary of all asset holdings at a point in time
18 | type HoldingSummary struct {
19 | Value float64
20 | Cost float64
21 | TotalChange c.HoldingChange
22 | DayChange c.HoldingChange
23 | }
24 |
25 | // GetAssets returns assets from an asset group quote
26 | func GetAssets(ctx c.Context, assetGroupQuote c.AssetGroupQuote) ([]c.Asset, HoldingSummary) {
27 |
28 | var holdingSummary HoldingSummary
29 | assets := make([]c.Asset, 0)
30 | holdingsBySymbol := getLots(assetGroupQuote.AssetGroup.ConfigAssetGroup.Holdings)
31 | orderIndex := make(map[string]int)
32 |
33 | for i, symbol := range assetGroupQuote.AssetGroup.ConfigAssetGroup.Holdings {
34 | if _, exists := orderIndex[symbol.Symbol]; !exists {
35 | orderIndex[strings.ToLower(symbol.Symbol)] = i
36 | }
37 | }
38 |
39 | for i, symbol := range assetGroupQuote.AssetGroup.ConfigAssetGroup.Watchlist {
40 | if _, exists := orderIndex[symbol]; !exists {
41 | orderIndex[strings.ToLower(symbol)] = i + len(assetGroupQuote.AssetGroup.ConfigAssetGroup.Holdings)
42 | }
43 | }
44 |
45 | for _, assetQuote := range assetGroupQuote.AssetQuotes {
46 |
47 | currencyRateByUse := getCurrencyRateByUse(ctx, assetQuote.Currency.FromCurrencyCode, assetQuote.Currency.ToCurrencyCode, assetQuote.Currency.Rate)
48 |
49 | holding := getHoldingFromAssetQuote(assetQuote, holdingsBySymbol, currencyRateByUse)
50 | holdingSummary = addHoldingToHoldingSummary(holdingSummary, holding, currencyRateByUse)
51 |
52 | assets = append(assets, c.Asset{
53 | Name: assetQuote.Name,
54 | Symbol: assetQuote.Symbol,
55 | Class: assetQuote.Class,
56 | Currency: c.Currency{
57 | FromCurrencyCode: assetQuote.Currency.FromCurrencyCode,
58 | ToCurrencyCode: currencyRateByUse.ToCurrencyCode,
59 | },
60 | Holding: holding,
61 | QuotePrice: convertAssetQuotePriceCurrency(currencyRateByUse, assetQuote.QuotePrice),
62 | QuoteExtended: convertAssetQuoteExtendedCurrency(currencyRateByUse, assetQuote.QuoteExtended),
63 | QuoteFutures: assetQuote.QuoteFutures,
64 | QuoteSource: assetQuote.QuoteSource,
65 | Exchange: assetQuote.Exchange,
66 | Meta: c.Meta{
67 | IsVariablePrecision: assetQuote.Meta.IsVariablePrecision,
68 | OrderIndex: orderIndex[strings.ToLower(assetQuote.Symbol)],
69 | },
70 | })
71 |
72 | }
73 |
74 | assets = updateHoldingWeights(assets, holdingSummary)
75 |
76 | return assets, holdingSummary
77 |
78 | }
79 |
80 | // calculateChangePercent calculates the percentage change, returning 0 if base is 0 to avoid division by zero
81 | func calculateChangePercent(changeAmount float64, base float64) float64 {
82 | if base == 0 {
83 | return 0
84 | }
85 |
86 | return (changeAmount / base) * 100
87 | }
88 |
89 | func addHoldingToHoldingSummary(holdingSummary HoldingSummary, holding c.Holding, currencyRateByUse currencyRateByUse) HoldingSummary {
90 |
91 | if holding.Value == 0 {
92 | return holdingSummary
93 | }
94 |
95 | value := holdingSummary.Value + (holding.Value * currencyRateByUse.SummaryValue)
96 | cost := holdingSummary.Cost + (holding.Cost * currencyRateByUse.SummaryCost)
97 | dayChange := holdingSummary.DayChange.Amount + (holding.DayChange.Amount * currencyRateByUse.SummaryValue)
98 | totalChange := value - cost
99 |
100 | totalChangePercent := calculateChangePercent(totalChange, cost)
101 | dayChangePercent := (dayChange / value) * 100
102 |
103 | return HoldingSummary{
104 | Value: value,
105 | Cost: cost,
106 | TotalChange: c.HoldingChange{
107 | Amount: totalChange,
108 | Percent: totalChangePercent,
109 | },
110 | DayChange: c.HoldingChange{
111 | Amount: dayChange,
112 | Percent: dayChangePercent,
113 | },
114 | }
115 | }
116 |
117 | func updateHoldingWeights(assets []c.Asset, holdingSummary HoldingSummary) []c.Asset {
118 |
119 | if holdingSummary.Value == 0 {
120 | return assets
121 | }
122 |
123 | for i, asset := range assets {
124 | assets[i].Holding.Weight = (asset.Holding.Value / holdingSummary.Value) * 100
125 | }
126 |
127 | return assets
128 |
129 | }
130 |
131 | func getHoldingFromAssetQuote(assetQuote c.AssetQuote, lotsBySymbol map[string]AggregatedLot, currencyRateByUse currencyRateByUse) c.Holding {
132 |
133 | if aggregatedLot, ok := lotsBySymbol[assetQuote.Symbol]; ok {
134 | value := aggregatedLot.Quantity * assetQuote.QuotePrice.Price * currencyRateByUse.QuotePrice
135 | cost := aggregatedLot.Cost * currencyRateByUse.PositionCost
136 | totalChangeAmount := value - cost
137 |
138 | totalChangePercent := calculateChangePercent(totalChangeAmount, cost)
139 |
140 | return c.Holding{
141 | Value: value,
142 | Cost: cost,
143 | Quantity: aggregatedLot.Quantity,
144 | UnitValue: value / aggregatedLot.Quantity,
145 | UnitCost: cost / aggregatedLot.Quantity,
146 | DayChange: c.HoldingChange{
147 | Amount: assetQuote.QuotePrice.Change * aggregatedLot.Quantity * currencyRateByUse.QuotePrice,
148 | Percent: assetQuote.QuotePrice.ChangePercent,
149 | },
150 | TotalChange: c.HoldingChange{
151 | Amount: totalChangeAmount,
152 | Percent: totalChangePercent,
153 | },
154 | Weight: 0,
155 | }
156 | }
157 |
158 | return c.Holding{}
159 |
160 | }
161 |
162 | func getLots(lots []c.Lot) map[string]AggregatedLot {
163 |
164 | if lots == nil {
165 | return map[string]AggregatedLot{}
166 | }
167 |
168 | aggregatedLots := map[string]AggregatedLot{}
169 |
170 | for i, lot := range lots {
171 |
172 | aggregatedLot, ok := aggregatedLots[lot.Symbol]
173 |
174 | if !ok {
175 |
176 | aggregatedLots[lot.Symbol] = AggregatedLot{
177 | Symbol: lot.Symbol,
178 | Cost: (lot.UnitCost * lot.Quantity) + lot.FixedCost,
179 | Quantity: lot.Quantity,
180 | OrderIndex: i,
181 | }
182 |
183 | } else {
184 |
185 | aggregatedLot.Quantity += lot.Quantity
186 | aggregatedLot.Cost += lot.Quantity * lot.UnitCost
187 |
188 | aggregatedLots[lot.Symbol] = aggregatedLot
189 |
190 | }
191 |
192 | }
193 |
194 | return aggregatedLots
195 | }
196 |
--------------------------------------------------------------------------------
/internal/ui/component/watchlist/watchlist.go:
--------------------------------------------------------------------------------
1 | package watchlist
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | c "github.com/achannarasappa/ticker/v5/internal/common"
8 | s "github.com/achannarasappa/ticker/v5/internal/sorter"
9 | row "github.com/achannarasappa/ticker/v5/internal/ui/component/watchlist/row"
10 | u "github.com/achannarasappa/ticker/v5/internal/ui/util"
11 |
12 | tea "github.com/charmbracelet/bubbletea"
13 | )
14 |
15 | // Config represents the configuration for the watchlist component
16 | type Config struct {
17 | Separate bool
18 | ShowHoldings bool
19 | ExtraInfoExchange bool
20 | ExtraInfoFundamentals bool
21 | Sort string
22 | Styles c.Styles
23 | }
24 |
25 | // Model for watchlist section
26 | type Model struct {
27 | width int
28 | assets []*c.Asset
29 | assetsBySymbol map[string]*c.Asset
30 | sorter s.Sorter
31 | config Config
32 | cellWidths row.CellWidthsContainer
33 | rows []*row.Model
34 | rowsBySymbol map[string]*row.Model
35 | }
36 |
37 | // Messages for replacing assets
38 | type SetAssetsMsg []c.Asset
39 |
40 | // Messages for updating assets
41 | type UpdateAssetsMsg []c.Asset
42 |
43 | // NewModel returns a model with default values
44 | func NewModel(config Config) *Model {
45 | return &Model{
46 | width: 80,
47 | config: config,
48 | assets: make([]*c.Asset, 0),
49 | assetsBySymbol: make(map[string]*c.Asset),
50 | sorter: s.NewSorter(config.Sort),
51 | rowsBySymbol: make(map[string]*row.Model),
52 | }
53 | }
54 |
55 | // Init initializes the watchlist
56 | func (m *Model) Init() tea.Cmd {
57 | return nil
58 | }
59 |
60 | // Update handles messages for the watchlist
61 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) {
62 | switch msg := msg.(type) {
63 | case SetAssetsMsg:
64 |
65 | var cmd tea.Cmd
66 | cmds := make([]tea.Cmd, 0)
67 |
68 | // Convert []c.Asset to []*c.Asset and update assetsBySymbol map
69 | assets := make([]*c.Asset, len(msg))
70 | assetsBySymbol := make(map[string]*c.Asset)
71 |
72 | for i := range msg {
73 | assets[i] = &msg[i]
74 | assetsBySymbol[msg[i].Symbol] = assets[i]
75 | }
76 |
77 | assets = m.sorter(assets)
78 |
79 | for i, asset := range assets {
80 | if i < len(m.rows) {
81 | m.rows[i], cmd = m.rows[i].Update(row.UpdateAssetMsg(asset))
82 | cmds = append(cmds, cmd)
83 | m.rowsBySymbol[assets[i].Symbol] = m.rows[i]
84 | } else {
85 | m.rows = append(m.rows, row.New(row.Config{
86 | Separate: m.config.Separate,
87 | ExtraInfoExchange: m.config.ExtraInfoExchange,
88 | ExtraInfoFundamentals: m.config.ExtraInfoFundamentals,
89 | ShowHoldings: m.config.ShowHoldings,
90 | Styles: m.config.Styles,
91 | Asset: asset,
92 | }))
93 | m.rowsBySymbol[assets[i].Symbol] = m.rows[len(m.rows)-1]
94 | }
95 | }
96 |
97 | if len(assets) < len(m.rows) {
98 | m.rows = m.rows[:len(assets)]
99 | }
100 |
101 | m.assets = assets
102 | m.assetsBySymbol = assetsBySymbol
103 |
104 | // TODO: only set conditionally if all assets have changed
105 | m.cellWidths = getCellWidths(m.assets)
106 | for i, r := range m.rows {
107 | m.rows[i], _ = r.Update(row.SetCellWidthsMsg{
108 | Width: m.width,
109 | CellWidths: m.cellWidths,
110 | })
111 | }
112 |
113 | return m, tea.Batch(cmds...)
114 |
115 | case tea.WindowSizeMsg:
116 |
117 | m.width = msg.Width
118 | m.cellWidths = getCellWidths(m.assets)
119 | for i, r := range m.rows {
120 | m.rows[i], _ = r.Update(row.SetCellWidthsMsg{
121 | Width: m.width,
122 | CellWidths: m.cellWidths,
123 | })
124 | }
125 |
126 | return m, nil
127 |
128 | case row.FrameMsg:
129 |
130 | var cmd tea.Cmd
131 | cmds := make([]tea.Cmd, 0)
132 |
133 | // TODO: send message to a specific row rather than all rows
134 | for i, r := range m.rows {
135 | m.rows[i], cmd = r.Update(msg)
136 | cmds = append(cmds, cmd)
137 | }
138 |
139 | return m, tea.Batch(cmds...)
140 |
141 | }
142 |
143 | return m, nil
144 | }
145 |
146 | // View rendering hook for bubbletea
147 | func (m *Model) View() string {
148 |
149 | if m.width < 80 {
150 | return fmt.Sprintf("Terminal window too narrow to render content\nResize to fix (%d/80)", m.width)
151 | }
152 |
153 | rows := make([]string, 0)
154 | for _, row := range m.rows {
155 | rows = append(rows, row.View())
156 | }
157 |
158 | return strings.Join(rows, "\n")
159 |
160 | }
161 | func getCellWidths(assets []*c.Asset) row.CellWidthsContainer {
162 |
163 | cellMaxWidths := row.CellWidthsContainer{}
164 |
165 | for _, asset := range assets {
166 | var quoteLength int
167 |
168 | volumeMarketCapLength := len(u.ConvertFloatToString(asset.QuoteExtended.MarketCap, true))
169 |
170 | if asset.QuoteExtended.FiftyTwoWeekHigh == 0.0 {
171 | quoteLength = len(u.ConvertFloatToString(asset.QuotePrice.Price, asset.Meta.IsVariablePrecision))
172 | }
173 |
174 | if asset.QuoteExtended.FiftyTwoWeekHigh != 0.0 {
175 | quoteLength = len(u.ConvertFloatToString(asset.QuoteExtended.FiftyTwoWeekHigh, asset.Meta.IsVariablePrecision))
176 | }
177 |
178 | if volumeMarketCapLength > cellMaxWidths.WidthVolumeMarketCap {
179 | cellMaxWidths.WidthVolumeMarketCap = volumeMarketCapLength
180 | }
181 |
182 | if quoteLength > cellMaxWidths.QuoteLength {
183 | cellMaxWidths.QuoteLength = quoteLength
184 | cellMaxWidths.WidthQuote = quoteLength + row.WidthChangeStatic
185 | cellMaxWidths.WidthQuoteExtended = quoteLength
186 | cellMaxWidths.WidthQuoteRange = row.WidthRangeStatic + (quoteLength * 2)
187 | }
188 |
189 | if asset.Holding != (c.Holding{}) {
190 | positionLength := len(u.ConvertFloatToString(asset.Holding.Value, asset.Meta.IsVariablePrecision))
191 | positionQuantityLength := len(u.ConvertFloatToString(asset.Holding.Quantity, asset.Meta.IsVariablePrecision))
192 |
193 | if positionLength > cellMaxWidths.PositionLength {
194 | cellMaxWidths.PositionLength = positionLength
195 | cellMaxWidths.WidthPosition = positionLength + row.WidthChangeStatic + row.WidthPositionGutter
196 | }
197 |
198 | if positionLength > cellMaxWidths.WidthPositionExtended {
199 | cellMaxWidths.WidthPositionExtended = positionLength
200 | }
201 |
202 | if positionQuantityLength > cellMaxWidths.WidthPositionExtended {
203 | cellMaxWidths.WidthPositionExtended = positionQuantityLength
204 | }
205 |
206 | }
207 |
208 | }
209 |
210 | return cellMaxWidths
211 |
212 | }
213 |
--------------------------------------------------------------------------------
/internal/sorter/sorter_test.go:
--------------------------------------------------------------------------------
1 | package sorter_test
2 |
3 | import (
4 | c "github.com/achannarasappa/ticker/v5/internal/common"
5 | . "github.com/achannarasappa/ticker/v5/internal/sorter"
6 |
7 | . "github.com/onsi/ginkgo/v2"
8 | . "github.com/onsi/gomega"
9 | )
10 |
11 | var _ = Describe("Sorter", func() {
12 |
13 | Describe("NewSorter", func() {
14 | bitcoinQuote := c.Asset{
15 | Symbol: "BTC-USD",
16 | Name: "Bitcoin",
17 | QuotePrice: c.QuotePrice{
18 | PricePrevClose: 10000.0,
19 | PriceOpen: 10000.0,
20 | Price: 50000.0,
21 | Change: 10000.0,
22 | ChangePercent: 20.0,
23 | },
24 | Holding: c.Holding{
25 | Value: 50000.0,
26 | },
27 | Exchange: c.Exchange{
28 | IsActive: true,
29 | IsRegularTradingSession: true,
30 | },
31 | Meta: c.Meta{
32 | OrderIndex: 1,
33 | },
34 | }
35 | twQuote := c.Asset{
36 | Symbol: "TW",
37 | Name: "ThoughtWorks",
38 | QuotePrice: c.QuotePrice{
39 | Price: 109.04,
40 | Change: 3.53,
41 | ChangePercent: 5.65,
42 | },
43 | Exchange: c.Exchange{
44 | IsActive: true,
45 | IsRegularTradingSession: false,
46 | },
47 | Meta: c.Meta{
48 | OrderIndex: 2,
49 | },
50 | }
51 | googleQuote := c.Asset{
52 | Symbol: "GOOG",
53 | Name: "Google Inc.",
54 | QuotePrice: c.QuotePrice{
55 | Price: 2523.53,
56 | Change: -32.02,
57 | ChangePercent: -1.35,
58 | },
59 | Holding: c.Holding{
60 | Value: 2523.53,
61 | },
62 | Exchange: c.Exchange{
63 | IsActive: true,
64 | IsRegularTradingSession: false,
65 | },
66 | Meta: c.Meta{
67 | OrderIndex: 0,
68 | },
69 | }
70 | msftQuote := c.Asset{
71 | Symbol: "MSFT",
72 | Name: "Microsoft Corporation",
73 | QuotePrice: c.QuotePrice{
74 | Price: 242.01,
75 | Change: -0.99,
76 | ChangePercent: -0.41,
77 | },
78 | Exchange: c.Exchange{
79 | IsActive: false,
80 | IsRegularTradingSession: false,
81 | },
82 | Meta: c.Meta{
83 | OrderIndex: 3,
84 | },
85 | }
86 | rblxQuote := c.Asset{
87 | Symbol: "RBLX",
88 | Name: "Roblox",
89 | QuotePrice: c.QuotePrice{
90 | Price: 85.00,
91 | Change: 10.00,
92 | ChangePercent: 7.32,
93 | },
94 | Exchange: c.Exchange{
95 | IsActive: false,
96 | IsRegularTradingSession: false,
97 | },
98 | Meta: c.Meta{
99 | OrderIndex: 4,
100 | },
101 | }
102 | assets := []*c.Asset{
103 | &bitcoinQuote,
104 | &twQuote,
105 | &googleQuote,
106 | &msftQuote,
107 | }
108 |
109 | When("providing no sort parameter", func() {
110 | It("should sort by default (change percent)", func() {
111 | sorter := NewSorter("")
112 |
113 | coinQuote := c.Asset{
114 | Symbol: "COIN",
115 | Name: "Coinbase",
116 | QuotePrice: c.QuotePrice{
117 | Price: 220.00,
118 | Change: 20.00,
119 | ChangePercent: 10.00,
120 | },
121 | Exchange: c.Exchange{
122 | IsActive: false,
123 | IsRegularTradingSession: false,
124 | },
125 | }
126 | assets := []*c.Asset{
127 | &rblxQuote,
128 | &bitcoinQuote,
129 | &twQuote,
130 | &googleQuote,
131 | &msftQuote,
132 | &coinQuote,
133 | }
134 |
135 | sortedQuotes := sorter(assets)
136 | expected := []*c.Asset{
137 | &bitcoinQuote,
138 | &twQuote,
139 | &googleQuote,
140 | &coinQuote,
141 | &rblxQuote,
142 | &msftQuote,
143 | }
144 |
145 | Expect(sortedQuotes).To(Equal(expected))
146 | })
147 | })
148 | When("providing \"alpha\" as a sort parameter", func() {
149 | It("should sort by alphabetical order", func() {
150 | sorter := NewSorter("alpha")
151 |
152 | sortedQuotes := sorter(assets)
153 | expected := []*c.Asset{
154 | &bitcoinQuote,
155 | &googleQuote,
156 | &msftQuote,
157 | &twQuote,
158 | }
159 |
160 | Expect(sortedQuotes).To(Equal(expected))
161 | })
162 | })
163 | When("providing \"position\" as a sort parameter", func() {
164 | It("should sort position value, with inactive quotes last", func() {
165 | sorter := NewSorter("value")
166 |
167 | bitcoinQuoteWithHolding := bitcoinQuote
168 | bitcoinQuoteWithHolding.Holding.Value = 50000.0
169 | googleQuoteWithHolding := googleQuote
170 | googleQuoteWithHolding.Holding.Value = 2523.53
171 | rblxQuoteWithHolding := rblxQuote
172 | rblxQuoteWithHolding.Holding.Value = 900.00
173 | msftQuoteWithHolding := msftQuote
174 | msftQuoteWithHolding.Holding.Value = 100.00
175 |
176 | assets := []*c.Asset{
177 | &bitcoinQuoteWithHolding,
178 | &twQuote,
179 | &googleQuoteWithHolding,
180 | &msftQuoteWithHolding,
181 | &rblxQuoteWithHolding,
182 | }
183 |
184 | sortedQuotes := sorter(assets)
185 | expected := []*c.Asset{
186 | &bitcoinQuoteWithHolding,
187 | &googleQuoteWithHolding,
188 | &twQuote,
189 | &rblxQuoteWithHolding,
190 | &msftQuoteWithHolding,
191 | }
192 |
193 | Expect(sortedQuotes).To(Equal(expected))
194 | })
195 | })
196 | When("providing \"user\" as a sort parameter", func() {
197 | It("should sort by the user defined order for positions and watchlist", func() {
198 | sorter := NewSorter("user")
199 |
200 | sortedQuotes := sorter(assets)
201 | expected := []*c.Asset{
202 | &googleQuote,
203 | &bitcoinQuote,
204 | &twQuote,
205 | &msftQuote,
206 | }
207 |
208 | Expect(sortedQuotes).To(Equal(expected))
209 | })
210 | })
211 | When("providing no quotes", func() {
212 | When("default sorter", func() {
213 | It("should return no quotes", func() {
214 | sorter := NewSorter("")
215 |
216 | sortedQuotes := sorter([]*c.Asset{})
217 | expected := []*c.Asset{}
218 | Expect(sortedQuotes).To(Equal(expected))
219 | })
220 | })
221 | When("alpha sorter", func() {
222 | It("should return no quotes", func() {
223 | sorter := NewSorter("alpha")
224 |
225 | sortedQuotes := sorter([]*c.Asset{})
226 | expected := []*c.Asset{}
227 | Expect(sortedQuotes).To(Equal(expected))
228 | })
229 | })
230 | When("value sorter", func() {
231 | It("should return no quotes", func() {
232 | sorter := NewSorter("value")
233 |
234 | sortedQuotes := sorter([]*c.Asset{})
235 | expected := []*c.Asset{}
236 | Expect(sortedQuotes).To(Equal(expected))
237 | })
238 | })
239 | When("user sorter", func() {
240 | It("should return no quotes", func() {
241 | sorter := NewSorter("user")
242 |
243 | sortedQuotes := sorter([]*c.Asset{})
244 | expected := []*c.Asset{}
245 | Expect(sortedQuotes).To(Equal(expected))
246 | })
247 | })
248 | })
249 | })
250 | })
251 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/unary/unary.go:
--------------------------------------------------------------------------------
1 | package unary
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | "strconv"
9 | "strings"
10 | "time"
11 |
12 | c "github.com/achannarasappa/ticker/v5/internal/common"
13 | )
14 |
15 | const (
16 | productTypeFuture = "FUTURE"
17 | )
18 |
19 | // Response represents the container object from the API response
20 | type Response struct {
21 | Products []ResponseQuote `json:"products"`
22 | }
23 |
24 | // ResponseQuoteFcmTradingSessionDetails represents the trading session details for a product
25 | type ResponseQuoteFcmTradingSessionDetails struct {
26 | IsSessionOpen bool `json:"is_session_open"`
27 | }
28 |
29 | // ResponseQuoteFutureProductDetails represents the details specific to futures contracts
30 | type ResponseQuoteFutureProductDetails struct {
31 | ContractDisplayName string `json:"contract_display_name"`
32 | GroupDescription string `json:"group_description"`
33 | ContractRootUnit string `json:"contract_root_unit"`
34 | ExpirationDate string `json:"contract_expiry"`
35 | ExpirationTimezone string `json:"expiration_timezone"`
36 | NonCrypto bool `json:"non_crypto"`
37 | }
38 |
39 | // ResponseQuote represents a quote of a single product from the Coinbase API
40 | type ResponseQuote struct {
41 | Symbol string `json:"base_display_symbol"`
42 | ProductID string `json:"product_id"`
43 | ShortName string `json:"base_name"`
44 | Price string `json:"price"`
45 | PriceChange24H string `json:"price_percentage_change_24h"`
46 | Volume24H string `json:"volume_24h"`
47 | DisplayName string `json:"display_name"`
48 | MarketState string `json:"status"`
49 | Currency string `json:"quote_currency_id"`
50 | ExchangeName string `json:"product_venue"`
51 | FcmTradingSessionDetails ResponseQuoteFcmTradingSessionDetails `json:"fcm_trading_session_details"`
52 | FutureProductDetails ResponseQuoteFutureProductDetails `json:"future_product_details"`
53 | ProductType string `json:"product_type"`
54 | }
55 |
56 | type AssetQuotesIndexed struct {
57 | AssetQuotes []c.AssetQuote
58 | AssetQuotesByProductId map[string]*c.AssetQuote
59 | }
60 |
61 | type UnaryAPI struct {
62 | client *http.Client
63 | baseURL string
64 | }
65 |
66 | func NewUnaryAPI(baseURL string) *UnaryAPI {
67 | return &UnaryAPI{
68 | client: &http.Client{},
69 | baseURL: baseURL,
70 | }
71 | }
72 |
73 | func formatExpiry(expirationDate time.Time) string {
74 | now := time.Now()
75 | diff := expirationDate.Sub(now)
76 | days := int(diff.Hours() / 24)
77 | hours := int(diff.Hours()) % 24
78 | minutes := int(diff.Minutes()) % 60
79 |
80 | if days == 0 {
81 | return fmt.Sprintf("%dh %dmin", hours, minutes)
82 | }
83 |
84 | return fmt.Sprintf("%dd %dh", days, hours)
85 | }
86 |
87 | func transformResponseQuote(responseQuote ResponseQuote) c.AssetQuote {
88 | price, _ := strconv.ParseFloat(responseQuote.Price, 64)
89 | volume, _ := strconv.ParseFloat(responseQuote.Volume24H, 64)
90 | changePercent, _ := strconv.ParseFloat(responseQuote.PriceChange24H, 64)
91 |
92 | // Calculate absolute price change from percentage change
93 | change := price * (changePercent / 100)
94 |
95 | name := responseQuote.ShortName
96 | symbol := responseQuote.Symbol + ".CB"
97 | isActive := responseQuote.MarketState == "online"
98 | class := c.AssetClassCryptocurrency
99 | quoteFutures := c.QuoteFutures{}
100 |
101 | if responseQuote.ProductType == productTypeFuture {
102 |
103 | name = responseQuote.FutureProductDetails.GroupDescription
104 | symbol = responseQuote.ProductID + ".CB"
105 | isActive = responseQuote.FcmTradingSessionDetails.IsSessionOpen
106 | class = c.AssetClassFuturesContract
107 | expirationTimezone, _ := time.LoadLocation(responseQuote.FutureProductDetails.ExpirationTimezone)
108 | expirationDate, _ := time.ParseInLocation(time.RFC3339, responseQuote.FutureProductDetails.ExpirationDate, expirationTimezone)
109 |
110 | quoteFutures = c.QuoteFutures{
111 | SymbolUnderlying: responseQuote.FutureProductDetails.ContractRootUnit + "-USD",
112 | Expiry: formatExpiry(expirationDate),
113 | }
114 | }
115 |
116 | return c.AssetQuote{
117 | Name: name,
118 | Symbol: symbol,
119 | Class: class,
120 | Currency: c.Currency{
121 | FromCurrencyCode: strings.ToUpper(responseQuote.Currency),
122 | },
123 | QuotePrice: c.QuotePrice{
124 | Price: price,
125 | Change: change,
126 | ChangePercent: changePercent,
127 | },
128 | QuoteExtended: c.QuoteExtended{
129 | Volume: volume,
130 | },
131 | QuoteFutures: quoteFutures,
132 | QuoteSource: c.QuoteSourceCoinbase,
133 | Exchange: c.Exchange{
134 | Name: responseQuote.ExchangeName,
135 | State: c.ExchangeStateOpen,
136 | IsActive: isActive,
137 | IsRegularTradingSession: true, // Crypto markets are always in regular session
138 | },
139 | Meta: c.Meta{
140 | IsVariablePrecision: true,
141 | SymbolInSourceAPI: responseQuote.ProductID,
142 | },
143 | }
144 | }
145 |
146 | func transformResponseQuotes(responseQuotes []ResponseQuote) ([]c.AssetQuote, map[string]*c.AssetQuote) {
147 | quotes := make([]c.AssetQuote, 0, len(responseQuotes))
148 | quotesByProductId := make(map[string]*c.AssetQuote, len(responseQuotes))
149 |
150 | // Transform quotes
151 | for _, responseQuote := range responseQuotes {
152 | quote := transformResponseQuote(responseQuote)
153 | quotes = append(quotes, quote)
154 | quotesByProductId[quote.Meta.SymbolInSourceAPI] = "e
155 | }
156 |
157 | return quotes, quotesByProductId
158 | }
159 |
160 | func (u *UnaryAPI) GetAssetQuotes(symbols []string) ([]c.AssetQuote, map[string]*c.AssetQuote, error) {
161 | if len(symbols) == 0 {
162 | return []c.AssetQuote{}, make(map[string]*c.AssetQuote), nil
163 | }
164 |
165 | // Build URL with query parameters
166 | reqURL, _ := url.Parse(u.baseURL + "/api/v3/brokerage/market/products")
167 | q := reqURL.Query()
168 | for _, symbol := range symbols {
169 | q.Add("product_ids", symbol)
170 | }
171 | reqURL.RawQuery = q.Encode()
172 |
173 | // Make request
174 | resp, err := u.client.Get(reqURL.String())
175 | if err != nil {
176 | return nil, nil, fmt.Errorf("failed to make request: %w", err)
177 | }
178 | defer resp.Body.Close()
179 |
180 | if resp.StatusCode != http.StatusOK {
181 | return nil, nil, fmt.Errorf("request failed with status %d", resp.StatusCode)
182 | }
183 |
184 | // Decode response
185 | var result Response
186 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
187 | return nil, nil, fmt.Errorf("failed to decode response: %w", err)
188 | }
189 |
190 | quotes, quotesByProductId := transformResponseQuotes(result.Products)
191 |
192 | return quotes, quotesByProductId, nil
193 | }
194 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/monitor-price/poller/poller_test.go:
--------------------------------------------------------------------------------
1 | package poller_test
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "time"
7 |
8 | . "github.com/onsi/ginkgo/v2"
9 | . "github.com/onsi/gomega"
10 | "github.com/onsi/gomega/ghttp"
11 |
12 | g "github.com/onsi/gomega/gstruct"
13 |
14 | c "github.com/achannarasappa/ticker/v5/internal/common"
15 | poller "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/monitor-price/poller"
16 | unary "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
17 | )
18 |
19 | var _ = Describe("Poller", func() {
20 | var (
21 | server *ghttp.Server
22 | ctx context.Context
23 | cancel context.CancelFunc
24 | inputUnaryAPI *unary.UnaryAPI
25 | inputChanUpdateAssetQuote chan c.MessageUpdate[c.AssetQuote]
26 | inputChanError chan error
27 | )
28 |
29 | BeforeEach(func() {
30 | server = ghttp.NewServer()
31 |
32 | server.RouteToHandler("GET", "/v7/finance/quote",
33 | ghttp.CombineHandlers(
34 | ghttp.VerifyRequest("GET", "/v7/finance/quote", "symbols=NET&fields=shortName,regularMarketChange,regularMarketChangePercent,regularMarketPrice,regularMarketPreviousClose,regularMarketOpen,regularMarketDayRange,regularMarketDayHigh,regularMarketDayLow,regularMarketVolume,postMarketChange,postMarketChangePercent,postMarketPrice,preMarketChange,preMarketChangePercent,preMarketPrice,fiftyTwoWeekHigh,fiftyTwoWeekLow,marketCap&formatted=true&lang=en-US®ion=US&corsDomain=finance.yahoo.com"),
35 | ghttp.RespondWithJSONEncoded(http.StatusOK, responseQuote1Fixture),
36 | ),
37 | )
38 |
39 | inputChanUpdateAssetQuote = make(chan c.MessageUpdate[c.AssetQuote], 5)
40 | inputChanError = make(chan error, 5)
41 | ctx, cancel = context.WithCancel(context.Background())
42 |
43 | inputUnaryAPI = unary.NewUnaryAPI(unary.Config{
44 | BaseURL: server.URL(),
45 | SessionRootURL: server.URL(),
46 | SessionCrumbURL: server.URL(),
47 | SessionConsentURL: server.URL(),
48 | })
49 | })
50 |
51 | AfterEach(func() {
52 | server.Close()
53 | })
54 |
55 | Describe("NewPoller", func() {
56 | It("should create a new poller instance", func() {
57 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
58 | UnaryAPI: inputUnaryAPI,
59 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
60 | ChanError: inputChanError,
61 | })
62 | Expect(p).NotTo(BeNil())
63 | })
64 | })
65 |
66 | Describe("Start", func() {
67 | It("should start polling for price updates", func() {
68 |
69 | p := poller.NewPoller(ctx, poller.PollerConfig{
70 | UnaryAPI: inputUnaryAPI,
71 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
72 | ChanError: inputChanError,
73 | })
74 |
75 | p.SetSymbols([]string{"NET"}, 0)
76 | p.SetRefreshInterval(time.Millisecond * 100)
77 |
78 | err := p.Start()
79 | Expect(err).NotTo(HaveOccurred())
80 |
81 | Eventually(inputChanUpdateAssetQuote).Should(Receive(
82 | g.MatchFields(g.IgnoreExtras, g.Fields{
83 | "Data": g.MatchFields(g.IgnoreExtras, g.Fields{
84 | "QuotePrice": g.MatchFields(g.IgnoreExtras, g.Fields{
85 | "Price": Equal(84.98),
86 | }),
87 | }),
88 | }),
89 | ))
90 | Eventually(inputChanError).ShouldNot(Receive())
91 |
92 | })
93 |
94 | When("the poller is already started", func() {
95 | When("and the poller is started again", func() {
96 | It("should return an error", func() {
97 |
98 | p := poller.NewPoller(ctx, poller.PollerConfig{
99 | UnaryAPI: inputUnaryAPI,
100 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
101 | ChanError: inputChanError,
102 | })
103 |
104 | p.SetSymbols([]string{"NET"}, 0)
105 | p.SetRefreshInterval(time.Millisecond * 100)
106 |
107 | err := p.Start()
108 | Expect(err).NotTo(HaveOccurred())
109 |
110 | err = p.Start()
111 | Expect(err).To(HaveOccurred())
112 | })
113 | })
114 |
115 | When("and the refresh interval is set again", func() {
116 | It("should return an error", func() {
117 |
118 | p := poller.NewPoller(ctx, poller.PollerConfig{
119 | UnaryAPI: inputUnaryAPI,
120 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
121 | ChanError: inputChanError,
122 | })
123 |
124 | p.SetSymbols([]string{"NET"}, 0)
125 | p.SetRefreshInterval(time.Millisecond * 100)
126 |
127 | err := p.Start()
128 | Expect(err).NotTo(HaveOccurred())
129 |
130 | err = p.SetRefreshInterval(time.Millisecond * 200)
131 | Expect(err).To(HaveOccurred())
132 |
133 | })
134 | })
135 | })
136 |
137 | When("the refresh interval is not set", func() {
138 | It("should return an error", func() {
139 | p := poller.NewPoller(ctx, poller.PollerConfig{
140 | UnaryAPI: inputUnaryAPI,
141 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
142 | ChanError: inputChanError,
143 | })
144 |
145 | p.SetSymbols([]string{"NET"}, 0)
146 |
147 | err := p.Start()
148 | Expect(err).To(HaveOccurred())
149 | })
150 | })
151 |
152 | When("the symbols are not set", func() {
153 | It("should not return any price updates", func() {
154 |
155 | p := poller.NewPoller(ctx, poller.PollerConfig{
156 | UnaryAPI: inputUnaryAPI,
157 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
158 | ChanError: inputChanError,
159 | })
160 |
161 | p.SetRefreshInterval(time.Millisecond * 100)
162 | p.SetSymbols([]string{}, 0)
163 |
164 | err := p.Start()
165 | Expect(err).NotTo(HaveOccurred())
166 |
167 | Consistently(inputChanUpdateAssetQuote).ShouldNot(Receive())
168 | Consistently(inputChanError).ShouldNot(Receive())
169 |
170 | })
171 | })
172 |
173 | When("the context is cancelled", func() {
174 | It("should stop the polling process", func() {
175 | p := poller.NewPoller(ctx, poller.PollerConfig{
176 | UnaryAPI: inputUnaryAPI,
177 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
178 | ChanError: inputChanError,
179 | })
180 |
181 | p.SetSymbols([]string{"NET"}, 0)
182 | p.SetRefreshInterval(time.Millisecond * 100)
183 |
184 | err := p.Start()
185 | Expect(err).NotTo(HaveOccurred())
186 |
187 | cancel()
188 |
189 | Consistently(inputChanUpdateAssetQuote).ShouldNot(Receive())
190 | Consistently(inputChanError).ShouldNot(Receive())
191 |
192 | Eventually(ctx.Done()).Should(BeClosed())
193 |
194 | })
195 | })
196 | })
197 |
198 | Describe("Stop", func() {
199 | It("should stop the polling process", func() {
200 |
201 | p := poller.NewPoller(ctx, poller.PollerConfig{
202 | UnaryAPI: inputUnaryAPI,
203 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
204 | ChanError: inputChanError,
205 | })
206 |
207 | p.SetSymbols([]string{"NET"}, 0)
208 | p.SetRefreshInterval(time.Millisecond * 100)
209 |
210 | err := p.Start()
211 | Expect(err).NotTo(HaveOccurred())
212 |
213 | err = p.Stop()
214 | Expect(err).NotTo(HaveOccurred())
215 |
216 | Consistently(inputChanUpdateAssetQuote).ShouldNot(Receive())
217 | Consistently(inputChanError).ShouldNot(Receive())
218 | })
219 | })
220 | })
221 |
--------------------------------------------------------------------------------
/internal/print/print_test.go:
--------------------------------------------------------------------------------
1 | package print_test
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "net/http"
7 | "os"
8 |
9 | c "github.com/achannarasappa/ticker/v5/internal/common"
10 | "github.com/achannarasappa/ticker/v5/internal/monitor/yahoo/unary"
11 | "github.com/achannarasappa/ticker/v5/internal/print"
12 | . "github.com/onsi/ginkgo/v2"
13 | . "github.com/onsi/gomega"
14 | "github.com/onsi/gomega/ghttp"
15 | "github.com/spf13/cobra"
16 | )
17 |
18 | func getStdout(fn func()) string {
19 | rescueStdout := os.Stdout
20 | r, w, _ := os.Pipe()
21 | os.Stdout = w
22 |
23 | fn()
24 |
25 | w.Close()
26 | out, _ := ioutil.ReadAll(r)
27 | os.Stdout = rescueStdout
28 |
29 | return string(out)
30 | }
31 |
32 | var _ = Describe("Print", func() {
33 |
34 | var (
35 | server *ghttp.Server
36 | inputOptions = print.Options{}
37 | inputContext = c.Context{}
38 | inputDependencies c.Dependencies
39 | )
40 |
41 | BeforeEach(func() {
42 | server = ghttp.NewServer()
43 |
44 | responseFixture := `"BTC.X","BTC-USDC","cb"
45 | "XRP.X","XRP-USDC","cb"
46 | "ETH.X","ETH-USD","cb"
47 | "SOL.X","SOL-USD","cb"
48 | "SUI.X","SUI-USD","cb"
49 | `
50 | server.RouteToHandler("GET", "/symbols.csv",
51 | ghttp.CombineHandlers(
52 | ghttp.VerifyRequest("GET", "/symbols.csv"),
53 | ghttp.RespondWith(http.StatusOK, responseFixture, http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}}),
54 | ),
55 | )
56 |
57 | server.RouteToHandler(http.MethodGet, "/v7/finance/quote",
58 | ghttp.CombineHandlers(
59 | func(w http.ResponseWriter, r *http.Request) {
60 | query := r.URL.Query()
61 | fields := query.Get("fields")
62 |
63 | if fields == "regularMarketPrice,currency" {
64 | json.NewEncoder(w).Encode(currencyResponseFixture)
65 | } else {
66 | json.NewEncoder(w).Encode(quoteCloudflareFixture)
67 | }
68 | },
69 | ),
70 | )
71 |
72 | inputDependencies = c.Dependencies{
73 | SymbolsURL: server.URL() + "/symbols.csv",
74 | MonitorYahooBaseURL: server.URL(),
75 | MonitorYahooSessionRootURL: server.URL(),
76 | MonitorYahooSessionCrumbURL: server.URL(),
77 | MonitorYahooSessionConsentURL: server.URL(),
78 | MonitorPriceCoinbaseBaseURL: server.URL(),
79 | MonitorPriceCoinbaseStreamingURL: server.URL(),
80 | }
81 |
82 | inputContext = c.Context{
83 | Groups: []c.AssetGroup{
84 | {
85 | SymbolsBySource: []c.AssetGroupSymbolsBySource{
86 | {
87 | Source: c.QuoteSourceYahoo,
88 | Symbols: []string{
89 | "GOOG",
90 | "RBLX",
91 | },
92 | },
93 | },
94 | ConfigAssetGroup: c.ConfigAssetGroup{
95 | Holdings: []c.Lot{
96 | {
97 | Symbol: "GOOG",
98 | UnitCost: 1000,
99 | Quantity: 10,
100 | },
101 | {
102 | Symbol: "RBLX",
103 | UnitCost: 50,
104 | Quantity: 10,
105 | },
106 | },
107 | },
108 | },
109 | },
110 | }
111 | })
112 |
113 | Describe("Run", func() {
114 |
115 | It("should print holdings in JSON format", func() {
116 |
117 | output := getStdout(func() {
118 | print.Run(&inputDependencies, &inputContext, &inputOptions)(&cobra.Command{}, []string{})
119 | })
120 | Expect(output).To(Equal("[{\"name\":\"Alphabet Inc.\",\"symbol\":\"GOOG\",\"price\":\"2838.420000\",\"value\":\"28384.200000\",\"cost\":\"10000.000000\",\"quantity\":\"10.000000\",\"weight\":\"96.996890\"},{\"name\":\"Roblox Corporation\",\"symbol\":\"RBLX\",\"price\":\"87.880000\",\"value\":\"878.800000\",\"cost\":\"500.000000\",\"quantity\":\"10.000000\",\"weight\":\"3.003110\"}]\n"))
121 | })
122 |
123 | When("there are no holdings in the default group", func() {
124 | BeforeEach(func() {
125 | inputContext.Groups[0].ConfigAssetGroup.Holdings = []c.Lot{}
126 | })
127 |
128 | It("should print an empty array", func() {
129 |
130 | output := getStdout(func() {
131 | print.Run(&inputDependencies, &inputContext, &inputOptions)(&cobra.Command{}, []string{})
132 | })
133 | Expect(output).To(Equal("[]\n"))
134 | })
135 | })
136 |
137 | When("the format option is set to csv", func() {
138 | It("should print the holdings in CSV format", func() {
139 | inputOptions := print.Options{
140 | Format: "csv",
141 | }
142 | output := getStdout(func() {
143 | print.Run(&inputDependencies, &inputContext, &inputOptions)(&cobra.Command{}, []string{})
144 | })
145 | Expect(output).To(Equal("name,symbol,price,value,cost,quantity,weight\nAlphabet Inc.,GOOG,2838.42,28384.20,10000.00,10.000,96.997\nRoblox Corporation,RBLX,87.880,878.80,500.00,10.000,3.0031\n\n"))
146 | })
147 | })
148 | })
149 |
150 | Describe("RunSummary", func() {
151 |
152 | It("should print the holdings summary in JSON format", func() {
153 | output := getStdout(func() {
154 | print.RunSummary(&inputDependencies, &inputContext, &inputOptions)(&cobra.Command{}, []string{})
155 | })
156 | Expect(output).To(Equal("{\"total_value\":\"29263.000000\",\"total_cost\":\"10500.000000\",\"day_change_amount\":\"2750.500000\",\"day_change_percent\":\"9.399241\",\"total_change_amount\":\"18763.000000\",\"total_change_percent\":\"178.695238\"}\n"))
157 | })
158 |
159 | When("the format option is set to csv", func() {
160 | It("should print the holdings summary in CSV format", func() {
161 | inputOptions := print.Options{
162 | Format: "csv",
163 | }
164 | output := getStdout(func() {
165 | print.RunSummary(&inputDependencies, &inputContext, &inputOptions)(&cobra.Command{}, []string{})
166 | })
167 | Expect(output).To(Equal("total_value,total_cost,day_change_amount,day_change_percent,total_change_amount,total_change_percent\n29263.000000,10500.000000,2750.500000,9.399241,18763.000000,178.695238\n\n"))
168 | })
169 | })
170 |
171 | })
172 |
173 | })
174 |
175 | var currencyResponseFixture = unary.Response{
176 | QuoteResponse: unary.ResponseQuoteResponse{
177 | Quotes: []unary.ResponseQuote{
178 | {
179 | Currency: "USD",
180 | Symbol: "RBLX",
181 | },
182 | {
183 | Currency: "USD",
184 | Symbol: "GOOG",
185 | },
186 | },
187 | },
188 | }
189 |
190 | var quoteCloudflareFixture = unary.Response{
191 | QuoteResponse: unary.ResponseQuoteResponse{
192 | Quotes: []unary.ResponseQuote{
193 | {
194 | ShortName: "Alphabet Inc.",
195 | Symbol: "GOOG",
196 | MarketState: "REGULAR",
197 | Currency: "USD",
198 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 2838.42, Fmt: "2838.42"},
199 | RegularMarketChangePercent: unary.ResponseFieldFloat{Raw: 10.00, Fmt: "10.00"},
200 | RegularMarketChange: unary.ResponseFieldFloat{Raw: 283.84, Fmt: "283.84"},
201 | },
202 | {
203 | ShortName: "Roblox Corporation",
204 | Symbol: "RBLX",
205 | MarketState: "REGULAR",
206 | Currency: "USD",
207 | RegularMarketPrice: unary.ResponseFieldFloat{Raw: 87.88, Fmt: "87.88"},
208 | RegularMarketChangePercent: unary.ResponseFieldFloat{Raw: -10.00, Fmt: "-10.00"},
209 | RegularMarketChange: unary.ResponseFieldFloat{Raw: -8.79, Fmt: "-8.79"},
210 | },
211 | },
212 | },
213 | }
214 |
--------------------------------------------------------------------------------
/internal/monitor/yahoo/unary/unary.go:
--------------------------------------------------------------------------------
1 | package unary
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | "strings"
9 |
10 | c "github.com/achannarasappa/ticker/v5/internal/common"
11 | )
12 |
13 | // UnaryAPI is a client for the API
14 | type UnaryAPI struct {
15 | client *http.Client
16 | baseURL string
17 | sessionRootURL string
18 | sessionCrumbURL string
19 | sessionConsentURL string
20 | cookies []*http.Cookie
21 | crumb string
22 | }
23 |
24 | // Config contains configuration options for the UnaryAPI client
25 | type Config struct {
26 | BaseURL string
27 | SessionRootURL string
28 | SessionCrumbURL string
29 | SessionConsentURL string
30 | }
31 |
32 | type SymbolToCurrency struct {
33 | Symbol string
34 | FromCurrency string
35 | }
36 |
37 | // NewUnaryAPI creates a new client
38 | func NewUnaryAPI(config Config) *UnaryAPI {
39 | // Create client with limited redirects
40 | client := &http.Client{
41 | CheckRedirect: func(req *http.Request, via []*http.Request) error {
42 | if len(via) >= 1 {
43 | return http.ErrUseLastResponse
44 | }
45 |
46 | return nil
47 | },
48 | }
49 |
50 | return &UnaryAPI{
51 | client: client,
52 | baseURL: config.BaseURL,
53 | sessionRootURL: config.SessionRootURL,
54 | sessionCrumbURL: config.SessionCrumbURL,
55 | sessionConsentURL: config.SessionConsentURL,
56 | }
57 | }
58 |
59 | // GetAssetQuotes issues a HTTP request to retrieve quotes from the API and process the response
60 | func (u *UnaryAPI) GetAssetQuotes(symbols []string) ([]c.AssetQuote, map[string]*c.AssetQuote, error) {
61 | if len(symbols) == 0 {
62 | return []c.AssetQuote{}, make(map[string]*c.AssetQuote), nil
63 | }
64 |
65 | result, err := u.getQuotes(symbols, []string{"shortName", "regularMarketChange", "regularMarketChangePercent", "regularMarketPrice", "regularMarketPreviousClose", "regularMarketOpen", "regularMarketDayRange", "regularMarketDayHigh", "regularMarketDayLow", "regularMarketVolume", "postMarketChange", "postMarketChangePercent", "postMarketPrice", "preMarketChange", "preMarketChangePercent", "preMarketPrice", "fiftyTwoWeekHigh", "fiftyTwoWeekLow", "marketCap"})
66 |
67 | if err != nil {
68 | return nil, nil, fmt.Errorf("failed to get quotes: %w", err)
69 | }
70 |
71 | quotes, quotesBySymbol := transformResponseQuotes(result.QuoteResponse.Quotes)
72 |
73 | return quotes, quotesBySymbol, nil
74 | }
75 |
76 | // GetCurrencyMap retrieves the currency which the price quote will be denominated in for the given symbols
77 | func (u *UnaryAPI) GetCurrencyMap(symbols []string) (map[string]SymbolToCurrency, error) {
78 | if len(symbols) == 0 {
79 | return map[string]SymbolToCurrency{}, nil
80 | }
81 |
82 | result, err := u.getQuotes(symbols, []string{"regularMarketPrice", "currency"})
83 |
84 | if err != nil {
85 | return map[string]SymbolToCurrency{}, err
86 | }
87 |
88 | symbolToCurrency := make(map[string]SymbolToCurrency)
89 |
90 | for _, quote := range result.QuoteResponse.Quotes {
91 | symbolToCurrency[quote.Symbol] = SymbolToCurrency{
92 | Symbol: quote.Symbol,
93 | FromCurrency: strings.ToUpper(quote.Currency),
94 | }
95 | }
96 |
97 | return symbolToCurrency, nil
98 | }
99 |
100 | // GetCurrencyRates accepts an array of ISO 4217 currency codes and a target ISO 4217 currency code and returns a conversion rate for each of the input currencies to the target currency
101 | func (u *UnaryAPI) GetCurrencyRates(fromCurrencies []string, toCurrency string) (c.CurrencyRates, error) {
102 | if toCurrency == "" {
103 | toCurrency = "USD"
104 | }
105 |
106 | if len(fromCurrencies) == 0 {
107 | return c.CurrencyRates{}, nil
108 | }
109 |
110 | // Create currency pair symbols in format "FROMTO=X" (e.g., "EURUSD=X")
111 | currencyPairSymbols := make([]string, 0)
112 | currencyPairSymbolsUnique := make(map[string]bool)
113 |
114 | for _, fromCurrency := range fromCurrencies {
115 |
116 | if fromCurrency == "" {
117 | continue
118 | }
119 |
120 | if fromCurrency == toCurrency {
121 | continue
122 | }
123 |
124 | pair := strings.ToUpper(fromCurrency) + toCurrency + "=X"
125 |
126 | if _, exists := currencyPairSymbolsUnique[pair]; !exists {
127 | currencyPairSymbolsUnique[pair] = true
128 | currencyPairSymbols = append(currencyPairSymbols, pair)
129 | }
130 | }
131 |
132 | if len(currencyPairSymbols) == 0 {
133 | return c.CurrencyRates{}, nil
134 | }
135 |
136 | // Get quotes for currency pairs
137 | result, err := u.getQuotes(currencyPairSymbols, []string{"currency", "regularMarketPrice"})
138 | if err != nil {
139 | return c.CurrencyRates{}, fmt.Errorf("failed to get currency rates: %w", err)
140 | }
141 |
142 | // Transform result to currency rates
143 | currencyRates := make(map[string]c.CurrencyRate)
144 |
145 | for _, quote := range result.QuoteResponse.Quotes {
146 | fromCurrency := strings.TrimSuffix(strings.TrimSuffix(quote.Symbol, "=X"), toCurrency)
147 | currencyRates[fromCurrency] = c.CurrencyRate{
148 | FromCurrency: fromCurrency,
149 | ToCurrency: toCurrency,
150 | Rate: quote.RegularMarketPrice.Raw,
151 | }
152 | }
153 |
154 | return currencyRates, nil
155 | }
156 |
157 | func (u *UnaryAPI) getQuotes(symbols []string, fields []string) (Response, error) {
158 |
159 | // Build URL with query parameters
160 | reqURL, err := url.Parse(u.baseURL + "/v7/finance/quote")
161 | if err != nil {
162 | return Response{}, fmt.Errorf("failed to create request: %w", err)
163 | }
164 |
165 | q := reqURL.Query()
166 | q.Set("fields", strings.Join(fields, ","))
167 | q.Set("symbols", strings.Join(symbols, ","))
168 |
169 | // Add common Yahoo Finance query parameters
170 | q.Set("formatted", "true")
171 | q.Set("lang", "en-US")
172 | q.Set("region", "US")
173 | q.Set("corsDomain", "finance.yahoo.com")
174 |
175 | // Add crumb if available
176 | if u.crumb != "" {
177 | q.Set("crumb", u.crumb)
178 | }
179 |
180 | reqURL.RawQuery = q.Encode()
181 |
182 | // Create request
183 | req, _ := http.NewRequest(http.MethodGet, reqURL.String(), nil)
184 |
185 | // Set common headers
186 | req.Header.Set("Authority", "query1.finance.yahoo.com")
187 | req.Header.Set("Accept", "*/*")
188 | req.Header.Set("Accept-Language", defaultAcceptLang)
189 | req.Header.Set("Origin", u.baseURL)
190 | req.Header.Set("User-Agent", defaultUserAgent)
191 |
192 | // Add cookies if available
193 | if len(u.cookies) > 0 {
194 | for _, cookie := range u.cookies {
195 | req.AddCookie(cookie)
196 | }
197 | }
198 |
199 | // Make request
200 | resp, err := u.client.Do(req)
201 | if err != nil {
202 | return Response{}, fmt.Errorf("failed to make request: %w", err)
203 | }
204 | defer resp.Body.Close()
205 |
206 | // Handle not ok responses
207 | if resp.StatusCode >= 400 {
208 | // Try to refresh session and retry once
209 | if err := u.refreshSession(); err != nil {
210 | return Response{}, fmt.Errorf("session refresh failed: %w", err)
211 | }
212 |
213 | // Retry request with refreshed session
214 | return u.getQuotes(symbols, fields)
215 | }
216 |
217 | // Handle unexpected responses
218 | if resp.StatusCode != http.StatusOK && resp.StatusCode < 400 {
219 | return Response{}, fmt.Errorf("unexpected response: %d", resp.StatusCode)
220 | }
221 |
222 | // Decode response
223 | var result Response
224 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
225 | return Response{}, fmt.Errorf("failed to decode response: %w", err)
226 | }
227 |
228 | return result, nil
229 | }
230 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/monitor-price/streamer/streamer.go:
--------------------------------------------------------------------------------
1 | package streamer
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "strconv"
8 | "sync"
9 |
10 | c "github.com/achannarasappa/ticker/v5/internal/common"
11 | "github.com/gorilla/websocket"
12 | )
13 |
14 | type messageSubscription struct {
15 | Type string `json:"type"`
16 | ProductIDs []string `json:"product_ids"`
17 | Channels []string `json:"channels"`
18 | }
19 |
20 | type messagePriceTick struct {
21 | Type string `json:"type"`
22 | Sequence int64 `json:"sequence"`
23 | ProductID string `json:"product_id"`
24 | Price string `json:"price"`
25 | Open24h string `json:"open_24h"`
26 | Volume24h string `json:"volume_24h"`
27 | Low24h string `json:"low_24h"`
28 | High24h string `json:"high_24h"`
29 | Volume30d string `json:"volume_30d"`
30 | BestBid string `json:"best_bid"`
31 | BestBidSize string `json:"best_bid_size"`
32 | BestAsk string `json:"best_ask"`
33 | BestAskSize string `json:"best_ask_size"`
34 | Side string `json:"side"`
35 | Time string `json:"time"`
36 | TradeID int64 `json:"trade_id"`
37 | LastSize string `json:"last_size"`
38 | }
39 |
40 | type Streamer struct {
41 | symbols []string
42 | conn *websocket.Conn
43 | isStarted bool
44 | url string
45 | subscriptionChan chan messageSubscription
46 | wg sync.WaitGroup
47 | ctx context.Context
48 | cancel context.CancelFunc
49 | chanStreamUpdateQuotePrice chan c.MessageUpdate[c.QuotePrice]
50 | chanStreamUpdateQuoteExtended chan c.MessageUpdate[c.QuoteExtended]
51 | chanError chan error
52 | versionVector int
53 | }
54 |
55 | type StreamerConfig struct {
56 | ChanStreamUpdateQuotePrice chan c.MessageUpdate[c.QuotePrice]
57 | ChanStreamUpdateQuoteExtended chan c.MessageUpdate[c.QuoteExtended]
58 | ChanError chan error
59 | }
60 |
61 | func NewStreamer(ctx context.Context, config StreamerConfig) *Streamer {
62 | ctx, cancel := context.WithCancel(ctx)
63 |
64 | s := &Streamer{
65 | chanStreamUpdateQuotePrice: config.ChanStreamUpdateQuotePrice,
66 | chanStreamUpdateQuoteExtended: config.ChanStreamUpdateQuoteExtended,
67 | chanError: config.ChanError,
68 | ctx: ctx,
69 | cancel: cancel,
70 | wg: sync.WaitGroup{},
71 | subscriptionChan: make(chan messageSubscription),
72 | versionVector: 0,
73 | }
74 |
75 | return s
76 | }
77 |
78 | func (s *Streamer) Start() error {
79 | if s.isStarted {
80 | return errors.New("streamer already started")
81 | }
82 |
83 | if s.url == "" {
84 | // TODO: log streaming not started
85 | return nil
86 | }
87 |
88 | // Create connection channel for result
89 | connChan := make(chan *websocket.Conn, 1)
90 | errChan := make(chan error, 1)
91 |
92 | // Connect the websocket address in a goroutine
93 | go func() {
94 | url := s.url
95 | conn, _, err := websocket.DefaultDialer.DialContext(s.ctx, url, nil)
96 | if err != nil {
97 | errChan <- err
98 |
99 | return
100 | }
101 | connChan <- conn
102 | }()
103 |
104 | // Wait for either connection, error, or context cancellation
105 | select {
106 | case conn := <-connChan:
107 | s.conn = conn
108 | case err := <-errChan:
109 |
110 | return err
111 | case <-s.ctx.Done():
112 |
113 | return fmt.Errorf("connection aborted: %w", s.ctx.Err())
114 | }
115 |
116 | // Disconnect on stop signal
117 | go func() {
118 | <-s.ctx.Done()
119 | s.wg.Wait()
120 | s.conn.Close()
121 | s.isStarted = false
122 | s.symbols = []string{}
123 | }()
124 |
125 | s.isStarted = true
126 |
127 | s.wg.Add(2)
128 | go s.readStreamQuote()
129 | go s.writeStreamSubscription()
130 |
131 | return nil
132 | }
133 |
134 | func (s *Streamer) SetSymbolsAndUpdateSubscriptions(symbols []string, versionVector int) error {
135 |
136 | var err error
137 |
138 | if !s.isStarted {
139 |
140 | return nil
141 | }
142 |
143 | s.symbols = symbols
144 | s.versionVector = versionVector
145 |
146 | // TODO: fix symbol change
147 | // err = s.unsubscribe()
148 | // if err != nil {
149 | // return err
150 | // }
151 |
152 | err = s.subscribe(s.symbols)
153 | if err != nil {
154 |
155 | return err
156 | }
157 |
158 | return nil
159 | }
160 |
161 | func (s *Streamer) SetURL(url string) error {
162 |
163 | if s.isStarted {
164 |
165 | return errors.New("cannot set URL while streamer is connected")
166 | }
167 |
168 | s.url = url
169 |
170 | return nil
171 | }
172 |
173 | func (s *Streamer) readStreamQuote() {
174 | defer s.wg.Done()
175 |
176 | for {
177 | select {
178 | case <-s.ctx.Done():
179 | return
180 | default:
181 | var message messagePriceTick
182 | err := s.conn.ReadJSON(&message)
183 | if err != nil {
184 | s.chanError <- err
185 |
186 | return
187 | }
188 |
189 | // Only handle ticker messages; first message is a subscription confirmation
190 | if message.Type != "ticker" {
191 |
192 | continue
193 | }
194 |
195 | qp, qe := transformPriceTick(message, s.versionVector)
196 | s.chanStreamUpdateQuotePrice <- qp
197 | s.chanStreamUpdateQuoteExtended <- qe
198 | }
199 | }
200 | }
201 |
202 | func (s *Streamer) writeStreamSubscription() {
203 | defer s.wg.Done()
204 |
205 | for {
206 | select {
207 | case <-s.ctx.Done():
208 |
209 | return
210 | case message := <-s.subscriptionChan:
211 |
212 | err := s.conn.WriteJSON(message)
213 | if err != nil {
214 | s.chanError <- err
215 |
216 | return
217 | }
218 | }
219 | }
220 | }
221 |
222 | func (s *Streamer) subscribe(productIDs []string) error {
223 |
224 | message := messageSubscription{
225 | Type: "subscribe",
226 | ProductIDs: productIDs,
227 | Channels: []string{"ticker"},
228 | }
229 |
230 | s.subscriptionChan <- message
231 |
232 | return nil
233 | }
234 |
235 | func (s *Streamer) unsubscribe() error { //nolint:unused
236 |
237 | message := messageSubscription{
238 | Type: "unsubscribe",
239 | Channels: []string{"ticker"},
240 | }
241 |
242 | s.subscriptionChan <- message
243 |
244 | return nil
245 | }
246 |
247 | func transformPriceTick(message messagePriceTick, versionVector int) (qp c.MessageUpdate[c.QuotePrice], qe c.MessageUpdate[c.QuoteExtended]) {
248 |
249 | price, _ := strconv.ParseFloat(message.Price, 64)
250 | priceOpen, _ := strconv.ParseFloat(message.Open24h, 64)
251 | priceDayHigh, _ := strconv.ParseFloat(message.High24h, 64)
252 | priceDayLow, _ := strconv.ParseFloat(message.Low24h, 64)
253 | change := price - priceOpen
254 | changePercent := change / priceOpen
255 |
256 | qp = c.MessageUpdate[c.QuotePrice]{
257 | ID: message.ProductID,
258 | Sequence: message.Sequence,
259 | VersionVector: versionVector,
260 | Data: c.QuotePrice{
261 | Price: price,
262 | PricePrevClose: priceOpen,
263 | PriceOpen: priceOpen,
264 | PriceDayHigh: priceDayHigh,
265 | PriceDayLow: priceDayLow,
266 | Change: change,
267 | ChangePercent: changePercent,
268 | },
269 | }
270 |
271 | volume, _ := strconv.ParseFloat(message.Volume24h, 64)
272 |
273 | qe = c.MessageUpdate[c.QuoteExtended]{
274 | ID: message.ProductID,
275 | Sequence: message.Sequence,
276 | VersionVector: versionVector,
277 | Data: c.QuoteExtended{
278 | Volume: volume,
279 | },
280 | }
281 |
282 | return qp, qe
283 | }
284 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/unary/unary_test.go:
--------------------------------------------------------------------------------
1 | package unary_test
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/achannarasappa/ticker/v5/internal/monitor/coinbase/unary"
9 | . "github.com/onsi/ginkgo/v2"
10 | . "github.com/onsi/gomega"
11 | "github.com/onsi/gomega/ghttp"
12 | )
13 |
14 | var _ = Describe("Unary", func() {
15 | var (
16 | server *ghttp.Server
17 | )
18 |
19 | BeforeEach(func() {
20 | server = ghttp.NewServer()
21 | })
22 |
23 | AfterEach(func() {
24 | server.Close()
25 | })
26 |
27 | Describe("NewUnaryAPI", func() {
28 | It("should return a new UnaryAPI", func() {
29 | api := unary.NewUnaryAPI(server.URL())
30 | Expect(api).NotTo(BeNil())
31 | })
32 | })
33 |
34 | Describe("GetAssetQuotes", func() {
35 | It("should return a list of asset quotes", func() {
36 | server.AppendHandlers(
37 | ghttp.CombineHandlers(
38 | ghttp.VerifyRequest("GET", "/api/v3/brokerage/market/products", "product_ids=BTC-USD"),
39 | ghttp.RespondWithJSONEncoded(http.StatusOK, unary.Response{
40 | Products: []unary.ResponseQuote{
41 | {
42 | Symbol: "BTC",
43 | ProductID: "BTC-USD",
44 | ShortName: "Bitcoin",
45 | Price: "50000.00",
46 | PriceChange24H: "5.00",
47 | Volume24H: "1000000.00",
48 | MarketState: "online",
49 | Currency: "USD",
50 | ExchangeName: "CBE",
51 | },
52 | },
53 | }),
54 | ),
55 | )
56 |
57 | api := unary.NewUnaryAPI(server.URL())
58 | quotes, _, err := api.GetAssetQuotes([]string{"BTC-USD"})
59 |
60 | Expect(err).NotTo(HaveOccurred())
61 | Expect(quotes).To(HaveLen(1))
62 | Expect(quotes[0].Symbol).To(Equal("BTC.CB"))
63 | Expect(quotes[0].QuotePrice.Price).To(Equal(50000.00))
64 | })
65 |
66 | When("there is a quote for a futures contract", func() {
67 | It("should return the futures product with calculated properties for the underlying asset", func() {
68 | server.AppendHandlers(
69 | ghttp.CombineHandlers(
70 | ghttp.VerifyRequest("GET", "/api/v3/brokerage/market/products", "product_ids=BIT-31JAN25-CDE&product_ids=BTC-USD"),
71 | ghttp.RespondWithJSONEncoded(http.StatusOK, unary.Response{
72 | Products: []unary.ResponseQuote{
73 | {
74 | Symbol: "BIT-31JAN25-CDE",
75 | ProductID: "BIT-31JAN25-CDE",
76 | ShortName: "Bitcoin January 2025 Future",
77 | Price: "60000.00",
78 | PriceChange24H: "5.00",
79 | Volume24H: "1000000.00",
80 | MarketState: "online",
81 | Currency: "USD",
82 | ExchangeName: "CDE",
83 | ProductType: "FUTURE",
84 | FutureProductDetails: unary.ResponseQuoteFutureProductDetails{
85 | ContractRootUnit: "BTC",
86 | },
87 | },
88 | {
89 | Symbol: "BTC",
90 | ProductID: "BTC-USD",
91 | ShortName: "Bitcoin",
92 | Price: "50000.00",
93 | PriceChange24H: "5.00",
94 | Volume24H: "1000000.00",
95 | MarketState: "online",
96 | Currency: "USD",
97 | ExchangeName: "CBE",
98 | ProductType: "SPOT",
99 | },
100 | },
101 | }),
102 | ),
103 | )
104 |
105 | api := unary.NewUnaryAPI(server.URL())
106 | quotes, _, err := api.GetAssetQuotes([]string{"BIT-31JAN25-CDE", "BTC-USD"})
107 |
108 | Expect(err).NotTo(HaveOccurred())
109 | Expect(quotes).To(HaveLen(2))
110 | Expect(quotes[0].Symbol).To(Equal("BIT-31JAN25-CDE.CB"))
111 | Expect(quotes[0].QuotePrice.Price).To(Equal(60000.00))
112 | Expect(quotes[0].QuoteFutures.SymbolUnderlying).To(Equal("BTC-USD"))
113 | Expect(quotes[0].QuoteFutures.IndexPrice).To(Equal(0.00))
114 | Expect(quotes[0].QuoteFutures.Basis).To(Equal(0.00))
115 | Expect(quotes[0].QuoteFutures.Expiry).To(MatchRegexp(`-?\d+d -?\d+h`))
116 | })
117 |
118 | When("the expiration is on the current day", func() {
119 | It("should return the expiry without days", func() {
120 | currentDate := time.Now().Format("02Jan06")
121 | productId := fmt.Sprintf("BIT-%s-CDE", currentDate)
122 |
123 | server.AppendHandlers(
124 | ghttp.CombineHandlers(
125 | ghttp.VerifyRequest("GET", "/api/v3/brokerage/market/products", fmt.Sprintf("product_ids=%s&product_ids=BTC-USD", productId)),
126 | ghttp.RespondWithJSONEncoded(http.StatusOK, unary.Response{
127 | Products: []unary.ResponseQuote{
128 | {
129 | Symbol: productId,
130 | ProductID: productId,
131 | ShortName: "Bitcoin Today Future",
132 | Price: "60000.00",
133 | PriceChange24H: "5.00",
134 | Volume24H: "1000000.00",
135 | MarketState: "online",
136 | Currency: "USD",
137 | ExchangeName: "CDE",
138 | ProductType: "FUTURE",
139 | FutureProductDetails: unary.ResponseQuoteFutureProductDetails{
140 | ContractRootUnit: "BTC",
141 | ExpirationDate: time.Now().Add(5*time.Hour + 30*time.Minute).Format(time.RFC3339),
142 | ExpirationTimezone: time.Local.String(),
143 | },
144 | },
145 | {
146 | Symbol: "BTC",
147 | ProductID: "BTC-USD",
148 | ShortName: "Bitcoin",
149 | Price: "50000.00",
150 | PriceChange24H: "5.00",
151 | Volume24H: "1000000.00",
152 | MarketState: "online",
153 | Currency: "USD",
154 | ExchangeName: "CBE",
155 | ProductType: "SPOT",
156 | },
157 | },
158 | }),
159 | ),
160 | )
161 |
162 | api := unary.NewUnaryAPI(server.URL())
163 | quotes, _, err := api.GetAssetQuotes([]string{productId, "BTC-USD"})
164 |
165 | Expect(err).NotTo(HaveOccurred())
166 | Expect(quotes).To(HaveLen(2))
167 | Expect(quotes[0].Symbol).To(Equal(productId + ".CB"))
168 | Expect(quotes[0].QuoteFutures.Expiry).To(MatchRegexp(`-?\d+h`))
169 | Expect(quotes[0].QuoteFutures.Expiry).To(MatchRegexp(`-?\d+min`))
170 | Expect(quotes[0].QuoteFutures.Expiry).NotTo(MatchRegexp(`-?\d+d`))
171 | })
172 | })
173 |
174 | })
175 |
176 | Context("when the request fails", func() {
177 | When("the request fails", func() {
178 | It("should return an error", func() {
179 | server.AppendHandlers(
180 | ghttp.CombineHandlers(
181 | ghttp.VerifyRequest("GET", "/api/v3/brokerage/market/products"),
182 | ghttp.RespondWith(http.StatusInternalServerError, ""),
183 | ),
184 | )
185 |
186 | api := unary.NewUnaryAPI(server.URL())
187 | quotes, _, err := api.GetAssetQuotes([]string{"BTC-USD"})
188 |
189 | Expect(err).To(HaveOccurred())
190 | Expect(quotes).To(BeEmpty())
191 | })
192 | })
193 |
194 | When("the response is invalid", func() {
195 | It("should return an error", func() {
196 | server.AppendHandlers(
197 | ghttp.CombineHandlers(
198 | ghttp.VerifyRequest("GET", "/api/v3/brokerage/market/products"),
199 | ghttp.RespondWith(http.StatusOK, "invalid"),
200 | ),
201 | )
202 |
203 | api := unary.NewUnaryAPI(server.URL())
204 | quotes, _, err := api.GetAssetQuotes([]string{"BTC-USD"})
205 |
206 | Expect(err).To(HaveOccurred())
207 | Expect(quotes).To(BeEmpty())
208 | })
209 | })
210 |
211 | When("the request is invalid", func() {
212 | It("should return an error", func() {
213 | api := unary.NewUnaryAPI("invalid")
214 | quotes, _, err := api.GetAssetQuotes([]string{"BTC-USD"})
215 |
216 | Expect(err).To(HaveOccurred())
217 | Expect(quotes).To(BeEmpty())
218 | })
219 | })
220 | })
221 |
222 | When("there are no symbols set", func() {
223 | It("should return an empty list", func() {
224 | api := unary.NewUnaryAPI(server.URL())
225 | quotes, _, err := api.GetAssetQuotes([]string{})
226 |
227 | Expect(err).NotTo(HaveOccurred())
228 | Expect(quotes).To(BeEmpty())
229 | })
230 | })
231 | })
232 | })
233 |
--------------------------------------------------------------------------------
/internal/common/common.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/spf13/afero"
7 | )
8 |
9 | // Context represents user defined configuration and derived reference configuration
10 | type Context struct {
11 | Config Config
12 | Groups []AssetGroup
13 | Reference Reference
14 | Logger *log.Logger
15 | }
16 |
17 | // Config represents user defined configuration
18 | type Config struct {
19 | RefreshInterval int `yaml:"interval"`
20 | Watchlist []string `yaml:"watchlist"`
21 | Lots []Lot `yaml:"lots"`
22 | Separate bool `yaml:"show-separator"`
23 | ExtraInfoExchange bool `yaml:"show-tags"`
24 | ExtraInfoFundamentals bool `yaml:"show-fundamentals"`
25 | ShowSummary bool `yaml:"show-summary"`
26 | ShowHoldings bool `yaml:"show-holdings"`
27 | Sort string `yaml:"sort"`
28 | Currency string `yaml:"currency"`
29 | CurrencyConvertSummaryOnly bool `yaml:"currency-summary-only"`
30 | CurrencyDisableUnitCostConversion bool `yaml:"currency-disable-unit-cost-conversion"`
31 | ColorScheme ConfigColorScheme `yaml:"colors"`
32 | AssetGroup []ConfigAssetGroup `yaml:"groups"`
33 | Debug bool `yaml:"debug"`
34 | }
35 |
36 | // ConfigColorScheme represents user defined color scheme
37 | type ConfigColorScheme struct {
38 | Text string `yaml:"text"`
39 | TextLight string `yaml:"text-light"`
40 | TextLabel string `yaml:"text-label"`
41 | TextLine string `yaml:"text-line"`
42 | TextTag string `yaml:"text-tag"`
43 | BackgroundTag string `yaml:"background-tag"`
44 | }
45 |
46 | type ConfigAssetGroup struct {
47 | Name string `yaml:"name"`
48 | Watchlist []string `yaml:"watchlist"`
49 | Holdings []Lot `yaml:"holdings"`
50 | }
51 |
52 | type AssetGroup struct {
53 | ConfigAssetGroup
54 | SymbolsBySource []AssetGroupSymbolsBySource
55 | }
56 |
57 | type AssetGroupSymbolsBySource struct {
58 | Symbols []string
59 | Source QuoteSource
60 | }
61 |
62 | type AssetGroupQuote struct {
63 | AssetGroup AssetGroup
64 | AssetQuotes []AssetQuote
65 | }
66 |
67 | // Reference represents derived configuration for internal use from user defined configuration
68 | type Reference struct {
69 | Styles Styles
70 | }
71 |
72 | // Dependencies represents references to external dependencies
73 | type Dependencies struct {
74 | Fs afero.Fs
75 | SymbolsURL string
76 | MonitorPriceCoinbaseBaseURL string
77 | MonitorPriceCoinbaseStreamingURL string
78 | MonitorYahooBaseURL string
79 | MonitorYahooSessionRootURL string
80 | MonitorYahooSessionCrumbURL string
81 | MonitorYahooSessionConsentURL string
82 | }
83 |
84 | type Monitor interface {
85 | Start() error
86 | GetAssetQuotes(ignoreCache ...bool) ([]AssetQuote, error)
87 | SetSymbols(symbols []string, versionVector int) error
88 | SetCurrencyRates(currencyRates CurrencyRates) error
89 | Stop() error
90 | }
91 |
92 | type MonitorCurrencyRate interface {
93 | Start() error
94 | SetTargetCurrency(targetCurrency string)
95 | Stop() error
96 | }
97 |
98 | // Lot represents a cost basis lot
99 | type Lot struct {
100 | Symbol string `yaml:"symbol"`
101 | UnitCost float64 `yaml:"unit_cost"`
102 | Quantity float64 `yaml:"quantity"`
103 | FixedCost float64 `yaml:"fixed_cost"`
104 | // FixedProperties LotFixedProperties `yaml:"fixed_properties"`
105 | }
106 |
107 | // type LotFixedProperties struct {
108 | // Class string `yaml:"class"`
109 | // Description string `yaml:"description"`
110 | // Currency string `yaml:"currency"`
111 | // UnitValue float64 `yaml:"unit_value"`
112 | // }
113 |
114 | // CurrencyRates is a map of currency rates for lookup by currency that needs to be converted
115 | type CurrencyRates map[string]CurrencyRate
116 |
117 | // CurrencyRate represents a single currency conversion pair
118 | type CurrencyRate struct {
119 | FromCurrency string
120 | ToCurrency string
121 | Rate float64
122 | }
123 |
124 | // Styles represents style functions for components of the UI
125 | type Styles struct {
126 | Text StyleFn
127 | TextLight StyleFn
128 | TextLabel StyleFn
129 | TextBold StyleFn
130 | TextLine StyleFn
131 | TextPrice func(float64, string) string
132 | Tag StyleFn
133 | }
134 |
135 | // StyleFn is a function that styles text
136 | type StyleFn func(string) string
137 |
138 | type HoldingChange struct {
139 | Amount float64
140 | Percent float64
141 | }
142 |
143 | type Meta struct {
144 | IsVariablePrecision bool
145 | OrderIndex int
146 | SymbolInSourceAPI string
147 | }
148 |
149 | type Holding struct {
150 | Value float64
151 | Cost float64
152 | Quantity float64
153 | UnitValue float64
154 | UnitCost float64
155 | DayChange HoldingChange
156 | TotalChange HoldingChange
157 | Weight float64
158 | }
159 |
160 | // Currency is the original and converted currency if applicable
161 | type Currency struct {
162 | // Code is the original currency code of the asset
163 | FromCurrencyCode string
164 | // CodeConverted is the currency code that pricing and values have been converted into
165 | ToCurrencyCode string
166 | // Rate is the conversion rate from the original currency to the converted currency
167 | Rate float64
168 | }
169 |
170 | type QuotePrice struct {
171 | Price float64
172 | PricePrevClose float64
173 | PriceOpen float64
174 | PriceDayHigh float64
175 | PriceDayLow float64
176 | Change float64
177 | ChangePercent float64
178 | }
179 |
180 | type QuoteExtended struct {
181 | FiftyTwoWeekHigh float64
182 | FiftyTwoWeekLow float64
183 | MarketCap float64
184 | Volume float64
185 | }
186 |
187 | type QuoteFutures struct {
188 | SymbolUnderlying string
189 | IndexPrice float64
190 | Basis float64
191 | OpenInterest float64
192 | Expiry string
193 | }
194 |
195 | type Exchange struct {
196 | Name string
197 | Delay float64
198 | DelayText string
199 | State ExchangeState
200 | IsActive bool
201 | IsRegularTradingSession bool
202 | }
203 |
204 | type ExchangeState int
205 |
206 | const (
207 | ExchangeStateOpen ExchangeState = iota
208 | ExchangeStatePremarket
209 | ExchangeStatePostmarket
210 | ExchangeStateClosed
211 | )
212 |
213 | type Asset struct {
214 | Name string
215 | Symbol string
216 | Class AssetClass
217 | Currency Currency
218 | Holding Holding
219 | QuotePrice QuotePrice
220 | QuoteExtended QuoteExtended
221 | QuoteFutures QuoteFutures
222 | QuoteSource QuoteSource
223 | Exchange Exchange
224 | Meta Meta
225 | }
226 |
227 | type AssetClass int
228 |
229 | const (
230 | AssetClassCash AssetClass = iota
231 | AssetClassStock
232 | AssetClassCryptocurrency
233 | AssetClassPrivateSecurity
234 | AssetClassUnknown
235 | AssetClassFuturesContract
236 | )
237 |
238 | type QuoteSource int
239 |
240 | const (
241 | QuoteSourceYahoo QuoteSource = iota
242 | QuoteSourceUserDefined
243 | QuoteSourceCoingecko
244 | QuoteSourceUnknown
245 | QuoteSourceCoinCap
246 | QuoteSourceCoinbase
247 | )
248 |
249 | // AssetQuote represents a price quote and related attributes for a single security
250 | type AssetQuote struct {
251 | Name string
252 | Symbol string
253 | Class AssetClass
254 | Currency Currency
255 | QuotePrice QuotePrice
256 | QuoteExtended QuoteExtended
257 | QuoteFutures QuoteFutures
258 | QuoteSource QuoteSource
259 | Exchange Exchange
260 | Meta Meta
261 | }
262 |
263 | type MessageUpdate[T any] struct {
264 | Data T
265 | ID string
266 | Sequence int64
267 | VersionVector int
268 | }
269 |
270 | type MessageRequest[T any] struct {
271 | Data T
272 | ID string
273 | VersionVector int
274 | }
275 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/monitor-price/streamer/streamer_test.go:
--------------------------------------------------------------------------------
1 | package streamer_test
2 |
3 | import (
4 | "context"
5 | "net/http/httptest"
6 | "time"
7 |
8 | . "github.com/onsi/ginkgo/v2"
9 | . "github.com/onsi/gomega"
10 | g "github.com/onsi/gomega/gstruct"
11 |
12 | c "github.com/achannarasappa/ticker/v5/internal/common"
13 | streamer "github.com/achannarasappa/ticker/v5/internal/monitor/coinbase/monitor-price/streamer"
14 | testWs "github.com/achannarasappa/ticker/v5/test/websocket"
15 | )
16 |
17 | var _ = Describe("Streamer", func() {
18 | var (
19 | inputServer *httptest.Server
20 | s *streamer.Streamer
21 | )
22 |
23 | Describe("NewStreamer", func() {
24 | It("should return a new Streamer", func() {
25 | s := streamer.NewStreamer(context.Background(), streamer.StreamerConfig{})
26 | Expect(s).NotTo(BeNil())
27 | })
28 | })
29 |
30 | Describe("Start", func() {
31 | BeforeEach(func() {
32 | inputServer = testWs.NewTestServer([]string{})
33 | s = streamer.NewStreamer(context.Background(), streamer.StreamerConfig{
34 | ChanStreamUpdateQuotePrice: make(chan c.MessageUpdate[c.QuotePrice], 5),
35 | ChanStreamUpdateQuoteExtended: make(chan c.MessageUpdate[c.QuoteExtended], 5),
36 | })
37 | s.SetURL("ws://" + inputServer.URL[7:])
38 | })
39 |
40 | AfterEach(func() {
41 | inputServer.Close()
42 | })
43 |
44 | It("should start the streamer without an error", func() {
45 | err := s.Start()
46 | Expect(err).NotTo(HaveOccurred())
47 | })
48 |
49 | When("the streamer is already started", func() {
50 | It("should return the error 'streamer already started'", func() {
51 | err := s.Start()
52 | Expect(err).NotTo(HaveOccurred())
53 |
54 | err = s.Start()
55 | Expect(err).To(MatchError("streamer already started"))
56 | })
57 | })
58 |
59 | When("the url is not set", func() {
60 | It("should not start the streamer and not return an error", func() {
61 | s = streamer.NewStreamer(context.Background(), streamer.StreamerConfig{})
62 | err := s.Start()
63 | Expect(err).NotTo(HaveOccurred())
64 | })
65 | })
66 |
67 | When("the websocket connection is not successful", func() {
68 | It("should return an error containing the text 'connection aborted'", func() {
69 | inputServer = testWs.NewTestServer([]string{})
70 | s.SetURL("http://" + inputServer.URL[7:])
71 | err := s.Start()
72 |
73 | Expect(err).To(HaveOccurred())
74 | Expect(err).To(MatchError(ContainSubstring("malformed ws or wss URL")))
75 | })
76 | })
77 |
78 | When("the context is cancelled while trying to connect to the websocket", func() {
79 | It("should return an error containing the text 'connection aborted'", func() {
80 | ctx, cancel := context.WithCancel(context.Background())
81 | s = streamer.NewStreamer(ctx, streamer.StreamerConfig{})
82 | s.SetURL("ws://" + inputServer.URL[7:])
83 | started := make(chan struct{})
84 | var err error
85 |
86 | go func() {
87 | err = s.Start()
88 | close(started)
89 | }()
90 | // Relies on the assumption that it will take longer to open websocket connection than cancel the context
91 | cancel()
92 | Eventually(started).Should(BeClosed())
93 | Expect(err).To(MatchError(ContainSubstring("connection aborted")))
94 | })
95 | })
96 |
97 | Describe("readStreamQuote", func() {
98 | When("a tick message is received", func() {
99 | It("should send send a quote and extended quote to the channels", func() {
100 | inputTick := `{
101 | "type": "ticker",
102 | "sequence": 37475248783,
103 | "product_id": "ETH-USD",
104 | "price": "1285.22",
105 | "open_24h": "1310.79",
106 | "volume_24h": "245532.79269678",
107 | "low_24h": "1280.52",
108 | "high_24h": "1313.8",
109 | "volume_30d": "9788783.60117027",
110 | "best_bid": "1285.04",
111 | "best_bid_size": "0.46688654",
112 | "best_ask": "1285.27",
113 | "best_ask_size": "1.56637040",
114 | "side": "buy",
115 | "time": "2022-10-19T23:28:22.061769Z",
116 | "trade_id": 370843401,
117 | "last_size": "11.4396987"
118 | }`
119 | inputServer = testWs.NewTestServer([]string{inputTick})
120 | outputChanStreamUpdateQuotePrice := make(chan c.MessageUpdate[c.QuotePrice], 5)
121 | outputChanStreamUpdateQuoteExtended := make(chan c.MessageUpdate[c.QuoteExtended], 5)
122 |
123 | s = streamer.NewStreamer(context.Background(), streamer.StreamerConfig{
124 | ChanStreamUpdateQuotePrice: outputChanStreamUpdateQuotePrice,
125 | ChanStreamUpdateQuoteExtended: outputChanStreamUpdateQuoteExtended,
126 | })
127 | s.SetURL("ws://" + inputServer.URL[7:])
128 |
129 | err := s.Start()
130 | Expect(err).NotTo(HaveOccurred())
131 |
132 | Eventually(outputChanStreamUpdateQuotePrice).Should(Receive(
133 | g.MatchFields(g.IgnoreExtras, g.Fields{
134 | "ID": Equal("ETH-USD"),
135 | "Sequence": Equal(int64(37475248783)),
136 | "Data": g.MatchFields(g.IgnoreExtras, g.Fields{
137 | "Price": Equal(1285.22),
138 | }),
139 | }),
140 | ))
141 | Eventually(outputChanStreamUpdateQuoteExtended).Should(Receive(
142 | g.MatchFields(g.IgnoreExtras, g.Fields{
143 | "ID": Equal("ETH-USD"),
144 | "Sequence": Equal(int64(37475248783)),
145 | "Data": g.MatchFields(g.IgnoreExtras, g.Fields{
146 | "Volume": Equal(245532.79269678),
147 | }),
148 | }),
149 | ))
150 | })
151 | })
152 |
153 | When("a message is not a price quote or extended quote", func() {
154 | It("should not send anything to the channel", func() {
155 | invalidMessage := `{"type": "unknown"}`
156 | inputServer = testWs.NewTestServer([]string{invalidMessage})
157 | outputChanStreamUpdateQuotePrice := make(chan c.MessageUpdate[c.QuotePrice], 5)
158 | outputChanStreamUpdateQuoteExtended := make(chan c.MessageUpdate[c.QuoteExtended], 5)
159 |
160 | s = streamer.NewStreamer(context.Background(), streamer.StreamerConfig{
161 | ChanStreamUpdateQuotePrice: outputChanStreamUpdateQuotePrice,
162 | ChanStreamUpdateQuoteExtended: outputChanStreamUpdateQuoteExtended,
163 | })
164 | s.SetURL("ws://" + inputServer.URL[7:])
165 |
166 | err := s.Start()
167 | Expect(err).NotTo(HaveOccurred())
168 |
169 | Consistently(outputChanStreamUpdateQuotePrice, 100*time.Millisecond).ShouldNot(Receive())
170 | Consistently(outputChanStreamUpdateQuoteExtended, 100*time.Millisecond).ShouldNot(Receive())
171 | })
172 | })
173 | })
174 | })
175 |
176 | Describe("SetSymbolsAndUpdateSubscriptions", func() {
177 | BeforeEach(func() {
178 | inputServer = testWs.NewTestServer([]string{})
179 | s = streamer.NewStreamer(context.Background(), streamer.StreamerConfig{})
180 | s.SetURL("ws://" + inputServer.URL[7:])
181 | })
182 |
183 | AfterEach(func() {
184 | inputServer.Close()
185 | })
186 |
187 | It("should set the symbols and not return an error", func() {
188 | err := s.Start()
189 | Expect(err).NotTo(HaveOccurred())
190 |
191 | err = s.SetSymbolsAndUpdateSubscriptions([]string{"BTC-USD"}, 0)
192 | Expect(err).NotTo(HaveOccurred())
193 | })
194 |
195 | When("the streamer is not started", func() {
196 | It("should return early without error", func() {
197 | err := s.SetSymbolsAndUpdateSubscriptions([]string{"BTC-USD"}, 0)
198 | Expect(err).NotTo(HaveOccurred())
199 | })
200 | })
201 | })
202 |
203 | Describe("SetURL", func() {
204 | It("should set the url and not return an error", func() {
205 | s := streamer.NewStreamer(context.Background(), streamer.StreamerConfig{})
206 | err := s.SetURL("wss://example.com")
207 | Expect(err).NotTo(HaveOccurred())
208 | })
209 |
210 | When("the streamer is started", func() {
211 | It("should return the error 'cannot set URL while streamer is connected'", func() {
212 | inputServer = testWs.NewTestServer([]string{})
213 | s = streamer.NewStreamer(context.Background(), streamer.StreamerConfig{})
214 | s.SetURL("ws://" + inputServer.URL[7:])
215 |
216 | err := s.Start()
217 | Expect(err).NotTo(HaveOccurred())
218 |
219 | err = s.SetURL("wss://example.com")
220 | Expect(err).To(MatchError("cannot set URL while streamer is connected"))
221 |
222 | inputServer.Close()
223 | })
224 | })
225 | })
226 | })
227 |
--------------------------------------------------------------------------------
/internal/monitor/coinbase/monitor-price/poller/poller_test.go:
--------------------------------------------------------------------------------
1 | package poller_test
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "net/http"
7 | "time"
8 |
9 | . "github.com/onsi/ginkgo/v2"
10 | . "github.com/onsi/gomega"
11 | "github.com/onsi/gomega/ghttp"
12 | g "github.com/onsi/gomega/gstruct"
13 |
14 | c "github.com/achannarasappa/ticker/v5/internal/common"
15 | poller "github.com/achannarasappa/ticker/v5/internal/monitor/coinbase/monitor-price/poller"
16 | unary "github.com/achannarasappa/ticker/v5/internal/monitor/coinbase/unary"
17 | )
18 |
19 | var _ = Describe("Poller", func() {
20 | var (
21 | server *ghttp.Server
22 | )
23 |
24 | BeforeEach(func() {
25 | server = ghttp.NewServer()
26 |
27 | server.RouteToHandler("GET", "/api/v3/brokerage/market/products",
28 | ghttp.CombineHandlers(
29 | ghttp.VerifyRequest("GET", "/api/v3/brokerage/market/products", "product_ids=BTC-USD"),
30 | ghttp.RespondWithJSONEncoded(http.StatusOK, unary.Response{
31 | Products: []unary.ResponseQuote{
32 | {
33 | Symbol: "BTC",
34 | ProductID: "BTC-USD",
35 | ShortName: "Bitcoin",
36 | Price: "50000.00",
37 | PriceChange24H: "5.00",
38 | Volume24H: "1000000.00",
39 | MarketState: "online",
40 | Currency: "USD",
41 | ExchangeName: "CBE",
42 | },
43 | },
44 | }),
45 | ),
46 | )
47 | })
48 |
49 | AfterEach(func() {
50 | server.Close()
51 | })
52 |
53 | Describe("NewPoller", func() {
54 | It("should create a new poller instance", func() {
55 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
56 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
57 | ChanUpdateAssetQuote: make(chan c.MessageUpdate[c.AssetQuote], 5),
58 | })
59 | Expect(p).NotTo(BeNil())
60 | })
61 | })
62 |
63 | Describe("Start", func() {
64 | It("should start polling for price updates", func() {
65 |
66 | inputChanUpdateAssetQuote := make(chan c.MessageUpdate[c.AssetQuote], 5)
67 |
68 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
69 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
70 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
71 | })
72 | p.SetSymbols([]string{"BTC-USD"}, 0)
73 | p.SetRefreshInterval(time.Millisecond * 250)
74 |
75 | err := p.Start()
76 | Expect(err).NotTo(HaveOccurred())
77 |
78 | Eventually(inputChanUpdateAssetQuote).Should(Receive(
79 | g.MatchFields(g.IgnoreExtras, g.Fields{
80 | "ID": Equal("BTC-USD"),
81 | "Data": g.MatchFields(g.IgnoreExtras, g.Fields{
82 | "QuotePrice": g.MatchFields(g.IgnoreExtras, g.Fields{
83 | "Price": Equal(50000.00),
84 | }),
85 | }),
86 | }),
87 | ))
88 | })
89 |
90 | When("the poller is already started", func() {
91 | When("and the poller is started again", func() {
92 | It("should return an error", func() {
93 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
94 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
95 | ChanUpdateAssetQuote: make(chan c.MessageUpdate[c.AssetQuote], 5),
96 | })
97 | p.SetSymbols([]string{"BTC-USD"}, 0)
98 | p.SetRefreshInterval(time.Second * 1)
99 |
100 | err := p.Start()
101 | Expect(err).NotTo(HaveOccurred())
102 | err = p.Start()
103 | Expect(err).To(HaveOccurred())
104 | Expect(err.Error()).To(Equal("poller already started"))
105 | })
106 |
107 | })
108 |
109 | When("and the refresh interval is set again", func() {
110 | It("should return an error", func() {
111 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
112 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
113 | ChanUpdateAssetQuote: make(chan c.MessageUpdate[c.AssetQuote], 5),
114 | })
115 | p.SetSymbols([]string{"BTC-USD"}, 0)
116 | p.SetRefreshInterval(time.Second * 1)
117 |
118 | err := p.Start()
119 | Expect(err).NotTo(HaveOccurred())
120 | err = p.SetRefreshInterval(time.Second * 1)
121 | Expect(err).To(HaveOccurred())
122 | Expect(err.Error()).To(Equal("cannot set refresh interval while poller is started"))
123 | })
124 |
125 | })
126 |
127 | })
128 |
129 | When("the refresh interval is not set", func() {
130 | It("should return an error", func() {
131 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
132 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
133 | ChanUpdateAssetQuote: make(chan c.MessageUpdate[c.AssetQuote], 5),
134 | })
135 | p.SetSymbols([]string{"BTC-USD"}, 0)
136 |
137 | err := p.Start()
138 | Expect(err).To(HaveOccurred())
139 | Expect(err.Error()).To(Equal("refresh interval is not set"))
140 | })
141 | })
142 |
143 | When("the symbols are not set", func() {
144 | It("should not return any price updates", func() {
145 |
146 | inputChanUpdateAssetQuote := make(chan c.MessageUpdate[c.AssetQuote], 5)
147 |
148 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
149 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
150 | ChanUpdateAssetQuote: inputChanUpdateAssetQuote,
151 | })
152 | p.SetRefreshInterval(time.Millisecond * 100)
153 |
154 | err := p.Start()
155 | Expect(err).NotTo(HaveOccurred())
156 |
157 | Consistently(inputChanUpdateAssetQuote).ShouldNot(Receive())
158 | })
159 | })
160 |
161 | When("the unary API returns an error", func() {
162 | It("should return an error", func() {
163 |
164 | requestCount := 0
165 |
166 | server.RouteToHandler("GET", "/api/v3/brokerage/market/products",
167 | ghttp.CombineHandlers(
168 | ghttp.VerifyRequest("GET", "/api/v3/brokerage/market/products", "product_ids=BTC-USD"),
169 | func(w http.ResponseWriter, r *http.Request) {
170 | requestCount++
171 | if requestCount > 3 {
172 | w.WriteHeader(http.StatusInternalServerError)
173 | } else {
174 | w.WriteHeader(http.StatusOK)
175 | json.NewEncoder(w).Encode(unary.Response{
176 | Products: []unary.ResponseQuote{
177 | {
178 | Symbol: "BTC",
179 | ProductID: "BTC-USD",
180 | ShortName: "Bitcoin",
181 | Price: "50000.00",
182 | PriceChange24H: "5.00",
183 | Volume24H: "1000000.00",
184 | MarketState: "online",
185 | Currency: "USD",
186 | ExchangeName: "CBE",
187 | ProductType: "SPOT",
188 | },
189 | },
190 | })
191 | }
192 | },
193 | ),
194 | )
195 |
196 | outputChanError := make(chan error, 1)
197 |
198 | p := poller.NewPoller(context.Background(), poller.PollerConfig{
199 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
200 | ChanUpdateAssetQuote: make(chan c.MessageUpdate[c.AssetQuote], 5),
201 | ChanError: outputChanError,
202 | })
203 | p.SetSymbols([]string{"BTC-USD"}, 0)
204 | p.SetRefreshInterval(time.Millisecond * 20)
205 |
206 | err := p.Start()
207 | Expect(err).NotTo(HaveOccurred())
208 |
209 | Eventually(outputChanError).Should(Receive(
210 | MatchError("request failed with status 500"),
211 | ))
212 |
213 | })
214 | })
215 |
216 | When("the context is cancelled", func() {
217 | It("should stop the polling process", func() {
218 | ctx, cancel := context.WithCancel(context.Background())
219 | defer cancel()
220 |
221 | outputChanUpdateAssetQuote := make(chan c.MessageUpdate[c.AssetQuote], 5)
222 | outputChanError := make(chan error, 5)
223 |
224 | p := poller.NewPoller(ctx, poller.PollerConfig{
225 | UnaryAPI: unary.NewUnaryAPI(server.URL()),
226 | ChanUpdateAssetQuote: outputChanUpdateAssetQuote,
227 | ChanError: outputChanError,
228 | })
229 | p.SetSymbols([]string{"BTC-USD"}, 0)
230 | p.SetRefreshInterval(time.Millisecond * 20)
231 |
232 | err := p.Start()
233 | Expect(err).NotTo(HaveOccurred())
234 |
235 | cancel()
236 |
237 | Consistently(outputChanUpdateAssetQuote).ShouldNot(Receive())
238 | Consistently(outputChanError).ShouldNot(Receive())
239 | })
240 | })
241 |
242 | })
243 |
244 | Describe("Stop", func() {
245 | It("should stop the polling process", func() {
246 | // Test implementation will go here
247 | })
248 | })
249 | })
250 |
--------------------------------------------------------------------------------
/internal/ui/component/watchlist/row/row_test.go:
--------------------------------------------------------------------------------
1 | package row_test
2 |
3 | import (
4 | "strings"
5 |
6 | c "github.com/achannarasappa/ticker/v5/internal/common"
7 | "github.com/achannarasappa/ticker/v5/internal/ui/component/watchlist/row"
8 |
9 | . "github.com/onsi/ginkgo/v2"
10 | . "github.com/onsi/gomega"
11 |
12 | "regexp"
13 | )
14 |
15 | var styles = c.Styles{
16 | Text: func(v string) string { return v },
17 | TextLight: func(v string) string { return v },
18 | TextLabel: func(v string) string { return v },
19 | TextBold: func(v string) string { return v },
20 | TextLine: func(v string) string { return v },
21 | TextPrice: func(percent float64, text string) string { return text },
22 | Tag: func(v string) string { return v },
23 | }
24 |
25 | var _ = Describe("Row", func() {
26 |
27 | Describe("Update", func() {
28 |
29 | Describe("UpdateAssetMsg", func() {
30 |
31 | When("the price has not changed or the symbol has changed", func() {
32 |
33 | It("should update the asset", func() {
34 |
35 | inputRow := row.New(row.Config{
36 | Styles: styles,
37 | Asset: &c.Asset{
38 | Symbol: "AAPL",
39 | QuotePrice: c.QuotePrice{
40 | Price: 150.00,
41 | },
42 | },
43 | })
44 |
45 | outputRow, _ := inputRow.Update(row.UpdateAssetMsg(&c.Asset{
46 | Symbol: "AAPL",
47 | QuotePrice: c.QuotePrice{
48 | Price: 150.00,
49 | },
50 | }))
51 |
52 | Expect(outputRow.View()).To(Equal(inputRow.View()))
53 | })
54 |
55 | When("the price has changed and symbol is the same", func() {
56 |
57 | When("the price has increased", func() {
58 |
59 | It("should animate the price increase", func() {
60 | stripANSI := func(str string) string {
61 | re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
62 | return re.ReplaceAllString(str, "")
63 | }
64 |
65 | inputRow := row.New(row.Config{
66 | ID: 1,
67 | Styles: styles,
68 | Asset: &c.Asset{
69 | Symbol: "AAPL",
70 | QuotePrice: c.QuotePrice{
71 | Price: 150.00,
72 | },
73 | },
74 | })
75 |
76 | // First update to trigger animation
77 | outputRow, cmd := inputRow.Update(row.UpdateAssetMsg(&c.Asset{
78 | Symbol: "AAPL",
79 | QuotePrice: c.QuotePrice{
80 | Price: 151.00,
81 | },
82 | }))
83 |
84 | view := stripANSI(outputRow.View())
85 | Expect(view).To(ContainSubstring("AAPL"), "output was: %q", view)
86 | Expect(view).To(ContainSubstring("151.00"), "output was: %q", view)
87 | Expect(cmd).ToNot(BeNil())
88 |
89 | // Simulate frame updates
90 | for i := 0; i < 4; i++ {
91 | outputRow, cmd = outputRow.Update(row.FrameMsg(1))
92 | Expect(cmd).ToNot(BeNil())
93 | }
94 |
95 | // Final frame should have no animation
96 | outputRow, cmd = outputRow.Update(row.FrameMsg(1))
97 |
98 | view = stripANSI(outputRow.View())
99 | Expect(cmd).To(BeNil(), "expected cmd to be nil after final frame, got: %v", cmd)
100 | Expect(view).To(ContainSubstring("AAPL"), "output was: %q", view)
101 | Expect(view).To(ContainSubstring("151.00"), "output was: %q", view)
102 | })
103 |
104 | })
105 |
106 | When("the price has decreased", func() {
107 |
108 | It("should animate the price decrease", func() {
109 | stripANSI := func(str string) string {
110 | re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
111 | return re.ReplaceAllString(str, "")
112 | }
113 |
114 | inputRow := row.New(row.Config{
115 | ID: 1,
116 | Styles: styles,
117 | Asset: &c.Asset{
118 | Symbol: "AAPL",
119 | QuotePrice: c.QuotePrice{
120 | Price: 151.00,
121 | },
122 | },
123 | })
124 |
125 | // First update to trigger animation
126 | outputRow, cmd := inputRow.Update(row.UpdateAssetMsg(&c.Asset{
127 | Symbol: "AAPL",
128 | QuotePrice: c.QuotePrice{
129 | Price: 150.00,
130 | },
131 | }))
132 |
133 | view := stripANSI(outputRow.View())
134 | Expect(view).To(ContainSubstring("AAPL"), "output was: %q", view)
135 | Expect(view).To(ContainSubstring("150.00"), "output was: %q", view)
136 | Expect(cmd).ToNot(BeNil())
137 |
138 | // Simulate frame updates
139 | for i := 0; i < 4; i++ {
140 | outputRow, cmd = outputRow.Update(row.FrameMsg(1))
141 | Expect(cmd).ToNot(BeNil())
142 | }
143 |
144 | // Final frame should have no animation
145 | outputRow, cmd = outputRow.Update(row.FrameMsg(1))
146 |
147 | view = stripANSI(outputRow.View())
148 | Expect(cmd).To(BeNil(), "expected cmd to be nil after final frame, got: %v", cmd)
149 | Expect(view).To(ContainSubstring("AAPL"), "output was: %q", view)
150 | Expect(view).To(ContainSubstring("150.00"), "output was: %q", view)
151 | })
152 |
153 | })
154 |
155 | When("the number of digits in the new and old price is different", func() {
156 |
157 | It("should animate the entire price", func() {
158 | stripANSI := func(str string) string {
159 | re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
160 | return re.ReplaceAllString(str, "")
161 | }
162 |
163 | inputRow := row.New(row.Config{
164 | ID: 1,
165 | Styles: styles,
166 | Asset: &c.Asset{
167 | Symbol: "AAPL",
168 | QuotePrice: c.QuotePrice{
169 | Price: 150.00,
170 | },
171 | },
172 | })
173 |
174 | // First update to trigger animation with different number of digits
175 | outputRow, cmd := inputRow.Update(row.UpdateAssetMsg(&c.Asset{
176 | Symbol: "AAPL",
177 | QuotePrice: c.QuotePrice{
178 | Price: 1500.00,
179 | },
180 | }))
181 |
182 | view := stripANSI(outputRow.View())
183 | Expect(view).To(ContainSubstring("AAPL"), "output was: %q", view)
184 | Expect(view).To(ContainSubstring("1500.00"), "output was: %q", view)
185 | Expect(cmd).ToNot(BeNil())
186 |
187 | // Simulate frame updates
188 | for i := 0; i < 4; i++ {
189 | outputRow, cmd = outputRow.Update(row.FrameMsg(1))
190 | Expect(cmd).ToNot(BeNil())
191 | }
192 |
193 | // Final frame should have no animation
194 | outputRow, cmd = outputRow.Update(row.FrameMsg(1))
195 |
196 | view = stripANSI(outputRow.View())
197 | Expect(cmd).To(BeNil(), "expected cmd to be nil after final frame, got: %v", cmd)
198 | Expect(view).To(ContainSubstring("AAPL"), "output was: %q", view)
199 | Expect(view).To(ContainSubstring("1500.00"), "output was: %q", view)
200 | })
201 |
202 | })
203 |
204 | })
205 |
206 | })
207 |
208 | })
209 |
210 | Describe("FrameMsg", func() {
211 |
212 | When("the message is for a different row", func() {
213 |
214 | It("should not animate the price", func() {
215 | stripANSI := func(str string) string {
216 | re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
217 | return re.ReplaceAllString(str, "")
218 | }
219 |
220 | inputRow := row.New(row.Config{
221 | ID: 1,
222 | Styles: styles,
223 | Asset: &c.Asset{
224 | Symbol: "AAPL",
225 | QuotePrice: c.QuotePrice{
226 | Price: 150.00,
227 | },
228 | },
229 | })
230 |
231 | // First update to trigger animation
232 | outputRow, cmd := inputRow.Update(row.UpdateAssetMsg(&c.Asset{
233 | Symbol: "AAPL",
234 | QuotePrice: c.QuotePrice{
235 | Price: 151.00,
236 | },
237 | }))
238 |
239 | // Send frame message for a different row ID
240 | outputRow, cmd = outputRow.Update(row.FrameMsg(2))
241 |
242 | view := stripANSI(outputRow.View())
243 | Expect(cmd).To(BeNil(), "expected cmd to be nil for different row ID")
244 | Expect(view).To(ContainSubstring("AAPL"), "output was: %q", view)
245 | Expect(view).To(ContainSubstring("151.00"), "output was: %q", view)
246 | })
247 |
248 | })
249 |
250 | })
251 |
252 | Describe("SetCellWidthsMsg", func() {
253 |
254 | It("should update the width and cell widths", func() {
255 | asset := &c.Asset{
256 | Symbol: "AAPL",
257 | QuotePrice: c.QuotePrice{
258 | Price: 150.00,
259 | },
260 | }
261 |
262 | inputRow := row.New(row.Config{
263 | Styles: styles,
264 | Asset: asset,
265 | })
266 |
267 | expectedCellWidths := row.CellWidthsContainer{
268 | PositionLength: 10,
269 | QuoteLength: 8,
270 | WidthQuote: 12,
271 | WidthQuoteExtended: 15,
272 | WidthQuoteRange: 20,
273 | WidthPosition: 12,
274 | WidthPositionExtended: 15,
275 | WidthVolumeMarketCap: 15,
276 | }
277 | expectedWidth := 100
278 |
279 | outputRow, cmd := inputRow.Update(row.SetCellWidthsMsg{
280 | Width: expectedWidth,
281 | CellWidths: expectedCellWidths,
282 | })
283 |
284 | Expect(cmd).To(BeNil())
285 |
286 | // Verify the width is applied by checking the rendered output
287 | view := outputRow.View()
288 | lines := strings.Split(view, "\n")
289 | Expect(lines[0]).To(HaveLen(expectedWidth))
290 | })
291 |
292 | })
293 |
294 | })
295 |
296 | })
297 |
--------------------------------------------------------------------------------
/internal/ui/util/util_test.go:
--------------------------------------------------------------------------------
1 | package util_test
2 |
3 | import (
4 | . "github.com/onsi/ginkgo/v2"
5 | . "github.com/onsi/gomega"
6 |
7 | c "github.com/achannarasappa/ticker/v5/internal/common"
8 | . "github.com/achannarasappa/ticker/v5/internal/ui/util"
9 | )
10 |
11 | var _ = Describe("Util", func() {
12 |
13 | stylesFixture := c.Styles{
14 | Text: func(v string) string { return v },
15 | TextLight: func(v string) string { return v },
16 | TextLabel: func(v string) string { return v },
17 | TextBold: func(v string) string { return v },
18 | TextLine: func(v string) string { return v },
19 | TextPrice: func(percent float64, text string) string { return text },
20 | Tag: func(v string) string { return v },
21 | }
22 |
23 | Describe("ConvertFloatToString", func() {
24 | It("should convert a float to a precision of two", func() {
25 | output := ConvertFloatToString(0.563412, false)
26 | Expect(output).To(Equal("0.56"))
27 | })
28 |
29 | When("there using variable precision", func() {
30 | It("should convert a float that smaller than 10 to a string with a precision of four", func() {
31 | output := ConvertFloatToString(0.563412, true)
32 | Expect(output).To(Equal("0.5634"))
33 | })
34 | It("should convert a float that between 10 and 100 to a string with a precision of three", func() {
35 | output := ConvertFloatToString(12.5634, true)
36 | Expect(output).To(Equal("12.563"))
37 | })
38 | It("should convert a float that greater than 100 to a string with a precision of two", func() {
39 | output := ConvertFloatToString(204.4325, true)
40 | Expect(output).To(Equal("204.43"))
41 | })
42 | It("should set a precision of two when the value is zero", func() {
43 | output := ConvertFloatToString(0.0, true)
44 | Expect(output).To(Equal("0.00"))
45 | })
46 | It("should set a precision of one when the value is negative and over 1000", func() {
47 | output := ConvertFloatToString(-2000.0, true)
48 | Expect(output).To(Equal("-2000.0"))
49 | })
50 | It("should set a precision of zero when the value is over 10000", func() {
51 | output := ConvertFloatToString(10000.0, true)
52 | Expect(output).To(Equal("10000.00"))
53 | })
54 | It("should append a M when the value is over a million", func() {
55 | output := ConvertFloatToString(43523398, true)
56 | Expect(output).To(Equal("43.523 M"))
57 | })
58 | It("should append a M when the value is over a billion", func() {
59 | output := ConvertFloatToString(43523398000, true)
60 | Expect(output).To(Equal("43.523 B"))
61 | })
62 | It("should append a M when the value is over a trillion", func() {
63 | output := ConvertFloatToString(43523398000000, true)
64 | Expect(output).To(Equal("43.523 T"))
65 | })
66 | })
67 |
68 | })
69 | Describe("ValueText", func() {
70 | When("value is <= 0.0", func() {
71 | It("should return an empty string", func() {
72 | output := ValueText(0.0, stylesFixture)
73 | Expect(output).To(ContainSubstring(""))
74 | })
75 | })
76 | It("should generate text for values", func() {
77 | output := ValueText(435.32, stylesFixture)
78 | expectedOutput := "435.32"
79 | Expect(output).To(Equal(expectedOutput))
80 | })
81 | })
82 | Describe("NewStyle", func() {
83 | It("should generate text with a background and foreground color", func() {
84 | inputStyleFn := NewStyle("#ffffff", "#000000", false)
85 | output := inputStyleFn("test")
86 | expectedASCII := "\x1b[;mtest\x1b[0m"
87 | expectedANSI16Color := "\x1b[97;40mtest\x1b[0m"
88 | expectedANSI256Color := "\x1b[38;5;231;48;5;16mtest\x1b[0m"
89 | expectedTrueColor := "\x1b[38;2;255;255;255;48;2;0;0;0mtest\x1b[0m"
90 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
91 | })
92 | It("should generate text with bold styling", func() {
93 | inputStyleFn := NewStyle("#ffffff", "#000000", true)
94 | output := inputStyleFn("test")
95 | expectedASCII := "\x1b[;;1mtest\x1b[0m"
96 | expectedANSI16Color := "\x1b[97;40;1mtest\x1b[0m"
97 | expectedANSI256Color := "\x1b[38;5;231;48;5;16;1mtest\x1b[0m"
98 | expectedTrueColor := "\x1b[38;2;255;255;255;48;2;0;0;0;1mtest\x1b[0m"
99 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
100 | })
101 | })
102 |
103 | Describe("GetColorScheme", func() {
104 |
105 | It("should use the default color scheme", func() {
106 | input := c.ConfigColorScheme{}
107 | output := GetColorScheme(input).Text("test")
108 | expectedASCII := "test"
109 | expectedANSI16Color := "\x1b[38;5;188mtest\x1b[0m"
110 | expectedANSI256Color := "\x1b[38;2;208;208;208mtest\x1b[0m"
111 | expectedTrueColor := "\x1b[38;2;208;208;208mtest\x1b[0m"
112 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
113 | })
114 |
115 | When("a custom color is set", func() {
116 | It("should use the custom color", func() {
117 | input := c.ConfigColorScheme{Text: "#ffffff"}
118 | output := GetColorScheme(input).Text("test")
119 | expectedASCII := "test"
120 | expectedANSI16Color := "\x1b[38;5;231mtest\x1b[0m"
121 | expectedANSI256Color := "\x1b[38;2;255;255;255mtest\x1b[0m"
122 | expectedTrueColor := "\x1b[38;2;255;255;255mtest\x1b[0m"
123 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
124 | })
125 | })
126 |
127 | Context("stylePrice", func() {
128 |
129 | styles := GetColorScheme(c.ConfigColorScheme{})
130 |
131 | When("there is no percent change", func() {
132 | It("should color text grey", func() {
133 | output := styles.TextPrice(0.0, "$100.00")
134 | expectedASCII := "$100.00"
135 | expectedANSI16Color := "\x1b[90m$100.00\x1b[0m"
136 | expectedANSI256Color := "\x1b[38;5;241m$100.00\x1b[0m"
137 | expectedTrueColor := "\x1b[38;5;241m$100.00\x1b[0m"
138 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
139 | })
140 | })
141 |
142 | When("there is a percent change over 10%", func() {
143 | It("should color text dark green", func() {
144 | output := styles.TextPrice(11.0, "$100.00")
145 | expectedASCII := "$100.00"
146 | expectedANSI16Color := "\x1b[32m$100.00\x1b[0m"
147 | expectedANSI256Color := "\x1b[38;5;70m$100.00\x1b[0m"
148 | expectedTrueColor := "\x1b[38;2;119;153;40m$100.00\x1b[0m"
149 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
150 | })
151 | })
152 |
153 | When("there is a percent change between 5% and 10%", func() {
154 | It("should color text medium green", func() {
155 | output := styles.TextPrice(7.0, "$100.00")
156 | expectedASCII := "$100.00"
157 | expectedANSI16Color := "\x1b[92m$100.00\x1b[0m"
158 | expectedANSI256Color := "\x1b[38;5;76m$100.00\x1b[0m"
159 | expectedTrueColor := "\x1b[38;2;143;184;48m$100.00\x1b[0m"
160 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
161 | })
162 | })
163 |
164 | When("there is a percent change between 0% and 5%", func() {
165 | It("should color text light green", func() {
166 | output := styles.TextPrice(3.0, "$100.00")
167 | expectedASCII := "$100.00"
168 | expectedANSI16Color := "\x1b[92m$100.00\x1b[0m"
169 | expectedANSI256Color := "\x1b[38;5;82m$100.00\x1b[0m"
170 | expectedTrueColor := "\x1b[38;2;174;224;56m$100.00\x1b[0m"
171 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
172 | })
173 | })
174 |
175 | When("there is a percent change over -10%", func() {
176 | It("should color text dark red", func() {
177 | output := styles.TextPrice(-11.0, "$100.00")
178 | expectedASCII := "$100.00"
179 | expectedANSI16Color := "\x1b[31m$100.00\x1b[0m"
180 | expectedANSI256Color := "\x1b[38;5;124m$100.00\x1b[0m"
181 | expectedTrueColor := "\x1b[38;2;153;73;38m$100.00\x1b[0m"
182 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
183 | })
184 | })
185 |
186 | When("there is a percent change between -5% and -10%", func() {
187 | It("should color text medium red", func() {
188 | output := styles.TextPrice(-7.0, "$100.00")
189 | expectedASCII := "$100.00"
190 | expectedANSI16Color := "\x1b[91m$100.00\x1b[0m"
191 | expectedANSI256Color := "\x1b[38;5;160m$100.00\x1b[0m"
192 | expectedTrueColor := "\x1b[38;2;184;87;46m$100.00\x1b[0m"
193 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
194 | })
195 | })
196 |
197 | When("there is a percent change between 0% and -5%", func() {
198 | It("should color text light red", func() {
199 | output := styles.TextPrice(-3.0, "$100.00")
200 | expectedASCII := "$100.00"
201 | expectedANSI16Color := "\x1b[91m$100.00\x1b[0m"
202 | expectedANSI256Color := "\x1b[38;5;196m$100.00\x1b[0m"
203 | expectedTrueColor := "\x1b[38;2;224;107;56m$100.00\x1b[0m"
204 | Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor)))
205 | })
206 | })
207 |
208 | })
209 |
210 | })
211 | })
212 |
--------------------------------------------------------------------------------
/internal/ui/ui.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "sync"
6 | "time"
7 |
8 | grid "github.com/achannarasappa/term-grid"
9 | "github.com/achannarasappa/ticker/v5/internal/asset"
10 | c "github.com/achannarasappa/ticker/v5/internal/common"
11 | mon "github.com/achannarasappa/ticker/v5/internal/monitor"
12 | "github.com/achannarasappa/ticker/v5/internal/ui/component/summary"
13 | "github.com/achannarasappa/ticker/v5/internal/ui/component/watchlist"
14 | "github.com/achannarasappa/ticker/v5/internal/ui/component/watchlist/row"
15 |
16 | util "github.com/achannarasappa/ticker/v5/internal/ui/util"
17 |
18 | "github.com/charmbracelet/bubbles/viewport"
19 | tea "github.com/charmbracelet/bubbletea"
20 | )
21 |
22 | //nolint:gochecknoglobals
23 | var (
24 | styleLogo = util.NewStyle("#ffffd7", "#ff8700", true)
25 | styleGroup = util.NewStyle("#8a8a8a", "#303030", false)
26 | styleHelp = util.NewStyle("#4e4e4e", "", true)
27 | )
28 |
29 | const (
30 | footerHeight = 1
31 | )
32 |
33 | // Model for UI
34 | type Model struct {
35 | ctx c.Context
36 | ready bool
37 | headerHeight int
38 | versionVector int
39 | requestInterval int
40 | assets []c.Asset
41 | assetQuotes []c.AssetQuote
42 | assetQuotesLookup map[string]int
43 | holdingSummary asset.HoldingSummary
44 | viewport viewport.Model
45 | watchlist *watchlist.Model
46 | summary *summary.Model
47 | lastUpdateTime string
48 | groupSelectedIndex int
49 | groupMaxIndex int
50 | groupSelectedName string
51 | monitors *mon.Monitor
52 | mu sync.RWMutex
53 | }
54 |
55 | type tickMsg struct {
56 | versionVector int
57 | }
58 |
59 | type SetAssetQuoteMsg struct {
60 | symbol string
61 | assetQuote c.AssetQuote
62 | versionVector int
63 | }
64 |
65 | type SetAssetGroupQuoteMsg struct {
66 | assetGroupQuote c.AssetGroupQuote
67 | versionVector int
68 | }
69 |
70 | // NewModel is the constructor for UI model
71 | func NewModel(dep c.Dependencies, ctx c.Context, monitors *mon.Monitor) *Model {
72 |
73 | groupMaxIndex := len(ctx.Groups) - 1
74 |
75 | return &Model{
76 | ctx: ctx,
77 | headerHeight: getVerticalMargin(ctx.Config),
78 | ready: false,
79 | requestInterval: ctx.Config.RefreshInterval,
80 | versionVector: 0,
81 | assets: make([]c.Asset, 0),
82 | assetQuotes: make([]c.AssetQuote, 0),
83 | assetQuotesLookup: make(map[string]int),
84 | holdingSummary: asset.HoldingSummary{},
85 | watchlist: watchlist.NewModel(watchlist.Config{
86 | Sort: ctx.Config.Sort,
87 | Separate: ctx.Config.Separate,
88 | ShowHoldings: ctx.Config.ShowHoldings,
89 | ExtraInfoExchange: ctx.Config.ExtraInfoExchange,
90 | ExtraInfoFundamentals: ctx.Config.ExtraInfoFundamentals,
91 | Styles: ctx.Reference.Styles,
92 | }),
93 | summary: summary.NewModel(ctx),
94 | groupMaxIndex: groupMaxIndex,
95 | groupSelectedIndex: 0,
96 | groupSelectedName: " ",
97 | monitors: monitors,
98 | }
99 | }
100 |
101 | // Init is the initialization hook for bubbletea
102 | func (m *Model) Init() tea.Cmd {
103 | (*m.monitors).Start()
104 |
105 | // Start renderer and set symbols in parallel
106 | return tea.Batch(
107 | tick(0),
108 | func() tea.Msg {
109 | err := (*m.monitors).SetAssetGroup(m.ctx.Groups[m.groupSelectedIndex], m.versionVector)
110 |
111 | if m.ctx.Config.Debug && err != nil {
112 | m.ctx.Logger.Println(err)
113 | }
114 |
115 | return nil
116 | },
117 | )
118 | }
119 |
120 | // Update hook for bubbletea
121 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122 | var cmd tea.Cmd
123 |
124 | switch msg := msg.(type) {
125 |
126 | case tea.KeyMsg:
127 | switch msg.String() {
128 |
129 | case "tab", "shift+tab":
130 | m.mu.Lock()
131 |
132 | groupSelectedCursor := -1
133 | if msg.String() == "tab" {
134 | groupSelectedCursor = 1
135 | }
136 |
137 | m.groupSelectedIndex = (m.groupSelectedIndex + groupSelectedCursor + m.groupMaxIndex + 1) % (m.groupMaxIndex + 1)
138 |
139 | // Invalidate all previous ticks, incremental price updates, and full price updates
140 | m.versionVector++
141 |
142 | m.mu.Unlock()
143 |
144 | // Set the new set of symbols in the monitors and initiate a request to refresh all price quotes
145 | // Eventually, SetAssetGroupQuoteMsg message will be sent with the new quotes once all of the HTTP request complete
146 | m.monitors.SetAssetGroup(m.ctx.Groups[m.groupSelectedIndex], m.versionVector) //nolint:errcheck
147 |
148 | return m, tickImmediate(m.versionVector)
149 | case "ctrl+c":
150 | fallthrough
151 | case "esc":
152 | fallthrough
153 | case "q":
154 | return m, tea.Quit
155 | case "up":
156 | m.viewport, cmd = m.viewport.Update(msg)
157 |
158 | return m, cmd
159 | case "down":
160 | m.viewport, cmd = m.viewport.Update(msg)
161 |
162 | return m, cmd
163 | case "pgup":
164 | m.viewport.PageUp()
165 |
166 | return m, nil
167 | case "pgdown":
168 | m.viewport.PageDown()
169 |
170 | return m, nil
171 |
172 | }
173 |
174 | case tea.WindowSizeMsg:
175 |
176 | var cmd tea.Cmd
177 |
178 | m.mu.Lock()
179 | defer m.mu.Unlock()
180 |
181 | viewportHeight := msg.Height - m.headerHeight - footerHeight
182 |
183 | if !m.ready {
184 | m.viewport = viewport.New(msg.Width, viewportHeight)
185 | m.ready = true
186 | } else {
187 | m.viewport.Width = msg.Width
188 | m.viewport.Height = viewportHeight
189 | }
190 |
191 | // Forward window size message to watchlist and summary component
192 | m.watchlist, cmd = m.watchlist.Update(msg)
193 | m.summary, _ = m.summary.Update(msg)
194 |
195 | return m, cmd
196 |
197 | // Trigger component re-render if data has changed
198 | case tickMsg:
199 |
200 | var cmd tea.Cmd
201 | cmds := make([]tea.Cmd, 0)
202 |
203 | m.mu.Lock()
204 | defer m.mu.Unlock()
205 |
206 | // Do not re-render if versionVector has changed and do not start a new timer with this versionVector
207 | if msg.versionVector != m.versionVector {
208 | return m, nil
209 | }
210 |
211 | // Update watchlist and summary components
212 | m.watchlist, cmd = m.watchlist.Update(watchlist.SetAssetsMsg(m.assets))
213 | m.summary, _ = m.summary.Update(summary.SetSummaryMsg(m.holdingSummary))
214 |
215 | cmds = append(cmds, cmd)
216 |
217 | // Set the current tick time
218 | m.lastUpdateTime = getTime()
219 |
220 | // Update the viewport
221 | if m.ready {
222 | m.viewport, cmd = m.viewport.Update(msg)
223 | cmds = append(cmds, cmd)
224 | }
225 |
226 | cmds = append(cmds, tick(msg.versionVector))
227 |
228 | return m, tea.Batch(cmds...)
229 |
230 | case SetAssetGroupQuoteMsg:
231 |
232 | m.mu.Lock()
233 | defer m.mu.Unlock()
234 |
235 | // Do not update the assets and holding summary if the versionVector has changed
236 | if msg.versionVector != m.versionVector {
237 | return m, nil
238 | }
239 |
240 | assets, holdingSummary := asset.GetAssets(m.ctx, msg.assetGroupQuote)
241 |
242 | m.assets = assets
243 | m.holdingSummary = holdingSummary
244 |
245 | m.assetQuotes = msg.assetGroupQuote.AssetQuotes
246 | for i, assetQuote := range m.assetQuotes {
247 | m.assetQuotesLookup[assetQuote.Symbol] = i
248 | }
249 |
250 | m.groupSelectedName = m.ctx.Groups[m.groupSelectedIndex].Name
251 |
252 | return m, nil
253 |
254 | case SetAssetQuoteMsg:
255 |
256 | var i int
257 | var ok bool
258 |
259 | m.mu.Lock()
260 | defer m.mu.Unlock()
261 |
262 | if msg.versionVector != m.versionVector {
263 | return m, nil
264 | }
265 |
266 | // Check if this symbol is in the lookup
267 | if i, ok = m.assetQuotesLookup[msg.symbol]; !ok {
268 | return m, nil
269 | }
270 |
271 | // Check if the index is out of bounds
272 | if i >= len(m.assetQuotes) {
273 | return m, nil
274 | }
275 |
276 | // Check if the symbol is the same
277 | if m.assetQuotes[i].Symbol != msg.symbol {
278 | return m, nil
279 | }
280 |
281 | // Update the asset quote and generate a new holding summary
282 | m.assetQuotes[i] = msg.assetQuote
283 |
284 | assetGroupQuote := c.AssetGroupQuote{
285 | AssetQuotes: m.assetQuotes,
286 | AssetGroup: m.ctx.Groups[m.groupSelectedIndex],
287 | }
288 |
289 | assets, holdingSummary := asset.GetAssets(m.ctx, assetGroupQuote)
290 |
291 | m.assets = assets
292 | m.holdingSummary = holdingSummary
293 |
294 | return m, nil
295 |
296 | case row.FrameMsg:
297 | var cmd tea.Cmd
298 | m.watchlist, cmd = m.watchlist.Update(msg)
299 |
300 | return m, cmd
301 | }
302 |
303 | return m, nil
304 | }
305 |
306 | // View rendering hook for bubbletea
307 | func (m *Model) View() string {
308 | m.mu.RLock()
309 | defer m.mu.RUnlock()
310 |
311 | if !m.ready {
312 | return "\n Initializing..."
313 | }
314 |
315 | m.viewport.SetContent(m.watchlist.View())
316 |
317 | viewSummary := ""
318 |
319 | if m.ctx.Config.ShowSummary && m.ctx.Config.ShowHoldings {
320 | viewSummary += m.summary.View() + "\n"
321 | }
322 |
323 | return viewSummary +
324 | m.viewport.View() + "\n" +
325 | footer(m.viewport.Width, m.lastUpdateTime, m.groupSelectedName)
326 |
327 | }
328 |
329 | func footer(width int, time string, groupSelectedName string) string {
330 |
331 | if width < 80 {
332 | return styleLogo(" ticker ")
333 | }
334 |
335 | if len(groupSelectedName) > 12 {
336 | groupSelectedName = groupSelectedName[:12]
337 | }
338 |
339 | return grid.Render(grid.Grid{
340 | Rows: []grid.Row{
341 | {
342 | Width: width,
343 | Cells: []grid.Cell{
344 | {Text: styleLogo(" ticker "), Width: 8},
345 | {Text: styleGroup(" " + groupSelectedName + " "), Width: len(groupSelectedName) + 2, VisibleMinWidth: 95},
346 | {Text: styleHelp(" q: exit ↑: scroll up ↓: scroll down ⭾: change group"), Width: 52},
347 | {Text: styleHelp("↻ " + time), Align: grid.Right},
348 | },
349 | },
350 | },
351 | })
352 |
353 | }
354 |
355 | func getVerticalMargin(config c.Config) int {
356 | if config.ShowSummary && config.ShowHoldings {
357 | return 2
358 | }
359 |
360 | return 0
361 | }
362 |
363 | // Send a new tick message with the versionVector 200ms from now
364 | func tick(versionVector int) tea.Cmd {
365 | return tea.Tick(time.Second/5, func(time.Time) tea.Msg {
366 | return tickMsg{
367 | versionVector: versionVector,
368 | }
369 | })
370 | }
371 |
372 | // Send a new tick message immediately
373 | func tickImmediate(versionVector int) tea.Cmd {
374 |
375 | return func() tea.Msg {
376 | return tickMsg{
377 | versionVector: versionVector,
378 | }
379 | }
380 | }
381 |
382 | func getTime() string {
383 | t := time.Now()
384 |
385 | return fmt.Sprintf("%s %02d:%02d:%02d", t.Weekday().String(), t.Hour(), t.Minute(), t.Second())
386 | }
387 |
--------------------------------------------------------------------------------