├── .gitignore ├── assets └── demo.gif ├── rustfmt.toml ├── src ├── service.rs ├── widget │ ├── block.rs │ ├── chart │ │ ├── mod.rs │ │ ├── volume_bar.rs │ │ ├── prices_line.rs │ │ └── prices_candlestick.rs │ ├── add_stock.rs │ ├── help.rs │ ├── stock_summary.rs │ ├── chart_configuration.rs │ └── options.rs ├── service │ ├── default_timestamps.rs │ ├── options.rs │ └── stock.rs ├── portfolio.rs ├── task │ ├── options_dates.rs │ ├── options_data.rs │ ├── company.rs │ ├── current_price.rs │ ├── default_timestamps.rs │ └── prices.rs ├── app.rs ├── widget.rs ├── theme.rs ├── task.rs ├── opts.rs ├── main.rs ├── common.rs ├── event.rs └── draw.rs ├── api ├── Cargo.toml └── src │ ├── lib.rs │ ├── model.rs │ └── client.rs ├── .github └── workflows │ ├── homebrew.yml │ ├── check-cross.yml │ ├── test.yml │ ├── check.yml │ ├── lints.yml │ └── release.yml ├── Cargo.toml ├── LICENSE ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | .env 4 | .vscode/ -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarkah/tickrs/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | imports_granularity = "Module" 3 | group_imports = "StdExternalCrate" -------------------------------------------------------------------------------- /src/service.rs: -------------------------------------------------------------------------------- 1 | pub mod default_timestamps; 2 | pub mod options; 3 | pub mod stock; 4 | 5 | /// Container of one or more tasks, that manages capturing all queued task responses 6 | /// into one update response 7 | pub trait Service { 8 | type Update; 9 | 10 | fn updates(&self) -> Vec; 11 | 12 | fn pause(&self); 13 | 14 | fn resume(&self); 15 | } 16 | -------------------------------------------------------------------------------- /src/widget/block.rs: -------------------------------------------------------------------------------- 1 | use ratatui::text::Span; 2 | use ratatui::widgets::{Block, Borders}; 3 | 4 | use crate::theme::style; 5 | use crate::THEME; 6 | 7 | pub fn new(title: &str) -> Block { 8 | Block::default() 9 | .borders(Borders::ALL) 10 | .border_style(style().fg(THEME.border_primary())) 11 | .title(Span::styled(title, style().fg(THEME.text_normal()))) 12 | } 13 | -------------------------------------------------------------------------------- /api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tickrs-api" 3 | version = "0.15.0" 4 | authors = ["tarkah "] 5 | edition = "2021" 6 | license = "MIT" 7 | description = "API for tickrs" 8 | repository = "https://github.com/tarkah/tickrs" 9 | 10 | [dependencies] 11 | anyhow = "1.0" 12 | futures = "0.3" 13 | http = "0.2" 14 | isahc = { version = "1.7", features = ["static-ssl"] } 15 | serde = { version = "1.0", features = ["derive"] } 16 | serde_json = "1.0" 17 | serde_urlencoded = "0.7" 18 | 19 | [dev-dependencies] 20 | async-std = { version = "1", features = ["attributes"] } 21 | -------------------------------------------------------------------------------- /.github/workflows/homebrew.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | inputs: 4 | tag: 5 | description: "Specify tag to bump" 6 | required: true 7 | 8 | name: Bump Homebrew 9 | 10 | jobs: 11 | bump-homebrew: 12 | name: "Bump Homebrew" 13 | runs-on: macos-latest 14 | steps: 15 | - name: Bump Homebrew Formula 16 | uses: mislav/bump-homebrew-formula-action@v1 17 | with: 18 | formula-name: tickrs 19 | homebrew-tap: tarkah/homebrew-tickrs 20 | tag-name: ${{ github.event.inputs.tag }} 21 | download-url: https://github.com/tarkah/tickrs/releases/download/${{ github.event.inputs.tag }}/tickrs-${{ github.event.inputs.tag }}-x86_64-apple-darwin.tar.gz 22 | env: 23 | COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} 24 | -------------------------------------------------------------------------------- /src/service/default_timestamps.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use super::*; 4 | use crate::common::TimeFrame; 5 | use crate::task::*; 6 | 7 | pub struct DefaultTimestampService { 8 | handle: AsyncTaskHandle>>, 9 | } 10 | 11 | impl DefaultTimestampService { 12 | pub fn new() -> DefaultTimestampService { 13 | let task = DefaultTimestamps::new(); 14 | let handle = task.connect(); 15 | 16 | DefaultTimestampService { handle } 17 | } 18 | } 19 | 20 | impl Service for DefaultTimestampService { 21 | type Update = HashMap>; 22 | 23 | fn updates(&self) -> Vec { 24 | self.handle.response().try_iter().collect() 25 | } 26 | 27 | fn pause(&self) { 28 | self.handle.pause(); 29 | } 30 | 31 | fn resume(&self) { 32 | self.handle.resume(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/portfolio.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::collections::HashMap; 3 | 4 | #[derive(Debug, Clone, Deserialize)] 5 | pub struct PortfolioItem { 6 | pub quantity: f64, 7 | pub average_price: f64, 8 | } 9 | 10 | #[derive(Debug, Clone, Deserialize, Default)] 11 | #[serde(transparent)] 12 | pub struct Portfolio { 13 | pub items: HashMap, 14 | } 15 | 16 | impl PortfolioItem { 17 | pub fn calculate_ticker_profit_loss(&self, current_price: f64) -> (f64, f64) { 18 | let invested = self.quantity * self.average_price; 19 | let current = self.quantity * current_price; 20 | let profit_loss = current - invested; 21 | let profit_loss_pct = if self.average_price > 0.0 { 22 | (current_price / self.average_price - 1.0) * 100.0 23 | } else { 24 | 0.0 25 | }; 26 | 27 | (profit_loss, profit_loss_pct) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/task/options_dates.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use async_std::sync::Arc; 4 | use futures::future::BoxFuture; 5 | 6 | use super::*; 7 | 8 | /// Returns options expiration dates for a company 9 | pub struct OptionsDates { 10 | symbol: String, 11 | } 12 | 13 | impl OptionsDates { 14 | pub fn new(symbol: String) -> OptionsDates { 15 | OptionsDates { symbol } 16 | } 17 | } 18 | 19 | impl AsyncTask for OptionsDates { 20 | type Input = String; 21 | type Response = Vec; 22 | 23 | fn update_interval(&self) -> Option { 24 | Some(Duration::from_secs(60 * 15)) 25 | } 26 | 27 | fn input(&self) -> Self::Input { 28 | self.symbol.clone() 29 | } 30 | 31 | fn task<'a>(input: Arc) -> BoxFuture<'a, Option> { 32 | Box::pin(async move { 33 | let symbol = input.as_ref(); 34 | 35 | crate::CLIENT 36 | .get_options_expiration_dates(symbol) 37 | .await 38 | .ok() 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tickrs" 3 | version = "0.15.0" 4 | authors = ["tarkah "] 5 | edition = "2021" 6 | license = "MIT" 7 | repository = "https://github.com/tarkah/tickrs" 8 | readme = "README.md" 9 | description = "Realtime ticker data in your terminal 📈" 10 | keywords = ["tui", "terminal", "stocks"] 11 | categories = ["command-line-utilities"] 12 | 13 | [profile.release] 14 | lto = true 15 | 16 | [workspace] 17 | members = [ 18 | ".", 19 | "api", 20 | ] 21 | 22 | [dependencies] 23 | anyhow = "1.0" 24 | async-std = "1.12" 25 | better-panic = "0.3" 26 | chrono = "0.4" 27 | crossbeam-channel = "0.5" 28 | crossterm = "0.25" # use the same version as tui 29 | dirs-next = "2.0.0" 30 | futures = "0.3" 31 | itertools = "0.10" 32 | lazy_static = "1.4" 33 | parking_lot = "0.12.1" 34 | rclite = "0.1.5" 35 | serde = { version = "1", features = ["derive"] } 36 | serde_yaml = "0.9" 37 | structopt = "0.3" 38 | tickrs-api = { path = "api/", version = "0.15.0" } 39 | ratatui = { version = "0.25.0", default-features = false, features = ["crossterm", "serde"] } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 tarkah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/task/options_data.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use async_std::sync::Arc; 4 | use futures::future::BoxFuture; 5 | 6 | use super::*; 7 | use crate::api::model; 8 | 9 | /// Returns options data for a company 10 | pub struct OptionsData { 11 | symbol: String, 12 | date: i64, 13 | } 14 | 15 | impl OptionsData { 16 | pub fn new(symbol: String, date: i64) -> OptionsData { 17 | OptionsData { symbol, date } 18 | } 19 | } 20 | 21 | impl AsyncTask for OptionsData { 22 | type Input = (String, i64); 23 | type Response = model::OptionsHeader; 24 | 25 | fn update_interval(&self) -> Option { 26 | Some(Duration::from_secs(1)) 27 | } 28 | 29 | fn input(&self) -> Self::Input { 30 | (self.symbol.clone(), self.date) 31 | } 32 | 33 | fn task<'a>(input: Arc) -> BoxFuture<'a, Option> { 34 | Box::pin(async move { 35 | let symbol = &input.0; 36 | let date = input.1; 37 | 38 | crate::CLIENT 39 | .get_options_for_expiration_date(symbol, date) 40 | .await 41 | .ok() 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/task/company.rs: -------------------------------------------------------------------------------- 1 | use async_std::sync::Arc; 2 | use futures::future::BoxFuture; 3 | 4 | use super::*; 5 | use crate::api::model::CompanyData; 6 | use crate::YAHOO_CRUMB; 7 | 8 | /// Returns a companies profile information. Only needs to be returned once. 9 | pub struct Company { 10 | symbol: String, 11 | } 12 | 13 | impl Company { 14 | pub fn new(symbol: String) -> Company { 15 | Company { symbol } 16 | } 17 | } 18 | 19 | impl AsyncTask for Company { 20 | type Input = String; 21 | type Response = CompanyData; 22 | 23 | fn update_interval(&self) -> Option { 24 | None 25 | } 26 | 27 | fn input(&self) -> Self::Input { 28 | self.symbol.clone() 29 | } 30 | 31 | fn task<'a>(input: Arc) -> BoxFuture<'a, Option> { 32 | Box::pin(async move { 33 | let symbol = input.as_ref(); 34 | 35 | let crumb = YAHOO_CRUMB.read().await.clone(); 36 | 37 | if let Some(crumb) = crumb { 38 | crate::CLIENT.get_company_data(symbol, crumb).await.ok() 39 | } else { 40 | None 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/check-cross.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "master" 5 | paths: 6 | - "**.rs" 7 | - "Cargo.toml" 8 | - "Cargo.lock" 9 | pull_request: 10 | branches: 11 | - "master" 12 | paths: 13 | - "**.rs" 14 | - "Cargo.toml" 15 | - "Cargo.lock" 16 | 17 | name: Check Cross 18 | 19 | jobs: 20 | test: 21 | name: "Check Cross" 22 | strategy: 23 | matrix: 24 | target: 25 | - armv7-unknown-linux-gnueabihf 26 | - aarch64-unknown-linux-gnu 27 | 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout sources 31 | uses: actions/checkout@v2 32 | 33 | - uses: actions/cache@v4 34 | with: 35 | path: | 36 | ~/.cargo/registry 37 | ~/.cargo/git 38 | ~/.cargo/bin/cross 39 | target 40 | key: ${{ matrix.target }}-cross-${{ hashFiles('**/Cargo.lock') }} 41 | 42 | - name: Install stable toolchain 43 | uses: actions-rs/toolchain@v1 44 | with: 45 | toolchain: stable 46 | target: ${{ matrix.target }} 47 | override: true 48 | 49 | - name: Run cargo check 50 | uses: actions-rs/cargo@v1 51 | with: 52 | use-cross: true 53 | command: check 54 | args: --all --target=${{ matrix.target }} 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "master" 5 | paths: 6 | - "**.rs" 7 | - "Cargo.toml" 8 | - "Cargo.lock" 9 | pull_request: 10 | branches: 11 | - "master" 12 | paths: 13 | - "**.rs" 14 | - "Cargo.toml" 15 | - "Cargo.lock" 16 | 17 | name: Test 18 | 19 | jobs: 20 | test: 21 | name: "Test" 22 | strategy: 23 | matrix: 24 | os: 25 | - "windows-latest" 26 | - "ubuntu-latest" 27 | - "macos-latest" 28 | 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout sources 32 | uses: actions/checkout@v2 33 | 34 | - name: Install GNU tar for macos # Fix for macos caching, https://github.com/actions/cache/issues/403 35 | if: matrix.os == 'macos-latest' 36 | run: | 37 | brew install gnu-tar 38 | echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH 39 | 40 | - uses: actions/cache@v4 41 | with: 42 | path: | 43 | ~/.cargo/registry 44 | ~/.cargo/git 45 | target 46 | key: ${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }} 47 | 48 | - name: Install stable toolchain 49 | uses: actions-rs/toolchain@v1 50 | with: 51 | profile: minimal 52 | toolchain: stable 53 | override: true 54 | 55 | - name: Run cargo test 56 | uses: actions-rs/cargo@v1 57 | with: 58 | command: test 59 | args: --workspace 60 | -------------------------------------------------------------------------------- /src/task/current_price.rs: -------------------------------------------------------------------------------- 1 | use async_std::sync::Arc; 2 | use futures::future::BoxFuture; 3 | 4 | use super::*; 5 | use crate::YAHOO_CRUMB; 6 | 7 | /// Returns the current price, only if it has changed 8 | pub struct CurrentPrice { 9 | symbol: String, 10 | } 11 | 12 | impl CurrentPrice { 13 | pub fn new(symbol: String) -> CurrentPrice { 14 | CurrentPrice { symbol } 15 | } 16 | } 17 | 18 | impl AsyncTask for CurrentPrice { 19 | type Input = String; 20 | type Response = (f64, Option, String); 21 | 22 | fn update_interval(&self) -> Option { 23 | Some(Duration::from_secs(1)) 24 | } 25 | 26 | fn input(&self) -> Self::Input { 27 | self.symbol.clone() 28 | } 29 | 30 | fn task<'a>(input: Arc) -> BoxFuture<'a, Option> { 31 | Box::pin(async move { 32 | let symbol = input.as_ref(); 33 | 34 | let crumb = YAHOO_CRUMB.read().await.clone(); 35 | 36 | if let Some(crumb) = crumb { 37 | if let Ok(response) = crate::CLIENT.get_company_data(symbol, crumb).await { 38 | let regular_price = response.price.regular_market_price.price; 39 | 40 | let post_price = response.price.post_market_price.price; 41 | 42 | let volume = response.price.regular_market_volume.fmt.unwrap_or_default(); 43 | 44 | return Some((regular_price, post_price, volume)); 45 | } 46 | } 47 | 48 | None 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "master" 5 | paths: 6 | - "**.rs" 7 | - "Cargo.toml" 8 | - "Cargo.lock" 9 | pull_request: 10 | branches: 11 | - "master" 12 | paths: 13 | - "**.rs" 14 | - "Cargo.toml" 15 | - "Cargo.lock" 16 | 17 | name: Check 18 | 19 | jobs: 20 | test: 21 | name: "Check" 22 | strategy: 23 | matrix: 24 | os: 25 | - "windows-latest" 26 | - "ubuntu-latest" 27 | - "macos-latest" 28 | 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout sources 32 | uses: actions/checkout@v2 33 | 34 | - name: Install GNU tar for macos # Fix for macos caching, https://github.com/actions/cache/issues/403 35 | if: matrix.os == 'macos-latest' 36 | run: | 37 | brew install gnu-tar 38 | echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH 39 | 40 | - uses: actions/cache@v4 41 | with: 42 | path: | 43 | ~/.cargo/registry 44 | ~/.cargo/git 45 | target 46 | key: ${{ runner.os }}-check-${{ hashFiles('**/Cargo.lock') }} 47 | 48 | - name: Install stable toolchain 49 | uses: actions-rs/toolchain@v1 50 | with: 51 | profile: minimal 52 | toolchain: stable 53 | override: true 54 | 55 | - name: Run cargo check 56 | uses: actions-rs/cargo@v1 57 | with: 58 | command: check 59 | args: --all --all-targets 60 | -------------------------------------------------------------------------------- /src/task/default_timestamps.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use async_std::sync::Arc; 4 | use futures::future::{join_all, BoxFuture}; 5 | 6 | use super::*; 7 | use crate::common::TimeFrame; 8 | 9 | /// Default timestamps to reference for stocks that haven't been around as long 10 | /// as the interval we are trying to graph 11 | pub struct DefaultTimestamps {} 12 | 13 | impl DefaultTimestamps { 14 | pub fn new() -> DefaultTimestamps { 15 | DefaultTimestamps {} 16 | } 17 | } 18 | 19 | impl AsyncTask for DefaultTimestamps { 20 | type Input = (); 21 | type Response = HashMap>; 22 | 23 | fn update_interval(&self) -> Option { 24 | Some(Duration::from_secs(60 * 15)) 25 | } 26 | 27 | fn input(&self) -> Self::Input {} 28 | 29 | fn task<'a>(_input: Arc) -> BoxFuture<'a, Option> { 30 | Box::pin(async move { 31 | let symbol = "SPY"; 32 | 33 | let tasks = TimeFrame::ALL[1..].iter().map(|timeframe| async move { 34 | let interval = timeframe.api_interval(); 35 | let range = timeframe.as_range(); 36 | 37 | if let Ok(chart) = crate::CLIENT 38 | .get_chart_data(symbol, interval, range, false) 39 | .await 40 | { 41 | Some((*timeframe, chart.timestamp)) 42 | } else { 43 | None 44 | } 45 | }); 46 | 47 | Some(join_all(tasks).await.into_iter().flatten().collect()) 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/task/prices.rs: -------------------------------------------------------------------------------- 1 | use async_std::sync::Arc; 2 | use futures::future::BoxFuture; 3 | 4 | use super::*; 5 | use crate::api::model::ChartMeta; 6 | use crate::common::{chart_data_to_prices, Price, TimeFrame}; 7 | 8 | /// Returns an array of prices, depending on the TimeFrame chosen 9 | pub struct Prices { 10 | symbol: String, 11 | time_frame: TimeFrame, 12 | } 13 | 14 | impl Prices { 15 | pub fn new(symbol: String, time_frame: TimeFrame) -> Prices { 16 | Prices { symbol, time_frame } 17 | } 18 | } 19 | 20 | impl AsyncTask for Prices { 21 | type Input = (String, TimeFrame); 22 | type Response = (TimeFrame, ChartMeta, Vec); 23 | 24 | fn update_interval(&self) -> Option { 25 | Some(self.time_frame.update_interval()) 26 | } 27 | 28 | fn input(&self) -> Self::Input { 29 | (self.symbol.clone(), self.time_frame) 30 | } 31 | 32 | fn task<'a>(input: Arc) -> BoxFuture<'a, Option> { 33 | Box::pin(async move { 34 | let symbol = &input.0; 35 | let time_frame = input.1; 36 | 37 | let interval = time_frame.api_interval(); 38 | 39 | let include_pre_post = time_frame == TimeFrame::Day1; 40 | 41 | if let Ok(response) = crate::CLIENT 42 | .get_chart_data(symbol, interval, time_frame.as_range(), include_pre_post) 43 | .await 44 | { 45 | Some(( 46 | time_frame, 47 | response.meta.clone(), 48 | chart_data_to_prices(response), 49 | )) 50 | } else { 51 | None 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/lints.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "master" 5 | paths: 6 | - "**.rs" 7 | - "Cargo.toml" 8 | - "Cargo.lock" 9 | pull_request: 10 | branches: 11 | - "master" 12 | paths: 13 | - "**.rs" 14 | - "Cargo.toml" 15 | - "Cargo.lock" 16 | 17 | name: Lints 18 | 19 | jobs: 20 | test: 21 | name: "Lints" 22 | strategy: 23 | matrix: 24 | os: 25 | - "windows-latest" 26 | - "ubuntu-latest" 27 | - "macos-latest" 28 | 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - name: Checkout sources 32 | uses: actions/checkout@v2 33 | 34 | - name: Install GNU tar for macos # Fix for macos caching, https://github.com/actions/cache/issues/403 35 | if: matrix.os == 'macos-latest' 36 | run: | 37 | brew install gnu-tar 38 | echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH 39 | 40 | - uses: actions/cache@v4 41 | with: 42 | path: | 43 | ~/.cargo/registry 44 | ~/.cargo/git 45 | target 46 | key: ${{ runner.os }}-lints-${{ hashFiles('**/Cargo.lock') }} 47 | 48 | # use nightly toolchain for rustfmt 49 | - name: Install nightly toolchain 50 | uses: actions-rs/toolchain@v1 51 | with: 52 | profile: minimal 53 | toolchain: nightly 54 | override: true 55 | components: rustfmt, clippy 56 | 57 | - name: Run cargo fmt 58 | uses: actions-rs/cargo@v1 59 | with: 60 | command: fmt 61 | args: --all -- --check 62 | 63 | - name: Run cargo clippy 64 | uses: actions-rs/cargo@v1 65 | with: 66 | command: clippy 67 | args: -- -D warnings 68 | -------------------------------------------------------------------------------- /src/widget/chart/mod.rs: -------------------------------------------------------------------------------- 1 | pub use self::prices_candlestick::PricesCandlestickChart; 2 | pub use self::prices_kagi::PricesKagiChart; 3 | pub use self::prices_line::PricesLineChart; 4 | pub use self::volume_bar::VolumeBarChart; 5 | 6 | mod prices_candlestick; 7 | pub mod prices_kagi; 8 | mod prices_line; 9 | mod volume_bar; 10 | 11 | const SCROLL_STEP: usize = 2; 12 | 13 | #[derive(Debug, Default, Clone, Copy, Hash)] 14 | pub struct ChartState { 15 | pub max_offset: Option, 16 | pub offset: Option, 17 | queued_scroll: Option, 18 | } 19 | 20 | impl ChartState { 21 | pub fn scroll_left(&mut self) { 22 | self.queued_scroll = Some(ChartScrollDirection::Left); 23 | } 24 | 25 | pub fn scroll_right(&mut self) { 26 | self.queued_scroll = Some(ChartScrollDirection::Right); 27 | } 28 | 29 | fn scroll(&mut self, direction: ChartScrollDirection, max_offset: usize) { 30 | if max_offset == 0 { 31 | return; 32 | } 33 | 34 | let new_offset = match direction { 35 | ChartScrollDirection::Left => self.offset.unwrap_or(0) + SCROLL_STEP, 36 | ChartScrollDirection::Right => self.offset.unwrap_or(0).saturating_sub(SCROLL_STEP), 37 | }; 38 | 39 | self.offset = if new_offset == 0 { 40 | None 41 | } else { 42 | Some(new_offset.min(max_offset)) 43 | }; 44 | } 45 | 46 | fn offset(&mut self, max_offset: usize) -> usize { 47 | if max_offset == 0 { 48 | self.max_offset.take(); 49 | self.offset.take(); 50 | } 51 | 52 | self.max_offset = Some(max_offset); 53 | 54 | max_offset - self.offset.map(|o| o.min(max_offset)).unwrap_or(0) 55 | } 56 | } 57 | 58 | #[derive(Debug, Clone, Copy, Hash)] 59 | enum ChartScrollDirection { 60 | Left, 61 | Right, 62 | } 63 | -------------------------------------------------------------------------------- /api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use self::client::Client; 2 | 3 | mod client; 4 | pub mod model; 5 | 6 | #[derive(Debug, Copy, Clone)] 7 | pub enum Interval { 8 | Minute1, 9 | Minute2, 10 | Minute5, 11 | Minute15, 12 | Minute30, 13 | Minute60, 14 | Minute90, 15 | Hour1, 16 | Day1, 17 | Day5, 18 | Week1, 19 | Month1, 20 | Month3, 21 | } 22 | 23 | impl std::fmt::Display for Interval { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | use Interval::*; 26 | 27 | let s = match self { 28 | Minute1 => "1m", 29 | Minute2 => "2m", 30 | Minute5 => "5m", 31 | Minute15 => "15m", 32 | Minute30 => "30m", 33 | Minute60 => "60m", 34 | Minute90 => "90m", 35 | Hour1 => "1h", 36 | Day1 => "1d", 37 | Day5 => "5d", 38 | Week1 => "1wk", 39 | Month1 => "1mo", 40 | Month3 => "3mo", 41 | }; 42 | 43 | write!(f, "{}", s) 44 | } 45 | } 46 | 47 | #[derive(Debug, Copy, Clone)] 48 | pub enum Range { 49 | Day1, 50 | Day5, 51 | Month1, 52 | Month3, 53 | Month6, 54 | Year1, 55 | Year2, 56 | Year5, 57 | Year10, 58 | Ytd, 59 | Max, 60 | } 61 | 62 | impl std::fmt::Display for Range { 63 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 64 | use Range::*; 65 | 66 | let s = match self { 67 | Day1 => "1d", 68 | Day5 => "5d", 69 | Month1 => "1mo", 70 | Month3 => "3mo", 71 | Month6 => "6mo", 72 | Year1 => "1y", 73 | Year2 => "2y", 74 | Year5 => "5y", 75 | Year10 => "10y", 76 | Ytd => "ytd", 77 | Max => "max", 78 | }; 79 | 80 | write!(f, "{}", s) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/service/options.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::api::model; 3 | use crate::task::*; 4 | 5 | pub struct OptionsService { 6 | symbol: String, 7 | expiration_dates_handle: AsyncTaskHandle>, 8 | options_data_handle: Option>, 9 | } 10 | 11 | impl OptionsService { 12 | pub fn new(symbol: String) -> OptionsService { 13 | let task = OptionsDates::new(symbol.clone()); 14 | let expiration_dates_handle = task.connect(); 15 | 16 | OptionsService { 17 | symbol, 18 | expiration_dates_handle, 19 | options_data_handle: None, 20 | } 21 | } 22 | 23 | pub fn set_expiration_date(&mut self, expiration_date: i64) { 24 | let task = OptionsData::new(self.symbol.clone(), expiration_date); 25 | let options_data_handle = task.connect(); 26 | 27 | self.options_data_handle = Some(options_data_handle); 28 | } 29 | } 30 | 31 | #[derive(Debug)] 32 | pub enum Update { 33 | ExpirationDates(Vec), 34 | OptionsData(model::OptionsHeader), 35 | } 36 | 37 | impl Service for OptionsService { 38 | type Update = Update; 39 | 40 | fn updates(&self) -> Vec { 41 | let mut updates = vec![]; 42 | 43 | let expiration_dates_updates = self 44 | .expiration_dates_handle 45 | .response() 46 | .try_iter() 47 | .map(Update::ExpirationDates); 48 | updates.extend(expiration_dates_updates); 49 | 50 | if let Some(ref options_data_handle) = self.options_data_handle { 51 | let options_data_updates = options_data_handle 52 | .response() 53 | .try_iter() 54 | .map(Update::OptionsData); 55 | updates.extend(options_data_updates); 56 | } 57 | 58 | updates 59 | } 60 | 61 | fn pause(&self) { 62 | self.expiration_dates_handle.pause(); 63 | if let Some(handle) = self.options_data_handle.as_ref() { 64 | handle.pause(); 65 | } 66 | } 67 | 68 | fn resume(&self) { 69 | self.expiration_dates_handle.resume(); 70 | if let Some(handle) = self.options_data_handle.as_ref() { 71 | handle.resume(); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::Event; 2 | 3 | use crate::common::{ChartType, TimeFrame}; 4 | use crate::service::default_timestamps::DefaultTimestampService; 5 | use crate::service::Service; 6 | use crate::{widget, DEFAULT_TIMESTAMPS}; 7 | 8 | #[derive(PartialEq, Eq, Clone, Copy, Debug)] 9 | pub enum Mode { 10 | AddStock, 11 | ConfigureChart, 12 | DisplayStock, 13 | DisplayOptions, 14 | DisplaySummary, 15 | Help, 16 | } 17 | 18 | pub struct App { 19 | pub mode: Mode, 20 | pub stocks: Vec, 21 | pub add_stock: widget::AddStockState, 22 | pub help: widget::HelpWidget, 23 | pub current_tab: usize, 24 | pub hide_help: bool, 25 | pub debug: DebugInfo, 26 | pub previous_mode: Mode, 27 | pub summary_time_frame: TimeFrame, 28 | pub default_timestamp_service: DefaultTimestampService, 29 | pub summary_scroll_state: SummaryScrollState, 30 | pub chart_type: ChartType, 31 | } 32 | 33 | impl App { 34 | pub fn update(&self) { 35 | let mut timestamp_updates = self.default_timestamp_service.updates(); 36 | 37 | if let Some(new_defaults) = timestamp_updates.pop() { 38 | *DEFAULT_TIMESTAMPS.write() = new_defaults; 39 | } 40 | } 41 | } 42 | 43 | pub struct EnvConfig { 44 | pub show_debug: bool, 45 | pub debug_mouse: bool, 46 | } 47 | 48 | impl EnvConfig { 49 | #[inline] 50 | fn env_match(key: &str, default: &str, expected: &str) -> bool { 51 | std::env::var(key).ok().unwrap_or_else(|| default.into()) == expected 52 | } 53 | 54 | pub fn load() -> Self { 55 | Self { 56 | show_debug: Self::env_match("SHOW_DEBUG", "0", "1"), 57 | debug_mouse: Self::env_match("DEBUG_MOUSE", "0", "1"), 58 | } 59 | } 60 | } 61 | 62 | #[derive(Debug)] 63 | pub struct DebugInfo { 64 | pub enabled: bool, 65 | pub dimensions: (u16, u16), 66 | pub cursor_location: Option<(u16, u16)>, 67 | pub last_event: Option, 68 | pub mode: Mode, 69 | } 70 | 71 | #[derive(Debug, Default, Clone, Copy)] 72 | pub struct SummaryScrollState { 73 | pub offset: usize, 74 | pub queued_scroll: Option, 75 | } 76 | 77 | #[derive(Debug, Clone, Copy)] 78 | pub enum ScrollDirection { 79 | Up, 80 | Down, 81 | } 82 | -------------------------------------------------------------------------------- /src/widget/chart/volume_bar.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use ratatui::buffer::Buffer; 3 | use ratatui::layout::Rect; 4 | use ratatui::symbols::bar; 5 | use ratatui::widgets::{BarChart, Block, Borders, StatefulWidget, Widget}; 6 | 7 | use crate::common::{Price, TimeFrame}; 8 | use crate::theme::style; 9 | use crate::widget::StockState; 10 | use crate::THEME; 11 | 12 | pub struct VolumeBarChart<'a> { 13 | pub data: &'a [Price], 14 | pub loaded: bool, 15 | pub show_x_labels: bool, 16 | } 17 | 18 | impl StatefulWidget for VolumeBarChart<'_> { 19 | type State = StockState; 20 | 21 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 22 | let mut volume_chunks = area; 23 | volume_chunks.height += 1; 24 | 25 | let x_offset = if !self.loaded { 26 | 8 27 | } else if self.show_x_labels { 28 | match state.time_frame { 29 | TimeFrame::Day1 => 9, 30 | TimeFrame::Week1 => 12, 31 | _ => 11, 32 | } 33 | } else { 34 | 9 35 | }; 36 | volume_chunks.x += x_offset; 37 | volume_chunks.width = volume_chunks.width.saturating_sub(x_offset + 1); 38 | 39 | let width = volume_chunks.width; 40 | let num_bars = width as usize; 41 | 42 | let volumes = state.volumes(self.data); 43 | let vol_count = volumes.len(); 44 | 45 | if vol_count > 0 { 46 | let volumes = self 47 | .data 48 | .iter() 49 | .flat_map(|p| [p.volume].repeat(num_bars)) 50 | .chunks(vol_count) 51 | .into_iter() 52 | .map(|c| ("", c.sum::() / vol_count as u64)) 53 | .collect::>(); 54 | 55 | volume_chunks.x = volume_chunks.x.saturating_sub(1); 56 | 57 | Block::default() 58 | .borders(Borders::LEFT) 59 | .border_style(style().fg(THEME.border_axis())) 60 | .render(volume_chunks, buf); 61 | 62 | volume_chunks.x += 1; 63 | 64 | BarChart::default() 65 | .bar_gap(0) 66 | .bar_set(bar::NINE_LEVELS) 67 | .style(style().fg(THEME.gray())) 68 | .data(&volumes) 69 | .render(volume_chunks, buf); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/widget/add_stock.rs: -------------------------------------------------------------------------------- 1 | use ratatui::buffer::Buffer; 2 | use ratatui::layout::{Alignment, Rect}; 3 | use ratatui::style::Modifier; 4 | use ratatui::text::{Line, Span}; 5 | use ratatui::widgets::{Paragraph, StatefulWidget, Widget, Wrap}; 6 | 7 | use super::block; 8 | use crate::common::ChartType; 9 | use crate::theme::style; 10 | use crate::THEME; 11 | 12 | pub struct AddStockState { 13 | search_string: String, 14 | has_user_input: bool, 15 | error_msg: Option, 16 | } 17 | 18 | impl AddStockState { 19 | pub fn new() -> AddStockState { 20 | AddStockState { 21 | search_string: String::new(), 22 | has_user_input: false, 23 | error_msg: Some(String::new()), 24 | } 25 | } 26 | 27 | pub fn add_char(&mut self, c: char) { 28 | self.search_string.push(c); 29 | self.has_user_input = true; 30 | } 31 | 32 | pub fn del_char(&mut self) { 33 | self.search_string.pop(); 34 | } 35 | 36 | pub fn reset(&mut self) { 37 | self.search_string.drain(..); 38 | self.has_user_input = false; 39 | self.error_msg = None; 40 | } 41 | 42 | pub fn enter(&mut self, chart_type: ChartType) -> super::StockState { 43 | super::StockState::new(self.search_string.clone().to_ascii_uppercase(), chart_type) 44 | } 45 | } 46 | 47 | pub struct AddStockWidget {} 48 | 49 | impl StatefulWidget for AddStockWidget { 50 | type State = AddStockState; 51 | 52 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 53 | let spans = if !state.has_user_input && state.error_msg.is_some() { 54 | Line::from(vec![ 55 | Span::styled("> ", style().fg(THEME.text_normal())), 56 | Span::styled( 57 | state.error_msg.as_ref().unwrap(), 58 | style().add_modifier(Modifier::BOLD).fg(THEME.loss()), 59 | ), 60 | ]) 61 | } else { 62 | Line::from(vec![ 63 | Span::styled("> ", style().fg(THEME.text_normal())), 64 | Span::styled( 65 | &state.search_string, 66 | style() 67 | .add_modifier(Modifier::BOLD) 68 | .fg(THEME.text_secondary()), 69 | ), 70 | ]) 71 | }; 72 | 73 | Paragraph::new(spans) 74 | .block(block::new(" Add Ticker ")) 75 | .style(style()) 76 | .alignment(Alignment::Left) 77 | .wrap(Wrap { trim: true }) 78 | .render(area, buf); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/service/stock.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::api::model::{ChartMeta, CompanyData}; 3 | use crate::common::*; 4 | use crate::task::*; 5 | 6 | pub struct StockService { 7 | symbol: String, 8 | current_price_handle: AsyncTaskHandle<(f64, Option, String)>, 9 | prices_handle: AsyncTaskHandle<(TimeFrame, ChartMeta, Vec)>, 10 | company_handle: AsyncTaskHandle, 11 | } 12 | 13 | impl StockService { 14 | pub fn new(symbol: String, time_frame: TimeFrame) -> StockService { 15 | let task = CurrentPrice::new(symbol.clone()); 16 | let current_price_handle = task.connect(); 17 | 18 | let task = Prices::new(symbol.clone(), time_frame); 19 | let prices_handle = task.connect(); 20 | 21 | let task = Company::new(symbol.clone()); 22 | let company_handle = task.connect(); 23 | 24 | StockService { 25 | symbol, 26 | current_price_handle, 27 | prices_handle, 28 | company_handle, 29 | } 30 | } 31 | 32 | pub fn update_time_frame(&mut self, time_frame: TimeFrame) { 33 | let task = Prices::new(self.symbol.clone(), time_frame); 34 | let prices_handle = task.connect(); 35 | 36 | self.prices_handle = prices_handle; 37 | } 38 | } 39 | 40 | #[derive(Debug)] 41 | pub enum Update { 42 | NewPrice((f64, Option, String)), 43 | Prices((TimeFrame, ChartMeta, Vec)), 44 | CompanyData(Box), 45 | } 46 | 47 | impl Service for StockService { 48 | type Update = Update; 49 | 50 | fn updates(&self) -> Vec { 51 | let mut updates = vec![]; 52 | 53 | let current_price_updates = self 54 | .current_price_handle 55 | .response() 56 | .try_iter() 57 | .map(Update::NewPrice); 58 | updates.extend(current_price_updates); 59 | 60 | let prices_updates = self.prices_handle.response().try_iter().map(Update::Prices); 61 | updates.extend(prices_updates); 62 | 63 | let company_updates = self 64 | .company_handle 65 | .response() 66 | .try_iter() 67 | .map(Box::new) 68 | .map(Update::CompanyData); 69 | updates.extend(company_updates); 70 | 71 | updates 72 | } 73 | 74 | fn pause(&self) { 75 | self.current_price_handle.pause(); 76 | self.prices_handle.pause(); 77 | self.company_handle.pause(); 78 | } 79 | 80 | fn resume(&self) { 81 | self.current_price_handle.resume(); 82 | self.prices_handle.resume(); 83 | self.company_handle.resume(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tick-rs 2 | [![Actions Status](https://github.com/tarkah/tickrs/workflows/Test/badge.svg)](https://github.com/tarkah/tickrs/actions) 3 | 4 | Realtime ticker data in your terminal 📈 Built with Rust. Data sourced from Yahoo! Finance. 5 | 6 | - [Installation](#installation) 7 | - [Config File](#config-file) 8 | - [CLI Usage](#cli-usage) 9 | - [Windows](#windows) 10 | - [Acknowledgments](#acknowledgments) 11 | 12 | 13 | 14 | ## Installation 15 | 16 | ### Binary 17 | 18 | Download the latest [release](https://github.com/tarkah/tickrs/releases/latest) for your platform 19 | 20 | ### Cargo 21 | 22 | ``` 23 | cargo install tickrs 24 | ``` 25 | 26 | ### Arch Linux 27 | 28 | ``` 29 | pacman -S tickrs 30 | ``` 31 | 32 | ### Homebrew 33 | 34 | ``` 35 | brew tap tarkah/tickrs 36 | brew install tickrs 37 | ``` 38 | 39 | ## Config File 40 | 41 | See [wiki entry](https://github.com/tarkah/tickrs/wiki/Config-file) 42 | 43 | ## CLI Usage 44 | 45 | ``` 46 | tickrs 47 | Realtime ticker data in your terminal 📈 48 | 49 | USAGE: 50 | tickrs [FLAGS] [OPTIONS] 51 | 52 | FLAGS: 53 | -p, --enable-pre-post Enable pre / post market hours for graphs 54 | -h, --help Prints help information 55 | --hide-help Hide help icon in top right 56 | --hide-prev-close Hide previous close line on 1D chart 57 | --hide-toggle Hide toggle block 58 | --show-volumes Show volumes graph 59 | -x, --show-x-labels Show x-axis labels 60 | --summary Start in summary mode 61 | --trunc-pre Truncate pre market graphing to only 30 minutes prior to markets opening 62 | -V, --version Prints version information 63 | 64 | OPTIONS: 65 | -c, --chart-type Chart type to start app with [default: line] [possible values: line, 66 | candle, kagi] 67 | -s, --symbols ... Comma separated list of ticker symbols to start app with 68 | -t, --time-frame Use specified time frame when starting program and when new stocks are 69 | added [default: 1D] [possible values: 1D, 1W, 1M, 3M, 6M, 1Y, 5Y] 70 | -i, --update-interval Interval to update data from API (seconds) [default: 1] 71 | ``` 72 | 73 | ### Windows 74 | 75 | Use [Windows Terminal](https://www.microsoft.com/en-us/p/windows-terminal-preview/9n0dx20hk701) to properly display this app. 76 | 77 | ## Acknowledgments 78 | - [fdehau](https://github.com/fdehau) / [tui-rs](https://github.com/fdehau/tui-rs) - great TUI library for Rust 79 | - [cjbassi](https://github.com/cjbassi) / [ytop](https://github.com/cjbassi/ytop) - thanks for the inspiration! 80 | -------------------------------------------------------------------------------- /src/widget.rs: -------------------------------------------------------------------------------- 1 | use std::collections::hash_map::DefaultHasher; 2 | use std::hash::{Hash, Hasher}; 3 | 4 | use ratatui::buffer::{Buffer, Cell}; 5 | use ratatui::layout::Rect; 6 | use ratatui::widgets::StatefulWidget; 7 | 8 | pub use self::add_stock::{AddStockState, AddStockWidget}; 9 | pub use self::chart_configuration::{ChartConfigurationWidget, KagiOptions}; 10 | pub use self::help::{HelpWidget, HELP_HEIGHT, HELP_WIDTH}; 11 | pub use self::options::{OptionsState, OptionsWidget}; 12 | pub use self::stock::{StockState, StockWidget}; 13 | pub use self::stock_summary::StockSummaryWidget; 14 | 15 | mod add_stock; 16 | pub mod block; 17 | mod chart; 18 | pub mod chart_configuration; 19 | mod help; 20 | pub mod options; 21 | mod stock; 22 | mod stock_summary; 23 | 24 | pub trait CachableWidget: StatefulWidget + Sized { 25 | fn cache_state_mut(state: &mut ::State) -> &mut CacheState; 26 | 27 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut ::State); 28 | 29 | fn render_cached( 30 | self, 31 | area: Rect, 32 | buf: &mut Buffer, 33 | state: &mut ::State, 34 | ) { 35 | // Hash our state 36 | let mut hasher = DefaultHasher::default(); 37 | state.hash(&mut hasher); 38 | let hash = hasher.finish(); 39 | 40 | // Get previously cached values 41 | let CacheState { 42 | prev_area, 43 | prev_content, 44 | prev_hash, 45 | } = >::cache_state_mut(state).clone(); 46 | 47 | // If current hash and layout matches previous, use cached buffer instead of re-rendering 48 | if hash == prev_hash && prev_area == area { 49 | for (idx, cell) in buf.content.iter_mut().enumerate() { 50 | let x = idx as u16 % buf.area.width; 51 | let y = idx as u16 / buf.area.width; 52 | 53 | if x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height 54 | { 55 | if let Some(cached_cell) = prev_content.get(idx) { 56 | *cell = cached_cell.clone(); 57 | } 58 | } 59 | } 60 | } 61 | // Otherwise re-render and store those values in the cache 62 | else { 63 | >::render(self, area, buf, state); 64 | 65 | let cached_state = >::cache_state_mut(state); 66 | cached_state.prev_hash = hash; 67 | cached_state.prev_area = area; 68 | cached_state.prev_content = buf.content.clone(); 69 | } 70 | } 71 | } 72 | 73 | #[derive(Debug, Clone, Default)] 74 | pub struct CacheState { 75 | prev_area: Rect, 76 | prev_hash: u64, 77 | prev_content: Vec, 78 | } 79 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::{Color, Style}; 2 | use serde::Deserialize; 3 | 4 | use self::de::deserialize_option_color_hex_string; 5 | use crate::THEME; 6 | 7 | #[inline] 8 | pub fn style() -> Style { 9 | Style::default().bg(THEME.background()) 10 | } 11 | 12 | macro_rules! def_theme_struct_with_defaults { 13 | ($($name:ident => $color:expr),+) => { 14 | #[derive(Debug, Clone, Copy, Deserialize)] 15 | pub struct Theme { 16 | $( 17 | #[serde(deserialize_with = "deserialize_option_color_hex_string")] 18 | #[serde(default)] 19 | $name: Option, 20 | )+ 21 | } 22 | impl Theme { 23 | $( 24 | #[inline] 25 | pub fn $name(self) -> Color { 26 | self.$name.unwrap_or($color) 27 | } 28 | )+ 29 | } 30 | impl Default for Theme { 31 | fn default() -> Theme { 32 | Self { 33 | $( $name: Some($color), )+ 34 | } 35 | } 36 | } 37 | }; 38 | } 39 | 40 | def_theme_struct_with_defaults!( 41 | background => Color::Reset, 42 | gray => Color::DarkGray, 43 | profit => Color::Green, 44 | loss => Color::Red, 45 | text_normal => Color::Reset, 46 | text_primary => Color::Yellow, 47 | text_secondary => Color::Cyan, 48 | border_primary => Color::Blue, 49 | border_secondary => Color::Reset, 50 | border_axis => Color::Blue, 51 | highlight_focused => Color::LightBlue, 52 | highlight_unfocused => Color::DarkGray 53 | ); 54 | 55 | fn hex_to_color(hex: &str) -> Option { 56 | if hex.len() == 7 { 57 | let hash = &hex[0..1]; 58 | let r = u8::from_str_radix(&hex[1..3], 16); 59 | let g = u8::from_str_radix(&hex[3..5], 16); 60 | let b = u8::from_str_radix(&hex[5..7], 16); 61 | 62 | return match (hash, r, g, b) { 63 | ("#", Ok(r), Ok(g), Ok(b)) => Some(Color::Rgb(r, g, b)), 64 | _ => None, 65 | }; 66 | } 67 | 68 | None 69 | } 70 | 71 | mod de { 72 | use std::fmt; 73 | 74 | use serde::de::{self, Error, Unexpected, Visitor}; 75 | 76 | use super::{hex_to_color, Color}; 77 | 78 | pub(crate) fn deserialize_option_color_hex_string<'de, D>( 79 | deserializer: D, 80 | ) -> Result, D::Error> 81 | where 82 | D: de::Deserializer<'de>, 83 | { 84 | struct ColorVisitor; 85 | 86 | impl Visitor<'_> for ColorVisitor { 87 | type Value = Option; 88 | 89 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 90 | formatter.write_str("a hex string in the format of '#09ACDF'") 91 | } 92 | 93 | fn visit_str(self, s: &str) -> Result 94 | where 95 | E: Error, 96 | { 97 | if let Some(color) = hex_to_color(s) { 98 | return Ok(Some(color)); 99 | } 100 | 101 | Err(de::Error::invalid_value(Unexpected::Str(s), &self)) 102 | } 103 | } 104 | 105 | deserializer.deserialize_any(ColorVisitor) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/widget/help.rs: -------------------------------------------------------------------------------- 1 | use ratatui::buffer::Buffer; 2 | use ratatui::layout::{Constraint, Direction, Layout, Rect}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{Paragraph, Widget}; 5 | 6 | use super::block; 7 | use crate::draw::{add_padding, PaddingDirection}; 8 | use crate::theme::style; 9 | use crate::THEME; 10 | 11 | const LEFT_TEXT: &str = r#" 12 | Quit: q or 13 | Add Stock: 14 | - /: open prompt 15 | - (while adding): 16 | - : accept 17 | - : quit 18 | Change Tab: 19 | - : next stock 20 | - : previous stock 21 | Reorder Current Tab: 22 | - : move 1 tab left 23 | - : move 1 tab right 24 | Change Time Frame: 25 | - : next time frame 26 | - : previous time frame 27 | Toggle Summary Pane: 28 | - s: toggle pane 29 | - : scroll pane 30 | "#; 31 | 32 | const RIGHT_TEXT: &str = r#" 33 | Remove Stock: k 34 | Graphing Display: 35 | - c: toggle candlestick chart 36 | - p: toggle pre / post market 37 | - v: toggle volumes graph 38 | - x: toggle labels 39 | Toggle Options Pane: 40 | - o: toggle pane 41 | - : close pane 42 | - : toggle calls / puts 43 | - Navigate with arrow keys 44 | - Cryptocurrency not supported 45 | Toggle Chart Configurations Pane: 46 | - e: toggle pane 47 | - : move up/down 48 | - : move up/down 49 | - : select options 50 | - : submit changes 51 | "#; 52 | 53 | const LEFT_WIDTH: usize = 34; 54 | const RIGHT_WIDTH: usize = 35; 55 | pub const HELP_WIDTH: usize = 2 + LEFT_WIDTH + 2 + RIGHT_WIDTH + 2; 56 | pub const HELP_HEIGHT: usize = 2 + 18 + 1; 57 | 58 | #[derive(Copy, Clone)] 59 | pub struct HelpWidget {} 60 | 61 | impl HelpWidget { 62 | pub fn get_rect(self, area: Rect) -> Rect { 63 | Rect { 64 | x: (area.width - HELP_WIDTH as u16) / 2, 65 | y: (area.height - HELP_HEIGHT as u16) / 2, 66 | width: HELP_WIDTH as u16, 67 | height: HELP_HEIGHT as u16, 68 | } 69 | } 70 | } 71 | 72 | impl Widget for HelpWidget { 73 | fn render(self, mut area: Rect, buf: &mut Buffer) { 74 | block::new(" Help - to go back ").render(area, buf); 75 | area = add_padding(area, 1, PaddingDirection::All); 76 | area = add_padding(area, 1, PaddingDirection::Left); 77 | 78 | let layout = Layout::default() 79 | .direction(Direction::Horizontal) 80 | .constraints([ 81 | Constraint::Length(LEFT_WIDTH as u16), 82 | Constraint::Length(2), 83 | Constraint::Length(RIGHT_WIDTH as u16), 84 | ]) 85 | .split(area); 86 | 87 | let left_text: Vec<_> = LEFT_TEXT 88 | .lines() 89 | .map(|line| { 90 | Line::from(Span::styled( 91 | format!("{}\n", line), 92 | style().fg(THEME.text_normal()), 93 | )) 94 | }) 95 | .collect(); 96 | 97 | let right_text: Vec<_> = RIGHT_TEXT 98 | .lines() 99 | .map(|line| { 100 | Line::from(Span::styled( 101 | format!("{}\n", line), 102 | style().fg(THEME.text_normal()), 103 | )) 104 | }) 105 | .collect(); 106 | 107 | Paragraph::new(left_text).render(layout[0], buf); 108 | Paragraph::new(right_text).render(layout[2], buf); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/task.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use async_std::sync::Arc; 4 | use async_std::task; 5 | use crossbeam_channel::{bounded, unbounded, Receiver, Sender}; 6 | use futures::future::BoxFuture; 7 | use task::JoinHandle; 8 | 9 | pub use self::company::Company; 10 | pub use self::current_price::CurrentPrice; 11 | pub use self::default_timestamps::DefaultTimestamps; 12 | pub use self::options_data::OptionsData; 13 | pub use self::options_dates::OptionsDates; 14 | pub use self::prices::Prices; 15 | use crate::{DATA_RECEIVED, UPDATE_INTERVAL}; 16 | 17 | mod company; 18 | mod current_price; 19 | mod default_timestamps; 20 | mod options_data; 21 | mod options_dates; 22 | mod prices; 23 | 24 | /// Trait to define a type that spawns an Async Task to complete background 25 | /// work. 26 | pub trait AsyncTask: 'static { 27 | type Input: Send + Sync; 28 | type Response: Send; 29 | 30 | /// Interval that `task` should be executed at 31 | /// 32 | /// If `None` is returned, the task will only get executed once then exit 33 | fn update_interval(&self) -> Option; 34 | 35 | /// Input data needed for the `task` 36 | fn input(&self) -> Self::Input; 37 | 38 | /// Defines the async task that will get executed and return` Response` 39 | fn task<'a>(input: Arc) -> BoxFuture<'a, Option>; 40 | 41 | /// Runs the task on the async runtime and returns a handle to query updates from 42 | fn connect(&self) -> AsyncTaskHandle { 43 | let (command_sender, command_receiver) = bounded(1); 44 | let (response_sender, response_receiver) = unbounded::(); 45 | let data_received = DATA_RECEIVED.0.clone(); 46 | 47 | let update_interval = self.update_interval(); 48 | let input = Arc::new(self.input()); 49 | 50 | let handle = task::spawn(async move { 51 | let mut last_updated = Instant::now(); 52 | 53 | let mut paused = false; 54 | 55 | // Execute the task initially and request a redraw to display this data 56 | if let Some(response) = ::task(input.clone()).await { 57 | let _ = response_sender.send(response); 58 | let _ = data_received.try_send(()); 59 | } 60 | 61 | // If no update interval is defined, exit task 62 | let update_interval = if let Some(interval) = update_interval { 63 | interval.max(Duration::from_secs(*UPDATE_INTERVAL)) 64 | } else { 65 | return; 66 | }; 67 | 68 | // Execute task every update interval 69 | loop { 70 | if let Ok(command) = command_receiver.try_recv() { 71 | match command { 72 | AsyncTaskCommand::Resume => paused = false, 73 | AsyncTaskCommand::Pause => paused = true, 74 | } 75 | } 76 | 77 | if last_updated.elapsed() >= update_interval && !paused { 78 | if let Some(response) = ::task(input.clone()).await { 79 | let _ = response_sender.send(response); 80 | let _ = data_received.try_send(()); 81 | } 82 | 83 | last_updated = Instant::now(); 84 | } 85 | 86 | // Free up some cycles 87 | task::sleep(Duration::from_millis(500)).await; 88 | } 89 | }); 90 | 91 | AsyncTaskHandle { 92 | response: response_receiver, 93 | handle: Some(handle), 94 | command_sender, 95 | } 96 | } 97 | } 98 | 99 | enum AsyncTaskCommand { 100 | Pause, 101 | Resume, 102 | } 103 | 104 | pub struct AsyncTaskHandle { 105 | response: Receiver, 106 | handle: Option>, 107 | command_sender: Sender, 108 | } 109 | 110 | impl AsyncTaskHandle { 111 | pub fn response(&self) -> &Receiver { 112 | &self.response 113 | } 114 | 115 | pub fn pause(&self) { 116 | let _ = self.command_sender.try_send(AsyncTaskCommand::Pause); 117 | } 118 | 119 | pub fn resume(&self) { 120 | let _ = self.command_sender.try_send(AsyncTaskCommand::Resume); 121 | } 122 | } 123 | 124 | impl Drop for AsyncTaskHandle { 125 | fn drop(&mut self) { 126 | let handle = self.handle.take().unwrap(); 127 | task::spawn(async { handle.cancel().await }); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | 6 | name: Release 7 | 8 | jobs: 9 | create-release: 10 | name: "Create Release" 11 | runs-on: ubuntu-latest 12 | outputs: 13 | upload_url: ${{ steps.create-release.outputs.upload_url }} 14 | steps: 15 | - name: Create Release 16 | id: create-release 17 | uses: actions/create-release@v1 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | tag_name: ${{ github.ref }} 22 | release_name: ${{ github.ref }} 23 | draft: true 24 | prerelease: false 25 | 26 | release: 27 | name: "Release" 28 | needs: create-release 29 | strategy: 30 | matrix: 31 | target: 32 | - target: x86_64-pc-windows-msvc 33 | os: windows-latest 34 | cross: false 35 | binary_path: target/x86_64-pc-windows-msvc/release/tickrs.exe 36 | - target: x86_64-unknown-linux-gnu 37 | os: ubuntu-latest 38 | cross: false 39 | binary_path: target/x86_64-unknown-linux-gnu/release/tickrs 40 | - target: x86_64-apple-darwin 41 | os: macos-latest 42 | cross: false 43 | binary_path: target/x86_64-apple-darwin/release/tickrs 44 | - target: armv7-unknown-linux-gnueabihf 45 | os: ubuntu-latest 46 | cross: true 47 | binary_path: target/armv7-unknown-linux-gnueabihf/release/tickrs 48 | - target: aarch64-unknown-linux-gnu 49 | os: ubuntu-latest 50 | cross: true 51 | binary_path: target/aarch64-unknown-linux-gnu/release/tickrs 52 | - target: aarch64-linux-android 53 | os: ubuntu-latest 54 | cross: true 55 | binary_path: target/aarch64-linux-android/release/tickrs 56 | 57 | runs-on: ${{ matrix.target.os }} 58 | steps: 59 | - name: Checkout sources 60 | uses: actions/checkout@v2 61 | 62 | - name: Get the version 63 | id: get_version 64 | shell: bash 65 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 66 | 67 | - name: Install stable toolchain 68 | uses: actions-rs/toolchain@v1 69 | with: 70 | # TODO: Update for future releases 71 | toolchain: 1.78.0 72 | target: ${{ matrix.target.target }} 73 | override: true 74 | 75 | - name: Run cargo build 76 | uses: actions-rs/cargo@v1 77 | with: 78 | use-cross: ${{ matrix.target.cross }} 79 | command: build 80 | args: --release --target=${{ matrix.target.target }} 81 | 82 | - name: Copy release files 83 | shell: bash 84 | run: | 85 | mkdir package 86 | cp -R assets LICENSE README.md CHANGELOG.md package/ 87 | cp ${{ matrix.target.binary_path }} package/ 88 | 89 | - name: Create Archive 90 | shell: bash 91 | if: matrix.target.os != 'windows-latest' 92 | run: | 93 | tar czvf tickrs-${{ steps.get_version.outputs.VERSION }}-${{ matrix.target.target }}.tar.gz -C package/ . 94 | 95 | - name: Create Archive (Windows) 96 | if: matrix.target.os == 'windows-latest' 97 | run: | 98 | cd package; 7z.exe a ../tickrs-${{ steps.get_version.outputs.VERSION }}-${{ matrix.target.target }}.zip . 99 | 100 | - name: Upload Release Asset 101 | if: matrix.target.os != 'windows-latest' 102 | uses: actions/upload-release-asset@v1 103 | env: 104 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 105 | with: 106 | upload_url: ${{ needs.create-release.outputs.upload_url }} 107 | asset_path: ./tickrs-${{ steps.get_version.outputs.VERSION }}-${{ matrix.target.target }}.tar.gz 108 | asset_name: tickrs-${{ steps.get_version.outputs.VERSION }}-${{ matrix.target.target }}.tar.gz 109 | asset_content_type: application/x-gzip 110 | 111 | - name: Upload Release Asset (Windows) 112 | if: matrix.target.os == 'windows-latest' 113 | uses: actions/upload-release-asset@v1 114 | env: 115 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 116 | with: 117 | upload_url: ${{ needs.create-release.outputs.upload_url }} 118 | asset_path: ./tickrs-${{ steps.get_version.outputs.VERSION }}-${{ matrix.target.target }}.zip 119 | asset_name: tickrs-${{ steps.get_version.outputs.VERSION }}-${{ matrix.target.target }}.zip 120 | asset_content_type: application/zip 121 | 122 | publish: 123 | name: "Publish" 124 | runs-on: ubuntu-latest 125 | steps: 126 | - name: Checkout sources 127 | uses: actions/checkout@v2 128 | 129 | - name: Install stable toolchain 130 | uses: actions-rs/toolchain@v1 131 | with: 132 | toolchain: stable 133 | override: true 134 | 135 | - name: Publish tickrs-api 136 | uses: actions-rs/cargo@v1 137 | with: 138 | command: publish 139 | args: --token ${{ secrets.CRATES_TOKEN }} --manifest-path ./api/Cargo.toml 140 | 141 | - name: Wait 142 | shell: bash 143 | run: sleep 30 144 | 145 | - name: Publish tickrs 146 | uses: actions-rs/cargo@v1 147 | with: 148 | command: publish 149 | args: --token ${{ secrets.CRATES_TOKEN }} 150 | -------------------------------------------------------------------------------- /src/opts.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::{fs, process}; 3 | 4 | use anyhow::{bail, format_err, Error}; 5 | use serde::Deserialize; 6 | use structopt::StructOpt; 7 | 8 | use crate::common::{ChartType, TimeFrame}; 9 | use crate::portfolio::Portfolio; 10 | use crate::theme::Theme; 11 | use crate::widget::KagiOptions; 12 | 13 | pub fn resolve_opts() -> Opts { 14 | let mut opts = get_cli_opts(); 15 | 16 | if let Ok(config_opts) = get_config_opts() { 17 | // Options 18 | opts.chart_type = opts.chart_type.or(config_opts.chart_type); 19 | opts.symbols = opts.symbols.or(config_opts.symbols); 20 | opts.time_frame = opts.time_frame.or(config_opts.time_frame); 21 | opts.update_interval = opts.update_interval.or(config_opts.update_interval); 22 | 23 | // Flags 24 | opts.enable_pre_post = opts.enable_pre_post || config_opts.enable_pre_post; 25 | opts.hide_help = opts.hide_help || config_opts.hide_help; 26 | opts.hide_prev_close = opts.hide_prev_close || config_opts.hide_prev_close; 27 | opts.hide_toggle = opts.hide_toggle || config_opts.hide_toggle; 28 | opts.show_volumes = opts.show_volumes || config_opts.show_volumes; 29 | opts.show_x_labels = opts.show_x_labels || config_opts.show_x_labels; 30 | opts.summary = opts.summary || config_opts.summary; 31 | opts.trunc_pre = opts.trunc_pre || config_opts.trunc_pre; 32 | 33 | // Theme 34 | opts.theme = config_opts.theme; 35 | 36 | // Kagi Options 37 | opts.kagi_options = config_opts.kagi_options; 38 | 39 | // Portfolio 40 | opts.portfolio = config_opts.portfolio; 41 | } 42 | 43 | opts 44 | } 45 | 46 | fn get_cli_opts() -> Opts { 47 | Opts::from_args() 48 | } 49 | 50 | fn get_config_opts() -> Result { 51 | let config_dir = dirs_next::config_dir() 52 | .ok_or_else(|| format_err!("Could not get config directory"))? 53 | .join("tickrs"); 54 | 55 | if !config_dir.exists() { 56 | let _ = fs::create_dir_all(&config_dir); 57 | } 58 | 59 | let config_path = config_dir.join("config.yml"); 60 | 61 | if !config_path.exists() { 62 | let _ = fs::write(&config_path, DEFAULT_CONFIG); 63 | } 64 | 65 | let config = fs::read_to_string(&config_path)?; 66 | 67 | let opts = match serde_yaml::from_str::>(&config) { 68 | Ok(Some(opts)) => opts, 69 | Ok(None) => bail!("Empty config file"), 70 | Err(e) => { 71 | println!( 72 | "Error parsing config file, make sure format is valid\n\n {}", 73 | e 74 | ); 75 | process::exit(1); 76 | } 77 | }; 78 | 79 | Ok(opts) 80 | } 81 | 82 | #[derive(Debug, StructOpt, Clone, Deserialize, Default)] 83 | #[structopt( 84 | name = "tickrs", 85 | about = "Realtime ticker data in your terminal 📈", 86 | version = env!("CARGO_PKG_VERSION") 87 | )] 88 | #[serde(default)] 89 | pub struct Opts { 90 | // Options 91 | // 92 | #[structopt(short, long, possible_values(&["line", "candle", "kagi"]))] 93 | /// Chart type to start app with [default: line] 94 | pub chart_type: Option, 95 | #[structopt(short, long, use_delimiter = true)] 96 | /// Comma separated list of ticker symbols to start app with 97 | pub symbols: Option>, 98 | #[structopt(short = "t", long, possible_values(&["1D", "1W", "1M", "3M", "6M", "1Y", "5Y"]))] 99 | /// Use specified time frame when starting program and when new stocks are added [default: 1D] 100 | pub time_frame: Option, 101 | #[structopt(short = "i", long)] 102 | /// Interval to update data from API (seconds) [default: 1] 103 | pub update_interval: Option, 104 | 105 | // Flags 106 | // 107 | #[structopt(short = "p", long)] 108 | /// Enable pre / post market hours for graphs 109 | pub enable_pre_post: bool, 110 | #[structopt(long)] 111 | /// Hide help icon in top right 112 | pub hide_help: bool, 113 | #[structopt(long)] 114 | /// Hide previous close line on 1D chart 115 | pub hide_prev_close: bool, 116 | #[structopt(long)] 117 | /// Hide toggle block 118 | pub hide_toggle: bool, 119 | #[structopt(long)] 120 | /// Show volumes graph 121 | pub show_volumes: bool, 122 | #[structopt(short = "x", long)] 123 | /// Show x-axis labels 124 | pub show_x_labels: bool, 125 | #[structopt(long)] 126 | /// Start in summary mode 127 | pub summary: bool, 128 | #[structopt(long)] 129 | /// Truncate pre market graphing to only 30 minutes prior to markets opening 130 | pub trunc_pre: bool, 131 | 132 | #[structopt(skip)] 133 | pub theme: Option, 134 | #[structopt(skip)] 135 | pub kagi_options: HashMap, 136 | #[structopt(skip)] 137 | pub portfolio: Option, 138 | } 139 | 140 | const DEFAULT_CONFIG: &str = "--- 141 | # List of ticker symbols to start app with 142 | #symbols: 143 | # - SPY 144 | # - AMD 145 | 146 | # Chart type to start app with 147 | # Default is line 148 | # Possible values: line, candle, kagi 149 | #chart_type: candle 150 | 151 | # Use specified time frame when starting program and when new stocks are added 152 | # Default is 1D 153 | # Possible values: 1D, 1W, 1M, 3M, 6M, 1Y, 5Y 154 | #time_frame: 1D 155 | 156 | # Interval to update data from API (seconds) 157 | # Default is 1 158 | #update_interval: 1 159 | 160 | # Enable pre / post market hours for graphs 161 | #enable_pre_post: true 162 | 163 | # Hide help icon in top right 164 | #hide_help: true 165 | 166 | # Hide previous close line on 1D chart 167 | #hide_prev_close: true 168 | 169 | # Hide toggle block 170 | #hide_toggle: true 171 | 172 | # Show volumes graph 173 | #show_volumes: true 174 | 175 | # Show x-axis labels 176 | #show_x_labels: true 177 | 178 | # Start in summary mode 179 | #summary: true 180 | 181 | # Truncate pre market graphing to only 30 minutes prior to markets opening 182 | #trunc_pre: true 183 | 184 | # Ticker options for Kagi charts 185 | # 186 | # A map of each ticker with reversal and/or price fields (both optional). If no 187 | # entry is defined for a symbol, a default of 'close' price and 1% for 1D and 4% 188 | # for non-1D timeframes is used. This can be updated in the GUI by pressing 'e' 189 | # 190 | # reversal can be supplied as a single value, or a map on time frame to give each 191 | # time frame a different reversal amount 192 | # 193 | # reversal.type can be 'amount' or 'pct' 194 | # 195 | # price can be 'close' or 'high_low' 196 | # 197 | #kagi_options: 198 | # SPY: 199 | # reversal: 200 | # type: amount 201 | # value: 5.00 202 | # price: close 203 | # AMD: 204 | # price: high_low 205 | # TSLA: 206 | # reversal: 207 | # type: pct 208 | # value: 0.08 209 | # NVDA: 210 | # reversal: 211 | # 1D: 212 | # type: pct 213 | # value: 0.02 214 | # 5Y: 215 | # type: pct 216 | # value: 0.10 217 | 218 | # Apply a custom theme 219 | # 220 | # All colors are optional. If commented out / omitted, the color will get sourced 221 | # from your terminal color scheme 222 | #theme: 223 | # background: '#403E41' 224 | # gray: '#727072' 225 | # profit: '#ADD977' 226 | # loss: '#FA648A' 227 | # text_normal: '#FCFCFA' 228 | # text_primary: '#FFDA65' 229 | # text_secondary: '#79DBEA' 230 | # border_primary: '#FC9766' 231 | # border_secondary: '#FCFCFA' 232 | # border_axis: '#FC9766' 233 | # highlight_focused: '#FC9766' 234 | # highlight_unfocused: '#727072' 235 | 236 | # Portfolio tracking 237 | # 238 | # Track your portfolio holdings to see profit/loss 239 | # Each ticker is tracked with quantity and average purchase price 240 | # Ticker symbols must match those in the symbols list in order to be visible 241 | # 242 | #portfolio: 243 | # AMD: 244 | # quantity: 1.3 245 | # average_price: 221 246 | # SPY: 247 | # quantity: 100 248 | # average_price: 450.25 249 | "; 250 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | # Changelog 6 | 7 | All notable changes to this project will be documented in this file. 8 | 9 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 10 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 11 | 12 | The sections should follow the order `Packaging`, `Added`, `Changed`, `Fixed` 13 | and `Removed`. 14 | 15 | ## [Unreleased] 16 | 17 | ## [0.15.0] - 2025-12-15 18 | 19 | ### Added 20 | 21 | - Portfolio tracking feature to display holdings, average price, and profit/loss for each ticker 22 | - Configuration support for portfolio holdings in `config.yml` 23 | 24 | ## [0.14.11] - 2025-02-21 25 | 26 | ### Fixed 27 | 28 | - User agent added to fix failing yahoo finance API requests 29 | 30 | ## [0.14.10] - 2024-06-17 31 | 32 | ### Fixed 33 | 34 | - Race condition preventing data from loading if a long update interval was defined 35 | 36 | ## [0.14.9] - 2023-07-25 37 | 38 | ### Fixed 39 | 40 | - Fixed API breakage preventing quotes from loading 41 | 42 | ## [0.14.8] - 2023-02-22 43 | 44 | ### Packaging 45 | 46 | - Updated dependencies 47 | 48 | ## [0.14.6] - 2022-08-17 49 | 50 | ### Changed 51 | 52 | - Improved dynamic decimal formatting 53 | 54 | ## [0.14.5] - 2021-05-07 55 | 56 | ### Added 57 | 58 | - Improve graphing for penny stocks by showing 4 decimal places and removing y-axis 59 | padding so they fill out the full graph 60 | 61 | ## [0.14.4] - 2021-03-17 62 | 63 | ### Packaging 64 | 65 | - Add support and releases for Android (aarch64-linux-android) 66 | 67 | ## [0.14.3] - 2021-03-15 68 | 69 | ### Changed 70 | 71 | - API data is only fetched for widgets that are actively shown 72 | - This greatly reduces the number of active API requests when many tickers are 73 | added. Data is lazily fetched & updated once a widget is in view ([#118]) 74 | 75 | ## [0.14.2] - 2021-03-05 76 | 77 | ### Fixed 78 | 79 | - Fix bug preventing Index tickers from working ([#115]) 80 | 81 | ## [0.14.1] - 2021-03-02 82 | 83 | ### Fixed 84 | 85 | - Fixed keybind to correctly capture SHIFT+TAB in the 86 | chart configuration pane ([#110]) 87 | 88 | ### Changed 89 | 90 | - UI changes so that wording is consistent throughout ([#112]) 91 | - Stock symbols show as uppercase in tabs section 92 | - Letters for stock information are now capitalized 93 | - Words in Options pane are now capitalized 94 | - Toggle box shows the current chart type rather than the next chart type 95 | 96 | ## [0.14.0] - 2021-02-26 97 | 98 | ### Added 99 | 100 | - Kagi charts have been added! ([#93]) 101 | - You can specify custom reversal type (pct or amount), reversal value, and 102 | price type (close or high_low) within the GUI by pressing 'e' 103 | - New config options have been added to configure the behavior of Kagi charts, 104 | see the updated [wiki entry](https://github.com/tarkah/tickrs/wiki/Config-file) 105 | - As Kagi charts x-axis is decoupled from time, the chart width may be wider than 106 | the terminal. You can now press SHIFT + < / > 107 | or SHIFT + LEFT / RIGHT to scroll the chart. 108 | An indicator in the bottom right corner will notify you if you can scroll further 109 | left / right 110 | - `--candle` has been deprecated in favor of `--chart-type` 111 | 112 | ### Packaging 113 | 114 | - Linux: Binary size has been reduced due to some optimizations, from 10.6MB to 115 | 8MB ([#86]) 116 | 117 | ## [0.13.1] - 2021-02-22 118 | 119 | ### Fixed 120 | 121 | - Fixed theme background not getting applied to all widgets ([#84]) 122 | - Fixed last x label for candlestick charts from showing unix time 0 for 1W - 5Y 123 | timeframes ([#85]) 124 | 125 | ## [0.13.0] - 2021-02-19 126 | 127 | ### Added 128 | 129 | - Candestick chart support has been added. You can press 'c' to toggle between 130 | line and candlestick charts ([#75]) 131 | - You can also pass the `--candle` flag on startup, or specify `candle: true` 132 | in the config file to launch with candlestick charting enabled 133 | 134 | ### Changed 135 | 136 | - All theme colors are now optional and can be selectively included / omitted from 137 | the theme config ([#76]) 138 | 139 | ### Fixed 140 | 141 | - Fixed panic when width of terminal was too small on main stock screen ([4cc00d0](https://github.com/tarkah/tickrs/commit/4cc00d052c4bfff993587f1342086498ee8b2766)) 142 | - Fix bug where cursor icon still shows in some terminals such as WSL2 on Windows with Alacritty ([#79]) 143 | 144 | ## [0.12.0] - 2021-02-17 145 | 146 | ### Added 147 | 148 | - Custom themes can now be applied. See the [themes wiki](https://github.com/tarkah/tickrs/wiki/Themes) entry for more 149 | information ([#69]) 150 | 151 | ## [0.11.0] - 2021-02-12 152 | 153 | ### Added 154 | 155 | - Summary pane can be scrolled with Up / Down arrows if more tickers are present 156 | than are able to be shown in the terminal ([#63]) 157 | - A config file can now be used to change program behavior. A default file will 158 | be created / can be updated at the following locations ([#66]) 159 | - Linux: `$HOME/.config/tickrs/config.yml` 160 | - macOS: `$HOME/Library/Application Support/tickrs/config.yml` 161 | - Windows: `%APPDATA%\tickrs\config.yml` 162 | - Current tab can be reordered by using `Ctrl + Left / Right` ([#67]) 163 | 164 | ## [0.10.2] - 2021-02-10 165 | 166 | ### Fixed 167 | 168 | - Fixed bug that would deadlock the program between 12am - 4am ET on the intraday 169 | 1D timeframe ([#59]) 170 | 171 | ## [0.10.1] - 2021-02-08 172 | 173 | ### Fixed 174 | 175 | - Options pane now re-renders correctly when resizing terminal window ([#57]) 176 | - Prevent application from crashing when terminal was too small with options pane 177 | open ([#57]) 178 | 179 | ## [0.10.0] - 2021-02-08 180 | 181 | ### Fixed 182 | 183 | - Huge improvements to optimization of program. CPU usage is way down ([#54]) 184 | - Fix 1W - 6M time frame graphing for Crypto tickers where not all datapoints 185 | were plotted correctly across the x-axis ([#55]) 186 | 187 | ## [0.9.1] - 2021-02-06 188 | 189 | ### Changed 190 | 191 | - Help page can be exited with `q` key ([#51]) 192 | - Added a note to help page about options not being enabled for crypto ([#50]) 193 | 194 | ### Fixed 195 | 196 | - Stocks that IPOd more recently than selected timeframe no longer stretch the 197 | entire x-axis width and now start plotting at the correct spot ([#48]) 198 | - Fix bug where too many file descriptors are opened due to recreating http 199 | client ([#53]) 200 | 201 | ## [0.9.0] - 2021-02-04 202 | 203 | ### Added 204 | 205 | - Added support for graphing volumes. You can press `v` to toggle volumes 206 | 207 | ### Fixed 208 | 209 | - Fixed issue on 1D graph with pre / post enabled where missing datapoints caused 210 | line to not reach end of x-axis by end of day. Now line always reaches end of 211 | x-axis 212 | 213 | 214 | [#48]: https://github.com/tarkah/tickrs/pull/48 215 | [#50]: https://github.com/tarkah/tickrs/pull/50 216 | [#51]: https://github.com/tarkah/tickrs/pull/51 217 | [#53]: https://github.com/tarkah/tickrs/pull/53 218 | [#54]: https://github.com/tarkah/tickrs/pull/54 219 | [#55]: https://github.com/tarkah/tickrs/pull/55 220 | [#57]: https://github.com/tarkah/tickrs/pull/57 221 | [#59]: https://github.com/tarkah/tickrs/pull/59 222 | [#63]: https://github.com/tarkah/tickrs/pull/63 223 | [#66]: https://github.com/tarkah/tickrs/pull/66 224 | [#67]: https://github.com/tarkah/tickrs/pull/67 225 | [#69]: https://github.com/tarkah/tickrs/pull/69 226 | [#75]: https://github.com/tarkah/tickrs/pull/75 227 | [#76]: https://github.com/tarkah/tickrs/pull/76 228 | [#79]: https://github.com/tarkah/tickrs/pull/79 229 | [#84]: https://github.com/tarkah/tickrs/pull/84 230 | [#85]: https://github.com/tarkah/tickrs/pull/85 231 | [#86]: https://github.com/tarkah/tickrs/pull/86 232 | [#93]: https://github.com/tarkah/tickrs/pull/93 233 | [#110]: https://github.com/tarkah/tickrs/pull/110 234 | [#112]: https://github.com/tarkah/tickrs/pull/112 235 | [#115]: https://github.com/tarkah/tickrs/pull/115 236 | [#118]: https://github.com/tarkah/tickrs/pull/118 237 | -------------------------------------------------------------------------------- /src/widget/stock_summary.rs: -------------------------------------------------------------------------------- 1 | use ratatui::buffer::Buffer; 2 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 3 | use ratatui::style::Modifier; 4 | use ratatui::text::{Line, Span}; 5 | use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}; 6 | 7 | use super::chart::{PricesCandlestickChart, PricesKagiChart, PricesLineChart, VolumeBarChart}; 8 | use super::stock::StockState; 9 | use super::{CachableWidget, CacheState}; 10 | use crate::common::{format_decimals, ChartType}; 11 | use crate::draw::{add_padding, PaddingDirection}; 12 | use crate::theme::style; 13 | use crate::{ENABLE_PRE_POST, SHOW_VOLUMES, THEME}; 14 | 15 | pub struct StockSummaryWidget {} 16 | 17 | impl StatefulWidget for StockSummaryWidget { 18 | type State = StockState; 19 | 20 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 21 | self.render_cached(area, buf, state); 22 | } 23 | } 24 | 25 | impl CachableWidget for StockSummaryWidget { 26 | fn cache_state_mut(state: &mut StockState) -> &mut CacheState { 27 | &mut state.cache_state 28 | } 29 | 30 | fn render(self, mut area: Rect, buf: &mut Buffer, state: &mut ::State) { 31 | let data = state.prices().collect::>(); 32 | 33 | let pct_change = state.pct_change(&data); 34 | 35 | let chart_type = state.chart_type; 36 | let enable_pre_post = *ENABLE_PRE_POST.read(); 37 | let show_volumes = *SHOW_VOLUMES.read() && chart_type != ChartType::Kagi; 38 | 39 | let loaded = state.loaded(); 40 | 41 | let (company_name, currency) = match state.profile.as_ref() { 42 | Some(profile) => ( 43 | profile.price.short_name.as_str(), 44 | profile.price.currency.as_deref().unwrap_or("USD"), 45 | ), 46 | None => ("", ""), 47 | }; 48 | 49 | let loading_indicator = ".".repeat(state.loading_tick); 50 | 51 | let title = &format!( 52 | " {}{}", 53 | state.symbol, 54 | if state.profile.is_some() { 55 | format!(" - {}", company_name) 56 | } else { 57 | "".to_string() 58 | } 59 | ); 60 | Block::default() 61 | .title(Span::styled( 62 | format!( 63 | " {}{} ", 64 | &title[..24.min(title.len())], 65 | if loaded { 66 | "".to_string() 67 | } else { 68 | format!("{:<4}", loading_indicator) 69 | } 70 | ), 71 | style().fg(THEME.text_normal()), 72 | )) 73 | .borders(Borders::TOP) 74 | .border_style(style().fg(THEME.border_secondary())) 75 | .render(area, buf); 76 | area = add_padding(area, 1, PaddingDirection::Top); 77 | 78 | let mut layout: Vec = Layout::default() 79 | .direction(Direction::Horizontal) 80 | .constraints([Constraint::Length(25), Constraint::Min(0)].as_ref()) 81 | .split(area) 82 | .to_vec(); 83 | 84 | { 85 | layout[0] = add_padding(layout[0], 1, PaddingDirection::Left); 86 | layout[0] = add_padding(layout[0], 2, PaddingDirection::Right); 87 | 88 | let (high, low) = state.high_low(&data); 89 | let current_fmt = format_decimals(state.current_price()); 90 | let high_fmt = format_decimals(high); 91 | let low_fmt = format_decimals(low); 92 | 93 | let vol = state.reg_mkt_volume.clone().unwrap_or_default(); 94 | 95 | let prices = vec![ 96 | Line::from(vec![ 97 | Span::styled("C: ", style().fg(THEME.text_normal())), 98 | Span::styled( 99 | if loaded { 100 | format!("{} {}", current_fmt, currency) 101 | } else { 102 | "".to_string() 103 | }, 104 | style() 105 | .add_modifier(Modifier::BOLD) 106 | .fg(THEME.text_primary()), 107 | ), 108 | ]), 109 | Line::from(vec![ 110 | Span::styled("H: ", style().fg(THEME.text_normal())), 111 | Span::styled( 112 | if loaded { high_fmt } else { "".to_string() }, 113 | style().fg(THEME.text_secondary()), 114 | ), 115 | ]), 116 | Line::from(vec![ 117 | Span::styled("L: ", style().fg(THEME.text_normal())), 118 | Span::styled( 119 | if loaded { low_fmt } else { "".to_string() }, 120 | style().fg(THEME.text_secondary()), 121 | ), 122 | ]), 123 | Line::default(), 124 | Line::from(vec![ 125 | Span::styled("Volume: ", style().fg(THEME.text_normal())), 126 | Span::styled( 127 | if loaded { vol } else { "".to_string() }, 128 | style().fg(THEME.text_secondary()), 129 | ), 130 | ]), 131 | ]; 132 | 133 | let pct = vec![Span::styled( 134 | if loaded { 135 | format!(" {:.2}%", pct_change * 100.0) 136 | } else { 137 | "".to_string() 138 | }, 139 | style() 140 | .add_modifier(Modifier::BOLD) 141 | .fg(if pct_change >= 0.0 { 142 | THEME.profit() 143 | } else { 144 | THEME.loss() 145 | }), 146 | )]; 147 | 148 | Paragraph::new(prices) 149 | .style(style()) 150 | .alignment(Alignment::Left) 151 | .render(layout[0], buf); 152 | 153 | Paragraph::new(Line::from(pct)) 154 | .style(style()) 155 | .alignment(Alignment::Right) 156 | .render(layout[0], buf); 157 | } 158 | 159 | // graph_chunks[0] = prices 160 | // graph_chunks[1] = volume 161 | let graph_chunks: Vec = if show_volumes { 162 | Layout::default() 163 | .constraints([Constraint::Min(5), Constraint::Length(1)].as_ref()) 164 | .split(layout[1]) 165 | .to_vec() 166 | } else { 167 | Layout::default() 168 | .constraints([Constraint::Min(0)].as_ref()) 169 | .split(layout[1]) 170 | .to_vec() 171 | }; 172 | 173 | // Draw prices line chart 174 | match chart_type { 175 | ChartType::Line => { 176 | PricesLineChart { 177 | data: &data, 178 | enable_pre_post, 179 | is_profit: pct_change >= 0.0, 180 | is_summary: true, 181 | loaded, 182 | show_x_labels: false, 183 | } 184 | .render(graph_chunks[0], buf, state); 185 | } 186 | ChartType::Candlestick => { 187 | PricesCandlestickChart { 188 | data: &data, 189 | loaded, 190 | show_x_labels: false, 191 | is_summary: true, 192 | } 193 | .render(graph_chunks[0], buf, state); 194 | } 195 | ChartType::Kagi => { 196 | PricesKagiChart { 197 | data: &data, 198 | loaded, 199 | show_x_labels: false, 200 | is_summary: true, 201 | kagi_options: state.chart_configuration.kagi_options.clone(), 202 | } 203 | .render(graph_chunks[0], buf, state); 204 | } 205 | } 206 | 207 | // Draw volumes bar chart 208 | if show_volumes { 209 | VolumeBarChart { 210 | data: &data, 211 | loaded, 212 | show_x_labels: false, 213 | } 214 | .render(graph_chunks[1], buf, state); 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /api/src/model.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::hash::{Hash, Hasher}; 3 | use std::marker::PhantomData; 4 | 5 | use anyhow::Result; 6 | use serde::de::{SeqAccess, Visitor}; 7 | use serde::{Deserialize, Deserializer}; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct CrumbData { 11 | pub cookie: String, 12 | pub crumb: String, 13 | } 14 | 15 | #[derive(Debug, Deserialize, Clone)] 16 | #[serde(rename_all = "camelCase")] 17 | pub struct Chart { 18 | pub chart: ChartStatus, 19 | } 20 | 21 | #[derive(Debug, Deserialize, Clone)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct ChartStatus { 24 | pub result: Option>, 25 | pub error: Option, 26 | } 27 | 28 | #[derive(Debug, Deserialize, Clone)] 29 | #[serde(rename_all = "camelCase")] 30 | pub struct Error { 31 | pub code: String, 32 | pub description: String, 33 | } 34 | 35 | #[derive(Debug, Deserialize, Clone)] 36 | #[serde(rename_all = "camelCase")] 37 | pub struct ChartData { 38 | pub meta: ChartMeta, 39 | pub timestamp: Vec, 40 | pub indicators: ChartIndicators, 41 | } 42 | #[derive(Debug, Deserialize, Clone)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct ChartMeta { 45 | pub instrument_type: Option, 46 | pub regular_market_price: f64, 47 | pub chart_previous_close: f64, 48 | pub current_trading_period: Option, 49 | } 50 | 51 | impl Hash for ChartMeta { 52 | fn hash(&self, state: &mut H) { 53 | self.instrument_type.hash(state); 54 | self.regular_market_price.to_bits().hash(state); 55 | self.chart_previous_close.to_bits().hash(state); 56 | self.current_trading_period.hash(state); 57 | } 58 | } 59 | 60 | #[derive(Debug, Deserialize, Clone, Hash)] 61 | #[serde(rename_all = "camelCase")] 62 | pub struct ChartCurrentTradingPeriod { 63 | pub regular: ChartTradingPeriod, 64 | pub pre: ChartTradingPeriod, 65 | pub post: ChartTradingPeriod, 66 | } 67 | 68 | #[derive(Debug, Deserialize, Clone, Hash)] 69 | #[serde(rename_all = "camelCase")] 70 | pub struct ChartTradingPeriod { 71 | pub start: i64, 72 | pub end: i64, 73 | } 74 | 75 | #[derive(Debug, Deserialize, Clone)] 76 | #[serde(rename_all = "camelCase")] 77 | pub struct ChartIndicators { 78 | pub quote: Vec, 79 | pub adjclose: Option>, 80 | } 81 | 82 | #[derive(Debug, Deserialize, Clone)] 83 | #[serde(rename_all = "camelCase")] 84 | pub struct ChartAdjClose { 85 | #[serde(deserialize_with = "deserialize_vec")] 86 | pub adjclose: Vec, 87 | } 88 | 89 | #[derive(Debug, Deserialize, Clone)] 90 | #[serde(rename_all = "camelCase")] 91 | pub struct ChartQuote { 92 | #[serde(deserialize_with = "deserialize_vec")] 93 | pub close: Vec, 94 | #[serde(deserialize_with = "deserialize_vec")] 95 | pub volume: Vec, 96 | #[serde(deserialize_with = "deserialize_vec")] 97 | pub high: Vec, 98 | #[serde(deserialize_with = "deserialize_vec")] 99 | pub low: Vec, 100 | #[serde(deserialize_with = "deserialize_vec")] 101 | pub open: Vec, 102 | } 103 | 104 | #[derive(Debug, Deserialize, Clone)] 105 | #[serde(rename_all = "camelCase")] 106 | pub struct Company { 107 | #[serde(rename = "quoteSummary")] 108 | pub company: CompanyStatus, 109 | } 110 | 111 | #[derive(Debug, Deserialize, Clone)] 112 | #[serde(rename_all = "camelCase")] 113 | pub struct CompanyStatus { 114 | pub result: Option>, 115 | pub error: Option, 116 | } 117 | 118 | #[derive(Debug, Deserialize, Clone)] 119 | #[serde(rename_all = "camelCase")] 120 | pub struct CompanyData { 121 | #[serde(rename = "assetProfile")] 122 | pub profile: Option, 123 | pub price: CompanyPrice, 124 | } 125 | 126 | #[derive(Debug, Deserialize, Clone, Hash)] 127 | #[serde(rename_all = "camelCase")] 128 | pub struct CompanyProfile { 129 | pub website: Option, 130 | pub industry: Option, 131 | pub sector: Option, 132 | #[serde(rename = "longBusinessSummary")] 133 | pub description: Option, 134 | #[serde(rename = "fullTimeEmployees")] 135 | pub employees: Option, 136 | } 137 | 138 | #[derive(Debug, Deserialize, Clone)] 139 | #[serde(rename_all = "camelCase")] 140 | pub struct CompanyPrice { 141 | pub symbol: String, 142 | pub short_name: String, 143 | pub long_name: Option, 144 | pub regular_market_price: CompanyMarketPrice, 145 | pub regular_market_previous_close: CompanyMarketPrice, 146 | pub post_market_price: OptionalCompanyMarketPrice, 147 | pub regular_market_volume: OptionalCompanyMarketPrice, 148 | pub currency: Option, 149 | } 150 | 151 | #[derive(Debug, Deserialize, Clone)] 152 | #[serde(rename_all = "camelCase")] 153 | pub struct CompanyMarketPrice { 154 | #[serde(rename = "raw")] 155 | pub price: f64, 156 | pub fmt: String, 157 | } 158 | 159 | #[derive(Debug, Deserialize, Clone)] 160 | #[serde(rename_all = "camelCase")] 161 | pub struct OptionalCompanyMarketPrice { 162 | #[serde(rename = "raw")] 163 | pub price: Option, 164 | pub fmt: Option, 165 | } 166 | 167 | #[derive(Debug, Deserialize, Clone)] 168 | #[serde(rename_all = "camelCase")] 169 | pub struct Options { 170 | pub option_chain: OptionsStatus, 171 | } 172 | 173 | #[derive(Debug, Deserialize, Clone)] 174 | #[serde(rename_all = "camelCase")] 175 | pub struct OptionsStatus { 176 | pub result: Option>, 177 | pub error: Option, 178 | } 179 | 180 | #[derive(Debug, Deserialize, Clone)] 181 | #[serde(rename_all = "camelCase")] 182 | pub struct OptionsHeader { 183 | pub quote: OptionsQuote, 184 | pub expiration_dates: Vec, 185 | pub options: Vec, 186 | } 187 | 188 | #[derive(Debug, Deserialize, Clone)] 189 | #[serde(rename_all = "camelCase")] 190 | pub struct OptionsQuote { 191 | pub regular_market_price: f64, 192 | } 193 | 194 | impl Hash for OptionsQuote { 195 | fn hash(&self, state: &mut H) { 196 | self.regular_market_price.to_bits().hash(state); 197 | } 198 | } 199 | 200 | #[derive(Debug, Deserialize, Clone, Hash)] 201 | #[serde(rename_all = "camelCase")] 202 | pub struct OptionsData { 203 | pub expiration_date: i64, 204 | pub calls: Vec, 205 | pub puts: Vec, 206 | } 207 | 208 | #[derive(Debug, Deserialize, Clone)] 209 | #[serde(rename_all = "camelCase")] 210 | pub struct OptionsContract { 211 | pub strike: f64, 212 | pub last_price: f64, 213 | pub change: f64, 214 | #[serde(default)] 215 | pub percent_change: f64, 216 | pub volume: Option, 217 | pub open_interest: Option, 218 | pub bid: Option, 219 | pub ask: Option, 220 | pub implied_volatility: Option, 221 | pub in_the_money: Option, 222 | pub currency: Option, 223 | } 224 | 225 | impl Hash for OptionsContract { 226 | fn hash(&self, state: &mut H) { 227 | self.strike.to_bits().hash(state); 228 | self.last_price.to_bits().hash(state); 229 | self.change.to_bits().hash(state); 230 | self.percent_change.to_bits().hash(state); 231 | self.volume.hash(state); 232 | self.open_interest.hash(state); 233 | self.bid.map(|f| f.to_bits()).hash(state); 234 | self.ask.map(|f| f.to_bits()).hash(state); 235 | self.implied_volatility.map(|f| f.to_bits()).hash(state); 236 | self.in_the_money.hash(state); 237 | self.currency.hash(state); 238 | } 239 | } 240 | 241 | fn deserialize_vec<'de, D, T>(deserializer: D) -> Result, D::Error> 242 | where 243 | D: Deserializer<'de>, 244 | T: Deserialize<'de> + Default, 245 | { 246 | struct SeqVisitor(PhantomData); 247 | 248 | impl<'de, T> Visitor<'de> for SeqVisitor 249 | where 250 | T: Deserialize<'de> + Default, 251 | { 252 | type Value = Vec; 253 | 254 | fn expecting(&self, fmt: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 255 | fmt.write_str("default vec") 256 | } 257 | 258 | fn visit_seq>(self, mut seq: A) -> Result { 259 | let mut vec = Vec::new(); 260 | while let Ok(Some(elem)) = seq.next_element::>() { 261 | vec.push(elem.unwrap_or_default()); 262 | } 263 | Ok(vec) 264 | } 265 | } 266 | deserializer.deserialize_seq(SeqVisitor(PhantomData)) 267 | } 268 | -------------------------------------------------------------------------------- /src/widget/chart/prices_line.rs: -------------------------------------------------------------------------------- 1 | use ratatui::buffer::Buffer; 2 | use ratatui::layout::Rect; 3 | use ratatui::symbols::Marker; 4 | use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, StatefulWidget, Widget}; 5 | 6 | use crate::common::{ 7 | cast_as_dataset, cast_historical_as_price, zeros_as_pre, Price, TimeFrame, TradingPeriod, 8 | }; 9 | use crate::theme::style; 10 | use crate::widget::StockState; 11 | use crate::{HIDE_PREV_CLOSE, THEME}; 12 | 13 | pub struct PricesLineChart<'a> { 14 | pub loaded: bool, 15 | pub enable_pre_post: bool, 16 | pub show_x_labels: bool, 17 | pub is_profit: bool, 18 | pub is_summary: bool, 19 | pub data: &'a [Price], 20 | } 21 | 22 | impl StatefulWidget for PricesLineChart<'_> { 23 | type State = StockState; 24 | 25 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 26 | let (min, max) = state.min_max(self.data); 27 | let (start, end) = state.start_end(); 28 | 29 | let mut prices: Vec<_> = self.data.iter().map(cast_historical_as_price).collect(); 30 | 31 | prices.pop(); 32 | prices.push(state.current_price()); 33 | zeros_as_pre(&mut prices); 34 | 35 | // Need more than one price for GraphType::Line to work 36 | let graph_type = if prices.len() <= 2 { 37 | GraphType::Scatter 38 | } else { 39 | GraphType::Line 40 | }; 41 | 42 | let x_labels = if self.show_x_labels { 43 | state.x_labels(area.width, start, end, self.data) 44 | } else { 45 | vec![] 46 | }; 47 | 48 | let trading_period = state.current_trading_period(self.data); 49 | 50 | let (reg_prices, pre_prices, post_prices) = if self.loaded { 51 | let (start_idx, end_idx) = state.regular_start_end_idx(self.data); 52 | 53 | if self.enable_pre_post && state.time_frame == TimeFrame::Day1 { 54 | ( 55 | prices 56 | .iter() 57 | .enumerate() 58 | .filter(|(idx, _)| { 59 | if let Some(start) = start_idx { 60 | *idx >= start 61 | } else { 62 | false 63 | } 64 | }) 65 | .filter(|(idx, _)| { 66 | if let Some(end) = end_idx { 67 | *idx <= end 68 | } else { 69 | true 70 | } 71 | }) 72 | .map(cast_as_dataset) 73 | .collect::>(), 74 | { 75 | let pre_end_idx = if let Some(start_idx) = start_idx { 76 | start_idx 77 | } else { 78 | prices.len() 79 | }; 80 | 81 | if pre_end_idx > 0 { 82 | Some( 83 | prices 84 | .iter() 85 | .enumerate() 86 | .filter(|(idx, _)| *idx <= pre_end_idx) 87 | .map(cast_as_dataset) 88 | .collect::>(), 89 | ) 90 | } else { 91 | None 92 | } 93 | }, 94 | { 95 | end_idx.map(|post_start_idx| { 96 | prices 97 | .iter() 98 | .enumerate() 99 | .filter(|(idx, _)| *idx >= post_start_idx) 100 | .map(cast_as_dataset) 101 | .collect::>() 102 | }) 103 | }, 104 | ) 105 | } else { 106 | ( 107 | prices 108 | .iter() 109 | .enumerate() 110 | .map(cast_as_dataset) 111 | .collect::>(), 112 | None, 113 | None, 114 | ) 115 | } 116 | } else { 117 | (vec![], None, None) 118 | }; 119 | 120 | let prev_close_line = if state.time_frame == TimeFrame::Day1 121 | && self.loaded 122 | && !*HIDE_PREV_CLOSE 123 | && state.prev_close_price.is_some() 124 | { 125 | let num_points = (end - start) / 60 + 1; 126 | 127 | Some( 128 | (0..num_points) 129 | .map(|i| ((i + 1) as f64, state.prev_close_price.unwrap())) 130 | .collect::>(), 131 | ) 132 | } else { 133 | None 134 | }; 135 | 136 | let mut datasets = vec![Dataset::default() 137 | .marker(Marker::Braille) 138 | .style(style().fg( 139 | if trading_period != TradingPeriod::Regular && self.enable_pre_post { 140 | THEME.gray() 141 | } else if self.is_profit { 142 | THEME.profit() 143 | } else { 144 | THEME.loss() 145 | }, 146 | )) 147 | .graph_type(graph_type) 148 | .data(®_prices)]; 149 | 150 | if let Some(data) = post_prices.as_ref() { 151 | datasets.push( 152 | Dataset::default() 153 | .marker(Marker::Braille) 154 | .style(style().fg(if trading_period != TradingPeriod::Post { 155 | THEME.gray() 156 | } else if self.is_profit { 157 | THEME.profit() 158 | } else { 159 | THEME.loss() 160 | })) 161 | .graph_type(GraphType::Line) 162 | .data(data), 163 | ); 164 | } 165 | 166 | if let Some(data) = pre_prices.as_ref() { 167 | datasets.insert( 168 | 0, 169 | Dataset::default() 170 | .marker(Marker::Braille) 171 | .style(style().fg(if trading_period != TradingPeriod::Pre { 172 | THEME.gray() 173 | } else if self.is_profit { 174 | THEME.profit() 175 | } else { 176 | THEME.loss() 177 | })) 178 | .graph_type(GraphType::Line) 179 | .data(data), 180 | ); 181 | } 182 | 183 | if let Some(data) = prev_close_line.as_ref() { 184 | datasets.insert( 185 | 0, 186 | Dataset::default() 187 | .marker(Marker::Braille) 188 | .style(style().fg(THEME.gray())) 189 | .graph_type(GraphType::Line) 190 | .data(data), 191 | ); 192 | } 193 | 194 | let mut chart = Chart::new(datasets) 195 | .style(style()) 196 | .x_axis({ 197 | let axis = Axis::default().bounds(state.x_bounds(start, end, self.data)); 198 | 199 | if self.show_x_labels && self.loaded && !self.is_summary { 200 | axis.labels(x_labels).style(style().fg(THEME.border_axis())) 201 | } else { 202 | axis 203 | } 204 | }) 205 | .y_axis( 206 | Axis::default() 207 | .bounds(state.y_bounds(min, max)) 208 | .labels(state.y_labels(min, max)) 209 | .style(style().fg(THEME.border_axis())), 210 | ); 211 | 212 | if !self.is_summary { 213 | chart = chart.block( 214 | Block::default() 215 | .style(style().fg(THEME.border_secondary())) 216 | .borders(Borders::TOP) 217 | .border_style(style()), 218 | ); 219 | } 220 | 221 | chart.render(area, buf); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::time::Duration; 3 | use std::{io, panic, thread}; 4 | 5 | use api::model::CrumbData; 6 | use crossbeam_channel::{bounded, select, unbounded, Receiver, Sender}; 7 | use crossterm::event::{Event, MouseEvent, MouseEventKind}; 8 | use crossterm::{cursor, execute, terminal}; 9 | use lazy_static::lazy_static; 10 | use parking_lot::{Mutex, RwLock}; 11 | use ratatui::backend::CrosstermBackend; 12 | use ratatui::Terminal; 13 | use rclite::Arc; 14 | use service::default_timestamps::DefaultTimestampService; 15 | use tickrs_api as api; 16 | 17 | use crate::app::DebugInfo; 18 | use crate::common::{ChartType, TimeFrame}; 19 | 20 | mod app; 21 | mod common; 22 | mod draw; 23 | mod event; 24 | mod opts; 25 | mod portfolio; 26 | mod service; 27 | mod task; 28 | mod theme; 29 | mod widget; 30 | 31 | lazy_static! { 32 | static ref CLIENT: api::Client = api::Client::new(); 33 | static ref DEBUG_LEVEL: app::EnvConfig = app::EnvConfig::load(); 34 | pub static ref OPTS: opts::Opts = opts::resolve_opts(); 35 | pub static ref UPDATE_INTERVAL: u64 = OPTS.update_interval.unwrap_or(1); 36 | pub static ref TIME_FRAME: TimeFrame = OPTS.time_frame.unwrap_or(TimeFrame::Day1); 37 | pub static ref HIDE_TOGGLE: bool = OPTS.hide_toggle; 38 | pub static ref HIDE_PREV_CLOSE: bool = OPTS.hide_prev_close; 39 | pub static ref REDRAW_REQUEST: (Sender<()>, Receiver<()>) = bounded(1); 40 | pub static ref DATA_RECEIVED: (Sender<()>, Receiver<()>) = bounded(1); 41 | pub static ref SHOW_X_LABELS: RwLock = RwLock::new(OPTS.show_x_labels); 42 | pub static ref ENABLE_PRE_POST: RwLock = RwLock::new(OPTS.enable_pre_post); 43 | pub static ref TRUNC_PRE: bool = OPTS.trunc_pre; 44 | pub static ref SHOW_VOLUMES: RwLock = RwLock::new(OPTS.show_volumes); 45 | pub static ref DEFAULT_TIMESTAMPS: RwLock>> = Default::default(); 46 | pub static ref THEME: theme::Theme = OPTS.theme.unwrap_or_default(); 47 | pub static ref YAHOO_CRUMB: async_std::sync::RwLock> = Default::default(); 48 | } 49 | 50 | fn main() { 51 | better_panic::install(); 52 | 53 | let opts = OPTS.clone(); 54 | 55 | let backend = CrosstermBackend::new(io::stdout()); 56 | let mut terminal = Terminal::new(backend).unwrap(); 57 | 58 | setup_panic_hook(); 59 | setup_terminal(); 60 | set_crumb(); 61 | 62 | let request_redraw = REDRAW_REQUEST.0.clone(); 63 | let data_received = DATA_RECEIVED.1.clone(); 64 | let ui_events = setup_ui_events(); 65 | 66 | let starting_chart_type = opts.chart_type.unwrap_or(ChartType::Line); 67 | 68 | let starting_stocks: Vec<_> = opts 69 | .symbols 70 | .unwrap_or_default() 71 | .into_iter() 72 | .map(|symbol| widget::StockState::new(symbol, starting_chart_type)) 73 | .collect(); 74 | 75 | let starting_mode = if starting_stocks.is_empty() { 76 | app::Mode::AddStock 77 | } else if opts.summary { 78 | app::Mode::DisplaySummary 79 | } else { 80 | app::Mode::DisplayStock 81 | }; 82 | 83 | let default_timestamp_service = DefaultTimestampService::new(); 84 | 85 | let app = Arc::new(Mutex::new(app::App { 86 | mode: starting_mode, 87 | stocks: starting_stocks, 88 | add_stock: widget::AddStockState::new(), 89 | help: widget::HelpWidget {}, 90 | current_tab: 0, 91 | hide_help: opts.hide_help, 92 | debug: DebugInfo { 93 | enabled: DEBUG_LEVEL.show_debug, 94 | dimensions: (0, 0), 95 | cursor_location: None, 96 | last_event: None, 97 | mode: starting_mode, 98 | }, 99 | previous_mode: if opts.summary { 100 | app::Mode::DisplaySummary 101 | } else { 102 | app::Mode::DisplayStock 103 | }, 104 | summary_time_frame: opts.time_frame.unwrap_or(TimeFrame::Day1), 105 | default_timestamp_service, 106 | summary_scroll_state: Default::default(), 107 | chart_type: starting_chart_type, 108 | })); 109 | 110 | let move_app = app.clone(); 111 | 112 | // Redraw thread 113 | thread::spawn(move || { 114 | let app = move_app; 115 | 116 | let redraw_requested = REDRAW_REQUEST.1.clone(); 117 | 118 | loop { 119 | select! { 120 | recv(redraw_requested) -> _ => { 121 | let mut app = app.lock(); 122 | 123 | draw::draw(&mut terminal, &mut app); 124 | } 125 | // Default redraw on every duration 126 | default(Duration::from_millis(500)) => { 127 | let mut app = app.lock(); 128 | 129 | // Drive animation of loading icon 130 | for stock in app.stocks.iter_mut() { 131 | stock.loading_tick(); 132 | } 133 | 134 | draw::draw(&mut terminal, &mut app); 135 | } 136 | } 137 | } 138 | }); 139 | 140 | loop { 141 | select! { 142 | // Notified that new data has been fetched from API, update widgets 143 | // so they can update their state with this new information 144 | recv(data_received) -> _ => { 145 | let mut app = app.lock(); 146 | 147 | app.update(); 148 | 149 | for stock in app.stocks.iter_mut() { 150 | stock.update(); 151 | 152 | if let Some(options) = stock.options.as_mut() { 153 | options.update(); 154 | } 155 | } 156 | } 157 | recv(ui_events) -> message => { 158 | let mut app = app.lock(); 159 | 160 | if app.debug.enabled { 161 | if let Ok(ref event) = message { 162 | app.debug.last_event = Some(event.clone()); 163 | } 164 | } 165 | 166 | match message { 167 | Ok(Event::Key(key_event)) => { 168 | event::handle_key_bindings(app.mode, key_event, &mut app, &request_redraw); 169 | } 170 | Ok(Event::Mouse(MouseEvent { kind, row, column,.. })) => { 171 | if app.debug.enabled { 172 | match kind { 173 | MouseEventKind::Down(_) => app.debug.cursor_location = Some((row, column)), 174 | MouseEventKind::Up(_) => app.debug.cursor_location = Some((row, column)), 175 | MouseEventKind::Drag(_) => app.debug.cursor_location = Some((row, column)), 176 | _ => {} 177 | } 178 | } 179 | } 180 | Ok(Event::Resize(..)) => { 181 | let _ = request_redraw.try_send(()); 182 | } 183 | _ => {} 184 | } 185 | } 186 | } 187 | } 188 | } 189 | 190 | fn setup_terminal() { 191 | let mut stdout = io::stdout(); 192 | 193 | execute!(stdout, cursor::Hide).unwrap(); 194 | execute!(stdout, terminal::EnterAlternateScreen).unwrap(); 195 | 196 | execute!(stdout, terminal::Clear(terminal::ClearType::All)).unwrap(); 197 | 198 | if DEBUG_LEVEL.debug_mouse { 199 | execute!(stdout, crossterm::event::EnableMouseCapture).unwrap(); 200 | } 201 | 202 | terminal::enable_raw_mode().unwrap(); 203 | } 204 | 205 | fn cleanup_terminal() { 206 | let mut stdout = io::stdout(); 207 | 208 | if DEBUG_LEVEL.debug_mouse { 209 | execute!(stdout, crossterm::event::DisableMouseCapture).unwrap(); 210 | } 211 | 212 | execute!(stdout, cursor::MoveTo(0, 0)).unwrap(); 213 | execute!(stdout, terminal::Clear(terminal::ClearType::All)).unwrap(); 214 | 215 | execute!(stdout, terminal::LeaveAlternateScreen).unwrap(); 216 | execute!(stdout, cursor::Show).unwrap(); 217 | 218 | terminal::disable_raw_mode().unwrap(); 219 | } 220 | 221 | fn setup_ui_events() -> Receiver { 222 | let (sender, receiver) = unbounded(); 223 | std::thread::spawn(move || loop { 224 | sender.send(crossterm::event::read().unwrap()).unwrap(); 225 | }); 226 | 227 | receiver 228 | } 229 | 230 | fn setup_panic_hook() { 231 | panic::set_hook(Box::new(|panic_info| { 232 | cleanup_terminal(); 233 | better_panic::Settings::auto().create_panic_handler()(panic_info); 234 | })); 235 | } 236 | 237 | fn set_crumb() { 238 | async_std::task::spawn(async move { 239 | let mut _guard = YAHOO_CRUMB.write().await; 240 | 241 | if let Ok(crumb) = CLIENT.get_crumb().await { 242 | *_guard = Some(crumb); 243 | } 244 | }); 245 | } 246 | -------------------------------------------------------------------------------- /src/common.rs: -------------------------------------------------------------------------------- 1 | use std::hash::{Hash, Hasher}; 2 | use std::str::FromStr; 3 | use std::time::Duration; 4 | 5 | use chrono::{Local, TimeZone, Utc}; 6 | use itertools::izip; 7 | use serde::Deserialize; 8 | use tickrs_api::Interval; 9 | 10 | use crate::api::model::ChartData; 11 | use crate::api::Range; 12 | 13 | #[derive(PartialEq, Eq, Clone, Copy, Debug, Hash, Deserialize)] 14 | pub enum ChartType { 15 | #[serde(rename = "line")] 16 | Line, 17 | #[serde(rename = "candle")] 18 | Candlestick, 19 | #[serde(rename = "kagi")] 20 | Kagi, 21 | } 22 | 23 | impl ChartType { 24 | pub fn toggle(self) -> Self { 25 | match self { 26 | ChartType::Line => ChartType::Candlestick, 27 | ChartType::Candlestick => ChartType::Kagi, 28 | ChartType::Kagi => ChartType::Line, 29 | } 30 | } 31 | 32 | pub fn as_str(self) -> &'static str { 33 | match self { 34 | ChartType::Line => "Line", 35 | ChartType::Candlestick => "Candle", 36 | ChartType::Kagi => "Kagi", 37 | } 38 | } 39 | } 40 | 41 | impl FromStr for ChartType { 42 | type Err = &'static str; 43 | 44 | fn from_str(s: &str) -> Result { 45 | use ChartType::*; 46 | 47 | match s { 48 | "line" => Ok(Line), 49 | "candle" => Ok(Candlestick), 50 | "kagi" => Ok(Kagi), 51 | _ => Err("Valid chart types are: 'line', 'candle', 'kagi'"), 52 | } 53 | } 54 | } 55 | #[derive(Clone, Copy, PartialOrd, Debug, Hash, PartialEq, Eq, Deserialize, Ord)] 56 | pub enum TimeFrame { 57 | #[serde(alias = "1D")] 58 | Day1, 59 | #[serde(alias = "1W")] 60 | Week1, 61 | #[serde(alias = "1M")] 62 | Month1, 63 | #[serde(alias = "3M")] 64 | Month3, 65 | #[serde(alias = "6M")] 66 | Month6, 67 | #[serde(alias = "1Y")] 68 | Year1, 69 | #[serde(alias = "5Y")] 70 | Year5, 71 | } 72 | 73 | impl FromStr for TimeFrame { 74 | type Err = &'static str; 75 | 76 | fn from_str(s: &str) -> Result { 77 | use TimeFrame::*; 78 | 79 | match s { 80 | "1D" => Ok(Day1), 81 | "1W" => Ok(Week1), 82 | "1M" => Ok(Month1), 83 | "3M" => Ok(Month3), 84 | "6M" => Ok(Month6), 85 | "1Y" => Ok(Year1), 86 | "5Y" => Ok(Year5), 87 | _ => Err("Valid time frames are: '1D', '1W', '1M', '3M', '6M', '1Y', '5Y'"), 88 | } 89 | } 90 | } 91 | 92 | impl TimeFrame { 93 | pub fn idx(self) -> usize { 94 | match self { 95 | TimeFrame::Day1 => 0, 96 | TimeFrame::Week1 => 1, 97 | TimeFrame::Month1 => 2, 98 | TimeFrame::Month3 => 3, 99 | TimeFrame::Month6 => 4, 100 | TimeFrame::Year1 => 5, 101 | TimeFrame::Year5 => 6, 102 | } 103 | } 104 | 105 | pub const fn tab_names() -> [&'static str; 7] { 106 | ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y"] 107 | } 108 | 109 | pub const ALL: [TimeFrame; 7] = [ 110 | TimeFrame::Day1, 111 | TimeFrame::Week1, 112 | TimeFrame::Month1, 113 | TimeFrame::Month3, 114 | TimeFrame::Month6, 115 | TimeFrame::Year1, 116 | TimeFrame::Year5, 117 | ]; 118 | 119 | pub fn update_interval(self) -> Duration { 120 | match self { 121 | TimeFrame::Day1 => Duration::from_secs(60), 122 | TimeFrame::Week1 => Duration::from_secs(60 * 5), 123 | TimeFrame::Month1 => Duration::from_secs(60 * 30), 124 | TimeFrame::Month3 => Duration::from_secs(60 * 60), 125 | TimeFrame::Month6 => Duration::from_secs(60 * 60), 126 | TimeFrame::Year1 => Duration::from_secs(60 * 60 * 24), 127 | TimeFrame::Year5 => Duration::from_secs(60 * 60 * 24), 128 | } 129 | } 130 | 131 | pub fn up(self) -> TimeFrame { 132 | match self { 133 | TimeFrame::Day1 => TimeFrame::Week1, 134 | TimeFrame::Week1 => TimeFrame::Month1, 135 | TimeFrame::Month1 => TimeFrame::Month3, 136 | TimeFrame::Month3 => TimeFrame::Month6, 137 | TimeFrame::Month6 => TimeFrame::Year1, 138 | TimeFrame::Year1 => TimeFrame::Year5, 139 | TimeFrame::Year5 => TimeFrame::Day1, 140 | } 141 | } 142 | 143 | pub fn down(self) -> TimeFrame { 144 | match self { 145 | TimeFrame::Day1 => TimeFrame::Year5, 146 | TimeFrame::Week1 => TimeFrame::Day1, 147 | TimeFrame::Month1 => TimeFrame::Week1, 148 | TimeFrame::Month3 => TimeFrame::Month1, 149 | TimeFrame::Month6 => TimeFrame::Month3, 150 | TimeFrame::Year1 => TimeFrame::Month6, 151 | TimeFrame::Year5 => TimeFrame::Year1, 152 | } 153 | } 154 | 155 | pub fn as_range(self) -> Range { 156 | match self { 157 | TimeFrame::Day1 => Range::Day1, 158 | TimeFrame::Week1 => Range::Day5, 159 | TimeFrame::Month1 => Range::Month1, 160 | TimeFrame::Month3 => Range::Month3, 161 | TimeFrame::Month6 => Range::Month6, 162 | TimeFrame::Year1 => Range::Year1, 163 | TimeFrame::Year5 => Range::Year5, 164 | } 165 | } 166 | 167 | pub fn api_interval(self) -> Interval { 168 | match self { 169 | TimeFrame::Day1 => Interval::Minute1, 170 | TimeFrame::Week1 => Interval::Minute5, 171 | TimeFrame::Month1 => Interval::Minute30, 172 | TimeFrame::Month3 => Interval::Minute60, 173 | TimeFrame::Month6 => Interval::Minute60, 174 | _ => Interval::Day1, 175 | } 176 | } 177 | 178 | pub fn round_by(self) -> i64 { 179 | match self { 180 | TimeFrame::Day1 => 60, 181 | TimeFrame::Week1 => 60 * 5, 182 | TimeFrame::Month1 => 60 * 30, 183 | TimeFrame::Month3 => 60 * 60, 184 | TimeFrame::Month6 => 60 * 60, 185 | _ => 60 * 60 * 24, 186 | } 187 | } 188 | 189 | pub fn format_time(&self, timestamp: i64) -> String { 190 | let utc_date = Utc.timestamp_opt(timestamp, 0).unwrap(); 191 | let local_date = utc_date.with_timezone(&Local); 192 | 193 | let fmt = match self { 194 | TimeFrame::Day1 => "%H:%M", 195 | TimeFrame::Week1 => "%m-%d %H:%M", 196 | _ => "%F", 197 | }; 198 | 199 | local_date.format(fmt).to_string() 200 | } 201 | } 202 | 203 | #[derive(Debug, Clone, Copy)] 204 | pub struct MarketHours(pub i64, pub i64); 205 | 206 | impl Default for MarketHours { 207 | fn default() -> Self { 208 | MarketHours(52200, 75600) 209 | } 210 | } 211 | 212 | impl Iterator for MarketHours { 213 | type Item = i64; 214 | 215 | fn next(&mut self) -> Option { 216 | let min_rounded_0 = self.0 - self.0 % 60; 217 | let min_rounded_1 = self.1 - self.1 % 60; 218 | 219 | if min_rounded_0 == min_rounded_1 { 220 | None 221 | } else { 222 | let result = Some(min_rounded_0); 223 | self.0 = min_rounded_0 + 60; 224 | result 225 | } 226 | } 227 | } 228 | 229 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 230 | pub enum TradingPeriod { 231 | Pre, 232 | Regular, 233 | Post, 234 | } 235 | 236 | #[derive(Debug, Clone, Copy, Default)] 237 | pub struct Price { 238 | pub close: f64, 239 | pub volume: u64, 240 | pub high: f64, 241 | pub low: f64, 242 | pub open: f64, 243 | pub date: i64, 244 | } 245 | 246 | impl Hash for Price { 247 | fn hash(&self, state: &mut H) { 248 | self.close.to_bits().hash(state); 249 | self.volume.hash(state); 250 | self.high.to_bits().hash(state); 251 | self.low.to_bits().hash(state); 252 | self.open.to_bits().hash(state); 253 | self.date.hash(state); 254 | } 255 | } 256 | 257 | pub fn chart_data_to_prices(mut chart_data: ChartData) -> Vec { 258 | if chart_data.indicators.quote.len() != 1 { 259 | return vec![]; 260 | } 261 | 262 | let quote = chart_data.indicators.quote.remove(0); 263 | let timestamps = chart_data.timestamp; 264 | 265 | izip!( 266 | "e.close, 267 | "e.volume, 268 | "e.high, 269 | "e.low, 270 | "e.open, 271 | ×tamps, 272 | ) 273 | .map(|(c, v, h, l, o, t)| Price { 274 | close: *c, 275 | volume: *v, 276 | high: *h, 277 | low: *l, 278 | open: *o, 279 | date: *t, 280 | }) 281 | .collect() 282 | } 283 | 284 | pub fn cast_as_dataset(input: (usize, &f64)) -> (f64, f64) { 285 | ((input.0 + 1) as f64, *input.1) 286 | } 287 | 288 | pub fn cast_historical_as_price(input: &Price) -> f64 { 289 | input.close 290 | } 291 | 292 | pub fn zeros_as_pre(prices: &mut [f64]) { 293 | if prices.len() <= 1 { 294 | return; 295 | } 296 | 297 | let zero_indexes = prices 298 | .iter() 299 | .enumerate() 300 | .filter_map(|(idx, price)| if *price == 0.0 { Some(idx) } else { None }) 301 | .collect::>(); 302 | 303 | for idx in zero_indexes { 304 | if idx == 0 { 305 | prices[0] = prices[1]; 306 | } else { 307 | prices[idx] = prices[idx - 1]; 308 | } 309 | } 310 | } 311 | 312 | pub fn format_decimals(value: f64) -> String { 313 | let abs = value.abs(); 314 | 315 | if abs == 0.0 { 316 | "0".into() 317 | } else { 318 | let max_chars: usize = 8; 319 | 320 | // Max chars minus (chars to left of decial + decimal place) 321 | let n = max_chars.saturating_sub(abs.log10() as usize + 2); 322 | 323 | format!("{:.*}", n, value) 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/widget/chart/prices_candlestick.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use ratatui::buffer::Buffer; 3 | use ratatui::layout::{Constraint, Direction, Layout, Rect}; 4 | use ratatui::text::Span; 5 | use ratatui::widgets::canvas::{Canvas, Line, Rectangle}; 6 | use ratatui::widgets::{Block, Borders, StatefulWidget, Widget}; 7 | 8 | use crate::common::{Price, TimeFrame}; 9 | use crate::draw::{add_padding, PaddingDirection}; 10 | use crate::theme::style; 11 | use crate::widget::StockState; 12 | use crate::{HIDE_PREV_CLOSE, THEME}; 13 | 14 | #[derive(Debug)] 15 | struct Candle { 16 | open: f64, 17 | close: f64, 18 | high: f64, 19 | low: f64, 20 | } 21 | 22 | pub struct PricesCandlestickChart<'a> { 23 | pub loaded: bool, 24 | pub data: &'a [Price], 25 | pub is_summary: bool, 26 | pub show_x_labels: bool, 27 | } 28 | 29 | impl StatefulWidget for PricesCandlestickChart<'_> { 30 | type State = StockState; 31 | 32 | fn render(self, mut area: Rect, buf: &mut Buffer, state: &mut Self::State) { 33 | if area.width <= 9 || area.height <= 3 { 34 | return; 35 | } 36 | 37 | if !self.is_summary { 38 | Block::default() 39 | .borders(Borders::TOP) 40 | .border_style(style().fg(THEME.border_secondary())) 41 | .render(area, buf); 42 | area = add_padding(area, 1, PaddingDirection::Top); 43 | } 44 | 45 | let mut data = self.data.to_vec(); 46 | data.push(Price { 47 | close: state.current_price(), 48 | open: state.current_price(), 49 | high: state.current_price(), 50 | low: state.current_price(), 51 | ..Default::default() 52 | }); 53 | 54 | let (min, max) = state.min_max(&data); 55 | let (start, end) = state.start_end(); 56 | let x_bounds = state.x_bounds(start, end, &data); 57 | 58 | // x_layout[0] - chart + y labels 59 | // x_layout[1] - (x labels) 60 | let x_layout: Vec = Layout::default() 61 | .constraints(if self.show_x_labels { 62 | &[Constraint::Min(0), Constraint::Length(1)][..] 63 | } else { 64 | &[Constraint::Min(0)][..] 65 | }) 66 | .split(area) 67 | .to_vec(); 68 | 69 | // layout[0] - Y lables 70 | // layout[1] - chart 71 | let mut layout: Vec = Layout::default() 72 | .direction(Direction::Horizontal) 73 | .constraints([ 74 | Constraint::Length(if !self.loaded { 75 | 8 76 | } else if self.show_x_labels { 77 | match state.time_frame { 78 | TimeFrame::Day1 => 9, 79 | TimeFrame::Week1 => 12, 80 | _ => 11, 81 | } 82 | } else { 83 | 9 84 | }), 85 | Constraint::Min(0), 86 | ]) 87 | .split(x_layout[0]) 88 | .to_vec(); 89 | 90 | // Fix for border render 91 | layout[1].x = layout[1].x.saturating_sub(1); 92 | layout[1].width += 1; 93 | 94 | // Draw x labels 95 | if self.show_x_labels && self.loaded { 96 | // Fix for y label render 97 | layout[0] = add_padding(layout[0], 1, PaddingDirection::Bottom); 98 | 99 | let mut x_area = x_layout[1]; 100 | x_area.x = layout[1].x + 1; 101 | x_area.width = layout[1].width - 1; 102 | 103 | let labels = state.x_labels(area.width, start, end, self.data); 104 | let total_width = labels.iter().map(Span::width).sum::() as u16; 105 | let labels_len = labels.len() as u16; 106 | if total_width < x_area.width && labels_len > 1 { 107 | for (i, label) in labels.iter().enumerate() { 108 | buf.set_span( 109 | x_area.left() + i as u16 * (x_area.width - 1) / (labels_len - 1) 110 | - label.width() as u16, 111 | x_area.top(), 112 | label, 113 | label.width() as u16, 114 | ); 115 | } 116 | } 117 | } 118 | 119 | // Draw y labels 120 | if self.loaded { 121 | let y_area = layout[0]; 122 | 123 | let labels = state.y_labels(min, max); 124 | let labels_len = labels.len() as u16; 125 | for (i, label) in labels.iter().enumerate() { 126 | let dy = i as u16 * (y_area.height - 1) / (labels_len - 1); 127 | if dy < y_area.bottom() { 128 | buf.set_span( 129 | y_area.left(), 130 | y_area.bottom() - 1 - dy, 131 | label, 132 | label.width() as u16, 133 | ); 134 | } 135 | } 136 | } 137 | 138 | let width = layout[1].width - 1; 139 | let num_candles = width / 2; 140 | 141 | let candles = data 142 | .iter() 143 | .flat_map(|p| vec![*p; num_candles as usize]) 144 | .chunks(x_bounds[1] as usize) 145 | .into_iter() 146 | .map(|c| { 147 | let prices = c.filter(|p| p.close.gt(&0.0)).collect::>(); 148 | 149 | if prices.is_empty() { 150 | return None; 151 | } 152 | 153 | let open = prices.first().unwrap().open; 154 | let close = prices.iter().last().unwrap().close; 155 | let high = prices 156 | .iter() 157 | .max_by(|a, b| a.high.partial_cmp(&b.high).unwrap()) 158 | .unwrap() 159 | .high; 160 | let low = prices 161 | .iter() 162 | .min_by(|a, b| a.low.partial_cmp(&b.low).unwrap()) 163 | .unwrap() 164 | .low; 165 | 166 | Some(Candle { 167 | open, 168 | close, 169 | high, 170 | low, 171 | }) 172 | }) 173 | .collect::>(); 174 | 175 | if self.loaded { 176 | Canvas::default() 177 | .background_color(THEME.background()) 178 | .block( 179 | Block::default() 180 | .style(style()) 181 | .borders(if self.show_x_labels { 182 | Borders::LEFT | Borders::BOTTOM 183 | } else { 184 | Borders::LEFT 185 | }) 186 | .border_style(style().fg(THEME.border_axis())), 187 | ) 188 | .x_bounds([0.0, num_candles as f64 * 4.0]) 189 | .y_bounds(state.y_bounds(min, max)) 190 | .paint(move |ctx| { 191 | if state.time_frame == TimeFrame::Day1 192 | && self.loaded 193 | && !*HIDE_PREV_CLOSE 194 | && state.prev_close_price.is_some() 195 | { 196 | ctx.draw(&Line { 197 | x1: 0.0, 198 | x2: num_candles as f64 * 4.0, 199 | y1: state.prev_close_price.unwrap(), 200 | y2: state.prev_close_price.unwrap(), 201 | color: THEME.gray(), 202 | }) 203 | } 204 | 205 | ctx.layer(); 206 | 207 | for (idx, candle) in candles.iter().enumerate() { 208 | if let Some(candle) = candle { 209 | let color = if candle.close.gt(&candle.open) { 210 | THEME.profit() 211 | } else { 212 | THEME.loss() 213 | }; 214 | 215 | ctx.draw(&Rectangle { 216 | x: idx as f64 * 4.0 + 1.0, 217 | y: candle.open.min(candle.close), 218 | width: 2.0, 219 | height: candle.open.max(candle.close) 220 | - candle.open.min(candle.close), 221 | color, 222 | }); 223 | 224 | ctx.draw(&Line { 225 | x1: idx as f64 * 4.0 + 2.0, 226 | x2: idx as f64 * 4.0 + 2.0, 227 | y1: candle.low, 228 | y2: candle.open.min(candle.close), 229 | color, 230 | }); 231 | 232 | ctx.draw(&Line { 233 | x1: idx as f64 * 4.0 + 2.0, 234 | x2: idx as f64 * 4.0 + 2.0, 235 | y1: candle.high, 236 | y2: candle.open.max(candle.close), 237 | color, 238 | }); 239 | } 240 | } 241 | }) 242 | .render(layout[1], buf); 243 | } else { 244 | Block::default() 245 | .borders(if self.show_x_labels { 246 | Borders::LEFT | Borders::BOTTOM 247 | } else { 248 | Borders::LEFT 249 | }) 250 | .border_style(style().fg(THEME.border_axis())) 251 | .render(layout[1], buf); 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /api/src/client.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{bail, Context, Result}; 4 | use futures::AsyncReadExt; 5 | use http::{header, Request, Uri}; 6 | use isahc::{AsyncReadResponseExt, HttpClient}; 7 | use serde::de::DeserializeOwned; 8 | 9 | use crate::model::{Chart, ChartData, Company, CompanyData, CrumbData, Options, OptionsHeader}; 10 | use crate::{Interval, Range}; 11 | 12 | #[derive(Debug)] 13 | pub struct Client { 14 | client: HttpClient, 15 | base: String, 16 | } 17 | 18 | impl Client { 19 | pub fn new() -> Self { 20 | Client::default() 21 | } 22 | 23 | fn get_url( 24 | &self, 25 | version: Version, 26 | path: &str, 27 | params: Option>, 28 | ) -> Result { 29 | if let Some(params) = params { 30 | let params = serde_urlencoded::to_string(params).unwrap_or_else(|_| String::from("")); 31 | let uri = format!("{}/{}/{}?{}", self.base, version.as_str(), path, params); 32 | Ok(uri.parse::()?) 33 | } else { 34 | let uri = format!("{}/{}/{}", self.base, version.as_str(), path); 35 | Ok(uri.parse::()?) 36 | } 37 | } 38 | 39 | async fn get(&self, url: Uri, cookie: Option) -> Result { 40 | let mut req = Request::builder() 41 | .method(http::Method::GET) 42 | .uri(url) 43 | .header(header::USER_AGENT, "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"); 44 | 45 | if let Some(cookie) = cookie { 46 | req = req.header(header::COOKIE, cookie); 47 | } 48 | 49 | let res = self 50 | .client 51 | .send_async(req.body(())?) 52 | .await 53 | .context("Failed to get request")?; 54 | 55 | let mut body = res.into_body(); 56 | let mut bytes = Vec::new(); 57 | body.read_to_end(&mut bytes).await?; 58 | 59 | let response = serde_json::from_slice(&bytes)?; 60 | 61 | Ok(response) 62 | } 63 | 64 | pub async fn get_chart_data( 65 | &self, 66 | symbol: &str, 67 | interval: Interval, 68 | range: Range, 69 | include_pre_post: bool, 70 | ) -> Result { 71 | let mut params = HashMap::new(); 72 | params.insert("interval", format!("{}", interval)); 73 | params.insert("range", format!("{}", range)); 74 | 75 | if include_pre_post { 76 | params.insert("includePrePost", format!("{}", true)); 77 | } 78 | 79 | let url = self.get_url( 80 | Version::V8, 81 | &format!("finance/chart/{}", symbol), 82 | Some(params), 83 | )?; 84 | 85 | let response: Chart = self.get(url, None).await?; 86 | 87 | if let Some(err) = response.chart.error { 88 | bail!( 89 | "Error getting chart data for {}: {}", 90 | symbol, 91 | err.description 92 | ); 93 | } 94 | 95 | if let Some(mut result) = response.chart.result { 96 | if result.len() == 1 { 97 | return Ok(result.remove(0)); 98 | } 99 | } 100 | 101 | bail!("Failed to get chart data for {}", symbol); 102 | } 103 | 104 | pub async fn get_company_data( 105 | &self, 106 | symbol: &str, 107 | crumb_data: CrumbData, 108 | ) -> Result { 109 | let mut params = HashMap::new(); 110 | params.insert("modules", "price,assetProfile".to_string()); 111 | params.insert("crumb", crumb_data.crumb); 112 | 113 | let url = self.get_url( 114 | Version::V10, 115 | &format!("finance/quoteSummary/{}", symbol), 116 | Some(params), 117 | )?; 118 | 119 | let response: Company = self.get(url, Some(crumb_data.cookie)).await?; 120 | 121 | if let Some(err) = response.company.error { 122 | bail!( 123 | "Error getting company data for {}: {}", 124 | symbol, 125 | err.description 126 | ); 127 | } 128 | 129 | if let Some(mut result) = response.company.result { 130 | if result.len() == 1 { 131 | return Ok(result.remove(0)); 132 | } 133 | } 134 | 135 | bail!("Failed to get company data for {}", symbol); 136 | } 137 | 138 | pub async fn get_options_expiration_dates(&self, symbol: &str) -> Result> { 139 | let url = self.get_url(Version::V7, &format!("finance/options/{}", symbol), None)?; 140 | 141 | let response: Options = self.get(url, None).await?; 142 | 143 | if let Some(err) = response.option_chain.error { 144 | bail!( 145 | "Error getting options data for {}: {}", 146 | symbol, 147 | err.description 148 | ); 149 | } 150 | 151 | if let Some(mut result) = response.option_chain.result { 152 | if result.len() == 1 { 153 | let options_header = result.remove(0); 154 | return Ok(options_header.expiration_dates); 155 | } 156 | } 157 | 158 | bail!("Failed to get options data for {}", symbol); 159 | } 160 | 161 | pub async fn get_options_for_expiration_date( 162 | &self, 163 | symbol: &str, 164 | expiration_date: i64, 165 | ) -> Result { 166 | let mut params = HashMap::new(); 167 | params.insert("date", format!("{}", expiration_date)); 168 | 169 | let url = self.get_url( 170 | Version::V7, 171 | &format!("finance/options/{}", symbol), 172 | Some(params), 173 | )?; 174 | 175 | let response: Options = self.get(url, None).await?; 176 | 177 | if let Some(err) = response.option_chain.error { 178 | bail!( 179 | "Error getting options data for {}: {}", 180 | symbol, 181 | err.description 182 | ); 183 | } 184 | 185 | if let Some(mut result) = response.option_chain.result { 186 | if result.len() == 1 { 187 | let options_header = result.remove(0); 188 | 189 | return Ok(options_header); 190 | } 191 | } 192 | 193 | bail!("Failed to get options data for {}", symbol); 194 | } 195 | 196 | pub async fn get_crumb(&self) -> Result { 197 | let res = self 198 | .client 199 | .get_async("https://fc.yahoo.com") 200 | .await 201 | .context("Failed to get request")?; 202 | 203 | let Some(cookie) = res 204 | .headers() 205 | .get(header::SET_COOKIE) 206 | .and_then(|header| header.to_str().ok()) 207 | .and_then(|s| s.split_once(';').map(|(value, _)| value)) 208 | else { 209 | bail!("Couldn't fetch cookie"); 210 | }; 211 | 212 | let request = Request::builder() 213 | .uri(self.get_url(Version::V1, "test/getcrumb", None)?) 214 | .header(header::USER_AGENT, "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36") 215 | .header(header::COOKIE, cookie) 216 | .method(http::Method::GET) 217 | .body(())?; 218 | let mut res = self.client.send_async(request).await?; 219 | 220 | let crumb = res.text().await?; 221 | 222 | Ok(CrumbData { 223 | cookie: cookie.to_string(), 224 | crumb, 225 | }) 226 | } 227 | } 228 | 229 | impl Default for Client { 230 | fn default() -> Client { 231 | #[allow(unused_mut)] 232 | let mut builder = HttpClient::builder(); 233 | 234 | #[cfg(target_os = "android")] 235 | { 236 | use isahc::config::{Configurable, SslOption}; 237 | 238 | builder = builder.ssl_options(SslOption::DANGER_ACCEPT_INVALID_CERTS); 239 | } 240 | 241 | let client = builder.build().unwrap(); 242 | 243 | let base = String::from("https://query1.finance.yahoo.com"); 244 | 245 | Client { client, base } 246 | } 247 | } 248 | 249 | #[derive(Debug, Clone)] 250 | pub enum Version { 251 | V1, 252 | V7, 253 | V8, 254 | V10, 255 | } 256 | 257 | impl Version { 258 | fn as_str(&self) -> &'static str { 259 | match self { 260 | Version::V1 => "v1", 261 | Version::V7 => "v7", 262 | Version::V8 => "v8", 263 | Version::V10 => "v10", 264 | } 265 | } 266 | } 267 | 268 | #[cfg(test)] 269 | mod tests { 270 | use super::*; 271 | 272 | #[async_std::test] 273 | async fn test_company_data() { 274 | let client = Client::new(); 275 | 276 | let symbols = vec!["SPY", "AAPL", "AMD", "TSLA", "ES=F", "BTC-USD", "DX-Y.NYB"]; 277 | 278 | let crumb = client.get_crumb().await.unwrap(); 279 | 280 | for symbol in symbols { 281 | let data = client.get_company_data(symbol, crumb.clone()).await; 282 | 283 | if let Err(e) = data { 284 | println!("{}", e); 285 | 286 | panic!(); 287 | } 288 | } 289 | } 290 | 291 | #[async_std::test] 292 | async fn test_options_data() { 293 | let client = Client::new(); 294 | 295 | let symbol = "SPY"; 296 | 297 | let exp_dates = client.get_options_expiration_dates(symbol).await; 298 | 299 | match exp_dates { 300 | Err(e) => { 301 | println!("{}", e); 302 | 303 | panic!(); 304 | } 305 | Ok(dates) => { 306 | for date in dates { 307 | let options = client.get_options_for_expiration_date(symbol, date).await; 308 | 309 | if let Err(e) = options { 310 | println!("{}", e); 311 | 312 | panic!(); 313 | } 314 | } 315 | } 316 | } 317 | } 318 | 319 | #[async_std::test] 320 | async fn test_chart_data() { 321 | let client = Client::new(); 322 | 323 | let combinations = [ 324 | (Range::Year5, Interval::Minute1), 325 | (Range::Day1, Interval::Minute1), 326 | (Range::Day5, Interval::Minute5), 327 | (Range::Month1, Interval::Minute30), 328 | (Range::Month3, Interval::Minute60), 329 | (Range::Month6, Interval::Minute60), 330 | (Range::Year1, Interval::Day1), 331 | (Range::Year5, Interval::Day1), 332 | ]; 333 | 334 | let ticker = "SPY"; 335 | 336 | for (idx, (range, interval)) in combinations.iter().enumerate() { 337 | let data = client.get_chart_data(ticker, *interval, *range, true).await; 338 | 339 | if let Err(e) = data { 340 | println!("{}", e); 341 | 342 | if idx > 0 { 343 | panic!(); 344 | } 345 | } else if idx == 0 { 346 | panic!(); 347 | } 348 | } 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | use app::ScrollDirection; 2 | use crossbeam_channel::Sender; 3 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 4 | 5 | use crate::app::{self, Mode}; 6 | use crate::common::ChartType; 7 | use crate::widget::options; 8 | use crate::{cleanup_terminal, ENABLE_PRE_POST, SHOW_VOLUMES, SHOW_X_LABELS}; 9 | 10 | fn handle_keys_add_stock(keycode: KeyCode, app: &mut app::App) { 11 | match keycode { 12 | KeyCode::Enter => { 13 | let mut stock = app.add_stock.enter(app.chart_type); 14 | 15 | if app.previous_mode == app::Mode::DisplaySummary { 16 | stock.set_time_frame(app.summary_time_frame); 17 | } 18 | 19 | app.stocks.push(stock); 20 | app.current_tab = app.stocks.len() - 1; 21 | 22 | app.add_stock.reset(); 23 | app.mode = app.previous_mode; 24 | } 25 | KeyCode::Char(c) => { 26 | app.add_stock.add_char(c); 27 | } 28 | KeyCode::Backspace => { 29 | app.add_stock.del_char(); 30 | } 31 | KeyCode::Esc => { 32 | app.add_stock.reset(); 33 | if !app.stocks.is_empty() { 34 | app.mode = app.previous_mode; 35 | } 36 | } 37 | _ => {} 38 | } 39 | } 40 | 41 | fn handle_keys_display_stock(keycode: KeyCode, modifiers: KeyModifiers, app: &mut app::App) { 42 | match (keycode, modifiers) { 43 | (KeyCode::Left, KeyModifiers::CONTROL) => { 44 | let new_idx = if app.current_tab == 0 { 45 | app.stocks.len() - 1 46 | } else { 47 | app.current_tab - 1 48 | }; 49 | app.stocks.swap(app.current_tab, new_idx); 50 | app.current_tab = new_idx; 51 | } 52 | (KeyCode::Right, KeyModifiers::CONTROL) => { 53 | let new_idx = (app.current_tab + 1) % app.stocks.len(); 54 | app.stocks.swap(app.current_tab, new_idx); 55 | app.current_tab = new_idx; 56 | } 57 | (KeyCode::BackTab, KeyModifiers::SHIFT) => { 58 | if app.current_tab == 0 { 59 | app.current_tab = app.stocks.len() - 1; 60 | } else { 61 | app.current_tab -= 1; 62 | } 63 | } 64 | (KeyCode::Left, KeyModifiers::NONE) => { 65 | app.stocks[app.current_tab].time_frame_down(); 66 | } 67 | (KeyCode::Right, KeyModifiers::NONE) => { 68 | app.stocks[app.current_tab].time_frame_up(); 69 | } 70 | (KeyCode::Char('/'), KeyModifiers::NONE) => { 71 | app.previous_mode = app.mode; 72 | app.mode = app::Mode::AddStock; 73 | } 74 | (KeyCode::Char('k'), KeyModifiers::NONE) => { 75 | app.stocks.remove(app.current_tab); 76 | 77 | if app.current_tab != 0 { 78 | app.current_tab -= 1; 79 | } 80 | 81 | if app.stocks.is_empty() { 82 | app.previous_mode = app.mode; 83 | app.mode = app::Mode::AddStock; 84 | } 85 | } 86 | (KeyCode::Char('s'), KeyModifiers::NONE) => { 87 | app.mode = app::Mode::DisplaySummary; 88 | 89 | for stock in app.stocks.iter_mut() { 90 | if stock.time_frame != app.summary_time_frame { 91 | stock.set_time_frame(app.summary_time_frame); 92 | } 93 | } 94 | } 95 | (KeyCode::Char('o'), KeyModifiers::NONE) => { 96 | if app.stocks[app.current_tab].toggle_options() { 97 | app.mode = app::Mode::DisplayOptions; 98 | } 99 | } 100 | (KeyCode::Char('e'), KeyModifiers::NONE) => { 101 | if app.stocks[app.current_tab].toggle_configure() { 102 | app.mode = app::Mode::ConfigureChart; 103 | } 104 | } 105 | (KeyCode::Tab, KeyModifiers::NONE) => { 106 | if app.current_tab == app.stocks.len() - 1 { 107 | app.current_tab = 0; 108 | } else { 109 | app.current_tab += 1; 110 | } 111 | } 112 | _ => {} 113 | } 114 | } 115 | 116 | fn handle_keys_display_summary(keycode: KeyCode, app: &mut app::App) { 117 | match keycode { 118 | KeyCode::Left => { 119 | app.summary_time_frame = app.summary_time_frame.down(); 120 | 121 | for stock in app.stocks.iter_mut() { 122 | stock.set_time_frame(app.summary_time_frame); 123 | } 124 | } 125 | KeyCode::Right => { 126 | app.summary_time_frame = app.summary_time_frame.up(); 127 | 128 | for stock in app.stocks.iter_mut() { 129 | stock.set_time_frame(app.summary_time_frame); 130 | } 131 | } 132 | KeyCode::Up => { 133 | app.summary_scroll_state.queued_scroll = Some(ScrollDirection::Up); 134 | } 135 | KeyCode::Down => { 136 | app.summary_scroll_state.queued_scroll = Some(ScrollDirection::Down); 137 | } 138 | KeyCode::Char('s') => { 139 | app.mode = app::Mode::DisplayStock; 140 | } 141 | KeyCode::Char('/') => { 142 | app.previous_mode = app.mode; 143 | app.mode = app::Mode::AddStock; 144 | } 145 | _ => {} 146 | } 147 | } 148 | 149 | fn handle_keys_display_options(keycode: KeyCode, app: &mut app::App) { 150 | match keycode { 151 | KeyCode::Esc | KeyCode::Char('o') | KeyCode::Char('q') => { 152 | app.stocks[app.current_tab].toggle_options(); 153 | app.mode = app::Mode::DisplayStock; 154 | } 155 | KeyCode::Tab => { 156 | app.stocks[app.current_tab] 157 | .options 158 | .as_mut() 159 | .unwrap() 160 | .toggle_option_type(); 161 | } 162 | KeyCode::Up => { 163 | match app.stocks[app.current_tab] 164 | .options 165 | .as_mut() 166 | .unwrap() 167 | .selection_mode 168 | { 169 | options::SelectionMode::Dates => { 170 | app.stocks[app.current_tab] 171 | .options 172 | .as_mut() 173 | .unwrap() 174 | .previous_date(); 175 | } 176 | options::SelectionMode::Options => { 177 | app.stocks[app.current_tab] 178 | .options 179 | .as_mut() 180 | .unwrap() 181 | .previous_option(); 182 | } 183 | } 184 | } 185 | KeyCode::Down => { 186 | match app.stocks[app.current_tab] 187 | .options 188 | .as_mut() 189 | .unwrap() 190 | .selection_mode 191 | { 192 | options::SelectionMode::Dates => { 193 | app.stocks[app.current_tab] 194 | .options 195 | .as_mut() 196 | .unwrap() 197 | .next_date(); 198 | } 199 | options::SelectionMode::Options => { 200 | app.stocks[app.current_tab] 201 | .options 202 | .as_mut() 203 | .unwrap() 204 | .next_option(); 205 | } 206 | } 207 | } 208 | KeyCode::Left => { 209 | app.stocks[app.current_tab] 210 | .options 211 | .as_mut() 212 | .unwrap() 213 | .selection_mode_left(); 214 | } 215 | KeyCode::Right => { 216 | if app.stocks[app.current_tab] 217 | .options 218 | .as_mut() 219 | .unwrap() 220 | .data() 221 | .is_some() 222 | { 223 | app.stocks[app.current_tab] 224 | .options 225 | .as_mut() 226 | .unwrap() 227 | .selection_mode_right(); 228 | } 229 | } 230 | _ => {} 231 | } 232 | } 233 | 234 | pub fn handle_keys_configure_chart(keycode: KeyCode, modifiers: KeyModifiers, app: &mut app::App) { 235 | match (keycode, modifiers) { 236 | (KeyCode::Esc | KeyCode::Char('e') | KeyCode::Char('q'), _) => { 237 | app.stocks[app.current_tab].toggle_configure(); 238 | app.mode = app::Mode::DisplayStock; 239 | } 240 | (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::BackTab, KeyModifiers::SHIFT) => { 241 | let config = app.stocks[app.current_tab].chart_config_mut(); 242 | config.selection_up(); 243 | } 244 | (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Tab, KeyModifiers::NONE) => { 245 | let config = app.stocks[app.current_tab].chart_config_mut(); 246 | config.selection_down(); 247 | } 248 | (KeyCode::Left, KeyModifiers::NONE) => { 249 | let config = app.stocks[app.current_tab].chart_config_mut(); 250 | config.back_tab(); 251 | } 252 | (KeyCode::Right, KeyModifiers::NONE) => { 253 | let config = app.stocks[app.current_tab].chart_config_mut(); 254 | config.tab(); 255 | } 256 | (KeyCode::Enter, KeyModifiers::NONE) => { 257 | let time_frame = app.stocks[app.current_tab].time_frame; 258 | let config = app.stocks[app.current_tab].chart_config_mut(); 259 | config.enter(time_frame); 260 | } 261 | (KeyCode::Char(c), KeyModifiers::NONE) => { 262 | if c.is_numeric() || c == '.' { 263 | let config = app.stocks[app.current_tab].chart_config_mut(); 264 | config.add_char(c); 265 | } 266 | } 267 | (KeyCode::Backspace, KeyModifiers::NONE) => { 268 | let config = app.stocks[app.current_tab].chart_config_mut(); 269 | config.del_char(); 270 | } 271 | _ => {} 272 | } 273 | } 274 | 275 | pub fn handle_key_bindings( 276 | mode: Mode, 277 | key_event: KeyEvent, 278 | app: &mut app::App, 279 | request_redraw: &Sender<()>, 280 | ) { 281 | match (mode, key_event.modifiers, key_event.code) { 282 | (_, KeyModifiers::CONTROL, KeyCode::Char('c')) => { 283 | cleanup_terminal(); 284 | std::process::exit(0); 285 | } 286 | (Mode::AddStock, modifiers, keycode) => { 287 | if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT { 288 | handle_keys_add_stock(keycode, app) 289 | } 290 | } 291 | (Mode::Help, modifiers, keycode) => { 292 | if modifiers.is_empty() 293 | && (matches!( 294 | keycode, 295 | KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') 296 | )) 297 | { 298 | app.mode = app.previous_mode; 299 | } 300 | } 301 | (mode, KeyModifiers::NONE, KeyCode::Char('q')) 302 | if !matches!(mode, Mode::DisplayOptions | Mode::ConfigureChart) => 303 | { 304 | cleanup_terminal(); 305 | std::process::exit(0); 306 | } 307 | (_, KeyModifiers::NONE, KeyCode::Char('?')) => { 308 | app.previous_mode = app.mode; 309 | app.mode = app::Mode::Help; 310 | } 311 | (_, KeyModifiers::NONE, KeyCode::Char('c')) if mode != Mode::ConfigureChart => { 312 | app.chart_type = app.chart_type.toggle(); 313 | 314 | for stock in app.stocks.iter_mut() { 315 | stock.set_chart_type(app.chart_type); 316 | } 317 | } 318 | (_, KeyModifiers::NONE, KeyCode::Char('v')) => { 319 | if app.chart_type != ChartType::Kagi { 320 | let mut show_volumes = SHOW_VOLUMES.write(); 321 | *show_volumes = !*show_volumes; 322 | } 323 | } 324 | (_, KeyModifiers::NONE, KeyCode::Char('p')) => { 325 | let mut guard = ENABLE_PRE_POST.write(); 326 | *guard = !*guard; 327 | } 328 | (Mode::DisplaySummary, modifiers, keycode) => { 329 | if modifiers.is_empty() { 330 | handle_keys_display_summary(keycode, app) 331 | } 332 | } 333 | (_, KeyModifiers::NONE, KeyCode::Char('x')) => { 334 | let mut show_x_labels = SHOW_X_LABELS.write(); 335 | *show_x_labels = !*show_x_labels; 336 | } 337 | (_, KeyModifiers::SHIFT, KeyCode::Left) | (_, KeyModifiers::NONE, KeyCode::Char('<')) => { 338 | if let Some(stock) = app.stocks.get_mut(app.current_tab) { 339 | if let Some(chart_state) = stock.chart_state_mut() { 340 | chart_state.scroll_left(); 341 | } 342 | } 343 | } 344 | (_, KeyModifiers::SHIFT, KeyCode::Right) | (_, KeyModifiers::NONE, KeyCode::Char('>')) => { 345 | if let Some(stock) = app.stocks.get_mut(app.current_tab) { 346 | if let Some(chart_state) = stock.chart_state_mut() { 347 | chart_state.scroll_right(); 348 | } 349 | } 350 | } 351 | (Mode::DisplayOptions, modifiers, keycode) => { 352 | if modifiers.is_empty() { 353 | handle_keys_display_options(keycode, app) 354 | } 355 | } 356 | (Mode::ConfigureChart, modifiers, keycode) => { 357 | handle_keys_configure_chart(keycode, modifiers, app) 358 | } 359 | (Mode::DisplayStock, modifiers, keycode) => { 360 | handle_keys_display_stock(keycode, modifiers, app) 361 | } 362 | } 363 | let _ = request_redraw.try_send(()); 364 | } 365 | -------------------------------------------------------------------------------- /src/draw.rs: -------------------------------------------------------------------------------- 1 | use ratatui::backend::Backend; 2 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 3 | use ratatui::text::{Line, Span}; 4 | use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap}; 5 | use ratatui::{Frame, Terminal}; 6 | 7 | use crate::app::{App, Mode, ScrollDirection}; 8 | use crate::common::{ChartType, TimeFrame}; 9 | use crate::service::Service; 10 | use crate::theme::style; 11 | use crate::widget::{ 12 | block, AddStockWidget, ChartConfigurationWidget, OptionsWidget, StockSummaryWidget, 13 | StockWidget, HELP_HEIGHT, HELP_WIDTH, 14 | }; 15 | use crate::{SHOW_VOLUMES, THEME}; 16 | 17 | pub fn draw(terminal: &mut Terminal, app: &mut App) { 18 | let current_size = terminal.size().unwrap_or_default(); 19 | 20 | if current_size.width <= 10 || current_size.height <= 10 { 21 | return; 22 | } 23 | 24 | if app.debug.enabled { 25 | app.debug.dimensions = (current_size.width, current_size.height); 26 | } 27 | 28 | terminal 29 | .draw(|frame| { 30 | // Set background color 31 | frame.render_widget(Block::default().style(style()), frame.size()); 32 | 33 | if app.debug.enabled && app.mode == Mode::AddStock { 34 | // layout[0] - Main window 35 | // layout[1] - Add Stock window 36 | // layout[2] - Debug window 37 | let layout = Layout::default() 38 | .constraints([ 39 | Constraint::Min(0), 40 | Constraint::Length(3), 41 | Constraint::Length(5), 42 | ]) 43 | .split(frame.size()); 44 | 45 | if !app.stocks.is_empty() { 46 | match app.previous_mode { 47 | Mode::DisplaySummary => draw_summary(frame, app, layout[0]), 48 | _ => draw_main(frame, app, layout[0]), 49 | } 50 | } 51 | 52 | draw_add_stock(frame, app, layout[1]); 53 | draw_debug(frame, app, layout[2]); 54 | } else if app.debug.enabled { 55 | // layout[0] - Main window 56 | // layout[1] - Debug window 57 | let layout = Layout::default() 58 | .constraints([Constraint::Min(0), Constraint::Length(5)]) 59 | .split(frame.size()); 60 | 61 | match app.mode { 62 | Mode::DisplaySummary => draw_summary(frame, app, layout[0]), 63 | Mode::Help => draw_help(frame, app, layout[0]), 64 | _ => draw_main(frame, app, layout[0]), 65 | } 66 | 67 | draw_debug(frame, app, layout[1]); 68 | } else if app.mode == Mode::AddStock { 69 | // layout[0] - Main window 70 | // layout[1] - Add Stock window 71 | let layout = Layout::default() 72 | .constraints([Constraint::Min(0), Constraint::Length(3)]) 73 | .split(frame.size()); 74 | 75 | if !app.stocks.is_empty() { 76 | match app.previous_mode { 77 | Mode::DisplaySummary => draw_summary(frame, app, layout[0]), 78 | _ => draw_main(frame, app, layout[0]), 79 | } 80 | } 81 | 82 | draw_add_stock(frame, app, layout[1]); 83 | } else { 84 | // layout - Main window 85 | let layout = frame.size(); 86 | 87 | match app.mode { 88 | Mode::DisplaySummary => draw_summary(frame, app, layout), 89 | Mode::Help => draw_help(frame, app, layout), 90 | _ => draw_main(frame, app, layout), 91 | } 92 | }; 93 | }) 94 | .unwrap(); 95 | } 96 | 97 | fn draw_main(frame: &mut Frame, app: &mut App, area: Rect) { 98 | // layout[0] - Header 99 | // layout[1] - Main widget 100 | let mut layout = Layout::default() 101 | .constraints([Constraint::Length(3), Constraint::Min(0)]) 102 | .split(area) 103 | .to_vec(); 104 | 105 | if !app.stocks.is_empty() { 106 | frame.render_widget(crate::widget::block::new(" Tabs "), layout[0]); 107 | let padded = add_padding(layout[0], 1, PaddingDirection::All); 108 | layout[0] = padded; 109 | 110 | // header[0] - Stock symbol tabs 111 | // header[1] - (Optional) help icon 112 | let header = if app.hide_help { 113 | vec![layout[0]] 114 | } else { 115 | let split = Layout::default() 116 | .direction(Direction::Horizontal) 117 | .constraints([Constraint::Min(0), Constraint::Length(10)]) 118 | .split(layout[0]); 119 | split.to_vec() 120 | }; 121 | 122 | // Draw tabs 123 | { 124 | let tabs: Vec<_> = app.stocks.iter().map(|w| Line::from(w.symbol())).collect(); 125 | 126 | frame.render_widget( 127 | Tabs::new(tabs) 128 | .select(app.current_tab) 129 | .style(style().fg(THEME.text_secondary())) 130 | .highlight_style(style().fg(THEME.text_primary())), 131 | header[0], 132 | ); 133 | } 134 | 135 | // Draw help icon 136 | if !app.hide_help { 137 | frame.render_widget( 138 | Paragraph::new(Line::from(Span::styled("Help '?'", style()))) 139 | .style(style().fg(THEME.text_normal())) 140 | .alignment(Alignment::Center), 141 | header[1], 142 | ); 143 | } 144 | } 145 | 146 | // Make sure only displayed stock has network activity 147 | app.stocks.iter().enumerate().for_each(|(idx, s)| { 148 | if idx == app.current_tab { 149 | s.stock_service.resume(); 150 | } else { 151 | s.stock_service.pause(); 152 | } 153 | }); 154 | 155 | // Draw main widget 156 | if let Some(stock) = app.stocks.get_mut(app.current_tab) { 157 | // main_chunks[0] - Stock widget 158 | // main_chunks[1] - Options widget / Configuration widget (optional) 159 | let mut main_chunks = 160 | if app.mode == Mode::DisplayOptions || app.mode == Mode::ConfigureChart { 161 | Layout::default() 162 | .direction(Direction::Horizontal) 163 | .constraints([Constraint::Min(0), Constraint::Length(44)]) 164 | .split(layout[1]) 165 | .to_vec() 166 | } else { 167 | vec![layout[1]] 168 | }; 169 | 170 | match app.mode { 171 | Mode::DisplayStock | Mode::AddStock => { 172 | frame.render_stateful_widget(StockWidget {}, main_chunks[0], stock); 173 | } 174 | // If width is too small, don't render stock widget and use entire space 175 | // for options / configure widget 176 | Mode::DisplayOptions | Mode::ConfigureChart => { 177 | if main_chunks[0].width >= 19 { 178 | frame.render_stateful_widget(StockWidget {}, main_chunks[0], stock); 179 | } else { 180 | main_chunks[1] = layout[1]; 181 | } 182 | } 183 | _ => {} 184 | } 185 | 186 | match app.mode { 187 | Mode::DisplayOptions => { 188 | if let Some(options) = stock.options.as_mut() { 189 | if main_chunks[1].width >= 44 && main_chunks[1].height >= 14 { 190 | frame.render_stateful_widget(OptionsWidget {}, main_chunks[1], options); 191 | } else { 192 | let mut padded = main_chunks[1]; 193 | padded = add_padding(padded, 1, PaddingDirection::Left); 194 | padded = add_padding(padded, 1, PaddingDirection::Top); 195 | main_chunks[1] = padded; 196 | 197 | frame.render_widget( 198 | Paragraph::new(Line::from(Span::styled( 199 | "Increase screen size to display options", 200 | style(), 201 | ))), 202 | main_chunks[1], 203 | ); 204 | } 205 | } 206 | } 207 | Mode::ConfigureChart => { 208 | if main_chunks[1].width >= 44 && main_chunks[1].height >= 14 { 209 | let state = &mut stock.chart_configuration; 210 | 211 | let chart_type = stock.chart_type; 212 | 213 | frame.render_stateful_widget( 214 | ChartConfigurationWidget { chart_type }, 215 | main_chunks[1], 216 | state, 217 | ); 218 | } else { 219 | let mut padded = main_chunks[1]; 220 | padded = add_padding(padded, 1, PaddingDirection::Left); 221 | padded = add_padding(padded, 1, PaddingDirection::Top); 222 | main_chunks[1] = padded; 223 | 224 | frame.render_widget( 225 | Paragraph::new(Line::from(Span::styled( 226 | "Increase screen size to display configuration screen", 227 | style(), 228 | ))) 229 | .wrap(Wrap { trim: false }), 230 | main_chunks[1], 231 | ); 232 | } 233 | } 234 | _ => {} 235 | } 236 | } 237 | } 238 | 239 | fn draw_add_stock(frame: &mut Frame, app: &mut App, area: Rect) { 240 | frame.render_stateful_widget(AddStockWidget {}, area, &mut app.add_stock); 241 | } 242 | 243 | fn draw_summary(frame: &mut Frame, app: &mut App, mut area: Rect) { 244 | let border = block::new(" Summary "); 245 | frame.render_widget(border, area); 246 | area = add_padding(area, 1, PaddingDirection::All); 247 | area = add_padding(area, 1, PaddingDirection::Right); 248 | 249 | let show_volumes = *SHOW_VOLUMES.read() && app.chart_type != ChartType::Kagi; 250 | let stock_widget_height = if show_volumes { 7 } else { 6 }; 251 | 252 | let height = area.height; 253 | let num_to_render = (((height - 3) / stock_widget_height) as usize).min(app.stocks.len()); 254 | 255 | // If the user queued an up / down scroll, calculate the new offset, store it in 256 | // state and use it for this render. Otherwise use stored offset from state. 257 | let mut scroll_offset = if let Some(direction) = app.summary_scroll_state.queued_scroll.take() { 258 | let new_offset = match direction { 259 | ScrollDirection::Up => { 260 | if app.summary_scroll_state.offset == 0 { 261 | 0 262 | } else { 263 | (app.summary_scroll_state.offset - 1).min(app.stocks.len()) 264 | } 265 | } 266 | ScrollDirection::Down => { 267 | (app.summary_scroll_state.offset + 1).min(app.stocks.len() - num_to_render) 268 | } 269 | }; 270 | 271 | app.summary_scroll_state.offset = new_offset; 272 | 273 | new_offset 274 | } else { 275 | app.summary_scroll_state.offset 276 | }; 277 | 278 | // If we resize the app up, adj the offset 279 | if num_to_render + scroll_offset > app.stocks.len() { 280 | scroll_offset -= (num_to_render + scroll_offset) - app.stocks.len(); 281 | app.summary_scroll_state.offset = scroll_offset; 282 | } 283 | 284 | // layouy[0] - Header 285 | // layouy[1] - Summary window 286 | // layouy[2] - Empty 287 | let mut layout = Layout::default() 288 | .constraints([ 289 | Constraint::Length(1), 290 | Constraint::Length((num_to_render * stock_widget_height as usize) as u16), 291 | Constraint::Min(0), 292 | ]) 293 | .split(area) 294 | .to_vec(); 295 | 296 | // header[0] 297 | // header[1] - (Optional) help icon 298 | let header = if app.hide_help { 299 | Layout::default() 300 | .direction(Direction::Horizontal) 301 | .constraints([Constraint::Min(0)]) 302 | .split(layout[0]) 303 | .to_vec() 304 | } else { 305 | Layout::default() 306 | .direction(Direction::Horizontal) 307 | .constraints([Constraint::Min(0), Constraint::Length(8)]) 308 | .split(layout[0]) 309 | .to_vec() 310 | }; 311 | 312 | // Draw help icon 313 | if !app.hide_help { 314 | frame.render_widget( 315 | Paragraph::new(Line::from(Span::styled("Help '?'", style()))) 316 | .style(style().fg(THEME.text_normal())) 317 | .alignment(Alignment::Center), 318 | header[1], 319 | ); 320 | } 321 | 322 | let contraints = app.stocks[scroll_offset..num_to_render + scroll_offset] 323 | .iter() 324 | .map(|_| Constraint::Length(stock_widget_height)) 325 | .collect::>(); 326 | 327 | let stock_layout = Layout::default() 328 | .constraints(contraints) 329 | .split(layout[1]) 330 | .to_vec(); 331 | 332 | // Make sure only displayed stocks have network activity 333 | app.stocks.iter().enumerate().for_each(|(idx, s)| { 334 | if idx >= scroll_offset && idx < num_to_render + scroll_offset { 335 | s.stock_service.resume(); 336 | } else { 337 | s.stock_service.pause(); 338 | } 339 | }); 340 | 341 | for (idx, stock) in app.stocks[scroll_offset..num_to_render + scroll_offset] 342 | .iter_mut() 343 | .enumerate() 344 | { 345 | frame.render_stateful_widget(StockSummaryWidget {}, stock_layout[idx], stock); 346 | } 347 | 348 | // Draw time frame & paging 349 | { 350 | let mut current = layout[2]; 351 | current = add_padding(current, 1, PaddingDirection::Left); 352 | frame.render_widget(Clear, current); 353 | frame.render_widget(Block::default().style(style()), current); 354 | 355 | let offset = current.height - 2; 356 | current = add_padding(current, offset, PaddingDirection::Top); 357 | 358 | frame.render_widget( 359 | Block::default() 360 | .borders(Borders::TOP) 361 | .border_style(style().fg(THEME.border_secondary())), 362 | current, 363 | ); 364 | 365 | current = add_padding(current, 1, PaddingDirection::Top); 366 | layout[2] = current; 367 | 368 | let time_frames = TimeFrame::tab_names() 369 | .iter() 370 | .map(|s| Line::from(*s)) 371 | .collect::>(); 372 | 373 | // botton_layout[0] - time frame 374 | // botton_layout[1] - paging indicator 375 | let bottom_layout = Layout::default() 376 | .direction(Direction::Horizontal) 377 | .constraints([Constraint::Min(0), Constraint::Length(3)]) 378 | .split(layout[2]) 379 | .to_vec(); 380 | 381 | let tabs = Tabs::new(time_frames) 382 | .select(app.summary_time_frame.idx()) 383 | .style(style().fg(THEME.text_secondary())) 384 | .highlight_style(style().fg(THEME.text_primary())); 385 | 386 | frame.render_widget(tabs, bottom_layout[0]); 387 | 388 | let more_up = scroll_offset > 0; 389 | let more_down = scroll_offset + num_to_render < app.stocks.len(); 390 | 391 | let up_arrow = Span::styled( 392 | "ᐱ", 393 | style().fg(if more_up { 394 | THEME.text_normal() 395 | } else { 396 | THEME.gray() 397 | }), 398 | ); 399 | let down_arrow = Span::styled( 400 | "ᐯ", 401 | style().fg(if more_down { 402 | THEME.text_normal() 403 | } else { 404 | THEME.gray() 405 | }), 406 | ); 407 | 408 | frame.render_widget( 409 | Paragraph::new(Line::from(vec![up_arrow, Span::raw(" "), down_arrow])), 410 | bottom_layout[1], 411 | ); 412 | } 413 | } 414 | 415 | fn draw_help(frame: &mut Frame, app: &App, area: Rect) { 416 | let mut layout = area; 417 | 418 | if layout.width < HELP_WIDTH as u16 || layout.height < HELP_HEIGHT as u16 { 419 | frame.render_widget( 420 | Paragraph::new(Line::from(Span::styled( 421 | "Increase screen size to display help", 422 | style(), 423 | ))), 424 | layout, 425 | ); 426 | } else { 427 | layout = app.help.get_rect(layout); 428 | 429 | frame.render_widget(app.help, layout); 430 | } 431 | } 432 | 433 | fn draw_debug(frame: &mut Frame, app: &mut App, area: Rect) { 434 | app.debug.mode = app.mode; 435 | 436 | let debug_text = Line::from(Span::styled(format!("{:?}", app.debug), style())); 437 | let debug_paragraph = Paragraph::new(debug_text).wrap(Wrap { trim: true }); 438 | 439 | frame.render_widget(debug_paragraph, area); 440 | } 441 | 442 | pub fn add_padding(mut rect: Rect, n: u16, direction: PaddingDirection) -> Rect { 443 | match direction { 444 | PaddingDirection::Top => { 445 | rect.y += n; 446 | rect.height = rect.height.saturating_sub(n); 447 | rect 448 | } 449 | PaddingDirection::Bottom => { 450 | rect.height = rect.height.saturating_sub(n); 451 | rect 452 | } 453 | PaddingDirection::Left => { 454 | rect.x += n; 455 | rect.width = rect.width.saturating_sub(n); 456 | rect 457 | } 458 | PaddingDirection::Right => { 459 | rect.width = rect.width.saturating_sub(n); 460 | rect 461 | } 462 | PaddingDirection::All => { 463 | rect.y += n; 464 | rect.height = rect.height.saturating_sub(n * 2); 465 | 466 | rect.x += n; 467 | rect.width = rect.width.saturating_sub(n * 2); 468 | 469 | rect 470 | } 471 | } 472 | } 473 | 474 | #[allow(dead_code)] 475 | pub enum PaddingDirection { 476 | Top, 477 | Bottom, 478 | Left, 479 | Right, 480 | All, 481 | } 482 | -------------------------------------------------------------------------------- /src/widget/chart_configuration.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::hash::{Hash, Hasher}; 3 | 4 | use crossterm::terminal; 5 | use ratatui::buffer::Buffer; 6 | use ratatui::layout::{Constraint, Layout, Rect}; 7 | use ratatui::text::{Line, Span}; 8 | use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}; 9 | use serde::Deserialize; 10 | 11 | use super::chart::prices_kagi::{self, ReversalOption}; 12 | use super::{block, CachableWidget, CacheState}; 13 | use crate::common::{ChartType, TimeFrame}; 14 | use crate::draw::{add_padding, PaddingDirection}; 15 | use crate::theme::style; 16 | use crate::THEME; 17 | 18 | #[derive(Default, Debug, Clone)] 19 | pub struct ChartConfigurationState { 20 | pub input: Input, 21 | pub selection: Option, 22 | pub error_message: Option, 23 | pub kagi_options: KagiOptions, 24 | pub cache_state: CacheState, 25 | } 26 | 27 | impl ChartConfigurationState { 28 | pub fn add_char(&mut self, c: char) { 29 | let input_field = match self.selection { 30 | Some(KagiSelection::ReversalValue) => &mut self.input.kagi_reversal_value, 31 | _ => return, 32 | }; 33 | 34 | // Width of our text input box 35 | if input_field.len() == 20 { 36 | return; 37 | } 38 | 39 | input_field.push(c); 40 | } 41 | 42 | pub fn del_char(&mut self) { 43 | let input_field = match self.selection { 44 | Some(KagiSelection::ReversalValue) => &mut self.input.kagi_reversal_value, 45 | _ => return, 46 | }; 47 | 48 | input_field.pop(); 49 | } 50 | 51 | fn get_tab_artifacts(&mut self) -> Option<(&mut usize, usize)> { 52 | let tab_field = match self.selection { 53 | Some(KagiSelection::ReversalType) => &mut self.input.kagi_reversal_type, 54 | Some(KagiSelection::PriceType) => &mut self.input.kagi_price_type, 55 | _ => return None, 56 | }; 57 | 58 | let mod_value = match self.selection { 59 | Some(KagiSelection::ReversalType) => 2, 60 | Some(KagiSelection::PriceType) => 2, 61 | _ => 1, 62 | }; 63 | Some((tab_field, mod_value)) 64 | } 65 | 66 | pub fn tab(&mut self) { 67 | if let Some((tab_field, mod_value)) = self.get_tab_artifacts() { 68 | *tab_field = (*tab_field + 1) % mod_value; 69 | } 70 | } 71 | 72 | pub fn back_tab(&mut self) { 73 | if let Some((tab_field, mod_value)) = self.get_tab_artifacts() { 74 | *tab_field = (*tab_field + mod_value - 1) % mod_value; 75 | } 76 | } 77 | 78 | pub fn enter(&mut self, time_frame: TimeFrame) { 79 | self.error_message.take(); 80 | 81 | // Validate Kagi reversal option 82 | let new_kagi_reversal_option = { 83 | let input_value = &self.input.kagi_reversal_value; 84 | 85 | let value = match input_value.parse::() { 86 | Ok(value) => value, 87 | Err(_) => { 88 | self.error_message = Some("Reversal Value must be a valid number".to_string()); 89 | return; 90 | } 91 | }; 92 | 93 | match self.input.kagi_reversal_type { 94 | 0 => ReversalOption::Pct(value), 95 | 1 => ReversalOption::Amount(value), 96 | _ => unreachable!(), 97 | } 98 | }; 99 | 100 | let new_kagi_price_option = Some(match self.input.kagi_price_type { 101 | 0 => prices_kagi::PriceOption::Close, 102 | 1 => prices_kagi::PriceOption::HighLow, 103 | _ => unreachable!(), 104 | }); 105 | 106 | // Everything validated, save the form values to our state 107 | match &mut self.kagi_options.reversal_option { 108 | reversal_options @ None => { 109 | let mut options_by_timeframe = BTreeMap::new(); 110 | for iter_time_frame in TimeFrame::ALL.iter() { 111 | let default_reversal_amount = match iter_time_frame { 112 | TimeFrame::Day1 => 0.01, 113 | _ => 0.04, 114 | }; 115 | 116 | // If this is the time frame we are submitting for, store that value, 117 | // otherwise use the default still 118 | if *iter_time_frame == time_frame { 119 | options_by_timeframe.insert(*iter_time_frame, new_kagi_reversal_option); 120 | } else { 121 | options_by_timeframe.insert( 122 | *iter_time_frame, 123 | ReversalOption::Pct(default_reversal_amount), 124 | ); 125 | } 126 | } 127 | 128 | *reversal_options = Some(KagiReversalOption::ByTimeFrame(options_by_timeframe)); 129 | } 130 | reversal_options @ Some(KagiReversalOption::Single(_)) => { 131 | // Always succeeds since we already pattern matched it 132 | if let KagiReversalOption::Single(config_option) = reversal_options.clone().unwrap() 133 | { 134 | let mut options_by_timeframe = BTreeMap::new(); 135 | for iter_time_frame in TimeFrame::ALL.iter() { 136 | // If this is the time frame we are submitting for, store that value, 137 | // otherwise use the single value defined from the config 138 | if *iter_time_frame == time_frame { 139 | options_by_timeframe.insert(*iter_time_frame, new_kagi_reversal_option); 140 | } else { 141 | options_by_timeframe.insert(*iter_time_frame, config_option); 142 | } 143 | } 144 | 145 | *reversal_options = Some(KagiReversalOption::ByTimeFrame(options_by_timeframe)); 146 | } 147 | } 148 | Some(KagiReversalOption::ByTimeFrame(options_by_timeframe)) => { 149 | options_by_timeframe.insert(time_frame, new_kagi_reversal_option); 150 | } 151 | } 152 | 153 | self.kagi_options.price_option = new_kagi_price_option; 154 | } 155 | 156 | pub fn selection_up(&mut self) { 157 | let new_selection = match self.selection { 158 | None => KagiSelection::ReversalValue, 159 | Some(KagiSelection::ReversalValue) => KagiSelection::ReversalType, 160 | Some(KagiSelection::ReversalType) => KagiSelection::PriceType, 161 | Some(KagiSelection::PriceType) => KagiSelection::ReversalValue, 162 | }; 163 | 164 | self.selection = Some(new_selection); 165 | } 166 | 167 | pub fn selection_down(&mut self) { 168 | let new_selection = match self.selection { 169 | None => KagiSelection::PriceType, 170 | Some(KagiSelection::PriceType) => KagiSelection::ReversalType, 171 | Some(KagiSelection::ReversalType) => KagiSelection::ReversalValue, 172 | Some(KagiSelection::ReversalValue) => KagiSelection::PriceType, 173 | }; 174 | 175 | self.selection = Some(new_selection); 176 | } 177 | 178 | pub fn reset_form(&mut self, time_frame: TimeFrame) { 179 | self.input = Default::default(); 180 | self.error_message.take(); 181 | 182 | let default_reversal_amount = match time_frame { 183 | TimeFrame::Day1 => 0.01, 184 | _ => 0.04, 185 | }; 186 | 187 | let (reversal_type, reversal_amount) = self 188 | .kagi_options 189 | .reversal_option 190 | .as_ref() 191 | .map(|o| { 192 | let option = match o { 193 | KagiReversalOption::Single(option) => *option, 194 | KagiReversalOption::ByTimeFrame(options_by_timeframe) => options_by_timeframe 195 | .get(&time_frame) 196 | .copied() 197 | .unwrap_or(ReversalOption::Pct(default_reversal_amount)), 198 | }; 199 | 200 | match option { 201 | ReversalOption::Pct(amount) => (0, amount), 202 | ReversalOption::Amount(amount) => (1, amount), 203 | } 204 | }) 205 | .unwrap_or((0, default_reversal_amount)); 206 | 207 | let price_type = self 208 | .kagi_options 209 | .price_option 210 | .map(|p| match p { 211 | prices_kagi::PriceOption::Close => 0, 212 | prices_kagi::PriceOption::HighLow => 1, 213 | }) 214 | .unwrap_or(0); 215 | 216 | self.selection = Some(KagiSelection::PriceType); 217 | self.input.kagi_reversal_value = format!("{:.2}", reversal_amount); 218 | self.input.kagi_reversal_type = reversal_type; 219 | self.input.kagi_price_type = price_type; 220 | } 221 | } 222 | 223 | impl Hash for ChartConfigurationState { 224 | fn hash(&self, state: &mut H) { 225 | self.input.hash(state); 226 | self.selection.hash(state); 227 | self.error_message.hash(state); 228 | self.kagi_options.hash(state); 229 | } 230 | } 231 | 232 | #[derive(Debug, Default, Clone, Hash)] 233 | pub struct Input { 234 | pub kagi_reversal_type: usize, 235 | pub kagi_reversal_value: String, 236 | pub kagi_price_type: usize, 237 | } 238 | 239 | #[derive(Default, Debug, Clone, Deserialize, Hash)] 240 | pub struct KagiOptions { 241 | #[serde(rename = "reversal")] 242 | pub reversal_option: Option, 243 | #[serde(rename = "price")] 244 | pub price_option: Option, 245 | } 246 | 247 | #[derive(Debug, Clone, Deserialize, Hash)] 248 | #[serde(untagged)] 249 | pub enum KagiReversalOption { 250 | Single(prices_kagi::ReversalOption), 251 | ByTimeFrame(BTreeMap), 252 | } 253 | 254 | #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] 255 | pub enum KagiSelection { 256 | PriceType, 257 | ReversalType, 258 | ReversalValue, 259 | } 260 | 261 | pub struct ChartConfigurationWidget { 262 | pub chart_type: ChartType, 263 | } 264 | 265 | impl StatefulWidget for ChartConfigurationWidget { 266 | type State = ChartConfigurationState; 267 | 268 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 269 | self.render_cached(area, buf, state); 270 | } 271 | } 272 | 273 | impl CachableWidget for ChartConfigurationWidget { 274 | fn cache_state_mut(state: &mut ChartConfigurationState) -> &mut CacheState { 275 | &mut state.cache_state 276 | } 277 | 278 | fn render(self, mut area: Rect, buf: &mut Buffer, state: &mut ChartConfigurationState) { 279 | block::new(" Configuration ").render(area, buf); 280 | area = add_padding(area, 1, PaddingDirection::All); 281 | area = add_padding(area, 1, PaddingDirection::Left); 282 | area = add_padding(area, 1, PaddingDirection::Right); 283 | 284 | // layout[0] - Info / Error message 285 | // layout[1] - Kagi options 286 | let mut layout = Layout::default() 287 | .constraints([Constraint::Length(6), Constraint::Min(0)]) 288 | .split(area) 289 | .to_vec(); 290 | 291 | let mut padded = layout[0]; 292 | padded = add_padding(padded, 1, PaddingDirection::Top); 293 | padded = add_padding(padded, 1, PaddingDirection::Bottom); 294 | layout[0] = padded; 295 | 296 | let info_error = if let Some(msg) = state.error_message.as_ref() { 297 | vec![Line::from(Span::styled(msg, style().fg(THEME.loss())))] 298 | } else { 299 | vec![ 300 | Line::from(Span::styled( 301 | " : move up / down", 302 | style().fg(THEME.text_normal()), 303 | )), 304 | Line::from(Span::styled( 305 | " : move up / down", 306 | style().fg(THEME.text_normal()), 307 | )), 308 | Line::from(Span::styled( 309 | " : toggle option", 310 | style().fg(THEME.text_normal()), 311 | )), 312 | Line::from(Span::styled( 313 | " : submit changes", 314 | style().fg(THEME.text_normal()), 315 | )), 316 | ] 317 | }; 318 | 319 | Paragraph::new(info_error) 320 | .style(style().fg(THEME.text_normal())) 321 | .render(layout[0], buf); 322 | 323 | match self.chart_type { 324 | ChartType::Line => {} 325 | ChartType::Candlestick => {} 326 | ChartType::Kagi => render_kagi_options(layout[1], buf, state), 327 | } 328 | } 329 | } 330 | 331 | fn render_kagi_options(mut area: Rect, buf: &mut Buffer, state: &ChartConfigurationState) { 332 | Block::default() 333 | .style(style()) 334 | .title(vec![Span::styled( 335 | "Kagi Options ", 336 | style().fg(THEME.text_normal()), 337 | )]) 338 | .borders(Borders::TOP) 339 | .border_style(style().fg(THEME.border_secondary())) 340 | .render(area, buf); 341 | 342 | area = add_padding(area, 1, PaddingDirection::Top); 343 | 344 | // layout[0] - Left column 345 | // layout[1] - Divider 346 | // layout[2] - Right Column 347 | let layout = Layout::default() 348 | .direction(ratatui::layout::Direction::Horizontal) 349 | .constraints([ 350 | Constraint::Length(16), 351 | Constraint::Length(3), 352 | Constraint::Min(0), 353 | ]) 354 | .split(area) 355 | .to_vec(); 356 | 357 | let left_column = vec![ 358 | Line::default(), 359 | Line::from(vec![ 360 | Span::styled( 361 | if state.selection == Some(KagiSelection::PriceType) { 362 | "> " 363 | } else { 364 | " " 365 | }, 366 | style().fg(THEME.text_primary()), 367 | ), 368 | Span::styled("Price Type", style().fg(THEME.text_normal())), 369 | ]), 370 | Line::default(), 371 | Line::from(vec![ 372 | Span::styled( 373 | if state.selection == Some(KagiSelection::ReversalType) { 374 | "> " 375 | } else { 376 | " " 377 | }, 378 | style().fg(THEME.text_primary()), 379 | ), 380 | Span::styled("Reversal Type", style().fg(THEME.text_normal())), 381 | ]), 382 | Line::default(), 383 | Line::from(vec![ 384 | Span::styled( 385 | if state.selection == Some(KagiSelection::ReversalValue) { 386 | "> " 387 | } else { 388 | " " 389 | }, 390 | style().fg(THEME.text_primary()), 391 | ), 392 | Span::styled("Reversal Value", style().fg(THEME.text_normal())), 393 | ]), 394 | ]; 395 | 396 | let right_column = vec![ 397 | Line::default(), 398 | Line::from(vec![ 399 | Span::styled( 400 | "Close", 401 | style().fg(THEME.text_normal()).bg( 402 | match (state.selection, state.input.kagi_price_type) { 403 | (Some(KagiSelection::PriceType), 0) => THEME.highlight_focused(), 404 | (_, 0) => THEME.highlight_unfocused(), 405 | (_, _) => THEME.background(), 406 | }, 407 | ), 408 | ), 409 | Span::styled(" | ", style().fg(THEME.text_normal())), 410 | Span::styled( 411 | "High / Low", 412 | style().fg(THEME.text_normal()).bg( 413 | match (state.selection, state.input.kagi_price_type) { 414 | (Some(KagiSelection::PriceType), 1) => THEME.highlight_focused(), 415 | (_, 1) => THEME.highlight_unfocused(), 416 | (_, _) => THEME.background(), 417 | }, 418 | ), 419 | ), 420 | ]), 421 | Line::default(), 422 | Line::from(vec![ 423 | Span::styled( 424 | "Pct", 425 | style().fg(THEME.text_normal()).bg( 426 | match (state.selection, state.input.kagi_reversal_type) { 427 | (Some(KagiSelection::ReversalType), 0) => THEME.highlight_focused(), 428 | (_, 0) => THEME.highlight_unfocused(), 429 | (_, _) => THEME.background(), 430 | }, 431 | ), 432 | ), 433 | Span::styled(" | ", style().fg(THEME.text_normal())), 434 | Span::styled( 435 | "Amount", 436 | style().fg(THEME.text_normal()).bg( 437 | match (state.selection, state.input.kagi_reversal_type) { 438 | (Some(KagiSelection::ReversalType), 1) => THEME.highlight_focused(), 439 | (_, 1) => THEME.highlight_unfocused(), 440 | (_, _) => THEME.background(), 441 | }, 442 | ), 443 | ), 444 | ]), 445 | Line::default(), 446 | Line::from(vec![Span::styled( 447 | format!("{: <22}", &state.input.kagi_reversal_value), 448 | style() 449 | .fg(if state.selection == Some(KagiSelection::ReversalValue) { 450 | THEME.text_secondary() 451 | } else { 452 | THEME.text_normal() 453 | }) 454 | .bg(if state.selection == Some(KagiSelection::ReversalValue) { 455 | THEME.highlight_unfocused() 456 | } else { 457 | THEME.background() 458 | }), 459 | )]), 460 | ]; 461 | 462 | Paragraph::new(left_column) 463 | .style(style().fg(THEME.text_normal())) 464 | .render(layout[0], buf); 465 | 466 | Paragraph::new(right_column) 467 | .style(style().fg(THEME.text_normal())) 468 | .render(layout[2], buf); 469 | 470 | // Set "cursor" color 471 | if matches!(state.selection, Some(KagiSelection::ReversalValue)) { 472 | let size = terminal::size().unwrap_or((0, 0)); 473 | 474 | let x = layout[2].left() as usize + state.input.kagi_reversal_value.len().min(20); 475 | let y = layout[2].top() as usize + 5; 476 | let idx = y * size.0 as usize + x; 477 | 478 | if let Some(cell) = buf.content.get_mut(idx) { 479 | cell.bg = THEME.text_secondary(); 480 | } 481 | } 482 | } 483 | -------------------------------------------------------------------------------- /src/widget/options.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::hash::{Hash, Hasher}; 3 | 4 | use chrono::NaiveDateTime; 5 | use ratatui::buffer::Buffer; 6 | use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; 7 | use ratatui::style::Modifier; 8 | use ratatui::text::{Line, Span}; 9 | use ratatui::widgets::{ 10 | Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, StatefulWidget, Table, 11 | TableState, Widget, 12 | }; 13 | 14 | use super::{block, CachableWidget, CacheState}; 15 | use crate::api::model::{OptionsData, OptionsQuote}; 16 | use crate::draw::{add_padding, PaddingDirection}; 17 | use crate::service::{self, Service}; 18 | use crate::theme::style; 19 | use crate::THEME; 20 | 21 | #[derive(Clone, Copy, PartialEq, Hash)] 22 | enum OptionType { 23 | Call, 24 | Put, 25 | } 26 | 27 | #[derive(Clone, Copy, PartialEq, Eq, Hash)] 28 | pub enum SelectionMode { 29 | Dates, 30 | Options, 31 | } 32 | 33 | pub struct OptionsState { 34 | options_service: service::options::OptionsService, 35 | exp_dates: Vec, 36 | exp_date: Option, 37 | data: HashMap, 38 | selected_type: OptionType, 39 | pub selection_mode: SelectionMode, 40 | selected_option: Option, 41 | quote: Option, 42 | cache_state: CacheState, 43 | } 44 | 45 | impl Hash for OptionsState { 46 | fn hash(&self, state: &mut H) { 47 | self.exp_dates.hash(state); 48 | self.exp_date.hash(state); 49 | self.data().hash(state); 50 | self.selected_type.hash(state); 51 | self.selection_mode.hash(state); 52 | self.selected_option.hash(state); 53 | self.quote.hash(state); 54 | } 55 | } 56 | 57 | impl OptionsState { 58 | pub fn new(symbol: String) -> OptionsState { 59 | let options_service = service::options::OptionsService::new(symbol); 60 | 61 | OptionsState { 62 | options_service, 63 | exp_dates: vec![], 64 | exp_date: None, 65 | data: HashMap::new(), 66 | selected_type: OptionType::Call, 67 | selection_mode: SelectionMode::Dates, 68 | selected_option: None, 69 | quote: None, 70 | cache_state: Default::default(), 71 | } 72 | } 73 | 74 | pub fn data(&self) -> Option<&OptionsData> { 75 | if let Some(date) = self.exp_date { 76 | self.data.get(&date) 77 | } else { 78 | None 79 | } 80 | } 81 | 82 | fn set_exp_date(&mut self, date: i64) { 83 | self.exp_date = Some(date); 84 | 85 | self.options_service.set_expiration_date(date); 86 | 87 | self.selected_option.take(); 88 | 89 | if self.data().is_some() { 90 | self.set_selected_as_closest(); 91 | } 92 | } 93 | 94 | pub fn toggle_option_type(&mut self) { 95 | match self.selected_type { 96 | OptionType::Call => self.selected_type = OptionType::Put, 97 | OptionType::Put => self.selected_type = OptionType::Call, 98 | } 99 | 100 | if self.data().is_some() { 101 | self.set_selected_as_closest(); 102 | } 103 | } 104 | 105 | fn set_selected_as_closest(&mut self) { 106 | let selected_range = match self.selected_type { 107 | OptionType::Call => &self.data().as_ref().unwrap().calls[..], 108 | OptionType::Put => &self.data().as_ref().unwrap().puts[..], 109 | }; 110 | 111 | let market_price = if let Some(ref quote) = self.quote { 112 | quote.regular_market_price 113 | } else { 114 | 0.0 115 | }; 116 | 117 | let mut closest_idx = selected_range 118 | .iter() 119 | .position(|c| c.strike < market_price) 120 | .unwrap_or_default(); 121 | 122 | if closest_idx > 0 && self.selected_type == OptionType::Call { 123 | closest_idx -= 1; 124 | } 125 | 126 | self.selected_option = Some(closest_idx); 127 | } 128 | 129 | pub fn previous_date(&mut self) { 130 | if let Some(idx) = self 131 | .exp_dates 132 | .iter() 133 | .position(|d| *d == self.exp_date.unwrap_or_default()) 134 | { 135 | let new_idx = if idx == 0 { 136 | self.exp_dates.len() - 1 137 | } else { 138 | idx - 1 139 | }; 140 | 141 | self.set_exp_date(self.exp_dates[new_idx]); 142 | } 143 | } 144 | 145 | pub fn next_date(&mut self) { 146 | if let Some(idx) = self 147 | .exp_dates 148 | .iter() 149 | .position(|d| *d == self.exp_date.unwrap_or_default()) 150 | { 151 | let new_idx = (idx + 1) % self.exp_dates.len(); 152 | 153 | self.set_exp_date(self.exp_dates[new_idx]); 154 | } 155 | } 156 | 157 | pub fn previous_option(&mut self) { 158 | if let Some(idx) = self.selected_option { 159 | let option_range = if self.selected_type == OptionType::Call { 160 | &self.data().as_ref().unwrap().calls[..] 161 | } else { 162 | &self.data().as_ref().unwrap().puts[..] 163 | }; 164 | 165 | let new_idx = if idx == 0 { 166 | option_range.len() - 1 167 | } else { 168 | idx - 1 169 | }; 170 | 171 | self.selected_option = Some(new_idx); 172 | } 173 | } 174 | 175 | pub fn next_option(&mut self) { 176 | if let Some(idx) = self.selected_option { 177 | let option_range = if self.selected_type == OptionType::Call { 178 | &self.data().as_ref().unwrap().calls[..] 179 | } else { 180 | &self.data().as_ref().unwrap().puts[..] 181 | }; 182 | 183 | let new_idx = (idx + 1) % option_range.len(); 184 | 185 | self.selected_option = Some(new_idx); 186 | } 187 | } 188 | 189 | pub fn selection_mode_left(&mut self) { 190 | if self.selection_mode == SelectionMode::Options { 191 | self.selection_mode = SelectionMode::Dates; 192 | } 193 | } 194 | 195 | pub fn selection_mode_right(&mut self) { 196 | if self.selection_mode == SelectionMode::Dates { 197 | self.selection_mode = SelectionMode::Options; 198 | } 199 | } 200 | 201 | pub fn update(&mut self) { 202 | let updates = self.options_service.updates(); 203 | 204 | for update in updates { 205 | match update { 206 | service::options::Update::ExpirationDates(dates) => { 207 | let prev_len = self.exp_dates.len(); 208 | 209 | self.exp_dates = dates; 210 | 211 | if prev_len == 0 && !self.exp_dates.is_empty() { 212 | self.set_exp_date(self.exp_dates[0]); 213 | } 214 | } 215 | service::options::Update::OptionsData(mut header) => { 216 | if header.options.len() == 1 { 217 | header.options[0].calls.reverse(); 218 | header.options[0].puts.reverse(); 219 | 220 | self.quote = Some(header.quote); 221 | 222 | self.data 223 | .insert(self.exp_date.unwrap(), header.options.remove(0)); 224 | 225 | if self.selected_option.is_none() { 226 | self.set_selected_as_closest(); 227 | } 228 | } 229 | } 230 | } 231 | } 232 | } 233 | } 234 | 235 | pub struct OptionsWidget {} 236 | 237 | impl StatefulWidget for OptionsWidget { 238 | type State = OptionsState; 239 | 240 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 241 | self.render_cached(area, buf, state); 242 | } 243 | } 244 | 245 | impl CachableWidget for OptionsWidget { 246 | fn cache_state_mut(state: &mut OptionsState) -> &mut CacheState { 247 | &mut state.cache_state 248 | } 249 | 250 | fn render(self, mut area: Rect, buf: &mut Buffer, state: &mut OptionsState) { 251 | block::new(" Options ").render(area, buf); 252 | area = add_padding(area, 1, PaddingDirection::All); 253 | 254 | // chunks[0] - call / put selector 255 | // chunks[1] - option info 256 | // chunks[2] - remainder (date selector | option selector) 257 | let mut chunks: Vec = Layout::default() 258 | .constraints( 259 | [ 260 | Constraint::Length(2), 261 | Constraint::Length(8), 262 | Constraint::Min(0), 263 | ] 264 | .as_ref(), 265 | ) 266 | .split(area) 267 | .to_vec(); 268 | 269 | // Draw call / put selector 270 | { 271 | let call_put_selector = vec![ 272 | Span::styled( 273 | "Call", 274 | style().fg(THEME.profit()).add_modifier( 275 | if state.selected_type == OptionType::Call { 276 | Modifier::BOLD | Modifier::UNDERLINED 277 | } else { 278 | Modifier::empty() 279 | }, 280 | ), 281 | ), 282 | Span::styled(" | ", style()), 283 | Span::styled( 284 | "Put", 285 | style().fg(THEME.loss()).add_modifier( 286 | if state.selected_type == OptionType::Put { 287 | Modifier::BOLD | Modifier::UNDERLINED 288 | } else { 289 | Modifier::empty() 290 | }, 291 | ), 292 | ), 293 | ]; 294 | 295 | chunks[0] = add_padding(chunks[0], 1, PaddingDirection::Left); 296 | chunks[0] = add_padding(chunks[0], 1, PaddingDirection::Right); 297 | 298 | Block::default() 299 | .style(style().fg(THEME.border_secondary())) 300 | .borders(Borders::BOTTOM) 301 | .render(chunks[0], buf); 302 | 303 | chunks[0] = add_padding(chunks[0], 1, PaddingDirection::Bottom); 304 | 305 | Paragraph::new(Line::from(call_put_selector)) 306 | .style(style().fg(THEME.text_normal())) 307 | .alignment(Alignment::Center) 308 | .render(chunks[0], buf); 309 | } 310 | 311 | // selector_chunks[0] - date selector 312 | // selector_chunks[1] - option selector 313 | let mut selector_chunks: Vec = Layout::default() 314 | .direction(Direction::Horizontal) 315 | .constraints([Constraint::Length(12), Constraint::Min(0)].as_ref()) 316 | .split(chunks[2]) 317 | .to_vec(); 318 | 319 | // Draw date selector 320 | { 321 | selector_chunks[0] = add_padding(selector_chunks[0], 1, PaddingDirection::Left); 322 | 323 | Block::default() 324 | .style(style().fg(THEME.border_secondary())) 325 | .borders(Borders::RIGHT) 326 | .render(selector_chunks[0], buf); 327 | selector_chunks[0] = add_padding(selector_chunks[0], 2, PaddingDirection::Right); 328 | 329 | let dates = state 330 | .exp_dates 331 | .iter() 332 | .map(|d| { 333 | let date = NaiveDateTime::from_timestamp_opt(*d, 0).unwrap().date(); 334 | ListItem::new(Span::styled(date.format("%b-%d-%y").to_string(), style())) 335 | }) 336 | .collect::>(); 337 | 338 | let list = List::new(dates) 339 | .style(style().fg(THEME.text_normal())) 340 | .highlight_style(style().bg(if state.selection_mode == SelectionMode::Dates { 341 | THEME.highlight_focused() 342 | } else { 343 | THEME.highlight_unfocused() 344 | })); 345 | 346 | let mut list_state = ListState::default(); 347 | if let Some(idx) = state 348 | .exp_dates 349 | .iter() 350 | .position(|d| *d == state.exp_date.unwrap_or_default()) 351 | { 352 | list_state.select(Some(idx)); 353 | } 354 | 355 | Paragraph::new(Span::styled("Date", style().fg(THEME.text_secondary()))) 356 | .render(selector_chunks[0], buf); 357 | 358 | selector_chunks[0] = add_padding(selector_chunks[0], 2, PaddingDirection::Top); 359 | 360 | ::render(list, selector_chunks[0], buf, &mut list_state); 361 | } 362 | 363 | // Draw options data 364 | { 365 | selector_chunks[1] = add_padding(selector_chunks[1], 1, PaddingDirection::Left); 366 | 367 | if let Some(data) = state.data() { 368 | let selected_data = if state.selected_type == OptionType::Call { 369 | &data.calls[..] 370 | } else { 371 | &data.puts[..] 372 | }; 373 | 374 | let rows = selected_data 375 | .iter() 376 | .map(|d| { 377 | Row::new(vec![ 378 | Cell::from(format!("{: <7.2}", d.strike)), 379 | Cell::from(format!("{: <7.2}", d.last_price)), 380 | Cell::from(format!("{: >7.2}%", d.percent_change)), 381 | ]) 382 | .style(style().fg(if d.percent_change >= 0.0 { 383 | THEME.profit() 384 | } else { 385 | THEME.loss() 386 | })) 387 | }) 388 | .collect::>(); 389 | 390 | let header = Row::new(vec!["Strike", "Price", "% Change"]) 391 | .style(style().fg(THEME.text_secondary())) 392 | .bottom_margin(1); 393 | let table = Table::new( 394 | rows, 395 | [ 396 | Constraint::Length(8), 397 | Constraint::Length(8), 398 | Constraint::Min(0), 399 | ], 400 | ) 401 | .header(header) 402 | .style(style().fg(THEME.text_normal())) 403 | .highlight_style( 404 | style() 405 | .bg(if state.selection_mode == SelectionMode::Options { 406 | THEME.highlight_focused() 407 | } else { 408 | THEME.highlight_unfocused() 409 | }) 410 | .fg(THEME.text_normal()), 411 | ) 412 | .column_spacing(2); 413 | 414 | let mut table_state = TableState::default(); 415 | if let Some(idx) = state.selected_option { 416 | table_state.select(Some(idx)); 417 | } 418 | 419 | selector_chunks[1] = add_padding(selector_chunks[1], 1, PaddingDirection::Right); 420 | 421 | ::render(table, selector_chunks[1], buf, &mut table_state); 422 | } 423 | } 424 | 425 | // Draw selected option info 426 | { 427 | chunks[1] = add_padding(chunks[1], 1, PaddingDirection::Left); 428 | chunks[1] = add_padding(chunks[1], 1, PaddingDirection::Right); 429 | 430 | Block::default() 431 | .style(style().fg(THEME.border_secondary())) 432 | .borders(Borders::BOTTOM) 433 | .render(chunks[1], buf); 434 | 435 | chunks[1] = add_padding(chunks[1], 1, PaddingDirection::Bottom); 436 | 437 | if let Some(idx) = state.selected_option { 438 | let option_range = if state.selected_type == OptionType::Call { 439 | &state.data().as_ref().unwrap().calls[..] 440 | } else { 441 | &state.data().as_ref().unwrap().puts[..] 442 | }; 443 | 444 | if let Some(option) = option_range.get(idx) { 445 | let mut columns: Vec = Layout::default() 446 | .direction(Direction::Horizontal) 447 | .constraints([Constraint::Length(20), Constraint::Length(20)].as_ref()) 448 | .split(chunks[1]) 449 | .to_vec(); 450 | 451 | columns[1] = add_padding(columns[1], 2, PaddingDirection::Left); 452 | 453 | let currency = option.currency.as_deref().unwrap_or("USD"); 454 | 455 | let gap_strike = 19 - (format!("{:.2} {}", option.strike, currency).len() + 7); 456 | let gap_last = 15 - (format!("{:.2}", option.last_price).len() + 6); 457 | let gap_ask = 15 - (format!("{:.2}", option.ask.unwrap_or_default()).len() + 4); 458 | let gap_bid = 15 - (format!("{:.2}", option.bid.unwrap_or_default()).len() + 4); 459 | let gap_volume = 460 | 18 - (format!("{}", option.volume.unwrap_or_default()).len() + 7); 461 | let gap_open_int = 462 | 18 - (format!("{}", option.open_interest.unwrap_or_default()).len() + 9); 463 | let gap_impl_vol = 17 464 | - (format!( 465 | "{:.0}%", 466 | option.implied_volatility.unwrap_or_default() * 100.0 467 | ) 468 | .len() 469 | + 11); 470 | 471 | let column_0 = vec![ 472 | Line::from(Span::styled( 473 | format!( 474 | "Strike:{}{:.2} {}", 475 | " ".repeat(gap_strike), 476 | option.strike, 477 | currency 478 | ), 479 | style(), 480 | )), 481 | Line::default(), 482 | Line::from(Span::styled( 483 | format!("Price:{}{:.2}", " ".repeat(gap_last), option.last_price,), 484 | style(), 485 | )), 486 | Line::default(), 487 | Line::from(Span::styled( 488 | format!( 489 | "Bid:{}{:.2}", 490 | " ".repeat(gap_ask), 491 | option.bid.unwrap_or_default(), 492 | ), 493 | style(), 494 | )), 495 | Line::default(), 496 | Line::from(Span::styled( 497 | format!( 498 | "Ask:{}{:.2}", 499 | " ".repeat(gap_bid), 500 | option.ask.unwrap_or_default(), 501 | ), 502 | style(), 503 | )), 504 | ]; 505 | 506 | let column_1 = vec![ 507 | Line::from(Span::styled( 508 | format!( 509 | "Volume:{}{}", 510 | " ".repeat(gap_volume), 511 | option.volume.unwrap_or_default(), 512 | ), 513 | style(), 514 | )), 515 | Line::default(), 516 | Line::from(Span::styled( 517 | format!( 518 | "Open Int:{}{}", 519 | " ".repeat(gap_open_int), 520 | option.open_interest.unwrap_or_default() 521 | ), 522 | style(), 523 | )), 524 | Line::default(), 525 | Line::from(Span::styled( 526 | format!( 527 | "Implied Vol:{}{:.0}%", 528 | " ".repeat(gap_impl_vol), 529 | option.implied_volatility.unwrap_or_default() * 100.0 530 | ), 531 | style(), 532 | )), 533 | ]; 534 | 535 | Paragraph::new(column_0) 536 | .style(style().fg(THEME.text_normal())) 537 | .render(columns[0], buf); 538 | Paragraph::new(column_1) 539 | .style(style().fg(THEME.text_normal())) 540 | .render(columns[1], buf); 541 | } 542 | } 543 | } 544 | } 545 | } 546 | --------------------------------------------------------------------------------