285 lines
11 KiB
Rust
285 lines
11 KiB
Rust
use std::rc::Rc;
|
|
|
|
use ely_gpui_component::{
|
|
buttons::{Button, ButtonVariant},
|
|
finance::{
|
|
Arrangement, Candle, CandlestickChart, ChartKind, ChartSync, ChartTypeSwitcher, DepthChart,
|
|
Drawing, DrawingToolbar, IndicatorSelector, IntervalSelector, MultiChartLayout, Overlay,
|
|
PointFigureChart, RenkoChart, Study, TimeRangeSelector, Tool,
|
|
},
|
|
theme::ActiveTheme,
|
|
};
|
|
use gpui::{
|
|
App, AppContext as _, IntoElement, ParentElement, SharedString, Styled, Window, div, px,
|
|
};
|
|
use jiff::Timestamp;
|
|
|
|
use crate::{
|
|
probe::probe,
|
|
ui::{keep, noise, row, section, set},
|
|
};
|
|
|
|
/// A demo chart's width, inside the page's column.
|
|
pub(super) const WIDE: f32 = 840.0;
|
|
|
|
/// Today's prices every five minutes from the last close, moving as a quiet stock does.
|
|
pub(super) fn day(seed: u64, close: f64) -> Vec<f32> {
|
|
let mut rng = noise(seed);
|
|
let mut price = close;
|
|
(0..78)
|
|
.map(|_| {
|
|
price *= 1.0 + (rng() - 0.5) * 0.004;
|
|
price as f32
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A steady walk of candles `step` seconds apart from `start`, so captures repeat.
|
|
pub(super) fn walk(seed: u64, count: usize, start: f64, step: i64) -> Vec<Candle> {
|
|
let mut rng = noise(seed);
|
|
let first = 1_767_364_200;
|
|
let mut close = start;
|
|
(0..count)
|
|
.map(|ix| {
|
|
let open = close;
|
|
let drift = (rng() - 0.47) * 0.034;
|
|
close = (open * (1.0 + drift)).max(1.0);
|
|
let high = open.max(close) * (1.0 + rng() * 0.011);
|
|
let low = open.min(close) * (1.0 - rng() * 0.011);
|
|
let volume = 1.0e6 * (0.5 + rng()) * (1.0 + drift.abs() * 24.0);
|
|
let time = Timestamp::from_second(first + ix as i64 * step).expect("a time in range");
|
|
Candle::new(time, (open, high, low, close), volume)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// How many candles of an interval a range holds, and each candle's seconds.
|
|
fn span(range: &str, interval: &str) -> (usize, i64) {
|
|
let seconds = match interval {
|
|
"1m" => 60,
|
|
"5m" => 300,
|
|
"15m" => 900,
|
|
"1h" => 3_600,
|
|
"4h" => 14_400,
|
|
"1W" => 604_800,
|
|
"1M" => 2_592_000,
|
|
_ => 86_400,
|
|
};
|
|
let days = match range {
|
|
"1D" => 1.0,
|
|
"5D" => 5.0,
|
|
"1M" => 22.0,
|
|
"3M" => 63.0,
|
|
"6M" => 126.0,
|
|
"YTD" => 180.0,
|
|
"1Y" => 252.0,
|
|
"5Y" => 1_260.0,
|
|
_ => 2_520.0,
|
|
};
|
|
let per_day = (23_400.0 / seconds as f64).max(1.0 / (seconds as f64 / 86_400.0));
|
|
(((days * per_day) as usize).clamp(12, 2_400), seconds)
|
|
}
|
|
|
|
pub fn market(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let kind = keep("finance-kind", || ChartKind::Candles, window, cx);
|
|
let range = keep("finance-range", || SharedString::from("6M"), window, cx);
|
|
let interval = keep("finance-interval", || SharedString::from("1D"), window, cx);
|
|
let (now_kind, now_range, now_interval) = (
|
|
*kind.read(cx),
|
|
range.read(cx).clone(),
|
|
interval.read(cx).clone(),
|
|
);
|
|
let (count, step) = span(&now_range, &now_interval);
|
|
let seed = 7 + now_interval.len() as u64 * 13 + step as u64 % 97;
|
|
let candles = walk(seed, count, 184.0, step);
|
|
let alert = (candles.last().expect("a range holds candles").close * 1.04).round();
|
|
let (pick_kind, pick_range, pick_interval) = (kind.clone(), range.clone(), interval.clone());
|
|
section(
|
|
"CandlestickChart / OHLCChart / HeikinAshiChart / LineQuoteChart / VolumeChart / ChartTypeSwitcher / TimeRangeSelector / IntervalSelector",
|
|
"Prices as candles, bars, smoothed candles, a line or an area, with volume along the bottom and averages over the top. Drag or scroll sideways to move through time, hold Cmd and scroll to zoom, double-press to return to the newest.",
|
|
cx,
|
|
)
|
|
.child(
|
|
row()
|
|
.justify_between()
|
|
.w_full()
|
|
.child(
|
|
row()
|
|
.child(ChartTypeSwitcher::new("finance-kind", now_kind).on_change(move |kind, _, cx| set(&pick_kind, kind, cx)))
|
|
.child(IntervalSelector::new("finance-interval", now_interval.clone()).on_change(move |key, _, cx| set(&pick_interval, key.clone(), cx))),
|
|
)
|
|
.child(TimeRangeSelector::new("finance-range", now_range).on_change(move |key, _, cx| set(&pick_range, key.clone(), cx))),
|
|
)
|
|
.child(probe(
|
|
"market",
|
|
div().w(px(WIDE)).child(
|
|
CandlestickChart::new("market", candles)
|
|
.kind(now_kind)
|
|
.title(format!("ELY · {now_interval}"))
|
|
.volume()
|
|
.overlay(Overlay::Sma(20))
|
|
.overlay(Overlay::Ema(50))
|
|
.alert(alert, "Alert"),
|
|
),
|
|
))
|
|
}
|
|
|
|
pub fn indicators(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let chosen = keep(
|
|
"finance-indicators",
|
|
|| {
|
|
(
|
|
vec![Overlay::Bollinger(20, 2.0)],
|
|
vec![Study::Macd, Study::Rsi],
|
|
)
|
|
},
|
|
window,
|
|
cx,
|
|
);
|
|
let (overlays, studies) = chosen.read(cx).clone();
|
|
let candles = walk(11, 160, 92.0, 86_400);
|
|
let peer = walk(19, 160, 61.0, 86_400);
|
|
let chart = CandlestickChart::new("studies", candles)
|
|
.title("ELY · 1D")
|
|
.compare("PEER", peer);
|
|
let chart = overlays
|
|
.iter()
|
|
.fold(chart, |chart, overlay| chart.overlay(*overlay));
|
|
let chart = studies
|
|
.iter()
|
|
.fold(chart, |chart, study| chart.study(*study));
|
|
let picked = chosen.clone();
|
|
section(
|
|
"IndicatorOverlay / IndicatorPane / IndicatorSelector / CompareSymbol / PriceAlertLine",
|
|
"Bollinger bands over the prices, MACD and RSI in panes below, and a second symbol drawn from the same start so their moves compare. Choose indicators from the menu.",
|
|
cx,
|
|
)
|
|
.child(row().child(IndicatorSelector::new("finance-choose", overlays, studies).on_change(move |overlays, studies, _, cx| set(&picked, (overlays, studies), cx))))
|
|
.child(div().w_full().child(chart.alert(98.0, "Take profit")))
|
|
}
|
|
|
|
pub fn drawings(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let tool = keep("finance-tool", || Some(Tool::Trend), window, cx);
|
|
let marks = keep(
|
|
"finance-marks",
|
|
|| vec![Drawing::Fib((96.0, 118.0), (128.0, 142.0))],
|
|
window,
|
|
cx,
|
|
);
|
|
let (now_tool, now_marks) = (*tool.read(cx), marks.read(cx).clone());
|
|
let (pick, kept, cleared) = (tool.clone(), marks.clone(), marks.clone());
|
|
section(
|
|
"DrawingTools / DrawingToolbar",
|
|
"Pick a tool and drag over the prices: a trend line, a level, a box, or retracement levels between a swing's two ends. The pointer moves through time again.",
|
|
cx,
|
|
)
|
|
.child(
|
|
row()
|
|
.child(DrawingToolbar::new("finance-tools", now_tool).on_change(move |tool, _, cx| set(&pick, tool, cx)))
|
|
.child(Button::new("finance-clear", "Clear").variant(ButtonVariant::Ghost).on_click(move |_, _, cx| set(&cleared, Vec::new(), cx))),
|
|
)
|
|
.child(probe(
|
|
"drawings",
|
|
div().w(px(WIDE)).child(
|
|
CandlestickChart::new("drawn", walk(23, 140, 120.0, 86_400))
|
|
.title("ELY · 1D")
|
|
.drawings(now_marks)
|
|
.tool(now_tool)
|
|
.on_draw(move |drawing, _, cx| {
|
|
kept.update(cx, |marks, cx| {
|
|
marks.push(drawing);
|
|
cx.notify();
|
|
})
|
|
}),
|
|
),
|
|
))
|
|
}
|
|
|
|
pub fn depth(cx: &mut App) -> impl IntoElement + use<> {
|
|
let mut rng = noise(29);
|
|
let bids: Vec<(f64, f64)> = (0..40)
|
|
.map(|ix| {
|
|
(
|
|
187.20 - ix as f64 * 0.05,
|
|
200.0 + rng() * 900.0 + ix as f64 * 40.0,
|
|
)
|
|
})
|
|
.collect();
|
|
let asks: Vec<(f64, f64)> = (0..40)
|
|
.map(|ix| {
|
|
(
|
|
187.26 + ix as f64 * 0.05,
|
|
200.0 + rng() * 900.0 + ix as f64 * 35.0,
|
|
)
|
|
})
|
|
.collect();
|
|
section("DepthChart", "Every bid and ask as a running total out from the spread. Hover to read the total to any price.", cx)
|
|
.child(probe("depth", div().w(px(WIDE)).child(DepthChart::new("depth", bids, asks))))
|
|
}
|
|
|
|
pub fn profile(cx: &mut App) -> impl IntoElement + use<> {
|
|
section(
|
|
"MarketProfile / VolumeProfile",
|
|
"How much traded at each price in view, as bars from the right edge; the busiest price is the darkest.",
|
|
cx,
|
|
)
|
|
.child(div().w_full().child(CandlestickChart::new("profile", walk(31, 120, 54.0, 86_400)).title("ELY · 1D").profile()))
|
|
}
|
|
|
|
pub fn bricks(cx: &mut App) -> impl IntoElement + use<> {
|
|
let candles = Rc::new(walk(37, 260, 74.0, 86_400));
|
|
section(
|
|
"RenkoChart / PointAndFigure",
|
|
"Time drops out: a brick for each whole move of two dollars, and columns of Xs and Os that turn after three boxes the other way.",
|
|
cx,
|
|
)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.gap_6()
|
|
.child(div().flex_1().child(RenkoChart::new("renko", candles.clone(), 2.0)))
|
|
.child(div().flex_1().child(PointFigureChart::new("figure", candles, 2.0, 3))),
|
|
)
|
|
}
|
|
|
|
pub fn together(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
|
let arrangement = keep("finance-arrangement", || Arrangement::Four, window, cx);
|
|
let sync = keep(
|
|
"finance-sync",
|
|
|| None::<gpui::Entity<ChartSync>>,
|
|
window,
|
|
cx,
|
|
);
|
|
if sync.read(cx).is_none() {
|
|
let made = cx.new(|_| ChartSync::default());
|
|
set(&sync, Some(made), cx);
|
|
}
|
|
let shared = sync.read(cx).clone().expect("made above");
|
|
let now = *arrangement.read(cx);
|
|
let pick = arrangement.clone();
|
|
let names = [
|
|
("ELY", 3, 184.0),
|
|
("NOVA", 5, 62.0),
|
|
("ORBIT", 8, 131.0),
|
|
("PINE", 13, 27.0),
|
|
];
|
|
let layout = names.iter().fold(
|
|
MultiChartLayout::new("finance-grid", now),
|
|
|layout, (name, seed, start)| {
|
|
let chart = CandlestickChart::new(
|
|
SharedString::from(format!("sync-{name}")),
|
|
walk(*seed, 120, *start, 86_400),
|
|
)
|
|
.title(*name)
|
|
.sync(&shared);
|
|
layout.cell(chart.h(cx.theme().chart().height * 0.8))
|
|
},
|
|
);
|
|
section(
|
|
"MultiChartLayout / ChartSync",
|
|
"Four symbols moving together: pan, zoom or point at one and the others follow. Switch between one chart, two across, two down or four.",
|
|
cx,
|
|
)
|
|
.child(layout.on_arrange(move |arrangement, _, cx| set(&pick, arrangement, cx)))
|
|
}
|