diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e01eed..50a67c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ and `Removed`. ## [Unreleased] +### Added + +- Kagi charts have been added! + - You can specify custom reversal type (pct or amount), reversal value, and + price type (close or high_low) within the GUI by pressing 'e' + - New config options have been added to configure the behavior of Kagi charts, + see the updated [wiki entry](https://github.com/tarkah/tickrs/wiki/Config-file) + - As Kagi charts x-axis is decoupled from time, the chart width may be wider than + the terminal. You can now press SHIFT + < / > + or SHIFT + LEFT / RIGHT to scroll the chart. + An indicator in the bottom right corner will notify you if you can scroll further + left / right + - `--candle` has been deprecated in favor of `--chart-type` + ### Packaging - Linux: Binary size has been reduced due to some optimizations, from 10.6MB to diff --git a/src/app.rs b/src/app.rs index 38a316e..5f4e13a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crossterm::event::Event; -use crate::common::TimeFrame; +use crate::common::{ChartType, TimeFrame}; use crate::service::default_timestamps::DefaultTimestampService; use crate::service::Service; use crate::{widget, DEFAULT_TIMESTAMPS}; @@ -8,6 +8,7 @@ use crate::{widget, DEFAULT_TIMESTAMPS}; #[derive(PartialEq, Clone, Copy, Debug)] pub enum Mode { AddStock, + ConfigureChart, DisplayStock, DisplayOptions, DisplaySummary, @@ -26,6 +27,7 @@ pub struct App { pub summary_time_frame: TimeFrame, pub default_timestamp_service: DefaultTimestampService, pub summary_scroll_state: SummaryScrollState, + pub chart_type: ChartType, } impl App { diff --git a/src/common.rs b/src/common.rs index f725292..62187d6 100644 --- a/src/common.rs +++ b/src/common.rs @@ -10,22 +10,49 @@ use tickrs_api::Interval; use crate::api::model::ChartData; use crate::api::Range; -#[derive(PartialEq, Clone, Copy, Debug, Hash)] +#[derive(PartialEq, Clone, Copy, Debug, Hash, Deserialize)] pub enum ChartType { + #[serde(rename = "line")] Line, + #[serde(rename = "candle")] Candlestick, + #[serde(rename = "kagi")] + Kagi, } impl ChartType { pub fn toggle(self) -> Self { match self { ChartType::Line => ChartType::Candlestick, - ChartType::Candlestick => ChartType::Line, + ChartType::Candlestick => ChartType::Kagi, + ChartType::Kagi => ChartType::Line, + } + } + + pub fn as_str(self) -> &'static str { + match self { + ChartType::Line => "Line", + ChartType::Candlestick => "Candle", + ChartType::Kagi => "Kagi", } } } -#[derive(Clone, Copy, PartialOrd, Debug, Hash, PartialEq, Eq, Deserialize)] +impl FromStr for ChartType { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + use ChartType::*; + + match s { + "line" => Ok(Line), + "candle" => Ok(Candlestick), + "kagi" => Ok(Kagi), + _ => Err("Valid chart types are: 'line', 'candle', 'kagi'"), + } + } +} +#[derive(Clone, Copy, PartialOrd, Debug, Hash, PartialEq, Eq, Deserialize, Ord)] pub enum TimeFrame { #[serde(alias = "1D")] Day1, diff --git a/src/draw.rs b/src/draw.rs index c0e2a7f..682ca06 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -5,10 +5,11 @@ use tui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap}; use tui::{Frame, Terminal}; use crate::app::{App, Mode, ScrollDirection}; -use crate::common::TimeFrame; +use crate::common::{ChartType, TimeFrame}; use crate::theme::style; use crate::widget::{ - block, AddStockWidget, OptionsWidget, StockSummaryWidget, StockWidget, HELP_HEIGHT, HELP_WIDTH, + block, AddStockWidget, ChartConfigurationWidget, OptionsWidget, StockSummaryWidget, + StockWidget, HELP_HEIGHT, HELP_WIDTH, }; use crate::{SHOW_VOLUMES, THEME}; @@ -144,23 +145,24 @@ fn draw_main(frame: &mut Frame, app: &mut App, area: Rect) { // Draw main widget if let Some(stock) = app.stocks.get_mut(app.current_tab) { // main_chunks[0] - Stock widget - // main_chunks[1] - Options widget (optional) - let mut main_chunks = if app.mode == Mode::DisplayOptions { - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(0), Constraint::Length(44)].as_ref()) - .split(layout[1]) - } else { - vec![layout[1]] - }; + // main_chunks[1] - Options widget / Configuration widget (optional) + let mut main_chunks = + if app.mode == Mode::DisplayOptions || app.mode == Mode::ConfigureChart { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(44)].as_ref()) + .split(layout[1]) + } else { + vec![layout[1]] + }; match app.mode { Mode::DisplayStock | Mode::AddStock => { frame.render_stateful_widget(StockWidget {}, main_chunks[0], stock); } // If width is too small, don't render stock widget and use entire space - // for options widget - Mode::DisplayOptions => { + // for options / configure widget + Mode::DisplayOptions | Mode::ConfigureChart => { if main_chunks[0].width >= 19 { frame.render_stateful_widget(StockWidget {}, main_chunks[0], stock); } else { @@ -170,21 +172,55 @@ fn draw_main(frame: &mut Frame, app: &mut App, area: Rect) { _ => {} } - if let Some(options) = stock.options.as_mut() { - if main_chunks[1].width >= 44 && main_chunks[1].height >= 14 { - frame.render_stateful_widget(OptionsWidget {}, main_chunks[1], options); - } else { - main_chunks[1] = add_padding(main_chunks[1], 1, PaddingDirection::Left); - main_chunks[1] = add_padding(main_chunks[1], 1, PaddingDirection::Top); - - frame.render_widget( - Paragraph::new(Text::styled( - "Increase screen size to display options", - style(), - )), - main_chunks[1], - ); + match app.mode { + Mode::DisplayOptions => { + if let Some(options) = stock.options.as_mut() { + if main_chunks[1].width >= 44 && main_chunks[1].height >= 14 { + frame.render_stateful_widget(OptionsWidget {}, main_chunks[1], options); + } else { + main_chunks[1] = add_padding(main_chunks[1], 1, PaddingDirection::Left); + main_chunks[1] = add_padding(main_chunks[1], 1, PaddingDirection::Top); + + frame.render_widget( + Paragraph::new(Text::styled( + "Increase screen size to display options", + style(), + )), + main_chunks[1], + ); + } + } } + Mode::ConfigureChart => { + if main_chunks[1].width >= 44 && main_chunks[1].height >= 14 { + let state = &mut stock.chart_configuration; + + let chart_type = stock.chart_type; + let time_frame = stock.time_frame; + + frame.render_stateful_widget( + ChartConfigurationWidget { + chart_type, + time_frame, + }, + main_chunks[1], + state, + ); + } else { + main_chunks[1] = add_padding(main_chunks[1], 1, PaddingDirection::Left); + main_chunks[1] = add_padding(main_chunks[1], 1, PaddingDirection::Top); + + frame.render_widget( + Paragraph::new(Text::styled( + "Increase screen size to display configuration screen", + style(), + )) + .wrap(Wrap { trim: false }), + main_chunks[1], + ); + } + } + _ => {} } } } @@ -199,7 +235,7 @@ fn draw_summary(frame: &mut Frame, app: &mut App, mut area: Rect) area = add_padding(area, 1, PaddingDirection::All); area = add_padding(area, 1, PaddingDirection::Right); - let show_volumes = *SHOW_VOLUMES.read().unwrap(); + let show_volumes = *SHOW_VOLUMES.read().unwrap() && app.chart_type != ChartType::Kagi; let stock_widget_height = if show_volumes { 7 } else { 6 }; let height = area.height; diff --git a/src/event.rs b/src/event.rs index e448ae6..a51296f 100644 --- a/src/event.rs +++ b/src/event.rs @@ -3,13 +3,14 @@ use crossbeam_channel::Sender; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::{self, Mode}; +use crate::common::ChartType; use crate::widget::options; -use crate::{cleanup_terminal, CHART_TYPE, ENABLE_PRE_POST, SHOW_VOLUMES, SHOW_X_LABELS}; +use crate::{cleanup_terminal, ENABLE_PRE_POST, SHOW_VOLUMES, SHOW_X_LABELS}; fn handle_keys_add_stock(keycode: KeyCode, mut app: &mut app::App) { match keycode { KeyCode::Enter => { - let mut stock = app.add_stock.enter(); + let mut stock = app.add_stock.enter(app.chart_type); if app.previous_mode == app::Mode::DisplaySummary { stock.set_time_frame(app.summary_time_frame); @@ -96,6 +97,11 @@ fn handle_keys_display_stock(keycode: KeyCode, modifiers: KeyModifiers, mut app: app.mode = app::Mode::DisplayOptions; } } + (KeyCode::Char('e'), KeyModifiers::NONE) => { + if app.stocks[app.current_tab].toggle_configure() { + app.mode = app::Mode::ConfigureChart; + } + } (KeyCode::Tab, KeyModifiers::NONE) => { if app.current_tab == app.stocks.len() - 1 { app.current_tab = 0; @@ -225,6 +231,47 @@ fn handle_keys_display_options(keycode: KeyCode, mut app: &mut app::App) { } } +pub fn handle_keys_configure_chart(keycode: KeyCode, mut app: &mut app::App) { + match keycode { + KeyCode::Esc | KeyCode::Char('e') | KeyCode::Char('q') => { + app.stocks[app.current_tab].toggle_configure(); + app.mode = app::Mode::DisplayStock; + } + KeyCode::Up | KeyCode::BackTab => { + let config = app.stocks[app.current_tab].chart_config_mut(); + config.selection_up(); + } + KeyCode::Down | KeyCode::Tab => { + let config = app.stocks[app.current_tab].chart_config_mut(); + config.selection_down(); + } + KeyCode::Left => { + let config = app.stocks[app.current_tab].chart_config_mut(); + config.back_tab(); + } + KeyCode::Right => { + let config = app.stocks[app.current_tab].chart_config_mut(); + config.tab(); + } + KeyCode::Enter => { + let time_frame = app.stocks[app.current_tab].time_frame; + let config = app.stocks[app.current_tab].chart_config_mut(); + config.enter(time_frame); + } + KeyCode::Char(c) => { + if c.is_numeric() || c == '.' { + let config = app.stocks[app.current_tab].chart_config_mut(); + config.add_char(c); + } + } + KeyCode::Backspace => { + let config = app.stocks[app.current_tab].chart_config_mut(); + config.del_char(); + } + _ => {} + } +} + pub fn handle_key_bindings( mode: Mode, key_event: KeyEvent, @@ -251,7 +298,9 @@ pub fn handle_key_bindings( app.mode = app.previous_mode; } } - (mode, KeyModifiers::NONE, KeyCode::Char('q')) if mode != Mode::DisplayOptions => { + (mode, KeyModifiers::NONE, KeyCode::Char('q')) + if !matches!(mode, Mode::DisplayOptions | Mode::ConfigureChart) => + { cleanup_terminal(); std::process::exit(0); } @@ -259,13 +308,18 @@ pub fn handle_key_bindings( app.previous_mode = app.mode; app.mode = app::Mode::Help; } - (_, KeyModifiers::NONE, KeyCode::Char('c')) => { - let mut chart_type = CHART_TYPE.write().unwrap(); - *chart_type = chart_type.toggle(); + (_, KeyModifiers::NONE, KeyCode::Char('c')) if mode != Mode::ConfigureChart => { + app.chart_type = app.chart_type.toggle(); + + for stock in app.stocks.iter_mut() { + stock.set_chart_type(app.chart_type); + } } (_, KeyModifiers::NONE, KeyCode::Char('v')) => { - let mut show_volumes = SHOW_VOLUMES.write().unwrap(); - *show_volumes = !*show_volumes; + if app.chart_type != ChartType::Kagi { + let mut show_volumes = SHOW_VOLUMES.write().unwrap(); + *show_volumes = !*show_volumes; + } } (_, KeyModifiers::NONE, KeyCode::Char('p')) => { let mut guard = ENABLE_PRE_POST.write().unwrap(); @@ -280,11 +334,30 @@ pub fn handle_key_bindings( let mut show_x_labels = SHOW_X_LABELS.write().unwrap(); *show_x_labels = !*show_x_labels; } + (_, KeyModifiers::SHIFT, KeyCode::Left) | (_, KeyModifiers::NONE, KeyCode::Char('<')) => { + if let Some(stock) = app.stocks.get_mut(app.current_tab) { + if let Some(chart_state) = stock.chart_state_mut() { + chart_state.scroll_left(); + } + } + } + (_, KeyModifiers::SHIFT, KeyCode::Right) | (_, KeyModifiers::NONE, KeyCode::Char('>')) => { + if let Some(stock) = app.stocks.get_mut(app.current_tab) { + if let Some(chart_state) = stock.chart_state_mut() { + chart_state.scroll_right(); + } + } + } (Mode::DisplayOptions, modifiers, keycode) => { if modifiers.is_empty() { handle_keys_display_options(keycode, app) } } + (Mode::ConfigureChart, modifiers, keycode) => { + if modifiers.is_empty() { + handle_keys_configure_chart(keycode, app) + } + } (Mode::DisplayStock, modifiers, keycode) => { handle_keys_display_stock(keycode, modifiers, app) } diff --git a/src/main.rs b/src/main.rs index ba7f547..aa449df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,11 +42,6 @@ lazy_static! { pub static ref SHOW_VOLUMES: RwLock = RwLock::new(OPTS.show_volumes); pub static ref DEFAULT_TIMESTAMPS: RwLock>> = Default::default(); pub static ref THEME: theme::Theme = OPTS.theme.unwrap_or_default(); - pub static ref CHART_TYPE: RwLock = RwLock::new(if OPTS.candle { - ChartType::Candlestick - } else { - ChartType::Line - }); } fn main() { @@ -64,11 +59,13 @@ fn main() { let data_received = DATA_RECEIVED.1.clone(); let ui_events = setup_ui_events(); + let starting_chart_type = opts.chart_type.unwrap_or(ChartType::Line); + let starting_stocks: Vec<_> = opts .symbols .unwrap_or_default() .into_iter() - .map(widget::StockState::new) + .map(|symbol| widget::StockState::new(symbol, starting_chart_type)) .collect(); let starting_mode = if starting_stocks.is_empty() { @@ -103,6 +100,7 @@ fn main() { summary_time_frame: opts.time_frame.unwrap_or(TimeFrame::Day1), default_timestamp_service, summary_scroll_state: Default::default(), + chart_type: starting_chart_type, })); let move_app = app.clone(); diff --git a/src/opts.rs b/src/opts.rs index 69755fa..6e73616 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -1,23 +1,25 @@ +use std::collections::HashMap; use std::{fs, process}; use anyhow::{bail, format_err, Error}; use serde::Deserialize; use structopt::StructOpt; -use crate::common::TimeFrame; +use crate::common::{ChartType, TimeFrame}; use crate::theme::Theme; +use crate::widget::KagiOptions; pub fn resolve_opts() -> Opts { let mut opts = get_cli_opts(); if let Ok(config_opts) = get_config_opts() { // Options + opts.chart_type = opts.chart_type.or(config_opts.chart_type); opts.symbols = opts.symbols.or(config_opts.symbols); opts.time_frame = opts.time_frame.or(config_opts.time_frame); opts.update_interval = opts.update_interval.or(config_opts.update_interval); // Flags - opts.candle = opts.candle || config_opts.candle; opts.enable_pre_post = opts.enable_pre_post || config_opts.enable_pre_post; opts.hide_help = opts.hide_help || config_opts.hide_help; opts.hide_prev_close = opts.hide_prev_close || config_opts.hide_prev_close; @@ -29,6 +31,9 @@ pub fn resolve_opts() -> Opts { // Theme opts.theme = config_opts.theme; + + // Kagi Options + opts.kagi_options = config_opts.kagi_options; } opts @@ -80,6 +85,9 @@ fn get_config_opts() -> Result { pub struct Opts { // Options // + #[structopt(short, long, possible_values(&["line", "candle", "kagi"]))] + /// Chart type to start app with [default: line] + pub chart_type: Option, #[structopt(short, long, use_delimiter = true)] /// Comma separated list of ticker symbols to start app with pub symbols: Option>, @@ -92,9 +100,6 @@ pub struct Opts { // Flags // - #[structopt(long)] - /// Use candlestick charts - pub candle: bool, #[structopt(short = "p", long)] /// Enable pre / post market hours for graphs pub enable_pre_post: bool, @@ -122,6 +127,8 @@ pub struct Opts { #[structopt(skip)] pub theme: Option, + #[structopt(skip)] + pub kagi_options: HashMap, } const DEFAULT_CONFIG: &str = "--- @@ -130,6 +137,11 @@ const DEFAULT_CONFIG: &str = "--- # - SPY # - AMD +# Chart type to start app with +# Default is line +# Possible values: line, candle, kagi +#chart_type: candle + # Use specified time frame when starting program and when new stocks are added # Default is 1D # Possible values: 1D, 1W, 1M, 3M, 6M, 1Y, 5Y @@ -139,9 +151,6 @@ const DEFAULT_CONFIG: &str = "--- # Default is 1 #update_interval: 1 -# Use candlestick charts -#candle: true - # Enable pre / post market hours for graphs #enable_pre_post: true @@ -166,6 +175,40 @@ const DEFAULT_CONFIG: &str = "--- # Truncate pre market graphing to only 30 minutes prior to markets opening #trunc_pre: true +# Ticker options for Kagi charts +# +# A map of each ticker with reversal and/or price fields (both optional). If no +# entry is defined for a symbol, a default of 'close' price and 1% for 1D and 4% +# for non-1D timeframes is used. This can be updated in the GUI by pressing 'e' +# +# reversal can be supplied as a single value, or a map on time frame to give each +# time frame a different reversal amount +# +# reversal.type can be 'amount' or 'pct' +# +# price can be 'close' or 'high_low' +# +#kagi_options: +# SPY: +# reversal: +# type: amount +# value: 5.00 +# price: close +# AMD: +# price: high_low +# TSLA: +# reversal: +# type: pct +# value: 0.08 +# NVDA: +# reversal: +# 1D: +# type: pct +# value: 0.02 +# 5Y: +# type: pct +# value: 0.10 + # Apply a custom theme # # All colors are optional. If commented out / omitted, the color will get sourced diff --git a/src/widget.rs b/src/widget.rs index 187a129..c9cefb0 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -6,6 +6,7 @@ use tui::layout::Rect; use tui::widgets::StatefulWidget; pub use self::add_stock::{AddStockState, AddStockWidget}; +pub use self::chart_configuration::{ChartConfigurationWidget, KagiOptions}; pub use self::help::{HelpWidget, HELP_HEIGHT, HELP_WIDTH}; pub use self::options::{OptionsState, OptionsWidget}; pub use self::stock::{StockState, StockWidget}; @@ -14,6 +15,7 @@ pub use self::stock_summary::StockSummaryWidget; mod add_stock; pub mod block; mod chart; +pub mod chart_configuration; mod help; pub mod options; mod stock; diff --git a/src/widget/add_stock.rs b/src/widget/add_stock.rs index 911e890..803855c 100644 --- a/src/widget/add_stock.rs +++ b/src/widget/add_stock.rs @@ -5,6 +5,7 @@ use tui::text::{Span, Spans}; use tui::widgets::{Paragraph, StatefulWidget, Widget, Wrap}; use super::block; +use crate::common::ChartType; use crate::theme::style; use crate::THEME; @@ -38,8 +39,8 @@ impl AddStockState { self.error_msg = None; } - pub fn enter(&mut self) -> super::StockState { - super::StockState::new(self.search_string.clone()) + pub fn enter(&mut self, chart_type: ChartType) -> super::StockState { + super::StockState::new(self.search_string.clone(), chart_type) } } diff --git a/src/widget/chart/mod.rs b/src/widget/chart/mod.rs index 829de5a..84a7dbf 100644 --- a/src/widget/chart/mod.rs +++ b/src/widget/chart/mod.rs @@ -1,7 +1,62 @@ pub use self::prices_candlestick::PricesCandlestickChart; +pub use self::prices_kagi::PricesKagiChart; pub use self::prices_line::PricesLineChart; pub use self::volume_bar::VolumeBarChart; mod prices_candlestick; +pub mod prices_kagi; mod prices_line; mod volume_bar; + +const SCROLL_STEP: usize = 2; + +#[derive(Debug, Default, Clone, Copy, Hash)] +pub struct ChartState { + pub max_offset: Option, + pub offset: Option, + queued_scroll: Option, +} + +impl ChartState { + pub fn scroll_left(&mut self) { + self.queued_scroll = Some(ChartScrollDirection::Left); + } + + pub fn scroll_right(&mut self) { + self.queued_scroll = Some(ChartScrollDirection::Right); + } + + fn scroll(&mut self, direction: ChartScrollDirection, max_offset: usize) { + if max_offset == 0 { + return; + } + + let new_offset = match direction { + ChartScrollDirection::Left => self.offset.unwrap_or(0) + SCROLL_STEP, + ChartScrollDirection::Right => self.offset.unwrap_or(0).saturating_sub(SCROLL_STEP), + }; + + self.offset = if new_offset == 0 { + None + } else { + Some(new_offset.min(max_offset)) + }; + } + + fn offset(&mut self, max_offset: usize) -> usize { + if max_offset == 0 { + self.max_offset.take(); + self.offset.take(); + } + + self.max_offset = Some(max_offset); + + max_offset - self.offset.map(|o| o.min(max_offset)).unwrap_or(0) + } +} + +#[derive(Debug, Clone, Copy, Hash)] +enum ChartScrollDirection { + Left, + Right, +} diff --git a/src/widget/chart/prices_kagi.rs b/src/widget/chart/prices_kagi.rs new file mode 100644 index 0000000..42a59d0 --- /dev/null +++ b/src/widget/chart/prices_kagi.rs @@ -0,0 +1,629 @@ +use std::hash::{Hash, Hasher}; + +use serde::Deserialize; +use tui::buffer::Buffer; +use tui::layout::{Constraint, Direction, Layout, Rect}; +use tui::text::Span; +use tui::widgets::canvas::{Canvas, Line}; +use tui::widgets::{Block, Borders, StatefulWidget, Widget}; + +use crate::common::{Price, TimeFrame}; +use crate::draw::{add_padding, PaddingDirection}; +use crate::theme::style; +use crate::widget::chart_configuration::{KagiOptions, KagiReversalOption}; +use crate::widget::StockState; +use crate::{HIDE_PREV_CLOSE, THEME}; + +#[derive(Debug, Clone, Copy)] +struct Trend { + direction: TrendDirection, + first_price: Price, + last_price: Price, + breakpoint: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum TrendDirection { + Up, + Down, +} + +impl TrendDirection { + fn reverse(self) -> TrendDirection { + match self { + TrendDirection::Up => TrendDirection::Down, + TrendDirection::Down => TrendDirection::Up, + } + } +} + +#[derive(Debug, Clone, Copy)] +struct Breakpoint { + price: Price, + kind: BreakpointKind, +} + +#[derive(Debug, Clone, Copy)] +enum BreakpointKind { + Yang, + Ying, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(tag = "type", content = "value")] +pub enum ReversalOption { + #[serde(rename = "pct")] + Pct(f64), + #[serde(rename = "amount")] + Amount(f64), +} + +impl Hash for ReversalOption { + fn hash(&self, state: &mut H) { + match self { + ReversalOption::Pct(amount) => { + 0.hash(state); + amount.to_bits().hash(state); + } + ReversalOption::Amount(amount) => { + 1.hash(state); + amount.to_bits().hash(state); + } + } + } +} + +#[derive(Debug, Clone, Copy, Hash, Deserialize)] +pub enum PriceOption { + #[serde(rename = "close")] + Close, + #[serde(rename = "high_low")] + HighLow, +} + +#[derive(Clone, Copy)] +enum ComparisonType { + Gt, + Lt, +} + +fn choose_price(price: &Price, option: PriceOption, comparison: ComparisonType) -> f64 { + match option { + PriceOption::Close => price.close, + PriceOption::HighLow => match comparison { + ComparisonType::Gt => price.high, + ComparisonType::Lt => price.low, + }, + } +} + +fn calculate_trends( + data: &[Price], + reversal_option: ReversalOption, + price_option: PriceOption, +) -> Vec { + let mut trends = vec![]; + + // Filter out 0 prices + let data = match price_option { + PriceOption::Close => data.iter().filter(|p| p.close.gt(&0.0)).collect::>(), + PriceOption::HighLow => data.iter().filter(|p| p.low.gt(&0.0)).collect::>(), + }; + + // Exit if data is empty + if data.is_empty() { + return trends; + } + + let first_price = **data.get(0).unwrap(); + + // Find initial trend direction + let mut initial_direction = TrendDirection::Up; + for price in data[1..].iter() { + let first_price_gt = choose_price(&first_price, price_option, ComparisonType::Gt); + let first_price_lt = choose_price(&first_price, price_option, ComparisonType::Lt); + + let price_gt = choose_price(&price, price_option, ComparisonType::Gt); + let price_lt = choose_price(&price, price_option, ComparisonType::Lt); + + if price_gt.gt(&first_price_gt) { + initial_direction = TrendDirection::Up; + break; + } else if price_lt.lt(&first_price_lt) { + initial_direction = TrendDirection::Down; + break; + } + } + + let mut curr_trend: Trend = Trend { + direction: initial_direction, + first_price, + last_price: first_price, + breakpoint: None, + }; + + for (idx, price) in data[1..].iter().enumerate() { + let (reversal_amount, diff) = { + let current_price = match curr_trend.direction { + TrendDirection::Up => choose_price(&price, price_option, ComparisonType::Lt), + TrendDirection::Down => choose_price(&price, price_option, ComparisonType::Gt), + }; + let last_price = match curr_trend.direction { + TrendDirection::Up => { + choose_price(&curr_trend.last_price, price_option, ComparisonType::Gt) + } + TrendDirection::Down => { + choose_price(&curr_trend.last_price, price_option, ComparisonType::Lt) + } + }; + + match reversal_option { + ReversalOption::Pct(reversal_amount) => { + (reversal_amount, current_price / last_price - 1.0) + } + ReversalOption::Amount(reversal_amount) => { + (reversal_amount, current_price - last_price) + } + } + }; + + let is_reversal = match curr_trend.direction { + TrendDirection::Up => diff < -reversal_amount, + TrendDirection::Down => diff > reversal_amount, + }; + + // Calculate breakpoint + if let Some(prev_trend) = trends.last() { + match curr_trend.direction { + TrendDirection::Up => { + let current_price = choose_price(&price, price_option, ComparisonType::Gt); + let breakpoint_price = + choose_price(&prev_trend.first_price, price_option, ComparisonType::Gt); + + if current_price.gt(&breakpoint_price) { + curr_trend.breakpoint = Some(Breakpoint { + kind: BreakpointKind::Yang, + price: prev_trend.first_price, + }) + } + } + TrendDirection::Down => { + let current_price = choose_price(&price, price_option, ComparisonType::Lt); + let breakpoint_price = + choose_price(&prev_trend.first_price, price_option, ComparisonType::Lt); + + if current_price.lt(&breakpoint_price) { + curr_trend.breakpoint = Some(Breakpoint { + kind: BreakpointKind::Ying, + price: prev_trend.first_price, + }) + } + } + } + } + + // Set last / low / high of trend where applicable + match curr_trend.direction { + TrendDirection::Up => { + let current_price = choose_price(&price, price_option, ComparisonType::Gt); + let last_price = + choose_price(&curr_trend.last_price, price_option, ComparisonType::Gt); + + if current_price.gt(&last_price) { + curr_trend.last_price = **price; + } + } + TrendDirection::Down => { + let current_price = choose_price(&price, price_option, ComparisonType::Lt); + let last_price = + choose_price(&curr_trend.last_price, price_option, ComparisonType::Lt); + + if current_price.lt(&last_price) { + curr_trend.last_price = **price; + } + } + } + + // Store this trend and start the next one + if is_reversal || idx == data[1..].len() - 1 { + trends.push(curr_trend); + + curr_trend = Trend { + direction: curr_trend.direction.reverse(), + first_price: curr_trend.last_price, + last_price: **price, + breakpoint: None, + } + } + } + + trends +} + +pub struct PricesKagiChart<'a> { + pub loaded: bool, + pub data: &'a [Price], + pub is_summary: bool, + pub show_x_labels: bool, + pub kagi_options: KagiOptions, +} + +impl<'a> PricesKagiChart<'a> { + fn min_max( + &self, + data: &[Trend], + time_frame: TimeFrame, + prev_close_price: Option, + ) -> (f64, f64) { + let (mut max, mut min) = self.high_low(data); + + if time_frame == TimeFrame::Day1 && !*HIDE_PREV_CLOSE { + if let Some(prev_close) = prev_close_price { + if prev_close.le(&min) { + min = prev_close; + } + + if prev_close.gt(&max) { + max = prev_close; + } + } + } + + (min, max) + } + + fn high_low(&self, data: &[Trend]) -> (f64, f64) { + let high = data + .iter() + .map(|t| { + if t.direction == TrendDirection::Up { + t.last_price + } else { + t.first_price + } + }) + .max_by(|a, b| a.high.partial_cmp(&b.high).unwrap()) + .map(|p| p.high) + .unwrap_or(1.0); + let low = data + .iter() + .map(|t| { + if t.direction == TrendDirection::Up { + t.first_price + } else { + t.last_price + } + }) + .min_by(|a, b| a.low.partial_cmp(&b.low).unwrap()) + .map(|p| p.low) + .unwrap_or(0.0); + + (high, low) + } +} + +impl<'a> StatefulWidget for PricesKagiChart<'a> { + type State = StockState; + + #[allow(clippy::clippy::unnecessary_unwrap)] + fn render(self, mut area: Rect, buf: &mut Buffer, state: &mut Self::State) { + if area.width <= 9 || area.height <= 3 { + return; + } + + let default_reversal_option = match state.time_frame { + TimeFrame::Day1 => ReversalOption::Pct(0.01), + _ => ReversalOption::Pct(0.04), + }; + + let reversal_option = self + .kagi_options + .reversal_option + .as_ref() + .map(|o| match o { + KagiReversalOption::Single(option) => *option, + KagiReversalOption::ByTimeFrame(options_by_timeframe) => options_by_timeframe + .get(&state.time_frame) + .copied() + .unwrap_or(default_reversal_option), + }) + .unwrap_or(default_reversal_option); + + let price_option = self.kagi_options.price_option.unwrap_or(PriceOption::Close); + + let kagi_trends = calculate_trends(&self.data, reversal_option, price_option); + + if !self.is_summary { + Block::default() + .borders(Borders::TOP) + .border_style(style().fg(THEME.border_secondary())) + .render(area, buf); + area = add_padding(area, 1, PaddingDirection::Top); + } + + // x_layout[0] - chart + y labels + // x_layout[1] - (x labels) + let x_layout = Layout::default() + .constraints(if self.show_x_labels { + &[Constraint::Min(0), Constraint::Length(1)][..] + } else { + &[Constraint::Min(0)][..] + }) + .split(area); + + // layout[0] - Y lables + // layout[1] - chart + let mut layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(if !self.loaded { + 8 + } else if self.show_x_labels { + match state.time_frame { + TimeFrame::Day1 => 9, + TimeFrame::Week1 => 12, + _ => 11, + } + } else { + 9 + }), + Constraint::Min(0), + ]) + .split(x_layout[0]); + + // Fix for border render + layout[1].x = layout[1].x.saturating_sub(1); + layout[1].width += 1; + + let width = layout[1].width - 1; + let num_trends_can_render = width as f64 / 1.5; + let num_trends = kagi_trends.len() as f64; + let max_offset = if num_trends > num_trends_can_render { + (num_trends - num_trends_can_render).ceil() as usize + } else { + 0 + }; + + let chart_width = num_trends_can_render * 3.0; + + let offset = if self.is_summary { + max_offset + } else if let Some(chart_state) = state.chart_state_mut() { + if let Some(direction) = chart_state.queued_scroll.take() { + chart_state.scroll(direction, max_offset); + } + + chart_state.offset(max_offset) + } else { + max_offset + }; + + let (min, max) = self.min_max( + &kagi_trends[offset..offset + num_trends_can_render.min(num_trends).floor() as usize], + state.time_frame, + state.prev_close_price, + ); + + // Draw x labels + if self.show_x_labels && self.loaded { + // Fix for y label render + layout[0] = add_padding(layout[0], 1, PaddingDirection::Bottom); + + // Plot labels on + let mut x_area = x_layout[1]; + x_area.x = layout[1].x + 1; + x_area.width = (num_trends_can_render.min(num_trends) * 1.5).floor() as u16; + + let labels = x_labels( + x_area.width + x_area.left(), + &kagi_trends + [offset..offset + num_trends_can_render.min(num_trends).floor() as usize], + state.time_frame, + ); + let total_width = labels.iter().map(Span::width).sum::() as u16; + let labels_len = labels.len() as u16; + if total_width <= (x_area.width + x_area.x) && labels_len >= 1 { + for (i, label) in labels.iter().enumerate() { + buf.set_span( + x_area.left() + i as u16 * (x_area.width - 1) / (labels_len.max(2) - 1) + - label.width() as u16, + x_area.top(), + label, + label.width() as u16, + ); + } + } + } + + // Draw y labels + if self.loaded { + let y_area = layout[0]; + + let labels = state.y_labels(min, max); + let labels_len = labels.len() as u16; + for (i, label) in labels.iter().enumerate() { + let dy = i as u16 * (y_area.height - 1) / (labels_len - 1); + if dy < y_area.bottom() { + buf.set_span( + y_area.left(), + y_area.bottom() - 1 - dy, + label, + label.width() as u16, + ); + } + } + } + + if self.loaded { + Canvas::default() + .background_color(THEME.background()) + .block( + Block::default() + .style(style()) + .borders(if self.show_x_labels { + Borders::LEFT | Borders::BOTTOM + } else { + Borders::LEFT + }) + .border_style(style().fg(THEME.border_axis())), + ) + .x_bounds([0.0, chart_width]) + .y_bounds(state.y_bounds(min, max)) + .paint(move |ctx| { + if state.time_frame == TimeFrame::Day1 + && self.loaded + && !*HIDE_PREV_CLOSE + && state.prev_close_price.is_some() + { + ctx.draw(&Line { + x1: 0.0, + x2: chart_width, + y1: state.prev_close_price.unwrap(), + y2: state.prev_close_price.unwrap(), + color: THEME.gray(), + }); + } + + ctx.layer(); + + let mut color = if let Some(first_trend) = kagi_trends.first() { + match first_trend.direction { + TrendDirection::Up => THEME.profit(), + TrendDirection::Down => THEME.loss(), + } + } else { + THEME.profit() + }; + + for (idx, trend) in kagi_trends + [offset..offset + num_trends_can_render.min(num_trends).floor() as usize] + .iter() + .enumerate() + { + let start = choose_price( + &trend.first_price, + price_option, + if trend.direction == TrendDirection::Up { + ComparisonType::Lt + } else { + ComparisonType::Gt + }, + ); + let mid = if let Some(breakpoint) = &trend.breakpoint { + choose_price( + &breakpoint.price, + price_option, + if trend.direction == TrendDirection::Up { + ComparisonType::Gt + } else { + ComparisonType::Lt + }, + ) + } else { + choose_price( + &trend.last_price, + price_option, + if trend.direction == TrendDirection::Up { + ComparisonType::Gt + } else { + ComparisonType::Lt + }, + ) + }; + let end = choose_price( + &trend.last_price, + price_option, + if trend.direction == TrendDirection::Up { + ComparisonType::Gt + } else { + ComparisonType::Lt + }, + ); + + // Draw connector to prev line + ctx.draw(&Line { + x1: (idx as f64 * 3.0 - 1.0).max(0.0), + x2: idx as f64 * 3.0 + 2.0, + y1: start, + y2: start, + color, + }); + + // Draw through mid (mid = end if no breakpoint) + ctx.draw(&Line { + x1: idx as f64 * 3.0 + 2.0, + x2: idx as f64 * 3.0 + 2.0, + y1: start, + y2: mid, + color, + }); + + // If there's a midpoint, change colors and draw through end + if let Some(breakpoint) = &trend.breakpoint { + color = match breakpoint.kind { + BreakpointKind::Yang => THEME.profit(), + BreakpointKind::Ying => THEME.loss(), + }; + + ctx.draw(&Line { + x1: idx as f64 * 3.0 + 2.0, + x2: idx as f64 * 3.0 + 2.0, + y1: mid, + y2: end, + color, + }); + } + } + }) + .render(layout[1], buf); + } else { + Block::default() + .borders(if self.show_x_labels { + Borders::LEFT | Borders::BOTTOM + } else { + Borders::LEFT + }) + .border_style(style().fg(THEME.border_axis())) + .render(layout[1], buf); + } + } +} + +fn x_labels(width: u16, trends: &[Trend], time_frame: TimeFrame) -> Vec { + let mut labels = vec![]; + + let trends = trends + .iter() + .map(|t| t.first_price.date) + .collect::>(); + + if trends.is_empty() { + return labels; + } + + let label_len = trends + .get(0) + .map_or(0, |d| time_frame.format_time(*d).len()) + + 5; + + let num_labels = width as usize / label_len; + + if num_labels == 0 { + return labels; + } + + for i in 0..num_labels { + let idx = i * (trends.len() - 1) / (num_labels.max(2) - 1); + + let timestamp = trends.get(idx).unwrap(); + + let label = Span::styled( + time_frame.format_time(*timestamp), + style().fg(THEME.text_normal()), + ); + + labels.push(label); + } + + labels +} diff --git a/src/widget/chart_configuration.rs b/src/widget/chart_configuration.rs new file mode 100644 index 0000000..999c198 --- /dev/null +++ b/src/widget/chart_configuration.rs @@ -0,0 +1,482 @@ +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; + +use crossterm::terminal; +use serde::Deserialize; +use tui::buffer::Buffer; +use tui::layout::{Constraint, Layout, Rect}; +use tui::text::{Span, Spans}; +use tui::widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}; + +use super::chart::prices_kagi::{self, ReversalOption}; +use super::{block, CachableWidget, CacheState}; +use crate::common::{ChartType, TimeFrame}; +use crate::draw::{add_padding, PaddingDirection}; +use crate::theme::style; +use crate::THEME; + +#[derive(Default, Debug, Clone)] +pub struct ChartConfigurationState { + pub input: Input, + pub selection: Option, + pub error_message: Option, + pub kagi_options: KagiOptions, + pub cache_state: CacheState, +} + +impl ChartConfigurationState { + pub fn add_char(&mut self, c: char) { + let input_field = match self.selection { + Some(Selection::KagiReversalValue) => &mut self.input.kagi_reversal_value, + _ => return, + }; + + // Width of our text input box + if input_field.len() == 20 { + return; + } + + input_field.push(c); + } + + pub fn del_char(&mut self) { + let input_field = match self.selection { + Some(Selection::KagiReversalValue) => &mut self.input.kagi_reversal_value, + _ => return, + }; + + input_field.pop(); + } + + fn get_tab_artifacts(&mut self) -> Option<(&mut usize, usize)> { + let tab_field = match self.selection { + Some(Selection::KagiReversalType) => &mut self.input.kagi_reversal_type, + Some(Selection::KagiPriceType) => &mut self.input.kagi_price_type, + _ => return None, + }; + + let mod_value = match self.selection { + Some(Selection::KagiReversalType) => 2, + Some(Selection::KagiPriceType) => 2, + _ => 1, + }; + Some((tab_field, mod_value)) + } + + pub fn tab(&mut self) { + if let Some((tab_field, mod_value)) = self.get_tab_artifacts() { + *tab_field = (*tab_field + 1) % mod_value; + } + } + + pub fn back_tab(&mut self) { + if let Some((tab_field, mod_value)) = self.get_tab_artifacts() { + *tab_field = (*tab_field + mod_value - 1) % mod_value; + } + } + + pub fn enter(&mut self, time_frame: TimeFrame) { + self.error_message.take(); + + // Validate Kagi reversal option + let new_kagi_reversal_option = { + let input_value = &self.input.kagi_reversal_value; + + let value = match input_value.parse::() { + Ok(value) => value, + Err(_) => { + self.error_message = Some("Reversal Value must be a valid number".to_string()); + return; + } + }; + + match self.input.kagi_reversal_type { + 0 => ReversalOption::Pct(value), + 1 => ReversalOption::Amount(value), + _ => unreachable!(), + } + }; + + let new_kagi_price_option = Some(match self.input.kagi_price_type { + 0 => prices_kagi::PriceOption::Close, + 1 => prices_kagi::PriceOption::HighLow, + _ => unreachable!(), + }); + + // Everything validated, save the form values to our state + match &mut self.kagi_options.reversal_option { + reversal_options @ None => { + let mut options_by_timeframe = BTreeMap::new(); + for iter_time_frame in TimeFrame::ALL.iter() { + let default_reversal_amount = match iter_time_frame { + TimeFrame::Day1 => 0.01, + _ => 0.04, + }; + + // If this is the time frame we are submitting for, store that value, + // otherwise use the default still + if *iter_time_frame == time_frame { + options_by_timeframe.insert(*iter_time_frame, new_kagi_reversal_option); + } else { + options_by_timeframe.insert( + *iter_time_frame, + ReversalOption::Pct(default_reversal_amount), + ); + } + } + + *reversal_options = Some(KagiReversalOption::ByTimeFrame(options_by_timeframe)); + } + reversal_options @ Some(KagiReversalOption::Single(_)) => { + // Always succeeds since we already pattern matched it + if let KagiReversalOption::Single(config_option) = reversal_options.clone().unwrap() + { + let mut options_by_timeframe = BTreeMap::new(); + for iter_time_frame in TimeFrame::ALL.iter() { + // If this is the time frame we are submitting for, store that value, + // otherwise use the single value defined from the config + if *iter_time_frame == time_frame { + options_by_timeframe.insert(*iter_time_frame, new_kagi_reversal_option); + } else { + options_by_timeframe.insert(*iter_time_frame, config_option); + } + } + + *reversal_options = Some(KagiReversalOption::ByTimeFrame(options_by_timeframe)); + } + } + Some(KagiReversalOption::ByTimeFrame(options_by_timeframe)) => { + options_by_timeframe.insert(time_frame, new_kagi_reversal_option); + } + } + + self.kagi_options.price_option = new_kagi_price_option; + } + + pub fn selection_up(&mut self) { + let new_selection = match self.selection { + None => Selection::KagiReversalValue, + Some(Selection::KagiReversalValue) => Selection::KagiReversalType, + Some(Selection::KagiReversalType) => Selection::KagiPriceType, + Some(Selection::KagiPriceType) => Selection::KagiReversalValue, + }; + + self.selection = Some(new_selection); + } + + pub fn selection_down(&mut self) { + let new_selection = match self.selection { + None => Selection::KagiPriceType, + Some(Selection::KagiPriceType) => Selection::KagiReversalType, + Some(Selection::KagiReversalType) => Selection::KagiReversalValue, + Some(Selection::KagiReversalValue) => Selection::KagiPriceType, + }; + + self.selection = Some(new_selection); + } + + pub fn reset_form(&mut self, time_frame: TimeFrame) { + self.input = Default::default(); + self.error_message.take(); + + let default_reversal_amount = match time_frame { + TimeFrame::Day1 => 0.01, + _ => 0.04, + }; + + let (reversal_type, reversal_amount) = self + .kagi_options + .reversal_option + .as_ref() + .map(|o| { + let option = match o { + KagiReversalOption::Single(option) => *option, + KagiReversalOption::ByTimeFrame(options_by_timeframe) => options_by_timeframe + .get(&time_frame) + .copied() + .unwrap_or(ReversalOption::Pct(default_reversal_amount)), + }; + + match option { + ReversalOption::Pct(amount) => (0, amount), + ReversalOption::Amount(amount) => (1, amount), + } + }) + .unwrap_or((0, default_reversal_amount)); + + let price_type = self + .kagi_options + .price_option + .map(|p| match p { + prices_kagi::PriceOption::Close => 0, + prices_kagi::PriceOption::HighLow => 1, + }) + .unwrap_or(0); + + self.selection = Some(Selection::KagiPriceType); + self.input.kagi_reversal_value = format!("{:.2}", reversal_amount); + self.input.kagi_reversal_type = reversal_type; + self.input.kagi_price_type = price_type; + } +} + +impl Hash for ChartConfigurationState { + fn hash(&self, state: &mut H) { + self.input.hash(state); + self.selection.hash(state); + self.error_message.hash(state); + self.kagi_options.hash(state); + } +} + +#[derive(Debug, Default, Clone, Hash)] +pub struct Input { + pub kagi_reversal_type: usize, + pub kagi_reversal_value: String, + pub kagi_price_type: usize, +} + +#[derive(Default, Debug, Clone, Deserialize, Hash)] +pub struct KagiOptions { + #[serde(rename = "reversal")] + pub reversal_option: Option, + #[serde(rename = "price")] + pub price_option: Option, +} + +#[derive(Debug, Clone, Deserialize, Hash)] +#[serde(untagged)] +pub enum KagiReversalOption { + Single(prices_kagi::ReversalOption), + ByTimeFrame(BTreeMap), +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq)] +pub enum Selection { + KagiPriceType, + KagiReversalType, + KagiReversalValue, +} + +pub struct ChartConfigurationWidget { + pub chart_type: ChartType, + pub time_frame: TimeFrame, +} + +impl StatefulWidget for ChartConfigurationWidget { + type State = ChartConfigurationState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + self.render_cached(area, buf, state); + } +} + +impl CachableWidget for ChartConfigurationWidget { + fn cache_state_mut(state: &mut ChartConfigurationState) -> &mut CacheState { + &mut state.cache_state + } + + fn render(self, mut area: Rect, buf: &mut Buffer, state: &mut ChartConfigurationState) { + block::new(" Configuration ").render(area, buf); + area = add_padding(area, 1, PaddingDirection::All); + area = add_padding(area, 1, PaddingDirection::Left); + area = add_padding(area, 1, PaddingDirection::Right); + + // layout[0] - Info / Error message + // layout[1] - Kagi options + let mut layout = Layout::default() + .constraints([Constraint::Length(6), Constraint::Min(0)]) + .split(area); + + layout[0] = add_padding(layout[0], 1, PaddingDirection::Top); + layout[0] = add_padding(layout[0], 1, PaddingDirection::Bottom); + + let info_error = if let Some(msg) = state.error_message.as_ref() { + vec![Spans::from(Span::styled(msg, style().fg(THEME.loss())))] + } else { + vec![ + Spans::from(Span::styled( + " : move up / down", + style().fg(THEME.text_normal()), + )), + Spans::from(Span::styled( + " : move up / down", + style().fg(THEME.text_normal()), + )), + Spans::from(Span::styled( + " : toggle option", + style().fg(THEME.text_normal()), + )), + Spans::from(Span::styled( + " : submit changes", + style().fg(THEME.text_normal()), + )), + ] + }; + + Paragraph::new(info_error) + .style(style().fg(THEME.text_normal())) + .render(layout[0], buf); + + match self.chart_type { + ChartType::Line => {} + ChartType::Candlestick => {} + ChartType::Kagi => render_kagi_options(layout[1], buf, state), + } + } +} + +fn render_kagi_options(mut area: Rect, buf: &mut Buffer, state: &mut ChartConfigurationState) { + Block::default() + .style(style()) + .title(vec![Span::styled( + "Kagi Options ", + style().fg(THEME.text_normal()), + )]) + .borders(Borders::TOP) + .border_style(style().fg(THEME.border_secondary())) + .render(area, buf); + + area = add_padding(area, 1, PaddingDirection::Top); + + // layout[0] - Left column + // layout[1] - Divider + // layout[2] - Right Column + let layout = Layout::default() + .direction(tui::layout::Direction::Horizontal) + .constraints( + [ + Constraint::Length(16), + Constraint::Length(3), + Constraint::Min(0), + ] + .as_ref(), + ) + .split(area); + + let left_column = vec![ + Spans::default(), + Spans::from(vec![ + Span::styled( + if state.selection == Some(Selection::KagiPriceType) { + "> " + } else { + " " + }, + style().fg(THEME.text_primary()), + ), + Span::styled("Price Type", style().fg(THEME.text_normal())), + ]), + Spans::default(), + Spans::from(vec![ + Span::styled( + if state.selection == Some(Selection::KagiReversalType) { + "> " + } else { + " " + }, + style().fg(THEME.text_primary()), + ), + Span::styled("Reversal Type", style().fg(THEME.text_normal())), + ]), + Spans::default(), + Spans::from(vec![ + Span::styled( + if state.selection == Some(Selection::KagiReversalValue) { + "> " + } else { + " " + }, + style().fg(THEME.text_primary()), + ), + Span::styled("Reversal Value", style().fg(THEME.text_normal())), + ]), + ]; + + let right_column = vec![ + Spans::default(), + Spans::from(vec![ + Span::styled( + "Close", + style().fg(THEME.text_normal()).bg( + match (state.selection, state.input.kagi_price_type) { + (Some(Selection::KagiPriceType), 0) => THEME.highlight_focused(), + (_, 0) => THEME.highlight_unfocused(), + (_, _) => THEME.background(), + }, + ), + ), + Span::styled(" | ", style().fg(THEME.text_normal())), + Span::styled( + "High / Low", + style().fg(THEME.text_normal()).bg( + match (state.selection, state.input.kagi_price_type) { + (Some(Selection::KagiPriceType), 1) => THEME.highlight_focused(), + (_, 1) => THEME.highlight_unfocused(), + (_, _) => THEME.background(), + }, + ), + ), + ]), + Spans::default(), + Spans::from(vec![ + Span::styled( + "Pct", + style().fg(THEME.text_normal()).bg( + match (state.selection, state.input.kagi_reversal_type) { + (Some(Selection::KagiReversalType), 0) => THEME.highlight_focused(), + (_, 0) => THEME.highlight_unfocused(), + (_, _) => THEME.background(), + }, + ), + ), + Span::styled(" | ", style().fg(THEME.text_normal())), + Span::styled( + "Amount", + style().fg(THEME.text_normal()).bg( + match (state.selection, state.input.kagi_reversal_type) { + (Some(Selection::KagiReversalType), 1) => THEME.highlight_focused(), + (_, 1) => THEME.highlight_unfocused(), + (_, _) => THEME.background(), + }, + ), + ), + ]), + Spans::default(), + Spans::from(vec![Span::styled( + format!("{: <22}", &state.input.kagi_reversal_value), + style() + .fg(if state.selection == Some(Selection::KagiReversalValue) { + THEME.text_secondary() + } else { + THEME.text_normal() + }) + .bg(if state.selection == Some(Selection::KagiReversalValue) { + THEME.highlight_unfocused() + } else { + THEME.background() + }), + )]), + ]; + + Paragraph::new(left_column) + .style(style().fg(THEME.text_normal())) + .render(layout[0], buf); + + Paragraph::new(right_column) + .style(style().fg(THEME.text_normal())) + .render(layout[2], buf); + + // Set "cursor" color + if matches!(state.selection, Some(Selection::KagiReversalValue)) { + let size = terminal::size().unwrap_or((0, 0)); + + let x = layout[2].left() as usize + state.input.kagi_reversal_value.len().min(20); + let y = layout[2].top() as usize + 5; + let idx = y * size.0 as usize + x; + + if let Some(cell) = buf.content.get_mut(idx) { + cell.bg = THEME.text_secondary(); + } + } +} diff --git a/src/widget/help.rs b/src/widget/help.rs index a0ed707..3d625dc 100644 --- a/src/widget/help.rs +++ b/src/widget/help.rs @@ -15,8 +15,6 @@ Add Stock: - (while adding): - : accept - : quit -Remove Stock: - - k: remove stock Change Tab: - : next stock - : previous stock @@ -26,9 +24,13 @@ Reorder Current Tab: Change Time Frame: - : next time frame - : previous time frame +Toggle Summary Pane: + - s: toggle pane + - : scroll pane "#; const RIGHT_TEXT: &str = r#" +Remove Stock: k Graphing Display: - c: toggle candlestick chart - p: toggle pre / post market @@ -40,15 +42,18 @@ Toggle Options Pane: - : toggle calls / puts - Navigate with arrow keys - Cryptocurrency not supported -Toggle Summary Pane: - - s: toggle pane - - : scroll pane +Toggle Chart Configurations Pane: + - e: toggle pane + - : move up/down + - : move up/down + - : select options + - : submit changes "#; const LEFT_WIDTH: usize = 34; -const RIGHT_WIDTH: usize = 32; +const RIGHT_WIDTH: usize = 35; pub const HELP_WIDTH: usize = 2 + LEFT_WIDTH + 2 + RIGHT_WIDTH + 2; -pub const HELP_HEIGHT: usize = 2 + 17 + 1; +pub const HELP_HEIGHT: usize = 2 + 18 + 1; #[derive(Copy, Clone)] pub struct HelpWidget {} diff --git a/src/widget/stock.rs b/src/widget/stock.rs index a2b8319..b059554 100644 --- a/src/widget/stock.rs +++ b/src/widget/stock.rs @@ -6,7 +6,10 @@ use tui::style::Modifier; use tui::text::{Span, Spans}; use tui::widgets::{Block, Borders, Paragraph, StatefulWidget, Tabs, Widget, Wrap}; -use super::chart::{PricesCandlestickChart, PricesLineChart, VolumeBarChart}; +use super::chart::{ + ChartState, PricesCandlestickChart, PricesKagiChart, PricesLineChart, VolumeBarChart, +}; +use super::chart_configuration::ChartConfigurationState; use super::{block, CachableWidget, CacheState, OptionsState}; use crate::api::model::{ChartMeta, CompanyData}; use crate::common::*; @@ -14,7 +17,7 @@ use crate::draw::{add_padding, PaddingDirection}; use crate::service::{self, Service}; use crate::theme::style; use crate::{ - CHART_TYPE, DEFAULT_TIMESTAMPS, ENABLE_PRE_POST, HIDE_PREV_CLOSE, HIDE_TOGGLE, SHOW_VOLUMES, + DEFAULT_TIMESTAMPS, ENABLE_PRE_POST, HIDE_PREV_CLOSE, HIDE_TOGGLE, OPTS, SHOW_VOLUMES, SHOW_X_LABELS, THEME, TIME_FRAME, TRUNC_PRE, }; @@ -22,6 +25,7 @@ const NUM_LOADING_TICKS: usize = 4; pub struct StockState { pub symbol: String, + pub chart_type: ChartType, pub stock_service: service::stock::StockService, pub profile: Option, pub current_regular_price: f64, @@ -31,16 +35,20 @@ pub struct StockState { pub prices: [Vec; 7], pub time_frame: TimeFrame, pub show_options: bool, + pub show_configure: bool, pub options: Option, + pub chart_configuration: ChartConfigurationState, pub loading_tick: usize, pub prev_state_loaded: bool, pub chart_meta: Option, + pub chart_state: Option, pub cache_state: CacheState, } impl Hash for StockState { fn hash(&self, state: &mut H) { self.symbol.hash(state); + self.chart_type.hash(state); self.current_regular_price.to_bits().hash(state); // Only fetched once, so just need to check if Some self.profile.is_some().hash(state); @@ -50,10 +58,16 @@ impl Hash for StockState { self.prices.hash(state); self.time_frame.hash(state); self.show_options.hash(state); + self.show_configure.hash(state); + self.chart_configuration.hash(state); self.loading_tick.hash(state); self.prev_state_loaded.hash(state); self.chart_meta.hash(state); + if let Some(chart_state) = self.chart_state.as_ref() { + chart_state.hash(state); + } + // Hash globals since they affect "state" of how widget is rendered DEFAULT_TIMESTAMPS .read() @@ -66,18 +80,19 @@ impl Hash for StockState { SHOW_VOLUMES.read().unwrap().hash(state); SHOW_X_LABELS.read().unwrap().hash(state); TRUNC_PRE.hash(state); - CHART_TYPE.read().unwrap().hash(state); } } impl StockState { - pub fn new(symbol: String) -> StockState { + pub fn new(symbol: String, chart_type: ChartType) -> StockState { let time_frame = *TIME_FRAME; let stock_service = service::stock::StockService::new(symbol.clone(), time_frame); + let kagi_options = OPTS.kagi_options.get(&symbol).cloned().unwrap_or_default(); StockState { symbol, + chart_type, stock_service, profile: None, current_regular_price: 0.0, @@ -87,11 +102,17 @@ impl StockState { prices: [vec![], vec![], vec![], vec![], vec![], vec![], vec![]], time_frame, show_options: false, + show_configure: false, options: None, + chart_configuration: ChartConfigurationState { + kagi_options, + ..Default::default() + }, loading_tick: NUM_LOADING_TICKS, prev_state_loaded: false, chart_meta: None, cache_state: Default::default(), + chart_state: None, } } @@ -111,6 +132,9 @@ impl StockState { self.time_frame = time_frame; self.stock_service.update_time_frame(time_frame); + + // Resets chart state where applicable + self.set_chart_type(self.chart_type); } pub fn prices(&self) -> impl Iterator { @@ -240,6 +264,10 @@ impl StockState { !self.is_crypto() } + fn configure_enabled(&self) -> bool { + self.chart_type == ChartType::Kagi + } + fn is_crypto(&self) -> bool { self.chart_meta .as_ref() @@ -263,6 +291,18 @@ impl StockState { true } + pub fn toggle_configure(&mut self) -> bool { + if !self.configure_enabled() { + return false; + } + + self.show_configure = !self.show_configure; + + self.chart_configuration.reset_form(self.time_frame); + + true + } + pub fn start_end(&self) -> (i64, i64) { let enable_pre_post = { *ENABLE_PRE_POST.read().unwrap() }; @@ -417,29 +457,24 @@ impl StockState { .get(0) .map_or(0, |d| self.time_frame.format_time(*d).len()) + 5; + let num_labels = width as usize / label_len; - let chunk_size = (dates.len() as f32 / (num_labels - 1) as f32).ceil() as usize; - for (idx, chunk) in dates.chunks(chunk_size).enumerate() { - if idx == 0 { - labels.push(chunk.get(0).map_or(Span::raw("".to_string()), |d| { - Span::styled( - self.time_frame.format_time(*d), - style().fg(THEME.text_normal()), - ) - })); - } + if num_labels == 0 { + return labels; + } - labels.push( - chunk - .get(chunk.len() - 1) - .map_or(Span::raw("".to_string()), |d| { - Span::styled( - self.time_frame.format_time(*d), - style().fg(THEME.text_normal()), - ) - }), + for i in 0..num_labels { + let idx = i * (dates.len() - 1) / (num_labels.max(2) - 1); + + let timestamp = dates.get(idx).unwrap(); + + let label = Span::styled( + self.time_frame.format_time(*timestamp), + style().fg(THEME.text_normal()), ); + + labels.push(label); } labels @@ -513,6 +548,24 @@ impl StockState { self.prev_state_loaded = true; } } + + pub fn set_chart_type(&mut self, chart_type: ChartType) { + self.chart_state.take(); + + if chart_type == ChartType::Kagi { + self.chart_state = Some(Default::default()); + } + + self.chart_type = chart_type; + } + + pub fn chart_state_mut(&mut self) -> Option<&mut ChartState> { + self.chart_state.as_mut() + } + + pub fn chart_config_mut(&mut self) -> &mut ChartConfigurationState { + &mut self.chart_configuration + } } pub struct StockWidget {} @@ -536,10 +589,10 @@ impl CachableWidget for StockWidget { let pct_change = state.pct_change(&data); - let chart_type = *CHART_TYPE.read().unwrap(); + let chart_type = state.chart_type; let show_x_labels = SHOW_X_LABELS.read().map_or(false, |l| *l); let enable_pre_post = *ENABLE_PRE_POST.read().unwrap(); - let show_volumes = *SHOW_VOLUMES.read().unwrap(); + let show_volumes = *SHOW_VOLUMES.read().unwrap() && chart_type != ChartType::Kagi; let loaded = state.loaded(); @@ -575,7 +628,7 @@ impl CachableWidget for StockWidget { // chunks[0] - Company Info // chunks[1] - Graph - fill remaining space // chunks[2] - Time Frame Tabs - let chunks = Layout::default() + let mut chunks = Layout::default() .constraints( [ Constraint::Length(6), @@ -686,24 +739,23 @@ impl CachableWidget for StockWidget { if loaded { left_info.push(Spans::from(Span::styled( - format!( - "{: <8} 'c'", - if chart_type == ChartType::Line { - "Candle" - } else { - "Line" - } - ), + format!("{: <8} 'c'", chart_type.toggle().as_str()), style(), ))); left_info.push(Spans::from(Span::styled( "Volumes 'v'", - style().bg(if show_volumes { - THEME.highlight_unfocused() - } else { - THEME.background() - }), + style() + .bg(if show_volumes { + THEME.highlight_unfocused() + } else { + THEME.background() + }) + .fg(if chart_type == ChartType::Kagi { + THEME.gray() + } else { + THEME.text_normal() + }), ))); left_info.push(Spans::from(Span::styled( @@ -723,6 +775,21 @@ impl CachableWidget for StockWidget { THEME.background() }), ))); + + right_info.push(Spans::from(Span::styled( + "Edit 'e'", + style() + .bg(if state.show_configure { + THEME.highlight_unfocused() + } else { + THEME.background() + }) + .fg(if state.configure_enabled() { + THEME.text_normal() + } else { + THEME.gray() + }), + ))); } if state.options_enabled() && loaded { @@ -782,6 +849,16 @@ impl CachableWidget for StockWidget { } .render(graph_chunks[0], buf, state); } + ChartType::Kagi => { + PricesKagiChart { + data: &data, + loaded, + show_x_labels, + is_summary: false, + kagi_options: state.chart_configuration.kagi_options.clone(), + } + .render(graph_chunks[0], buf, state); + } } // Draw volumes bar chart @@ -794,23 +871,61 @@ impl CachableWidget for StockWidget { .render(graph_chunks[1], buf, state); } - // Draw time frame tabs + // Draw time frame tabs & optional chart scroll indicators { + Block::default() + .borders(Borders::TOP) + .border_style(style().fg(THEME.border_secondary())) + .render(chunks[2], buf); + chunks[2] = add_padding(chunks[2], 1, PaddingDirection::Top); + + // layout[0] - timeframe + // layout[1] - scroll indicators + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(if state.chart_state.is_some() { + [Constraint::Min(0), Constraint::Length(3)].as_ref() + } else { + [Constraint::Min(0)].as_ref() + }) + .split(chunks[2]); + let tab_names = TimeFrame::tab_names() .iter() .map(|s| Spans::from(*s)) .collect(); Tabs::new(tab_names) - .block( - Block::default() - .borders(Borders::TOP) - .border_style(style().fg(THEME.border_secondary())), - ) .select(state.time_frame.idx()) .style(style().fg(THEME.text_secondary())) .highlight_style(style().fg(THEME.text_primary())) - .render(chunks[2], buf); + .render(layout[0], buf); + + if let Some(chart_state) = state.chart_state.as_ref() { + let more_left = chart_state.offset.unwrap_or_default() + < chart_state.max_offset.unwrap_or_default(); + let more_right = chart_state.offset.is_some(); + + let left_arrow = Span::styled( + "ᐸ", + style().fg(if more_left { + THEME.text_normal() + } else { + THEME.gray() + }), + ); + let right_arrow = Span::styled( + "ᐳ", + style().fg(if more_right { + THEME.text_normal() + } else { + THEME.gray() + }), + ); + + Paragraph::new(Spans::from(vec![left_arrow, Span::raw(" "), right_arrow])) + .render(layout[1], buf); + } } } } diff --git a/src/widget/stock_summary.rs b/src/widget/stock_summary.rs index 655c184..9398f39 100644 --- a/src/widget/stock_summary.rs +++ b/src/widget/stock_summary.rs @@ -4,13 +4,13 @@ use tui::style::Modifier; use tui::text::{Span, Spans}; use tui::widgets::{Block, Borders, Paragraph, StatefulWidget, Widget}; -use super::chart::{PricesCandlestickChart, PricesLineChart, VolumeBarChart}; +use super::chart::{PricesCandlestickChart, PricesKagiChart, PricesLineChart, VolumeBarChart}; use super::stock::StockState; use super::{CachableWidget, CacheState}; use crate::common::ChartType; use crate::draw::{add_padding, PaddingDirection}; use crate::theme::style; -use crate::{CHART_TYPE, ENABLE_PRE_POST, SHOW_VOLUMES, THEME}; +use crate::{ENABLE_PRE_POST, SHOW_VOLUMES, THEME}; pub struct StockSummaryWidget {} @@ -31,9 +31,9 @@ impl CachableWidget for StockSummaryWidget { let data = state.prices().collect::>(); let pct_change = state.pct_change(&data); - let chart_type = *CHART_TYPE.read().unwrap(); + let chart_type = state.chart_type; let enable_pre_post = *ENABLE_PRE_POST.read().unwrap(); - let show_volumes = *SHOW_VOLUMES.read().unwrap(); + let show_volumes = *SHOW_VOLUMES.read().unwrap() && chart_type != ChartType::Kagi; let loaded = state.loaded(); @@ -192,6 +192,16 @@ impl CachableWidget for StockSummaryWidget { } .render(graph_chunks[0], buf, state); } + ChartType::Kagi => { + PricesKagiChart { + data: &data, + loaded, + show_x_labels: false, + is_summary: true, + kagi_options: state.chart_configuration.kagi_options.clone(), + } + .render(graph_chunks[0], buf, state); + } } // Draw volumes bar chart