Implement portfolio management backend and terminal UI

This commit is contained in:
2026-04-05 22:36:18 -04:00
parent bbe94c06a1
commit 91cc3cc3d4
23 changed files with 3218 additions and 260 deletions

View File

@@ -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);
}

View File

@@ -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]

View File

@@ -7,8 +7,11 @@
mod agent;
mod commands;
mod error;
mod portfolio;
mod state;
mod terminal;
#[cfg(test)]
mod test_support;
use tauri::Manager;

View 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());
}
}

View 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,
};

View 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),
}
}
}

View 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,
}

View File

@@ -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),
})

View File

@@ -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![]));

View File

@@ -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,
};

View File

@@ -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.

View 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(()))
}

View File

@@ -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>

View File

@@ -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,68 +44,228 @@ 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>
<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>
</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">
<td className="px-4 py-3">
<div className="font-semibold text-[#e0e0e0]">{holding.symbol}</div>
<div className="text-[10px] text-[#888888]">{holding.name}</div>
</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)}
<div className="text-[10px]">
({gainPositive ? '+' : ''}{holding.gainLossPercent.toFixed(2)}%)
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<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-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="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]">
{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)}%)
</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>
);
})}
</tbody>
</table>
</div>
)}
</section>
</div>
);

View File

@@ -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>
);

View File

