├── .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 | --------------------------------------------------------------------------------