Implement portfolio management backend and terminal UI
This commit is contained in:
@@ -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<T>(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<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn cleanup_test_data_dir(path: PathBuf) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
mod agent;
|
||||
mod commands;
|
||||
mod error;
|
||||
mod portfolio;
|
||||
mod state;
|
||||
mod terminal;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
|
||||
443
MosaicIQ/src-tauri/src/portfolio/engine.rs
Normal file
443
MosaicIQ/src-tauri/src/portfolio/engine.rs
Normal file
@@ -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<ReplaySnapshot, PortfolioEngineError> {
|
||||
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<String, Vec<OpenLot>> = HashMap::new();
|
||||
let mut names_by_symbol: HashMap<String, String> = HashMap::new();
|
||||
let mut latest_trade_at: HashMap<String, String> = HashMap::new();
|
||||
let mut latest_trade_price: HashMap<String, f64> = 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::<f64>();
|
||||
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::<f64>();
|
||||
if quantity <= FLOAT_TOLERANCE {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PositionSnapshot {
|
||||
cost_basis: open_lots
|
||||
.iter()
|
||||
.map(|lot| lot.quantity * lot.price)
|
||||
.sum::<f64>(),
|
||||
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::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
14
MosaicIQ/src-tauri/src/portfolio/mod.rs
Normal file
14
MosaicIQ/src-tauri/src/portfolio/mod.rs
Normal file
@@ -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,
|
||||
};
|
||||
725
MosaicIQ/src-tauri/src/portfolio/service.rs
Normal file
725
MosaicIQ/src-tauri/src/portfolio/service.rs
Normal file
@@ -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<Portfolio, PortfolioCommandError>>;
|
||||
fn stats<'a>(&'a self) -> BoxFuture<'a, Result<PortfolioStats, PortfolioCommandError>>;
|
||||
fn history<'a>(
|
||||
&'a self,
|
||||
limit: usize,
|
||||
) -> BoxFuture<'a, Result<Vec<PortfolioTransaction>, PortfolioCommandError>>;
|
||||
fn buy<'a>(
|
||||
&'a self,
|
||||
symbol: &'a str,
|
||||
quantity: f64,
|
||||
price_override: Option<f64>,
|
||||
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>>;
|
||||
fn sell<'a>(
|
||||
&'a self,
|
||||
symbol: &'a str,
|
||||
quantity: f64,
|
||||
price_override: Option<f64>,
|
||||
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>>;
|
||||
fn deposit_cash<'a>(
|
||||
&'a self,
|
||||
amount: f64,
|
||||
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>>;
|
||||
fn withdraw_cash<'a>(
|
||||
&'a self,
|
||||
amount: f64,
|
||||
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>>;
|
||||
}
|
||||
|
||||
pub struct PortfolioService<R: Runtime> {
|
||||
app_handle: AppHandle<R>,
|
||||
security_lookup: Arc<dyn SecurityLookup>,
|
||||
write_lock: Mutex<()>,
|
||||
next_transaction_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl<R: Runtime> PortfolioService<R> {
|
||||
pub fn new(app_handle: &AppHandle<R>, security_lookup: Arc<dyn SecurityLookup>) -> Self {
|
||||
Self {
|
||||
app_handle: app_handle.clone(),
|
||||
security_lookup,
|
||||
write_lock: Mutex::new(()),
|
||||
next_transaction_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_ledger(&self) -> Result<PortfolioLedger, PortfolioStoreError> {
|
||||
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<ReplaySnapshot, PortfolioStoreError> {
|
||||
replay_ledger(ledger).map_err(map_engine_error)
|
||||
}
|
||||
|
||||
async fn resolve_trade_input(
|
||||
&self,
|
||||
symbol: &str,
|
||||
price_override: Option<f64>,
|
||||
) -> 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<TradeConfirmation, PortfolioCommandError> {
|
||||
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<CashConfirmation, PortfolioCommandError> {
|
||||
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<PortfolioSnapshot, PortfolioCommandError> {
|
||||
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::<Vec<_>>()
|
||||
.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::<f64>();
|
||||
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<R: Runtime> PortfolioManagement for PortfolioService<R> {
|
||||
fn portfolio<'a>(&'a self) -> BoxFuture<'a, Result<Portfolio, PortfolioCommandError>> {
|
||||
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<PortfolioStats, PortfolioCommandError>> {
|
||||
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<Vec<PortfolioTransaction>, 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<f64>,
|
||||
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
|
||||
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<f64>,
|
||||
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
|
||||
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<CashConfirmation, PortfolioCommandError>> {
|
||||
Box::pin(async move {
|
||||
self.candidate_cash_result(amount, TransactionKind::CashDeposit)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn withdraw_cash<'a>(
|
||||
&'a self,
|
||||
amount: f64,
|
||||
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>> {
|
||||
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<Vec<SecurityMatch>, SecurityLookupError>> {
|
||||
Box::pin(async { Ok(Vec::new()) })
|
||||
}
|
||||
|
||||
fn load_company<'a>(
|
||||
&'a self,
|
||||
security_match: &'a SecurityMatch,
|
||||
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
|
||||
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<MockRuntime> {
|
||||
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<MockRuntime>, 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<T>(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),
|
||||
}
|
||||
}
|
||||
}
|
||||
164
MosaicIQ/src-tauri/src/portfolio/types.rs
Normal file
164
MosaicIQ/src-tauri/src/portfolio/types.rs
Normal file
@@ -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<PortfolioTransaction>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub company_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quantity: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub price: Option<f64>,
|
||||
pub gross_amount: f64,
|
||||
pub fee: f64,
|
||||
pub executed_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<OpenLot>,
|
||||
pub latest_trade_at: Option<String>,
|
||||
pub latest_trade_price: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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<PositionSnapshot>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
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<PortfolioHolding>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -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<dyn SecurityLookup> = 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),
|
||||
})
|
||||
|
||||
@@ -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<dyn SecurityLookup>,
|
||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||
portfolio_service: Arc<dyn PortfolioManagement>,
|
||||
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<dyn SecurityLookup>,
|
||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||
portfolio_service: Arc<dyn PortfolioManagement>,
|
||||
) -> 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<dyn SecurityLookup>,
|
||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||
portfolio_service: Arc<dyn PortfolioManagement>,
|
||||
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<f64>)> {
|
||||
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<f64> {
|
||||
let parsed = value.trim().parse::<f64>().ok()?;
|
||||
(parsed.is_finite() && parsed > 0.0).then_some(parsed)
|
||||
}
|
||||
|
||||
fn parse_positive_usize(value: &str) -> Option<usize> {
|
||||
let parsed = value.trim().parse::<usize>().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::<Vec<_>>()
|
||||
.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<Portfolio, PortfolioCommandError>,
|
||||
stats: Result<PortfolioStats, PortfolioCommandError>,
|
||||
history: Result<Vec<PortfolioTransaction>, PortfolioCommandError>,
|
||||
buy: Result<TradeConfirmation, PortfolioCommandError>,
|
||||
sell: Result<TradeConfirmation, PortfolioCommandError>,
|
||||
deposit_cash: Result<CashConfirmation, PortfolioCommandError>,
|
||||
withdraw_cash: Result<CashConfirmation, PortfolioCommandError>,
|
||||
}
|
||||
|
||||
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<Portfolio, PortfolioCommandError>> {
|
||||
Box::pin(async move { self.portfolio.clone() })
|
||||
}
|
||||
|
||||
fn stats<'a>(&'a self) -> BoxFuture<'a, Result<PortfolioStats, PortfolioCommandError>> {
|
||||
Box::pin(async move { self.stats.clone() })
|
||||
}
|
||||
|
||||
fn history<'a>(
|
||||
&'a self,
|
||||
limit: usize,
|
||||
) -> BoxFuture<'a, Result<Vec<PortfolioTransaction>, 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<f64>,
|
||||
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
|
||||
Box::pin(async move { self.buy.clone() })
|
||||
}
|
||||
|
||||
fn sell<'a>(
|
||||
&'a self,
|
||||
_symbol: &'a str,
|
||||
_quantity: f64,
|
||||
_price_override: Option<f64>,
|
||||
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
|
||||
Box::pin(async move { self.sell.clone() })
|
||||
}
|
||||
|
||||
fn deposit_cash<'a>(
|
||||
&'a self,
|
||||
_amount: f64,
|
||||
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>> {
|
||||
Box::pin(async move { self.deposit_cash.clone() })
|
||||
}
|
||||
|
||||
fn withdraw_cash<'a>(
|
||||
&'a self,
|
||||
_amount: f64,
|
||||
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>> {
|
||||
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<Vec<SecurityMatch>, SecurityLookupError>,
|
||||
) -> (TerminalCommandService, Arc<FakeSecurityLookup>) {
|
||||
build_service_with_portfolio(search_result, Arc::new(FakePortfolioService::default()))
|
||||
}
|
||||
|
||||
fn build_service_with_portfolio(
|
||||
search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
|
||||
portfolio_service: Arc<dyn PortfolioManagement>,
|
||||
) -> (TerminalCommandService, Arc<FakeSecurityLookup>) {
|
||||
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![]));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unrealized_gain: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_trade_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub invested_cost_basis: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub realized_gain: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unrealized_gain: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holdings_count: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stale_pricing_symbols: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// News item serialized with an ISO timestamp for transport safety.
|
||||
|
||||
8
MosaicIQ/src-tauri/src/test_support.rs
Normal file
8
MosaicIQ/src-tauri/src/test_support.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[cfg(test)]
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
@@ -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<AgentConfigStatus | null>(null);
|
||||
const [activeView, setActiveView] = React.useState<AppView>('terminal');
|
||||
const portfolioWorkflow = usePortfolioWorkflow();
|
||||
const commandHistoryRefs = useRef<Record<string, string[]>>({});
|
||||
const commandIndexRefs = useRef<Record<string, number>>({});
|
||||
const commandInputRef = useRef<CommandInputHandle | null>(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<PortfolioActionDraft>) => {
|
||||
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<HTMLDivElement | null>(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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<PortfolioPanelProps> = ({ 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<PortfolioPanelProps> = ({
|
||||
portfolio,
|
||||
onRunCommand,
|
||||
onStartAction,
|
||||
onSelectHolding,
|
||||
}) => {
|
||||
const totalGainPositive = portfolio.totalGain >= 0;
|
||||
const dayChangePositive = portfolio.dayChange >= 0;
|
||||
|
||||
@@ -22,60 +44,219 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({ 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 (
|
||||
<div className="portfolio-panel py-4">
|
||||
{/* Header */}
|
||||
<header className="mb-6">
|
||||
<h2 className="text-heading-lg text-[#e0e0e0]">Portfolio Summary</h2>
|
||||
<header className="mb-6 flex flex-col gap-4 border-b border-[#1a1a1a] pb-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-heading-lg text-[#e0e0e0]">Portfolio Overview</h2>
|
||||
<p className="mt-1 text-[11px] font-mono text-[#888888]">
|
||||
Review positions, run portfolio commands, and open trade workflows from here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-[#666666]">
|
||||
{portfolio.stalePricingSymbols?.length
|
||||
? `Stale pricing fallback: ${portfolio.stalePricingSymbols.join(', ')}`
|
||||
: 'Live pricing loaded'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onStartAction('buy')}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onStartAction('sell')}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onStartAction('deposit')}
|
||||
>
|
||||
Deposit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onStartAction('withdraw')}
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onRunCommand('/portfolio stats')}
|
||||
>
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onRunCommand('/portfolio history')}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Summary Stats - Inline metric grid */}
|
||||
<section className="mb-8">
|
||||
<MetricGrid metrics={summaryMetrics} columns={3} />
|
||||
</section>
|
||||
|
||||
{/* Holdings Table - Minimal */}
|
||||
<section className="holdings-section border-t border-[#1a1a1a] pt-4">
|
||||
<h3 className="text-heading-sm text-[#e0e0e0] mb-4">Holdings ({portfolio.holdings.length})</h3>
|
||||
<section className="border-t border-[#1a1a1a] pt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-heading-sm text-[#e0e0e0]">
|
||||
Holdings ({portfolio.holdings.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0]"
|
||||
onClick={() => onRunCommand('/portfolio')}
|
||||
>
|
||||
Reload overview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{portfolio.holdings.length === 0 ? (
|
||||
<div className="border border-[#1f1f1f] bg-[#111111] px-4 py-5">
|
||||
<div className="text-sm font-mono text-[#e0e0e0]">No holdings yet.</div>
|
||||
<div className="mt-1 text-[11px] font-mono text-[#888888]">
|
||||
Deposit cash first, then open a buy workflow to record your first position.
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onStartAction('deposit')}
|
||||
>
|
||||
Deposit cash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={actionButtonClass}
|
||||
onClick={() => onStartAction('buy')}
|
||||
>
|
||||
Buy first company
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead className="text-[#888888]">
|
||||
<tr className="border-b border-[#1a1a1a]">
|
||||
<th className="px-4 py-2 text-left text-[10px] uppercase tracking-wider">Symbol</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">Qty</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">Avg Cost</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">Current</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">Value</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">Gain/Loss</th>
|
||||
<th className="px-4 py-2 text-left text-[10px] uppercase tracking-wider">
|
||||
Symbol
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Qty
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Avg Cost
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Current
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Value
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Gain/Loss
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#1a1a1a]">
|
||||
{portfolio.holdings.map((holding) => {
|
||||
const gainPositive = holding.gainLoss >= 0;
|
||||
|
||||
return (
|
||||
<tr key={holding.symbol} className="hover:bg-[#1a1a1a]/50 transition-colors">
|
||||
<tr key={holding.symbol} className="transition-colors hover:bg-[#151515]">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-left"
|
||||
onClick={() => onSelectHolding?.(holding.symbol)}
|
||||
>
|
||||
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
|
||||
<div className="text-[10px] text-[#888888]">{holding.name}</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{holding.quantity}</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{formatCurrency(holding.avgCost)}</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{formatCurrency(holding.currentPrice)}</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">{formatCurrency(holding.currentValue)}</td>
|
||||
<td className={`px-4 py-3 text-right font-semibold ${gainPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{gainPositive ? '+' : ''}{formatCurrency(holding.gainLoss)}
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatQuantity(holding.quantity)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.avgCost)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.currentPrice)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-[#e0e0e0]">
|
||||
{formatCurrency(holding.currentValue)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-semibold ${
|
||||
gainPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||
}`}
|
||||
>
|
||||
{formatSignedCurrency(holding.gainLoss)}
|
||||
<div className="text-[10px]">
|
||||
({gainPositive ? '+' : ''}{holding.gainLossPercent.toFixed(2)}%)
|
||||
({gainPositive ? '+' : ''}
|
||||
{holding.gainLossPercent.toFixed(2)}%)
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="border border-[#2a2a2a] px-2 py-1 text-[11px] text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
|
||||
onClick={() =>
|
||||
onStartAction('sell', { symbol: holding.symbol })
|
||||
}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border border-[#2a2a2a] px-2 py-1 text-[11px] text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
|
||||
onClick={() => onRunCommand(`/search ${holding.symbol}`)}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -84,6 +265,7 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({ portfolio }) =>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<PortfolioSummaryProps> = ({ portfolio }) => {
|
||||
export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({
|
||||
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 (
|
||||
<div className="border-l-2 border-[#1a1a1a] px-3 py-3">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">
|
||||
Portfolio
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-mono text-[#666666]">
|
||||
Load your latest portfolio snapshot into the sidebar.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadPortfolio}
|
||||
className="mt-3 border border-[#2a2a2a] bg-[#161616] px-3 py-2 text-xs font-mono text-[#888888] transition-colors hover:border-[#58a6ff] hover:text-[#e0e0e0]"
|
||||
>
|
||||
Load portfolio
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="border-l-2 border-[#1a1a1a] hover:border-[#58a6ff] transition-colors">
|
||||
<div className="border-l-2 border-[#1a1a1a] transition-colors hover:border-[#58a6ff]">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={() => {
|
||||
setIsExpanded((current) => !current);
|
||||
onLoadPortfolio();
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">Portfolio</h4>
|
||||
<span className="text-[10px] font-mono text-[#666666]">({portfolio.holdings.length} positions)</span>
|
||||
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">
|
||||
Portfolio
|
||||
</h4>
|
||||
<span className="text-[10px] font-mono text-[#666666]">
|
||||
({portfolio.holdings.length} positions)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex items-center gap-1 ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
<div
|
||||
className={`flex items-center gap-1 ${
|
||||
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||
}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
<span className="text-xs font-mono">
|
||||
{isPositive ? '+' : ''}{portfolio.dayChangePercent.toFixed(1)}%
|
||||
{isPositive ? '+' : ''}
|
||||
{portfolio.dayChangePercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
{hasMoreHoldings && (
|
||||
{hasMoreHoldings ? (
|
||||
<span className="text-[#666666]">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
@@ -52,7 +90,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({ portfolio })
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,53 +98,66 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({ portfolio })
|
||||
<span className="text-sm font-mono font-bold text-[#e0e0e0]">
|
||||
{formatCurrency(portfolio.totalValue)}
|
||||
</span>
|
||||
<span className={`text-xs font-mono ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{isPositive ? '+' : ''}{formatCurrency(portfolio.dayChange)} today
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||
}`}
|
||||
>
|
||||
{isPositive ? '+' : ''}
|
||||
{formatCurrency(portfolio.dayChange)} today
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Holdings List */}
|
||||
<div className="px-3 pb-2 space-y-1">
|
||||
<div className="space-y-1 px-3 pb-2">
|
||||
{visibleHoldings.map((holding) => {
|
||||
const holdingPositive = holding.gainLoss >= 0;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={holding.symbol}
|
||||
className="flex items-center justify-between text-xs py-1 group cursor-pointer hover:bg-[#1a1a1a] px-2 -mx-2 rounded transition-colors"
|
||||
type="button"
|
||||
onClick={onLoadPortfolio}
|
||||
className="group flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors hover:bg-[#1a1a1a]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[#e0e0e0] group-hover:text-[#58a6ff] transition-colors">
|
||||
<span className="font-mono text-[#e0e0e0] transition-colors group-hover:text-[#58a6ff]">
|
||||
{holding.symbol}
|
||||
</span>
|
||||
<span className="text-[10px] text-[#888888]">
|
||||
{holding.quantity} {holding.quantity === 1 ? 'share' : 'shares'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`font-mono ${holdingPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
||||
{holdingPositive ? '+' : ''}{holding.gainLossPercent.toFixed(1)}%
|
||||
<span
|
||||
className={`font-mono ${
|
||||
holdingPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||
}`}
|
||||
>
|
||||
{holdingPositive ? '+' : ''}
|
||||
{holding.gainLossPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasMoreHoldings && !isExpanded && (
|
||||
{hasMoreHoldings && !isExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="w-full text-center text-[10px] font-mono text-[#888888] hover:text-[#e0e0e0] py-1.5 transition-colors"
|
||||
className="w-full py-1.5 text-center text-[10px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0]"
|
||||
>
|
||||
See {portfolio.holdings.length - INITIAL_HOLDINGS_COUNT} more positions
|
||||
See {portfolio.holdings.length - initialHoldingsCount} more positions
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isExpanded && hasMoreHoldings && (
|
||||
{isExpanded && hasMoreHoldings ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="w-full text-center text-[10px] font-mono text-[#888888] hover:text-[#e0e0e0] py-1.5 transition-colors"
|
||||
className="w-full py-1.5 text-center text-[10px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0]"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<SidebarProps> = ({
|
||||
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<SidebarProps> = ({
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Portfolio Summary */}
|
||||
<PortfolioSummary portfolio={portfolio} />
|
||||
<PortfolioSummary
|
||||
portfolio={portfolio}
|
||||
onLoadPortfolio={() => onCommand('/portfolio')}
|
||||
/>
|
||||
|
||||
{/* Ticker History - shows only when loaded */}
|
||||
{isTickerHistoryLoaded && (
|
||||
|
||||
@@ -1,44 +1,187 @@
|
||||
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<PortfolioActionDraft>) => 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<CommandInputProps> = ({
|
||||
export interface CommandInputHandle {
|
||||
focusWithText: (text: string) => void;
|
||||
}
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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.' };
|
||||
}
|
||||
|
||||
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<CommandInputHandle, CommandInputProps>(
|
||||
(
|
||||
{
|
||||
onSubmit,
|
||||
onStartPortfolioAction,
|
||||
onUpdatePortfolioDraft,
|
||||
onClearPortfolioAction,
|
||||
isProcessing,
|
||||
getPreviousCommand,
|
||||
getNextCommand,
|
||||
resetCommandIndex,
|
||||
placeholder = 'Type command or natural language query...'
|
||||
}) => {
|
||||
portfolioMode,
|
||||
activePortfolioAction,
|
||||
portfolioDraft,
|
||||
lastPortfolioCommand,
|
||||
placeholder = 'Type command or natural language query...',
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const actionPrimaryFieldRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
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 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();
|
||||
}
|
||||
}, [isProcessing]);
|
||||
}
|
||||
}, [actionComposerActive, isProcessing]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusWithText: (text: string) => {
|
||||
setInput(text);
|
||||
setShowSuggestions(text.startsWith('/'));
|
||||
resetCommandIndex();
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}),
|
||||
[resetCommandIndex],
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = input.trim();
|
||||
@@ -50,112 +193,241 @@ export const CommandInput: React.FC<CommandInputProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = getPreviousCommand();
|
||||
if (prev !== null) {
|
||||
setInput(prev);
|
||||
const handleActionSubmit = () => {
|
||||
if (!actionComposerActive || !actionMeta || actionMeta.error || isProcessing) {
|
||||
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 + ' ');
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
|
||||
onSubmit(actionMeta.command);
|
||||
onClearPortfolioAction();
|
||||
setInput('');
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
resetCommandIndex();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(e.target.value);
|
||||
if (e.target.value.startsWith('/')) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
const activateSuggestion = (command: string) => {
|
||||
const action = suggestionToAction(command);
|
||||
if (action) {
|
||||
onStartPortfolioAction(action);
|
||||
setInput('');
|
||||
setShowSuggestions(false);
|
||||
resetCommandIndex();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (command: string) => {
|
||||
setInput(command + ' ');
|
||||
setInput(`${command} `);
|
||||
setShowSuggestions(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(event.target.value);
|
||||
setShowSuggestions(event.target.value.startsWith('/'));
|
||||
};
|
||||
|
||||
const suggestionMatches = SUGGESTIONS.filter(
|
||||
(suggestion) => !input || suggestion.command.startsWith(input),
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="relative">
|
||||
{/* Suggestions Dropdown */}
|
||||
{showSuggestions && suggestions.some(s => s.command.startsWith(input)) && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md overflow-hidden shadow-lg z-20"
|
||||
>
|
||||
{suggestions
|
||||
.filter(s => !input || s.command.startsWith(input))
|
||||
.map((suggestion, idx) => (
|
||||
{actionComposerActive && actionMeta ? (
|
||||
<div className="mb-3 border border-[#2a2a2a] bg-[#111111]">
|
||||
<div className="flex items-center justify-between border-b border-[#1f1f1f] px-4 py-2">
|
||||
<div className="text-xs font-mono text-[#e0e0e0]">
|
||||
{ACTION_LABELS[activePortfolioAction]}
|
||||
</div>
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-4 py-2 hover:bg-[#2a2a2a] transition-colors font-mono text-sm"
|
||||
onClick={() => handleSuggestionClick(suggestion.command)}
|
||||
type="button"
|
||||
onClick={onClearPortfolioAction}
|
||||
className="text-[11px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0]"
|
||||
>
|
||||
Use raw command instead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 px-4 py-3 md:grid-cols-3">
|
||||
{(activePortfolioAction === 'buy' || activePortfolioAction === 'sell') && (
|
||||
<>
|
||||
<label className="flex flex-col gap-1 text-[11px] font-mono text-[#888888]">
|
||||
<span>Symbol</span>
|
||||
<input
|
||||
ref={actionPrimaryFieldRef}
|
||||
type="text"
|
||||
value={portfolioDraft.symbol}
|
||||
onChange={(event) =>
|
||||
onUpdatePortfolioDraft({ symbol: event.target.value.toUpperCase() })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="border border-[#2a2a2a] bg-[#0d0d0d] px-3 py-2 text-sm text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] font-mono text-[#888888]">
|
||||
<span>Quantity</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={portfolioDraft.quantity}
|
||||
onChange={(event) =>
|
||||
onUpdatePortfolioDraft({ quantity: event.target.value })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="border border-[#2a2a2a] bg-[#0d0d0d] px-3 py-2 text-sm text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] font-mono text-[#888888]">
|
||||
<span>Price (optional)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={portfolioDraft.price}
|
||||
onChange={(event) =>
|
||||
onUpdatePortfolioDraft({ price: event.target.value })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="border border-[#2a2a2a] bg-[#0d0d0d] px-3 py-2 text-sm text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(activePortfolioAction === 'deposit' || activePortfolioAction === 'withdraw') && (
|
||||
<label className="flex flex-col gap-1 text-[11px] font-mono text-[#888888] md:col-span-2">
|
||||
<span>Amount</span>
|
||||
<input
|
||||
ref={actionPrimaryFieldRef}
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={portfolioDraft.amount}
|
||||
onChange={(event) =>
|
||||
onUpdatePortfolioDraft({ amount: event.target.value })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="border border-[#2a2a2a] bg-[#0d0d0d] px-3 py-2 text-sm text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#1f1f1f] px-4 py-3">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#666666]">
|
||||
Command Preview
|
||||
</div>
|
||||
<div className="mt-2 border border-[#1f1f1f] bg-[#0d0d0d] px-3 py-2 font-mono text-sm text-[#e0e0e0]">
|
||||
{actionMeta.command}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<div className="min-h-[18px] text-[11px] font-mono text-[#ff4757]">
|
||||
{actionMeta.error}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleActionSubmit}
|
||||
disabled={Boolean(actionMeta.error) || isProcessing}
|
||||
className="border border-[#2a2a2a] bg-[#161616] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{isProcessing
|
||||
? `Running ${actionMeta.command.split(' ')[0]}...`
|
||||
: `Run ${ACTION_LABELS[activePortfolioAction]}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSuggestions && suggestionMatches.length > 0 ? (
|
||||
<div className="absolute inset-x-0 top-full z-20 mt-2 border border-[#2a2a2a] bg-[#1a1a1a]">
|
||||
{suggestionMatches.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion.command}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm font-mono transition-colors hover:bg-[#232323]"
|
||||
onClick={() => activateSuggestion(suggestion.command)}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-[#58a6ff]">{suggestion.command}</span>
|
||||
<span className="text-[#888888] ml-2">{suggestion.description}</span>
|
||||
<span className="ml-4 text-[11px] text-[#888888]">{suggestion.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Input Bar */}
|
||||
<div className="flex items-center gap-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md px-4 py-3 focus-within:border-[#58a6ff] focus-within:shadow-[0_0_0_2px_rgba(88,166,255,0.1)] transition-all">
|
||||
{/* Prompt */}
|
||||
<span className="text-[#58a6ff] font-mono text-lg select-none">{'>'}</span>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border border-[#2a2a2a] bg-[#1a1a1a] px-4 py-3 transition-all focus-within:border-[#58a6ff]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="select-none font-mono text-lg text-[#58a6ff]">{'>'}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
placeholder={
|
||||
actionComposerActive
|
||||
? 'Or type a raw command directly...'
|
||||
: placeholder
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 bg-transparent border-none outline-none text-[#e0e0e0] font-mono text-[15px] placeholder-[#888888] disabled:opacity-50"
|
||||
className="flex-1 bg-transparent text-[15px] text-[#e0e0e0] outline-none placeholder:text-[#888888] disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* Processing Indicator */}
|
||||
{isProcessing && (
|
||||
{isProcessing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-[#58a6ff] rounded-full animate-pulse"></span>
|
||||
<span className="w-2 h-2 bg-[#58a6ff] rounded-full animate-pulse delay-75"></span>
|
||||
<span className="w-2 h-2 bg-[#58a6ff] rounded-full animate-pulse delay-150"></span>
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[#58a6ff]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[#58a6ff] delay-75" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[#58a6ff] delay-150" />
|
||||
</div>
|
||||
) : input ? (
|
||||
<span className="select-none text-xs font-mono text-[#888888]">↵</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Hint */}
|
||||
{!isProcessing && input && (
|
||||
<span className="text-[#888888] text-xs font-mono select-none">↵</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command hint */}
|
||||
<div className="mt-2 flex gap-4 text-xs text-[#888888] font-mono">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-mono text-[#888888]">
|
||||
<span>↑/↓ history</span>
|
||||
<span>Tab autocomplete</span>
|
||||
<span>Ctrl+L clear</span>
|
||||
<span className="text-[#666666]">{helperText}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
CommandInput.displayName = 'CommandInput';
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
inputRef: React.RefObject<CommandInputHandle | null>;
|
||||
onSubmit: (command: string) => void;
|
||||
onRunCommand: (command: string) => void;
|
||||
onStartPortfolioAction: (
|
||||
action: PortfolioAction,
|
||||
seed?: PortfolioActionSeed,
|
||||
) => void;
|
||||
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
|
||||
onClearPortfolioAction: () => void;
|
||||
getPreviousCommand: () => string | null;
|
||||
getNextCommand: () => string | null;
|
||||
resetCommandIndex: () => void;
|
||||
portfolioWorkflow: PortfolioWorkflowState;
|
||||
}
|
||||
|
||||
export const Terminal: React.FC<TerminalProps> = ({
|
||||
history,
|
||||
isProcessing,
|
||||
outputRef,
|
||||
inputRef,
|
||||
onSubmit,
|
||||
onRunCommand,
|
||||
onStartPortfolioAction,
|
||||
onUpdatePortfolioDraft,
|
||||
onClearPortfolioAction,
|
||||
getPreviousCommand,
|
||||
getNextCommand,
|
||||
resetCommandIndex
|
||||
resetCommandIndex,
|
||||
portfolioWorkflow,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#0a0a0a] relative">
|
||||
@@ -30,16 +51,29 @@ export const Terminal: React.FC<TerminalProps> = ({
|
||||
{/* Command Input */}
|
||||
<div className="flex-shrink-0 p-6 border-b border-[#2a2a2a] bg-[#0a0a0a]">
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
onSubmit={onSubmit}
|
||||
onStartPortfolioAction={onStartPortfolioAction}
|
||||
onUpdatePortfolioDraft={onUpdatePortfolioDraft}
|
||||
onClearPortfolioAction={onClearPortfolioAction}
|
||||
isProcessing={isProcessing}
|
||||
getPreviousCommand={getPreviousCommand}
|
||||
getNextCommand={getNextCommand}
|
||||
resetCommandIndex={resetCommandIndex}
|
||||
portfolioMode={portfolioWorkflow.isPortfolioMode}
|
||||
activePortfolioAction={portfolioWorkflow.activePortfolioAction}
|
||||
portfolioDraft={portfolioWorkflow.draft}
|
||||
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Terminal Output */}
|
||||
<TerminalOutput history={history} outputRef={outputRef} />
|
||||
<TerminalOutput
|
||||
history={history}
|
||||
outputRef={outputRef}
|
||||
onRunCommand={onRunCommand}
|
||||
onStartPortfolioAction={onStartPortfolioAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
onRunCommand: (command: string) => void;
|
||||
onStartPortfolioAction: (
|
||||
action: PortfolioAction,
|
||||
seed?: PortfolioActionSeed,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const TerminalOutput: React.FC<TerminalOutputProps> = ({ history, outputRef }) => {
|
||||
export const TerminalOutput: React.FC<TerminalOutputProps> = ({
|
||||
history,
|
||||
outputRef,
|
||||
onRunCommand,
|
||||
onStartPortfolioAction,
|
||||
}) => {
|
||||
// Auto-scroll to bottom when history changes
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
@@ -99,7 +114,14 @@ export const TerminalOutput: React.FC<TerminalOutputProps> = ({ history, outputR
|
||||
case 'error':
|
||||
return <ErrorPanel error={panelData.data} />;
|
||||
case 'portfolio':
|
||||
return <PortfolioPanel portfolio={panelData.data} />;
|
||||
return (
|
||||
<PortfolioPanel
|
||||
portfolio={panelData.data}
|
||||
onRunCommand={onRunCommand}
|
||||
onStartAction={onStartPortfolioAction}
|
||||
onSelectHolding={(symbol) => onRunCommand(`/search ${symbol}`)}
|
||||
/>
|
||||
);
|
||||
case 'news':
|
||||
return <NewsPanel news={panelData.data} ticker={panelData.ticker} />;
|
||||
case 'analysis':
|
||||
@@ -163,7 +185,7 @@ export const TerminalOutput: React.FC<TerminalOutputProps> = ({ history, outputR
|
||||
{history.length === 0 && (
|
||||
<div className="text-[#888888] font-mono text-center py-20">
|
||||
<div className="text-4xl mb-4">⚡</div>
|
||||
<div>Terminal ready. Type a command to get started.</div>
|
||||
<div>Terminal ready. Type a command or load /portfolio to open portfolio tools.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
247
MosaicIQ/src/hooks/usePortfolioWorkflow.ts
Normal file
247
MosaicIQ/src/hooks/usePortfolioWorkflow.ts
Normal file
@@ -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<Record<string, PortfolioWorkflowState>>({});
|
||||
|
||||
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<PortfolioActionDraft>) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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()
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user