From 91cc3cc3d48b5f491eee29c36e02812c0816f200 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sun, 5 Apr 2026 22:36:18 -0400 Subject: [PATCH] Implement portfolio management backend and terminal UI --- MosaicIQ/src-tauri/src/agent/service.rs | 9 +- MosaicIQ/src-tauri/src/commands/terminal.rs | 4 +- MosaicIQ/src-tauri/src/lib.rs | 3 + MosaicIQ/src-tauri/src/portfolio/engine.rs | 443 +++++++++++ MosaicIQ/src-tauri/src/portfolio/mod.rs | 14 + MosaicIQ/src-tauri/src/portfolio/service.rs | 725 ++++++++++++++++++ MosaicIQ/src-tauri/src/portfolio/types.rs | 164 ++++ MosaicIQ/src-tauri/src/state.rs | 8 +- .../src-tauri/src/terminal/command_service.rs | 664 +++++++++++++++- MosaicIQ/src-tauri/src/terminal/mod.rs | 6 +- MosaicIQ/src-tauri/src/terminal/types.rs | 20 +- MosaicIQ/src-tauri/src/test_support.rs | 8 + MosaicIQ/src/App.tsx | 120 ++- .../src/components/Panels/PortfolioPanel.tsx | 280 +++++-- .../components/Sidebar/PortfolioSummary.tsx | 115 ++- MosaicIQ/src/components/Sidebar/Sidebar.tsx | 11 +- .../src/components/Terminal/CommandInput.tsx | 528 +++++++++---- MosaicIQ/src/components/Terminal/Terminal.tsx | 42 +- .../components/Terminal/TerminalOutput.tsx | 30 +- MosaicIQ/src/hooks/usePortfolioWorkflow.ts | 247 ++++++ MosaicIQ/src/hooks/useTabs.ts | 4 +- MosaicIQ/src/types/financial.ts | 9 + MosaicIQ/src/types/terminal.ts | 24 + 23 files changed, 3218 insertions(+), 260 deletions(-) create mode 100644 MosaicIQ/src-tauri/src/portfolio/engine.rs create mode 100644 MosaicIQ/src-tauri/src/portfolio/mod.rs create mode 100644 MosaicIQ/src-tauri/src/portfolio/service.rs create mode 100644 MosaicIQ/src-tauri/src/portfolio/types.rs create mode 100644 MosaicIQ/src-tauri/src/test_support.rs create mode 100644 MosaicIQ/src/hooks/usePortfolioWorkflow.ts diff --git a/MosaicIQ/src-tauri/src/agent/service.rs b/MosaicIQ/src-tauri/src/agent/service.rs index bcab993..17e4730 100644 --- a/MosaicIQ/src-tauri/src/agent/service.rs +++ b/MosaicIQ/src-tauri/src/agent/service.rs @@ -190,7 +190,6 @@ mod tests { use std::env; use std::fs; use std::path::PathBuf; - use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use super::SessionManager; @@ -485,7 +484,7 @@ mod tests { } fn with_test_home(prefix: &str, test: impl FnOnce() -> T) -> T { - let _lock = env_lock().lock().unwrap(); + let _lock = crate::test_support::env_lock().lock().unwrap(); let home = env::temp_dir().join(unique_identifier(prefix)); fs::create_dir_all(&home).unwrap(); @@ -506,12 +505,6 @@ mod tests { Err(payload) => std::panic::resume_unwind(payload), } } - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - fn cleanup_test_data_dir(path: PathBuf) { let _ = fs::remove_dir_all(path); } diff --git a/MosaicIQ/src-tauri/src/commands/terminal.rs b/MosaicIQ/src-tauri/src/commands/terminal.rs index 1b63c44..52eef6e 100644 --- a/MosaicIQ/src-tauri/src/commands/terminal.rs +++ b/MosaicIQ/src-tauri/src/commands/terminal.rs @@ -8,7 +8,9 @@ use crate::agent::{ ChatStreamStart, }; use crate::state::AppState; -use crate::terminal::{Company, ExecuteTerminalCommandRequest, LookupCompanyRequest, TerminalCommandResponse}; +use crate::terminal::{ + Company, ExecuteTerminalCommandRequest, LookupCompanyRequest, TerminalCommandResponse, +}; /// Executes a slash command and returns either terminal text or a structured panel payload. #[tauri::command] diff --git a/MosaicIQ/src-tauri/src/lib.rs b/MosaicIQ/src-tauri/src/lib.rs index f727e00..e7a22d0 100644 --- a/MosaicIQ/src-tauri/src/lib.rs +++ b/MosaicIQ/src-tauri/src/lib.rs @@ -7,8 +7,11 @@ mod agent; mod commands; mod error; +mod portfolio; mod state; mod terminal; +#[cfg(test)] +mod test_support; use tauri::Manager; diff --git a/MosaicIQ/src-tauri/src/portfolio/engine.rs b/MosaicIQ/src-tauri/src/portfolio/engine.rs new file mode 100644 index 0000000..78aed01 --- /dev/null +++ b/MosaicIQ/src-tauri/src/portfolio/engine.rs @@ -0,0 +1,443 @@ +use std::collections::HashMap; + +use thiserror::Error; + +use crate::portfolio::{ + OpenLot, PortfolioLedger, PortfolioTransaction, PositionSnapshot, ReplaySnapshot, + TransactionKind, +}; + +const FLOAT_TOLERANCE: f64 = 1e-9; + +#[derive(Debug, Error, Clone, PartialEq)] +pub enum PortfolioEngineError { + #[error("ledger transaction {transaction_id} has invalid numeric fields")] + InvalidTransactionNumbers { transaction_id: String }, + #[error("ledger transaction {transaction_id} is missing required fields")] + MissingTransactionFields { transaction_id: String }, + #[error("ledger transaction {transaction_id} would make cash negative by {shortfall:.2}")] + NegativeCash { + transaction_id: String, + shortfall: f64, + }, + #[error( + "ledger transaction {transaction_id} tries to sell {requested_quantity:.4} shares of {symbol} but only {available_quantity:.4} are available" + )] + InsufficientShares { + transaction_id: String, + symbol: String, + requested_quantity: f64, + available_quantity: f64, + }, +} + +#[must_use] +pub fn replay_ledger(ledger: &PortfolioLedger) -> Result { + let mut ordered_transactions = ledger.transactions.clone(); + ordered_transactions.sort_by(|left, right| { + left.executed_at + .cmp(&right.executed_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let mut cash_balance = 0.0; + let mut realized_gain = 0.0; + let mut lots_by_symbol: HashMap> = HashMap::new(); + let mut names_by_symbol: HashMap = HashMap::new(); + let mut latest_trade_at: HashMap = HashMap::new(); + let mut latest_trade_price: HashMap = HashMap::new(); + + for transaction in &ordered_transactions { + match transaction.kind { + TransactionKind::CashDeposit => { + validate_cash_amount(transaction)?; + cash_balance += transaction.gross_amount; + } + TransactionKind::CashWithdrawal => { + validate_cash_amount(transaction)?; + ensure_cash_available(cash_balance, transaction.gross_amount, &transaction.id)?; + cash_balance -= transaction.gross_amount; + } + TransactionKind::Buy => { + let (symbol, company_name, quantity, price) = + validate_equity_transaction(transaction)?; + let trade_total = quantity * price + transaction.fee; + ensure_cash_available(cash_balance, trade_total, &transaction.id)?; + cash_balance -= trade_total; + lots_by_symbol + .entry(symbol.clone()) + .or_default() + .push(OpenLot { quantity, price }); + names_by_symbol.insert(symbol.clone(), company_name); + latest_trade_at.insert(symbol.clone(), transaction.executed_at.clone()); + latest_trade_price.insert(symbol, price); + } + TransactionKind::Sell => { + let (symbol, company_name, quantity, price) = + validate_equity_transaction(transaction)?; + let Some(open_lots) = lots_by_symbol.get_mut(&symbol) else { + return Err(PortfolioEngineError::InsufficientShares { + transaction_id: transaction.id.clone(), + symbol, + requested_quantity: quantity, + available_quantity: 0.0, + }); + }; + + let available_quantity = open_lots.iter().map(|lot| lot.quantity).sum::(); + if available_quantity + FLOAT_TOLERANCE < quantity { + return Err(PortfolioEngineError::InsufficientShares { + transaction_id: transaction.id.clone(), + symbol, + requested_quantity: quantity, + available_quantity, + }); + } + + let depleted_cost = consume_fifo_lots(open_lots, quantity); + open_lots.retain(|lot| lot.quantity > FLOAT_TOLERANCE); + + if open_lots.is_empty() { + lots_by_symbol.remove(&symbol); + } + + let proceeds = quantity * price - transaction.fee; + realized_gain += proceeds - depleted_cost; + cash_balance += proceeds; + names_by_symbol.insert(symbol.clone(), company_name); + latest_trade_at.insert(symbol.clone(), transaction.executed_at.clone()); + latest_trade_price.insert(symbol, price); + } + } + } + + let mut positions = lots_by_symbol + .into_iter() + .filter_map(|(symbol, open_lots)| { + let quantity = open_lots.iter().map(|lot| lot.quantity).sum::(); + if quantity <= FLOAT_TOLERANCE { + return None; + } + + Some(PositionSnapshot { + cost_basis: open_lots + .iter() + .map(|lot| lot.quantity * lot.price) + .sum::(), + company_name: names_by_symbol + .get(&symbol) + .cloned() + .unwrap_or_else(|| symbol.clone()), + latest_trade_at: latest_trade_at.get(&symbol).cloned(), + latest_trade_price: latest_trade_price.get(&symbol).copied(), + open_lots, + quantity, + symbol, + }) + }) + .collect::>(); + positions.sort_by(|left, right| left.symbol.cmp(&right.symbol)); + + Ok(ReplaySnapshot { + cash_balance, + realized_gain, + positions, + }) +} + +fn validate_cash_amount(transaction: &PortfolioTransaction) -> Result<(), PortfolioEngineError> { + if !transaction.gross_amount.is_finite() + || !transaction.fee.is_finite() + || transaction.gross_amount <= 0.0 + || transaction.fee < 0.0 + { + return Err(PortfolioEngineError::InvalidTransactionNumbers { + transaction_id: transaction.id.clone(), + }); + } + + Ok(()) +} + +fn validate_equity_transaction( + transaction: &PortfolioTransaction, +) -> Result<(String, String, f64, f64), PortfolioEngineError> { + let Some(symbol) = transaction + .symbol + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err(PortfolioEngineError::MissingTransactionFields { + transaction_id: transaction.id.clone(), + }); + }; + + let Some(company_name) = transaction + .company_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err(PortfolioEngineError::MissingTransactionFields { + transaction_id: transaction.id.clone(), + }); + }; + + let Some(quantity) = transaction.quantity else { + return Err(PortfolioEngineError::MissingTransactionFields { + transaction_id: transaction.id.clone(), + }); + }; + let Some(price) = transaction.price else { + return Err(PortfolioEngineError::MissingTransactionFields { + transaction_id: transaction.id.clone(), + }); + }; + + if !quantity.is_finite() + || !price.is_finite() + || !transaction.gross_amount.is_finite() + || !transaction.fee.is_finite() + || quantity <= 0.0 + || price <= 0.0 + || transaction.gross_amount <= 0.0 + || transaction.fee < 0.0 + { + return Err(PortfolioEngineError::InvalidTransactionNumbers { + transaction_id: transaction.id.clone(), + }); + } + + Ok(( + symbol.to_string(), + company_name.to_string(), + quantity, + price, + )) +} + +fn ensure_cash_available( + cash_balance: f64, + required_amount: f64, + transaction_id: &str, +) -> Result<(), PortfolioEngineError> { + if cash_balance + FLOAT_TOLERANCE < required_amount { + return Err(PortfolioEngineError::NegativeCash { + transaction_id: transaction_id.to_string(), + shortfall: required_amount - cash_balance, + }); + } + + Ok(()) +} + +fn consume_fifo_lots(open_lots: &mut [OpenLot], requested_quantity: f64) -> f64 { + let mut remaining_quantity = requested_quantity; + let mut depleted_cost = 0.0; + + for lot in open_lots { + if remaining_quantity <= FLOAT_TOLERANCE { + break; + } + + let consumed_quantity = lot.quantity.min(remaining_quantity); + depleted_cost += consumed_quantity * lot.price; + lot.quantity -= consumed_quantity; + remaining_quantity -= consumed_quantity; + } + + depleted_cost +} + +#[cfg(test)] +mod tests { + use super::replay_ledger; + use crate::portfolio::{PortfolioLedger, PortfolioTransaction, TransactionKind}; + + fn buy( + id: &str, + symbol: &str, + quantity: f64, + price: f64, + executed_at: &str, + ) -> PortfolioTransaction { + PortfolioTransaction { + id: id.to_string(), + kind: TransactionKind::Buy, + symbol: Some(symbol.to_string()), + company_name: Some(format!("{symbol} Corp")), + quantity: Some(quantity), + price: Some(price), + gross_amount: quantity * price, + fee: 0.0, + executed_at: executed_at.to_string(), + note: None, + } + } + + fn sell( + id: &str, + symbol: &str, + quantity: f64, + price: f64, + executed_at: &str, + ) -> PortfolioTransaction { + PortfolioTransaction { + id: id.to_string(), + kind: TransactionKind::Sell, + symbol: Some(symbol.to_string()), + company_name: Some(format!("{symbol} Corp")), + quantity: Some(quantity), + price: Some(price), + gross_amount: quantity * price, + fee: 0.0, + executed_at: executed_at.to_string(), + note: None, + } + } + + fn cash( + id: &str, + kind: TransactionKind, + amount: f64, + executed_at: &str, + ) -> PortfolioTransaction { + PortfolioTransaction { + id: id.to_string(), + kind, + symbol: None, + company_name: None, + quantity: None, + price: None, + gross_amount: amount, + fee: 0.0, + executed_at: executed_at.to_string(), + note: None, + } + } + + #[test] + fn replay_creates_open_lot_and_reduces_cash_for_buy() { + let ledger = PortfolioLedger { + transactions: vec![ + cash( + "1", + TransactionKind::CashDeposit, + 1_000.0, + "2026-01-01T09:00:00Z", + ), + buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"), + ], + ..PortfolioLedger::default() + }; + + let snapshot = replay_ledger(&ledger).expect("replay should succeed"); + + assert_eq!(snapshot.cash_balance, 500.0); + assert_eq!(snapshot.positions.len(), 1); + assert_eq!(snapshot.positions[0].quantity, 5.0); + } + + #[test] + fn replay_keeps_multiple_fifo_lots_for_multiple_buys() { + let ledger = PortfolioLedger { + transactions: vec![ + cash( + "1", + TransactionKind::CashDeposit, + 2_000.0, + "2026-01-01T09:00:00Z", + ), + buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"), + buy("3", "AAPL", 2.0, 120.0, "2026-01-02T10:00:00Z"), + ], + ..PortfolioLedger::default() + }; + + let snapshot = replay_ledger(&ledger).expect("replay should succeed"); + + assert_eq!(snapshot.positions[0].open_lots.len(), 2); + assert_eq!(snapshot.positions[0].quantity, 7.0); + } + + #[test] + fn replay_consumes_earliest_lots_first_for_sell() { + let ledger = PortfolioLedger { + transactions: vec![ + cash( + "1", + TransactionKind::CashDeposit, + 3_000.0, + "2026-01-01T09:00:00Z", + ), + buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"), + buy("3", "AAPL", 5.0, 120.0, "2026-01-02T10:00:00Z"), + sell("4", "AAPL", 6.0, 130.0, "2026-01-03T10:00:00Z"), + ], + ..PortfolioLedger::default() + }; + + let snapshot = replay_ledger(&ledger).expect("replay should succeed"); + + assert_eq!(snapshot.positions[0].quantity, 4.0); + assert_eq!(snapshot.positions[0].open_lots[0].quantity, 4.0); + assert_eq!(snapshot.positions[0].open_lots[0].price, 120.0); + } + + #[test] + fn replay_computes_realized_gain_across_multiple_lots() { + let ledger = PortfolioLedger { + transactions: vec![ + cash( + "1", + TransactionKind::CashDeposit, + 3_000.0, + "2026-01-01T09:00:00Z", + ), + buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"), + buy("3", "AAPL", 5.0, 120.0, "2026-01-02T10:00:00Z"), + sell("4", "AAPL", 6.0, 130.0, "2026-01-03T10:00:00Z"), + ], + ..PortfolioLedger::default() + }; + + let snapshot = replay_ledger(&ledger).expect("replay should succeed"); + + assert_eq!(snapshot.realized_gain, 160.0); + } + + #[test] + fn replay_updates_cash_for_cash_deposit_and_withdrawal() { + let ledger = PortfolioLedger { + transactions: vec![ + cash( + "1", + TransactionKind::CashDeposit, + 1_500.0, + "2026-01-01T09:00:00Z", + ), + cash( + "2", + TransactionKind::CashWithdrawal, + 250.0, + "2026-01-01T10:00:00Z", + ), + ], + ..PortfolioLedger::default() + }; + + let snapshot = replay_ledger(&ledger).expect("replay should succeed"); + + assert_eq!(snapshot.cash_balance, 1_250.0); + } + + #[test] + fn replay_returns_zeroed_snapshot_for_empty_ledger() { + let snapshot = replay_ledger(&PortfolioLedger::default()).expect("replay should succeed"); + + assert_eq!(snapshot.cash_balance, 0.0); + assert_eq!(snapshot.realized_gain, 0.0); + assert!(snapshot.positions.is_empty()); + } +} diff --git a/MosaicIQ/src-tauri/src/portfolio/mod.rs b/MosaicIQ/src-tauri/src/portfolio/mod.rs new file mode 100644 index 0000000..e773329 --- /dev/null +++ b/MosaicIQ/src-tauri/src/portfolio/mod.rs @@ -0,0 +1,14 @@ +mod engine; +mod service; +mod types; + +#[allow(unused_imports)] +pub use service::{ + PortfolioCommandError, PortfolioManagement, PortfolioQuoteError, PortfolioService, + PortfolioStoreError, PortfolioValidationError, +}; +pub use types::{ + CashConfirmation, OpenLot, PortfolioHolding, PortfolioLedger, PortfolioSnapshot, + PortfolioStats, PortfolioTransaction, PositionSnapshot, ReplaySnapshot, TradeConfirmation, + TransactionKind, PORTFOLIO_LEDGER_KEY, PORTFOLIO_LEDGER_STORE_PATH, +}; diff --git a/MosaicIQ/src-tauri/src/portfolio/service.rs b/MosaicIQ/src-tauri/src/portfolio/service.rs new file mode 100644 index 0000000..01c9994 --- /dev/null +++ b/MosaicIQ/src-tauri/src/portfolio/service.rs @@ -0,0 +1,725 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use chrono::Utc; +use futures::future::BoxFuture; +use futures::stream::{self, StreamExt}; +use serde_json::json; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_store::StoreExt; +use thiserror::Error; +use tokio::sync::Mutex; + +use crate::portfolio::engine::{replay_ledger, PortfolioEngineError}; +use crate::portfolio::{ + CashConfirmation, PortfolioHolding, PortfolioLedger, PortfolioSnapshot, PortfolioStats, + PortfolioTransaction, ReplaySnapshot, TradeConfirmation, TransactionKind, PORTFOLIO_LEDGER_KEY, + PORTFOLIO_LEDGER_STORE_PATH, +}; +use crate::terminal::security_lookup::{ + SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, +}; +use crate::terminal::{Holding, Portfolio}; + +const QUOTE_CONCURRENCY_LIMIT: usize = 4; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum PortfolioStoreError { + #[error("portfolio store unavailable: {0}")] + StoreUnavailable(String), + #[error("portfolio ledger is not valid JSON: {0}")] + Deserialize(String), + #[error("portfolio ledger could not be saved: {0}")] + SaveFailed(String), + #[error("portfolio ledger is invalid: {0}")] + CorruptLedger(String), +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum PortfolioValidationError { + #[error("cash balance is insufficient for this trade")] + InsufficientCash, + #[error("cash balance is insufficient for this withdrawal")] + InsufficientCashWithdrawal, + #[error("not enough shares are available to sell for {symbol}")] + InsufficientShares { symbol: String }, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum PortfolioQuoteError { + #[error("quote unavailable for {symbol}: {detail}")] + Unavailable { symbol: String, detail: String }, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum PortfolioCommandError { + #[error(transparent)] + Store(#[from] PortfolioStoreError), + #[error(transparent)] + Validation(#[from] PortfolioValidationError), + #[error(transparent)] + Quote(#[from] PortfolioQuoteError), +} + +pub trait PortfolioManagement: Send + Sync { + fn portfolio<'a>(&'a self) -> BoxFuture<'a, Result>; + fn stats<'a>(&'a self) -> BoxFuture<'a, Result>; + fn history<'a>( + &'a self, + limit: usize, + ) -> BoxFuture<'a, Result, PortfolioCommandError>>; + fn buy<'a>( + &'a self, + symbol: &'a str, + quantity: f64, + price_override: Option, + ) -> BoxFuture<'a, Result>; + fn sell<'a>( + &'a self, + symbol: &'a str, + quantity: f64, + price_override: Option, + ) -> BoxFuture<'a, Result>; + fn deposit_cash<'a>( + &'a self, + amount: f64, + ) -> BoxFuture<'a, Result>; + fn withdraw_cash<'a>( + &'a self, + amount: f64, + ) -> BoxFuture<'a, Result>; +} + +pub struct PortfolioService { + app_handle: AppHandle, + security_lookup: Arc, + write_lock: Mutex<()>, + next_transaction_id: AtomicU64, +} + +impl PortfolioService { + pub fn new(app_handle: &AppHandle, security_lookup: Arc) -> Self { + Self { + app_handle: app_handle.clone(), + security_lookup, + write_lock: Mutex::new(()), + next_transaction_id: AtomicU64::new(1), + } + } + + fn load_ledger(&self) -> Result { + let store = self + .app_handle + .store(PORTFOLIO_LEDGER_STORE_PATH) + .map_err(|error| PortfolioStoreError::StoreUnavailable(error.to_string()))?; + + match store.get(PORTFOLIO_LEDGER_KEY) { + Some(value) => serde_json::from_value(value.clone()) + .map_err(|error| PortfolioStoreError::Deserialize(error.to_string())), + None => Ok(PortfolioLedger::default()), + } + } + + fn save_ledger(&self, ledger: &PortfolioLedger) -> Result<(), PortfolioStoreError> { + let store = self + .app_handle + .store(PORTFOLIO_LEDGER_STORE_PATH) + .map_err(|error| PortfolioStoreError::StoreUnavailable(error.to_string()))?; + + store.set(PORTFOLIO_LEDGER_KEY.to_string(), json!(ledger)); + store + .save() + .map_err(|error| PortfolioStoreError::SaveFailed(error.to_string())) + } + + fn replay(&self, ledger: &PortfolioLedger) -> Result { + replay_ledger(ledger).map_err(map_engine_error) + } + + async fn resolve_trade_input( + &self, + symbol: &str, + price_override: Option, + ) -> Result<(String, String, f64), PortfolioCommandError> { + let normalized_symbol = symbol.trim().to_ascii_uppercase(); + let security_match = SecurityMatch { + symbol: normalized_symbol.clone(), + name: None, + exchange: None, + kind: SecurityKind::Equity, + }; + + let company = self + .security_lookup + .load_company(&security_match) + .await + .map_err(|error| map_quote_error(&normalized_symbol, error))?; + + Ok(( + normalized_symbol, + company.name, + price_override.unwrap_or(company.price), + )) + } + + async fn candidate_trade_result( + &self, + symbol: &str, + company_name: &str, + quantity: f64, + price: f64, + kind: TransactionKind, + ) -> Result { + let _guard = self.write_lock.lock().await; + let mut ledger = self.load_ledger()?; + let before_snapshot = self.replay(&ledger)?; + + let transaction = PortfolioTransaction { + id: self.next_transaction_id(), + kind: kind.clone(), + symbol: Some(symbol.to_string()), + company_name: Some(company_name.to_string()), + quantity: Some(quantity), + price: Some(price), + gross_amount: quantity * price, + fee: 0.0, + executed_at: Utc::now().to_rfc3339(), + note: None, + }; + + ledger.transactions.push(transaction); + + let after_snapshot = self.replay(&ledger).map_err(|error| match error { + PortfolioStoreError::CorruptLedger(message) + if message.contains("would make cash negative") => + { + PortfolioCommandError::Validation(PortfolioValidationError::InsufficientCash) + } + PortfolioStoreError::CorruptLedger(message) if message.contains("tries to sell") => { + PortfolioCommandError::Validation(PortfolioValidationError::InsufficientShares { + symbol: symbol.to_string(), + }) + } + other => PortfolioCommandError::Store(other), + })?; + + self.save_ledger(&ledger)?; + + Ok(TradeConfirmation { + symbol: symbol.to_string(), + company_name: company_name.to_string(), + quantity, + price, + gross_amount: quantity * price, + cash_balance: after_snapshot.cash_balance, + realized_gain: matches!(kind, TransactionKind::Sell) + .then_some(after_snapshot.realized_gain - before_snapshot.realized_gain), + }) + } + + async fn candidate_cash_result( + &self, + amount: f64, + kind: TransactionKind, + ) -> Result { + let _guard = self.write_lock.lock().await; + let mut ledger = self.load_ledger()?; + + ledger.transactions.push(PortfolioTransaction { + id: self.next_transaction_id(), + kind: kind.clone(), + symbol: None, + company_name: None, + quantity: None, + price: None, + gross_amount: amount, + fee: 0.0, + executed_at: Utc::now().to_rfc3339(), + note: None, + }); + + let snapshot = self.replay(&ledger).map_err(|error| match error { + PortfolioStoreError::CorruptLedger(message) + if message.contains("would make cash negative") => + { + PortfolioCommandError::Validation( + PortfolioValidationError::InsufficientCashWithdrawal, + ) + } + other => PortfolioCommandError::Store(other), + })?; + + self.save_ledger(&ledger)?; + + Ok(CashConfirmation { + amount, + cash_balance: snapshot.cash_balance, + kind, + }) + } + + async fn value_portfolio(&self) -> Result { + let ledger = self.load_ledger()?; + let replay_snapshot = self.replay(&ledger)?; + + let priced_holdings = stream::iter(replay_snapshot.positions.iter().cloned()) + .map(|position| async move { + let security_match = SecurityMatch { + symbol: position.symbol.clone(), + name: Some(position.company_name.clone()), + exchange: None, + kind: SecurityKind::Equity, + }; + + let quote = self.security_lookup.load_company(&security_match).await; + (position, quote) + }) + .buffer_unordered(QUOTE_CONCURRENCY_LIMIT) + .collect::>() + .await; + + let mut holdings = Vec::with_capacity(priced_holdings.len()); + let mut stale_pricing_symbols = Vec::new(); + let mut equities_market_value = 0.0; + let mut unrealized_gain = 0.0; + let mut day_change = 0.0; + + for (position, quote_result) in priced_holdings { + let (current_price, day_change_value, stale_pricing) = match quote_result { + Ok(company) => (company.price, company.change * position.quantity, false), + Err(_) => { + let fallback_price = position.latest_trade_price.unwrap_or(0.0); + stale_pricing_symbols.push(position.symbol.clone()); + (fallback_price, 0.0, true) + } + }; + + let current_value = current_price * position.quantity; + let holding_unrealized_gain = current_value - position.cost_basis; + equities_market_value += current_value; + unrealized_gain += holding_unrealized_gain; + day_change += day_change_value; + + holdings.push(PortfolioHolding { + symbol: position.symbol, + name: position.company_name, + quantity: position.quantity, + cost_basis: position.cost_basis, + current_price, + current_value, + unrealized_gain: holding_unrealized_gain, + gain_loss_percent: percent_change(holding_unrealized_gain, position.cost_basis), + latest_trade_at: position.latest_trade_at, + stale_pricing, + day_change: day_change_value, + }); + } + + holdings.sort_by(|left, right| left.symbol.cmp(&right.symbol)); + + let invested_cost_basis = holdings + .iter() + .map(|holding| holding.cost_basis) + .sum::(); + let total_portfolio_value = equities_market_value + replay_snapshot.cash_balance; + let baseline_value = total_portfolio_value - day_change; + + Ok(PortfolioSnapshot { + cash_balance: replay_snapshot.cash_balance, + day_change, + day_change_percent: if baseline_value > 0.0 { + (day_change / baseline_value) * 100.0 + } else { + 0.0 + }, + equities_market_value, + holdings, + invested_cost_basis, + realized_gain: replay_snapshot.realized_gain, + stale_pricing_symbols, + total_portfolio_value, + unrealized_gain, + }) + } + + fn next_transaction_id(&self) -> String { + let id = self.next_transaction_id.fetch_add(1, Ordering::Relaxed); + format!("portfolio-tx-{id}") + } +} + +impl PortfolioManagement for PortfolioService { + fn portfolio<'a>(&'a self) -> BoxFuture<'a, Result> { + Box::pin(async move { + let snapshot = self.value_portfolio().await?; + let holdings_count = snapshot.holdings.len(); + + Ok(Portfolio { + cash_balance: Some(snapshot.cash_balance), + day_change: snapshot.day_change, + day_change_percent: snapshot.day_change_percent, + holdings: snapshot + .holdings + .into_iter() + .map(|holding| Holding { + avg_cost: if holding.quantity > 0.0 { + holding.cost_basis / holding.quantity + } else { + 0.0 + }, + cost_basis: Some(holding.cost_basis), + current_price: holding.current_price, + current_value: holding.current_value, + gain_loss: holding.unrealized_gain, + gain_loss_percent: holding.gain_loss_percent, + latest_trade_at: holding.latest_trade_at, + name: holding.name, + quantity: holding.quantity, + symbol: holding.symbol, + unrealized_gain: Some(holding.unrealized_gain), + }) + .collect(), + holdings_count: Some(holdings_count), + invested_cost_basis: Some(snapshot.invested_cost_basis), + realized_gain: Some(snapshot.realized_gain), + stale_pricing_symbols: Some(snapshot.stale_pricing_symbols), + total_gain: snapshot.unrealized_gain, + total_gain_percent: percent_change( + snapshot.unrealized_gain, + snapshot.invested_cost_basis, + ), + total_value: snapshot.total_portfolio_value, + unrealized_gain: Some(snapshot.unrealized_gain), + }) + }) + } + + fn stats<'a>(&'a self) -> BoxFuture<'a, Result> { + Box::pin(async move { + let snapshot = self.value_portfolio().await?; + + Ok(PortfolioStats { + cash_balance: snapshot.cash_balance, + day_change: snapshot.day_change, + equities_market_value: snapshot.equities_market_value, + holdings_count: snapshot.holdings.len(), + invested_cost_basis: snapshot.invested_cost_basis, + realized_gain: snapshot.realized_gain, + stale_pricing_symbols: snapshot.stale_pricing_symbols, + total_portfolio_value: snapshot.total_portfolio_value, + unrealized_gain: snapshot.unrealized_gain, + }) + }) + } + + fn history<'a>( + &'a self, + limit: usize, + ) -> BoxFuture<'a, Result, PortfolioCommandError>> { + Box::pin(async move { + let ledger = self.load_ledger()?; + let mut transactions = ledger.transactions; + transactions.sort_by(|left, right| { + right + .executed_at + .cmp(&left.executed_at) + .then_with(|| right.id.cmp(&left.id)) + }); + transactions.truncate(limit); + Ok(transactions) + }) + } + + fn buy<'a>( + &'a self, + symbol: &'a str, + quantity: f64, + price_override: Option, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let (symbol, company_name, price) = + self.resolve_trade_input(symbol, price_override).await?; + self.candidate_trade_result( + &symbol, + &company_name, + quantity, + price, + TransactionKind::Buy, + ) + .await + }) + } + + fn sell<'a>( + &'a self, + symbol: &'a str, + quantity: f64, + price_override: Option, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let (symbol, company_name, price) = + self.resolve_trade_input(symbol, price_override).await?; + self.candidate_trade_result( + &symbol, + &company_name, + quantity, + price, + TransactionKind::Sell, + ) + .await + }) + } + + fn deposit_cash<'a>( + &'a self, + amount: f64, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.candidate_cash_result(amount, TransactionKind::CashDeposit) + .await + }) + } + + fn withdraw_cash<'a>( + &'a self, + amount: f64, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.candidate_cash_result(amount, TransactionKind::CashWithdrawal) + .await + }) + } +} + +fn map_engine_error(error: PortfolioEngineError) -> PortfolioStoreError { + PortfolioStoreError::CorruptLedger(error.to_string()) +} + +fn map_quote_error(symbol: &str, error: SecurityLookupError) -> PortfolioCommandError { + let detail = match error { + SecurityLookupError::DetailUnavailable { detail, .. } + | SecurityLookupError::SearchUnavailable { detail, .. } => detail, + }; + + PortfolioCommandError::Quote(PortfolioQuoteError::Unavailable { + symbol: symbol.to_string(), + detail, + }) +} + +fn percent_change(delta: f64, base: f64) -> f64 { + if base > 0.0 { + (delta / base) * 100.0 + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use std::env; + use std::fs; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + use futures::future::BoxFuture; + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; + use tauri_plugin_store::StoreExt; + + use super::{PortfolioCommandError, PortfolioManagement, PortfolioService}; + use crate::portfolio::{ + PortfolioLedger, PortfolioTransaction, PortfolioValidationError, TransactionKind, + PORTFOLIO_LEDGER_KEY, PORTFOLIO_LEDGER_STORE_PATH, + }; + use crate::terminal::security_lookup::{SecurityLookup, SecurityLookupError, SecurityMatch}; + use crate::terminal::Company; + + #[derive(Debug)] + struct FakeSecurityLookup; + + impl SecurityLookup for FakeSecurityLookup { + fn provider_name(&self) -> &'static str { + "Google Finance" + } + + fn search<'a>( + &'a self, + _query: &'a str, + ) -> BoxFuture<'a, Result, SecurityLookupError>> { + Box::pin(async { Ok(Vec::new()) }) + } + + fn load_company<'a>( + &'a self, + security_match: &'a SecurityMatch, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(Company { + symbol: security_match.symbol.clone(), + name: format!("{} Corp", security_match.symbol), + price: 100.0, + change: 2.0, + change_percent: 2.0, + market_cap: 1_000_000.0, + volume: None, + volume_label: None, + pe: None, + eps: None, + high52_week: None, + low52_week: None, + profile: None, + price_chart: None, + price_chart_ranges: None, + }) + }) + } + } + + #[test] + fn loads_default_empty_ledger_when_store_is_empty() { + with_test_home("empty-ledger", || { + let service = build_service(); + + let history = + futures::executor::block_on(service.history(10)).expect("history should load"); + + assert!(history.is_empty()); + }); + } + + #[test] + fn persists_appended_transactions() { + with_test_home("persisted-ledger", || { + let service = build_service(); + + futures::executor::block_on(service.deposit_cash(1_000.0)) + .expect("deposit should succeed"); + + let history = + futures::executor::block_on(service.history(10)).expect("history should load"); + + assert_eq!(history.len(), 1); + assert_eq!(history[0].kind, TransactionKind::CashDeposit); + }); + } + + #[test] + fn rejects_buy_when_cash_is_insufficient() { + with_test_home("buy-insufficient-cash", || { + let service = build_service(); + + let error = futures::executor::block_on(service.buy("AAPL", 10.0, Some(100.0))) + .expect_err("buy should fail"); + + assert!(matches!( + error, + PortfolioCommandError::Validation(PortfolioValidationError::InsufficientCash) + )); + }); + } + + #[test] + fn rejects_sell_when_shares_are_insufficient() { + with_test_home("sell-insufficient-shares", || { + let service = build_service(); + + futures::executor::block_on(service.deposit_cash(1_000.0)) + .expect("deposit should succeed"); + + let error = futures::executor::block_on(service.sell("AAPL", 1.0, Some(100.0))) + .expect_err("sell should fail"); + + assert!(matches!( + error, + PortfolioCommandError::Validation( + PortfolioValidationError::InsufficientShares { .. } + ) + )); + }); + } + + #[test] + fn rejects_withdrawal_when_cash_is_insufficient() { + with_test_home("withdrawal-insufficient-cash", || { + let service = build_service(); + + let error = futures::executor::block_on(service.withdraw_cash(100.0)) + .expect_err("withdrawal should fail"); + + assert!(matches!( + error, + PortfolioCommandError::Validation( + PortfolioValidationError::InsufficientCashWithdrawal + ) + )); + }); + } + + fn build_service() -> PortfolioService { + let app = mock_builder() + .plugin(tauri_plugin_store::Builder::new().build()) + .build(mock_context(noop_assets())) + .expect("test app should build"); + + PortfolioService::new(&app.handle(), Arc::new(FakeSecurityLookup)) + } + + #[allow(dead_code)] + fn seed_ledger(service: &PortfolioService, ledger: PortfolioLedger) { + let store = service + .app_handle + .store(PORTFOLIO_LEDGER_STORE_PATH) + .expect("store should exist"); + store.set(PORTFOLIO_LEDGER_KEY.to_string(), serde_json::json!(ledger)); + store.save().expect("store should save"); + } + + #[allow(dead_code)] + fn cash_transaction(id: &str, amount: f64) -> PortfolioTransaction { + PortfolioTransaction { + id: id.to_string(), + kind: TransactionKind::CashDeposit, + symbol: None, + company_name: None, + quantity: None, + price: None, + gross_amount: amount, + fee: 0.0, + executed_at: "2026-01-01T00:00:00Z".to_string(), + note: None, + } + } + + fn unique_identifier(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should work") + .as_nanos(); + format!("com.mosaiciq.portfolio.tests.{prefix}.{nanos}") + } + + fn with_test_home(prefix: &str, test: impl FnOnce() -> T) -> T { + let _lock = crate::test_support::env_lock() + .lock() + .expect("env lock should succeed"); + let home = env::temp_dir().join(unique_identifier(prefix)); + fs::create_dir_all(&home).expect("home dir should exist"); + + let original_home = env::var_os("HOME"); + env::set_var("HOME", &home); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test)); + + match original_home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + + let _ = fs::remove_dir_all(&home); + + match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + } + } +} diff --git a/MosaicIQ/src-tauri/src/portfolio/types.rs b/MosaicIQ/src-tauri/src/portfolio/types.rs new file mode 100644 index 0000000..0d68cd7 --- /dev/null +++ b/MosaicIQ/src-tauri/src/portfolio/types.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; + +/// Store path for the persisted portfolio ledger. +pub const PORTFOLIO_LEDGER_STORE_PATH: &str = "portfolio-ledger.json"; +/// Top-level key used inside the Tauri store. +pub const PORTFOLIO_LEDGER_KEY: &str = "ledger"; +/// Current persisted schema version. +pub const PORTFOLIO_SCHEMA_VERSION: u32 = 1; +/// Base currency supported by the local portfolio backend. +pub const DEFAULT_BASE_CURRENCY: &str = "USD"; + +/// Persisted portfolio ledger containing the transaction source of truth. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PortfolioLedger { + pub schema_version: u32, + pub base_currency: String, + pub transactions: Vec, +} + +impl Default for PortfolioLedger { + fn default() -> Self { + Self { + schema_version: PORTFOLIO_SCHEMA_VERSION, + base_currency: DEFAULT_BASE_CURRENCY.to_string(), + transactions: Vec::new(), + } + } +} + +/// Supported portfolio transaction kinds. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum TransactionKind { + Buy, + Sell, + CashDeposit, + CashWithdrawal, +} + +impl TransactionKind { + #[must_use] + pub const fn as_label(&self) -> &'static str { + match self { + Self::Buy => "BUY", + Self::Sell => "SELL", + Self::CashDeposit => "CASH_DEPOSIT", + Self::CashWithdrawal => "CASH_WITHDRAWAL", + } + } +} + +/// Persisted transaction record. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PortfolioTransaction { + pub id: String, + pub kind: TransactionKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub company_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quantity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + pub gross_amount: f64, + pub fee: f64, + pub executed_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +/// Open FIFO lot tracked during replay. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenLot { + pub quantity: f64, + pub price: f64, +} + +/// Replay-derived position state before live quote enrichment. +#[derive(Debug, Clone, PartialEq)] +pub struct PositionSnapshot { + pub symbol: String, + pub company_name: String, + pub quantity: f64, + pub cost_basis: f64, + pub open_lots: Vec, + pub latest_trade_at: Option, + pub latest_trade_price: Option, +} + +/// Replay-derived snapshot for the entire ledger. +#[derive(Debug, Clone, PartialEq)] +pub struct ReplaySnapshot { + pub cash_balance: f64, + pub realized_gain: f64, + pub positions: Vec, +} + +/// Live-priced holding row returned to command consumers. +#[derive(Debug, Clone, PartialEq)] +pub struct PortfolioHolding { + pub symbol: String, + pub name: String, + pub quantity: f64, + pub cost_basis: f64, + pub current_price: f64, + pub current_value: f64, + pub unrealized_gain: f64, + pub gain_loss_percent: f64, + pub latest_trade_at: Option, + pub stale_pricing: bool, + pub day_change: f64, +} + +/// Portfolio valuation and summary stats after live price enrichment. +#[derive(Debug, Clone, PartialEq)] +pub struct PortfolioSnapshot { + pub holdings: Vec, + pub cash_balance: f64, + pub invested_cost_basis: f64, + pub equities_market_value: f64, + pub total_portfolio_value: f64, + pub realized_gain: f64, + pub unrealized_gain: f64, + pub day_change: f64, + pub day_change_percent: f64, + pub stale_pricing_symbols: Vec, +} + +/// Summary values rendered by `/portfolio stats`. +#[derive(Debug, Clone, PartialEq)] +pub struct PortfolioStats { + pub cash_balance: f64, + pub holdings_count: usize, + pub invested_cost_basis: f64, + pub equities_market_value: f64, + pub total_portfolio_value: f64, + pub realized_gain: f64, + pub unrealized_gain: f64, + pub day_change: f64, + pub stale_pricing_symbols: Vec, +} + +/// Confirmation payload returned after a trade command succeeds. +#[derive(Debug, Clone, PartialEq)] +pub struct TradeConfirmation { + pub symbol: String, + pub company_name: String, + pub quantity: f64, + pub price: f64, + pub gross_amount: f64, + pub cash_balance: f64, + pub realized_gain: Option, +} + +/// Confirmation payload returned after a cash command succeeds. +#[derive(Debug, Clone, PartialEq)] +pub struct CashConfirmation { + pub amount: f64, + pub cash_balance: f64, + pub kind: TransactionKind, +} diff --git a/MosaicIQ/src-tauri/src/state.rs b/MosaicIQ/src-tauri/src/state.rs index 26b34ac..9b0d595 100644 --- a/MosaicIQ/src-tauri/src/state.rs +++ b/MosaicIQ/src-tauri/src/state.rs @@ -7,10 +7,12 @@ use tauri::{AppHandle, Wry}; use crate::agent::{AgentService, AgentSettingsService}; use crate::error::AppError; +use crate::portfolio::PortfolioService; use crate::terminal::google_finance::GoogleFinanceLookup; use crate::terminal::sec_edgar::{ LiveSecFetcher, SecEdgarClient, SecEdgarLookup, SecUserAgentProvider, }; +use crate::terminal::security_lookup::SecurityLookup; use crate::terminal::TerminalCommandService; struct SettingsBackedSecUserAgentProvider { @@ -49,15 +51,19 @@ impl AppState { let sec_user_agent_provider = Arc::new(SettingsBackedSecUserAgentProvider { settings: AgentSettingsService::new(app_handle), }); + let security_lookup: Arc = Arc::new(GoogleFinanceLookup::default()); let sec_edgar_lookup = Arc::new(SecEdgarLookup::new(Arc::new(SecEdgarClient::new( Box::new(LiveSecFetcher::new(sec_user_agent_provider)), )))); + let portfolio_service = + Arc::new(PortfolioService::new(app_handle, security_lookup.clone())); Ok(Self { agent: Mutex::new(AgentService::new(app_handle)?), command_service: TerminalCommandService::new( - Arc::new(GoogleFinanceLookup::default()), + security_lookup, sec_edgar_lookup, + portfolio_service, ), next_request_id: AtomicU64::new(1), }) diff --git a/MosaicIQ/src-tauri/src/terminal/command_service.rs b/MosaicIQ/src-tauri/src/terminal/command_service.rs index c8fdff6..068d37d 100644 --- a/MosaicIQ/src-tauri/src/terminal/command_service.rs +++ b/MosaicIQ/src-tauri/src/terminal/command_service.rs @@ -1,9 +1,12 @@ use std::sync::Arc; use std::time::Duration; -use crate::terminal::google_finance::GoogleFinanceLookup; +use crate::portfolio::{ + CashConfirmation, PortfolioCommandError, PortfolioManagement, PortfolioStats, + PortfolioTransaction, TradeConfirmation, TransactionKind, +}; use crate::terminal::mock_data::load_mock_financial_data; -use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError, SecEdgarLookup}; +use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError}; use crate::terminal::security_lookup::{ SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch, }; @@ -17,30 +20,22 @@ pub struct TerminalCommandService { mock_data: MockFinancialData, security_lookup: Arc, edgar_lookup: Arc, + portfolio_service: Arc, lookup_followup_delay: Duration, } -impl Default for TerminalCommandService { - fn default() -> Self { - Self::with_dependencies( - load_mock_financial_data(), - Arc::new(GoogleFinanceLookup::default()), - Arc::new(SecEdgarLookup::default()), - DEFAULT_LOOKUP_FOLLOWUP_DELAY, - ) - } -} - impl TerminalCommandService { /// Creates a terminal command service with a custom security lookup backend. pub fn new( security_lookup: Arc, edgar_lookup: Arc, + portfolio_service: Arc, ) -> Self { Self::with_dependencies( load_mock_financial_data(), security_lookup, edgar_lookup, + portfolio_service, DEFAULT_LOOKUP_FOLLOWUP_DELAY, ) } @@ -49,12 +44,14 @@ impl TerminalCommandService { mock_data: MockFinancialData, security_lookup: Arc, edgar_lookup: Arc, + portfolio_service: Arc, lookup_followup_delay: Duration, ) -> Self { Self { mock_data, security_lookup, edgar_lookup, + portfolio_service, lookup_followup_delay, } } @@ -65,11 +62,10 @@ impl TerminalCommandService { match command.command.as_str() { "/search" => self.search(command.args.join(" ").trim()).await, - "/portfolio" => TerminalCommandResponse::Panel { - panel: PanelPayload::Portfolio { - data: self.mock_data.portfolio.clone(), - }, - }, + "/portfolio" => self.portfolio_command(&command.args).await, + "/buy" => self.buy_command(&command.args).await, + "/sell" => self.sell_command(&command.args).await, + "/cash" => self.cash_command(&command.args).await, "/fa" => { if command.args.len() > 2 { TerminalCommandResponse::Text { @@ -284,6 +280,110 @@ impl TerminalCommandService { } } + async fn portfolio_command(&self, args: &[String]) -> TerminalCommandResponse { + match args { + [] => match self.portfolio_service.portfolio().await { + Ok(portfolio) => TerminalCommandResponse::Panel { + panel: PanelPayload::Portfolio { data: portfolio }, + }, + Err(error) => portfolio_error_response(error), + }, + [subcommand] if subcommand.eq_ignore_ascii_case("stats") => { + match self.portfolio_service.stats().await { + Ok(stats) => TerminalCommandResponse::Text { + content: format_portfolio_stats(&stats), + }, + Err(error) => portfolio_error_response(error), + } + } + [subcommand] if subcommand.eq_ignore_ascii_case("history") => { + self.portfolio_history_response(10).await + } + [subcommand, limit] if subcommand.eq_ignore_ascii_case("history") => { + let Some(limit) = parse_positive_usize(limit) else { + return TerminalCommandResponse::Text { + content: "Usage: /portfolio history [limit]".to_string(), + }; + }; + self.portfolio_history_response(limit).await + } + _ => TerminalCommandResponse::Text { + content: "Usage: /portfolio [stats|history [limit]]".to_string(), + }, + } + } + + async fn portfolio_history_response(&self, limit: usize) -> TerminalCommandResponse { + match self.portfolio_service.history(limit).await { + Ok(history) if history.is_empty() => TerminalCommandResponse::Text { + content: "Portfolio history is empty.".to_string(), + }, + Ok(history) => TerminalCommandResponse::Text { + content: format_portfolio_history(&history), + }, + Err(error) => portfolio_error_response(error), + } + } + + async fn buy_command(&self, args: &[String]) -> TerminalCommandResponse { + let Some((symbol, quantity, price_override)) = parse_trade_args("/buy", args) else { + return TerminalCommandResponse::Text { + content: "Usage: /buy [ticker] [quantity] [price?]".to_string(), + }; + }; + + match self + .portfolio_service + .buy(symbol.as_str(), quantity, price_override) + .await + { + Ok(confirmation) => TerminalCommandResponse::Text { + content: format_buy_confirmation(&confirmation), + }, + Err(error) => portfolio_error_response(error), + } + } + + async fn sell_command(&self, args: &[String]) -> TerminalCommandResponse { + let Some((symbol, quantity, price_override)) = parse_trade_args("/sell", args) else { + return TerminalCommandResponse::Text { + content: "Usage: /sell [ticker] [quantity] [price?]".to_string(), + }; + }; + + match self + .portfolio_service + .sell(symbol.as_str(), quantity, price_override) + .await + { + Ok(confirmation) => TerminalCommandResponse::Text { + content: format_sell_confirmation(&confirmation), + }, + Err(error) => portfolio_error_response(error), + } + } + + async fn cash_command(&self, args: &[String]) -> TerminalCommandResponse { + let Some((subcommand, amount)) = parse_cash_args(args) else { + return TerminalCommandResponse::Text { + content: "Usage: /cash [deposit|withdraw] [amount]".to_string(), + }; + }; + + let result = if subcommand.eq_ignore_ascii_case("deposit") { + self.portfolio_service.deposit_cash(amount).await + } else { + self.portfolio_service.withdraw_cash(amount).await + }; + + match result { + Ok(confirmation) => TerminalCommandResponse::Text { + content: format_cash_confirmation(&confirmation), + }, + Err(error) => portfolio_error_response(error), + } + } + async fn financials( &self, ticker: Option<&String>, @@ -534,6 +634,49 @@ fn parse_symbol_and_frequency( Ok((ticker.to_ascii_uppercase(), frequency)) } +fn parse_trade_args(command: &str, args: &[String]) -> Option<(String, f64, Option)> { + if args.len() < 2 || args.len() > 3 { + return None; + } + + let symbol = args.first()?.trim(); + if symbol.is_empty() { + return None; + } + + let quantity = parse_positive_f64(args.get(1)?)?; + let price_override = match args.get(2) { + Some(value) => Some(parse_positive_f64(value)?), + None => None, + }; + + let _ = command; + Some((symbol.to_ascii_uppercase(), quantity, price_override)) +} + +fn parse_cash_args(args: &[String]) -> Option<(String, f64)> { + if args.len() != 2 { + return None; + } + + let subcommand = args.first()?.trim().to_ascii_lowercase(); + if !matches!(subcommand.as_str(), "deposit" | "withdraw") { + return None; + } + + Some((subcommand, parse_positive_f64(args.get(1)?)?)) +} + +fn parse_positive_f64(value: &str) -> Option { + let parsed = value.trim().parse::().ok()?; + (parsed.is_finite() && parsed > 0.0).then_some(parsed) +} + +fn parse_positive_usize(value: &str) -> Option { + let parsed = value.trim().parse::().ok()?; + (parsed > 0).then_some(parsed) +} + /// Parses raw slash-command input into a normalized command plus positional arguments. fn parse_command(input: &str) -> ChatCommandRequest { let trimmed = input.trim(); @@ -550,7 +693,7 @@ fn parse_command(input: &str) -> ChatCommandRequest { /// Human-readable help text returned for `/help` and unknown commands. fn help_text() -> &'static str { - "Available Commands:\n\n /search [ticker] - Search live security data\n /fa [ticker] [annual|quarterly] - SEC financial statements\n /cf [ticker] [annual|quarterly] - SEC cash flow summary\n /dvd [ticker] - SEC dividends history\n /em [ticker] [annual|quarterly] - SEC earnings history\n /portfolio - Show your portfolio\n /news [ticker?] - Show market news\n /analyze [ticker] - Get stock analysis\n /help - Show this help text\n /clear - Clear terminal locally" + "Available Commands:\n\n /search [ticker] - Search live security data\n /buy [ticker] [quantity] [price?] - Buy a company into the portfolio\n /sell [ticker] [quantity] [price?] - Sell a company from the portfolio\n /cash [deposit|withdraw] [amount] - Adjust portfolio cash\n /portfolio - Show your portfolio\n /portfolio stats - Show portfolio statistics\n /portfolio history [limit] - Show recent transactions\n /fa [ticker] [annual|quarterly] - SEC financial statements\n /cf [ticker] [annual|quarterly] - SEC cash flow summary\n /dvd [ticker] - SEC dividends history\n /em [ticker] [annual|quarterly] - SEC earnings history\n /news [ticker?] - Show market news\n /analyze [ticker] - Get stock analysis\n /help - Show this help text\n /clear - Clear terminal locally" } /// Wraps the shared help text into the terminal command response envelope. @@ -560,6 +703,114 @@ fn help_response() -> TerminalCommandResponse { } } +fn portfolio_error_response(error: PortfolioCommandError) -> TerminalCommandResponse { + TerminalCommandResponse::Text { + content: error.to_string(), + } +} + +fn format_portfolio_stats(stats: &PortfolioStats) -> String { + let stale_pricing = if stats.stale_pricing_symbols.is_empty() { + "none".to_string() + } else { + stats.stale_pricing_symbols.join(", ") + }; + + format!( + "Portfolio Stats\n\nCash Balance: {}\nHoldings Count: {}\nInvested Cost Basis: {}\nEquities Market Value: {}\nTotal Portfolio Value: {}\nUnrealized Gain/Loss: {}\nRealized Gain/Loss: {}\nDay Change: {}\nStale Pricing: {}", + format_currency(stats.cash_balance), + stats.holdings_count, + format_currency(stats.invested_cost_basis), + format_currency(stats.equities_market_value), + format_currency(stats.total_portfolio_value), + format_currency(stats.unrealized_gain), + format_currency(stats.realized_gain), + format_currency(stats.day_change), + stale_pricing, + ) +} + +fn format_portfolio_history(transactions: &[PortfolioTransaction]) -> String { + let rows = transactions + .iter() + .map(|transaction| { + let subject = transaction.symbol.as_deref().unwrap_or("CASH"); + let quantity = transaction + .quantity + .map(|value| format!("qty {}", format_quantity(value))) + .unwrap_or_else(|| "qty -".to_string()); + let price = transaction + .price + .map(format_currency) + .unwrap_or_else(|| "-".to_string()); + + format!( + "{} | {} | {} | {} | price {} | gross {}", + transaction.executed_at, + transaction.kind.as_label(), + subject, + quantity, + price, + format_currency(transaction.gross_amount), + ) + }) + .collect::>() + .join("\n"); + + format!("Portfolio History\n\n{rows}") +} + +fn format_buy_confirmation(confirmation: &TradeConfirmation) -> String { + format!( + "Bought {} {} @ {} for {}. Cash balance: {}.", + format_quantity(confirmation.quantity), + confirmation.symbol, + format_currency(confirmation.price), + format_currency(confirmation.gross_amount), + format_currency(confirmation.cash_balance), + ) +} + +fn format_sell_confirmation(confirmation: &TradeConfirmation) -> String { + let realized_gain = confirmation.realized_gain.unwrap_or(0.0); + format!( + "Sold {} {} @ {} for {}. Realized P/L: {}. Cash balance: {}.", + format_quantity(confirmation.quantity), + confirmation.symbol, + format_currency(confirmation.price), + format_currency(confirmation.gross_amount), + format_currency(realized_gain), + format_currency(confirmation.cash_balance), + ) +} + +fn format_cash_confirmation(confirmation: &CashConfirmation) -> String { + let action = match confirmation.kind { + TransactionKind::CashDeposit => "Deposited", + TransactionKind::CashWithdrawal => "Withdrew", + TransactionKind::Buy | TransactionKind::Sell => "Adjusted", + }; + + format!( + "{action} {}. Cash balance: {}.", + format_currency(confirmation.amount), + format_currency(confirmation.cash_balance), + ) +} + +fn format_currency(value: f64) -> String { + let sign = if value < 0.0 { "-" } else { "" }; + format!("{sign}${:.2}", value.abs()) +} + +fn format_quantity(value: f64) -> String { + let formatted = format!("{value:.4}"); + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -569,6 +820,10 @@ mod tests { use futures::future::BoxFuture; use super::TerminalCommandService; + use crate::portfolio::{ + CashConfirmation, PortfolioCommandError, PortfolioManagement, PortfolioStats, + PortfolioTransaction, TradeConfirmation, TransactionKind, + }; use crate::terminal::mock_data::load_mock_financial_data; use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError}; use crate::terminal::security_lookup::{ @@ -576,8 +831,8 @@ mod tests { }; use crate::terminal::{ CashFlowPanelData, Company, DividendsPanelData, EarningsPanelData, - ExecuteTerminalCommandRequest, FinancialsPanelData, Frequency, PanelPayload, SourceStatus, - TerminalCommandResponse, + ExecuteTerminalCommandRequest, FinancialsPanelData, Frequency, Holding, PanelPayload, + Portfolio, SourceStatus, TerminalCommandResponse, }; struct FakeSecurityLookup { @@ -650,6 +905,131 @@ mod tests { struct FakeEdgarLookup; + #[derive(Clone)] + struct FakePortfolioService { + portfolio: Result, + stats: Result, + history: Result, PortfolioCommandError>, + buy: Result, + sell: Result, + deposit_cash: Result, + withdraw_cash: Result, + } + + impl Default for FakePortfolioService { + fn default() -> Self { + Self { + portfolio: Ok(Portfolio { + holdings: Vec::new(), + total_value: 0.0, + day_change: 0.0, + day_change_percent: 0.0, + total_gain: 0.0, + total_gain_percent: 0.0, + cash_balance: Some(0.0), + invested_cost_basis: Some(0.0), + realized_gain: Some(0.0), + unrealized_gain: Some(0.0), + holdings_count: Some(0), + stale_pricing_symbols: Some(Vec::new()), + }), + stats: Ok(PortfolioStats { + cash_balance: 0.0, + holdings_count: 0, + invested_cost_basis: 0.0, + equities_market_value: 0.0, + total_portfolio_value: 0.0, + realized_gain: 0.0, + unrealized_gain: 0.0, + day_change: 0.0, + stale_pricing_symbols: Vec::new(), + }), + history: Ok(Vec::new()), + buy: Ok(TradeConfirmation { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + quantity: 1.0, + price: 100.0, + gross_amount: 100.0, + cash_balance: 900.0, + realized_gain: None, + }), + sell: Ok(TradeConfirmation { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + quantity: 1.0, + price: 110.0, + gross_amount: 110.0, + cash_balance: 1_010.0, + realized_gain: Some(10.0), + }), + deposit_cash: Ok(CashConfirmation { + amount: 1_000.0, + cash_balance: 1_000.0, + kind: TransactionKind::CashDeposit, + }), + withdraw_cash: Ok(CashConfirmation { + amount: 100.0, + cash_balance: 900.0, + kind: TransactionKind::CashWithdrawal, + }), + } + } + } + + impl PortfolioManagement for FakePortfolioService { + fn portfolio<'a>(&'a self) -> BoxFuture<'a, Result> { + Box::pin(async move { self.portfolio.clone() }) + } + + fn stats<'a>(&'a self) -> BoxFuture<'a, Result> { + Box::pin(async move { self.stats.clone() }) + } + + fn history<'a>( + &'a self, + limit: usize, + ) -> BoxFuture<'a, Result, PortfolioCommandError>> { + Box::pin(async move { + let mut transactions = self.history.clone()?; + transactions.truncate(limit); + Ok(transactions) + }) + } + + fn buy<'a>( + &'a self, + _symbol: &'a str, + _quantity: f64, + _price_override: Option, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { self.buy.clone() }) + } + + fn sell<'a>( + &'a self, + _symbol: &'a str, + _quantity: f64, + _price_override: Option, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { self.sell.clone() }) + } + + fn deposit_cash<'a>( + &'a self, + _amount: f64, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { self.deposit_cash.clone() }) + } + + fn withdraw_cash<'a>( + &'a self, + _amount: f64, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { self.withdraw_cash.clone() }) + } + } + impl EdgarDataLookup for FakeEdgarLookup { fn financials<'a>( &'a self, @@ -743,6 +1123,13 @@ mod tests { fn build_service( search_result: Result, SecurityLookupError>, + ) -> (TerminalCommandService, Arc) { + build_service_with_portfolio(search_result, Arc::new(FakePortfolioService::default())) + } + + fn build_service_with_portfolio( + search_result: Result, SecurityLookupError>, + portfolio_service: Arc, ) -> (TerminalCommandService, Arc) { let lookup = Arc::new(FakeSecurityLookup { search_result, @@ -756,6 +1143,7 @@ mod tests { load_mock_financial_data(), lookup.clone(), Arc::new(FakeEdgarLookup), + portfolio_service, Duration::ZERO, ), lookup, @@ -774,6 +1162,7 @@ mod tests { detail_calls: AtomicUsize::new(0), }), Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), Duration::ZERO, ) } @@ -943,6 +1332,7 @@ mod tests { load_mock_financial_data(), Arc::new(FakeSecurityLookup::successful(vec![])), Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), Duration::ZERO, ); @@ -985,6 +1375,7 @@ mod tests { load_mock_financial_data(), Arc::new(FakeSecurityLookup::successful(vec![])), Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), Duration::ZERO, ); @@ -1004,6 +1395,7 @@ mod tests { load_mock_financial_data(), Arc::new(FakeSecurityLookup::successful(vec![])), Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), Duration::ZERO, ); @@ -1023,6 +1415,7 @@ mod tests { load_mock_financial_data(), Arc::new(FakeSecurityLookup::successful(vec![])), Arc::new(FakeEdgarLookup), + Arc::new(FakePortfolioService::default()), Duration::ZERO, ); @@ -1039,6 +1432,235 @@ mod tests { } } + #[test] + fn buy_command_uses_provided_execution_price() { + let portfolio_service = Arc::new(FakePortfolioService { + buy: Ok(TradeConfirmation { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + quantity: 2.0, + price: 178.25, + gross_amount: 356.5, + cash_balance: 643.5, + realized_gain: None, + }), + ..FakePortfolioService::default() + }); + let (service, _) = build_service_with_portfolio(Ok(vec![]), portfolio_service); + + let response = execute(&service, "/buy AAPL 2 178.25"); + + match response { + TerminalCommandResponse::Text { content } => { + assert!(content.contains("Bought 2 AAPL @ $178.25")); + assert!(content.contains("Cash balance: $643.50")); + } + other => panic!("expected text response, got {other:?}"), + } + } + + #[test] + fn buy_command_falls_back_to_live_quote_when_price_is_omitted() { + let (service, _) = build_service_with_portfolio( + Ok(vec![]), + Arc::new(FakePortfolioService { + buy: Ok(TradeConfirmation { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + quantity: 2.0, + price: 100.0, + gross_amount: 200.0, + cash_balance: 800.0, + realized_gain: None, + }), + ..FakePortfolioService::default() + }), + ); + + let response = execute(&service, "/buy AAPL 2"); + + match response { + TerminalCommandResponse::Text { content } => { + assert!(content.contains("Bought 2 AAPL @ $100.00")); + } + other => panic!("expected text response, got {other:?}"), + } + } + + #[test] + fn buy_command_surfaces_quote_lookup_failure() { + let (service, _) = build_service_with_portfolio( + Ok(vec![]), + Arc::new(FakePortfolioService { + buy: Err(PortfolioCommandError::Quote( + crate::portfolio::PortfolioQuoteError::Unavailable { + symbol: "AAPL".to_string(), + detail: "quote endpoint timed out".to_string(), + }, + )), + ..FakePortfolioService::default() + }), + ); + + let response = execute(&service, "/buy AAPL 2"); + + match response { + TerminalCommandResponse::Text { content } => { + assert!(content.contains("quote unavailable for AAPL")); + } + other => panic!("expected text response, got {other:?}"), + } + } + + #[test] + fn sell_command_returns_realized_gain_text() { + let (service, _) = build_service_with_portfolio( + Ok(vec![]), + Arc::new(FakePortfolioService { + sell: Ok(TradeConfirmation { + symbol: "AAPL".to_string(), + company_name: "Apple Inc.".to_string(), + quantity: 1.5, + price: 110.0, + gross_amount: 165.0, + cash_balance: 965.0, + realized_gain: Some(15.0), + }), + ..FakePortfolioService::default() + }), + ); + + let response = execute(&service, "/sell AAPL 1.5 110"); + + match response { + TerminalCommandResponse::Text { content } => { + assert!(content.contains("Sold 1.5 AAPL @ $110.00")); + assert!(content.contains("Realized P/L: $15.00")); + } + other => panic!("expected text response, got {other:?}"), + } + } + + #[test] + fn portfolio_command_returns_panel_backed_by_portfolio_service() { + let portfolio_service = Arc::new(FakePortfolioService { + portfolio: Ok(Portfolio { + holdings: vec![Holding { + symbol: "AAPL".to_string(), + name: "Apple Inc.".to_string(), + quantity: 3.0, + avg_cost: 90.0, + current_price: 100.0, + current_value: 300.0, + gain_loss: 30.0, + gain_loss_percent: 11.11, + cost_basis: Some(270.0), + unrealized_gain: Some(30.0), + latest_trade_at: Some("2026-01-01T10:00:00Z".to_string()), + }], + total_value: 450.0, + day_change: 6.0, + day_change_percent: 1.35, + total_gain: 30.0, + total_gain_percent: 11.11, + cash_balance: Some(150.0), + invested_cost_basis: Some(270.0), + realized_gain: Some(10.0), + unrealized_gain: Some(30.0), + holdings_count: Some(1), + stale_pricing_symbols: Some(Vec::new()), + }), + ..FakePortfolioService::default() + }); + let (service, _) = build_service_with_portfolio(Ok(vec![]), portfolio_service); + + let response = execute(&service, "/portfolio"); + + match response { + TerminalCommandResponse::Panel { + panel: PanelPayload::Portfolio { data }, + } => { + assert_eq!(data.total_value, 450.0); + assert_eq!(data.holdings.len(), 1); + assert_eq!(data.holdings[0].symbol, "AAPL"); + } + other => panic!("expected portfolio panel, got {other:?}"), + } + } + + #[test] + fn portfolio_history_limits_results() { + let (service, _) = build_service_with_portfolio( + Ok(vec![]), + Arc::new(FakePortfolioService { + history: Ok(vec![ + PortfolioTransaction { + id: "3".to_string(), + kind: TransactionKind::Sell, + symbol: Some("AAPL".to_string()), + company_name: Some("Apple Inc.".to_string()), + quantity: Some(1.0), + price: Some(110.0), + gross_amount: 110.0, + fee: 0.0, + executed_at: "2026-01-03T10:00:00Z".to_string(), + note: None, + }, + PortfolioTransaction { + id: "2".to_string(), + kind: TransactionKind::Buy, + symbol: Some("MSFT".to_string()), + company_name: Some("Microsoft".to_string()), + quantity: Some(2.0), + price: Some(200.0), + gross_amount: 400.0, + fee: 0.0, + executed_at: "2026-01-02T10:00:00Z".to_string(), + note: None, + }, + PortfolioTransaction { + id: "1".to_string(), + kind: TransactionKind::CashDeposit, + symbol: None, + company_name: None, + quantity: None, + price: None, + gross_amount: 1_000.0, + fee: 0.0, + executed_at: "2026-01-01T10:00:00Z".to_string(), + note: None, + }, + ]), + ..FakePortfolioService::default() + }), + ); + + let response = execute(&service, "/portfolio history 2"); + + match response { + TerminalCommandResponse::Text { content } => { + assert!(content.contains("2026-01-03T10:00:00Z")); + assert!(content.contains("2026-01-02T10:00:00Z")); + assert!(!content.contains("2026-01-01T10:00:00Z")); + } + other => panic!("expected text response, got {other:?}"), + } + } + + #[test] + fn invalid_portfolio_command_forms_return_usage_text() { + let (service, _) = build_service(Ok(vec![])); + + let response = execute(&service, "/buy AAPL nope"); + + match response { + TerminalCommandResponse::Text { content } => { + assert_eq!(content, "Usage: /buy [ticker] [quantity] [price?]"); + } + other => panic!("expected text response, got {other:?}"), + } + } + #[test] fn direct_lookup_company_returns_live_company_snapshot() { let (service, lookup) = build_service(Ok(vec![])); diff --git a/MosaicIQ/src-tauri/src/terminal/mod.rs b/MosaicIQ/src-tauri/src/terminal/mod.rs index 2edd27c..0bd39e3 100644 --- a/MosaicIQ/src-tauri/src/terminal/mod.rs +++ b/MosaicIQ/src-tauri/src/terminal/mod.rs @@ -9,7 +9,7 @@ pub use command_service::TerminalCommandService; pub use types::{ CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint, CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod, - ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, - LookupCompanyRequest, MockFinancialData, PanelPayload, SourceStatus, StatementPeriod, - TerminalCommandResponse, + ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding, + LookupCompanyRequest, MockFinancialData, PanelPayload, Portfolio, SourceStatus, + StatementPeriod, TerminalCommandResponse, }; diff --git a/MosaicIQ/src-tauri/src/terminal/types.rs b/MosaicIQ/src-tauri/src/terminal/types.rs index 402561c..5d747ca 100644 --- a/MosaicIQ/src-tauri/src/terminal/types.rs +++ b/MosaicIQ/src-tauri/src/terminal/types.rs @@ -155,12 +155,18 @@ pub struct CompanyPricePoint { pub struct Holding { pub symbol: String, pub name: String, - pub quantity: u64, + pub quantity: f64, pub avg_cost: f64, pub current_price: f64, pub current_value: f64, pub gain_loss: f64, pub gain_loss_percent: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_basis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unrealized_gain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_trade_at: Option, } /// Portfolio summary and holdings data. @@ -173,6 +179,18 @@ pub struct Portfolio { pub day_change_percent: f64, pub total_gain: f64, pub total_gain_percent: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub cash_balance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub invested_cost_basis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub realized_gain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub unrealized_gain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub holdings_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stale_pricing_symbols: Option>, } /// News item serialized with an ISO timestamp for transport safety. diff --git a/MosaicIQ/src-tauri/src/test_support.rs b/MosaicIQ/src-tauri/src/test_support.rs new file mode 100644 index 0000000..dbc3401 --- /dev/null +++ b/MosaicIQ/src-tauri/src/test_support.rs @@ -0,0 +1,8 @@ +#[cfg(test)] +use std::sync::{Mutex, OnceLock}; + +#[cfg(test)] +pub(crate) fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} diff --git a/MosaicIQ/src/App.tsx b/MosaicIQ/src/App.tsx index b697a04..76dc735 100644 --- a/MosaicIQ/src/App.tsx +++ b/MosaicIQ/src/App.tsx @@ -1,8 +1,13 @@ import React, { useEffect, useCallback, useRef } from 'react'; import { Terminal } from './components/Terminal/Terminal'; +import { CommandInputHandle } from './components/Terminal/CommandInput'; import { Sidebar } from './components/Sidebar/Sidebar'; import { TabBar } from './components/TabBar/TabBar'; import { SettingsPage } from './components/Settings/SettingsPage'; +import { + isPortfolioCommand, + usePortfolioWorkflow, +} from './hooks/usePortfolioWorkflow'; import { useTabs } from './hooks/useTabs'; import { useTickerHistory } from './hooks/useTickerHistory'; import { createEntry } from './hooks/useTerminal'; @@ -13,10 +18,30 @@ import { } from './lib/tickerHistory'; import { terminalBridge } from './lib/terminalBridge'; import { AgentConfigStatus } from './types/agentSettings'; +import { Portfolio } from './types/financial'; +import { + PortfolioAction, + PortfolioActionDraft, + PortfolioActionSeed, +} from './types/terminal'; import './App.css'; type AppView = 'terminal' | 'settings'; +const isEditableTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) { + return false; + } + + const tagName = target.tagName; + return ( + target.isContentEditable || + tagName === 'INPUT' || + tagName === 'TEXTAREA' || + tagName === 'SELECT' + ); +}; + function App() { const tabs = useTabs(); const tickerHistory = useTickerHistory(); @@ -24,8 +49,11 @@ function App() { const [isProcessing, setIsProcessing] = React.useState(false); const [agentStatus, setAgentStatus] = React.useState(null); const [activeView, setActiveView] = React.useState('terminal'); + const portfolioWorkflow = usePortfolioWorkflow(); const commandHistoryRefs = useRef>({}); const commandIndexRefs = useRef>({}); + const commandInputRef = useRef(null); + const hasAutoLoadedPortfolioRef = useRef(false); const getActiveHistory = () => { return tabs.activeWorkspace?.history || []; @@ -63,14 +91,34 @@ function App() { try { await refreshAgentStatus(); } finally { + portfolioWorkflow.exitPortfolioMode(tabs.activeWorkspaceId); setActiveView('settings'); } - }, [refreshAgentStatus]); + }, [portfolioWorkflow, refreshAgentStatus, tabs.activeWorkspaceId]); const handleReturnToTerminal = useCallback(() => { setActiveView('terminal'); }, []); + const handleStartPortfolioAction = useCallback( + (action: PortfolioAction, seed?: PortfolioActionSeed) => { + setActiveView('terminal'); + portfolioWorkflow.startPortfolioAction(tabs.activeWorkspaceId, action, seed); + }, + [portfolioWorkflow, tabs.activeWorkspaceId], + ); + + const handleUpdatePortfolioDraft = useCallback( + (patch: Partial) => { + portfolioWorkflow.updateDraft(tabs.activeWorkspaceId, patch); + }, + [portfolioWorkflow, tabs.activeWorkspaceId], + ); + + const handleClearPortfolioAction = useCallback(() => { + portfolioWorkflow.clearPortfolioAction(tabs.activeWorkspaceId); + }, [portfolioWorkflow, tabs.activeWorkspaceId]); + const handleCommand = useCallback(async (command: string) => { const trimmedCommand = command.trim(); const latestTicker = tickerHistory.history[0]?.company.symbol; @@ -94,6 +142,9 @@ function App() { pushCommandHistory(workspaceId, resolvedCommand); setIsProcessing(true); + if (isPortfolioCommand(resolvedCommand)) { + portfolioWorkflow.noteCommandStart(workspaceId, resolvedCommand); + } if (isSlashCommand) { // Slash commands intentionally reset the transcript and session before rendering a fresh result. @@ -116,6 +167,8 @@ function App() { ), ); + portfolioWorkflow.noteCommandResponse(workspaceId, resolvedCommand, response); + const tickerSymbol = extractTickerSymbolFromResponse(response); if (tickerSymbol) { void tickerHistory.recordTicker(tickerSymbol); @@ -128,6 +181,7 @@ function App() { content: error instanceof Error ? error.message : 'Command execution failed.', }), ); + portfolioWorkflow.noteCommandError(workspaceId, resolvedCommand); } finally { setIsProcessing(false); } @@ -191,7 +245,20 @@ function App() { })); setIsProcessing(false); } - }, [tabs, clearWorkspaceSession, pushCommandHistory, tickerHistory]); + }, [ + tabs, + clearWorkspaceSession, + pushCommandHistory, + tickerHistory, + portfolioWorkflow, + ]); + + const runCommand = useCallback( + (command: string) => { + void handleCommand(command); + }, + [handleCommand], + ); // Command history navigation // Accesses from END of array (most recent commands first) @@ -231,6 +298,8 @@ function App() { }, [tabs.activeWorkspaceId]); const outputRef = useRef(null); + const activePortfolioWorkflow = portfolioWorkflow.readWorkflow(tabs.activeWorkspaceId); + const portfolioSnapshot: Portfolio | null = activePortfolioWorkflow.portfolioSnapshot; useEffect(() => { let active = true; @@ -271,24 +340,42 @@ function App() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if ( + activeView === 'terminal' && + !isProcessing && + e.key === '/' && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !isEditableTarget(e.target) + ) { + e.preventDefault(); + commandInputRef.current?.focusWithText('/'); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key === 't') { e.preventDefault(); handleCreateWorkspace(); + return; } if ((e.metaKey || e.ctrlKey) && e.key === 'w') { e.preventDefault(); tabs.closeWorkspace(tabs.activeWorkspaceId); + return; } if ((e.metaKey || e.ctrlKey) && e.key === 'b') { e.preventDefault(); setSidebarOpen(prev => !prev); + return; } if ((e.metaKey || e.ctrlKey) && e.key === 'l') { e.preventDefault(); clearTerminal(); + return; } if ((e.metaKey || e.ctrlKey) && e.key === ',') { @@ -299,7 +386,14 @@ function App() { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [tabs, clearTerminal, handleCreateWorkspace, handleOpenSettings]); + }, [ + activeView, + clearTerminal, + handleCreateWorkspace, + handleOpenSettings, + isProcessing, + tabs, + ]); useEffect(() => { return () => { @@ -308,6 +402,19 @@ function App() { }; }, []); + useEffect(() => { + if (hasAutoLoadedPortfolioRef.current) { + return; + } + + if (activeView !== 'terminal' || isProcessing) { + return; + } + + hasAutoLoadedPortfolioRef.current = true; + void handleCommand('/portfolio'); + }, [activeView, handleCommand, isProcessing]); + const tabBarTabs = tabs.workspaces.map(w => ({ id: w.id, name: w.name, @@ -325,6 +432,7 @@ function App() { }} onToggle={() => setSidebarOpen(!sidebarOpen)} onCommand={handleCommand} + portfolio={portfolioSnapshot} tickerHistory={tickerHistory.history} isTickerHistoryLoaded={tickerHistory.isLoaded} /> @@ -355,10 +463,16 @@ function App() { history={getActiveHistory()} isProcessing={isProcessing} outputRef={outputRef} + inputRef={commandInputRef} onSubmit={handleCommand} + onRunCommand={runCommand} + onStartPortfolioAction={handleStartPortfolioAction} + onUpdatePortfolioDraft={handleUpdatePortfolioDraft} + onClearPortfolioAction={handleClearPortfolioAction} getPreviousCommand={getPreviousCommand} getNextCommand={getNextCommand} resetCommandIndex={resetCommandIndex} + portfolioWorkflow={activePortfolioWorkflow} /> )} diff --git a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx index 93bab8b..f7be0df 100644 --- a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx +++ b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx @@ -1,16 +1,38 @@ import React from 'react'; import { Portfolio } from '../../types/financial'; +import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal'; import { MetricGrid } from '../ui'; interface PortfolioPanelProps { portfolio: Portfolio; + onRunCommand: (command: string) => void; + onStartAction: (action: PortfolioAction, seed?: PortfolioActionSeed) => void; + onSelectHolding?: (symbol: string) => void; } -const formatCurrency = (value: number) => { - return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +const formatCurrency = (value: number) => + `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + +const formatSignedCurrency = (value: number) => + `${value >= 0 ? '+' : '-'}${formatCurrency(Math.abs(value))}`; + +const formatQuantity = (value: number) => { + const rendered = value.toFixed(4); + return rendered.replace(/\.?0+$/, ''); }; -export const PortfolioPanel: React.FC = ({ portfolio }) => { +const actionButtonClass = + 'border border-[#2a2a2a] bg-[#161616] px-3 py-1.5 text-xs font-mono text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]'; + +export const PortfolioPanel: React.FC = ({ + portfolio, + onRunCommand, + onStartAction, + onSelectHolding, +}) => { const totalGainPositive = portfolio.totalGain >= 0; const dayChangePositive = portfolio.dayChange >= 0; @@ -22,68 +44,228 @@ export const PortfolioPanel: React.FC = ({ portfolio }) => }, { label: "Today's Change", - value: `${dayChangePositive ? '+' : ''}${formatCurrency(portfolio.dayChange)} (${dayChangePositive ? '+' : ''}${portfolio.dayChangePercent.toFixed(2)}%)`, + value: `${dayChangePositive ? '+' : ''}${formatCurrency(Math.abs(portfolio.dayChange))} (${dayChangePositive ? '+' : ''}${portfolio.dayChangePercent.toFixed(2)}%)`, sentiment: (dayChangePositive ? 'positive' : 'negative') as 'positive' | 'negative', }, { - label: 'Total Gain/Loss', - value: `${totalGainPositive ? '+' : ''}${formatCurrency(portfolio.totalGain)} (${totalGainPositive ? '+' : ''}${portfolio.totalGainPercent.toFixed(2)}%)`, + label: 'Unrealized Gain/Loss', + value: `${totalGainPositive ? '+' : ''}${formatCurrency(Math.abs(portfolio.totalGain))} (${totalGainPositive ? '+' : ''}${portfolio.totalGainPercent.toFixed(2)}%)`, sentiment: (totalGainPositive ? 'positive' : 'negative') as 'positive' | 'negative', }, + { + label: 'Cash Balance', + value: formatCurrency(portfolio.cashBalance ?? 0), + }, + { + label: 'Realized Gain', + value: formatCurrency(portfolio.realizedGain ?? 0), + sentiment: + ((portfolio.realizedGain ?? 0) >= 0 ? 'positive' : 'negative') as 'positive' | 'negative', + }, + { + label: 'Holdings', + value: String(portfolio.holdingsCount ?? portfolio.holdings.length), + }, ]; return (
- {/* Header */} -
-

Portfolio Summary

+
+
+
+

Portfolio Overview

+

+ Review positions, run portfolio commands, and open trade workflows from here. +

+
+
+ {portfolio.stalePricingSymbols?.length + ? `Stale pricing fallback: ${portfolio.stalePricingSymbols.join(', ')}` + : 'Live pricing loaded'} +
+
+ +
+ + + + + + +
- {/* Summary Stats - Inline metric grid */}
- {/* Holdings Table - Minimal */} -
-

Holdings ({portfolio.holdings.length})

-
- - - - - - - - - - - - - {portfolio.holdings.map((holding) => { - const gainPositive = holding.gainLoss >= 0; - return ( - - - - - - - - - ); - })} - -
SymbolQtyAvg CostCurrentValueGain/Loss
-
{holding.symbol}
-
{holding.name}
-
{holding.quantity}{formatCurrency(holding.avgCost)}{formatCurrency(holding.currentPrice)}{formatCurrency(holding.currentValue)} - {gainPositive ? '+' : ''}{formatCurrency(holding.gainLoss)} -
- ({gainPositive ? '+' : ''}{holding.gainLossPercent.toFixed(2)}%) -
-
+
+
+

+ Holdings ({portfolio.holdings.length}) +

+
+ + {portfolio.holdings.length === 0 ? ( +
+
No holdings yet.
+
+ Deposit cash first, then open a buy workflow to record your first position. +
+
+ + +
+
+ ) : ( +
+ + + + + + + + + + + + + + {portfolio.holdings.map((holding) => { + const gainPositive = holding.gainLoss >= 0; + + return ( + + + + + + + + + + ); + })} + +
+ Symbol + + Qty + + Avg Cost + + Current + + Value + + Gain/Loss + + Actions +
+ + + {formatQuantity(holding.quantity)} + + {formatCurrency(holding.avgCost)} + + {formatCurrency(holding.currentPrice)} + + {formatCurrency(holding.currentValue)} + + {formatSignedCurrency(holding.gainLoss)} +
+ ({gainPositive ? '+' : ''} + {holding.gainLossPercent.toFixed(2)}%) +
+
+
+ + +
+
+
+ )}
); diff --git a/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx b/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx index 2d7fcea..d22c292 100644 --- a/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx +++ b/MosaicIQ/src/components/Sidebar/PortfolioSummary.tsx @@ -1,50 +1,88 @@ import React, { useState } from 'react'; +import { ChevronDown, ChevronUp, TrendingDown, TrendingUp } from 'lucide-react'; import { Portfolio } from '../../types/financial'; -import { ChevronDown, ChevronUp, TrendingUp, TrendingDown } from 'lucide-react'; interface PortfolioSummaryProps { - portfolio: Portfolio; + portfolio: Portfolio | null; + onLoadPortfolio: () => void; } -export const PortfolioSummary: React.FC = ({ portfolio }) => { +export const PortfolioSummary: React.FC = ({ + portfolio, + onLoadPortfolio, +}) => { const [isExpanded, setIsExpanded] = useState(false); - const INITIAL_HOLDINGS_COUNT = 3; + const initialHoldingsCount = 3; const formatCurrency = (value: number) => { - if (value >= 1000) { + if (Math.abs(value) >= 1000) { return `$${(value / 1000).toFixed(1)}K`; } return `$${value.toFixed(0)}`; }; + if (!portfolio) { + return ( +
+
+ Portfolio +
+
+ Load your latest portfolio snapshot into the sidebar. +
+ +
+ ); + } + const isPositive = portfolio.dayChange >= 0; - const visibleHoldings = isExpanded ? portfolio.holdings : portfolio.holdings.slice(0, INITIAL_HOLDINGS_COUNT); - const hasMoreHoldings = portfolio.holdings.length > INITIAL_HOLDINGS_COUNT; + const visibleHoldings = isExpanded + ? portfolio.holdings + : portfolio.holdings.slice(0, initialHoldingsCount); + const hasMoreHoldings = portfolio.holdings.length > initialHoldingsCount; return ( -
+
- {/* Holdings List */} -
+
{visibleHoldings.map((holding) => { const holdingPositive = holding.gainLoss >= 0; return ( -
- + {holding.symbol} {holding.quantity} {holding.quantity === 1 ? 'share' : 'shares'}
- - {holdingPositive ? '+' : ''}{holding.gainLossPercent.toFixed(1)}% + + {holdingPositive ? '+' : ''} + {holding.gainLossPercent.toFixed(1)}% -
+ ); })} - {hasMoreHoldings && !isExpanded && ( + {hasMoreHoldings && !isExpanded ? ( - )} + ) : null} - {isExpanded && hasMoreHoldings && ( + {isExpanded && hasMoreHoldings ? ( - )} + ) : null}
); diff --git a/MosaicIQ/src/components/Sidebar/Sidebar.tsx b/MosaicIQ/src/components/Sidebar/Sidebar.tsx index 1d65a1e..5676132 100644 --- a/MosaicIQ/src/components/Sidebar/Sidebar.tsx +++ b/MosaicIQ/src/components/Sidebar/Sidebar.tsx @@ -3,6 +3,7 @@ import { Settings, ChevronRight, ChevronLeft, Briefcase } from 'lucide-react'; import { PortfolioSummary } from './PortfolioSummary'; import { TickerHistory } from './TickerHistory'; import { useRealFinancialData } from '../../hooks/useRealFinancialData'; +import { Portfolio } from '../../types/financial'; import { TickerHistoryEntry } from '../../types/terminal'; interface SidebarProps { @@ -13,6 +14,7 @@ interface SidebarProps { onToggle: () => void; tickerHistory: TickerHistoryEntry[]; isTickerHistoryLoaded: boolean; + portfolio: Portfolio | null; } type SidebarState = 'closed' | 'minimized' | 'open'; @@ -25,10 +27,10 @@ export const Sidebar: React.FC = ({ onToggle, tickerHistory, isTickerHistoryLoaded, + portfolio, }) => { - const { getAllCompanies, getPortfolio } = useRealFinancialData(); + const { getAllCompanies } = useRealFinancialData(); const companies = getAllCompanies(); - const portfolio = getPortfolio(); const handleCompanyClick = (symbol: string) => { onCommand(`/search ${symbol}`); @@ -150,7 +152,10 @@ export const Sidebar: React.FC = ({ {/* Content */}
{/* Portfolio Summary */} - + onCommand('/portfolio')} + /> {/* Ticker History - shows only when loaded */} {isTickerHistoryLoaded && ( diff --git a/MosaicIQ/src/components/Terminal/CommandInput.tsx b/MosaicIQ/src/components/Terminal/CommandInput.tsx index 0913e1c..34fb13c 100644 --- a/MosaicIQ/src/components/Terminal/CommandInput.tsx +++ b/MosaicIQ/src/components/Terminal/CommandInput.tsx @@ -1,161 +1,433 @@ -import React, { useState, useRef, useEffect, KeyboardEvent } from 'react'; +import React, { + KeyboardEvent, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { + CommandSuggestion, + PortfolioAction, + PortfolioActionDraft, +} from '../../types/terminal'; interface CommandInputProps { onSubmit: (command: string) => void; + onStartPortfolioAction: (action: PortfolioAction) => void; + onUpdatePortfolioDraft: (patch: Partial) => void; + onClearPortfolioAction: () => void; isProcessing: boolean; getPreviousCommand: () => string | null; getNextCommand: () => string | null; resetCommandIndex: () => void; + portfolioMode: boolean; + activePortfolioAction: PortfolioAction | null; + portfolioDraft: PortfolioActionDraft; + lastPortfolioCommand: string | null; placeholder?: string; } -export const CommandInput: React.FC = ({ - onSubmit, - isProcessing, - getPreviousCommand, - getNextCommand, - resetCommandIndex, - placeholder = 'Type command or natural language query...' -}) => { - const [input, setInput] = useState(''); - const [showSuggestions, setShowSuggestions] = useState(false); - const inputRef = useRef(null); - const suggestionsRef = useRef(null); +export interface CommandInputHandle { + focusWithText: (text: string) => void; +} - const suggestions = [ - { command: '/search', description: 'Search live security data' }, - { command: '/fa', description: 'SEC financial statements' }, - { command: '/cf', description: 'SEC cash flow summary' }, - { command: '/dvd', description: 'SEC dividends history' }, - { command: '/em', description: 'SEC earnings history' }, - { command: '/portfolio', description: 'Show portfolio' }, - { command: '/news', description: 'Market news' }, - { command: '/analyze', description: 'AI analysis' }, - { command: '/help', description: 'List commands' } - ]; +const SUGGESTIONS: CommandSuggestion[] = [ + { command: '/search', description: 'Search live security data', category: 'search' }, + { command: '/portfolio', description: 'Show portfolio overview', category: 'portfolio' }, + { command: '/portfolio stats', description: 'Show portfolio statistics', category: 'portfolio' }, + { command: '/portfolio history', description: 'Show recent portfolio transactions', category: 'portfolio' }, + { command: '/buy', description: 'Create a buy order', category: 'portfolio' }, + { command: '/sell', description: 'Create a sell order', category: 'portfolio' }, + { command: '/cash deposit', description: 'Add cash to the portfolio', category: 'cash' }, + { command: '/cash withdraw', description: 'Withdraw cash from the portfolio', category: 'cash' }, + { command: '/fa', description: 'SEC financial statements', category: 'financials' }, + { command: '/cf', description: 'SEC cash flow summary', category: 'cashflow' }, + { command: '/dvd', description: 'SEC dividends history', category: 'dividends' }, + { command: '/em', description: 'SEC earnings history', category: 'earnings' }, + { command: '/news', description: 'Market news', category: 'news' }, + { command: '/analyze', description: 'AI analysis', category: 'analysis' }, + { command: '/help', description: 'List commands', category: 'system' }, +]; - useEffect(() => { - if (!isProcessing) { - inputRef.current?.focus(); +const ACTION_LABELS: Record<'buy' | 'sell' | 'deposit' | 'withdraw', string> = { + buy: 'Buy', + sell: 'Sell', + deposit: 'Deposit', + withdraw: 'Withdraw', +}; + +const isActionComposer = ( + action: PortfolioAction | null, +): action is 'buy' | 'sell' | 'deposit' | 'withdraw' => + action === 'buy' || action === 'sell' || action === 'deposit' || action === 'withdraw'; + +const suggestionToAction = ( + command: string, +): 'buy' | 'sell' | 'deposit' | 'withdraw' | null => { + switch (command) { + case '/buy': + return 'buy'; + case '/sell': + return 'sell'; + case '/cash deposit': + return 'deposit'; + case '/cash withdraw': + return 'withdraw'; + default: + return null; + } +}; + +const buildGeneratedCommand = ( + action: 'buy' | 'sell' | 'deposit' | 'withdraw', + draft: PortfolioActionDraft, +) => { + if (action === 'buy' || action === 'sell') { + const symbol = draft.symbol.trim().toUpperCase(); + const quantity = draft.quantity.trim(); + const price = draft.price.trim(); + + if (!symbol) { + return { command: `/${action} [ticker] [quantity] [price?]`, error: 'Ticker symbol is required.' }; + } + if (!quantity) { + return { command: `/${action} ${symbol} [quantity] [price?]`, error: 'Quantity is required.' }; + } + if (Number.isNaN(Number(quantity)) || Number(quantity) <= 0) { + return { command: `/${action} ${symbol} ${quantity}`, error: 'Quantity must be greater than zero.' }; + } + if (price && (Number.isNaN(Number(price)) || Number(price) <= 0)) { + return { command: `/${action} ${symbol} ${quantity} ${price}`, error: 'Price must be greater than zero when provided.' }; } - }, [isProcessing]); - const handleSubmit = () => { - const trimmed = input.trim(); - if (trimmed && !isProcessing) { - onSubmit(trimmed); + return { + command: price ? `/${action} ${symbol} ${quantity} ${price}` : `/${action} ${symbol} ${quantity}`, + error: null, + }; + } + + const amount = draft.amount.trim(); + if (!amount) { + return { + command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} [amount]`, + error: 'Amount is required.', + }; + } + if (Number.isNaN(Number(amount)) || Number(amount) <= 0) { + return { + command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`, + error: 'Amount must be greater than zero.', + }; + } + + return { + command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`, + error: null, + }; +}; + +export const CommandInput = React.forwardRef( + ( + { + onSubmit, + onStartPortfolioAction, + onUpdatePortfolioDraft, + onClearPortfolioAction, + isProcessing, + getPreviousCommand, + getNextCommand, + resetCommandIndex, + portfolioMode, + activePortfolioAction, + portfolioDraft, + lastPortfolioCommand, + placeholder = 'Type command or natural language query...', + }, + ref, + ) => { + const [input, setInput] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + const actionPrimaryFieldRef = useRef(null); + + const actionComposerActive = isActionComposer(activePortfolioAction); + const actionMeta = useMemo( + () => + actionComposerActive + ? buildGeneratedCommand(activePortfolioAction, portfolioDraft) + : null, + [actionComposerActive, activePortfolioAction, portfolioDraft], + ); + + useEffect(() => { + if (!isProcessing) { + if (actionComposerActive) { + actionPrimaryFieldRef.current?.focus(); + } else { + inputRef.current?.focus(); + } + } + }, [actionComposerActive, isProcessing]); + + useImperativeHandle( + ref, + () => ({ + focusWithText: (text: string) => { + setInput(text); + setShowSuggestions(text.startsWith('/')); + resetCommandIndex(); + inputRef.current?.focus(); + }, + }), + [resetCommandIndex], + ); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (trimmed && !isProcessing) { + onSubmit(trimmed); + setInput(''); + setShowSuggestions(false); + resetCommandIndex(); + } + }; + + const handleActionSubmit = () => { + if (!actionComposerActive || !actionMeta || actionMeta.error || isProcessing) { + return; + } + + onSubmit(actionMeta.command); + onClearPortfolioAction(); setInput(''); setShowSuggestions(false); resetCommandIndex(); - } - }; + }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSubmit(); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - const prev = getPreviousCommand(); - if (prev !== null) { - setInput(prev); + const activateSuggestion = (command: string) => { + const action = suggestionToAction(command); + if (action) { + onStartPortfolioAction(action); + setInput(''); + setShowSuggestions(false); + resetCommandIndex(); + return; } - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - const next = getNextCommand(); - if (next !== null) { - setInput(next); - } - } else if (e.key === 'Tab') { - e.preventDefault(); - // Simple autocomplete - if (input.startsWith('/')) { - const match = suggestions.find(s => s.command.startsWith(input)); - if (match) { - setInput(match.command + ' '); + + setInput(`${command} `); + setShowSuggestions(false); + inputRef.current?.focus(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSubmit(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + const previous = getPreviousCommand(); + if (previous !== null) { + setInput(previous); } + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + const next = getNextCommand(); + if (next !== null) { + setInput(next); + } + } else if (event.key === 'Tab') { + event.preventDefault(); + if (input.startsWith('/')) { + const match = SUGGESTIONS.find((suggestion) => suggestion.command.startsWith(input)); + if (match) { + activateSuggestion(match.command); + } + } + } else if (event.key === 'Escape') { + setShowSuggestions(false); } - } else if (e.key === 'Escape') { - setShowSuggestions(false); - } - }; + }; - const handleInputChange = (e: React.ChangeEvent) => { - setInput(e.target.value); - if (e.target.value.startsWith('/')) { - setShowSuggestions(true); - } else { - setShowSuggestions(false); - } - }; + const handleInputChange = (event: React.ChangeEvent) => { + setInput(event.target.value); + setShowSuggestions(event.target.value.startsWith('/')); + }; - const handleSuggestionClick = (command: string) => { - setInput(command + ' '); - setShowSuggestions(false); - inputRef.current?.focus(); - }; + const suggestionMatches = SUGGESTIONS.filter( + (suggestion) => !input || suggestion.command.startsWith(input), + ); - return ( -
- {/* Suggestions Dropdown */} - {showSuggestions && suggestions.some(s => s.command.startsWith(input)) && ( -
- {suggestions - .filter(s => !input || s.command.startsWith(input)) - .map((suggestion, idx) => ( + const helperText = actionComposerActive + ? `Running ${actionMeta?.command ?? lastPortfolioCommand ?? '/portfolio'} will keep raw terminal output in view.` + : portfolioMode + ? 'Portfolio tools stay active while you move between overview, history, stats, and trade actions.' + : 'Use /portfolio to load interactive portfolio tools.'; + + return ( +
+ {actionComposerActive && actionMeta ? ( +
+
+
+ {ACTION_LABELS[activePortfolioAction]} +
+
+ +
+ {(activePortfolioAction === 'buy' || activePortfolioAction === 'sell') && ( + <> + + + + + )} + + {(activePortfolioAction === 'deposit' || activePortfolioAction === 'withdraw') && ( + + )} +
+ +
+
+ Command Preview +
+
+ {actionMeta.command} +
+
+
+ {actionMeta.error} +
+ +
+
+
+ ) : null} + + {showSuggestions && suggestionMatches.length > 0 ? ( +
+ {suggestionMatches.map((suggestion) => ( + ))} -
- )} - - {/* Input Bar */} -
- {/* Prompt */} - {'>'} - - {/* Input */} - - - {/* Processing Indicator */} - {isProcessing && ( -
- - -
- )} + ) : null} - {/* Submit Hint */} - {!isProcessing && input && ( - - )} -
+
+
+ {'>'} + - {/* Command hint */} -
- ↑/↓ history - Tab autocomplete - Ctrl+L clear + {isProcessing ? ( +
+ + + +
+ ) : input ? ( + + ) : null} +
+
+ +
+ ↑/↓ history + Tab autocomplete + Ctrl+L clear + {helperText} +
-
- ); -}; + ); + }, +); + +CommandInput.displayName = 'CommandInput'; diff --git a/MosaicIQ/src/components/Terminal/Terminal.tsx b/MosaicIQ/src/components/Terminal/Terminal.tsx index d088c2b..0e735c3 100644 --- a/MosaicIQ/src/components/Terminal/Terminal.tsx +++ b/MosaicIQ/src/components/Terminal/Terminal.tsx @@ -1,26 +1,47 @@ import React from 'react'; -import { TerminalEntry } from '../../types/terminal'; +import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow'; +import { + PortfolioAction, + PortfolioActionDraft, + PortfolioActionSeed, + TerminalEntry, +} from '../../types/terminal'; import { TerminalOutput } from './TerminalOutput'; -import { CommandInput } from './CommandInput'; +import { CommandInput, CommandInputHandle } from './CommandInput'; interface TerminalProps { history: TerminalEntry[]; isProcessing: boolean; outputRef: React.RefObject; + inputRef: React.RefObject; onSubmit: (command: string) => void; + onRunCommand: (command: string) => void; + onStartPortfolioAction: ( + action: PortfolioAction, + seed?: PortfolioActionSeed, + ) => void; + onUpdatePortfolioDraft: (patch: Partial) => void; + onClearPortfolioAction: () => void; getPreviousCommand: () => string | null; getNextCommand: () => string | null; resetCommandIndex: () => void; + portfolioWorkflow: PortfolioWorkflowState; } export const Terminal: React.FC = ({ history, isProcessing, outputRef, + inputRef, onSubmit, + onRunCommand, + onStartPortfolioAction, + onUpdatePortfolioDraft, + onClearPortfolioAction, getPreviousCommand, getNextCommand, - resetCommandIndex + resetCommandIndex, + portfolioWorkflow, }) => { return (
@@ -30,16 +51,29 @@ export const Terminal: React.FC = ({ {/* Command Input */}
{/* Terminal Output */} - +
); }; diff --git a/MosaicIQ/src/components/Terminal/TerminalOutput.tsx b/MosaicIQ/src/components/Terminal/TerminalOutput.tsx index 007a625..ab8d124 100644 --- a/MosaicIQ/src/components/Terminal/TerminalOutput.tsx +++ b/MosaicIQ/src/components/Terminal/TerminalOutput.tsx @@ -1,5 +1,10 @@ import React, { useEffect } from 'react'; -import { PanelPayload, TerminalEntry } from '../../types/terminal'; +import { + PanelPayload, + PortfolioAction, + PortfolioActionSeed, + TerminalEntry, +} from '../../types/terminal'; import { CompanyPanel } from '../Panels/CompanyPanel'; import { PortfolioPanel } from '../Panels/PortfolioPanel'; import { NewsPanel } from '../Panels/NewsPanel'; @@ -13,9 +18,19 @@ import { EarningsPanel } from '../Panels/EarningsPanel'; interface TerminalOutputProps { history: TerminalEntry[]; outputRef: React.RefObject; + onRunCommand: (command: string) => void; + onStartPortfolioAction: ( + action: PortfolioAction, + seed?: PortfolioActionSeed, + ) => void; } -export const TerminalOutput: React.FC = ({ history, outputRef }) => { +export const TerminalOutput: React.FC = ({ + history, + outputRef, + onRunCommand, + onStartPortfolioAction, +}) => { // Auto-scroll to bottom when history changes useEffect(() => { if (outputRef.current) { @@ -99,7 +114,14 @@ export const TerminalOutput: React.FC = ({ history, outputR case 'error': return ; case 'portfolio': - return ; + return ( + onRunCommand(`/search ${symbol}`)} + /> + ); case 'news': return ; case 'analysis': @@ -163,7 +185,7 @@ export const TerminalOutput: React.FC = ({ history, outputR {history.length === 0 && (
-
Terminal ready. Type a command to get started.
+
Terminal ready. Type a command or load /portfolio to open portfolio tools.
)}
diff --git a/MosaicIQ/src/hooks/usePortfolioWorkflow.ts b/MosaicIQ/src/hooks/usePortfolioWorkflow.ts new file mode 100644 index 0000000..0fea91c --- /dev/null +++ b/MosaicIQ/src/hooks/usePortfolioWorkflow.ts @@ -0,0 +1,247 @@ +import { useCallback, useState } from 'react'; +import { Portfolio } from '../types/financial'; +import { + PortfolioAction, + PortfolioActionDraft, + PortfolioActionSeed, + ResolvedTerminalCommandResponse, +} from '../types/terminal'; + +export type PortfolioSnapshotStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export interface PortfolioWorkflowState { + isPortfolioMode: boolean; + activePortfolioAction: PortfolioAction | null; + portfolioSnapshot: Portfolio | null; + portfolioSnapshotStatus: PortfolioSnapshotStatus; + draft: PortfolioActionDraft; + lastPortfolioCommand: string | null; +} + +const EMPTY_DRAFT: PortfolioActionDraft = { + symbol: '', + quantity: '', + price: '', + amount: '', +}; + +const createDefaultState = (): PortfolioWorkflowState => ({ + isPortfolioMode: false, + activePortfolioAction: null, + portfolioSnapshot: null, + portfolioSnapshotStatus: 'idle', + draft: EMPTY_DRAFT, + lastPortfolioCommand: null, +}); + +const mergeDraft = ( + draft: PortfolioActionDraft, + seed?: PortfolioActionSeed, +): PortfolioActionDraft => ({ + symbol: seed?.symbol ?? draft.symbol, + quantity: seed?.quantity ?? draft.quantity, + price: seed?.price ?? draft.price, + amount: seed?.amount ?? draft.amount, +}); + +const commandToPortfolioAction = (command: string): PortfolioAction | null => { + const normalized = command.trim().toLowerCase(); + + if (normalized === '/portfolio') { + return 'overview'; + } + if (normalized === '/portfolio stats') { + return 'stats'; + } + if (normalized.startsWith('/portfolio history')) { + return 'history'; + } + if (normalized.startsWith('/buy ')) { + return 'buy'; + } + if (normalized.startsWith('/sell ')) { + return 'sell'; + } + if (normalized.startsWith('/cash deposit')) { + return 'deposit'; + } + if (normalized.startsWith('/cash withdraw')) { + return 'withdraw'; + } + + return null; +}; + +export const isPortfolioCommand = (command: string): boolean => + commandToPortfolioAction(command) !== null; + +export const usePortfolioWorkflow = () => { + const [workflows, setWorkflows] = useState>({}); + + const readWorkflow = useCallback( + (workspaceId: string): PortfolioWorkflowState => + workflows[workspaceId] ?? createDefaultState(), + [workflows], + ); + + const updateWorkflow = useCallback( + ( + workspaceId: string, + updater: (current: PortfolioWorkflowState) => PortfolioWorkflowState, + ) => { + setWorkflows((current) => { + const nextCurrent = current[workspaceId] ?? createDefaultState(); + return { + ...current, + [workspaceId]: updater(nextCurrent), + }; + }); + }, + [], + ); + + const noteCommandStart = useCallback( + (workspaceId: string, command: string) => { + const action = commandToPortfolioAction(command); + if (!action) { + return; + } + + updateWorkflow(workspaceId, (current) => ({ + ...current, + isPortfolioMode: true, + activePortfolioAction: action, + lastPortfolioCommand: command, + portfolioSnapshotStatus: + action === 'overview' ? 'loading' : current.portfolioSnapshotStatus, + })); + }, + [updateWorkflow], + ); + + const noteCommandResponse = useCallback( + ( + workspaceId: string, + command: string, + response: ResolvedTerminalCommandResponse, + ) => { + const action = commandToPortfolioAction(command); + if (!action) { + return; + } + + updateWorkflow(workspaceId, (current) => { + if (response.kind === 'panel' && response.panel.type === 'portfolio') { + return { + ...current, + isPortfolioMode: true, + activePortfolioAction: 'overview', + portfolioSnapshot: response.panel.data, + portfolioSnapshotStatus: 'ready', + lastPortfolioCommand: command, + }; + } + + const completedTradeAction = + action === 'buy' || + action === 'sell' || + action === 'deposit' || + action === 'withdraw'; + + return { + ...current, + isPortfolioMode: true, + activePortfolioAction: completedTradeAction ? null : action, + lastPortfolioCommand: command, + }; + }); + }, + [updateWorkflow], + ); + + const noteCommandError = useCallback( + (workspaceId: string, command: string) => { + const action = commandToPortfolioAction(command); + if (!action) { + return; + } + + updateWorkflow(workspaceId, (current) => ({ + ...current, + isPortfolioMode: true, + activePortfolioAction: action, + portfolioSnapshotStatus: + action === 'overview' ? 'error' : current.portfolioSnapshotStatus, + lastPortfolioCommand: command, + })); + }, + [updateWorkflow], + ); + + const startPortfolioAction = useCallback( + (workspaceId: string, action: PortfolioAction, seed?: PortfolioActionSeed) => { + updateWorkflow(workspaceId, (current) => ({ + ...current, + isPortfolioMode: true, + activePortfolioAction: action, + draft: mergeDraft( + { + ...EMPTY_DRAFT, + ...(action === 'buy' || action === 'sell' + ? { symbol: current.draft.symbol } + : {}), + }, + seed, + ), + })); + }, + [updateWorkflow], + ); + + const clearPortfolioAction = useCallback( + (workspaceId: string) => { + updateWorkflow(workspaceId, (current) => ({ + ...current, + activePortfolioAction: null, + draft: EMPTY_DRAFT, + })); + }, + [updateWorkflow], + ); + + const updateDraft = useCallback( + (workspaceId: string, patch: Partial) => { + updateWorkflow(workspaceId, (current) => ({ + ...current, + draft: { + ...current.draft, + ...patch, + }, + })); + }, + [updateWorkflow], + ); + + const exitPortfolioMode = useCallback( + (workspaceId: string) => { + updateWorkflow(workspaceId, (current) => ({ + ...current, + isPortfolioMode: false, + activePortfolioAction: null, + draft: EMPTY_DRAFT, + })); + }, + [updateWorkflow], + ); + + return { + readWorkflow, + noteCommandStart, + noteCommandResponse, + noteCommandError, + startPortfolioAction, + clearPortfolioAction, + updateDraft, + exitPortfolioMode, + }; +}; diff --git a/MosaicIQ/src/hooks/useTabs.ts b/MosaicIQ/src/hooks/useTabs.ts index 9728c11..f2fe3e0 100644 --- a/MosaicIQ/src/hooks/useTabs.ts +++ b/MosaicIQ/src/hooks/useTabs.ts @@ -18,7 +18,7 @@ export const useTabs = () => { { id: 'welcome', type: 'system', - content: 'MosaicIQ Financial Terminal v1.0\nSlash commands (/) clear the panel.\nNatural language builds a conversation.', + content: 'MosaicIQ Financial Terminal v1.0\nUse /portfolio to open portfolio tools.\nSlash commands (/) clear the panel. Natural language builds a conversation.', timestamp: new Date() } ], @@ -38,7 +38,7 @@ export const useTabs = () => { { id: `welcome-${Date.now()}`, type: 'system', - content: 'MosaicIQ Financial Terminal v1.0\nSlash commands (/) clear the panel.\nNatural language builds a conversation.', + content: 'MosaicIQ Financial Terminal v1.0\nUse /portfolio to open portfolio tools.\nSlash commands (/) clear the panel. Natural language builds a conversation.', timestamp: new Date() } ], diff --git a/MosaicIQ/src/types/financial.ts b/MosaicIQ/src/types/financial.ts index e36a054..ff6d42d 100644 --- a/MosaicIQ/src/types/financial.ts +++ b/MosaicIQ/src/types/financial.ts @@ -53,6 +53,9 @@ export interface Holding { currentValue: number; gainLoss: number; gainLossPercent: number; + costBasis?: number; + unrealizedGain?: number; + latestTradeAt?: string; } export interface Portfolio { @@ -62,6 +65,12 @@ export interface Portfolio { dayChangePercent: number; totalGain: number; totalGainPercent: number; + cashBalance?: number; + investedCostBasis?: number; + realizedGain?: number; + unrealizedGain?: number; + holdingsCount?: number; + stalePricingSymbols?: string[]; } export interface NewsItem { diff --git a/MosaicIQ/src/types/terminal.ts b/MosaicIQ/src/types/terminal.ts index 1ee214d..11a82c3 100644 --- a/MosaicIQ/src/types/terminal.ts +++ b/MosaicIQ/src/types/terminal.ts @@ -110,11 +110,35 @@ export interface CommandSuggestion { | 'dividends' | 'earnings' | 'portfolio' + | 'cash' | 'news' | 'analysis' | 'system'; } +export type PortfolioAction = + | 'overview' + | 'stats' + | 'history' + | 'buy' + | 'sell' + | 'deposit' + | 'withdraw'; + +export interface PortfolioActionDraft { + symbol: string; + quantity: string; + price: string; + amount: string; +} + +export interface PortfolioActionSeed { + symbol?: string; + quantity?: string; + price?: string; + amount?: string; +} + export interface TerminalState { history: TerminalEntry[]; currentIndex: number;