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