338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
App, ElementId, IntoElement, PathBuilder, Pixels, Point, RenderOnce, SharedString, Window, fill,
|
|
};
|
|
|
|
use super::{
|
|
draw::Scene,
|
|
series::{Overlay, Study},
|
|
};
|
|
use crate::{
|
|
buttons::{ButtonVariant, ToggleGroup, ToggleItem},
|
|
charts::{Rect, at, finish, place, ring, tint},
|
|
menus::{DropdownMenu, Menu, MenuItem},
|
|
primitives::IconName,
|
|
theme::ControlSize,
|
|
};
|
|
|
|
/// A tool that draws on a market chart.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Tool {
|
|
Trend,
|
|
Level,
|
|
Box,
|
|
Fib,
|
|
}
|
|
|
|
/// A mark drawn on a market chart, pinned to candles and prices so it moves with them: each point is a candle, which may fall between two, and a price.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum Drawing {
|
|
/// A straight line between two points.
|
|
Trend((f64, f64), (f64, f64)),
|
|
/// A level across the chart.
|
|
Level(f64),
|
|
/// A shaded rectangle between two corners.
|
|
Box((f64, f64), (f64, f64)),
|
|
/// Retracement levels between a swing's two ends.
|
|
Fib((f64, f64), (f64, f64)),
|
|
}
|
|
|
|
/// The shares of a swing that retracement levels mark.
|
|
pub const FIB: [f64; 6] = [0.0, 0.236, 0.382, 0.5, 0.618, 1.0];
|
|
|
|
impl Drawing {
|
|
/// What a tool draws from where a drag began to where it is.
|
|
pub(crate) fn of(tool: Tool, from: (f64, f64), to: (f64, f64)) -> Drawing {
|
|
match tool {
|
|
Tool::Trend => Drawing::Trend(from, to),
|
|
Tool::Level => Drawing::Level(to.1),
|
|
Tool::Box => Drawing::Box(from, to),
|
|
Tool::Fib => Drawing::Fib(from, to),
|
|
}
|
|
}
|
|
|
|
/// Each retracement level's price, from the swing's end back toward its start.
|
|
pub(crate) fn levels((_, start): (f64, f64), (_, end): (f64, f64)) -> [(f64, f64); 6] {
|
|
FIB.map(|share| (share, end - (end - start) * share))
|
|
}
|
|
}
|
|
|
|
/// Each tool with its key, words and icon; the pointer draws nothing.
|
|
const TOOLS: [(Option<Tool>, &str, &str, IconName); 5] = [
|
|
(None, "pointer", "Point", IconName::MousePointer2),
|
|
(
|
|
Some(Tool::Trend),
|
|
"trend",
|
|
"Trend line",
|
|
IconName::TrendingUp,
|
|
),
|
|
(Some(Tool::Level), "level", "Level", IconName::Minus),
|
|
(Some(Tool::Box), "box", "Box", IconName::Square),
|
|
(
|
|
Some(Tool::Fib),
|
|
"fib",
|
|
"Fibonacci retracement",
|
|
IconName::AlignJustify,
|
|
),
|
|
];
|
|
|
|
type OnTool = Rc<dyn Fn(Option<Tool>, &mut Window, &mut App)>;
|
|
|
|
/// The tools that draw on a market chart, and the pointer that draws nothing, one chosen at a time.
|
|
#[derive(IntoElement)]
|
|
pub struct DrawingToolbar {
|
|
id: ElementId,
|
|
tool: Option<Tool>,
|
|
on_change: Option<OnTool>,
|
|
}
|
|
|
|
impl DrawingToolbar {
|
|
pub fn new(id: impl Into<ElementId>, tool: Option<Tool>) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
tool,
|
|
on_change: None,
|
|
}
|
|
}
|
|
|
|
pub fn on_change(
|
|
mut self,
|
|
handler: impl Fn(Option<Tool>, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
self.on_change = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for DrawingToolbar {
|
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
|
let chosen = TOOLS
|
|
.iter()
|
|
.find(|(tool, ..)| *tool == self.tool)
|
|
.map(|(_, key, ..)| *key)
|
|
.expect("every tool is listed");
|
|
let group = TOOLS.iter().fold(
|
|
ToggleGroup::new(self.id).size(ControlSize::Sm),
|
|
|group, (_, key, words, icon)| {
|
|
group.item(ToggleItem::new(*key).icon(*icon).tooltip(*words))
|
|
},
|
|
);
|
|
let group = group.selected([chosen]);
|
|
match self.on_change {
|
|
Some(on_change) => group.on_change(move |keys, window, cx| {
|
|
let tool = keys
|
|
.first()
|
|
.and_then(|key| TOOLS.iter().find(|(_, listed, ..)| *listed == key.as_ref()))
|
|
.and_then(|(tool, ..)| *tool);
|
|
log::info!("drawing toolbar: {tool:?}");
|
|
on_change(tool, window, cx)
|
|
}),
|
|
None => group,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The overlays and studies an indicator menu offers.
|
|
const OVERLAYS: [(Overlay, &str); 5] = [
|
|
(Overlay::Sma(20), "Moving average 20"),
|
|
(Overlay::Ema(50), "Exponential average 50"),
|
|
(Overlay::Bollinger(20, 2.0), "Bollinger bands"),
|
|
(Overlay::Vwap, "VWAP"),
|
|
(Overlay::Sma(200), "Moving average 200"),
|
|
];
|
|
|
|
const STUDIES: [(Study, &str); 4] = [
|
|
(Study::Macd, "MACD"),
|
|
(Study::Rsi, "RSI"),
|
|
(Study::Kdj, "KDJ"),
|
|
(Study::Obv, "On-balance volume"),
|
|
];
|
|
|
|
type OnIndicators = Rc<dyn Fn(Vec<Overlay>, Vec<Study>, &mut Window, &mut App)>;
|
|
|
|
/// A menu of indicators, each checked when shown: overlays over the prices, and studies in panes of their own.
|
|
#[derive(IntoElement)]
|
|
pub struct IndicatorSelector {
|
|
id: ElementId,
|
|
overlays: Vec<Overlay>,
|
|
studies: Vec<Study>,
|
|
on_change: Option<OnIndicators>,
|
|
}
|
|
|
|
impl IndicatorSelector {
|
|
pub fn new(id: impl Into<ElementId>, overlays: Vec<Overlay>, studies: Vec<Study>) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
overlays,
|
|
studies,
|
|
on_change: None,
|
|
}
|
|
}
|
|
|
|
/// Gets the overlays and studies after one is checked or cleared.
|
|
pub fn on_change(
|
|
mut self,
|
|
handler: impl Fn(Vec<Overlay>, Vec<Study>, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
self.on_change = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for IndicatorSelector {
|
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
|
let (overlays, studies) = (Rc::new(self.overlays), Rc::new(self.studies));
|
|
let on_change = self.on_change;
|
|
let flip = |overlay: Option<Overlay>, study: Option<Study>| {
|
|
let (overlays, studies, on_change) =
|
|
(overlays.clone(), studies.clone(), on_change.clone());
|
|
move |window: &mut Window, cx: &mut App| {
|
|
let (mut overlays, mut studies) = ((*overlays).clone(), (*studies).clone());
|
|
if let Some(overlay) = overlay {
|
|
match overlays.iter().position(|shown| *shown == overlay) {
|
|
Some(ix) => drop(overlays.remove(ix)),
|
|
None => overlays.push(overlay),
|
|
}
|
|
}
|
|
if let Some(study) = study {
|
|
match studies.iter().position(|shown| *shown == study) {
|
|
Some(ix) => drop(studies.remove(ix)),
|
|
None => studies.push(study),
|
|
}
|
|
}
|
|
log::info!("indicators: {overlays:?} and {studies:?}");
|
|
if let Some(on_change) = &on_change {
|
|
on_change(overlays, studies, window, cx);
|
|
}
|
|
}
|
|
};
|
|
let over = OVERLAYS.iter().map(|(overlay, words)| {
|
|
MenuItem::check(*words, overlays.contains(overlay)).on_click(flip(Some(*overlay), None))
|
|
});
|
|
let under = STUDIES.iter().map(|(study, words)| {
|
|
MenuItem::check(*words, studies.contains(study)).on_click(flip(None, Some(*study)))
|
|
});
|
|
let menu = Menu::new()
|
|
.group("Over the prices", over)
|
|
.group("In a pane below", under);
|
|
let shown = overlays.len() + studies.len();
|
|
let label: SharedString = if shown == 0 {
|
|
"Indicators".into()
|
|
} else {
|
|
format!("Indicators · {shown}").into()
|
|
};
|
|
DropdownMenu::new(self.id, label, menu)
|
|
.icon(IconName::Activity)
|
|
.variant(ButtonVariant::Ghost)
|
|
}
|
|
}
|
|
|
|
/// Marks drawn over the prices: trend lines with their ends, levels, boxes, and retracement levels with the middle band shaded.
|
|
pub(super) fn drawings(scene: &Scene, origin: Point<Pixels>, window: &mut Window) {
|
|
let (main, scale, pen) = (scene.panes[0].0, &scene.panes[0].1, &scene.pen);
|
|
let spot = |(ix, price): (f64, f64)| (scene.visible.x(ix, (main.x, main.w)), scale.at(price));
|
|
let (ink, accent) = (pen.palette.fg_muted, tint(&pen.palette, 0));
|
|
for drawing in &scene.drawings {
|
|
match *drawing {
|
|
Drawing::Trend(from, to) => {
|
|
let (from, to) = (spot(from), spot(to));
|
|
let mut line = PathBuilder::stroke(pen.stroke * 0.75);
|
|
line.move_to(at(origin, from));
|
|
line.line_to(at(origin, to));
|
|
finish(line, ink, window);
|
|
for end in [from, to] {
|
|
ring(
|
|
at(origin, end),
|
|
pen.stroke * 1.5,
|
|
(pen.palette.bg, ink),
|
|
pen.hairline,
|
|
window,
|
|
);
|
|
}
|
|
}
|
|
Drawing::Level(price) => {
|
|
let y = scale.at(price);
|
|
window.paint_quad(fill(
|
|
place(
|
|
origin,
|
|
Rect {
|
|
x: main.x,
|
|
y,
|
|
w: main.w,
|
|
h: f32::from(pen.hairline),
|
|
},
|
|
),
|
|
ink,
|
|
));
|
|
}
|
|
Drawing::Box(from, to) => {
|
|
let ((x0, y0), (x1, y1)) = (spot(from), spot(to));
|
|
let rect = Rect {
|
|
x: x0.min(x1),
|
|
y: y0.min(y1),
|
|
w: (x1 - x0).abs(),
|
|
h: (y1 - y0).abs(),
|
|
};
|
|
window.paint_quad(
|
|
fill(place(origin, rect), accent.opacity(0.1))
|
|
.border_widths(pen.hairline)
|
|
.border_color(accent),
|
|
);
|
|
}
|
|
Drawing::Fib(from, to) => {
|
|
let (x0, x1) = (spot(from).0.min(spot(to).0), spot(from).0.max(spot(to).0));
|
|
let levels = Drawing::levels(from, to);
|
|
let (upper, lower) = (scale.at(levels[2].1), scale.at(levels[4].1));
|
|
let band = Rect {
|
|
x: x0,
|
|
y: upper.min(lower),
|
|
w: x1 - x0,
|
|
h: (upper - lower).abs(),
|
|
};
|
|
window.paint_quad(fill(place(origin, band), accent.opacity(0.08)));
|
|
for (share, price) in levels {
|
|
let color = if share == FIB[0] || share == FIB[5] {
|
|
ink
|
|
} else {
|
|
pen.palette.fg_subtle
|
|
};
|
|
window.paint_quad(fill(
|
|
place(
|
|
origin,
|
|
Rect {
|
|
x: x0,
|
|
y: scale.at(price),
|
|
w: x1 - x0,
|
|
h: f32::from(pen.hairline),
|
|
},
|
|
),
|
|
color,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tools_make_their_drawings_and_levels_run_back_from_the_end() {
|
|
assert_eq!(
|
|
Drawing::of(Tool::Level, (1.0, 10.0), (4.0, 12.0)),
|
|
Drawing::Level(12.0)
|
|
);
|
|
assert_eq!(
|
|
Drawing::of(Tool::Trend, (1.0, 10.0), (4.0, 12.0)),
|
|
Drawing::Trend((1.0, 10.0), (4.0, 12.0))
|
|
);
|
|
let levels = Drawing::levels((0.0, 100.0), (5.0, 200.0));
|
|
assert_eq!(levels[0], (0.0, 200.0), "the swing's end");
|
|
assert_eq!(levels[3], (0.5, 150.0), "halfway back");
|
|
assert_eq!(levels[5], (1.0, 100.0), "all the way back");
|
|
}
|
|
}
|