@@ -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 && (

View File

@@ -1,161 +1,433 @@
import React, { useState, useRef, useEffect, KeyboardEvent } from 'react';
import React, {
KeyboardEvent,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import {
CommandSuggestion,
PortfolioAction,
PortfolioActionDraft,
} from '../../types/terminal';
interface CommandInputProps {
onSubmit: (command: string) => void;
onStartPortfolioAction: (action: PortfolioAction) => void;
onUpdatePortfolioDraft: (patch: Partial<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> = ({
onSubmit,
isProcessing,
getPreviousCommand,
getNextCommand,
resetCommandIndex,
placeholder = 'Type command or natural language query...'
}) => {
const [input, setInput] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
export interface CommandInputHandle {
focusWithText: (text: string) => void;
}
const suggestions = [
{ command: '/search', description: 'Search live security data' },
{ command: '/fa', description: 'SEC financial statements' },
{ command: '/cf', description: 'SEC cash flow summary' },
{ command: '/dvd', description: 'SEC dividends history' },
{ command: '/em', description: 'SEC earnings history' },
{ command: '/portfolio', description: 'Show portfolio' },
{ command: '/news', description: 'Market news' },
{ command: '/analyze', description: 'AI analysis' },
{ command: '/help', description: 'List commands' }
];
const SUGGESTIONS: CommandSuggestion[] = [
{ command: '/search', description: 'Search live security data', category: 'search' },
{ command: '/portfolio', description: 'Show portfolio overview', category: 'portfolio' },
{ command: '/portfolio stats', description: 'Show portfolio statistics', category: 'portfolio' },
{ command: '/portfolio history', description: 'Show recent portfolio transactions', category: 'portfolio' },
{ command: '/buy', description: 'Create a buy order', category: 'portfolio' },
{ command: '/sell', description: 'Create a sell order', category: 'portfolio' },
{ command: '/cash deposit', description: 'Add cash to the portfolio', category: 'cash' },
{ command: '/cash withdraw', description: 'Withdraw cash from the portfolio', category: 'cash' },
{ command: '/fa', description: 'SEC financial statements', category: 'financials' },
{ command: '/cf', description: 'SEC cash flow summary', category: 'cashflow' },
{ command: '/dvd', description: 'SEC dividends history', category: 'dividends' },
{ command: '/em', description: 'SEC earnings history', category: 'earnings' },
{ command: '/news', description: 'Market news', category: 'news' },
{ command: '/analyze', description: 'AI analysis', category: 'analysis' },
{ command: '/help', description: 'List commands', category: 'system' },
];
useEffect(() => {
if (!isProcessing) {
inputRef.current?.focus();
const ACTION_LABELS: Record<'buy' | 'sell' | 'deposit' | 'withdraw', string> = {
buy: 'Buy',
sell: 'Sell',
deposit: 'Deposit',
withdraw: 'Withdraw',
};
const isActionComposer = (
action: PortfolioAction | null,
): action is 'buy' | 'sell' | 'deposit' | 'withdraw' =>
action === 'buy' || action === 'sell' || action === 'deposit' || action === 'withdraw';
const suggestionToAction = (
command: string,
): 'buy' | 'sell' | 'deposit' | 'withdraw' | null => {
switch (command) {
case '/buy':
return 'buy';
case '/sell':
return 'sell';
case '/cash deposit':
return 'deposit';
case '/cash withdraw':
return 'withdraw';
default:
return null;
}
};
const buildGeneratedCommand = (
action: 'buy' | 'sell' | 'deposit' | 'withdraw',
draft: PortfolioActionDraft,
) => {
if (action === 'buy' || action === 'sell') {
const symbol = draft.symbol.trim().toUpperCase();
const quantity = draft.quantity.trim();
const price = draft.price.trim();
if (!symbol) {
return { command: `/${action} [ticker] [quantity] [price?]`, error: 'Ticker symbol is required.' };
}
if (!quantity) {
return { command: `/${action} ${symbol} [quantity] [price?]`, error: 'Quantity is required.' };
}
if (Number.isNaN(Number(quantity)) || Number(quantity) <= 0) {
return { command: `/${action} ${symbol} ${quantity}`, error: 'Quantity must be greater than zero.' };
}
if (price && (Number.isNaN(Number(price)) || Number(price) <= 0)) {
return { command: `/${action} ${symbol} ${quantity} ${price}`, error: 'Price must be greater than zero when provided.' };
}
}, [isProcessing]);
const handleSubmit = () => {
const trimmed = input.trim();
if (trimmed && !isProcessing) {
onSubmit(trimmed);
return {
command: price ? `/${action} ${symbol} ${quantity} ${price}` : `/${action} ${symbol} ${quantity}`,
error: null,
};
}
const amount = draft.amount.trim();
if (!amount) {
return {
command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} [amount]`,
error: 'Amount is required.',
};
}
if (Number.isNaN(Number(amount)) || Number(amount) <= 0) {
return {
command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`,
error: 'Amount must be greater than zero.',
};
}
return {
command: `/cash ${action === 'deposit' ? 'deposit' : 'withdraw'} ${amount}`,
error: null,
};
};
export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputProps>(
(
{
onSubmit,
onStartPortfolioAction,
onUpdatePortfolioDraft,
onClearPortfolioAction,
isProcessing,
getPreviousCommand,
getNextCommand,
resetCommandIndex,
portfolioMode,
activePortfolioAction,
portfolioDraft,
lastPortfolioCommand,
placeholder = 'Type command or natural language query...',
},
ref,
) => {
const [input, setInput] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const actionPrimaryFieldRef = useRef<HTMLInputElement>(null);
const actionComposerActive = isActionComposer(activePortfolioAction);
const actionMeta = useMemo(
() =>
actionComposerActive
? buildGeneratedCommand(activePortfolioAction, portfolioDraft)
: null,
[actionComposerActive, activePortfolioAction, portfolioDraft],
);
useEffect(() => {
if (!isProcessing) {
if (actionComposerActive) {
actionPrimaryFieldRef.current?.focus();
} else {
inputRef.current?.focus();
}
}
}, [actionComposerActive, isProcessing]);
useImperativeHandle(
ref,
() => ({
focusWithText: (text: string) => {
setInput(text);
setShowSuggestions(text.startsWith('/'));
resetCommandIndex();
inputRef.current?.focus();
},
}),
[resetCommandIndex],
);
const handleSubmit = () => {
const trimmed = input.trim();
if (trimmed && !isProcessing) {
onSubmit(trimmed);
setInput('');
setShowSuggestions(false);
resetCommandIndex();
}
};
const handleActionSubmit = () => {
if (!actionComposerActive || !actionMeta || actionMeta.error || isProcessing) {
return;
}
onSubmit(actionMeta.command);
onClearPortfolioAction();
setInput('');
setShowSuggestions(false);
resetCommandIndex();
}
};
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = getPreviousCommand();
if (prev !== null) {
setInput(prev);
const activateSuggestion = (command: string) => {
const action = suggestionToAction(command);
if (action) {
onStartPortfolioAction(action);
setInput('');
setShowSuggestions(false);
resetCommandIndex();
return;
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const next = getNextCommand();
if (next !== null) {
setInput(next);
}
} else if (e.key === 'Tab') {
e.preventDefault();
// Simple autocomplete
if (input.startsWith('/')) {
const match = suggestions.find(s => s.command.startsWith(input));
if (match) {
setInput(match.command + ' ');
setInput(`${command} `);
setShowSuggestions(false);
inputRef.current?.focus();
};
const handleKeyDown = (event: KeyboardEvent<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);
}
} else if (e.key === 'Escape') {
setShowSuggestions(false);
}
};
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
if (e.target.value.startsWith('/')) {
setShowSuggestions(true);
} else {
setShowSuggestions(false);
}
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
setShowSuggestions(event.target.value.startsWith('/'));
};
const handleSuggestionClick = (command: string) => {
setInput(command + ' ');
setShowSuggestions(false);
inputRef.current?.focus();
};
const suggestionMatches = SUGGESTIONS.filter(
(suggestion) => !input || suggestion.command.startsWith(input),
);
return (
<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) => (
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">
{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>
)}
{/* 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 */}
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isProcessing}
className="flex-1 bg-transparent border-none outline-none text-[#e0e0e0] font-mono text-[15px] placeholder-[#888888] disabled:opacity-50"
/>
{/* Processing Indicator */}
{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>
</div>
)}
) : null}
{/* Submit Hint */}
{!isProcessing && input && (
<span className="text-[#888888] text-xs font-mono select-none"></span>
)}
</div>
<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={
actionComposerActive
? 'Or type a raw command directly...'
: placeholder
}
disabled={isProcessing}
className="flex-1 bg-transparent text-[15px] text-[#e0e0e0] outline-none placeholder:text-[#888888] disabled:opacity-50"
/>
{/* Command hint */}
<div className="mt-2 flex gap-4 text-xs text-[#888888] font-mono">
<span>/ history</span>
<span>Tab autocomplete</span>
<span>Ctrl+L clear</span>
{isProcessing ? (
<div className="flex items-center gap-1">
<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>
</div>
<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>
</div>
);
};
);
},
);
CommandInput.displayName = 'CommandInput';

View File

@@ -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>
);
};

View File

@@ -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>

View 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,
};
};

View File

@@ -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()
}
],

View File

@@ -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 {

View File

@@ -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;