1250 lines
50 KiB
Rust
1250 lines
50 KiB
Rust
use chrono::Utc;
|
|
use egui::{Color32, Context, RichText, TextEdit};
|
|
use std::sync::mpsc;
|
|
|
|
use crate::analysis::{self, AnalysisResult};
|
|
use crate::config::Config;
|
|
use crate::data::cache::CacheStore;
|
|
use crate::data::{self, AnalysisInput};
|
|
use crate::ui::{analysis_panel, data_panel};
|
|
use crate::watchlist::{self, Watchlist, WatchlistEntry};
|
|
|
|
// ─── Background task message types ────────────────────────────────────────
|
|
|
|
enum BgCmd {
|
|
UpdateWatchlist(Vec<WatchlistEntry>),
|
|
RunNow,
|
|
CheckUpdate,
|
|
Quit,
|
|
}
|
|
|
|
enum UpdateCheckResult {
|
|
Available(String, String), // tag, download_url
|
|
UpToDate,
|
|
}
|
|
|
|
enum BgCheckResult {
|
|
Entry {
|
|
symbol: String,
|
|
input: AnalysisInput,
|
|
result: AnalysisResult,
|
|
prev_signal: String,
|
|
prev_score: f64,
|
|
},
|
|
CycleDone,
|
|
}
|
|
|
|
// ─── App state ─────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum FetchState {
|
|
Idle,
|
|
Loading,
|
|
Done,
|
|
Error(String),
|
|
}
|
|
|
|
pub struct TickrApp {
|
|
// Analysis
|
|
ticker_input: String,
|
|
current_analysis: Option<(AnalysisInput, AnalysisResult)>,
|
|
fetch_state: FetchState,
|
|
cache: CacheStore,
|
|
config: Config,
|
|
status_message: String,
|
|
save_status: Option<String>,
|
|
|
|
// Async fetch channel
|
|
fetch_tx: mpsc::Sender<Result<AnalysisInput, String>>,
|
|
fetch_rx: mpsc::Receiver<Result<AnalysisInput, String>>,
|
|
tokio_rt: tokio::runtime::Runtime,
|
|
|
|
// UI state
|
|
show_settings: bool,
|
|
show_watchlist_panel: bool,
|
|
|
|
// Watchlist
|
|
watchlist: Watchlist,
|
|
bg_cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<BgCmd>>,
|
|
bg_result_rx: mpsc::Receiver<BgCheckResult>,
|
|
snapshot_batch: Vec<(String, String, AnalysisResult)>, // (ticker, company, result) — accumulated per cycle
|
|
|
|
// Updater
|
|
update_available: Option<(String, String)>, // (tag, download_url)
|
|
update_in_progress: bool,
|
|
update_result_rx: mpsc::Receiver<UpdateCheckResult>,
|
|
|
|
// Tray — use our own channels fed by set_event_handler so events are
|
|
// delivered even when the window is hidden (the global receiver() only
|
|
// works reliably while the winit loop is actively processing messages).
|
|
#[allow(dead_code)]
|
|
tray_icon: Option<tray_icon::TrayIcon>,
|
|
tray_open_id: Option<tray_icon::menu::MenuId>,
|
|
tray_check_id: Option<tray_icon::menu::MenuId>,
|
|
tray_quit_id: Option<tray_icon::menu::MenuId>,
|
|
tray_ev_rx: mpsc::Receiver<tray_icon::TrayIconEvent>,
|
|
menu_ev_rx: mpsc::Receiver<tray_icon::menu::MenuEvent>,
|
|
window_visible: bool,
|
|
hide_to_tray: bool,
|
|
should_quit: bool,
|
|
}
|
|
|
|
impl TickrApp {
|
|
pub fn new(
|
|
cc: &eframe::CreationContext<'_>,
|
|
config: Config,
|
|
icon_rgba: Vec<u8>,
|
|
icon_size: u32,
|
|
start_visible: bool,
|
|
) -> Self {
|
|
let ctx = &cc.egui_ctx;
|
|
let mut visuals = egui::Visuals::dark();
|
|
visuals.override_text_color = Some(Color32::from_rgb(220, 220, 220));
|
|
ctx.set_visuals(visuals);
|
|
|
|
let (fetch_tx, fetch_rx) = mpsc::channel();
|
|
let (bg_result_tx, bg_result_rx) = mpsc::channel::<BgCheckResult>();
|
|
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
|
|
|
|
let watchlist = watchlist::load();
|
|
|
|
// Set up tray
|
|
let (tray_icon, tray_open_id, tray_check_id, tray_quit_id) =
|
|
setup_tray(icon_rgba.clone(), icon_size, watchlist.tickers.len());
|
|
|
|
// Route tray events through our own sync channels + wake egui immediately.
|
|
// SyncSender is Send + Sync, satisfying the 'static closure requirement.
|
|
// (The global receiver() channel is NOT used — set_event_handler replaces it.)
|
|
let (tray_ev_tx, tray_ev_rx) = mpsc::sync_channel::<tray_icon::TrayIconEvent>(64);
|
|
let (menu_ev_tx, menu_ev_rx) = mpsc::sync_channel::<tray_icon::menu::MenuEvent>(64);
|
|
|
|
let ctx_tray = cc.egui_ctx.clone();
|
|
tray_icon::TrayIconEvent::set_event_handler(Some(move |e| {
|
|
let _ = tray_ev_tx.send(e);
|
|
ctx_tray.request_repaint();
|
|
}));
|
|
|
|
let ctx_menu = cc.egui_ctx.clone();
|
|
tray_icon::menu::MenuEvent::set_event_handler(Some(move |e| {
|
|
let _ = menu_ev_tx.send(e);
|
|
ctx_menu.request_repaint();
|
|
}));
|
|
|
|
// Spawn background check task
|
|
let (bg_cmd_tx, bg_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BgCmd>();
|
|
let (update_result_tx, update_result_rx) = mpsc::channel::<UpdateCheckResult>();
|
|
let initial_entries = watchlist.tickers.clone();
|
|
let bg_config = config.clone();
|
|
rt.spawn(background_task(bg_cmd_rx, bg_result_tx, update_result_tx, initial_entries, bg_config));
|
|
|
|
Self {
|
|
ticker_input: String::new(),
|
|
current_analysis: None,
|
|
fetch_state: FetchState::Idle,
|
|
cache: CacheStore::new(config.cache.ttl_minutes),
|
|
status_message: "Ready".to_string(),
|
|
save_status: None,
|
|
fetch_tx,
|
|
fetch_rx,
|
|
tokio_rt: rt,
|
|
show_settings: false,
|
|
show_watchlist_panel: false,
|
|
watchlist,
|
|
bg_cmd_tx: Some(bg_cmd_tx),
|
|
bg_result_rx,
|
|
snapshot_batch: Vec::new(),
|
|
update_available: None,
|
|
update_in_progress: false,
|
|
update_result_rx,
|
|
tray_icon,
|
|
tray_open_id,
|
|
tray_check_id,
|
|
tray_quit_id,
|
|
tray_ev_rx,
|
|
menu_ev_rx,
|
|
window_visible: start_visible,
|
|
hide_to_tray: false,
|
|
should_quit: false,
|
|
config,
|
|
}
|
|
}
|
|
|
|
// ─── Fetch ────────────────────────────────────────────────────────────
|
|
|
|
fn trigger_fetch(&mut self, symbol: &str, bypass_cache: bool) {
|
|
let sym = symbol.trim().to_uppercase();
|
|
if sym.is_empty() {
|
|
return;
|
|
}
|
|
|
|
if !bypass_cache {
|
|
if let Some(cached) = self.cache.get(&sym) {
|
|
let age = self.cache.age_minutes(&sym).unwrap_or(0);
|
|
let result = analysis::analyze(cached, &self.config);
|
|
self.current_analysis = Some((cached.clone(), result));
|
|
self.fetch_state = FetchState::Done;
|
|
self.status_message = format!("Using cached data (fetched {age} min ago)");
|
|
return;
|
|
}
|
|
}
|
|
|
|
self.fetch_state = FetchState::Loading;
|
|
self.status_message = format!("Fetching data for {sym}…");
|
|
let tx = self.fetch_tx.clone();
|
|
self.tokio_rt.spawn(async move {
|
|
let result = data::fetch::fetch_ticker(&sym).await;
|
|
let _ = tx.send(result.map_err(|e| e.to_string()));
|
|
});
|
|
}
|
|
|
|
fn poll_fetch_result(&mut self) {
|
|
if let Ok(result) = self.fetch_rx.try_recv() {
|
|
match result {
|
|
Ok(input) => {
|
|
let sym = input.symbol().to_string();
|
|
self.cache.insert(input.clone());
|
|
let analysis = analysis::analyze(&input, &self.config);
|
|
self.current_analysis = Some((input, analysis));
|
|
self.fetch_state = FetchState::Done;
|
|
self.status_message = format!("Analysis complete for {sym}");
|
|
}
|
|
Err(e) => {
|
|
let sym = self.ticker_input.trim().to_uppercase();
|
|
if let Some(stale) = self.cache.get_stale(&sym) {
|
|
let result = analysis::analyze(stale, &self.config);
|
|
self.current_analysis = Some((stale.clone(), result));
|
|
self.fetch_state =
|
|
FetchState::Error(format!("Network error — showing stale data"));
|
|
self.status_message = "Network error — using stale cache".to_string();
|
|
} else {
|
|
self.fetch_state = FetchState::Error(e.clone());
|
|
self.status_message = format!("Error: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Background check ─────────────────────────────────────────────────
|
|
|
|
fn poll_bg_results(&mut self) {
|
|
while let Ok(res) = self.bg_result_rx.try_recv() {
|
|
match res {
|
|
BgCheckResult::CycleDone => {
|
|
self.send_watchlist_refresh_batch();
|
|
}
|
|
BgCheckResult::Entry { symbol, input, result, prev_signal, prev_score } => {
|
|
let new_signal = result.decision.label().to_string();
|
|
let new_score = result.final_score;
|
|
let signal_changed = new_signal != prev_signal;
|
|
let score_drift = (new_score - prev_score).abs();
|
|
|
|
// Update cache with fresh data
|
|
self.cache.insert(input.clone());
|
|
|
|
// Update watchlist entry
|
|
self.watchlist.update_entry(&symbol, &new_signal, new_score);
|
|
let _ = watchlist::save(&self.watchlist);
|
|
|
|
// Resolve company name (needed for both Discord and snapshot)
|
|
let company = match &input {
|
|
AnalysisInput::Equity(d) => {
|
|
d.company_name.clone().unwrap_or_else(|| symbol.clone())
|
|
}
|
|
AnalysisInput::Etf(d) => {
|
|
d.fund_name.clone().unwrap_or_else(|| symbol.clone())
|
|
}
|
|
};
|
|
|
|
// Accumulate for end-of-cycle snapshot (fires unconditionally when CycleDone arrives)
|
|
self.snapshot_batch.push((symbol.clone(), company.clone(), result.clone()));
|
|
|
|
// Existing per-ticker notifications (gated by user flags)
|
|
let url = self.config.discord.webhook_url.clone();
|
|
if !url.is_empty() {
|
|
if signal_changed && self.config.discord.notify_on_change {
|
|
let url2 = url.clone();
|
|
let sym = symbol.clone();
|
|
let prev = prev_signal.clone();
|
|
let new_s = new_signal.clone();
|
|
let analysis = result.clone();
|
|
let co = company.clone();
|
|
self.tokio_rt.spawn(async move {
|
|
if let Err(e) =
|
|
crate::discord::send_signal_embed(&url2, &sym, &co, &prev, &new_s, &analysis)
|
|
.await
|
|
{
|
|
eprintln!("Discord error: {e}");
|
|
}
|
|
});
|
|
}
|
|
|
|
if !signal_changed && score_drift >= 0.2 && self.config.discord.notify_on_drift {
|
|
let sym = symbol.clone();
|
|
let old_s = prev_score;
|
|
let analysis = result.clone();
|
|
let sig_copy = new_signal.clone();
|
|
self.tokio_rt.spawn(async move {
|
|
if let Err(e) = crate::discord::send_drift_embed(
|
|
&url, &sym, old_s, new_score, &sig_copy, &analysis,
|
|
)
|
|
.await
|
|
{
|
|
eprintln!("Discord error: {e}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// If currently displayed ticker was refreshed, update the panel
|
|
if self.ticker_input.trim().to_uppercase() == symbol.to_uppercase() {
|
|
let a = analysis::analyze(&input, &self.config);
|
|
self.current_analysis = Some((input, a));
|
|
}
|
|
|
|
self.status_message = format!("Watchlist: {} updated ({})", symbol, new_signal);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn send_watchlist_refresh_batch(&mut self) {
|
|
if self.config.discord.webhook_url.is_empty() || self.snapshot_batch.is_empty() {
|
|
self.snapshot_batch.clear();
|
|
return;
|
|
}
|
|
let url = self.config.discord.webhook_url.clone();
|
|
let batch = std::mem::take(&mut self.snapshot_batch);
|
|
|
|
self.tokio_rt.spawn(async move {
|
|
// Split into groups of ≤10 (Discord's embed-per-message limit)
|
|
let chunks: Vec<Vec<(String, String, AnalysisResult)>> =
|
|
batch.chunks(10).map(|c| c.to_vec()).collect();
|
|
for (i, chunk) in chunks.into_iter().enumerate() {
|
|
if i > 0 {
|
|
// Rate-limit protection: 30 s between groups
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
|
|
}
|
|
if let Err(e) = crate::discord::send_watchlist_snapshot(&url, &chunk).await {
|
|
eprintln!("Discord snapshot error: {e}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn send_watchlist_to_bg(&self) {
|
|
if let Some(ref tx) = self.bg_cmd_tx {
|
|
let _ = tx.send(BgCmd::UpdateWatchlist(self.watchlist.tickers.clone()));
|
|
}
|
|
}
|
|
|
|
fn trigger_bg_check_now(&self) {
|
|
if let Some(ref tx) = self.bg_cmd_tx {
|
|
let _ = tx.send(BgCmd::RunNow);
|
|
}
|
|
}
|
|
|
|
fn trigger_update_check(&self) {
|
|
if let Some(ref tx) = self.bg_cmd_tx {
|
|
let _ = tx.send(BgCmd::CheckUpdate);
|
|
}
|
|
}
|
|
|
|
fn poll_update_results(&mut self) {
|
|
while let Ok(result) = self.update_result_rx.try_recv() {
|
|
match result {
|
|
UpdateCheckResult::Available(tag, url) => {
|
|
if self.config.updater.auto_update {
|
|
self.update_in_progress = true;
|
|
let url2 = url.clone();
|
|
self.tokio_rt.spawn(async move {
|
|
if let Err(e) = crate::updater::download_and_swap(&url2).await {
|
|
eprintln!("Auto-update failed: {e}");
|
|
}
|
|
});
|
|
} else {
|
|
self.update_available = Some((tag.clone(), url));
|
|
self.status_message = format!("Update available: {tag}");
|
|
}
|
|
}
|
|
UpdateCheckResult::UpToDate => {
|
|
if self.update_available.is_none() {
|
|
self.status_message = format!("Tickr v{} — Up to date", crate::VERSION);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_tray_tooltip(&self) {
|
|
if let Some(ref tray) = self.tray_icon {
|
|
let n = self.watchlist.tickers.len();
|
|
let tip = format!("Tickr — {n} ticker{} watched", if n == 1 { "" } else { "s" });
|
|
let _ = tray.set_tooltip(Some(tip));
|
|
}
|
|
}
|
|
|
|
// ─── Watchlist helpers ────────────────────────────────────────────────
|
|
|
|
fn add_to_watchlist(&mut self) {
|
|
let Some((input, result)) = &self.current_analysis else {
|
|
return;
|
|
};
|
|
let sym = input.symbol().to_uppercase();
|
|
if self.watchlist.contains(&sym) {
|
|
return;
|
|
}
|
|
|
|
let company = match input {
|
|
crate::data::AnalysisInput::Equity(d) => {
|
|
d.company_name.clone().unwrap_or_else(|| sym.clone())
|
|
}
|
|
crate::data::AnalysisInput::Etf(d) => {
|
|
d.fund_name.clone().unwrap_or_else(|| sym.clone())
|
|
}
|
|
};
|
|
|
|
let entry = WatchlistEntry {
|
|
symbol: sym.clone(),
|
|
last_signal: result.decision.label().to_string(),
|
|
last_score: result.final_score,
|
|
last_checked: Utc::now(),
|
|
};
|
|
self.watchlist.add(entry);
|
|
let _ = watchlist::save(&self.watchlist);
|
|
self.send_watchlist_to_bg();
|
|
self.update_tray_tooltip();
|
|
|
|
if !self.config.discord.webhook_url.is_empty() {
|
|
let url = self.config.discord.webhook_url.clone();
|
|
let analysis = result.clone();
|
|
self.tokio_rt.spawn(async move {
|
|
if let Err(e) =
|
|
crate::discord::send_added_to_watchlist_embed(&url, &sym, &company, &analysis)
|
|
.await
|
|
{
|
|
eprintln!("Discord error: {e}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn remove_from_watchlist(&mut self, symbol: &str) {
|
|
self.watchlist.remove(symbol);
|
|
let _ = watchlist::save(&self.watchlist);
|
|
self.send_watchlist_to_bg();
|
|
self.update_tray_tooltip();
|
|
}
|
|
|
|
fn current_ticker_watched(&self) -> bool {
|
|
let sym = self.ticker_input.trim().to_uppercase();
|
|
!sym.is_empty() && self.watchlist.contains(&sym)
|
|
}
|
|
|
|
fn can_watch(&self) -> bool {
|
|
let sym = self.ticker_input.trim().to_uppercase();
|
|
self.current_analysis
|
|
.as_ref()
|
|
.map_or(false, |(input, _)| input.symbol().to_uppercase() == sym)
|
|
}
|
|
|
|
// ─── Window / tray helpers ────────────────────────────────────────────
|
|
|
|
fn show_window(&mut self, ctx: &Context) {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
|
self.window_visible = true;
|
|
}
|
|
|
|
// ─── Save report ──────────────────────────────────────────────────────
|
|
|
|
fn save_report(&mut self) {
|
|
let Some((input, result)) = &self.current_analysis else {
|
|
return;
|
|
};
|
|
let sym = input.symbol();
|
|
let date = Utc::now().format("%Y-%m-%d");
|
|
let ext = &self.config.output.save_format;
|
|
let filename = format!("{sym}_{date}.{ext}");
|
|
|
|
let save_dir = if self.config.output.save_directory.is_empty() {
|
|
dirs::desktop_dir().unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
} else {
|
|
std::path::PathBuf::from(&self.config.output.save_directory)
|
|
};
|
|
|
|
let path = save_dir.join(&filename);
|
|
match std::fs::write(&path, build_report_text(input, result)) {
|
|
Ok(()) => {
|
|
self.save_status = Some(format!("Saved: {}", path.display()));
|
|
let _ = std::process::Command::new("explorer").arg(save_dir).spawn();
|
|
}
|
|
Err(e) => {
|
|
self.save_status = Some(format!("Save failed: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── UI renderers ─────────────────────────────────────────────────────
|
|
|
|
fn render_toolbar(&mut self, ui: &mut egui::Ui) {
|
|
ui.horizontal(|ui| {
|
|
ui.label(RichText::new("Ticker:").strong());
|
|
let resp = ui.add(
|
|
TextEdit::singleline(&mut self.ticker_input)
|
|
.desired_width(120.0)
|
|
.hint_text("e.g. AAPL"),
|
|
);
|
|
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
|
let sym = self.ticker_input.clone();
|
|
self.trigger_fetch(&sym, false);
|
|
}
|
|
|
|
let loading = self.fetch_state == FetchState::Loading;
|
|
|
|
if ui.add_enabled(!loading, egui::Button::new("Analyze")).clicked() {
|
|
let sym = self.ticker_input.clone();
|
|
self.trigger_fetch(&sym, false);
|
|
}
|
|
|
|
// Watch button
|
|
let watched = self.current_ticker_watched();
|
|
let can_watch = self.can_watch();
|
|
let watch_label = if watched { "✓ Watching" } else { "+ Watch" };
|
|
let watch_btn = ui.add_enabled(can_watch, egui::Button::new(watch_label));
|
|
if watch_btn.clicked() {
|
|
if watched {
|
|
let sym = self.ticker_input.trim().to_uppercase();
|
|
self.remove_from_watchlist(&sym);
|
|
} else {
|
|
self.add_to_watchlist();
|
|
}
|
|
}
|
|
if watch_btn.hovered() && watched {
|
|
egui::show_tooltip_text(ui.ctx(), egui::Id::new("watch_tip"), "Click to remove from watchlist");
|
|
}
|
|
|
|
if ui.add_enabled(!loading, egui::Button::new("⟳ Refresh")).clicked() {
|
|
let sym = self.ticker_input.clone();
|
|
self.trigger_fetch(&sym, true);
|
|
}
|
|
|
|
if loading {
|
|
ui.spinner();
|
|
}
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.button("⚙ Settings").clicked() {
|
|
self.show_settings = !self.show_settings;
|
|
}
|
|
let wl_label = if self.show_watchlist_panel {
|
|
"▼ Watchlist"
|
|
} else {
|
|
"▲ Watchlist"
|
|
};
|
|
if ui.button(wl_label).clicked() {
|
|
self.show_watchlist_panel = !self.show_watchlist_panel;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn render_status_bar(&self, ui: &mut egui::Ui) {
|
|
ui.horizontal(|ui| {
|
|
let color = match &self.fetch_state {
|
|
FetchState::Error(_) => Color32::from_rgb(231, 76, 60),
|
|
FetchState::Loading => Color32::from_rgb(241, 196, 15),
|
|
FetchState::Done => Color32::from_rgb(39, 174, 96),
|
|
FetchState::Idle => Color32::GRAY,
|
|
};
|
|
ui.label(RichText::new(format!("Status: {}", self.status_message)).color(color));
|
|
|
|
if let Some(ref msg) = self.save_status {
|
|
ui.separator();
|
|
ui.label(RichText::new(msg).color(Color32::from_rgb(100, 200, 255)));
|
|
}
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
ui.label(RichText::new("v0.3.0").color(Color32::GRAY));
|
|
});
|
|
});
|
|
}
|
|
|
|
fn render_watchlist_panel(&mut self, ui: &mut egui::Ui) {
|
|
ui.horizontal(|ui| {
|
|
ui.label(RichText::new("WATCHLIST").strong());
|
|
ui.separator();
|
|
if ui.button("Run Check Now").clicked() {
|
|
self.trigger_bg_check_now();
|
|
self.status_message = "Watchlist check triggered…".to_string();
|
|
}
|
|
});
|
|
|
|
if self.watchlist.tickers.is_empty() {
|
|
ui.label(
|
|
RichText::new("No tickers watched. Analyze a ticker and click [+ Watch] to add it.")
|
|
.color(Color32::GRAY)
|
|
.italics(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
let now = Utc::now();
|
|
let to_remove: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
|
|
let to_load: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
|
|
|
|
egui::Grid::new("watchlist_grid")
|
|
.striped(true)
|
|
.min_col_width(80.0)
|
|
.show(ui, |ui| {
|
|
// Header
|
|
ui.label(RichText::new("Symbol").strong());
|
|
ui.label(RichText::new("Signal").strong());
|
|
ui.label(RichText::new("Score").strong());
|
|
ui.label(RichText::new("Last Checked").strong());
|
|
ui.label(""); // remove button column
|
|
ui.end_row();
|
|
|
|
for entry in &self.watchlist.tickers {
|
|
let sym = entry.symbol.clone();
|
|
let btn = ui.selectable_label(false, RichText::new(&sym).strong());
|
|
if btn.clicked() {
|
|
*to_load.borrow_mut() = Some(sym.clone());
|
|
}
|
|
btn.on_hover_text("Click to load analysis");
|
|
|
|
// Signal with color
|
|
let sig_color = match entry.last_signal.as_str() {
|
|
"BUY" => Color32::from_rgb(39, 174, 96),
|
|
"SELL" => Color32::from_rgb(231, 76, 60),
|
|
_ => Color32::from_rgb(241, 196, 15),
|
|
};
|
|
ui.label(RichText::new(&entry.last_signal).color(sig_color).strong());
|
|
|
|
// Score
|
|
ui.label(format!("{:.2}", entry.last_score));
|
|
|
|
// Age
|
|
let mins = (now - entry.last_checked).num_minutes();
|
|
let age = if mins < 2 {
|
|
"just now".to_string()
|
|
} else if mins < 60 {
|
|
format!("{mins} min ago")
|
|
} else {
|
|
format!("{:.0} hr ago", mins as f64 / 60.0)
|
|
};
|
|
ui.label(age);
|
|
|
|
// Remove button
|
|
if ui
|
|
.small_button(RichText::new("✕").color(Color32::from_rgb(180, 60, 60)))
|
|
.clicked()
|
|
{
|
|
*to_remove.borrow_mut() = Some(sym.clone());
|
|
}
|
|
|
|
ui.end_row();
|
|
}
|
|
});
|
|
|
|
if let Some(sym) = to_load.into_inner() {
|
|
self.ticker_input = sym.clone();
|
|
self.trigger_fetch(&sym, false);
|
|
}
|
|
if let Some(sym) = to_remove.into_inner() {
|
|
self.remove_from_watchlist(&sym);
|
|
}
|
|
}
|
|
|
|
fn render_settings_window(&mut self, ctx: &Context) {
|
|
let mut open = self.show_settings;
|
|
egui::Window::new("Settings")
|
|
.open(&mut open)
|
|
.resizable(true)
|
|
.min_width(380.0)
|
|
.show(ctx, |ui| {
|
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
// ── User ──────────────────────────────────────────
|
|
ui.heading("User");
|
|
ui.horizontal(|ui| {
|
|
ui.label("Name:");
|
|
ui.text_edit_singleline(&mut self.config.user.name);
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Preferences ───────────────────────────────────
|
|
ui.heading("Analysis Preferences");
|
|
ui.horizontal(|ui| {
|
|
ui.label("Risk tolerance:");
|
|
egui::ComboBox::from_id_source("risk_tol")
|
|
.selected_text(&self.config.preferences.default_risk_tolerance)
|
|
.show_ui(ui, |ui| {
|
|
ui.selectable_value(
|
|
&mut self.config.preferences.default_risk_tolerance,
|
|
"low".into(),
|
|
"Low",
|
|
);
|
|
ui.selectable_value(
|
|
&mut self.config.preferences.default_risk_tolerance,
|
|
"medium".into(),
|
|
"Medium",
|
|
);
|
|
ui.selectable_value(
|
|
&mut self.config.preferences.default_risk_tolerance,
|
|
"high".into(),
|
|
"High",
|
|
);
|
|
});
|
|
});
|
|
ui.checkbox(
|
|
&mut self.config.preferences.dividend_focus,
|
|
"Prioritize dividend analysis",
|
|
);
|
|
ui.checkbox(
|
|
&mut self.config.preferences.avoid_borderline,
|
|
"Widen PASS zone (avoid borderline calls)",
|
|
);
|
|
ui.checkbox(
|
|
&mut self.config.preferences.start_minimized,
|
|
"Start minimized to system tray",
|
|
);
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Cache ─────────────────────────────────────────
|
|
ui.heading("Cache");
|
|
ui.horizontal(|ui| {
|
|
ui.label("Cache TTL (minutes):");
|
|
ui.add(egui::DragValue::new(&mut self.config.cache.ttl_minutes)
|
|
.speed(1)
|
|
.clamp_range(1..=120));
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Output ────────────────────────────────────────
|
|
ui.heading("Report Output");
|
|
ui.checkbox(&mut self.config.output.auto_save, "Auto-save report after analysis");
|
|
ui.horizontal(|ui| {
|
|
ui.label("Format:");
|
|
egui::ComboBox::from_id_source("save_fmt")
|
|
.selected_text(&self.config.output.save_format)
|
|
.show_ui(ui, |ui| {
|
|
ui.selectable_value(
|
|
&mut self.config.output.save_format,
|
|
"txt".into(),
|
|
"Plain text (.txt)",
|
|
);
|
|
ui.selectable_value(
|
|
&mut self.config.output.save_format,
|
|
"md".into(),
|
|
"Markdown (.md)",
|
|
);
|
|
});
|
|
});
|
|
ui.horizontal(|ui| {
|
|
ui.label("Save directory:");
|
|
ui.text_edit_singleline(&mut self.config.output.save_directory)
|
|
.on_hover_text("Leave blank to use Desktop");
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Watchlist ─────────────────────────────────────
|
|
ui.heading("Watchlist");
|
|
ui.horizontal(|ui| {
|
|
ui.label("Check interval (minutes):");
|
|
ui.add(egui::DragValue::new(
|
|
&mut self.config.watchlist_check.interval_minutes,
|
|
)
|
|
.speed(1)
|
|
.clamp_range(5..=1440));
|
|
});
|
|
ui.label(
|
|
RichText::new("⚠ Interval change takes effect on next app restart.")
|
|
.color(Color32::GRAY)
|
|
.small(),
|
|
);
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Discord ───────────────────────────────────────
|
|
ui.heading("Discord Notifications");
|
|
ui.horizontal(|ui| {
|
|
ui.label("Webhook URL:");
|
|
ui.add(
|
|
TextEdit::singleline(&mut self.config.discord.webhook_url)
|
|
.desired_width(240.0)
|
|
.hint_text("https://discord.com/api/webhooks/…"),
|
|
);
|
|
});
|
|
ui.checkbox(
|
|
&mut self.config.discord.notify_on_change,
|
|
"Notify on signal change (BUY/SELL/PASS flip)",
|
|
);
|
|
ui.checkbox(
|
|
&mut self.config.discord.notify_on_drift,
|
|
"Notify on score drift ≥ 0.2 (without signal flip)",
|
|
);
|
|
|
|
if !self.config.discord.webhook_url.is_empty() {
|
|
if ui.button("🔔 Send test notification").clicked() {
|
|
let url = self.config.discord.webhook_url.clone();
|
|
self.tokio_rt.spawn(async move {
|
|
let _ = crate::discord::send_signal_embed(
|
|
&url,
|
|
"TEST",
|
|
"Tickr Test",
|
|
"PASS",
|
|
"BUY",
|
|
// We need a dummy AnalysisResult — just log the error
|
|
&make_dummy_result(),
|
|
)
|
|
.await;
|
|
});
|
|
self.status_message = "Test notification sent.".to_string();
|
|
}
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
|
|
// ── Updates ───────────────────────────────────────
|
|
ui.heading("Updates");
|
|
let auto_changed = ui.checkbox(
|
|
&mut self.config.updater.auto_update,
|
|
"Auto-update when new version is available",
|
|
).changed();
|
|
if auto_changed {
|
|
let _ = crate::config::save(&self.config);
|
|
}
|
|
|
|
ui.horizontal(|ui| {
|
|
let checking = self.update_in_progress;
|
|
if ui.add_enabled(!checking, egui::Button::new("🔍 Check Now")).clicked() {
|
|
self.trigger_update_check();
|
|
}
|
|
|
|
if self.update_in_progress {
|
|
ui.spinner();
|
|
ui.label(RichText::new("Downloading update…").color(Color32::from_rgb(241, 196, 15)));
|
|
} else if let Some((ref tag, ref url)) = self.update_available.clone() {
|
|
ui.label(
|
|
RichText::new(format!("Update available: {tag}"))
|
|
.color(Color32::from_rgb(39, 174, 96))
|
|
.strong(),
|
|
);
|
|
if ui.button("⬇ Apply Update").clicked() {
|
|
self.update_in_progress = true;
|
|
let url2 = url.clone();
|
|
self.tokio_rt.spawn(async move {
|
|
if let Err(e) = crate::updater::download_and_swap(&url2).await {
|
|
eprintln!("Update failed: {e}");
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
ui.label(
|
|
RichText::new(format!("Tickr v{} — Up to date", crate::VERSION))
|
|
.color(Color32::GRAY),
|
|
);
|
|
}
|
|
});
|
|
|
|
ui.add_space(12.0);
|
|
|
|
// ── Save button ───────────────────────────────────
|
|
if ui.button("💾 Save Settings").clicked() {
|
|
if let Err(e) = crate::config::save(&self.config) {
|
|
self.status_message = format!("Failed to save settings: {e}");
|
|
} else {
|
|
self.cache = CacheStore::new(self.config.cache.ttl_minutes);
|
|
self.status_message = "Settings saved.".to_string();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
if !open {
|
|
self.show_settings = false;
|
|
}
|
|
}
|
|
|
|
fn render_earnings_warning(&self, ui: &mut egui::Ui) {
|
|
if let Some((AnalysisInput::Equity(data), _)) = &self.current_analysis {
|
|
if let Some(next) = data.next_earnings_date {
|
|
let today = Utc::now().date_naive();
|
|
let days = (next - today).num_days();
|
|
if days >= 0 && days <= 7 {
|
|
let frame = egui::Frame::none()
|
|
.fill(Color32::from_rgb(80, 60, 0))
|
|
.inner_margin(6.0);
|
|
frame.show(ui, |ui| {
|
|
ui.label(
|
|
RichText::new(format!(
|
|
"⚠ Earnings in {days} day{} — higher uncertainty",
|
|
if days == 1 { "" } else { "s" }
|
|
))
|
|
.color(Color32::YELLOW),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn poll_tray_events(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
|
// Drain icon-level events (single/double click → show window)
|
|
while let Ok(event) = self.tray_ev_rx.try_recv() {
|
|
match event {
|
|
tray_icon::TrayIconEvent::Click { .. } => self.show_window(ctx),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Drain context-menu events
|
|
while let Ok(event) = self.menu_ev_rx.try_recv() {
|
|
if self.tray_open_id.as_ref() == Some(&event.id) {
|
|
self.show_window(ctx);
|
|
} else if self.tray_check_id.as_ref() == Some(&event.id) {
|
|
self.trigger_bg_check_now();
|
|
self.status_message = "Watchlist check triggered…".to_string();
|
|
} else if self.tray_quit_id.as_ref() == Some(&event.id) {
|
|
self.should_quit = true;
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── eframe::App impl ─────────────────────────────────────────────────────
|
|
|
|
impl eframe::App for TickrApp {
|
|
fn update(&mut self, ctx: &Context, frame: &mut eframe::Frame) {
|
|
// Handle window close button
|
|
if ctx.input(|i| i.viewport().close_requested()) {
|
|
if self.should_quit {
|
|
if let Some(ref tx) = self.bg_cmd_tx {
|
|
let _ = tx.send(BgCmd::Quit);
|
|
}
|
|
// Let it close (do not cancel)
|
|
} else {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
|
self.hide_to_tray = true;
|
|
}
|
|
}
|
|
|
|
self.poll_fetch_result();
|
|
self.poll_bg_results();
|
|
self.poll_update_results();
|
|
self.poll_tray_events(ctx, frame);
|
|
|
|
// Apply pending hide-to-tray
|
|
if self.hide_to_tray {
|
|
self.hide_to_tray = false;
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
|
self.window_visible = false;
|
|
}
|
|
|
|
// Keep event loop alive for tray responsiveness when window is hidden
|
|
if !self.window_visible {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(300));
|
|
return; // Don't render UI when hidden
|
|
}
|
|
|
|
if self.fetch_state == FetchState::Loading {
|
|
ctx.request_repaint();
|
|
}
|
|
|
|
if self.show_settings {
|
|
self.render_settings_window(ctx);
|
|
}
|
|
|
|
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
|
|
ui.add_space(4.0);
|
|
self.render_toolbar(ui);
|
|
ui.add_space(4.0);
|
|
self.render_earnings_warning(ui);
|
|
});
|
|
|
|
if self.show_watchlist_panel {
|
|
egui::TopBottomPanel::bottom("watchlist_panel")
|
|
.resizable(true)
|
|
.default_height(160.0)
|
|
.show(ctx, |ui| {
|
|
self.render_watchlist_panel(ui);
|
|
});
|
|
}
|
|
|
|
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
|
ui.add_space(2.0);
|
|
self.render_status_bar(ui);
|
|
ui.add_space(2.0);
|
|
});
|
|
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
|
if let Some((input, result)) = &self.current_analysis.clone() {
|
|
let available = ui.available_size();
|
|
let panel_width = available.x * 0.35;
|
|
|
|
ui.columns(2, |cols| {
|
|
cols[0].set_width(panel_width);
|
|
data_panel::render(&mut cols[0], input, result);
|
|
|
|
cols[1].vertical(|ui| {
|
|
let save_h = 30.0;
|
|
let analysis_h = available.y - save_h - 8.0;
|
|
egui::ScrollArea::vertical()
|
|
.id_source("analysis_outer_scroll")
|
|
.max_height(analysis_h)
|
|
.show(ui, |ui| {
|
|
analysis_panel::render(ui, result);
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.button("💾 Save Report").clicked() {
|
|
self.save_report();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
} else {
|
|
ui.centered_and_justified(|ui| {
|
|
ui.label(
|
|
RichText::new("Enter a ticker symbol above and click Analyze")
|
|
.color(Color32::GRAY)
|
|
.size(16.0),
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Background task ──────────────────────────────────────────────────────
|
|
|
|
async fn background_task(
|
|
mut cmd_rx: tokio::sync::mpsc::UnboundedReceiver<BgCmd>,
|
|
result_tx: mpsc::Sender<BgCheckResult>,
|
|
update_tx: mpsc::Sender<UpdateCheckResult>,
|
|
initial_entries: Vec<WatchlistEntry>,
|
|
config: Config,
|
|
) {
|
|
let watchlist_secs = config.watchlist_check.interval_minutes * 60;
|
|
let mut watchlist_interval =
|
|
tokio::time::interval(tokio::time::Duration::from_secs(watchlist_secs));
|
|
watchlist_interval.tick().await; // skip immediate first tick
|
|
|
|
let mut update_interval =
|
|
tokio::time::interval(tokio::time::Duration::from_secs(60 * 60));
|
|
update_interval.tick().await; // skip immediate first tick
|
|
|
|
let mut entries = initial_entries;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = watchlist_interval.tick() => {
|
|
run_watchlist_check(&entries, &result_tx, &config).await;
|
|
}
|
|
_ = update_interval.tick() => {
|
|
if config.updater.enabled {
|
|
run_update_check(&update_tx).await;
|
|
}
|
|
}
|
|
Some(cmd) = cmd_rx.recv() => {
|
|
match cmd {
|
|
BgCmd::UpdateWatchlist(e) => entries = e,
|
|
BgCmd::RunNow => run_watchlist_check(&entries, &result_tx, &config).await,
|
|
BgCmd::CheckUpdate => run_update_check(&update_tx).await,
|
|
BgCmd::Quit => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_update_check(update_tx: &mpsc::Sender<UpdateCheckResult>) {
|
|
match crate::updater::check_for_update().await {
|
|
Ok(Some((tag, url))) => { let _ = update_tx.send(UpdateCheckResult::Available(tag, url)); }
|
|
Ok(None) => { let _ = update_tx.send(UpdateCheckResult::UpToDate); }
|
|
Err(e) => eprintln!("Update check failed: {e}"),
|
|
}
|
|
}
|
|
|
|
async fn run_watchlist_check(
|
|
entries: &[WatchlistEntry],
|
|
result_tx: &mpsc::Sender<BgCheckResult>,
|
|
config: &Config,
|
|
) {
|
|
for entry in entries {
|
|
match data::fetch::fetch_ticker(&entry.symbol).await {
|
|
Ok(input) => {
|
|
let result = analysis::analyze(&input, config);
|
|
let _ = result_tx.send(BgCheckResult::Entry {
|
|
symbol: entry.symbol.clone(),
|
|
input,
|
|
result,
|
|
prev_signal: entry.last_signal.clone(),
|
|
prev_score: entry.last_score,
|
|
});
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Watchlist check failed for {}: {e}", entry.symbol);
|
|
}
|
|
}
|
|
}
|
|
// Signal that the full cycle is complete so the UI thread can fire the batch snapshot
|
|
let _ = result_tx.send(BgCheckResult::CycleDone);
|
|
}
|
|
|
|
// ─── Tray setup ───────────────────────────────────────────────────────────
|
|
|
|
fn setup_tray(
|
|
rgba: Vec<u8>,
|
|
size: u32,
|
|
watched_count: usize,
|
|
) -> (
|
|
Option<tray_icon::TrayIcon>,
|
|
Option<tray_icon::menu::MenuId>,
|
|
Option<tray_icon::menu::MenuId>,
|
|
Option<tray_icon::menu::MenuId>,
|
|
) {
|
|
use tray_icon::menu::{Menu, MenuItem, PredefinedMenuItem};
|
|
use tray_icon::TrayIconBuilder;
|
|
|
|
let icon = match tray_icon::Icon::from_rgba(rgba, size, size) {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
eprintln!("Could not create tray icon: {e}");
|
|
return (None, None, None, None);
|
|
}
|
|
};
|
|
|
|
let open_item = MenuItem::new("Open Tickr", true, None);
|
|
let sep1 = PredefinedMenuItem::separator();
|
|
let check_item = MenuItem::new("Run Check Now", true, None);
|
|
let sep2 = PredefinedMenuItem::separator();
|
|
let quit_item = MenuItem::new("Quit", true, None);
|
|
|
|
let open_id = open_item.id().clone();
|
|
let check_id = check_item.id().clone();
|
|
let quit_id = quit_item.id().clone();
|
|
|
|
let menu = Menu::new();
|
|
let _ = menu.append_items(&[
|
|
&open_item,
|
|
&sep1,
|
|
&check_item,
|
|
&sep2,
|
|
&quit_item,
|
|
]);
|
|
|
|
let n = watched_count;
|
|
let tooltip = format!("Tickr — {n} ticker{} watched", if n == 1 { "" } else { "s" });
|
|
|
|
match TrayIconBuilder::new()
|
|
.with_icon(icon)
|
|
.with_tooltip(tooltip)
|
|
.with_menu(Box::new(menu))
|
|
.build()
|
|
{
|
|
Ok(tray) => (Some(tray), Some(open_id), Some(check_id), Some(quit_id)),
|
|
Err(e) => {
|
|
eprintln!("Could not create system tray: {e}");
|
|
(None, None, None, None)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Report builder ───────────────────────────────────────────────────────
|
|
|
|
fn build_report_text(input: &AnalysisInput, result: &AnalysisResult) -> String {
|
|
use std::fmt::Write;
|
|
let mut out = String::new();
|
|
|
|
let sym = input.symbol();
|
|
let date = Utc::now().format("%Y-%m-%d");
|
|
let fetched = input.fetched_at().format("%Y-%m-%d %H:%M UTC");
|
|
|
|
let _ = writeln!(out, "TICKR ANALYSIS REPORT");
|
|
let _ = writeln!(out, "Ticker: {sym}");
|
|
let _ = writeln!(out, "Date: {date}");
|
|
let _ = writeln!(out, "Data fetched: {fetched}");
|
|
let _ = writeln!(out, "{}", "=".repeat(60));
|
|
let _ = writeln!(out);
|
|
let _ = writeln!(out, "FINAL OPINION: {}", result.decision.label());
|
|
let _ = writeln!(out, "Confidence: {}", result.confidence.label());
|
|
let _ = writeln!(out, "Risk Level: {}", result.risk_level.label());
|
|
let _ = writeln!(out);
|
|
|
|
for (title, content) in &[
|
|
("ONE-SENTENCE VERDICT", result.section_verdict.as_str()),
|
|
("WHAT THIS COMPANY DOES", result.section_what_company_does.as_str()),
|
|
("WHY THIS IS THE OPINION", result.section_why_opinion.as_str()),
|
|
("DIVIDEND / INCOME CHECK", result.section_dividend.as_str()),
|
|
("GROWTH CHECK", result.section_growth.as_str()),
|
|
("VALUATION CHECK", result.section_valuation.as_str()),
|
|
("WHAT WOULD CHANGE MY MIND", result.section_change_mind.as_str()),
|
|
("WHAT YOU SHOULD STUDY NEXT", result.section_study_next.as_str()),
|
|
("PLAIN ENGLISH TRANSLATION", result.section_translation.as_str()),
|
|
] {
|
|
let _ = writeln!(out, "{}", "-".repeat(40));
|
|
let _ = writeln!(out, "{title}");
|
|
let _ = writeln!(out, "{content}");
|
|
let _ = writeln!(out);
|
|
}
|
|
|
|
if !result.section_looks_good.is_empty() {
|
|
let _ = writeln!(out, "{}", "-".repeat(40));
|
|
let _ = writeln!(out, "WHAT LOOKS GOOD");
|
|
for item in &result.section_looks_good {
|
|
let _ = writeln!(out, " ✓ {item}");
|
|
}
|
|
let _ = writeln!(out);
|
|
}
|
|
if !result.section_worries.is_empty() {
|
|
let _ = writeln!(out, "{}", "-".repeat(40));
|
|
let _ = writeln!(out, "WHAT WORRIES ME");
|
|
for item in &result.section_worries {
|
|
let _ = writeln!(out, " • {item}");
|
|
}
|
|
let _ = writeln!(out);
|
|
}
|
|
|
|
let _ = writeln!(out, "{}", "=".repeat(60));
|
|
let _ = writeln!(out, "FINAL ANSWER: {}", result.section_final_answer);
|
|
let _ = writeln!(out, "REASON: {}", result.section_final_reason);
|
|
out
|
|
}
|
|
|
|
// ─── Dummy result for test Discord notification ────────────────────────────
|
|
|
|
fn make_dummy_result() -> AnalysisResult {
|
|
use crate::analysis::{Confidence, Decision, RiskLevel};
|
|
AnalysisResult {
|
|
decision: Decision::Buy,
|
|
confidence: Confidence::Medium,
|
|
risk_level: RiskLevel::Medium,
|
|
raw_score: 0.7,
|
|
final_score: 0.7,
|
|
force_pass_reason: None,
|
|
category_scores: vec![],
|
|
speculation_flags: vec![],
|
|
hype_flags: vec![],
|
|
analyst_trend: None,
|
|
section_verdict: "Test notification from Tickr".to_string(),
|
|
section_what_company_does: String::new(),
|
|
section_why_opinion: "This is a test Discord notification.".to_string(),
|
|
section_looks_good: vec![],
|
|
section_worries: vec![],
|
|
section_dividend: String::new(),
|
|
section_growth: String::new(),
|
|
section_valuation: String::new(),
|
|
section_change_mind: String::new(),
|
|
section_study_next: String::new(),
|
|
section_translation: String::new(),
|
|
section_final_answer: "BUY".to_string(),
|
|
section_final_reason: "Test".to_string(),
|
|
}
|
|
}
|