mirror of
https://github.com/michaelshimeles/terminal.git
synced 2026-11-07 05:56:17 -10:00
chore: apply clippy fixes and rustfmt configuration
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f86c66c4d9
commit
d0e7b33ffd
32 files changed
+803
-266
No files matched your search
+140
-43
@@ -7,8 +7,8 @@ use egui::{Align2, Color32, Event, Key, Modifiers, Rect, RichText, Stroke, Strok
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tr_chart::drawings::{Drawing, Tool};
|
||||
use tr_chart::scene::ChartType;
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_congress::Trade;
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_core::Timeframe;
|
||||
use tr_data::Quote;
|
||||
|
||||
@@ -124,7 +124,8 @@ fn settings_path() -> std::path::PathBuf {
|
||||
impl TerminalApp {
|
||||
pub fn new(ctx: &egui::Context, worker: Worker, provider: String) -> Self {
|
||||
theme::apply(ctx);
|
||||
let settings: Option<Settings> = std::fs::read(settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let settings: Option<Settings> =
|
||||
std::fs::read(settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let mut app = Self {
|
||||
worker,
|
||||
provider,
|
||||
@@ -145,7 +146,9 @@ impl TerminalApp {
|
||||
pol_search: String::new(),
|
||||
alerts: Vec::new(),
|
||||
congress_status: String::new(),
|
||||
watchlist: ["SPY", "QQQ", "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "JPM"].map(String::from).to_vec(),
|
||||
watchlist: ["SPY", "QQQ", "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "JPM"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
quotes: HashMap::new(),
|
||||
last_quotes: None,
|
||||
last_feed: None,
|
||||
@@ -300,7 +303,9 @@ impl TerminalApp {
|
||||
}
|
||||
let disc = |i: usize| std::mem::discriminant(&self.panes[i].kind);
|
||||
let n = self.panes.len();
|
||||
if let Some(i) = (0..n).rev().find(|&i| !matches!(self.panes[i].kind, PaneKind::Chart(_)) && (0..n).any(|j| j != i && disc(j) == disc(i))) {
|
||||
if let Some(i) = (0..n).rev().find(|&i| {
|
||||
!matches!(self.panes[i].kind, PaneKind::Chart(_)) && (0..n).any(|j| j != i && disc(j) == disc(i))
|
||||
}) {
|
||||
return i;
|
||||
}
|
||||
let candidates: Vec<usize> = (0..n).filter(|&i| !matches!(self.panes[i].kind, PaneKind::Chart(_))).collect();
|
||||
@@ -350,7 +355,9 @@ impl TerminalApp {
|
||||
for a in actions {
|
||||
match a {
|
||||
Action::OpenChart(s) => self.open_chart(&s),
|
||||
Action::OpenTicker(s) => self.open_panel(PaneKind::Ticker { symbol: s, trades: Vec::new(), loading: true }),
|
||||
Action::OpenTicker(s) => {
|
||||
self.open_panel(PaneKind::Ticker { symbol: s, trades: Vec::new(), loading: true })
|
||||
}
|
||||
Action::OpenProfile(q) => self.open_panel(PaneKind::Profile { query: q, data: None, loading: true }),
|
||||
Action::Track(q, on) => {
|
||||
self.worker.send(Request::Track { query: q.clone(), on });
|
||||
@@ -434,7 +441,8 @@ impl TerminalApp {
|
||||
Function::ClosePane => {
|
||||
if self.panes.len() > 1 {
|
||||
self.panes.remove(self.focus);
|
||||
let prev = Layout::ALL.into_iter().rev().find(|l| l.slots() <= self.panes.len()).unwrap_or(Layout::One);
|
||||
let prev =
|
||||
Layout::ALL.into_iter().rev().find(|l| l.slots() <= self.panes.len()).unwrap_or(Layout::One);
|
||||
self.layout = prev;
|
||||
self.fit_panes_to_layout();
|
||||
}
|
||||
@@ -492,23 +500,21 @@ impl TerminalApp {
|
||||
}
|
||||
Response::Latest(t) => self.latest = t,
|
||||
Response::TickerTrades { pane, symbol, trades } => {
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane) {
|
||||
if let PaneKind::Ticker { symbol: s, trades: t, loading } = &mut p.kind {
|
||||
if *s == symbol {
|
||||
*t = trades;
|
||||
*loading = false;
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
|
||||
&& let PaneKind::Ticker { symbol: s, trades: t, loading } = &mut p.kind
|
||||
&& *s == symbol
|
||||
{
|
||||
*t = trades;
|
||||
*loading = false;
|
||||
}
|
||||
}
|
||||
Response::Profile { pane, profile, query } => {
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane) {
|
||||
if let PaneKind::Profile { query: q, data, loading } = &mut p.kind {
|
||||
if *q == query {
|
||||
*data = profile;
|
||||
*loading = false;
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
|
||||
&& let PaneKind::Profile { query: q, data, loading } = &mut p.kind
|
||||
&& *q == query
|
||||
{
|
||||
*data = profile;
|
||||
*loading = false;
|
||||
}
|
||||
}
|
||||
Response::Politicians(list) => self.politicians = list,
|
||||
@@ -535,7 +541,9 @@ impl TerminalApp {
|
||||
self.worker.send(Request::SearchPoliticians(String::new()));
|
||||
for p in &self.panes {
|
||||
match &p.kind {
|
||||
PaneKind::Ticker { symbol, .. } => self.worker.send(Request::TickerTrades { pane: p.id, symbol: symbol.clone() }),
|
||||
PaneKind::Ticker { symbol, .. } => {
|
||||
self.worker.send(Request::TickerTrades { pane: p.id, symbol: symbol.clone() })
|
||||
}
|
||||
PaneKind::Chart(c) => self.worker.send(Request::Markers { pane: p.id, symbol: c.symbol.clone() }),
|
||||
_ => {}
|
||||
}
|
||||
@@ -564,7 +572,9 @@ impl TerminalApp {
|
||||
let mut out = String::new();
|
||||
for e in &i.events {
|
||||
match e {
|
||||
Event::Key { pressed: true, modifiers, .. } => chord = modifiers.alt || modifiers.ctrl || modifiers.command,
|
||||
Event::Key { pressed: true, modifiers, .. } => {
|
||||
chord = modifiers.alt || modifiers.ctrl || modifiers.command
|
||||
}
|
||||
Event::Text(t) if !chord => out.push_str(t),
|
||||
_ => {}
|
||||
}
|
||||
@@ -572,7 +582,8 @@ impl TerminalApp {
|
||||
out
|
||||
});
|
||||
if !typed.is_empty() {
|
||||
let starts = !self.cmd.is_empty() || typed.chars().next().is_some_and(|c| c.is_ascii_alphanumeric() || c == '^' || c == '?');
|
||||
let starts = !self.cmd.is_empty()
|
||||
|| typed.chars().next().is_some_and(|c| c.is_ascii_alphanumeric() || c == '^' || c == '?');
|
||||
if starts {
|
||||
self.cmd.push_str(&typed.to_ascii_uppercase());
|
||||
self.focus_cmd = true;
|
||||
@@ -605,7 +616,9 @@ impl TerminalApp {
|
||||
if !is_chart {
|
||||
return;
|
||||
}
|
||||
for (k, t) in [(Key::T, Tool::Trendline), (Key::H, Tool::Horizontal), (Key::V, Tool::Vertical), (Key::F, Tool::Fib)] {
|
||||
for (k, t) in
|
||||
[(Key::T, Tool::Trendline), (Key::H, Tool::Horizontal), (Key::V, Tool::Vertical), (Key::F, Tool::Fib)]
|
||||
{
|
||||
if pressed(Modifiers::ALT, k) {
|
||||
self.with_chart(|c, _| c.start_tool(t));
|
||||
}
|
||||
@@ -635,7 +648,10 @@ impl TerminalApp {
|
||||
if pressed(Modifiers::NONE, Key::ArrowRight) {
|
||||
self.with_chart(|c, _| c.pan_bars(5.0));
|
||||
}
|
||||
if pressed(Modifiers::COMMAND, Key::ArrowUp) || pressed(Modifiers::NONE, Key::Plus) || pressed(Modifiers::NONE, Key::Equals) {
|
||||
if pressed(Modifiers::COMMAND, Key::ArrowUp)
|
||||
|| pressed(Modifiers::NONE, Key::Plus)
|
||||
|| pressed(Modifiers::NONE, Key::Equals)
|
||||
{
|
||||
self.with_chart(|c, _| c.zoom(1.25));
|
||||
}
|
||||
if pressed(Modifiers::COMMAND, Key::ArrowDown) || pressed(Modifiers::NONE, Key::Minus) {
|
||||
@@ -655,19 +671,41 @@ impl TerminalApp {
|
||||
let clock = format!("{} {} UTC", now.format("%a %d %b %H:%M:%S"), chrono::Utc::now().format("%H:%M"));
|
||||
p.text(pos2(rect.right() - 8.0, rect.top() + 14.0), Align2::RIGHT_CENTER, clock, mono(12.0), AMBER);
|
||||
let unread = self.alerts.iter().filter(|a| !a.seen).count();
|
||||
let right_info = format!("LAYOUT {} DATA {}{}", self.layout.label(), self.provider.to_uppercase(), if unread > 0 { format!(" ALERTS {unread}") } else { String::new() });
|
||||
p.text(pos2(rect.right() - 8.0, rect.top() + 31.0), Align2::RIGHT_CENTER, right_info, mono(11.0), if unread > 0 { AMBER } else { MUTED });
|
||||
let right_info = format!(
|
||||
"LAYOUT {} DATA {}{}",
|
||||
self.layout.label(),
|
||||
self.provider.to_uppercase(),
|
||||
if unread > 0 { format!(" ALERTS {unread}") } else { String::new() }
|
||||
);
|
||||
p.text(
|
||||
pos2(rect.right() - 8.0, rect.top() + 31.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
right_info,
|
||||
mono(11.0),
|
||||
if unread > 0 { AMBER } else { MUTED },
|
||||
);
|
||||
|
||||
let cmd_rect = Rect::from_min_max(pos2(logo.right() + 8.0, rect.top() + 4.0), pos2(rect.right() - 300.0, rect.top() + 24.0));
|
||||
let cmd_rect = Rect::from_min_max(
|
||||
pos2(logo.right() + 8.0, rect.top() + 4.0),
|
||||
pos2(rect.right() - 300.0, rect.top() + 24.0),
|
||||
);
|
||||
let mut child = ui.new_child(UiBuilder::new().max_rect(cmd_rect));
|
||||
let id = egui::Id::new("command-line");
|
||||
let te = egui::TextEdit::singleline(&mut self.cmd)
|
||||
.id(id)
|
||||
.font(mono(14.0))
|
||||
.text_color(AMBER)
|
||||
.hint_text(RichText::new("<TICKER> <GO> e.g. AAPL · NVDA CONG · POL PELOSI · RSI · 5 · LAYOUT 4").color(AMBER_DIM))
|
||||
.hint_text(
|
||||
RichText::new("<TICKER> <GO> e.g. AAPL · NVDA CONG · POL PELOSI · RSI · 5 · LAYOUT 4")
|
||||
.color(AMBER_DIM),
|
||||
)
|
||||
.desired_width(cmd_rect.width())
|
||||
.frame(egui::Frame::new().fill(Color32::from_rgb(20, 16, 0)).stroke(Stroke::new(1.0, AMBER_DIM)).inner_margin(vec2(6.0, 2.0)));
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(Color32::from_rgb(20, 16, 0))
|
||||
.stroke(Stroke::new(1.0, AMBER_DIM))
|
||||
.inner_margin(vec2(6.0, 2.0)),
|
||||
);
|
||||
let resp = child.add(te);
|
||||
if self.focus_cmd {
|
||||
resp.request_focus();
|
||||
@@ -680,7 +718,14 @@ impl TerminalApp {
|
||||
self.focus_cmd = false;
|
||||
}
|
||||
let focused = resp.has_focus();
|
||||
let (enter, esc, up, down) = ui.input(|i| (i.key_pressed(Key::Enter), i.key_pressed(Key::Escape), i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowDown)));
|
||||
let (enter, esc, up, down) = ui.input(|i| {
|
||||
(
|
||||
i.key_pressed(Key::Enter),
|
||||
i.key_pressed(Key::Escape),
|
||||
i.key_pressed(Key::ArrowUp),
|
||||
i.key_pressed(Key::ArrowDown),
|
||||
)
|
||||
});
|
||||
if resp.lost_focus() && enter {
|
||||
let input = std::mem::take(&mut self.cmd);
|
||||
if !input.trim().is_empty() {
|
||||
@@ -708,14 +753,30 @@ impl TerminalApp {
|
||||
self.cmd = self.cmd_history[pos].clone();
|
||||
}
|
||||
let hint = command::hint(&self.cmd);
|
||||
p.text(pos2(cmd_rect.left() + 2.0, rect.top() + 32.0), Align2::LEFT_CENTER, hint, mono(11.0), if self.cmd.is_empty() { MUTED } else { TEXT });
|
||||
p.text(
|
||||
pos2(cmd_rect.left() + 2.0, rect.top() + 32.0),
|
||||
Align2::LEFT_CENTER,
|
||||
hint,
|
||||
mono(11.0),
|
||||
if self.cmd.is_empty() { MUTED } else { TEXT },
|
||||
);
|
||||
focused
|
||||
}
|
||||
|
||||
fn bottom_bar(&self, ui: &Ui, rect: Rect) {
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 0.0, theme::PANEL);
|
||||
let keys = [("F1", "HELP"), ("F2", "CONG"), ("F3", "POL"), ("F4", "ALRT"), ("F5", "WL"), ("F6", "SYNC"), ("TAB", "PANE"), ("ALT+T/H/V/F", "DRAW"), ("ALT+R", "RESET")];
|
||||
let keys = [
|
||||
("F1", "HELP"),
|
||||
("F2", "CONG"),
|
||||
("F3", "POL"),
|
||||
("F4", "ALRT"),
|
||||
("F5", "WL"),
|
||||
("F6", "SYNC"),
|
||||
("TAB", "PANE"),
|
||||
("ALT+T/H/V/F", "DRAW"),
|
||||
("ALT+R", "RESET"),
|
||||
];
|
||||
let mut x = rect.left() + 4.0;
|
||||
for (k, label) in keys {
|
||||
let g = p.layout_no_wrap(k.to_string(), mono(11.0), Color32::BLACK);
|
||||
@@ -744,11 +805,22 @@ impl TerminalApp {
|
||||
|
||||
fn watchlist(&mut self, ui: &mut Ui) -> Vec<Action> {
|
||||
let mut out = Vec::new();
|
||||
ui.add(egui::Label::new(RichText::new("SYMBOL LAST CHG CHG%").font(mono(11.5)).color(AMBER).background_color(theme::HEADER)).extend());
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new("SYMBOL LAST CHG CHG%")
|
||||
.font(mono(11.5))
|
||||
.color(AMBER)
|
||||
.background_color(theme::HEADER),
|
||||
)
|
||||
.extend(),
|
||||
);
|
||||
let mut remove = None;
|
||||
for s in &self.watchlist {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::Label::new(RichText::new(format!("{s:<8}")).font(mono(12.5)).color(AMBER)).sense(egui::Sense::click()));
|
||||
let r = ui.add(
|
||||
egui::Label::new(RichText::new(format!("{s:<8}")).font(mono(12.5)).color(AMBER))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.clicked() {
|
||||
out.push(Action::OpenChart(s.clone()));
|
||||
}
|
||||
@@ -758,7 +830,11 @@ impl TerminalApp {
|
||||
match self.quotes.get(s) {
|
||||
Some(q) => {
|
||||
let c = if q.change() >= 0.0 { theme::UP } else { theme::DOWN };
|
||||
ui.label(RichText::new(format!("{:>10.2} {:>+9.2} {:>+8.2}%", q.last, q.change(), q.change_pct())).font(mono(12.5)).color(c));
|
||||
ui.label(
|
||||
RichText::new(format!("{:>10.2} {:>+9.2} {:>+8.2}%", q.last, q.change(), q.change_pct()))
|
||||
.font(mono(12.5))
|
||||
.color(c),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new(" …").font(mono(12.5)).color(MUTED));
|
||||
@@ -769,7 +845,9 @@ impl TerminalApp {
|
||||
if let Some(s) = remove {
|
||||
self.watchlist.retain(|x| *x != s);
|
||||
}
|
||||
ui.label(RichText::new("WL ADD <TICKER> · right-click to remove · click to chart").font(mono(10.5)).color(MUTED));
|
||||
ui.label(
|
||||
RichText::new("WL ADD <TICKER> · right-click to remove · click to chart").font(mono(10.5)).color(MUTED),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -827,7 +905,10 @@ DATA APCA_API_KEY_ID/APCA_API_SECRET_KEY -> Alpaca; otherwise Yahoo; TR_PROVIDE
|
||||
let screen = ctx.content_rect();
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toasts")));
|
||||
for (k, (msg, _)) in self.toasts.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(screen.right() - 470.0, screen.top() + 48.0 + k as f32 * 30.0), vec2(460.0, 26.0));
|
||||
let r = Rect::from_min_size(
|
||||
pos2(screen.right() - 470.0, screen.top() + 48.0 + k as f32 * 30.0),
|
||||
vec2(460.0, 26.0),
|
||||
);
|
||||
p.rect_filled(r, 0.0, Color32::from_rgb(60, 40, 0));
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, AMBER), StrokeKind::Inside);
|
||||
p.text(r.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, format!("ALERT {msg}"), mono(12.0), AMBER);
|
||||
@@ -845,7 +926,10 @@ impl eframe::App for TerminalApp {
|
||||
ui.painter().rect_filled(screen, 0.0, theme::BG);
|
||||
let top = Rect::from_min_size(screen.min, vec2(screen.width(), TOP_H));
|
||||
let bottom = Rect::from_min_max(pos2(screen.left(), screen.bottom() - BOTTOM_H), screen.max);
|
||||
let area = Rect::from_min_max(pos2(screen.left() + 2.0, top.bottom() + 2.0), pos2(screen.right() - 2.0, bottom.top() - 2.0));
|
||||
let area = Rect::from_min_max(
|
||||
pos2(screen.left() + 2.0, top.bottom() + 2.0),
|
||||
pos2(screen.right() - 2.0, bottom.top() - 2.0),
|
||||
);
|
||||
|
||||
self.hotkeys(&ctx);
|
||||
self.top_bar(ui, top);
|
||||
@@ -869,7 +953,12 @@ impl eframe::App for TerminalApp {
|
||||
let mut child = ui.new_child(UiBuilder::new().max_rect(inner).id_salt(("pane", pane.id)));
|
||||
child.set_clip_rect(inner);
|
||||
match kind {
|
||||
PaneKind::Feed => actions.extend(congress_view::feed(&mut child, &self.latest, &mut self.feed_filter, &self.congress_status)),
|
||||
PaneKind::Feed => actions.extend(congress_view::feed(
|
||||
&mut child,
|
||||
&self.latest,
|
||||
&mut self.feed_filter,
|
||||
&self.congress_status,
|
||||
)),
|
||||
PaneKind::Ticker { symbol, trades, loading } => {
|
||||
if *loading {
|
||||
child.label(RichText::new("Loading…").color(MUTED));
|
||||
@@ -877,8 +966,14 @@ impl eframe::App for TerminalApp {
|
||||
actions.extend(congress_view::ticker_trades(&mut child, symbol, trades));
|
||||
}
|
||||
}
|
||||
PaneKind::Profile { query, data, loading } => actions.extend(congress_view::profile(&mut child, query, data.as_deref(), *loading)),
|
||||
PaneKind::Politicians => actions.extend(congress_view::politicians(&mut child, &self.politicians, &mut self.pol_search)),
|
||||
PaneKind::Profile { query, data, loading } => {
|
||||
actions.extend(congress_view::profile(&mut child, query, data.as_deref(), *loading))
|
||||
}
|
||||
PaneKind::Politicians => actions.extend(congress_view::politicians(
|
||||
&mut child,
|
||||
&self.politicians,
|
||||
&mut self.pol_search,
|
||||
)),
|
||||
PaneKind::Alerts => {
|
||||
let (a, seen) = congress_view::alerts(&mut child, &self.alerts);
|
||||
actions.extend(a);
|
||||
@@ -895,7 +990,9 @@ impl eframe::App for TerminalApp {
|
||||
let wl = self.watchlist(&mut child);
|
||||
actions.extend(wl);
|
||||
}
|
||||
let clicked = ui.input(|inp| inp.pointer.primary_pressed() && inp.pointer.interact_pos().is_some_and(|p| rect.contains(p)));
|
||||
let clicked = ui.input(|inp| {
|
||||
inp.pointer.primary_pressed() && inp.pointer.interact_pos().is_some_and(|p| rect.contains(p))
|
||||
});
|
||||
if clicked {
|
||||
new_focus = Some(i);
|
||||
}
|
||||
|
||||
+206
-41
@@ -104,7 +104,13 @@ impl ChartState {
|
||||
self.manual_scale = None;
|
||||
self.compare_line = None;
|
||||
self.markers.clear();
|
||||
w.send(Request::History { pane: self.id, generation: self.generation, symbol: self.symbol.clone(), tf: self.tf, limit: initial_limit(self.tf) });
|
||||
w.send(Request::History {
|
||||
pane: self.id,
|
||||
generation: self.generation,
|
||||
symbol: self.symbol.clone(),
|
||||
tf: self.tf,
|
||||
limit: initial_limit(self.tf),
|
||||
});
|
||||
w.send(Request::Markers { pane: self.id, symbol: self.symbol.clone() });
|
||||
}
|
||||
|
||||
@@ -215,20 +221,41 @@ impl ChartState {
|
||||
|
||||
fn request_indicators(&self, w: &Worker) {
|
||||
if !self.bars.is_empty() {
|
||||
w.send(Request::Indicators { pane: self.id, generation: self.generation, bars: self.bars.clone(), tf: self.tf, specs: self.indicators.clone() });
|
||||
w.send(Request::Indicators {
|
||||
pane: self.id,
|
||||
generation: self.generation,
|
||||
bars: self.bars.clone(),
|
||||
tf: self.tf,
|
||||
specs: self.indicators.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn request_compare(&self, w: &Worker) {
|
||||
if let (Some(sym), false) = (&self.compare, self.bars.is_empty()) {
|
||||
w.send(Request::Compare { pane: self.id, generation: self.generation, symbol: sym.clone(), tf: self.tf, base: self.bars.clone() });
|
||||
w.send(Request::Compare {
|
||||
pane: self.id,
|
||||
generation: self.generation,
|
||||
symbol: sym.clone(),
|
||||
tf: self.tf,
|
||||
base: self.bars.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- worker responses -------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn on_history(&mut self, generation: u64, bars: Arc<Vec<Bar>>, gpu: SeriesGpu, prepended: usize, from_cache: bool, exhausted: bool, w: &Worker) {
|
||||
pub fn on_history(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
bars: Arc<Vec<Bar>>,
|
||||
gpu: SeriesGpu,
|
||||
prepended: usize,
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
w: &Worker,
|
||||
) {
|
||||
if generation != self.generation {
|
||||
return;
|
||||
}
|
||||
@@ -334,7 +361,8 @@ impl ChartState {
|
||||
x += g.size().x + 12.0;
|
||||
for tf in Timeframe::ALL {
|
||||
let label = tf.label();
|
||||
let r = Rect::from_min_size(pos2(x, header.top() + 1.0), vec2(label.len() as f32 * 7.6 + 6.0, HEADER_H - 2.0));
|
||||
let r =
|
||||
Rect::from_min_size(pos2(x, header.top() + 1.0), vec2(label.len() as f32 * 7.6 + 6.0, HEADER_H - 2.0));
|
||||
if r.right() > header.right() - 120.0 {
|
||||
break;
|
||||
}
|
||||
@@ -343,7 +371,13 @@ impl ChartState {
|
||||
if active {
|
||||
painter.rect_filled(r, 0.0, Color32::from_rgb(70, 50, 0));
|
||||
}
|
||||
let col = if active { AMBER } else if resp.hovered() { TEXT } else { MUTED };
|
||||
let col = if active {
|
||||
AMBER
|
||||
} else if resp.hovered() {
|
||||
TEXT
|
||||
} else {
|
||||
MUTED
|
||||
};
|
||||
painter.text(r.center(), Align2::CENTER_CENTER, label, mono(11.5), col);
|
||||
if resp.clicked() {
|
||||
self.set_timeframe(tf, w);
|
||||
@@ -354,12 +388,19 @@ impl ChartState {
|
||||
x += 8.0;
|
||||
for ct in ChartType::ALL {
|
||||
let label = ct.label();
|
||||
let r = Rect::from_min_size(pos2(x, header.top() + 1.0), vec2(label.len() as f32 * 7.2 + 6.0, HEADER_H - 2.0));
|
||||
let r =
|
||||
Rect::from_min_size(pos2(x, header.top() + 1.0), vec2(label.len() as f32 * 7.2 + 6.0, HEADER_H - 2.0));
|
||||
if r.right() > header.right() - 4.0 {
|
||||
break;
|
||||
}
|
||||
let resp = ui.interact(r, ui.id().with((self.id, "ct", label)), Sense::click());
|
||||
let col = if ct == self.chart_type { AMBER } else if resp.hovered() { TEXT } else { MUTED };
|
||||
let col = if ct == self.chart_type {
|
||||
AMBER
|
||||
} else if resp.hovered() {
|
||||
TEXT
|
||||
} else {
|
||||
MUTED
|
||||
};
|
||||
painter.text(r.center(), Align2::CENTER_CENTER, label, mono(11.0), col);
|
||||
if resp.clicked() {
|
||||
self.chart_type = ct;
|
||||
@@ -374,7 +415,13 @@ impl ChartState {
|
||||
for r in Range::ALL {
|
||||
let lr = Rect::from_min_size(pos2(rx, range_row.top()), vec2(r.label().len() as f32 * 7.4 + 8.0, RANGE_H));
|
||||
let resp = ui.interact(lr, ui.id().with((self.id, "range", r.label())), Sense::click());
|
||||
painter.text(lr.center(), Align2::CENTER_CENTER, r.label(), mono(11.0), if resp.hovered() { AMBER } else { MUTED });
|
||||
painter.text(
|
||||
lr.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
r.label(),
|
||||
mono(11.0),
|
||||
if resp.hovered() { AMBER } else { MUTED },
|
||||
);
|
||||
if resp.clicked() {
|
||||
self.set_range(r, w);
|
||||
clicked = true;
|
||||
@@ -385,9 +432,20 @@ impl ChartState {
|
||||
(Some(e), _, _) => format!("ERR {e}"),
|
||||
(_, true, _) => "loading…".into(),
|
||||
(_, _, true) => "loading history…".into(),
|
||||
_ => format!("{} bars{}{}", self.bars.len(), if self.log { " LOG" } else { "" }, if self.magnet { " MAGNET" } else { "" }),
|
||||
_ => format!(
|
||||
"{} bars{}{}",
|
||||
self.bars.len(),
|
||||
if self.log { " LOG" } else { "" },
|
||||
if self.magnet { " MAGNET" } else { "" }
|
||||
),
|
||||
};
|
||||
painter.text(pos2(range_row.right() - 6.0, range_row.center().y), Align2::RIGHT_CENTER, status, mono(10.5), MUTED);
|
||||
painter.text(
|
||||
pos2(range_row.right() - 6.0, range_row.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
status,
|
||||
mono(10.5),
|
||||
MUTED,
|
||||
);
|
||||
|
||||
let body = Rect::from_min_max(pos2(rect.left(), header.bottom()), pos2(rect.right(), range_row.top()));
|
||||
let plot_all = Rect::from_min_max(body.min, pos2(body.right() - AXIS_W, body.bottom() - TIME_H));
|
||||
@@ -405,17 +463,21 @@ impl ChartState {
|
||||
let pane_specs: Vec<usize> = (0..self.built.len()).filter(|&i| self.built[i].spec.is_pane()).collect();
|
||||
let n_sub = pane_specs.len().min(3);
|
||||
let sub_h = if n_sub > 0 { (plot_all.height() * 0.2).clamp(50.0, 160.0) } else { 0.0 };
|
||||
let price_rect = Rect::from_min_max(plot_all.min, pos2(plot_all.right(), plot_all.bottom() - sub_h * n_sub as f32));
|
||||
let price_rect =
|
||||
Rect::from_min_max(plot_all.min, pos2(plot_all.right(), plot_all.bottom() - sub_h * n_sub as f32));
|
||||
|
||||
let len = self.bars.len();
|
||||
let v = self.viewport.get_or_insert_with(|| Viewport::fit_last(len, DEFAULT_VISIBLE, price_rect.width() as f64));
|
||||
let v =
|
||||
self.viewport.get_or_insert_with(|| Viewport::fit_last(len, DEFAULT_VISIBLE, price_rect.width() as f64));
|
||||
v.set_width(price_rect.width() as f64);
|
||||
|
||||
// ---- input ----
|
||||
let plot_resp = ui.interact(plot_all, ui.id().with((self.id, "plot")), Sense::click_and_drag());
|
||||
let axis_rect = Rect::from_min_max(pos2(price_rect.right(), price_rect.top()), pos2(body.right(), price_rect.bottom()));
|
||||
let axis_rect =
|
||||
Rect::from_min_max(pos2(price_rect.right(), price_rect.top()), pos2(body.right(), price_rect.bottom()));
|
||||
let axis_resp = ui.interact(axis_rect, ui.id().with((self.id, "paxis")), Sense::click_and_drag());
|
||||
let time_rect = Rect::from_min_max(pos2(plot_all.left(), plot_all.bottom()), pos2(plot_all.right(), body.bottom()));
|
||||
let time_rect =
|
||||
Rect::from_min_max(pos2(plot_all.left(), plot_all.bottom()), pos2(plot_all.right(), body.bottom()));
|
||||
let time_resp = ui.interact(time_rect, ui.id().with((self.id, "taxis")), Sense::click_and_drag());
|
||||
if plot_resp.clicked() || plot_resp.drag_started() || axis_resp.clicked() || time_resp.clicked() {
|
||||
clicked = true;
|
||||
@@ -474,12 +536,22 @@ impl ChartState {
|
||||
}
|
||||
|
||||
// ---- price pane GPU frame ----
|
||||
let mut overlays: Vec<Overlay> = self.built.iter().filter(|b| !b.spec.is_pane()).flat_map(|b| b.overlays.iter().map(|o| o.1.clone())).collect();
|
||||
let mut overlays: Vec<Overlay> = self
|
||||
.built
|
||||
.iter()
|
||||
.filter(|b| !b.spec.is_pane())
|
||||
.flat_map(|b| b.overlays.iter().map(|o| o.1.clone()))
|
||||
.collect();
|
||||
if let Some(c) = &self.compare_line {
|
||||
let (lo, hi) = self.viewport.as_ref().map(|v| v.visible_range(len)).unwrap_or((0, len));
|
||||
let scale = scene::rebase_factor(&gpu.closes, c, lo, hi);
|
||||
self.compare_scale = scale;
|
||||
overlays.push(Overlay { line: c.clone(), color: theme::rgba(theme::SERIES[5]), kind: LineKind::Line, scale });
|
||||
overlays.push(Overlay {
|
||||
line: c.clone(),
|
||||
color: theme::rgba(theme::SERIES[5]),
|
||||
kind: LineKind::Line,
|
||||
scale,
|
||||
});
|
||||
}
|
||||
let style = Style {
|
||||
up: theme::rgba(theme::UP),
|
||||
@@ -542,7 +614,11 @@ impl ChartState {
|
||||
p.hline(r.x_range(), r.top(), Stroke::new(1.0, theme::BORDER));
|
||||
if matches!(b.spec, IndicatorSpec::Rsi(_)) {
|
||||
for lvl in [30.0, 70.0] {
|
||||
p.hline(r.x_range(), r.top() + so.scale.y_of(lvl) as f32, Stroke::new(1.0, Color32::from_rgb(60, 45, 80)));
|
||||
p.hline(
|
||||
r.x_range(),
|
||||
r.top() + so.scale.y_of(lvl) as f32,
|
||||
Stroke::new(1.0, Color32::from_rgb(60, 45, 80)),
|
||||
);
|
||||
}
|
||||
}
|
||||
let frame = Frame { pane: self.id * 8 + 1 + k as u64, frame_no, cmds: so.cmds };
|
||||
@@ -556,7 +632,13 @@ impl ChartState {
|
||||
for t in &ticks {
|
||||
let y = price_rect.top() + scale.y_of(*t) as f32;
|
||||
if y > price_rect.top() + 6.0 && y < price_rect.bottom() - 6.0 {
|
||||
axis_painter.text(pos2(plot_all.right() + 6.0, y), Align2::LEFT_CENTER, scale::format_price(*t, dec), mono(11.0), MUTED);
|
||||
axis_painter.text(
|
||||
pos2(plot_all.right() + 6.0, y),
|
||||
Align2::LEFT_CENTER,
|
||||
scale::format_price(*t, dec),
|
||||
mono(11.0),
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (r, bi, s) in &sub_rects {
|
||||
@@ -565,7 +647,13 @@ impl ChartState {
|
||||
for t in scale::price_ticks(s.lo, s.hi, 3) {
|
||||
let y = r.top() + s.y_of(t) as f32;
|
||||
if y > r.top() + 8.0 && y < r.bottom() - 4.0 {
|
||||
axis_painter.text(pos2(plot_all.right() + 6.0, y), Align2::LEFT_CENTER, format!("{t:.1}"), mono(10.5), MUTED);
|
||||
axis_painter.text(
|
||||
pos2(plot_all.right() + 6.0, y),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("{t:.1}"),
|
||||
mono(10.5),
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,12 +680,22 @@ impl ChartState {
|
||||
let col = if last.is_up() { theme::UP } else { theme::DOWN };
|
||||
let mut xx = price_rect.left();
|
||||
while xx < price_rect.right() {
|
||||
price_painter.hline(xx..=(xx + 3.0).min(price_rect.right()), y, Stroke::new(1.0, col.gamma_multiply(0.6)));
|
||||
price_painter.hline(
|
||||
xx..=(xx + 3.0).min(price_rect.right()),
|
||||
y,
|
||||
Stroke::new(1.0, col.gamma_multiply(0.6)),
|
||||
);
|
||||
xx += 6.0;
|
||||
}
|
||||
let tag = Rect::from_min_size(pos2(plot_all.right() + 1.0, y - 8.0), vec2(AXIS_W - 2.0, 16.0));
|
||||
axis_painter.rect_filled(tag, 0.0, col);
|
||||
axis_painter.text(tag.left_center() + vec2(5.0, 0.0), Align2::LEFT_CENTER, scale::format_price(last.close, dec.max(2)), mono(11.0), Color32::BLACK);
|
||||
axis_painter.text(
|
||||
tag.left_center() + vec2(5.0, 0.0),
|
||||
Align2::LEFT_CENTER,
|
||||
scale::format_price(last.close, dec.max(2)),
|
||||
mono(11.0),
|
||||
Color32::BLACK,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +720,13 @@ impl ChartState {
|
||||
let price = scale.price_at((p.y - price_rect.top()) as f64);
|
||||
let tag = Rect::from_min_size(pos2(plot_all.right() + 1.0, p.y - 8.0), vec2(AXIS_W - 2.0, 16.0));
|
||||
axis_painter.rect_filled(tag, 0.0, Color32::from_gray(70));
|
||||
axis_painter.text(tag.left_center() + vec2(5.0, 0.0), Align2::LEFT_CENTER, scale::format_price(price, dec.max(2)), mono(11.0), TEXT);
|
||||
axis_painter.text(
|
||||
tag.left_center() + vec2(5.0, 0.0),
|
||||
Align2::LEFT_CENTER,
|
||||
scale::format_price(price, dec.max(2)),
|
||||
mono(11.0),
|
||||
TEXT,
|
||||
);
|
||||
}
|
||||
if let Some(i) = hovered_bar {
|
||||
let label = scale::crosshair_label(self.bars[i].ts, self.tf);
|
||||
@@ -668,9 +772,27 @@ impl ChartState {
|
||||
let mut ly = y + 16.0;
|
||||
let value_at = |l: &LineRef| l.pyramid.data.get(i).map(|p| p[1] as f64).filter(|v| v.is_finite());
|
||||
for bi in self.built.iter().filter(|b| !b.spec.is_pane()) {
|
||||
let vals: Vec<String> = bi.overlays.iter().filter_map(|(_, o)| value_at(&o.line).map(|v| format!("{v:.2}"))).collect();
|
||||
let c = bi.overlays.get(bi.overlays.len() / 2).map(|o| Color32::from_rgba_unmultiplied((o.1.color[0] * 255.0) as u8, (o.1.color[1] * 255.0) as u8, (o.1.color[2] * 255.0) as u8, 255)).unwrap_or(TEXT);
|
||||
p.text(pos2(price_rect.left() + 6.0, ly), Align2::LEFT_TOP, format!("{} {}", bi.spec.label(), vals.join(" ")), mono(11.0), c);
|
||||
let vals: Vec<String> =
|
||||
bi.overlays.iter().filter_map(|(_, o)| value_at(&o.line).map(|v| format!("{v:.2}"))).collect();
|
||||
let c = bi
|
||||
.overlays
|
||||
.get(bi.overlays.len() / 2)
|
||||
.map(|o| {
|
||||
Color32::from_rgba_unmultiplied(
|
||||
(o.1.color[0] * 255.0) as u8,
|
||||
(o.1.color[1] * 255.0) as u8,
|
||||
(o.1.color[2] * 255.0) as u8,
|
||||
255,
|
||||
)
|
||||
})
|
||||
.unwrap_or(TEXT);
|
||||
p.text(
|
||||
pos2(price_rect.left() + 6.0, ly),
|
||||
Align2::LEFT_TOP,
|
||||
format!("{} {}", bi.spec.label(), vals.join(" ")),
|
||||
mono(11.0),
|
||||
c,
|
||||
);
|
||||
ly += 14.0;
|
||||
}
|
||||
if let (Some(sym), Some(line)) = (&self.compare, &self.compare_line) {
|
||||
@@ -685,18 +807,30 @@ impl ChartState {
|
||||
ly += 14.0;
|
||||
}
|
||||
if let Some(tool) = self.draw.tool {
|
||||
let msg = if self.draw.pending.is_some() { "click second point (Esc cancels)" } else { "click to place (Esc cancels)" };
|
||||
p.text(pos2(price_rect.left() + 6.0, ly), Align2::LEFT_TOP, format!("{}: {msg}", tool.label()), mono(11.0), AMBER);
|
||||
let msg = if self.draw.pending.is_some() {
|
||||
"click second point (Esc cancels)"
|
||||
} else {
|
||||
"click to place (Esc cancels)"
|
||||
};
|
||||
p.text(
|
||||
pos2(price_rect.left() + 6.0, ly),
|
||||
Align2::LEFT_TOP,
|
||||
format!("{}: {msg}", tool.label()),
|
||||
mono(11.0),
|
||||
AMBER,
|
||||
);
|
||||
}
|
||||
for (r, bi, _) in sub_rects {
|
||||
let b = &self.built[*bi];
|
||||
let vals: Vec<String> = b.overlays.iter().filter_map(|(n, o)| value_at(&o.line).map(|v| format!("{n} {v:.2}"))).collect();
|
||||
let vals: Vec<String> =
|
||||
b.overlays.iter().filter_map(|(n, o)| value_at(&o.line).map(|v| format!("{n} {v:.2}"))).collect();
|
||||
ui.painter_at(*r).text(r.left_top() + vec2(90.0, 3.0), Align2::LEFT_TOP, vals.join(" "), mono(10.5), TEXT);
|
||||
}
|
||||
}
|
||||
|
||||
fn drawing_shapes(&self, d: &Drawing, rect: Rect, v: &Viewport, s: &PriceScale) -> Vec<(Shape, Option<String>)> {
|
||||
let px = |a: &Anchor| [rect.left() as f64 + v.x_of(self.index_of_ts(a.ts)), rect.top() as f64 + s.y_of(a.price)];
|
||||
let px =
|
||||
|a: &Anchor| [rect.left() as f64 + v.x_of(self.index_of_ts(a.ts)), rect.top() as f64 + s.y_of(a.price)];
|
||||
match d.tool {
|
||||
Tool::Trendline => vec![(Shape::Segment(px(&d.a), px(&d.b)), None)],
|
||||
Tool::Horizontal => vec![(Shape::HLine(px(&d.a)[1]), Some(format!("{:.2}", d.a.price)))],
|
||||
@@ -715,15 +849,23 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_drawings(&mut self, ui: &Ui, rect: Rect, v: &Viewport, s: &PriceScale, hover: Option<Pos2>, resp: &egui::Response) {
|
||||
fn paint_drawings(
|
||||
&mut self,
|
||||
ui: &Ui,
|
||||
rect: Rect,
|
||||
v: &Viewport,
|
||||
s: &PriceScale,
|
||||
hover: Option<Pos2>,
|
||||
resp: &egui::Response,
|
||||
) {
|
||||
let p = ui.painter_at(rect);
|
||||
let pointer_anchor = |this: &Self, pos: Pos2| -> Anchor {
|
||||
let idx = v.index_at((pos.x - rect.left()) as f64);
|
||||
let mut price = s.price_at((pos.y - rect.top()) as f64);
|
||||
if this.magnet {
|
||||
if let Some(b) = this.bars.get(idx.floor().max(0.0) as usize) {
|
||||
price = drawings::magnet(price, [b.open, b.high, b.low, b.close]);
|
||||
}
|
||||
if this.magnet
|
||||
&& let Some(b) = this.bars.get(idx.floor().max(0.0) as usize)
|
||||
{
|
||||
price = drawings::magnet(price, [b.open, b.high, b.low, b.close]);
|
||||
}
|
||||
Anchor { ts: this.ts_of_index(idx), price }
|
||||
};
|
||||
@@ -753,7 +895,8 @@ impl ChartState {
|
||||
});
|
||||
}
|
||||
|
||||
let mut all: Vec<(Drawing, bool)> = self.drawings.iter().cloned().enumerate().map(|(i, d)| (d, self.draw.selected == Some(i))).collect();
|
||||
let mut all: Vec<(Drawing, bool)> =
|
||||
self.drawings.iter().cloned().enumerate().map(|(i, d)| (d, self.draw.selected == Some(i))).collect();
|
||||
if let (Some(tool), Some(first), Some(h)) = (self.draw.tool, self.draw.pending, hover) {
|
||||
all.push((Drawing::new(tool, first, pointer_anchor(self, h)), true));
|
||||
}
|
||||
@@ -765,7 +908,13 @@ impl ChartState {
|
||||
Shape::Segment(a, b) => {
|
||||
p.line_segment([pos2(a[0] as f32, a[1] as f32), pos2(b[0] as f32, b[1] as f32)], stroke);
|
||||
if let Some(l) = label {
|
||||
p.text(pos2(a[0] as f32 + 3.0, a[1] as f32 - 1.0), Align2::LEFT_BOTTOM, l, mono(10.0), color);
|
||||
p.text(
|
||||
pos2(a[0] as f32 + 3.0, a[1] as f32 - 1.0),
|
||||
Align2::LEFT_BOTTOM,
|
||||
l,
|
||||
mono(10.0),
|
||||
color,
|
||||
);
|
||||
}
|
||||
if selected && d.tool == Tool::Trendline {
|
||||
for q in [a, b] {
|
||||
@@ -776,7 +925,13 @@ impl ChartState {
|
||||
Shape::HLine(y) => {
|
||||
p.hline(rect.x_range(), y as f32, stroke);
|
||||
if let Some(l) = label {
|
||||
p.text(pos2(rect.right() - 4.0, y as f32 - 1.0), Align2::RIGHT_BOTTOM, l, mono(10.0), color);
|
||||
p.text(
|
||||
pos2(rect.right() - 4.0, y as f32 - 1.0),
|
||||
Align2::RIGHT_BOTTOM,
|
||||
l,
|
||||
mono(10.0),
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
Shape::VLine(x) => {
|
||||
@@ -810,7 +965,11 @@ impl ChartState {
|
||||
let n = stack.entry((i, sell)).or_insert(0);
|
||||
let x = rect.left() + v.x_center(i) as f32;
|
||||
let off = 6.0 + r + *n as f32 * (2.0 * r + 1.0);
|
||||
let y = if sell { rect.top() + s.y_of(bar.high) as f32 - off } else { rect.top() + s.y_of(bar.low) as f32 + off };
|
||||
let y = if sell {
|
||||
rect.top() + s.y_of(bar.high) as f32 - off
|
||||
} else {
|
||||
rect.top() + s.y_of(bar.low) as f32 + off
|
||||
};
|
||||
*n += 1;
|
||||
let c = Pos2::new(x, y);
|
||||
let col = theme::party_color(t.party);
|
||||
@@ -859,7 +1018,10 @@ fn dashed_v(p: &egui::Painter, x: f32, y: egui::Rangef, s: Stroke) {
|
||||
/// Hover card for a congressional trade marker, including the disclosure lag.
|
||||
fn hover_card(ui: &Ui, at: Pos2, t: &Trade) {
|
||||
let lines = [
|
||||
(format!("{} ({}-{})", t.politician, t.party.code(), t.state.as_deref().unwrap_or("??")), theme::party_color(t.party)),
|
||||
(
|
||||
format!("{} ({}-{})", t.politician, t.party.code(), t.state.as_deref().unwrap_or("??")),
|
||||
theme::party_color(t.party),
|
||||
),
|
||||
(format!("{} · {}", t.chamber.as_str(), t.owner.as_deref().unwrap_or("Self")), MUTED),
|
||||
(
|
||||
format!(
|
||||
@@ -878,7 +1040,10 @@ fn hover_card(ui: &Ui, at: Pos2, t: &Trade) {
|
||||
),
|
||||
(format!("Traded {}", t.tx_date), TEXT),
|
||||
(format!("Filed {}", t.filed_date), TEXT),
|
||||
(format!("Disclosure lag {} days", t.disclosure_lag_days()), if t.disclosure_lag_days() > 45 { theme::DOWN } else { AMBER }),
|
||||
(
|
||||
format!("Disclosure lag {} days", t.disclosure_lag_days()),
|
||||
if t.disclosure_lag_days() > 45 { theme::DOWN } else { AMBER },
|
||||
),
|
||||
];
|
||||
let w = lines.iter().map(|(l, _)| l.len()).max().unwrap_or(10) as f32 * 7.2 + 14.0;
|
||||
let h = lines.len() as f32 * 14.0 + 8.0;
|
||||
|
||||
+44
-25
@@ -38,7 +38,9 @@ impl IndicatorSpec {
|
||||
pub fn same_kind(&self, other: &IndicatorSpec) -> bool {
|
||||
std::mem::discriminant(self) == std::mem::discriminant(other)
|
||||
&& match (self, other) {
|
||||
(IndicatorSpec::Sma(a), IndicatorSpec::Sma(b)) | (IndicatorSpec::Ema(a), IndicatorSpec::Ema(b)) => a == b,
|
||||
(IndicatorSpec::Sma(a), IndicatorSpec::Sma(b)) | (IndicatorSpec::Ema(a), IndicatorSpec::Ema(b)) => {
|
||||
a == b
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -66,7 +68,8 @@ pub enum Range {
|
||||
}
|
||||
|
||||
impl Range {
|
||||
pub const ALL: [Range; 9] = [Range::D1, Range::D5, Range::M1, Range::M3, Range::M6, Range::Ytd, Range::Y1, Range::Y5, Range::All];
|
||||
pub const ALL: [Range; 9] =
|
||||
[Range::D1, Range::D5, Range::M1, Range::M3, Range::M6, Range::Ytd, Range::Y1, Range::Y5, Range::All];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -167,9 +170,10 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
|
||||
"SMA" => Function::Indicator(IndicatorSpec::Sma(num(rest.first()).unwrap_or(20))),
|
||||
"EMA" => Function::Indicator(IndicatorSpec::Ema(num(rest.first()).unwrap_or(20))),
|
||||
"VWAP" => Function::Indicator(IndicatorSpec::Vwap),
|
||||
"BB" | "BOLL" | "BOLLINGER" => {
|
||||
Function::Indicator(IndicatorSpec::Bollinger(num(rest.first()).unwrap_or(20), num(rest.get(1)).unwrap_or(2.0)))
|
||||
}
|
||||
"BB" | "BOLL" | "BOLLINGER" => Function::Indicator(IndicatorSpec::Bollinger(
|
||||
num(rest.first()).unwrap_or(20),
|
||||
num(rest.get(1)).unwrap_or(2.0),
|
||||
)),
|
||||
"RSI" => Function::Indicator(IndicatorSpec::Rsi(num(rest.first()).unwrap_or(14))),
|
||||
"MACD" => Function::Indicator(IndicatorSpec::Macd(
|
||||
num(rest.first()).unwrap_or(12),
|
||||
@@ -198,13 +202,21 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
|
||||
Some("DEL") | Some("RM") | Some("REMOVE") => Function::WatchRemove(words()[1..].to_vec()),
|
||||
_ => Function::Watchlist,
|
||||
},
|
||||
"LAYOUT" | "GRID" => Function::Layout(rest.first().map(|s| s.to_ascii_uppercase()).unwrap_or_else(|| "1".into())),
|
||||
"LAYOUT" | "GRID" => {
|
||||
Function::Layout(rest.first().map(|s| s.to_ascii_uppercase()).unwrap_or_else(|| "1".into()))
|
||||
}
|
||||
"NEW" => Function::NewPane,
|
||||
"CLOSE" => Function::ClosePane,
|
||||
"HELP" | "?" | "H" => Function::Help,
|
||||
"RANGE" => Function::Range(Range::parse(rest.first()?)?),
|
||||
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`).
|
||||
_ => return head.parse::<Timeframe>().ok().map(Function::Timeframe).or_else(|| Range::parse(&up).map(Function::Range)),
|
||||
_ => {
|
||||
return head
|
||||
.parse::<Timeframe>()
|
||||
.ok()
|
||||
.map(Function::Timeframe)
|
||||
.or_else(|| Range::parse(&up).map(Function::Range));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,10 +230,7 @@ fn looks_like_ticker(s: &str) -> bool {
|
||||
|
||||
/// Parse one command line. Returns `None` for empty or unrecognised input.
|
||||
pub fn parse(input: &str) -> Option<Command> {
|
||||
let tokens: Vec<&str> = input
|
||||
.split_whitespace()
|
||||
.filter(|t| !t.eq_ignore_ascii_case("<GO>"))
|
||||
.collect();
|
||||
let tokens: Vec<&str> = input.split_whitespace().filter(|t| !t.eq_ignore_ascii_case("<GO>")).collect();
|
||||
let (&head, rest) = tokens.split_first()?;
|
||||
// A function keyword (or timeframe) wins over a ticker of the same spelling,
|
||||
// except for single letters that are common tickers when followed by nothing.
|
||||
@@ -232,7 +241,8 @@ pub fn parse(input: &str) -> Option<Command> {
|
||||
return None;
|
||||
}
|
||||
let symbol = head.to_ascii_uppercase().replace('/', ".");
|
||||
let rest: Vec<&str> = rest.iter().copied().filter(|t| !MARKET_SECTORS.contains(&t.to_ascii_uppercase().as_str())).collect();
|
||||
let rest: Vec<&str> =
|
||||
rest.iter().copied().filter(|t| !MARKET_SECTORS.contains(&t.to_ascii_uppercase().as_str())).collect();
|
||||
match rest.split_first() {
|
||||
None => Some(Command::Symbol(symbol)),
|
||||
Some((&f, args)) => parse_function(f, args).map(|func| Command::SymbolFunction(symbol, func)),
|
||||
@@ -252,17 +262,20 @@ pub fn hint(input: &str) -> String {
|
||||
|
||||
fn describe(f: &Function) -> String {
|
||||
match f {
|
||||
Function::Timeframe(tf) => format!("Interval {}", match tf {
|
||||
Timeframe::M1 => "1 minute",
|
||||
Timeframe::M5 => "5 minutes",
|
||||
Timeframe::M15 => "15 minutes",
|
||||
Timeframe::M30 => "30 minutes",
|
||||
Timeframe::H1 => "1 hour",
|
||||
Timeframe::H4 => "4 hours",
|
||||
Timeframe::D1 => "1 day",
|
||||
Timeframe::W1 => "1 week",
|
||||
Timeframe::MN1 => "1 month",
|
||||
}),
|
||||
Function::Timeframe(tf) => format!(
|
||||
"Interval {}",
|
||||
match tf {
|
||||
Timeframe::M1 => "1 minute",
|
||||
Timeframe::M5 => "5 minutes",
|
||||
Timeframe::M15 => "15 minutes",
|
||||
Timeframe::M30 => "30 minutes",
|
||||
Timeframe::H1 => "1 hour",
|
||||
Timeframe::H4 => "4 hours",
|
||||
Timeframe::D1 => "1 day",
|
||||
Timeframe::W1 => "1 week",
|
||||
Timeframe::MN1 => "1 month",
|
||||
}
|
||||
),
|
||||
Function::ChartType(c) => format!("Chart type {}", c.label()),
|
||||
Function::Indicator(i) => format!("Toggle {}", i.label()),
|
||||
Function::ClearIndicators => "Remove all indicators".into(),
|
||||
@@ -325,14 +338,20 @@ mod tests {
|
||||
assert_eq!(parse("FIB"), Some(Command::Function(Function::Draw(Tool::Fib))));
|
||||
assert_eq!(parse("CLEAR DRAW"), Some(Command::Function(Function::ClearDrawings)));
|
||||
assert_eq!(parse("POL Nancy Pelosi"), Some(Command::Function(Function::Politician("Nancy Pelosi".into()))));
|
||||
assert_eq!(parse("WL ADD msft nvda"), Some(Command::Function(Function::WatchAdd(vec!["MSFT".into(), "NVDA".into()]))));
|
||||
assert_eq!(
|
||||
parse("WL ADD msft nvda"),
|
||||
Some(Command::Function(Function::WatchAdd(vec!["MSFT".into(), "NVDA".into()])))
|
||||
);
|
||||
assert_eq!(parse("LAYOUT 4"), Some(Command::Function(Function::Layout("4".into()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_with_function() {
|
||||
assert_eq!(parse("NVDA CONG <GO>"), Some(Command::SymbolFunction("NVDA".into(), Function::Congress)));
|
||||
assert_eq!(parse("AAPL US EQUITY 1W"), Some(Command::SymbolFunction("AAPL".into(), Function::Timeframe(Timeframe::W1))));
|
||||
assert_eq!(
|
||||
parse("AAPL US EQUITY 1W"),
|
||||
Some(Command::SymbolFunction("AAPL".into(), Function::Timeframe(Timeframe::W1)))
|
||||
);
|
||||
assert_eq!(parse("AAPL WHATEVER"), None);
|
||||
}
|
||||
|
||||
|
||||
+100
-42
@@ -70,11 +70,20 @@ fn plain(ui: &mut Ui, text: impl Into<String>, color: Color32) {
|
||||
|
||||
/// Column header row; never wraps (narrow panes clip instead, like a terminal).
|
||||
fn header(ui: &mut Ui, text: &str) {
|
||||
ui.add(egui::Label::new(RichText::new(text).font(mono(11.5)).color(AMBER).background_color(theme::HEADER)).extend());
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(text).font(mono(11.5)).color(AMBER).background_color(theme::HEADER)).extend(),
|
||||
);
|
||||
}
|
||||
|
||||
/// One trade row. `show_politician` / `show_ticker` drop redundant columns.
|
||||
fn trade_row(ui: &mut Ui, t: &Trade, show_politician: bool, show_ticker: bool, extra: Option<(String, Color32)>, out: &mut Vec<Action>) {
|
||||
fn trade_row(
|
||||
ui: &mut Ui,
|
||||
t: &Trade,
|
||||
show_politician: bool,
|
||||
show_ticker: bool,
|
||||
extra: Option<(String, Color32)>,
|
||||
out: &mut Vec<Action>,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
plain(ui, t.filed_date.format("%m/%d/%y").to_string(), MUTED);
|
||||
@@ -87,7 +96,11 @@ fn trade_row(ui: &mut Ui, t: &Trade, show_politician: bool, show_ticker: bool, e
|
||||
if r.clicked() {
|
||||
out.push(Action::OpenProfile(t.politician.clone()));
|
||||
}
|
||||
plain(ui, format!("{}-{:<2}", t.party.code(), t.state.as_deref().unwrap_or("")), theme::party_color(t.party));
|
||||
plain(
|
||||
ui,
|
||||
format!("{}-{:<2}", t.party.code(), t.state.as_deref().unwrap_or("")),
|
||||
theme::party_color(t.party),
|
||||
);
|
||||
plain(ui, if t.chamber == Chamber::House { "H" } else { "S" }, MUTED);
|
||||
}
|
||||
let (tx, c) = tx_text(t.tx_type);
|
||||
@@ -119,7 +132,9 @@ fn trade_row(ui: &mut Ui, t: &Trade, show_politician: bool, show_ticker: bool, e
|
||||
pub fn feed(ui: &mut Ui, trades: &[Trade], filter: &mut FeedFilter, status: &str) -> Vec<Action> {
|
||||
let mut out = Vec::new();
|
||||
ui.horizontal(|ui| {
|
||||
let chip = |ui: &mut Ui, label: &str, on: bool| ui.selectable_label(on, RichText::new(label).font(mono(11.5))).clicked();
|
||||
let chip = |ui: &mut Ui, label: &str, on: bool| {
|
||||
ui.selectable_label(on, RichText::new(label).font(mono(11.5))).clicked()
|
||||
};
|
||||
if chip(ui, "ALL", *filter == FeedFilter::default()) {
|
||||
*filter = FeedFilter::default();
|
||||
}
|
||||
@@ -164,31 +179,54 @@ pub fn ticker_trades(ui: &mut Ui, symbol: &str, trades: &[Trade]) -> Vec<Action>
|
||||
let mut people: Vec<&str> = trades.iter().map(|t| t.politician.as_str()).collect();
|
||||
people.sort();
|
||||
people.dedup();
|
||||
let buy_vol: f64 = trades.iter().filter(|t| t.tx_type.is_buy()).filter_map(|t| t.amount.map(|a| a.midpoint())).sum();
|
||||
let sell_vol: f64 = trades.iter().filter(|t| t.tx_type.is_sell()).filter_map(|t| t.amount.map(|a| a.midpoint())).sum();
|
||||
let buy_vol: f64 =
|
||||
trades.iter().filter(|t| t.tx_type.is_buy()).filter_map(|t| t.amount.map(|a| a.midpoint())).sum();
|
||||
let sell_vol: f64 =
|
||||
trades.iter().filter(|t| t.tx_type.is_sell()).filter_map(|t| t.amount.map(|a| a.midpoint())).sum();
|
||||
ui.horizontal(|ui| {
|
||||
let r = clickable(ui, format!("{symbol} US Equity"), AMBER);
|
||||
if r.clicked() {
|
||||
out.push(Action::OpenChart(symbol.to_string()));
|
||||
}
|
||||
plain(ui, format!("{} members · {buys} buys / {sells} sells · est. net ${:+.1}M", people.len(), (buy_vol - sell_vol) / 1e6), TEXT);
|
||||
plain(
|
||||
ui,
|
||||
format!(
|
||||
"{} members · {buys} buys / {sells} sells · est. net ${:+.1}M",
|
||||
people.len(),
|
||||
(buy_vol - sell_vol) / 1e6
|
||||
),
|
||||
TEXT,
|
||||
);
|
||||
});
|
||||
header(ui, "FILED TRADED LAG POLITICIAN PTY C TYPE AMOUNT OWNER ASSET");
|
||||
if trades.is_empty() {
|
||||
plain(ui, format!("No congressional trades disclosed for {symbol}."), MUTED);
|
||||
}
|
||||
ScrollArea::both().id_salt(("ticker", symbol)).auto_shrink([false, false]).show_rows(ui, 16.0, trades.len(), |ui, range| {
|
||||
for t in &trades[range] {
|
||||
trade_row(ui, t, true, false, None, &mut out);
|
||||
}
|
||||
});
|
||||
ScrollArea::both().id_salt(("ticker", symbol)).auto_shrink([false, false]).show_rows(
|
||||
ui,
|
||||
16.0,
|
||||
trades.len(),
|
||||
|ui, range| {
|
||||
for t in &trades[range] {
|
||||
trade_row(ui, t, true, false, None, &mut out);
|
||||
}
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn profile(ui: &mut Ui, query: &str, p: Option<&Profile>, loading: bool) -> Vec<Action> {
|
||||
let mut out = Vec::new();
|
||||
let Some(p) = p else {
|
||||
plain(ui, if loading { format!("Loading profile for '{query}'…") } else { format!("No politician matches '{query}'. Try POL with a last name.") }, MUTED);
|
||||
plain(
|
||||
ui,
|
||||
if loading {
|
||||
format!("Loading profile for '{query}'…")
|
||||
} else {
|
||||
format!("No politician matches '{query}'. Try POL with a last name.")
|
||||
},
|
||||
MUTED,
|
||||
);
|
||||
return out;
|
||||
};
|
||||
let pol = &p.politician;
|
||||
@@ -211,7 +249,11 @@ pub fn profile(ui: &mut Ui, query: &str, p: Option<&Profile>, loading: bool) ->
|
||||
TEXT,
|
||||
);
|
||||
let label = if p.tracked { "★ TRACKED" } else { "☆ TRACK" };
|
||||
if ui.button(RichText::new(label).font(mono(12.0)).color(AMBER)).on_hover_text("Alert me when this politician files a new trade").clicked() {
|
||||
if ui
|
||||
.button(RichText::new(label).font(mono(12.0)).color(AMBER))
|
||||
.on_hover_text("Alert me when this politician files a new trade")
|
||||
.clicked()
|
||||
{
|
||||
out.push(Action::Track(pol.id.to_string(), !p.tracked));
|
||||
}
|
||||
});
|
||||
@@ -233,7 +275,7 @@ pub fn profile(ui: &mut Ui, query: &str, p: Option<&Profile>, loading: bool) ->
|
||||
}
|
||||
}
|
||||
let mut top: Vec<(&str, usize)> = counts.into_iter().collect();
|
||||
top.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
top.sort_by_key(|t| std::cmp::Reverse(t.1));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
plain(ui, "Most traded:", MUTED);
|
||||
for (tk, n) in top.into_iter().take(12) {
|
||||
@@ -244,12 +286,20 @@ pub fn profile(ui: &mut Ui, query: &str, p: Option<&Profile>, loading: bool) ->
|
||||
});
|
||||
header(ui, "FILED TRADED LAG TYPE TICKER AMOUNT RETURN OWNER ASSET");
|
||||
let ret_by_id: std::collections::HashMap<i64, f64> = p.returns.iter().map(|r| (r.trade_id, r.ret)).collect();
|
||||
ScrollArea::both().id_salt(("profile", pol.id)).auto_shrink([false, false]).show_rows(ui, 16.0, p.trades.len(), |ui, range| {
|
||||
for t in &p.trades[range] {
|
||||
let extra = ret_by_id.get(&t.id).map(|r| (format!("{:>+6.1}%", r * 100.0), if *r >= 0.0 { theme::UP } else { theme::DOWN })).unwrap_or((" n/a ".into(), MUTED));
|
||||
trade_row(ui, t, false, true, Some(extra), &mut out);
|
||||
}
|
||||
});
|
||||
ScrollArea::both().id_salt(("profile", pol.id)).auto_shrink([false, false]).show_rows(
|
||||
ui,
|
||||
16.0,
|
||||
p.trades.len(),
|
||||
|ui, range| {
|
||||
for t in &p.trades[range] {
|
||||
let extra = ret_by_id
|
||||
.get(&t.id)
|
||||
.map(|r| (format!("{:>+6.1}%", r * 100.0), if *r >= 0.0 { theme::UP } else { theme::DOWN }))
|
||||
.unwrap_or((" n/a ".into(), MUTED));
|
||||
trade_row(ui, t, false, true, Some(extra), &mut out);
|
||||
}
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -262,26 +312,32 @@ pub fn politicians(ui: &mut Ui, list: &[PoliticianSummary], search: &mut String)
|
||||
});
|
||||
header(ui, "TRK POLITICIAN PTY ST CH TRADES LAST FILED");
|
||||
let q = search.to_ascii_lowercase();
|
||||
let rows: Vec<&PoliticianSummary> = list.iter().filter(|p| q.is_empty() || p.politician.name.to_ascii_lowercase().contains(&q)).collect();
|
||||
ScrollArea::both().id_salt("politicians").auto_shrink([false, false]).show_rows(ui, 16.0, rows.len(), |ui, range| {
|
||||
for s in &rows[range] {
|
||||
let p = &s.politician;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
if clickable(ui, if s.tracked { " ★ ".into() } else { " ☆ ".into() }, AMBER).clicked() {
|
||||
out.push(Action::Track(p.id.to_string(), !s.tracked));
|
||||
}
|
||||
if clickable(ui, truncate(&p.name, 24), theme::party_color(p.party)).clicked() {
|
||||
out.push(Action::OpenProfile(p.id.to_string()));
|
||||
}
|
||||
plain(ui, format!("{:<4}", p.party.code()), theme::party_color(p.party));
|
||||
plain(ui, format!("{:<3}", p.state.as_deref().unwrap_or("")), TEXT);
|
||||
plain(ui, format!("{:<7}", p.chamber.as_str()), MUTED);
|
||||
plain(ui, format!("{:>6}", s.trades), TEXT);
|
||||
plain(ui, s.last_filed.map(|d| d.to_string()).unwrap_or_default(), MUTED);
|
||||
});
|
||||
}
|
||||
});
|
||||
let rows: Vec<&PoliticianSummary> =
|
||||
list.iter().filter(|p| q.is_empty() || p.politician.name.to_ascii_lowercase().contains(&q)).collect();
|
||||
ScrollArea::both().id_salt("politicians").auto_shrink([false, false]).show_rows(
|
||||
ui,
|
||||
16.0,
|
||||
rows.len(),
|
||||
|ui, range| {
|
||||
for s in &rows[range] {
|
||||
let p = &s.politician;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
if clickable(ui, if s.tracked { " ★ ".into() } else { " ☆ ".into() }, AMBER).clicked() {
|
||||
out.push(Action::Track(p.id.to_string(), !s.tracked));
|
||||
}
|
||||
if clickable(ui, truncate(&p.name, 24), theme::party_color(p.party)).clicked() {
|
||||
out.push(Action::OpenProfile(p.id.to_string()));
|
||||
}
|
||||
plain(ui, format!("{:<4}", p.party.code()), theme::party_color(p.party));
|
||||
plain(ui, format!("{:<3}", p.state.as_deref().unwrap_or("")), TEXT);
|
||||
plain(ui, format!("{:<7}", p.chamber.as_str()), MUTED);
|
||||
plain(ui, format!("{:>6}", s.trades), TEXT);
|
||||
plain(ui, s.last_filed.map(|d| d.to_string()).unwrap_or_default(), MUTED);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -300,7 +356,9 @@ pub fn alerts(ui: &mut Ui, alerts: &[Alert]) -> (Vec<Action>, bool) {
|
||||
ScrollArea::vertical().id_salt("alerts").auto_shrink([false, false]).show(ui, |ui| {
|
||||
for a in alerts {
|
||||
ui.horizontal(|ui| {
|
||||
let ts = chrono::DateTime::from_timestamp(a.created_at, 0).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default();
|
||||
let ts = chrono::DateTime::from_timestamp(a.created_at, 0)
|
||||
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_default();
|
||||
plain(ui, ts, if a.seen { MUTED } else { AMBER });
|
||||
if clickable(ui, truncate(&a.politician, 24), TEXT).clicked() {
|
||||
out.push(Action::OpenProfile(a.politician_id.to_string()));
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ pub enum Layout {
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub const ALL: [Layout; 6] = [Layout::One, Layout::TwoCols, Layout::TwoRows, Layout::MainRight2, Layout::Grid2x2, Layout::Grid3x2];
|
||||
pub const ALL: [Layout; 6] =
|
||||
[Layout::One, Layout::TwoCols, Layout::TwoRows, Layout::MainRight2, Layout::Grid2x2, Layout::Grid3x2];
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.trim().to_ascii_uppercase().as_str() {
|
||||
|
||||
+9
-5
@@ -14,7 +14,10 @@ use std::time::Instant;
|
||||
fn main() -> eframe::Result {
|
||||
let t0 = Instant::now();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,wgpu_core=warn,wgpu_hal=warn,naga=warn".into()))
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,wgpu_core=warn,wgpu_hal=warn,naga=warn".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
@@ -30,10 +33,11 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
|
||||
rs.renderer
|
||||
.write()
|
||||
.callback_resources
|
||||
.insert(tr_chart::gpu::ChartRenderer::new(&rs.device, rs.target_format, 1));
|
||||
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
|
||||
&rs.device,
|
||||
rs.target_format,
|
||||
1,
|
||||
));
|
||||
let provider = tr_data::from_env();
|
||||
let name = provider.name().to_string();
|
||||
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
|
||||
|
||||
+7
-1
@@ -66,7 +66,13 @@ pub fn apply(ctx: &egui::Context) {
|
||||
v.widgets.active.bg_fill = Color32::from_rgb(80, 60, 0);
|
||||
v.window_corner_radius = 0.into();
|
||||
v.menu_corner_radius = 0.into();
|
||||
for w in [&mut v.widgets.noninteractive, &mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] {
|
||||
for w in [
|
||||
&mut v.widgets.noninteractive,
|
||||
&mut v.widgets.inactive,
|
||||
&mut v.widgets.hovered,
|
||||
&mut v.widgets.active,
|
||||
&mut v.widgets.open,
|
||||
] {
|
||||
w.corner_radius = 0.into();
|
||||
}
|
||||
ctx.set_visuals(v);
|
||||
|
||||
+140
-29
@@ -23,18 +23,57 @@ use crate::theme;
|
||||
pub type PaneId = u64;
|
||||
|
||||
pub enum Request {
|
||||
History { pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize },
|
||||
History {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
symbol: String,
|
||||
tf: Timeframe,
|
||||
limit: usize,
|
||||
},
|
||||
/// Prepend older history to `existing`.
|
||||
MoreHistory { pane: PaneId, generation: u64, symbol: String, tf: Timeframe, existing: Arc<Vec<Bar>>, limit: usize },
|
||||
Indicators { pane: PaneId, generation: u64, bars: Arc<Vec<Bar>>, tf: Timeframe, specs: Vec<IndicatorSpec> },
|
||||
Compare { pane: PaneId, generation: u64, symbol: String, tf: Timeframe, base: Arc<Vec<Bar>> },
|
||||
Markers { pane: PaneId, symbol: String },
|
||||
MoreHistory {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
symbol: String,
|
||||
tf: Timeframe,
|
||||
existing: Arc<Vec<Bar>>,
|
||||
limit: usize,
|
||||
},
|
||||
Indicators {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
bars: Arc<Vec<Bar>>,
|
||||
tf: Timeframe,
|
||||
specs: Vec<IndicatorSpec>,
|
||||
},
|
||||
Compare {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
symbol: String,
|
||||
tf: Timeframe,
|
||||
base: Arc<Vec<Bar>>,
|
||||
},
|
||||
Markers {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
},
|
||||
Quotes(Vec<String>),
|
||||
Latest { limit: usize },
|
||||
TickerTrades { pane: PaneId, symbol: String },
|
||||
Profile { pane: PaneId, query: String },
|
||||
Latest {
|
||||
limit: usize,
|
||||
},
|
||||
TickerTrades {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
},
|
||||
Profile {
|
||||
pane: PaneId,
|
||||
query: String,
|
||||
},
|
||||
SearchPoliticians(String),
|
||||
Track { query: String, on: bool },
|
||||
Track {
|
||||
query: String,
|
||||
on: bool,
|
||||
},
|
||||
Alerts,
|
||||
MarkAlertsSeen,
|
||||
RefreshCongress,
|
||||
@@ -66,14 +105,39 @@ pub enum Response {
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
},
|
||||
HistoryError { pane: PaneId, generation: u64, error: String },
|
||||
Indicators { pane: PaneId, generation: u64, built: Vec<BuiltIndicator> },
|
||||
Compare { pane: PaneId, generation: u64, symbol: String, line: LineRef },
|
||||
Markers { pane: PaneId, symbol: String, trades: Vec<Trade> },
|
||||
HistoryError {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
error: String,
|
||||
},
|
||||
Indicators {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
built: Vec<BuiltIndicator>,
|
||||
},
|
||||
Compare {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
symbol: String,
|
||||
line: LineRef,
|
||||
},
|
||||
Markers {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
trades: Vec<Trade>,
|
||||
},
|
||||
Quotes(Vec<Quote>),
|
||||
Latest(Vec<Trade>),
|
||||
TickerTrades { pane: PaneId, symbol: String, trades: Vec<Trade> },
|
||||
Profile { pane: PaneId, profile: Option<Box<Profile>>, query: String },
|
||||
TickerTrades {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
trades: Vec<Trade>,
|
||||
},
|
||||
Profile {
|
||||
pane: PaneId,
|
||||
profile: Option<Box<Profile>>,
|
||||
query: String,
|
||||
},
|
||||
Politicians(Vec<PoliticianSummary>),
|
||||
Alerts(Vec<Alert>),
|
||||
NewAlerts(Vec<NewTradeAlert>),
|
||||
@@ -110,7 +174,10 @@ pub fn data_dir() -> PathBuf {
|
||||
if cfg!(target_os = "macos") {
|
||||
home.join("Library/Application Support/terminal-rs")
|
||||
} else {
|
||||
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local/share")).join("terminal-rs")
|
||||
std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join(".local/share"))
|
||||
.join("terminal-rs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,8 +189,13 @@ impl Worker {
|
||||
tracing::warn!("congress db unavailable ({e:#}); using in-memory store");
|
||||
Db::open_in_memory()
|
||||
})?;
|
||||
let cache = BarCache::open(dir.join("bars.sqlite3")).map_err(|e| tracing::warn!("bar cache unavailable: {e:#}")).ok();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(4).enable_all().thread_name("tr-worker").build()?;
|
||||
let cache =
|
||||
BarCache::open(dir.join("bars.sqlite3")).map_err(|e| tracing::warn!("bar cache unavailable: {e:#}")).ok();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(4)
|
||||
.enable_all()
|
||||
.thread_name("tr-worker")
|
||||
.build()?;
|
||||
let (tx, mut req_rx) = tokio::sync::mpsc::unbounded_channel::<Request>();
|
||||
let (out, rx) = mpsc::channel();
|
||||
let shared = Arc::new(Shared { provider, cache: std::sync::Mutex::new(cache), db: Mutex::new(db), out, ctx });
|
||||
@@ -202,7 +274,9 @@ pub fn build_indicators(bars: &[Bar], tf: Timeframe, specs: &[IndicatorSpec]) ->
|
||||
let overlays = match *spec {
|
||||
IndicatorSpec::Sma(n) => vec![(spec.label(), overlay(&indicators::sma(&closes, n), c, LineKind::Line))],
|
||||
IndicatorSpec::Ema(n) => vec![(spec.label(), overlay(&indicators::ema(&closes, n), c, LineKind::Line))],
|
||||
IndicatorSpec::Vwap => vec![(spec.label(), overlay(&indicators::vwap_by(bars, session_key(tf)), c, LineKind::Line))],
|
||||
IndicatorSpec::Vwap => {
|
||||
vec![(spec.label(), overlay(&indicators::vwap_by(bars, session_key(tf)), c, LineKind::Line))]
|
||||
}
|
||||
IndicatorSpec::Bollinger(n, k) => {
|
||||
let b = indicators::bollinger(&closes, n, k);
|
||||
let dim = c.gamma_multiply(0.7);
|
||||
@@ -212,7 +286,9 @@ pub fn build_indicators(bars: &[Bar], tf: Timeframe, specs: &[IndicatorSpec]) ->
|
||||
("BB lower".into(), overlay(&b.lower, dim, LineKind::Line)),
|
||||
]
|
||||
}
|
||||
IndicatorSpec::Rsi(n) => vec![(spec.label(), overlay(&indicators::rsi(&closes, n), theme::IND, LineKind::Line))],
|
||||
IndicatorSpec::Rsi(n) => {
|
||||
vec![(spec.label(), overlay(&indicators::rsi(&closes, n), theme::IND, LineKind::Line))]
|
||||
}
|
||||
IndicatorSpec::Macd(f, sl, sg) => {
|
||||
let m = indicators::macd(&closes, f, sl, sg);
|
||||
vec![
|
||||
@@ -265,19 +341,39 @@ async fn handle(s: &Shared, req: Request) {
|
||||
if let Some(bars) = cached.filter(|b| !b.is_empty()) {
|
||||
let bars = Arc::new(bars);
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History { pane, generation, symbol: symbol.clone(), tf, bars, gpu, prepended: 0, from_cache: true, exhausted: false });
|
||||
s.send(Response::History {
|
||||
pane,
|
||||
generation,
|
||||
symbol: symbol.clone(),
|
||||
tf,
|
||||
bars,
|
||||
gpu,
|
||||
prepended: 0,
|
||||
from_cache: true,
|
||||
exhausted: false,
|
||||
});
|
||||
}
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(bars) if !bars.is_empty() => {
|
||||
if let Ok(mut c) = s.cache.lock() {
|
||||
if let Some(c) = c.as_mut() {
|
||||
let _ = c.store(&symbol, tf, &bars);
|
||||
}
|
||||
if let Ok(mut c) = s.cache.lock()
|
||||
&& let Some(c) = c.as_mut()
|
||||
{
|
||||
let _ = c.store(&symbol, tf, &bars);
|
||||
}
|
||||
let exhausted = bars.len() < limit / 2;
|
||||
let bars = Arc::new(bars);
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History { pane, generation, symbol, tf, bars, gpu, prepended: 0, from_cache: false, exhausted });
|
||||
s.send(Response::History {
|
||||
pane,
|
||||
generation,
|
||||
symbol,
|
||||
tf,
|
||||
bars,
|
||||
gpu,
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted,
|
||||
});
|
||||
}
|
||||
Ok(_) => s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") }),
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
@@ -293,13 +389,24 @@ async fn handle(s: &Shared, req: Request) {
|
||||
merged.extend_from_slice(&existing);
|
||||
let bars = Arc::new(merged);
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History { pane, generation, symbol, tf, bars, gpu, prepended, from_cache: false, exhausted: prepended == 0 });
|
||||
s.send(Response::History {
|
||||
pane,
|
||||
generation,
|
||||
symbol,
|
||||
tf,
|
||||
bars,
|
||||
gpu,
|
||||
prepended,
|
||||
from_cache: false,
|
||||
exhausted: prepended == 0,
|
||||
});
|
||||
}
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
}
|
||||
}
|
||||
Request::Indicators { pane, generation, bars, tf, specs } => {
|
||||
let built = tokio::task::spawn_blocking(move || build_indicators(&bars, tf, &specs)).await.unwrap_or_default();
|
||||
let built =
|
||||
tokio::task::spawn_blocking(move || build_indicators(&bars, tf, &specs)).await.unwrap_or_default();
|
||||
s.send(Response::Indicators { pane, generation, built });
|
||||
}
|
||||
Request::Compare { pane, generation, symbol, tf, base } => {
|
||||
@@ -449,7 +556,11 @@ mod tests {
|
||||
#[test]
|
||||
fn indicators_build_expected_overlays() {
|
||||
let bars = tr_core::sample::generate("X", Timeframe::D1, 300, 1_790_000_000);
|
||||
let built = build_indicators(&bars, Timeframe::D1, &[IndicatorSpec::Bollinger(20, 2.0), IndicatorSpec::Macd(12, 26, 9), IndicatorSpec::Vwap]);
|
||||
let built = build_indicators(
|
||||
&bars,
|
||||
Timeframe::D1,
|
||||
&[IndicatorSpec::Bollinger(20, 2.0), IndicatorSpec::Macd(12, 26, 9), IndicatorSpec::Vwap],
|
||||
);
|
||||
assert_eq!(built[0].overlays.len(), 3);
|
||||
assert_eq!(built[1].overlays[0].1.kind, LineKind::Hist);
|
||||
assert_eq!(built[2].overlays.len(), 1);
|
||||
|
||||
@@ -19,8 +19,18 @@ fn bench(c: &mut Criterion) {
|
||||
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
|
||||
let series = SeriesGpu::build(&bars);
|
||||
let overlays = [
|
||||
Overlay { line: line_ref(&indicators::ema(&closes, 20)), color: [0.0, 0.6, 1.0, 1.0], kind: LineKind::Line, scale: 1.0 },
|
||||
Overlay { line: line_ref(&indicators::sma(&closes, 50)), color: [1.0, 0.0, 1.0, 1.0], kind: LineKind::Line, scale: 1.0 },
|
||||
Overlay {
|
||||
line: line_ref(&indicators::ema(&closes, 20)),
|
||||
color: [0.0, 0.6, 1.0, 1.0],
|
||||
kind: LineKind::Line,
|
||||
scale: 1.0,
|
||||
},
|
||||
Overlay {
|
||||
line: line_ref(&indicators::sma(&closes, 50)),
|
||||
color: [1.0, 0.0, 1.0, 1.0],
|
||||
kind: LineKind::Line,
|
||||
scale: 1.0,
|
||||
},
|
||||
];
|
||||
let mut g = c.benchmark_group("frame_1M_bars_1600x900");
|
||||
g.sample_size(50);
|
||||
|
||||
@@ -28,8 +28,18 @@ fn main() {
|
||||
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
|
||||
let series = SeriesGpu::build(&bars);
|
||||
let overlays = [
|
||||
Overlay { line: line_ref(&indicators::ema(&closes, 20)), color: [0.0, 0.6, 1.0, 1.0], kind: LineKind::Line, scale: 1.0 },
|
||||
Overlay { line: line_ref(&indicators::bollinger(&closes, 20, 2.0).upper), color: [0.8, 0.8, 0.8, 1.0], kind: LineKind::Line, scale: 1.0 },
|
||||
Overlay {
|
||||
line: line_ref(&indicators::ema(&closes, 20)),
|
||||
color: [0.0, 0.6, 1.0, 1.0],
|
||||
kind: LineKind::Line,
|
||||
scale: 1.0,
|
||||
},
|
||||
Overlay {
|
||||
line: line_ref(&indicators::bollinger(&closes, 20, 2.0).upper),
|
||||
color: [0.8, 0.8, 0.8, 1.0],
|
||||
kind: LineKind::Line,
|
||||
scale: 1.0,
|
||||
},
|
||||
];
|
||||
let prep_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
println!("{n} bars: generate {gen_ms:.0} ms, pyramids+indicators {prep_ms:.0} ms");
|
||||
@@ -56,7 +66,8 @@ fn main() {
|
||||
let first = render(&v);
|
||||
println!("first frame (includes GPU upload): {:.1} ms", first.1.as_secs_f64() * 1e3);
|
||||
|
||||
let phases: [(&str, Box<dyn Fn(&mut Viewport, usize)>); 3] = [
|
||||
type Step = Box<dyn Fn(&mut Viewport, usize)>;
|
||||
let phases: [(&str, Step); 3] = [
|
||||
("zoom out to all bars", Box::new(|v: &mut Viewport, _| v.zoom(0.97, 1500.0))),
|
||||
("pan across history", Box::new(|v: &mut Viewport, i| v.pan_px(if i < 150 { 40.0 } else { -40.0 }))),
|
||||
("zoom in to 1 bar/8px", Box::new(|v: &mut Viewport, _| v.zoom(1.035, 800.0))),
|
||||
|
||||
@@ -275,11 +275,18 @@ impl ChartRenderer {
|
||||
panes: HashMap::new(),
|
||||
frame_no: u64::MAX,
|
||||
next_slot: 0,
|
||||
max_binding: device.limits().max_storage_buffer_binding_size as u64,
|
||||
max_binding: device.limits().max_storage_buffer_binding_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, device: &wgpu::Device, is_line: bool, key: u64, version: u64, bytes: impl FnOnce() -> Vec<u8>) {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
is_line: bool,
|
||||
key: u64,
|
||||
version: u64,
|
||||
bytes: impl FnOnce() -> Vec<u8>,
|
||||
) {
|
||||
let frame = self.frame_no;
|
||||
if let Some(b) = self.buffers.get_mut(&(is_line, key)) {
|
||||
b.last_used = frame;
|
||||
|
||||
@@ -77,7 +77,10 @@ impl Headless {
|
||||
view: &self.view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
//! - [`scale`]: axis ticks and labels. [`drawings`]: user drawing model and hit testing.
|
||||
|
||||
pub mod drawings;
|
||||
pub mod headless;
|
||||
pub mod gpu;
|
||||
pub mod headless;
|
||||
pub mod lod;
|
||||
pub mod scene;
|
||||
pub mod scale;
|
||||
pub mod scene;
|
||||
pub mod viewport;
|
||||
|
||||
pub use egui_wgpu;
|
||||
|
||||
@@ -203,7 +203,10 @@ mod tests {
|
||||
assert_eq!(p.levels(), 4); // 5, 3, 2, 1
|
||||
let l1 = p.level(1);
|
||||
assert_eq!(l1.len(), 3);
|
||||
assert_eq!(l1[0], GpuBar { x: 0.0, w: 2.0, open: 0.0, high: 3.0, low: -1.0, close: 2.0, volume: 2.0, _pad: 0.0 });
|
||||
assert_eq!(
|
||||
l1[0],
|
||||
GpuBar { x: 0.0, w: 2.0, open: 0.0, high: 3.0, low: -1.0, close: 2.0, volume: 2.0, _pad: 0.0 }
|
||||
);
|
||||
assert_eq!(l1[2].x, 4.0);
|
||||
assert_eq!(l1[2].w, 1.0);
|
||||
let top = p.level(3);
|
||||
|
||||
@@ -117,10 +117,8 @@ pub fn time_ticks(times: &[i64], lo: usize, hi: usize, min_gap_bars: f64, tf: Ti
|
||||
return Vec::new();
|
||||
}
|
||||
let gap = min_gap_bars.max(1.0).ceil() as usize;
|
||||
let mut cands: Vec<(Boundary, usize)> = (lo.max(1)..hi)
|
||||
.map(|i| (boundary(times[i - 1], times[i]), i))
|
||||
.filter(|(b, _)| *b > Boundary::None)
|
||||
.collect();
|
||||
let mut cands: Vec<(Boundary, usize)> =
|
||||
(lo.max(1)..hi).map(|i| (boundary(times[i - 1], times[i]), i)).filter(|(b, _)| *b > Boundary::None).collect();
|
||||
// Higher boundaries first; within a level, keep chronological order.
|
||||
cands.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
let mut chosen: Vec<(usize, Boundary)> = Vec::new();
|
||||
|
||||
@@ -126,7 +126,12 @@ fn transform(v: &Viewport, height_px: f64, scale: &PriceScale) -> PaneTransform
|
||||
}
|
||||
|
||||
/// Autoscale range for the visible part of the series and overlays.
|
||||
pub fn visible_extent(series: &SeriesGpu, overlays: &[Overlay], v: &Viewport, chart_type: ChartType) -> Option<(f64, f64, f64)> {
|
||||
pub fn visible_extent(
|
||||
series: &SeriesGpu,
|
||||
overlays: &[Overlay],
|
||||
v: &Viewport,
|
||||
chart_type: ChartType,
|
||||
) -> Option<(f64, f64, f64)> {
|
||||
let p = &series.bars.pyramid;
|
||||
let (lo, hi) = v.visible_range(p.len0);
|
||||
let k = level_for(v.bar_px, p.levels());
|
||||
@@ -202,7 +207,12 @@ pub fn price_pane(p: &PricePane<'_>) -> PaneOutput {
|
||||
}
|
||||
let mut ls = style;
|
||||
ls.thickness_px = 2.0;
|
||||
cmds.push(DrawCmd::Line { kind: LineKind::Line, data: p.series.closes.clone(), range, uniforms: Uniforms::new(&t, &ls) });
|
||||
cmds.push(DrawCmd::Line {
|
||||
kind: LineKind::Line,
|
||||
data: p.series.closes.clone(),
|
||||
range,
|
||||
uniforms: Uniforms::new(&t, &ls),
|
||||
});
|
||||
}
|
||||
}
|
||||
cmds.extend(overlay_cmds(p.overlays, v, &t, &style));
|
||||
@@ -248,7 +258,13 @@ fn overlay_cmds(overlays: &[Overlay], v: &Viewport, t: &PaneTransform, base: &St
|
||||
}
|
||||
|
||||
/// An indicator sub-pane (RSI, MACD): lines and histograms with their own scale.
|
||||
pub fn sub_pane(overlays: &[Overlay], v: &Viewport, height_px: f64, fixed: Option<(f64, f64)>, style: Style) -> PaneOutput {
|
||||
pub fn sub_pane(
|
||||
overlays: &[Overlay],
|
||||
v: &Viewport,
|
||||
height_px: f64,
|
||||
fixed: Option<(f64, f64)>,
|
||||
style: Style,
|
||||
) -> PaneOutput {
|
||||
let len = overlays.first().map(|o| o.line.pyramid.level_len(0)).unwrap_or(0);
|
||||
let (lo, hi) = v.visible_range(len);
|
||||
let (l, h) = fixed.unwrap_or_else(|| {
|
||||
@@ -263,7 +279,11 @@ pub fn sub_pane(overlays: &[Overlay], v: &Viewport, height_px: f64, fixed: Optio
|
||||
// Histograms grow from zero, so keep zero in range.
|
||||
if overlays.iter().any(|o| o.kind == LineKind::Hist) { (a.min(0.0), b.max(0.0)) } else { (a, b) }
|
||||
});
|
||||
let scale = if fixed.is_some() { PriceScale::new(l, h, false, height_px) } else { PriceScale::padded(l, h, 0.1, false, height_px) };
|
||||
let scale = if fixed.is_some() {
|
||||
PriceScale::new(l, h, false, height_px)
|
||||
} else {
|
||||
PriceScale::padded(l, h, 0.1, false, height_px)
|
||||
};
|
||||
let t = transform(v, height_px, &scale);
|
||||
let cmds = overlay_cmds(overlays, v, &t, &style);
|
||||
PaneOutput { cmds, scale, level: 0, vol_max: 0.0 }
|
||||
|
||||
@@ -8,12 +8,12 @@ use tr_core::{Timeframe, indicators, sample};
|
||||
|
||||
fn dump_ppm(path: &str, w: u32, h: u32, rgba: &[u8]) {
|
||||
let mut out = format!("P6 {w} {h} 255\n").into_bytes();
|
||||
out.extend(rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]));
|
||||
out.extend(rgba.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]));
|
||||
std::fs::write(path, out).unwrap();
|
||||
}
|
||||
|
||||
fn count(px: &[u8], pred: impl Fn(&[u8]) -> bool) -> usize {
|
||||
px.chunks_exact(4).filter(|p| pred(p)).count()
|
||||
px.as_chunks::<4>().0.iter().filter(|p| pred(p.as_slice())).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -26,7 +26,12 @@ fn renders_candles_volume_lines_and_hist() {
|
||||
let bars = sample::generate("AAPL", Timeframe::D1, 2_000, 1_790_000_000);
|
||||
let series = SeriesGpu::build(&bars);
|
||||
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
|
||||
let overlays = [Overlay { line: line_ref(&indicators::sma(&closes, 20)), color: [0.0, 0.6, 1.0, 1.0], kind: LineKind::Line, scale: 1.0 }];
|
||||
let overlays = [Overlay {
|
||||
line: line_ref(&indicators::sma(&closes, 20)),
|
||||
color: [0.0, 0.6, 1.0, 1.0],
|
||||
kind: LineKind::Line,
|
||||
scale: 1.0,
|
||||
}];
|
||||
let v = Viewport::fit_last(bars.len(), 150, 800.0);
|
||||
for ct in ChartType::ALL {
|
||||
let out = price_pane(&PricePane {
|
||||
|
||||
@@ -25,8 +25,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
for t in db.latest_trades(15)? {
|
||||
println!(
|
||||
"{} {:<24} {} {:<6} {:<5} {:<12} tx {} filed {} lag {}d",
|
||||
t.chamber.as_str(), t.politician, t.party.code(), t.ticker.as_deref().unwrap_or("-"),
|
||||
t.tx_type.marker(), t.amount.map(|a| a.to_string()).unwrap_or_default(), t.tx_date, t.filed_date, t.disclosure_lag_days()
|
||||
t.chamber.as_str(),
|
||||
t.politician,
|
||||
t.party.code(),
|
||||
t.ticker.as_deref().unwrap_or("-"),
|
||||
t.tx_type.marker(),
|
||||
t.amount.map(|a| a.to_string()).unwrap_or_default(),
|
||||
t.tx_date,
|
||||
t.filed_date,
|
||||
t.disclosure_lag_days()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -4,7 +4,10 @@ fn main() {
|
||||
match tr_congress::house::parse_ptr_pdf(&std::fs::read(&p).unwrap()) {
|
||||
Ok(r) => {
|
||||
for t in r.trades {
|
||||
println!("{:?} | {:?} | {:?} | {} | {} | {:?}", t.owner, t.ticker, t.tx_type, t.tx_date, t.asset_name, t.amount);
|
||||
println!(
|
||||
"{:?} | {:?} | {:?} | {} | {} | {:?}",
|
||||
t.owner, t.ticker, t.tx_type, t.tx_date, t.asset_name, t.amount
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("{p}: {e}"),
|
||||
|
||||
@@ -261,7 +261,10 @@ impl Db {
|
||||
for &d in dups {
|
||||
tx.execute("UPDATE trades SET politician_id = ?1 WHERE politician_id = ?2", params![keep, d])?;
|
||||
tx.execute("UPDATE filings SET politician_id = ?1 WHERE politician_id = ?2", params![keep, d])?;
|
||||
tx.execute("UPDATE OR IGNORE tracked SET politician_id = ?1 WHERE politician_id = ?2", params![keep, d])?;
|
||||
tx.execute(
|
||||
"UPDATE OR IGNORE tracked SET politician_id = ?1 WHERE politician_id = ?2",
|
||||
params![keep, d],
|
||||
)?;
|
||||
tx.execute("DELETE FROM tracked WHERE politician_id = ?1", [d])?;
|
||||
tx.execute("UPDATE alerts SET politician_id = ?1 WHERE politician_id = ?2", params![keep, d])?;
|
||||
tx.execute("DELETE FROM politicians WHERE id = ?1", [d])?;
|
||||
@@ -587,7 +590,10 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
db.conn().execute("UPDATE politicians SET bioguide = 'D000001' WHERE id = ?1", [dup]).unwrap();
|
||||
assert_eq!(db.apply_roster(&[("D000001".into(), "Jane Doe".into(), Party::Republican, Some("MO".into()))]).unwrap(), 1);
|
||||
assert_eq!(
|
||||
db.apply_roster(&[("D000001".into(), "Jane Doe".into(), Party::Republican, Some("MO".into()))]).unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(db.politician(dup).unwrap().is_none());
|
||||
let s = db.search_politicians("doe", 10).unwrap();
|
||||
assert_eq!(s[0].trades, 2);
|
||||
|
||||
@@ -331,7 +331,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn exchange_prefixed_ticker_and_cusip() {
|
||||
assert_eq!(ticker_from_asset("DIA - SPDR Dow Jones Indust Avg ETF Trust NYSEARCA: DIA").as_deref(), Some("DIA"));
|
||||
assert_eq!(
|
||||
ticker_from_asset("DIA - SPDR Dow Jones Indust Avg ETF Trust NYSEARCA: DIA").as_deref(),
|
||||
Some("DIA")
|
||||
);
|
||||
assert_eq!(ticker_from_asset("US Treasury 01/31/28 (91282CGH8)"), None);
|
||||
assert_eq!(ticker_from_asset("Berkshire Hathaway Inc. New Common Stock (BRK.B)").as_deref(), Some("BRK.B"));
|
||||
}
|
||||
|
||||
@@ -133,10 +133,7 @@ impl AmountRange {
|
||||
/// Parse `$1,001 - $15,000`, `Over $50,000,000`, `Spouse/DC Over $1,000,000`,
|
||||
/// and line-wrapped forms such as `$15,001 -\n$50,000`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let nums: Vec<u64> = DOLLARS
|
||||
.captures_iter(s)
|
||||
.filter_map(|c| c[1].replace(',', "").parse().ok())
|
||||
.collect();
|
||||
let nums: Vec<u64> = DOLLARS.captures_iter(s).filter_map(|c| c[1].replace(',', "").parse().ok()).collect();
|
||||
let over = s.to_ascii_lowercase().contains("over");
|
||||
match (nums.as_slice(), over) {
|
||||
([n], true) => Some(Self { min: n + 1, max: None }),
|
||||
@@ -255,10 +252,7 @@ mod tests {
|
||||
assert_eq!(AmountRange::parse("$1,001 - $15,000"), Some(AmountRange { min: 1001, max: Some(15000) }));
|
||||
assert_eq!(AmountRange::parse("$15,001 -\n$50,000"), Some(AmountRange { min: 15001, max: Some(50000) }));
|
||||
assert_eq!(AmountRange::parse("Over $50,000,000"), Some(AmountRange { min: 50_000_001, max: None }));
|
||||
assert_eq!(
|
||||
AmountRange::parse("Spouse/DC Over $1,000,000"),
|
||||
Some(AmountRange { min: 1_000_001, max: None })
|
||||
);
|
||||
assert_eq!(AmountRange::parse("Spouse/DC Over $1,000,000"), Some(AmountRange { min: 1_000_001, max: None }));
|
||||
assert_eq!(AmountRange::parse("--"), None);
|
||||
let r = AmountRange::parse("$1,001 - $15,000").unwrap();
|
||||
assert_eq!(r.to_string(), "$1K-$15K");
|
||||
|
||||
@@ -64,7 +64,11 @@ pub fn estimate(
|
||||
scored: out.len(),
|
||||
unscored,
|
||||
weighted_return: if wsum > 0.0 { out.iter().map(|r| r.ret * r.weight).sum::<f64>() / wsum } else { 0.0 },
|
||||
hit_rate: if out.is_empty() { 0.0 } else { out.iter().filter(|r| r.ret > 0.0).count() as f64 / out.len() as f64 },
|
||||
hit_rate: if out.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
out.iter().filter(|r| r.ret > 0.0).count() as f64 / out.len() as f64
|
||||
},
|
||||
avg_lag_days: if trades.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
@@ -109,8 +113,15 @@ mod tests {
|
||||
];
|
||||
let (p, rs) = estimate(
|
||||
&trades,
|
||||
|tk, _| match tk { "UP" | "DOWN" => Some(100.0), _ => None },
|
||||
|tk| match tk { "UP" => Some(110.0), "DOWN" => Some(80.0), _ => None },
|
||||
|tk, _| match tk {
|
||||
"UP" | "DOWN" => Some(100.0),
|
||||
_ => None,
|
||||
},
|
||||
|tk| match tk {
|
||||
"UP" => Some(110.0),
|
||||
"DOWN" => Some(80.0),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
assert_eq!(p.scored, 2);
|
||||
assert_eq!(p.unscored, 1);
|
||||
|
||||
@@ -41,8 +41,7 @@ impl RefreshConfig {
|
||||
/// Sensible defaults derived from the database's last successful run.
|
||||
pub fn incremental(db: &Db) -> Self {
|
||||
let today = Utc::now().date_naive();
|
||||
let last: Option<NaiveDate> =
|
||||
db.meta("last_refresh_date").ok().flatten().and_then(|s| s.parse().ok());
|
||||
let last: Option<NaiveDate> = db.meta("last_refresh_date").ok().flatten().and_then(|s| s.parse().ok());
|
||||
let (house_years, senate_since) = match last {
|
||||
// Re-scan a week back: filings can appear in the index late.
|
||||
Some(d) => {
|
||||
@@ -177,7 +176,12 @@ async fn store(
|
||||
t
|
||||
};
|
||||
let message = if parsed.trades.is_empty() {
|
||||
format!("{} filed a new PTR ({}) on {}", politician.name, if rec.paper { "paper" } else { "scanned" }, rec.filed_date)
|
||||
format!(
|
||||
"{} filed a new PTR ({}) on {}",
|
||||
politician.name,
|
||||
if rec.paper { "paper" } else { "scanned" },
|
||||
rec.filed_date
|
||||
)
|
||||
} else {
|
||||
format!("{} filed {} trade(s): {}", politician.name, parsed.trades.len(), tickers.join(", "))
|
||||
};
|
||||
@@ -207,7 +211,7 @@ async fn refresh_house<S: Source>(
|
||||
todo.extend(entries.into_iter().filter(|e| e.is_ptr() && !known.contains(&format!("house:{}", e.doc_id))));
|
||||
}
|
||||
// Newest first so a capped run still gets the freshest filings.
|
||||
todo.sort_by(|a, b| b.filing_date.cmp(&a.filing_date));
|
||||
todo.sort_by_key(|e| std::cmp::Reverse(e.filing_date));
|
||||
todo.truncate(cfg.max_new_per_chamber);
|
||||
debug!("house: {} new PTRs to fetch", todo.len());
|
||||
|
||||
@@ -303,7 +307,7 @@ async fn refresh_senate<S: Source>(
|
||||
.into_iter()
|
||||
.filter(|r| !known.contains(&format!("senate:{}", r.id)))
|
||||
.collect();
|
||||
todo.sort_by(|a, b| b.date_received.cmp(&a.date_received));
|
||||
todo.sort_by_key(|r| std::cmp::Reverse(r.date_received));
|
||||
todo.truncate(cfg.max_new_per_chamber);
|
||||
debug!("senate: {} new PTRs to fetch", todo.len());
|
||||
|
||||
|
||||
@@ -38,17 +38,16 @@ pub struct Roster {
|
||||
pub fn normalize_name(s: &str) -> Vec<String> {
|
||||
s.split(|c: char| c.is_whitespace() || c == ',')
|
||||
.map(|w| w.chars().filter(|c| c.is_alphanumeric() || *c == '-').collect::<String>().to_lowercase())
|
||||
.filter(|w| !w.is_empty() && !matches!(w.as_str(), "jr" | "sr" | "ii" | "iii" | "iv" | "hon" | "the" | "honorable"))
|
||||
.filter(|w| {
|
||||
!w.is_empty() && !matches!(w.as_str(), "jr" | "sr" | "ii" | "iii" | "iv" | "hon" | "the" | "honorable")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Roster {
|
||||
pub fn new(house: Vec<RosterMember>, senate: Vec<RosterMember>) -> Self {
|
||||
let by_district = house
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, m)| m.state_district.clone().map(|d| (d, i)))
|
||||
.collect();
|
||||
let by_district =
|
||||
house.iter().enumerate().filter_map(|(i, m)| m.state_district.clone().map(|d| (d, i))).collect();
|
||||
Self { house, senate, by_district }
|
||||
}
|
||||
|
||||
|
||||
@@ -183,11 +183,8 @@ pub fn parse_ptr_html(html: &str) -> Result<SenatePtr> {
|
||||
let t = t.trim_start_matches("The Honorable").trim();
|
||||
t.split(" (").next().unwrap_or(t).trim().to_string()
|
||||
});
|
||||
let filed_date = doc
|
||||
.select(&sel("p.muted strong"))
|
||||
.map(text_of)
|
||||
.find(|t| t.contains("Filed"))
|
||||
.and_then(|t| parse_us_date(&t));
|
||||
let filed_date =
|
||||
doc.select(&sel("p.muted strong")).map(text_of).find(|t| t.contains("Filed")).and_then(|t| parse_us_date(&t));
|
||||
|
||||
let table = doc
|
||||
.select(&sel("table.table"))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Parser tests against real filings captured from the official sources.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use tr_congress::Chamber;
|
||||
use tr_congress::house::{self, PdfError};
|
||||
use tr_congress::model::{AmountRange, Party, TxType};
|
||||
use tr_congress::roster::{self, Roster};
|
||||
use tr_congress::senate::{self, ReportKind};
|
||||
use tr_congress::Chamber;
|
||||
|
||||
const FIX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
|
||||
|
||||
|
||||
@@ -64,11 +64,7 @@ pub fn rsi(closes: &[f64], n: usize) -> Vec<f64> {
|
||||
let al = rma(&losses, n);
|
||||
for i in (n - 1)..gains.len() {
|
||||
let (g, l) = (ag[i], al[i]);
|
||||
out[i + 1] = if l == 0.0 {
|
||||
if g == 0.0 { 50.0 } else { 100.0 }
|
||||
} else {
|
||||
100.0 - 100.0 / (1.0 + g / l)
|
||||
};
|
||||
out[i + 1] = if l == 0.0 { if g == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + g / l) };
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -158,10 +154,7 @@ mod tests {
|
||||
fn assert_series(got: &[f64], want: &[f64]) {
|
||||
assert_eq!(got.len(), want.len());
|
||||
for (i, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
assert!(
|
||||
(g.is_nan() && w.is_nan()) || close_to(*g, *w),
|
||||
"index {i}: got {g}, want {w}"
|
||||
);
|
||||
assert!((g.is_nan() && w.is_nan()) || close_to(*g, *w), "index {i}: got {g}, want {w}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,8 +179,8 @@ mod tests {
|
||||
fn rsi_reference_values() {
|
||||
// Wilder's original 14-period example (New Concepts in Technical Trading Systems).
|
||||
let closes = [
|
||||
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28,
|
||||
46.28, 46.00, 46.03, 46.41, 46.22, 45.64,
|
||||
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28,
|
||||
46.00, 46.03, 46.41, 46.22, 45.64,
|
||||
];
|
||||
let r = rsi(&closes, 14);
|
||||
assert!(r[13].is_nan());
|
||||
|
||||
@@ -168,9 +168,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resample_minutes_to_hour() {
|
||||
let bars: Vec<Bar> = (0..120)
|
||||
.map(|i| Bar::new(i * 60, i as f64, i as f64 + 1.0, i as f64 - 1.0, i as f64 + 0.5, 1.0))
|
||||
.collect();
|
||||
let bars: Vec<Bar> =
|
||||
(0..120).map(|i| Bar::new(i * 60, i as f64, i as f64 + 1.0, i as f64 - 1.0, i as f64 + 0.5, 1.0)).collect();
|
||||
let h = Timeframe::H1.resample(&bars);
|
||||
assert_eq!(h.len(), 2);
|
||||
assert_eq!(h[0], Bar::new(0, 0.0, 60.0, -1.0, 59.5, 60.0));
|
||||
|
||||
@@ -61,12 +61,8 @@ struct RawBar {
|
||||
/// Parse one page of `/v2/stocks/{symbol}/bars`.
|
||||
pub fn parse_bars_page(json: &str) -> Result<(Vec<Bar>, Option<String>)> {
|
||||
let page: BarsPage = serde_json::from_str(json).context("parsing Alpaca bars")?;
|
||||
let bars = page
|
||||
.bars
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|b| Bar::new(b.t.timestamp(), b.o, b.h, b.l, b.c, b.v))
|
||||
.collect();
|
||||
let bars =
|
||||
page.bars.unwrap_or_default().into_iter().map(|b| Bar::new(b.t.timestamp(), b.o, b.h, b.l, b.c, b.v)).collect();
|
||||
Ok((bars, page.next_page_token.filter(|t| !t.is_empty())))
|
||||
}
|
||||
|
||||
|
||||
@@ -180,10 +180,15 @@ impl MarketData for YahooProvider {
|
||||
for s in symbols {
|
||||
let c = self.chart(s, "1d", now - 10 * 86_400, now + 60).await?;
|
||||
let Some(last) = c.bars.last() else { continue };
|
||||
let prev = c.prev_close.unwrap_or_else(|| {
|
||||
if c.bars.len() > 1 { c.bars[c.bars.len() - 2].close } else { last.open }
|
||||
let prev = c
|
||||
.prev_close
|
||||
.unwrap_or_else(|| if c.bars.len() > 1 { c.bars[c.bars.len() - 2].close } else { last.open });
|
||||
out.push(Quote {
|
||||
symbol: s.clone(),
|
||||
last: c.last_price.unwrap_or(last.close),
|
||||
prev_close: prev,
|
||||
ts: last.ts,
|
||||
});
|
||||
out.push(Quote { symbol: s.clone(), last: c.last_price.unwrap_or(last.close), prev_close: prev, ts: last.ts });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 120
|
||||
use_small_heuristics = "Max"
|
||||
Reference in new issue
Block a user