Implement portfolio management backend and terminal UI
This commit is contained in:
@@ -190,7 +190,6 @@ mod tests {
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::SessionManager;
|
use super::SessionManager;
|
||||||
@@ -485,7 +484,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn with_test_home<T>(prefix: &str, test: impl FnOnce() -> T) -> T {
|
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));
|
let home = env::temp_dir().join(unique_identifier(prefix));
|
||||||
fs::create_dir_all(&home).unwrap();
|
fs::create_dir_all(&home).unwrap();
|
||||||
|
|
||||||
@@ -506,12 +505,6 @@ mod tests {
|
|||||||
Err(payload) => std::panic::resume_unwind(payload),
|
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) {
|
fn cleanup_test_data_dir(path: PathBuf) {
|
||||||
let _ = fs::remove_dir_all(path);
|
let _ = fs::remove_dir_all(path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use crate::agent::{
|
|||||||
ChatStreamStart,
|
ChatStreamStart,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
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.
|
/// Executes a slash command and returns either terminal text or a structured panel payload.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
mod agent;
|
mod agent;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod portfolio;
|
||||||
mod state;
|
mod state;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_support;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
|||||||
443
MosaicIQ/src-tauri/src/portfolio/engine.rs
Normal file
443
MosaicIQ/src-tauri/src/portfolio/engine.rs
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::portfolio::{
|
||||||
|
OpenLot, PortfolioLedger, PortfolioTransaction, PositionSnapshot, ReplaySnapshot,
|
||||||
|
TransactionKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLOAT_TOLERANCE: f64 = 1e-9;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq)]
|
||||||
|
pub enum PortfolioEngineError {
|
||||||
|
#[error("ledger transaction {transaction_id} has invalid numeric fields")]
|
||||||
|
InvalidTransactionNumbers { transaction_id: String },
|
||||||
|
#[error("ledger transaction {transaction_id} is missing required fields")]
|
||||||
|
MissingTransactionFields { transaction_id: String },
|
||||||
|
#[error("ledger transaction {transaction_id} would make cash negative by {shortfall:.2}")]
|
||||||
|
NegativeCash {
|
||||||
|
transaction_id: String,
|
||||||
|
shortfall: f64,
|
||||||
|
},
|
||||||
|
#[error(
|
||||||
|
"ledger transaction {transaction_id} tries to sell {requested_quantity:.4} shares of {symbol} but only {available_quantity:.4} are available"
|
||||||
|
)]
|
||||||
|
InsufficientShares {
|
||||||
|
transaction_id: String,
|
||||||
|
symbol: String,
|
||||||
|
requested_quantity: f64,
|
||||||
|
available_quantity: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn replay_ledger(ledger: &PortfolioLedger) -> Result<ReplaySnapshot, PortfolioEngineError> {
|
||||||
|
let mut ordered_transactions = ledger.transactions.clone();
|
||||||
|
ordered_transactions.sort_by(|left, right| {
|
||||||
|
left.executed_at
|
||||||
|
.cmp(&right.executed_at)
|
||||||
|
.then_with(|| left.id.cmp(&right.id))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut cash_balance = 0.0;
|
||||||
|
let mut realized_gain = 0.0;
|
||||||
|
let mut lots_by_symbol: HashMap<String, Vec<OpenLot>> = HashMap::new();
|
||||||
|
let mut names_by_symbol: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut latest_trade_at: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut latest_trade_price: HashMap<String, f64> = HashMap::new();
|
||||||
|
|
||||||
|
for transaction in &ordered_transactions {
|
||||||
|
match transaction.kind {
|
||||||
|
TransactionKind::CashDeposit => {
|
||||||
|
validate_cash_amount(transaction)?;
|
||||||
|
cash_balance += transaction.gross_amount;
|
||||||
|
}
|
||||||
|
TransactionKind::CashWithdrawal => {
|
||||||
|
validate_cash_amount(transaction)?;
|
||||||
|
ensure_cash_available(cash_balance, transaction.gross_amount, &transaction.id)?;
|
||||||
|
cash_balance -= transaction.gross_amount;
|
||||||
|
}
|
||||||
|
TransactionKind::Buy => {
|
||||||
|
let (symbol, company_name, quantity, price) =
|
||||||
|
validate_equity_transaction(transaction)?;
|
||||||
|
let trade_total = quantity * price + transaction.fee;
|
||||||
|
ensure_cash_available(cash_balance, trade_total, &transaction.id)?;
|
||||||
|
cash_balance -= trade_total;
|
||||||
|
lots_by_symbol
|
||||||
|
.entry(symbol.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(OpenLot { quantity, price });
|
||||||
|
names_by_symbol.insert(symbol.clone(), company_name);
|
||||||
|
latest_trade_at.insert(symbol.clone(), transaction.executed_at.clone());
|
||||||
|
latest_trade_price.insert(symbol, price);
|
||||||
|
}
|
||||||
|
TransactionKind::Sell => {
|
||||||
|
let (symbol, company_name, quantity, price) =
|
||||||
|
validate_equity_transaction(transaction)?;
|
||||||
|
let Some(open_lots) = lots_by_symbol.get_mut(&symbol) else {
|
||||||
|
return Err(PortfolioEngineError::InsufficientShares {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
symbol,
|
||||||
|
requested_quantity: quantity,
|
||||||
|
available_quantity: 0.0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let available_quantity = open_lots.iter().map(|lot| lot.quantity).sum::<f64>();
|
||||||
|
if available_quantity + FLOAT_TOLERANCE < quantity {
|
||||||
|
return Err(PortfolioEngineError::InsufficientShares {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
symbol,
|
||||||
|
requested_quantity: quantity,
|
||||||
|
available_quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let depleted_cost = consume_fifo_lots(open_lots, quantity);
|
||||||
|
open_lots.retain(|lot| lot.quantity > FLOAT_TOLERANCE);
|
||||||
|
|
||||||
|
if open_lots.is_empty() {
|
||||||
|
lots_by_symbol.remove(&symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
let proceeds = quantity * price - transaction.fee;
|
||||||
|
realized_gain += proceeds - depleted_cost;
|
||||||
|
cash_balance += proceeds;
|
||||||
|
names_by_symbol.insert(symbol.clone(), company_name);
|
||||||
|
latest_trade_at.insert(symbol.clone(), transaction.executed_at.clone());
|
||||||
|
latest_trade_price.insert(symbol, price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut positions = lots_by_symbol
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(symbol, open_lots)| {
|
||||||
|
let quantity = open_lots.iter().map(|lot| lot.quantity).sum::<f64>();
|
||||||
|
if quantity <= FLOAT_TOLERANCE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(PositionSnapshot {
|
||||||
|
cost_basis: open_lots
|
||||||
|
.iter()
|
||||||
|
.map(|lot| lot.quantity * lot.price)
|
||||||
|
.sum::<f64>(),
|
||||||
|
company_name: names_by_symbol
|
||||||
|
.get(&symbol)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| symbol.clone()),
|
||||||
|
latest_trade_at: latest_trade_at.get(&symbol).cloned(),
|
||||||
|
latest_trade_price: latest_trade_price.get(&symbol).copied(),
|
||||||
|
open_lots,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
positions.sort_by(|left, right| left.symbol.cmp(&right.symbol));
|
||||||
|
|
||||||
|
Ok(ReplaySnapshot {
|
||||||
|
cash_balance,
|
||||||
|
realized_gain,
|
||||||
|
positions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_cash_amount(transaction: &PortfolioTransaction) -> Result<(), PortfolioEngineError> {
|
||||||
|
if !transaction.gross_amount.is_finite()
|
||||||
|
|| !transaction.fee.is_finite()
|
||||||
|
|| transaction.gross_amount <= 0.0
|
||||||
|
|| transaction.fee < 0.0
|
||||||
|
{
|
||||||
|
return Err(PortfolioEngineError::InvalidTransactionNumbers {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_equity_transaction(
|
||||||
|
transaction: &PortfolioTransaction,
|
||||||
|
) -> Result<(String, String, f64, f64), PortfolioEngineError> {
|
||||||
|
let Some(symbol) = transaction
|
||||||
|
.symbol
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return Err(PortfolioEngineError::MissingTransactionFields {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(company_name) = transaction
|
||||||
|
.company_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return Err(PortfolioEngineError::MissingTransactionFields {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(quantity) = transaction.quantity else {
|
||||||
|
return Err(PortfolioEngineError::MissingTransactionFields {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(price) = transaction.price else {
|
||||||
|
return Err(PortfolioEngineError::MissingTransactionFields {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if !quantity.is_finite()
|
||||||
|
|| !price.is_finite()
|
||||||
|
|| !transaction.gross_amount.is_finite()
|
||||||
|
|| !transaction.fee.is_finite()
|
||||||
|
|| quantity <= 0.0
|
||||||
|
|| price <= 0.0
|
||||||
|
|| transaction.gross_amount <= 0.0
|
||||||
|
|| transaction.fee < 0.0
|
||||||
|
{
|
||||||
|
return Err(PortfolioEngineError::InvalidTransactionNumbers {
|
||||||
|
transaction_id: transaction.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
symbol.to_string(),
|
||||||
|
company_name.to_string(),
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_cash_available(
|
||||||
|
cash_balance: f64,
|
||||||
|
required_amount: f64,
|
||||||
|
transaction_id: &str,
|
||||||
|
) -> Result<(), PortfolioEngineError> {
|
||||||
|
if cash_balance + FLOAT_TOLERANCE < required_amount {
|
||||||
|
return Err(PortfolioEngineError::NegativeCash {
|
||||||
|
transaction_id: transaction_id.to_string(),
|
||||||
|
shortfall: required_amount - cash_balance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consume_fifo_lots(open_lots: &mut [OpenLot], requested_quantity: f64) -> f64 {
|
||||||
|
let mut remaining_quantity = requested_quantity;
|
||||||
|
let mut depleted_cost = 0.0;
|
||||||
|
|
||||||
|
for lot in open_lots {
|
||||||
|
if remaining_quantity <= FLOAT_TOLERANCE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumed_quantity = lot.quantity.min(remaining_quantity);
|
||||||
|
depleted_cost += consumed_quantity * lot.price;
|
||||||
|
lot.quantity -= consumed_quantity;
|
||||||
|
remaining_quantity -= consumed_quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
depleted_cost
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::replay_ledger;
|
||||||
|
use crate::portfolio::{PortfolioLedger, PortfolioTransaction, TransactionKind};
|
||||||
|
|
||||||
|
fn buy(
|
||||||
|
id: &str,
|
||||||
|
symbol: &str,
|
||||||
|
quantity: f64,
|
||||||
|
price: f64,
|
||||||
|
executed_at: &str,
|
||||||
|
) -> PortfolioTransaction {
|
||||||
|
PortfolioTransaction {
|
||||||
|
id: id.to_string(),
|
||||||
|
kind: TransactionKind::Buy,
|
||||||
|
symbol: Some(symbol.to_string()),
|
||||||
|
company_name: Some(format!("{symbol} Corp")),
|
||||||
|
quantity: Some(quantity),
|
||||||
|
price: Some(price),
|
||||||
|
gross_amount: quantity * price,
|
||||||
|
fee: 0.0,
|
||||||
|
executed_at: executed_at.to_string(),
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sell(
|
||||||
|
id: &str,
|
||||||
|
symbol: &str,
|
||||||
|
quantity: f64,
|
||||||
|
price: f64,
|
||||||
|
executed_at: &str,
|
||||||
|
) -> PortfolioTransaction {
|
||||||
|
PortfolioTransaction {
|
||||||
|
id: id.to_string(),
|
||||||
|
kind: TransactionKind::Sell,
|
||||||
|
symbol: Some(symbol.to_string()),
|
||||||
|
company_name: Some(format!("{symbol} Corp")),
|
||||||
|
quantity: Some(quantity),
|
||||||
|
price: Some(price),
|
||||||
|
gross_amount: quantity * price,
|
||||||
|
fee: 0.0,
|
||||||
|
executed_at: executed_at.to_string(),
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cash(
|
||||||
|
id: &str,
|
||||||
|
kind: TransactionKind,
|
||||||
|
amount: f64,
|
||||||
|
executed_at: &str,
|
||||||
|
) -> PortfolioTransaction {
|
||||||
|
PortfolioTransaction {
|
||||||
|
id: id.to_string(),
|
||||||
|
kind,
|
||||||
|
symbol: None,
|
||||||
|
company_name: None,
|
||||||
|
quantity: None,
|
||||||
|
price: None,
|
||||||
|
gross_amount: amount,
|
||||||
|
fee: 0.0,
|
||||||
|
executed_at: executed_at.to_string(),
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_creates_open_lot_and_reduces_cash_for_buy() {
|
||||||
|
let ledger = PortfolioLedger {
|
||||||
|
transactions: vec![
|
||||||
|
cash(
|
||||||
|
"1",
|
||||||
|
TransactionKind::CashDeposit,
|
||||||
|
1_000.0,
|
||||||
|
"2026-01-01T09:00:00Z",
|
||||||
|
),
|
||||||
|
buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"),
|
||||||
|
],
|
||||||
|
..PortfolioLedger::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = replay_ledger(&ledger).expect("replay should succeed");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.cash_balance, 500.0);
|
||||||
|
assert_eq!(snapshot.positions.len(), 1);
|
||||||
|
assert_eq!(snapshot.positions[0].quantity, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_keeps_multiple_fifo_lots_for_multiple_buys() {
|
||||||
|
let ledger = PortfolioLedger {
|
||||||
|
transactions: vec![
|
||||||
|
cash(
|
||||||
|
"1",
|
||||||
|
TransactionKind::CashDeposit,
|
||||||
|
2_000.0,
|
||||||
|
"2026-01-01T09:00:00Z",
|
||||||
|
),
|
||||||
|
buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"),
|
||||||
|
buy("3", "AAPL", 2.0, 120.0, "2026-01-02T10:00:00Z"),
|
||||||
|
],
|
||||||
|
..PortfolioLedger::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = replay_ledger(&ledger).expect("replay should succeed");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.positions[0].open_lots.len(), 2);
|
||||||
|
assert_eq!(snapshot.positions[0].quantity, 7.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_consumes_earliest_lots_first_for_sell() {
|
||||||
|
let ledger = PortfolioLedger {
|
||||||
|
transactions: vec![
|
||||||
|
cash(
|
||||||
|
"1",
|
||||||
|
TransactionKind::CashDeposit,
|
||||||
|
3_000.0,
|
||||||
|
"2026-01-01T09:00:00Z",
|
||||||
|
),
|
||||||
|
buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"),
|
||||||
|
buy("3", "AAPL", 5.0, 120.0, "2026-01-02T10:00:00Z"),
|
||||||
|
sell("4", "AAPL", 6.0, 130.0, "2026-01-03T10:00:00Z"),
|
||||||
|
],
|
||||||
|
..PortfolioLedger::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = replay_ledger(&ledger).expect("replay should succeed");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.positions[0].quantity, 4.0);
|
||||||
|
assert_eq!(snapshot.positions[0].open_lots[0].quantity, 4.0);
|
||||||
|
assert_eq!(snapshot.positions[0].open_lots[0].price, 120.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_computes_realized_gain_across_multiple_lots() {
|
||||||
|
let ledger = PortfolioLedger {
|
||||||
|
transactions: vec![
|
||||||
|
cash(
|
||||||
|
"1",
|
||||||
|
TransactionKind::CashDeposit,
|
||||||
|
3_000.0,
|
||||||
|
"2026-01-01T09:00:00Z",
|
||||||
|
),
|
||||||
|
buy("2", "AAPL", 5.0, 100.0, "2026-01-01T10:00:00Z"),
|
||||||
|
buy("3", "AAPL", 5.0, 120.0, "2026-01-02T10:00:00Z"),
|
||||||
|
sell("4", "AAPL", 6.0, 130.0, "2026-01-03T10:00:00Z"),
|
||||||
|
],
|
||||||
|
..PortfolioLedger::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = replay_ledger(&ledger).expect("replay should succeed");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.realized_gain, 160.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_updates_cash_for_cash_deposit_and_withdrawal() {
|
||||||
|
let ledger = PortfolioLedger {
|
||||||
|
transactions: vec![
|
||||||
|
cash(
|
||||||
|
"1",
|
||||||
|
TransactionKind::CashDeposit,
|
||||||
|
1_500.0,
|
||||||
|
"2026-01-01T09:00:00Z",
|
||||||
|
),
|
||||||
|
cash(
|
||||||
|
"2",
|
||||||
|
TransactionKind::CashWithdrawal,
|
||||||
|
250.0,
|
||||||
|
"2026-01-01T10:00:00Z",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
..PortfolioLedger::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = replay_ledger(&ledger).expect("replay should succeed");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.cash_balance, 1_250.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_returns_zeroed_snapshot_for_empty_ledger() {
|
||||||
|
let snapshot = replay_ledger(&PortfolioLedger::default()).expect("replay should succeed");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.cash_balance, 0.0);
|
||||||
|
assert_eq!(snapshot.realized_gain, 0.0);
|
||||||
|
assert!(snapshot.positions.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
14
MosaicIQ/src-tauri/src/portfolio/mod.rs
Normal file
14
MosaicIQ/src-tauri/src/portfolio/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
mod engine;
|
||||||
|
mod service;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use service::{
|
||||||
|
PortfolioCommandError, PortfolioManagement, PortfolioQuoteError, PortfolioService,
|
||||||
|
PortfolioStoreError, PortfolioValidationError,
|
||||||
|
};
|
||||||
|
pub use types::{
|
||||||
|
CashConfirmation, OpenLot, PortfolioHolding, PortfolioLedger, PortfolioSnapshot,
|
||||||
|
PortfolioStats, PortfolioTransaction, PositionSnapshot, ReplaySnapshot, TradeConfirmation,
|
||||||
|
TransactionKind, PORTFOLIO_LEDGER_KEY, PORTFOLIO_LEDGER_STORE_PATH,
|
||||||
|
};
|
||||||
725
MosaicIQ/src-tauri/src/portfolio/service.rs
Normal file
725
MosaicIQ/src-tauri/src/portfolio/service.rs
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use futures::stream::{self, StreamExt};
|
||||||
|
use serde_json::json;
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::portfolio::engine::{replay_ledger, PortfolioEngineError};
|
||||||
|
use crate::portfolio::{
|
||||||
|
CashConfirmation, PortfolioHolding, PortfolioLedger, PortfolioSnapshot, PortfolioStats,
|
||||||
|
PortfolioTransaction, ReplaySnapshot, TradeConfirmation, TransactionKind, PORTFOLIO_LEDGER_KEY,
|
||||||
|
PORTFOLIO_LEDGER_STORE_PATH,
|
||||||
|
};
|
||||||
|
use crate::terminal::security_lookup::{
|
||||||
|
SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
|
||||||
|
};
|
||||||
|
use crate::terminal::{Holding, Portfolio};
|
||||||
|
|
||||||
|
const QUOTE_CONCURRENCY_LIMIT: usize = 4;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PortfolioStoreError {
|
||||||
|
#[error("portfolio store unavailable: {0}")]
|
||||||
|
StoreUnavailable(String),
|
||||||
|
#[error("portfolio ledger is not valid JSON: {0}")]
|
||||||
|
Deserialize(String),
|
||||||
|
#[error("portfolio ledger could not be saved: {0}")]
|
||||||
|
SaveFailed(String),
|
||||||
|
#[error("portfolio ledger is invalid: {0}")]
|
||||||
|
CorruptLedger(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PortfolioValidationError {
|
||||||
|
#[error("cash balance is insufficient for this trade")]
|
||||||
|
InsufficientCash,
|
||||||
|
#[error("cash balance is insufficient for this withdrawal")]
|
||||||
|
InsufficientCashWithdrawal,
|
||||||
|
#[error("not enough shares are available to sell for {symbol}")]
|
||||||
|
InsufficientShares { symbol: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PortfolioQuoteError {
|
||||||
|
#[error("quote unavailable for {symbol}: {detail}")]
|
||||||
|
Unavailable { symbol: String, detail: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PortfolioCommandError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Store(#[from] PortfolioStoreError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Validation(#[from] PortfolioValidationError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Quote(#[from] PortfolioQuoteError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait PortfolioManagement: Send + Sync {
|
||||||
|
fn portfolio<'a>(&'a self) -> BoxFuture<'a, Result<Portfolio, PortfolioCommandError>>;
|
||||||
|
fn stats<'a>(&'a self) -> BoxFuture<'a, Result<PortfolioStats, PortfolioCommandError>>;
|
||||||
|
fn history<'a>(
|
||||||
|
&'a self,
|
||||||
|
limit: usize,
|
||||||
|
) -> BoxFuture<'a, Result<Vec<PortfolioTransaction>, PortfolioCommandError>>;
|
||||||
|
fn buy<'a>(
|
||||||
|
&'a self,
|
||||||
|
symbol: &'a str,
|
||||||
|
quantity: f64,
|
||||||
|
price_override: Option<f64>,
|
||||||
|
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>>;
|
||||||
|
fn sell<'a>(
|
||||||
|
&'a self,
|
||||||
|
symbol: &'a str,
|
||||||
|
quantity: f64,
|
||||||
|
price_override: Option<f64>,
|
||||||
|
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>>;
|
||||||
|
fn deposit_cash<'a>(
|
||||||
|
&'a self,
|
||||||
|
amount: f64,
|
||||||
|
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>>;
|
||||||
|
fn withdraw_cash<'a>(
|
||||||
|
&'a self,
|
||||||
|
amount: f64,
|
||||||
|
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PortfolioService<R: Runtime> {
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
security_lookup: Arc<dyn SecurityLookup>,
|
||||||
|
write_lock: Mutex<()>,
|
||||||
|
next_transaction_id: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Runtime> PortfolioService<R> {
|
||||||
|
pub fn new(app_handle: &AppHandle<R>, security_lookup: Arc<dyn SecurityLookup>) -> Self {
|
||||||
|
Self {
|
||||||
|
app_handle: app_handle.clone(),
|
||||||
|
security_lookup,
|
||||||
|
write_lock: Mutex::new(()),
|
||||||
|
next_transaction_id: AtomicU64::new(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_ledger(&self) -> Result<PortfolioLedger, PortfolioStoreError> {
|
||||||
|
let store = self
|
||||||
|
.app_handle
|
||||||
|
.store(PORTFOLIO_LEDGER_STORE_PATH)
|
||||||
|
.map_err(|error| PortfolioStoreError::StoreUnavailable(error.to_string()))?;
|
||||||
|
|
||||||
|
match store.get(PORTFOLIO_LEDGER_KEY) {
|
||||||
|
Some(value) => serde_json::from_value(value.clone())
|
||||||
|
.map_err(|error| PortfolioStoreError::Deserialize(error.to_string())),
|
||||||
|
None => Ok(PortfolioLedger::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_ledger(&self, ledger: &PortfolioLedger) -> Result<(), PortfolioStoreError> {
|
||||||
|
let store = self
|
||||||
|
.app_handle
|
||||||
|
.store(PORTFOLIO_LEDGER_STORE_PATH)
|
||||||
|
.map_err(|error| PortfolioStoreError::StoreUnavailable(error.to_string()))?;
|
||||||
|
|
||||||
|
store.set(PORTFOLIO_LEDGER_KEY.to_string(), json!(ledger));
|
||||||
|
store
|
||||||
|
.save()
|
||||||
|
.map_err(|error| PortfolioStoreError::SaveFailed(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay(&self, ledger: &PortfolioLedger) -> Result<ReplaySnapshot, PortfolioStoreError> {
|
||||||
|
replay_ledger(ledger).map_err(map_engine_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_trade_input(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
price_override: Option<f64>,
|
||||||
|
) -> Result<(String, String, f64), PortfolioCommandError> {
|
||||||
|
let normalized_symbol = symbol.trim().to_ascii_uppercase();
|
||||||
|
let security_match = SecurityMatch {
|
||||||
|
symbol: normalized_symbol.clone(),
|
||||||
|
name: None,
|
||||||
|
exchange: None,
|
||||||
|
kind: SecurityKind::Equity,
|
||||||
|
};
|
||||||
|
|
||||||
|
let company = self
|
||||||
|
.security_lookup
|
||||||
|
.load_company(&security_match)
|
||||||
|
.await
|
||||||
|
.map_err(|error| map_quote_error(&normalized_symbol, error))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
normalized_symbol,
|
||||||
|
company.name,
|
||||||
|
price_override.unwrap_or(company.price),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn candidate_trade_result(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
company_name: &str,
|
||||||
|
quantity: f64,
|
||||||
|
price: f64,
|
||||||
|
kind: TransactionKind,
|
||||||
|
) -> Result<TradeConfirmation, PortfolioCommandError> {
|
||||||
|
let _guard = self.write_lock.lock().await;
|
||||||
|
let mut ledger = self.load_ledger()?;
|
||||||
|
let before_snapshot = self.replay(&ledger)?;
|
||||||
|
|
||||||
|
let transaction = PortfolioTransaction {
|
||||||
|
id: self.next_transaction_id(),
|
||||||
|
kind: kind.clone(),
|
||||||
|
symbol: Some(symbol.to_string()),
|
||||||
|
company_name: Some(company_name.to_string()),
|
||||||
|
quantity: Some(quantity),
|
||||||
|
price: Some(price),
|
||||||
|
gross_amount: quantity * price,
|
||||||
|
fee: 0.0,
|
||||||
|
executed_at: Utc::now().to_rfc3339(),
|
||||||
|
note: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
ledger.transactions.push(transaction);
|
||||||
|
|
||||||
|
let after_snapshot = self.replay(&ledger).map_err(|error| match error {
|
||||||
|
PortfolioStoreError::CorruptLedger(message)
|
||||||
|
if message.contains("would make cash negative") =>
|
||||||
|
{
|
||||||
|
PortfolioCommandError::Validation(PortfolioValidationError::InsufficientCash)
|
||||||
|
}
|
||||||
|
PortfolioStoreError::CorruptLedger(message) if message.contains("tries to sell") => {
|
||||||
|
PortfolioCommandError::Validation(PortfolioValidationError::InsufficientShares {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => PortfolioCommandError::Store(other),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.save_ledger(&ledger)?;
|
||||||
|
|
||||||
|
Ok(TradeConfirmation {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
company_name: company_name.to_string(),
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
gross_amount: quantity * price,
|
||||||
|
cash_balance: after_snapshot.cash_balance,
|
||||||
|
realized_gain: matches!(kind, TransactionKind::Sell)
|
||||||
|
.then_some(after_snapshot.realized_gain - before_snapshot.realized_gain),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn candidate_cash_result(
|
||||||
|
&self,
|
||||||
|
amount: f64,
|
||||||
|
kind: TransactionKind,
|
||||||
|
) -> Result<CashConfirmation, PortfolioCommandError> {
|
||||||
|
let _guard = self.write_lock.lock().await;
|
||||||
|
let mut ledger = self.load_ledger()?;
|
||||||
|
|
||||||
|
ledger.transactions.push(PortfolioTransaction {
|
||||||
|
id: self.next_transaction_id(),
|
||||||
|
kind: kind.clone(),
|
||||||
|
symbol: None,
|
||||||
|
company_name: None,
|
||||||
|
quantity: None,
|
||||||
|
price: None,
|
||||||
|
gross_amount: amount,
|
||||||
|
fee: 0.0,
|
||||||
|
executed_at: Utc::now().to_rfc3339(),
|
||||||
|
note: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let snapshot = self.replay(&ledger).map_err(|error| match error {
|
||||||
|
PortfolioStoreError::CorruptLedger(message)
|
||||||
|
if message.contains("would make cash negative") =>
|
||||||
|
{
|
||||||
|
PortfolioCommandError::Validation(
|
||||||
|
PortfolioValidationError::InsufficientCashWithdrawal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => PortfolioCommandError::Store(other),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.save_ledger(&ledger)?;
|
||||||
|
|
||||||
|
Ok(CashConfirmation {
|
||||||
|
amount,
|
||||||
|
cash_balance: snapshot.cash_balance,
|
||||||
|
kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn value_portfolio(&self) -> Result<PortfolioSnapshot, PortfolioCommandError> {
|
||||||
|
let ledger = self.load_ledger()?;
|
||||||
|
let replay_snapshot = self.replay(&ledger)?;
|
||||||
|
|
||||||
|
let priced_holdings = stream::iter(replay_snapshot.positions.iter().cloned())
|
||||||
|
.map(|position| async move {
|
||||||
|
let security_match = SecurityMatch {
|
||||||
|
symbol: position.symbol.clone(),
|
||||||
|
name: Some(position.company_name.clone()),
|
||||||
|
exchange: None,
|
||||||
|
kind: SecurityKind::Equity,
|
||||||
|
};
|
||||||
|
|
||||||
|
let quote = self.security_lookup.load_company(&security_match).await;
|
||||||
|
(position, quote)
|
||||||
|
})
|
||||||
|
.buffer_unordered(QUOTE_CONCURRENCY_LIMIT)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut holdings = Vec::with_capacity(priced_holdings.len());
|
||||||
|
let mut stale_pricing_symbols = Vec::new();
|
||||||
|
let mut equities_market_value = 0.0;
|
||||||
|
let mut unrealized_gain = 0.0;
|
||||||
|
let mut day_change = 0.0;
|
||||||
|
|
||||||
|
for (position, quote_result) in priced_holdings {
|
||||||
|
let (current_price, day_change_value, stale_pricing) = match quote_result {
|
||||||
|
Ok(company) => (company.price, company.change * position.quantity, false),
|
||||||
|
Err(_) => {
|
||||||
|
let fallback_price = position.latest_trade_price.unwrap_or(0.0);
|
||||||
|
stale_pricing_symbols.push(position.symbol.clone());
|
||||||
|
(fallback_price, 0.0, true)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_value = current_price * position.quantity;
|
||||||
|
let holding_unrealized_gain = current_value - position.cost_basis;
|
||||||
|
equities_market_value += current_value;
|
||||||
|
unrealized_gain += holding_unrealized_gain;
|
||||||
|
day_change += day_change_value;
|
||||||
|
|
||||||
|
holdings.push(PortfolioHolding {
|
||||||
|
symbol: position.symbol,
|
||||||
|
name: position.company_name,
|
||||||
|
quantity: position.quantity,
|
||||||
|
cost_basis: position.cost_basis,
|
||||||
|
current_price,
|
||||||
|
current_value,
|
||||||
|
unrealized_gain: holding_unrealized_gain,
|
||||||
|
gain_loss_percent: percent_change(holding_unrealized_gain, position.cost_basis),
|
||||||
|
latest_trade_at: position.latest_trade_at,
|
||||||
|
stale_pricing,
|
||||||
|
day_change: day_change_value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
holdings.sort_by(|left, right| left.symbol.cmp(&right.symbol));
|
||||||
|
|
||||||
|
let invested_cost_basis = holdings
|
||||||
|
.iter()
|
||||||
|
.map(|holding| holding.cost_basis)
|
||||||
|
.sum::<f64>();
|
||||||
|
let total_portfolio_value = equities_market_value + replay_snapshot.cash_balance;
|
||||||
|
let baseline_value = total_portfolio_value - day_change;
|
||||||
|
|
||||||
|
Ok(PortfolioSnapshot {
|
||||||
|
cash_balance: replay_snapshot.cash_balance,
|
||||||
|
day_change,
|
||||||
|
day_change_percent: if baseline_value > 0.0 {
|
||||||
|
(day_change / baseline_value) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
equities_market_value,
|
||||||
|
holdings,
|
||||||
|
invested_cost_basis,
|
||||||
|
realized_gain: replay_snapshot.realized_gain,
|
||||||
|
stale_pricing_symbols,
|
||||||
|
total_portfolio_value,
|
||||||
|
unrealized_gain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_transaction_id(&self) -> String {
|
||||||
|
let id = self.next_transaction_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
format!("portfolio-tx-{id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: Runtime> PortfolioManagement for PortfolioService<R> {
|
||||||
|
fn portfolio<'a>(&'a self) -> BoxFuture<'a, Result<Portfolio, PortfolioCommandError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let snapshot = self.value_portfolio().await?;
|
||||||
|
let holdings_count = snapshot.holdings.len();
|
||||||
|
|
||||||
|
Ok(Portfolio {
|
||||||
|
cash_balance: Some(snapshot.cash_balance),
|
||||||
|
day_change: snapshot.day_change,
|
||||||
|
day_change_percent: snapshot.day_change_percent,
|
||||||
|
holdings: snapshot
|
||||||
|
.holdings
|
||||||
|
.into_iter()
|
||||||
|
.map(|holding| Holding {
|
||||||
|
avg_cost: if holding.quantity > 0.0 {
|
||||||
|
holding.cost_basis / holding.quantity
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
cost_basis: Some(holding.cost_basis),
|
||||||
|
current_price: holding.current_price,
|
||||||
|
current_value: holding.current_value,
|
||||||
|
gain_loss: holding.unrealized_gain,
|
||||||
|
gain_loss_percent: holding.gain_loss_percent,
|
||||||
|
latest_trade_at: holding.latest_trade_at,
|
||||||
|
name: holding.name,
|
||||||
|
quantity: holding.quantity,
|
||||||
|
symbol: holding.symbol,
|
||||||
|
unrealized_gain: Some(holding.unrealized_gain),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
holdings_count: Some(holdings_count),
|
||||||
|
invested_cost_basis: Some(snapshot.invested_cost_basis),
|
||||||
|
realized_gain: Some(snapshot.realized_gain),
|
||||||
|
stale_pricing_symbols: Some(snapshot.stale_pricing_symbols),
|
||||||
|
total_gain: snapshot.unrealized_gain,
|
||||||
|
total_gain_percent: percent_change(
|
||||||
|
snapshot.unrealized_gain,
|
||||||
|
snapshot.invested_cost_basis,
|
||||||
|
),
|
||||||
|
total_value: snapshot.total_portfolio_value,
|
||||||
|
unrealized_gain: Some(snapshot.unrealized_gain),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stats<'a>(&'a self) -> BoxFuture<'a, Result<PortfolioStats, PortfolioCommandError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let snapshot = self.value_portfolio().await?;
|
||||||
|
|
||||||
|
Ok(PortfolioStats {
|
||||||
|
cash_balance: snapshot.cash_balance,
|
||||||
|
day_change: snapshot.day_change,
|
||||||
|
equities_market_value: snapshot.equities_market_value,
|
||||||
|
holdings_count: snapshot.holdings.len(),
|
||||||
|
invested_cost_basis: snapshot.invested_cost_basis,
|
||||||
|
realized_gain: snapshot.realized_gain,
|
||||||
|
stale_pricing_symbols: snapshot.stale_pricing_symbols,
|
||||||
|
total_portfolio_value: snapshot.total_portfolio_value,
|
||||||
|
unrealized_gain: snapshot.unrealized_gain,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history<'a>(
|
||||||
|
&'a self,
|
||||||
|
limit: usize,
|
||||||
|
) -> BoxFuture<'a, Result<Vec<PortfolioTransaction>, PortfolioCommandError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let ledger = self.load_ledger()?;
|
||||||
|
let mut transactions = ledger.transactions;
|
||||||
|
transactions.sort_by(|left, right| {
|
||||||
|
right
|
||||||
|
.executed_at
|
||||||
|
.cmp(&left.executed_at)
|
||||||
|
.then_with(|| right.id.cmp(&left.id))
|
||||||
|
});
|
||||||
|
transactions.truncate(limit);
|
||||||
|
Ok(transactions)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buy<'a>(
|
||||||
|
&'a self,
|
||||||
|
symbol: &'a str,
|
||||||
|
quantity: f64,
|
||||||
|
price_override: Option<f64>,
|
||||||
|
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let (symbol, company_name, price) =
|
||||||
|
self.resolve_trade_input(symbol, price_override).await?;
|
||||||
|
self.candidate_trade_result(
|
||||||
|
&symbol,
|
||||||
|
&company_name,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
TransactionKind::Buy,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sell<'a>(
|
||||||
|
&'a self,
|
||||||
|
symbol: &'a str,
|
||||||
|
quantity: f64,
|
||||||
|
price_override: Option<f64>,
|
||||||
|
) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let (symbol, company_name, price) =
|
||||||
|
self.resolve_trade_input(symbol, price_override).await?;
|
||||||
|
self.candidate_trade_result(
|
||||||
|
&symbol,
|
||||||
|
&company_name,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
TransactionKind::Sell,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deposit_cash<'a>(
|
||||||
|
&'a self,
|
||||||
|
amount: f64,
|
||||||
|
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
self.candidate_cash_result(amount, TransactionKind::CashDeposit)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn withdraw_cash<'a>(
|
||||||
|
&'a self,
|
||||||
|
amount: f64,
|
||||||
|
) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
self.candidate_cash_result(amount, TransactionKind::CashWithdrawal)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_engine_error(error: PortfolioEngineError) -> PortfolioStoreError {
|
||||||
|
PortfolioStoreError::CorruptLedger(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_quote_error(symbol: &str, error: SecurityLookupError) -> PortfolioCommandError {
|
||||||
|
let detail = match error {
|
||||||
|
SecurityLookupError::DetailUnavailable { detail, .. }
|
||||||
|
| SecurityLookupError::SearchUnavailable { detail, .. } => detail,
|
||||||
|
};
|
||||||
|
|
||||||
|
PortfolioCommandError::Quote(PortfolioQuoteError::Unavailable {
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_change(delta: f64, base: f64) -> f64 {
|
||||||
|
if base > 0.0 {
|
||||||
|
(delta / base) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime};
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
use super::{PortfolioCommandError, PortfolioManagement, PortfolioService};
|
||||||
|
use crate::portfolio::{
|
||||||
|
PortfolioLedger, PortfolioTransaction, PortfolioValidationError, TransactionKind,
|
||||||
|
PORTFOLIO_LEDGER_KEY, PORTFOLIO_LEDGER_STORE_PATH,
|
||||||
|
};
|
||||||
|
use crate::terminal::security_lookup::{SecurityLookup, SecurityLookupError, SecurityMatch};
|
||||||
|
use crate::terminal::Company;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FakeSecurityLookup;
|
||||||
|
|
||||||
|
impl SecurityLookup for FakeSecurityLookup {
|
||||||
|
fn provider_name(&self) -> &'static str {
|
||||||
|
"Google Finance"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search<'a>(
|
||||||
|
&'a self,
|
||||||
|
_query: &'a str,
|
||||||
|
) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> {
|
||||||
|
Box::pin(async { Ok(Vec::new()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_company<'a>(
|
||||||
|
&'a self,
|
||||||
|
security_match: &'a SecurityMatch,
|
||||||
|
) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
Ok(Company {
|
||||||
|
symbol: security_match.symbol.clone(),
|
||||||
|
name: format!("{} Corp", security_match.symbol),
|
||||||
|
price: 100.0,
|
||||||
|
change: 2.0,
|
||||||
|
change_percent: 2.0,
|
||||||
|
market_cap: 1_000_000.0,
|
||||||
|
volume: None,
|
||||||
|
volume_label: None,
|
||||||
|
pe: None,
|
||||||
|
eps: None,
|
||||||
|
high52_week: None,
|
||||||
|
low52_week: None,
|
||||||
|
profile: None,
|
||||||
|
price_chart: None,
|
||||||
|
price_chart_ranges: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_default_empty_ledger_when_store_is_empty() {
|
||||||
|
with_test_home("empty-ledger", || {
|
||||||
|
let service = build_service();
|
||||||
|
|
||||||
|
let history =
|
||||||
|
futures::executor::block_on(service.history(10)).expect("history should load");
|
||||||
|
|
||||||
|
assert!(history.is_empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persists_appended_transactions() {
|
||||||
|
with_test_home("persisted-ledger", || {
|
||||||
|
let service = build_service();
|
||||||
|
|
||||||
|
futures::executor::block_on(service.deposit_cash(1_000.0))
|
||||||
|
.expect("deposit should succeed");
|
||||||
|
|
||||||
|
let history =
|
||||||
|
futures::executor::block_on(service.history(10)).expect("history should load");
|
||||||
|
|
||||||
|
assert_eq!(history.len(), 1);
|
||||||
|
assert_eq!(history[0].kind, TransactionKind::CashDeposit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_buy_when_cash_is_insufficient() {
|
||||||
|
with_test_home("buy-insufficient-cash", || {
|
||||||
|
let service = build_service();
|
||||||
|
|
||||||
|
let error = futures::executor::block_on(service.buy("AAPL", 10.0, Some(100.0)))
|
||||||
|
.expect_err("buy should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
PortfolioCommandError::Validation(PortfolioValidationError::InsufficientCash)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_sell_when_shares_are_insufficient() {
|
||||||
|
with_test_home("sell-insufficient-shares", || {
|
||||||
|
let service = build_service();
|
||||||
|
|
||||||
|
futures::executor::block_on(service.deposit_cash(1_000.0))
|
||||||
|
.expect("deposit should succeed");
|
||||||
|
|
||||||
|
let error = futures::executor::block_on(service.sell("AAPL", 1.0, Some(100.0)))
|
||||||
|
.expect_err("sell should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
PortfolioCommandError::Validation(
|
||||||
|
PortfolioValidationError::InsufficientShares { .. }
|
||||||
|
)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_withdrawal_when_cash_is_insufficient() {
|
||||||
|
with_test_home("withdrawal-insufficient-cash", || {
|
||||||
|
let service = build_service();
|
||||||
|
|
||||||
|
let error = futures::executor::block_on(service.withdraw_cash(100.0))
|
||||||
|
.expect_err("withdrawal should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
PortfolioCommandError::Validation(
|
||||||
|
PortfolioValidationError::InsufficientCashWithdrawal
|
||||||
|
)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_service() -> PortfolioService<MockRuntime> {
|
||||||
|
let app = mock_builder()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.build(mock_context(noop_assets()))
|
||||||
|
.expect("test app should build");
|
||||||
|
|
||||||
|
PortfolioService::new(&app.handle(), Arc::new(FakeSecurityLookup))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn seed_ledger(service: &PortfolioService<MockRuntime>, ledger: PortfolioLedger) {
|
||||||
|
let store = service
|
||||||
|
.app_handle
|
||||||
|
.store(PORTFOLIO_LEDGER_STORE_PATH)
|
||||||
|
.expect("store should exist");
|
||||||
|
store.set(PORTFOLIO_LEDGER_KEY.to_string(), serde_json::json!(ledger));
|
||||||
|
store.save().expect("store should save");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn cash_transaction(id: &str, amount: f64) -> PortfolioTransaction {
|
||||||
|
PortfolioTransaction {
|
||||||
|
id: id.to_string(),
|
||||||
|
kind: TransactionKind::CashDeposit,
|
||||||
|
symbol: None,
|
||||||
|
company_name: None,
|
||||||
|
quantity: None,
|
||||||
|
price: None,
|
||||||
|
gross_amount: amount,
|
||||||
|
fee: 0.0,
|
||||||
|
executed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_identifier(prefix: &str) -> String {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock should work")
|
||||||
|
.as_nanos();
|
||||||
|
format!("com.mosaiciq.portfolio.tests.{prefix}.{nanos}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_test_home<T>(prefix: &str, test: impl FnOnce() -> T) -> T {
|
||||||
|
let _lock = crate::test_support::env_lock()
|
||||||
|
.lock()
|
||||||
|
.expect("env lock should succeed");
|
||||||
|
let home = env::temp_dir().join(unique_identifier(prefix));
|
||||||
|
fs::create_dir_all(&home).expect("home dir should exist");
|
||||||
|
|
||||||
|
let original_home = env::var_os("HOME");
|
||||||
|
env::set_var("HOME", &home);
|
||||||
|
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test));
|
||||||
|
|
||||||
|
match original_home {
|
||||||
|
Some(value) => env::set_var("HOME", value),
|
||||||
|
None => env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&home);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(payload) => std::panic::resume_unwind(payload),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
MosaicIQ/src-tauri/src/portfolio/types.rs
Normal file
164
MosaicIQ/src-tauri/src/portfolio/types.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Store path for the persisted portfolio ledger.
|
||||||
|
pub const PORTFOLIO_LEDGER_STORE_PATH: &str = "portfolio-ledger.json";
|
||||||
|
/// Top-level key used inside the Tauri store.
|
||||||
|
pub const PORTFOLIO_LEDGER_KEY: &str = "ledger";
|
||||||
|
/// Current persisted schema version.
|
||||||
|
pub const PORTFOLIO_SCHEMA_VERSION: u32 = 1;
|
||||||
|
/// Base currency supported by the local portfolio backend.
|
||||||
|
pub const DEFAULT_BASE_CURRENCY: &str = "USD";
|
||||||
|
|
||||||
|
/// Persisted portfolio ledger containing the transaction source of truth.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PortfolioLedger {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub base_currency: String,
|
||||||
|
pub transactions: Vec<PortfolioTransaction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PortfolioLedger {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: PORTFOLIO_SCHEMA_VERSION,
|
||||||
|
base_currency: DEFAULT_BASE_CURRENCY.to_string(),
|
||||||
|
transactions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported portfolio transaction kinds.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TransactionKind {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
CashDeposit,
|
||||||
|
CashWithdrawal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransactionKind {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Buy => "BUY",
|
||||||
|
Self::Sell => "SELL",
|
||||||
|
Self::CashDeposit => "CASH_DEPOSIT",
|
||||||
|
Self::CashWithdrawal => "CASH_WITHDRAWAL",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted transaction record.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PortfolioTransaction {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: TransactionKind,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub company_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub quantity: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub price: Option<f64>,
|
||||||
|
pub gross_amount: f64,
|
||||||
|
pub fee: f64,
|
||||||
|
pub executed_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open FIFO lot tracked during replay.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OpenLot {
|
||||||
|
pub quantity: f64,
|
||||||
|
pub price: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay-derived position state before live quote enrichment.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PositionSnapshot {
|
||||||
|
pub symbol: String,
|
||||||
|
pub company_name: String,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub cost_basis: f64,
|
||||||
|
pub open_lots: Vec<OpenLot>,
|
||||||
|
pub latest_trade_at: Option<String>,
|
||||||
|
pub latest_trade_price: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay-derived snapshot for the entire ledger.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ReplaySnapshot {
|
||||||
|
pub cash_balance: f64,
|
||||||
|
pub realized_gain: f64,
|
||||||
|
pub positions: Vec<PositionSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live-priced holding row returned to command consumers.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PortfolioHolding {
|
||||||
|
pub symbol: String,
|
||||||
|
pub name: String,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub cost_basis: f64,
|
||||||
|
pub current_price: f64,
|
||||||
|
pub current_value: f64,
|
||||||
|
pub unrealized_gain: f64,
|
||||||
|
pub gain_loss_percent: f64,
|
||||||
|
pub latest_trade_at: Option<String>,
|
||||||
|
pub stale_pricing: bool,
|
||||||
|
pub day_change: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Portfolio valuation and summary stats after live price enrichment.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PortfolioSnapshot {
|
||||||
|
pub holdings: Vec<PortfolioHolding>,
|
||||||
|
pub cash_balance: f64,
|
||||||
|
pub invested_cost_basis: f64,
|
||||||
|
pub equities_market_value: f64,
|
||||||
|
pub total_portfolio_value: f64,
|
||||||
|
pub realized_gain: f64,
|
||||||
|
pub unrealized_gain: f64,
|
||||||
|
pub day_change: f64,
|
||||||
|
pub day_change_percent: f64,
|
||||||
|
pub stale_pricing_symbols: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary values rendered by `/portfolio stats`.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PortfolioStats {
|
||||||
|
pub cash_balance: f64,
|
||||||
|
pub holdings_count: usize,
|
||||||
|
pub invested_cost_basis: f64,
|
||||||
|
pub equities_market_value: f64,
|
||||||
|
pub total_portfolio_value: f64,
|
||||||
|
pub realized_gain: f64,
|
||||||
|
pub unrealized_gain: f64,
|
||||||
|
pub day_change: f64,
|
||||||
|
pub stale_pricing_symbols: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation payload returned after a trade command succeeds.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TradeConfirmation {
|
||||||
|
pub symbol: String,
|
||||||
|
pub company_name: String,
|
||||||
|
pub quantity: f64,
|
||||||
|
pub price: f64,
|
||||||
|
pub gross_amount: f64,
|
||||||
|
pub cash_balance: f64,
|
||||||
|
pub realized_gain: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation payload returned after a cash command succeeds.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct CashConfirmation {
|
||||||
|
pub amount: f64,
|
||||||
|
pub cash_balance: f64,
|
||||||
|
pub kind: TransactionKind,
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ use tauri::{AppHandle, Wry};
|
|||||||
|
|
||||||
use crate::agent::{AgentService, AgentSettingsService};
|
use crate::agent::{AgentService, AgentSettingsService};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::portfolio::PortfolioService;
|
||||||
use crate::terminal::google_finance::GoogleFinanceLookup;
|
use crate::terminal::google_finance::GoogleFinanceLookup;
|
||||||
use crate::terminal::sec_edgar::{
|
use crate::terminal::sec_edgar::{
|
||||||
LiveSecFetcher, SecEdgarClient, SecEdgarLookup, SecUserAgentProvider,
|
LiveSecFetcher, SecEdgarClient, SecEdgarLookup, SecUserAgentProvider,
|
||||||
};
|
};
|
||||||
|
use crate::terminal::security_lookup::SecurityLookup;
|
||||||
use crate::terminal::TerminalCommandService;
|
use crate::terminal::TerminalCommandService;
|
||||||
|
|
||||||
struct SettingsBackedSecUserAgentProvider {
|
struct SettingsBackedSecUserAgentProvider {
|
||||||
@@ -49,15 +51,19 @@ impl AppState {
|
|||||||
let sec_user_agent_provider = Arc::new(SettingsBackedSecUserAgentProvider {
|
let sec_user_agent_provider = Arc::new(SettingsBackedSecUserAgentProvider {
|
||||||
settings: AgentSettingsService::new(app_handle),
|
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(
|
let sec_edgar_lookup = Arc::new(SecEdgarLookup::new(Arc::new(SecEdgarClient::new(
|
||||||
Box::new(LiveSecFetcher::new(sec_user_agent_provider)),
|
Box::new(LiveSecFetcher::new(sec_user_agent_provider)),
|
||||||
))));
|
))));
|
||||||
|
let portfolio_service =
|
||||||
|
Arc::new(PortfolioService::new(app_handle, security_lookup.clone()));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
agent: Mutex::new(AgentService::new(app_handle)?),
|
agent: Mutex::new(AgentService::new(app_handle)?),
|
||||||
command_service: TerminalCommandService::new(
|
command_service: TerminalCommandService::new(
|
||||||
Arc::new(GoogleFinanceLookup::default()),
|
security_lookup,
|
||||||
sec_edgar_lookup,
|
sec_edgar_lookup,
|
||||||
|
portfolio_service,
|
||||||
),
|
),
|
||||||
next_request_id: AtomicU64::new(1),
|
next_request_id: AtomicU64::new(1),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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::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::{
|
use crate::terminal::security_lookup::{
|
||||||
SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
|
SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
|
||||||
};
|
};
|
||||||
@@ -17,30 +20,22 @@ pub struct TerminalCommandService {
|
|||||||
mock_data: MockFinancialData,
|
mock_data: MockFinancialData,
|
||||||
security_lookup: Arc<dyn SecurityLookup>,
|
security_lookup: Arc<dyn SecurityLookup>,
|
||||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||||
|
portfolio_service: Arc<dyn PortfolioManagement>,
|
||||||
lookup_followup_delay: Duration,
|
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 {
|
impl TerminalCommandService {
|
||||||
/// Creates a terminal command service with a custom security lookup backend.
|
/// Creates a terminal command service with a custom security lookup backend.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
security_lookup: Arc<dyn SecurityLookup>,
|
security_lookup: Arc<dyn SecurityLookup>,
|
||||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||||
|
portfolio_service: Arc<dyn PortfolioManagement>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::with_dependencies(
|
Self::with_dependencies(
|
||||||
load_mock_financial_data(),
|
load_mock_financial_data(),
|
||||||
security_lookup,
|
security_lookup,
|
||||||
edgar_lookup,
|
edgar_lookup,
|
||||||
|
portfolio_service,
|
||||||
DEFAULT_LOOKUP_FOLLOWUP_DELAY,
|
DEFAULT_LOOKUP_FOLLOWUP_DELAY,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -49,12 +44,14 @@ impl TerminalCommandService {
|
|||||||
mock_data: MockFinancialData,
|
mock_data: MockFinancialData,
|
||||||
security_lookup: Arc<dyn SecurityLookup>,
|
security_lookup: Arc<dyn SecurityLookup>,
|
||||||
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
edgar_lookup: Arc<dyn EdgarDataLookup>,
|
||||||
|
portfolio_service: Arc<dyn PortfolioManagement>,
|
||||||
lookup_followup_delay: Duration,
|
lookup_followup_delay: Duration,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
mock_data,
|
mock_data,
|
||||||
security_lookup,
|
security_lookup,
|
||||||
edgar_lookup,
|
edgar_lookup,
|
||||||
|
portfolio_service,
|
||||||
lookup_followup_delay,
|
lookup_followup_delay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,11 +62,10 @@ impl TerminalCommandService {
|
|||||||
|
|
||||||
match command.command.as_str() {
|
match command.command.as_str() {
|
||||||
"/search" => self.search(command.args.join(" ").trim()).await,
|
"/search" => self.search(command.args.join(" ").trim()).await,
|
||||||
"/portfolio" => TerminalCommandResponse::Panel {
|
"/portfolio" => self.portfolio_command(&command.args).await,
|
||||||
panel: PanelPayload::Portfolio {
|
"/buy" => self.buy_command(&command.args).await,
|
||||||
data: self.mock_data.portfolio.clone(),
|
"/sell" => self.sell_command(&command.args).await,
|
||||||
},
|
"/cash" => self.cash_command(&command.args).await,
|
||||||
},
|
|
||||||
"/fa" => {
|
"/fa" => {
|
||||||
if command.args.len() > 2 {
|
if command.args.len() > 2 {
|
||||||
TerminalCommandResponse::Text {
|
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(
|
async fn financials(
|
||||||
&self,
|
&self,
|
||||||
ticker: Option<&String>,
|
ticker: Option<&String>,
|
||||||
@@ -534,6 +634,49 @@ fn parse_symbol_and_frequency(
|
|||||||
Ok((ticker.to_ascii_uppercase(), 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.
|
/// Parses raw slash-command input into a normalized command plus positional arguments.
|
||||||
fn parse_command(input: &str) -> ChatCommandRequest {
|
fn parse_command(input: &str) -> ChatCommandRequest {
|
||||||
let trimmed = input.trim();
|
let trimmed = input.trim();
|
||||||
@@ -550,7 +693,7 @@ fn parse_command(input: &str) -> ChatCommandRequest {
|
|||||||
|
|
||||||
/// Human-readable help text returned for `/help` and unknown commands.
|
/// Human-readable help text returned for `/help` and unknown commands.
|
||||||
fn help_text() -> &'static str {
|
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.
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
@@ -569,6 +820,10 @@ mod tests {
|
|||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
|
|
||||||
use super::TerminalCommandService;
|
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::mock_data::load_mock_financial_data;
|
||||||
use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError};
|
use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError};
|
||||||
use crate::terminal::security_lookup::{
|
use crate::terminal::security_lookup::{
|
||||||
@@ -576,8 +831,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::terminal::{
|
use crate::terminal::{
|
||||||
CashFlowPanelData, Company, DividendsPanelData, EarningsPanelData,
|
CashFlowPanelData, Company, DividendsPanelData, EarningsPanelData,
|
||||||
ExecuteTerminalCommandRequest, FinancialsPanelData, Frequency, PanelPayload, SourceStatus,
|
ExecuteTerminalCommandRequest, FinancialsPanelData, Frequency, Holding, PanelPayload,
|
||||||
TerminalCommandResponse,
|
Portfolio, SourceStatus, TerminalCommandResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct FakeSecurityLookup {
|
struct FakeSecurityLookup {
|
||||||
@@ -650,6 +905,131 @@ mod tests {
|
|||||||
|
|
||||||
struct FakeEdgarLookup;
|
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 {
|
impl EdgarDataLookup for FakeEdgarLookup {
|
||||||
fn financials<'a>(
|
fn financials<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -743,6 +1123,13 @@ mod tests {
|
|||||||
|
|
||||||
fn build_service(
|
fn build_service(
|
||||||
search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
|
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>) {
|
) -> (TerminalCommandService, Arc<FakeSecurityLookup>) {
|
||||||
let lookup = Arc::new(FakeSecurityLookup {
|
let lookup = Arc::new(FakeSecurityLookup {
|
||||||
search_result,
|
search_result,
|
||||||
@@ -756,6 +1143,7 @@ mod tests {
|
|||||||
load_mock_financial_data(),
|
load_mock_financial_data(),
|
||||||
lookup.clone(),
|
lookup.clone(),
|
||||||
Arc::new(FakeEdgarLookup),
|
Arc::new(FakeEdgarLookup),
|
||||||
|
portfolio_service,
|
||||||
Duration::ZERO,
|
Duration::ZERO,
|
||||||
),
|
),
|
||||||
lookup,
|
lookup,
|
||||||
@@ -774,6 +1162,7 @@ mod tests {
|
|||||||
detail_calls: AtomicUsize::new(0),
|
detail_calls: AtomicUsize::new(0),
|
||||||
}),
|
}),
|
||||||
Arc::new(FakeEdgarLookup),
|
Arc::new(FakeEdgarLookup),
|
||||||
|
Arc::new(FakePortfolioService::default()),
|
||||||
Duration::ZERO,
|
Duration::ZERO,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -943,6 +1332,7 @@ mod tests {
|
|||||||
load_mock_financial_data(),
|
load_mock_financial_data(),
|
||||||
Arc::new(FakeSecurityLookup::successful(vec![])),
|
Arc::new(FakeSecurityLookup::successful(vec![])),
|
||||||
Arc::new(FakeEdgarLookup),
|
Arc::new(FakeEdgarLookup),
|
||||||
|
Arc::new(FakePortfolioService::default()),
|
||||||
Duration::ZERO,
|
Duration::ZERO,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -985,6 +1375,7 @@ mod tests {
|
|||||||
load_mock_financial_data(),
|
load_mock_financial_data(),
|
||||||
Arc::new(FakeSecurityLookup::successful(vec![])),
|
Arc::new(FakeSecurityLookup::successful(vec![])),
|
||||||
Arc::new(FakeEdgarLookup),
|
Arc::new(FakeEdgarLookup),
|
||||||
|
Arc::new(FakePortfolioService::default()),
|
||||||
Duration::ZERO,
|
Duration::ZERO,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1004,6 +1395,7 @@ mod tests {
|
|||||||
load_mock_financial_data(),
|
load_mock_financial_data(),
|
||||||
Arc::new(FakeSecurityLookup::successful(vec![])),
|
Arc::new(FakeSecurityLookup::successful(vec![])),
|
||||||
Arc::new(FakeEdgarLookup),
|
Arc::new(FakeEdgarLookup),
|
||||||
|
Arc::new(FakePortfolioService::default()),
|
||||||
Duration::ZERO,
|
Duration::ZERO,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1023,6 +1415,7 @@ mod tests {
|
|||||||
load_mock_financial_data(),
|
load_mock_financial_data(),
|
||||||
Arc::new(FakeSecurityLookup::successful(vec![])),
|
Arc::new(FakeSecurityLookup::successful(vec![])),
|
||||||
Arc::new(FakeEdgarLookup),
|
Arc::new(FakeEdgarLookup),
|
||||||
|
Arc::new(FakePortfolioService::default()),
|
||||||
Duration::ZERO,
|
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]
|
#[test]
|
||||||
fn direct_lookup_company_returns_live_company_snapshot() {
|
fn direct_lookup_company_returns_live_company_snapshot() {
|
||||||
let (service, lookup) = build_service(Ok(vec![]));
|
let (service, lookup) = build_service(Ok(vec![]));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pub use command_service::TerminalCommandService;
|
|||||||
pub use types::{
|
pub use types::{
|
||||||
CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint,
|
CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint,
|
||||||
CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod,
|
CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod,
|
||||||
ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency,
|
ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding,
|
||||||
LookupCompanyRequest, MockFinancialData, PanelPayload, SourceStatus, StatementPeriod,
|
LookupCompanyRequest, MockFinancialData, PanelPayload, Portfolio, SourceStatus,
|
||||||
TerminalCommandResponse,
|
StatementPeriod, TerminalCommandResponse,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,12 +155,18 @@ pub struct CompanyPricePoint {
|
|||||||
pub struct Holding {
|
pub struct Holding {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub quantity: u64,
|
pub quantity: f64,
|
||||||
pub avg_cost: f64,
|
pub avg_cost: f64,
|
||||||
pub current_price: f64,
|
pub current_price: f64,
|
||||||
pub current_value: f64,
|
pub current_value: f64,
|
||||||
pub gain_loss: f64,
|
pub gain_loss: f64,
|
||||||
pub gain_loss_percent: 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.
|
/// Portfolio summary and holdings data.
|
||||||
@@ -173,6 +179,18 @@ pub struct Portfolio {
|
|||||||
pub day_change_percent: f64,
|
pub day_change_percent: f64,
|
||||||
pub total_gain: f64,
|
pub total_gain: f64,
|
||||||
pub total_gain_percent: 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.
|
/// News item serialized with an ISO timestamp for transport safety.
|
||||||
|
|||||||
8
MosaicIQ/src-tauri/src/test_support.rs
Normal file
8
MosaicIQ/src-tauri/src/test_support.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn env_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import React, { useEffect, useCallback, useRef } from 'react';
|
import React, { useEffect, useCallback, useRef } from 'react';
|
||||||
import { Terminal } from './components/Terminal/Terminal';
|
import { Terminal } from './components/Terminal/Terminal';
|
||||||
|
import { CommandInputHandle } from './components/Terminal/CommandInput';
|
||||||
import { Sidebar } from './components/Sidebar/Sidebar';
|
import { Sidebar } from './components/Sidebar/Sidebar';
|
||||||
import { TabBar } from './components/TabBar/TabBar';
|
import { TabBar } from './components/TabBar/TabBar';
|
||||||
import { SettingsPage } from './components/Settings/SettingsPage';
|
import { SettingsPage } from './components/Settings/SettingsPage';
|
||||||
|
import {
|
||||||
|
isPortfolioCommand,
|
||||||
|
usePortfolioWorkflow,
|
||||||
|
} from './hooks/usePortfolioWorkflow';
|
||||||
import { useTabs } from './hooks/useTabs';
|
import { useTabs } from './hooks/useTabs';
|
||||||
import { useTickerHistory } from './hooks/useTickerHistory';
|
import { useTickerHistory } from './hooks/useTickerHistory';
|
||||||
import { createEntry } from './hooks/useTerminal';
|
import { createEntry } from './hooks/useTerminal';
|
||||||
@@ -13,10 +18,30 @@ import {
|
|||||||
} from './lib/tickerHistory';
|
} from './lib/tickerHistory';
|
||||||
import { terminalBridge } from './lib/terminalBridge';
|
import { terminalBridge } from './lib/terminalBridge';
|
||||||
import { AgentConfigStatus } from './types/agentSettings';
|
import { AgentConfigStatus } from './types/agentSettings';
|
||||||
|
import { Portfolio } from './types/financial';
|
||||||
|
import {
|
||||||
|
PortfolioAction,
|
||||||
|
PortfolioActionDraft,
|
||||||
|
PortfolioActionSeed,
|
||||||
|
} from './types/terminal';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
type AppView = 'terminal' | 'settings';
|
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() {
|
function App() {
|
||||||
const tabs = useTabs();
|
const tabs = useTabs();
|
||||||
const tickerHistory = useTickerHistory();
|
const tickerHistory = useTickerHistory();
|
||||||
@@ -24,8 +49,11 @@ function App() {
|
|||||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||||
const [agentStatus, setAgentStatus] = React.useState<AgentConfigStatus | null>(null);
|
const [agentStatus, setAgentStatus] = React.useState<AgentConfigStatus | null>(null);
|
||||||
const [activeView, setActiveView] = React.useState<AppView>('terminal');
|
const [activeView, setActiveView] = React.useState<AppView>('terminal');
|
||||||
|
const portfolioWorkflow = usePortfolioWorkflow();
|
||||||
const commandHistoryRefs = useRef<Record<string, string[]>>({});
|
const commandHistoryRefs = useRef<Record<string, string[]>>({});
|
||||||
const commandIndexRefs = useRef<Record<string, number>>({});
|
const commandIndexRefs = useRef<Record<string, number>>({});
|
||||||
|
const commandInputRef = useRef<CommandInputHandle | null>(null);
|
||||||
|
const hasAutoLoadedPortfolioRef = useRef(false);
|
||||||
|
|
||||||
const getActiveHistory = () => {
|
const getActiveHistory = () => {
|
||||||
return tabs.activeWorkspace?.history || [];
|
return tabs.activeWorkspace?.history || [];
|
||||||
@@ -63,14 +91,34 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await refreshAgentStatus();
|
await refreshAgentStatus();
|
||||||
} finally {
|
} finally {
|
||||||
|
portfolioWorkflow.exitPortfolioMode(tabs.activeWorkspaceId);
|
||||||
setActiveView('settings');
|
setActiveView('settings');
|
||||||
}
|
}
|
||||||
}, [refreshAgentStatus]);
|
}, [portfolioWorkflow, refreshAgentStatus, tabs.activeWorkspaceId]);
|
||||||
|
|
||||||
const handleReturnToTerminal = useCallback(() => {
|
const handleReturnToTerminal = useCallback(() => {
|
||||||
setActiveView('terminal');
|
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 handleCommand = useCallback(async (command: string) => {
|
||||||
const trimmedCommand = command.trim();
|
const trimmedCommand = command.trim();
|
||||||
const latestTicker = tickerHistory.history[0]?.company.symbol;
|
const latestTicker = tickerHistory.history[0]?.company.symbol;
|
||||||
@@ -94,6 +142,9 @@ function App() {
|
|||||||
|
|
||||||
pushCommandHistory(workspaceId, resolvedCommand);
|
pushCommandHistory(workspaceId, resolvedCommand);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
if (isPortfolioCommand(resolvedCommand)) {
|
||||||
|
portfolioWorkflow.noteCommandStart(workspaceId, resolvedCommand);
|
||||||
|
}
|
||||||
|
|
||||||
if (isSlashCommand) {
|
if (isSlashCommand) {
|
||||||
// Slash commands intentionally reset the transcript and session before rendering a fresh result.
|
// 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);
|
const tickerSymbol = extractTickerSymbolFromResponse(response);
|
||||||
if (tickerSymbol) {
|
if (tickerSymbol) {
|
||||||
void tickerHistory.recordTicker(tickerSymbol);
|
void tickerHistory.recordTicker(tickerSymbol);
|
||||||
@@ -128,6 +181,7 @@ function App() {
|
|||||||
content: error instanceof Error ? error.message : 'Command execution failed.',
|
content: error instanceof Error ? error.message : 'Command execution failed.',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
portfolioWorkflow.noteCommandError(workspaceId, resolvedCommand);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -191,7 +245,20 @@ function App() {
|
|||||||
}));
|
}));
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [tabs, clearWorkspaceSession, pushCommandHistory, tickerHistory]);
|
}, [
|
||||||
|
tabs,
|
||||||
|
clearWorkspaceSession,
|
||||||
|
pushCommandHistory,
|
||||||
|
tickerHistory,
|
||||||
|
portfolioWorkflow,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runCommand = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
void handleCommand(command);
|
||||||
|
},
|
||||||
|
[handleCommand],
|
||||||
|
);
|
||||||
|
|
||||||
// Command history navigation
|
// Command history navigation
|
||||||
// Accesses from END of array (most recent commands first)
|
// Accesses from END of array (most recent commands first)
|
||||||
@@ -231,6 +298,8 @@ function App() {
|
|||||||
}, [tabs.activeWorkspaceId]);
|
}, [tabs.activeWorkspaceId]);
|
||||||
|
|
||||||
const outputRef = useRef<HTMLDivElement | null>(null);
|
const outputRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const activePortfolioWorkflow = portfolioWorkflow.readWorkflow(tabs.activeWorkspaceId);
|
||||||
|
const portfolioSnapshot: Portfolio | null = activePortfolioWorkflow.portfolioSnapshot;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -271,24 +340,42 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 't') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleCreateWorkspace();
|
handleCreateWorkspace();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
tabs.closeWorkspace(tabs.activeWorkspaceId);
|
tabs.closeWorkspace(tabs.activeWorkspaceId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSidebarOpen(prev => !prev);
|
setSidebarOpen(prev => !prev);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearTerminal();
|
clearTerminal();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
|
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
|
||||||
@@ -299,7 +386,14 @@ function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [tabs, clearTerminal, handleCreateWorkspace, handleOpenSettings]);
|
}, [
|
||||||
|
activeView,
|
||||||
|
clearTerminal,
|
||||||
|
handleCreateWorkspace,
|
||||||
|
handleOpenSettings,
|
||||||
|
isProcessing,
|
||||||
|
tabs,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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 => ({
|
const tabBarTabs = tabs.workspaces.map(w => ({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
name: w.name,
|
name: w.name,
|
||||||
@@ -325,6 +432,7 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||||
onCommand={handleCommand}
|
onCommand={handleCommand}
|
||||||
|
portfolio={portfolioSnapshot}
|
||||||
tickerHistory={tickerHistory.history}
|
tickerHistory={tickerHistory.history}
|
||||||
isTickerHistoryLoaded={tickerHistory.isLoaded}
|
isTickerHistoryLoaded={tickerHistory.isLoaded}
|
||||||
/>
|
/>
|
||||||
@@ -355,10 +463,16 @@ function App() {
|
|||||||
history={getActiveHistory()}
|
history={getActiveHistory()}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
outputRef={outputRef}
|
outputRef={outputRef}
|
||||||
|
inputRef={commandInputRef}
|
||||||
onSubmit={handleCommand}
|
onSubmit={handleCommand}
|
||||||
|
onRunCommand={runCommand}
|
||||||
|
onStartPortfolioAction={handleStartPortfolioAction}
|
||||||
|
onUpdatePortfolioDraft={handleUpdatePortfolioDraft}
|
||||||
|
onClearPortfolioAction={handleClearPortfolioAction}
|
||||||
getPreviousCommand={getPreviousCommand}
|
getPreviousCommand={getPreviousCommand}
|
||||||
getNextCommand={getNextCommand}
|
getNextCommand={getNextCommand}
|
||||||
resetCommandIndex={resetCommandIndex}
|
resetCommandIndex={resetCommandIndex}
|
||||||
|
portfolioWorkflow={activePortfolioWorkflow}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Portfolio } from '../../types/financial';
|
import { Portfolio } from '../../types/financial';
|
||||||
|
import { PortfolioAction, PortfolioActionSeed } from '../../types/terminal';
|
||||||
import { MetricGrid } from '../ui';
|
import { MetricGrid } from '../ui';
|
||||||
|
|
||||||
interface PortfolioPanelProps {
|
interface PortfolioPanelProps {
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
|
onRunCommand: (command: string) => void;
|
||||||
|
onStartAction: (action: PortfolioAction, seed?: PortfolioActionSeed) => void;
|
||||||
|
onSelectHolding?: (symbol: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) =>
|
||||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
`$${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 totalGainPositive = portfolio.totalGain >= 0;
|
||||||
const dayChangePositive = portfolio.dayChange >= 0;
|
const dayChangePositive = portfolio.dayChange >= 0;
|
||||||
|
|
||||||
@@ -22,68 +44,228 @@ export const PortfolioPanel: React.FC<PortfolioPanelProps> = ({ portfolio }) =>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Today's Change",
|
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',
|
sentiment: (dayChangePositive ? 'positive' : 'negative') as 'positive' | 'negative',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Total Gain/Loss',
|
label: 'Unrealized Gain/Loss',
|
||||||
value: `${totalGainPositive ? '+' : ''}${formatCurrency(portfolio.totalGain)} (${totalGainPositive ? '+' : ''}${portfolio.totalGainPercent.toFixed(2)}%)`,
|
value: `${totalGainPositive ? '+' : ''}${formatCurrency(Math.abs(portfolio.totalGain))} (${totalGainPositive ? '+' : ''}${portfolio.totalGainPercent.toFixed(2)}%)`,
|
||||||
sentiment: (totalGainPositive ? 'positive' : 'negative') as 'positive' | 'negative',
|
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 (
|
return (
|
||||||
<div className="portfolio-panel py-4">
|
<div className="portfolio-panel py-4">
|
||||||
{/* Header */}
|
<header className="mb-6 flex flex-col gap-4 border-b border-[#1a1a1a] pb-4">
|
||||||
<header className="mb-6">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<h2 className="text-heading-lg text-[#e0e0e0]">Portfolio Summary</h2>
|
<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>
|
</header>
|
||||||
|
|
||||||
{/* Summary Stats - Inline metric grid */}
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<MetricGrid metrics={summaryMetrics} columns={3} />
|
<MetricGrid metrics={summaryMetrics} columns={3} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Holdings Table - Minimal */}
|
<section className="border-t border-[#1a1a1a] pt-4">
|
||||||
<section className="holdings-section border-t border-[#1a1a1a] pt-4">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-heading-sm text-[#e0e0e0] mb-4">Holdings ({portfolio.holdings.length})</h3>
|
<h3 className="text-heading-sm text-[#e0e0e0]">
|
||||||
<div className="overflow-x-auto">
|
Holdings ({portfolio.holdings.length})
|
||||||
<table className="w-full text-sm font-mono">
|
</h3>
|
||||||
<thead className="text-[#888888]">
|
<button
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
type="button"
|
||||||
<th className="px-4 py-2 text-left text-[10px] uppercase tracking-wider">Symbol</th>
|
className="text-[11px] font-mono text-[#888888] transition-colors hover:text-[#e0e0e0]"
|
||||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">Qty</th>
|
onClick={() => onRunCommand('/portfolio')}
|
||||||
<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>
|
Reload overview
|
||||||
<th className="px-4 py-2 text-right text-[10px] uppercase tracking-wider">Value</th>
|
</button>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,50 +1,88 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
import { Portfolio } from '../../types/financial';
|
import { Portfolio } from '../../types/financial';
|
||||||
import { ChevronDown, ChevronUp, TrendingUp, TrendingDown } from 'lucide-react';
|
|
||||||
|
|
||||||
interface PortfolioSummaryProps {
|
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 [isExpanded, setIsExpanded] = useState(false);
|
||||||
const INITIAL_HOLDINGS_COUNT = 3;
|
const initialHoldingsCount = 3;
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
if (value >= 1000) {
|
if (Math.abs(value) >= 1000) {
|
||||||
return `$${(value / 1000).toFixed(1)}K`;
|
return `$${(value / 1000).toFixed(1)}K`;
|
||||||
}
|
}
|
||||||
return `$${value.toFixed(0)}`;
|
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 isPositive = portfolio.dayChange >= 0;
|
||||||
const visibleHoldings = isExpanded ? portfolio.holdings : portfolio.holdings.slice(0, INITIAL_HOLDINGS_COUNT);
|
const visibleHoldings = isExpanded
|
||||||
const hasMoreHoldings = portfolio.holdings.length > INITIAL_HOLDINGS_COUNT;
|
? portfolio.holdings
|
||||||
|
: portfolio.holdings.slice(0, initialHoldingsCount);
|
||||||
|
const hasMoreHoldings = portfolio.holdings.length > initialHoldingsCount;
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => {
|
||||||
|
setIsExpanded((current) => !current);
|
||||||
|
onLoadPortfolio();
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 text-left"
|
className="w-full px-3 py-2 text-left"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">Portfolio</h4>
|
<h4 className="text-[10px] font-mono uppercase tracking-wider text-[#888888]">
|
||||||
<span className="text-[10px] font-mono text-[#666666]">({portfolio.holdings.length} positions)</span>
|
Portfolio
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] font-mono text-[#666666]">
|
||||||
|
({portfolio.holdings.length} positions)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{isPositive ? (
|
||||||
<TrendingUp className="h-3 w-3" />
|
<TrendingUp className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<TrendingDown className="h-3 w-3" />
|
<TrendingDown className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-mono">
|
<span className="text-xs font-mono">
|
||||||
{isPositive ? '+' : ''}{portfolio.dayChangePercent.toFixed(1)}%
|
{isPositive ? '+' : ''}
|
||||||
|
{portfolio.dayChangePercent.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{hasMoreHoldings && (
|
{hasMoreHoldings ? (
|
||||||
<span className="text-[#666666]">
|
<span className="text-[#666666]">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronUp className="h-3 w-3" />
|
<ChevronUp className="h-3 w-3" />
|
||||||
@@ -52,7 +90,7 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({ portfolio })
|
|||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,53 +98,66 @@ export const PortfolioSummary: React.FC<PortfolioSummaryProps> = ({ portfolio })
|
|||||||
<span className="text-sm font-mono font-bold text-[#e0e0e0]">
|
<span className="text-sm font-mono font-bold text-[#e0e0e0]">
|
||||||
{formatCurrency(portfolio.totalValue)}
|
{formatCurrency(portfolio.totalValue)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-mono ${isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
<span
|
||||||
{isPositive ? '+' : ''}{formatCurrency(portfolio.dayChange)} today
|
className={`text-xs font-mono ${
|
||||||
|
isPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : ''}
|
||||||
|
{formatCurrency(portfolio.dayChange)} today
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Holdings List */}
|
<div className="space-y-1 px-3 pb-2">
|
||||||
<div className="px-3 pb-2 space-y-1">
|
|
||||||
{visibleHoldings.map((holding) => {
|
{visibleHoldings.map((holding) => {
|
||||||
const holdingPositive = holding.gainLoss >= 0;
|
const holdingPositive = holding.gainLoss >= 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={holding.symbol}
|
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">
|
<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}
|
{holding.symbol}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-[#888888]">
|
<span className="text-[10px] text-[#888888]">
|
||||||
{holding.quantity} {holding.quantity === 1 ? 'share' : 'shares'}
|
{holding.quantity} {holding.quantity === 1 ? 'share' : 'shares'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`font-mono ${holdingPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'}`}>
|
<span
|
||||||
{holdingPositive ? '+' : ''}{holding.gainLossPercent.toFixed(1)}%
|
className={`font-mono ${
|
||||||
|
holdingPositive ? 'text-[#00d26a]' : 'text-[#ff4757]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{holdingPositive ? '+' : ''}
|
||||||
|
{holding.gainLossPercent.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{hasMoreHoldings && !isExpanded && (
|
{hasMoreHoldings && !isExpanded ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setIsExpanded(true)}
|
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>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{isExpanded && hasMoreHoldings && (
|
{isExpanded && hasMoreHoldings ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setIsExpanded(false)}
|
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
|
Show less
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Settings, ChevronRight, ChevronLeft, Briefcase } from 'lucide-react';
|
|||||||
import { PortfolioSummary } from './PortfolioSummary';
|
import { PortfolioSummary } from './PortfolioSummary';
|
||||||
import { TickerHistory } from './TickerHistory';
|
import { TickerHistory } from './TickerHistory';
|
||||||
import { useRealFinancialData } from '../../hooks/useRealFinancialData';
|
import { useRealFinancialData } from '../../hooks/useRealFinancialData';
|
||||||
|
import { Portfolio } from '../../types/financial';
|
||||||
import { TickerHistoryEntry } from '../../types/terminal';
|
import { TickerHistoryEntry } from '../../types/terminal';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -13,6 +14,7 @@ interface SidebarProps {
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
tickerHistory: TickerHistoryEntry[];
|
tickerHistory: TickerHistoryEntry[];
|
||||||
isTickerHistoryLoaded: boolean;
|
isTickerHistoryLoaded: boolean;
|
||||||
|
portfolio: Portfolio | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SidebarState = 'closed' | 'minimized' | 'open';
|
type SidebarState = 'closed' | 'minimized' | 'open';
|
||||||
@@ -25,10 +27,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
tickerHistory,
|
tickerHistory,
|
||||||
isTickerHistoryLoaded,
|
isTickerHistoryLoaded,
|
||||||
|
portfolio,
|
||||||
}) => {
|
}) => {
|
||||||
const { getAllCompanies, getPortfolio } = useRealFinancialData();
|
const { getAllCompanies } = useRealFinancialData();
|
||||||
const companies = getAllCompanies();
|
const companies = getAllCompanies();
|
||||||
const portfolio = getPortfolio();
|
|
||||||
|
|
||||||
const handleCompanyClick = (symbol: string) => {
|
const handleCompanyClick = (symbol: string) => {
|
||||||
onCommand(`/search ${symbol}`);
|
onCommand(`/search ${symbol}`);
|
||||||
@@ -150,7 +152,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
{/* Portfolio Summary */}
|
{/* Portfolio Summary */}
|
||||||
<PortfolioSummary portfolio={portfolio} />
|
<PortfolioSummary
|
||||||
|
portfolio={portfolio}
|
||||||
|
onLoadPortfolio={() => onCommand('/portfolio')}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Ticker History - shows only when loaded */}
|
{/* Ticker History - shows only when loaded */}
|
||||||
{isTickerHistoryLoaded && (
|
{isTickerHistoryLoaded && (
|
||||||
|
|||||||
@@ -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 {
|
interface CommandInputProps {
|
||||||
onSubmit: (command: string) => void;
|
onSubmit: (command: string) => void;
|
||||||
|
onStartPortfolioAction: (action: PortfolioAction) => void;
|
||||||
|
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
|
||||||
|
onClearPortfolioAction: () => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
getPreviousCommand: () => string | null;
|
getPreviousCommand: () => string | null;
|
||||||
getNextCommand: () => string | null;
|
getNextCommand: () => string | null;
|
||||||
resetCommandIndex: () => void;
|
resetCommandIndex: () => void;
|
||||||
|
portfolioMode: boolean;
|
||||||
|
activePortfolioAction: PortfolioAction | null;
|
||||||
|
portfolioDraft: PortfolioActionDraft;
|
||||||
|
lastPortfolioCommand: string | null;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommandInput: React.FC<CommandInputProps> = ({
|
export interface CommandInputHandle {
|
||||||
onSubmit,
|
focusWithText: (text: string) => void;
|
||||||
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);
|
|
||||||
|
|
||||||
const suggestions = [
|
const SUGGESTIONS: CommandSuggestion[] = [
|
||||||
{ command: '/search', description: 'Search live security data' },
|
{ command: '/search', description: 'Search live security data', category: 'search' },
|
||||||
{ command: '/fa', description: 'SEC financial statements' },
|
{ command: '/portfolio', description: 'Show portfolio overview', category: 'portfolio' },
|
||||||
{ command: '/cf', description: 'SEC cash flow summary' },
|
{ command: '/portfolio stats', description: 'Show portfolio statistics', category: 'portfolio' },
|
||||||
{ command: '/dvd', description: 'SEC dividends history' },
|
{ command: '/portfolio history', description: 'Show recent portfolio transactions', category: 'portfolio' },
|
||||||
{ command: '/em', description: 'SEC earnings history' },
|
{ command: '/buy', description: 'Create a buy order', category: 'portfolio' },
|
||||||
{ command: '/portfolio', description: 'Show portfolio' },
|
{ command: '/sell', description: 'Create a sell order', category: 'portfolio' },
|
||||||
{ command: '/news', description: 'Market news' },
|
{ command: '/cash deposit', description: 'Add cash to the portfolio', category: 'cash' },
|
||||||
{ command: '/analyze', description: 'AI analysis' },
|
{ command: '/cash withdraw', description: 'Withdraw cash from the portfolio', category: 'cash' },
|
||||||
{ command: '/help', description: 'List commands' }
|
{ 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(() => {
|
const ACTION_LABELS: Record<'buy' | 'sell' | 'deposit' | 'withdraw', string> = {
|
||||||
if (!isProcessing) {
|
buy: 'Buy',
|
||||||
inputRef.current?.focus();
|
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 = () => {
|
return {
|
||||||
const trimmed = input.trim();
|
command: price ? `/${action} ${symbol} ${quantity} ${price}` : `/${action} ${symbol} ${quantity}`,
|
||||||
if (trimmed && !isProcessing) {
|
error: null,
|
||||||
onSubmit(trimmed);
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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('');
|
setInput('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
resetCommandIndex();
|
resetCommandIndex();
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
const activateSuggestion = (command: string) => {
|
||||||
if (e.key === 'Enter') {
|
const action = suggestionToAction(command);
|
||||||
e.preventDefault();
|
if (action) {
|
||||||
handleSubmit();
|
onStartPortfolioAction(action);
|
||||||
} else if (e.key === 'ArrowUp') {
|
setInput('');
|
||||||
e.preventDefault();
|
setShowSuggestions(false);
|
||||||
const prev = getPreviousCommand();
|
resetCommandIndex();
|
||||||
if (prev !== null) {
|
return;
|
||||||
setInput(prev);
|
|
||||||
}
|
}
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
setInput(`${command} `);
|
||||||
const next = getNextCommand();
|
setShowSuggestions(false);
|
||||||
if (next !== null) {
|
inputRef.current?.focus();
|
||||||
setInput(next);
|
};
|
||||||
}
|
|
||||||
} else if (e.key === 'Tab') {
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault();
|
if (event.key === 'Enter') {
|
||||||
// Simple autocomplete
|
event.preventDefault();
|
||||||
if (input.startsWith('/')) {
|
handleSubmit();
|
||||||
const match = suggestions.find(s => s.command.startsWith(input));
|
} else if (event.key === 'ArrowUp') {
|
||||||
if (match) {
|
event.preventDefault();
|
||||||
setInput(match.command + ' ');
|
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>) => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInput(e.target.value);
|
setInput(event.target.value);
|
||||||
if (e.target.value.startsWith('/')) {
|
setShowSuggestions(event.target.value.startsWith('/'));
|
||||||
setShowSuggestions(true);
|
};
|
||||||
} else {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestionClick = (command: string) => {
|
const suggestionMatches = SUGGESTIONS.filter(
|
||||||
setInput(command + ' ');
|
(suggestion) => !input || suggestion.command.startsWith(input),
|
||||||
setShowSuggestions(false);
|
);
|
||||||
inputRef.current?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const helperText = actionComposerActive
|
||||||
<div className="relative">
|
? `Running ${actionMeta?.command ?? lastPortfolioCommand ?? '/portfolio'} will keep raw terminal output in view.`
|
||||||
{/* Suggestions Dropdown */}
|
: portfolioMode
|
||||||
{showSuggestions && suggestions.some(s => s.command.startsWith(input)) && (
|
? 'Portfolio tools stay active while you move between overview, history, stats, and trade actions.'
|
||||||
<div
|
: 'Use /portfolio to load interactive portfolio tools.';
|
||||||
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"
|
return (
|
||||||
>
|
<div className="relative">
|
||||||
{suggestions
|
{actionComposerActive && actionMeta ? (
|
||||||
.filter(s => !input || s.command.startsWith(input))
|
<div className="mb-3 border border-[#2a2a2a] bg-[#111111]">
|
||||||
.map((suggestion, idx) => (
|
<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
|
<button
|
||||||
key={idx}
|
type="button"
|
||||||
className="w-full text-left px-4 py-2 hover:bg-[#2a2a2a] transition-colors font-mono text-sm"
|
onClick={onClearPortfolioAction}
|
||||||
onClick={() => handleSuggestionClick(suggestion.command)}
|
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-[#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>
|
</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>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Submit Hint */}
|
<div className="border border-[#2a2a2a] bg-[#1a1a1a] px-4 py-3 transition-all focus-within:border-[#58a6ff]">
|
||||||
{!isProcessing && input && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[#888888] text-xs font-mono select-none">↵</span>
|
<span className="select-none font-mono text-lg text-[#58a6ff]">{'>'}</span>
|
||||||
)}
|
<input
|
||||||
</div>
|
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 */}
|
{isProcessing ? (
|
||||||
<div className="mt-2 flex gap-4 text-xs text-[#888888] font-mono">
|
<div className="flex items-center gap-1">
|
||||||
<span>↑/↓ history</span>
|
<span className="h-2 w-2 animate-pulse rounded-full bg-[#58a6ff]" />
|
||||||
<span>Tab autocomplete</span>
|
<span className="h-2 w-2 animate-pulse rounded-full bg-[#58a6ff] delay-75" />
|
||||||
<span>Ctrl+L clear</span>
|
<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>
|
||||||
</div>
|
);
|
||||||
);
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
|
CommandInput.displayName = 'CommandInput';
|
||||||
|
|||||||
@@ -1,26 +1,47 @@
|
|||||||
import React from 'react';
|
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 { TerminalOutput } from './TerminalOutput';
|
||||||
import { CommandInput } from './CommandInput';
|
import { CommandInput, CommandInputHandle } from './CommandInput';
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
history: TerminalEntry[];
|
history: TerminalEntry[];
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
outputRef: React.RefObject<HTMLDivElement | null>;
|
outputRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
inputRef: React.RefObject<CommandInputHandle | null>;
|
||||||
onSubmit: (command: string) => void;
|
onSubmit: (command: string) => void;
|
||||||
|
onRunCommand: (command: string) => void;
|
||||||
|
onStartPortfolioAction: (
|
||||||
|
action: PortfolioAction,
|
||||||
|
seed?: PortfolioActionSeed,
|
||||||
|
) => void;
|
||||||
|
onUpdatePortfolioDraft: (patch: Partial<PortfolioActionDraft>) => void;
|
||||||
|
onClearPortfolioAction: () => void;
|
||||||
getPreviousCommand: () => string | null;
|
getPreviousCommand: () => string | null;
|
||||||
getNextCommand: () => string | null;
|
getNextCommand: () => string | null;
|
||||||
resetCommandIndex: () => void;
|
resetCommandIndex: () => void;
|
||||||
|
portfolioWorkflow: PortfolioWorkflowState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Terminal: React.FC<TerminalProps> = ({
|
export const Terminal: React.FC<TerminalProps> = ({
|
||||||
history,
|
history,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
outputRef,
|
outputRef,
|
||||||
|
inputRef,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onRunCommand,
|
||||||
|
onStartPortfolioAction,
|
||||||
|
onUpdatePortfolioDraft,
|
||||||
|
onClearPortfolioAction,
|
||||||
getPreviousCommand,
|
getPreviousCommand,
|
||||||
getNextCommand,
|
getNextCommand,
|
||||||
resetCommandIndex
|
resetCommandIndex,
|
||||||
|
portfolioWorkflow,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-[#0a0a0a] relative">
|
<div className="flex flex-col h-full bg-[#0a0a0a] relative">
|
||||||
@@ -30,16 +51,29 @@ export const Terminal: React.FC<TerminalProps> = ({
|
|||||||
{/* Command Input */}
|
{/* Command Input */}
|
||||||
<div className="flex-shrink-0 p-6 border-b border-[#2a2a2a] bg-[#0a0a0a]">
|
<div className="flex-shrink-0 p-6 border-b border-[#2a2a2a] bg-[#0a0a0a]">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
|
ref={inputRef}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onStartPortfolioAction={onStartPortfolioAction}
|
||||||
|
onUpdatePortfolioDraft={onUpdatePortfolioDraft}
|
||||||
|
onClearPortfolioAction={onClearPortfolioAction}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
getPreviousCommand={getPreviousCommand}
|
getPreviousCommand={getPreviousCommand}
|
||||||
getNextCommand={getNextCommand}
|
getNextCommand={getNextCommand}
|
||||||
resetCommandIndex={resetCommandIndex}
|
resetCommandIndex={resetCommandIndex}
|
||||||
|
portfolioMode={portfolioWorkflow.isPortfolioMode}
|
||||||
|
activePortfolioAction={portfolioWorkflow.activePortfolioAction}
|
||||||
|
portfolioDraft={portfolioWorkflow.draft}
|
||||||
|
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal Output */}
|
{/* Terminal Output */}
|
||||||
<TerminalOutput history={history} outputRef={outputRef} />
|
<TerminalOutput
|
||||||
|
history={history}
|
||||||
|
outputRef={outputRef}
|
||||||
|
onRunCommand={onRunCommand}
|
||||||
|
onStartPortfolioAction={onStartPortfolioAction}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import React, { useEffect } from 'react';
|
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 { CompanyPanel } from '../Panels/CompanyPanel';
|
||||||
import { PortfolioPanel } from '../Panels/PortfolioPanel';
|
import { PortfolioPanel } from '../Panels/PortfolioPanel';
|
||||||
import { NewsPanel } from '../Panels/NewsPanel';
|
import { NewsPanel } from '../Panels/NewsPanel';
|
||||||
@@ -13,9 +18,19 @@ import { EarningsPanel } from '../Panels/EarningsPanel';
|
|||||||
interface TerminalOutputProps {
|
interface TerminalOutputProps {
|
||||||
history: TerminalEntry[];
|
history: TerminalEntry[];
|
||||||
outputRef: React.RefObject<HTMLDivElement | null>;
|
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
|
// Auto-scroll to bottom when history changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (outputRef.current) {
|
if (outputRef.current) {
|
||||||
@@ -99,7 +114,14 @@ export const TerminalOutput: React.FC<TerminalOutputProps> = ({ history, outputR
|
|||||||
case 'error':
|
case 'error':
|
||||||
return <ErrorPanel error={panelData.data} />;
|
return <ErrorPanel error={panelData.data} />;
|
||||||
case 'portfolio':
|
case 'portfolio':
|
||||||
return <PortfolioPanel portfolio={panelData.data} />;
|
return (
|
||||||
|
<PortfolioPanel
|
||||||
|
portfolio={panelData.data}
|
||||||
|
onRunCommand={onRunCommand}
|
||||||
|
onStartAction={onStartPortfolioAction}
|
||||||
|
onSelectHolding={(symbol) => onRunCommand(`/search ${symbol}`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'news':
|
case 'news':
|
||||||
return <NewsPanel news={panelData.data} ticker={panelData.ticker} />;
|
return <NewsPanel news={panelData.data} ticker={panelData.ticker} />;
|
||||||
case 'analysis':
|
case 'analysis':
|
||||||
@@ -163,7 +185,7 @@ export const TerminalOutput: React.FC<TerminalOutputProps> = ({ history, outputR
|
|||||||
{history.length === 0 && (
|
{history.length === 0 && (
|
||||||
<div className="text-[#888888] font-mono text-center py-20">
|
<div className="text-[#888888] font-mono text-center py-20">
|
||||||
<div className="text-4xl mb-4">⚡</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
247
MosaicIQ/src/hooks/usePortfolioWorkflow.ts
Normal file
247
MosaicIQ/src/hooks/usePortfolioWorkflow.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Portfolio } from '../types/financial';
|
||||||
|
import {
|
||||||
|
PortfolioAction,
|
||||||
|
PortfolioActionDraft,
|
||||||
|
PortfolioActionSeed,
|
||||||
|
ResolvedTerminalCommandResponse,
|
||||||
|
} from '../types/terminal';
|
||||||
|
|
||||||
|
export type PortfolioSnapshotStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
export interface PortfolioWorkflowState {
|
||||||
|
isPortfolioMode: boolean;
|
||||||
|
activePortfolioAction: PortfolioAction | null;
|
||||||
|
portfolioSnapshot: Portfolio | null;
|
||||||
|
portfolioSnapshotStatus: PortfolioSnapshotStatus;
|
||||||
|
draft: PortfolioActionDraft;
|
||||||
|
lastPortfolioCommand: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: PortfolioActionDraft = {
|
||||||
|
symbol: '',
|
||||||
|
quantity: '',
|
||||||
|
price: '',
|
||||||
|
amount: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDefaultState = (): PortfolioWorkflowState => ({
|
||||||
|
isPortfolioMode: false,
|
||||||
|
activePortfolioAction: null,
|
||||||
|
portfolioSnapshot: null,
|
||||||
|
portfolioSnapshotStatus: 'idle',
|
||||||
|
draft: EMPTY_DRAFT,
|
||||||
|
lastPortfolioCommand: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergeDraft = (
|
||||||
|
draft: PortfolioActionDraft,
|
||||||
|
seed?: PortfolioActionSeed,
|
||||||
|
): PortfolioActionDraft => ({
|
||||||
|
symbol: seed?.symbol ?? draft.symbol,
|
||||||
|
quantity: seed?.quantity ?? draft.quantity,
|
||||||
|
price: seed?.price ?? draft.price,
|
||||||
|
amount: seed?.amount ?? draft.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandToPortfolioAction = (command: string): PortfolioAction | null => {
|
||||||
|
const normalized = command.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (normalized === '/portfolio') {
|
||||||
|
return 'overview';
|
||||||
|
}
|
||||||
|
if (normalized === '/portfolio stats') {
|
||||||
|
return 'stats';
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('/portfolio history')) {
|
||||||
|
return 'history';
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('/buy ')) {
|
||||||
|
return 'buy';
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('/sell ')) {
|
||||||
|
return 'sell';
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('/cash deposit')) {
|
||||||
|
return 'deposit';
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('/cash withdraw')) {
|
||||||
|
return 'withdraw';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPortfolioCommand = (command: string): boolean =>
|
||||||
|
commandToPortfolioAction(command) !== null;
|
||||||
|
|
||||||
|
export const usePortfolioWorkflow = () => {
|
||||||
|
const [workflows, setWorkflows] = useState<Record<string, PortfolioWorkflowState>>({});
|
||||||
|
|
||||||
|
const readWorkflow = useCallback(
|
||||||
|
(workspaceId: string): PortfolioWorkflowState =>
|
||||||
|
workflows[workspaceId] ?? createDefaultState(),
|
||||||
|
[workflows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateWorkflow = useCallback(
|
||||||
|
(
|
||||||
|
workspaceId: string,
|
||||||
|
updater: (current: PortfolioWorkflowState) => PortfolioWorkflowState,
|
||||||
|
) => {
|
||||||
|
setWorkflows((current) => {
|
||||||
|
const nextCurrent = current[workspaceId] ?? createDefaultState();
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[workspaceId]: updater(nextCurrent),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteCommandStart = useCallback(
|
||||||
|
(workspaceId: string, command: string) => {
|
||||||
|
const action = commandToPortfolioAction(command);
|
||||||
|
if (!action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorkflow(workspaceId, (current) => ({
|
||||||
|
...current,
|
||||||
|
isPortfolioMode: true,
|
||||||
|
activePortfolioAction: action,
|
||||||
|
lastPortfolioCommand: command,
|
||||||
|
portfolioSnapshotStatus:
|
||||||
|
action === 'overview' ? 'loading' : current.portfolioSnapshotStatus,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateWorkflow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteCommandResponse = useCallback(
|
||||||
|
(
|
||||||
|
workspaceId: string,
|
||||||
|
command: string,
|
||||||
|
response: ResolvedTerminalCommandResponse,
|
||||||
|
) => {
|
||||||
|
const action = commandToPortfolioAction(command);
|
||||||
|
if (!action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorkflow(workspaceId, (current) => {
|
||||||
|
if (response.kind === 'panel' && response.panel.type === 'portfolio') {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
isPortfolioMode: true,
|
||||||
|
activePortfolioAction: 'overview',
|
||||||
|
portfolioSnapshot: response.panel.data,
|
||||||
|
portfolioSnapshotStatus: 'ready',
|
||||||
|
lastPortfolioCommand: command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedTradeAction =
|
||||||
|
action === 'buy' ||
|
||||||
|
action === 'sell' ||
|
||||||
|
action === 'deposit' ||
|
||||||
|
action === 'withdraw';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
isPortfolioMode: true,
|
||||||
|
activePortfolioAction: completedTradeAction ? null : action,
|
||||||
|
lastPortfolioCommand: command,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateWorkflow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteCommandError = useCallback(
|
||||||
|
(workspaceId: string, command: string) => {
|
||||||
|
const action = commandToPortfolioAction(command);
|
||||||
|
if (!action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorkflow(workspaceId, (current) => ({
|
||||||
|
...current,
|
||||||
|
isPortfolioMode: true,
|
||||||
|
activePortfolioAction: action,
|
||||||
|
portfolioSnapshotStatus:
|
||||||
|
action === 'overview' ? 'error' : current.portfolioSnapshotStatus,
|
||||||
|
lastPortfolioCommand: command,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateWorkflow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startPortfolioAction = useCallback(
|
||||||
|
(workspaceId: string, action: PortfolioAction, seed?: PortfolioActionSeed) => {
|
||||||
|
updateWorkflow(workspaceId, (current) => ({
|
||||||
|
...current,
|
||||||
|
isPortfolioMode: true,
|
||||||
|
activePortfolioAction: action,
|
||||||
|
draft: mergeDraft(
|
||||||
|
{
|
||||||
|
...EMPTY_DRAFT,
|
||||||
|
...(action === 'buy' || action === 'sell'
|
||||||
|
? { symbol: current.draft.symbol }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
seed,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateWorkflow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearPortfolioAction = useCallback(
|
||||||
|
(workspaceId: string) => {
|
||||||
|
updateWorkflow(workspaceId, (current) => ({
|
||||||
|
...current,
|
||||||
|
activePortfolioAction: null,
|
||||||
|
draft: EMPTY_DRAFT,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateWorkflow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateDraft = useCallback(
|
||||||
|
(workspaceId: string, patch: Partial<PortfolioActionDraft>) => {
|
||||||
|
updateWorkflow(workspaceId, (current) => ({
|
||||||
|
...current,
|
||||||
|
draft: {
|
||||||
|
...current.draft,
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateWorkflow],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitPortfolioMode = useCallback(
|
||||||
|
(workspaceId: string) => {
|
||||||
|
updateWorkflow(workspaceId, (current) => ({
|
||||||
|
...current,
|
||||||
|
isPortfolioMode: false,
|
||||||
|
activePortfolioAction: null,
|
||||||
|
draft: EMPTY_DRAFT,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[updateWorkflow],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
readWorkflow,
|
||||||
|
noteCommandStart,
|
||||||
|
noteCommandResponse,
|
||||||
|
noteCommandError,
|
||||||
|
startPortfolioAction,
|
||||||
|
clearPortfolioAction,
|
||||||
|
updateDraft,
|
||||||
|
exitPortfolioMode,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ export const useTabs = () => {
|
|||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
type: 'system',
|
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()
|
timestamp: new Date()
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -38,7 +38,7 @@ export const useTabs = () => {
|
|||||||
{
|
{
|
||||||
id: `welcome-${Date.now()}`,
|
id: `welcome-${Date.now()}`,
|
||||||
type: 'system',
|
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()
|
timestamp: new Date()
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export interface Holding {
|
|||||||
currentValue: number;
|
currentValue: number;
|
||||||
gainLoss: number;
|
gainLoss: number;
|
||||||
gainLossPercent: number;
|
gainLossPercent: number;
|
||||||
|
costBasis?: number;
|
||||||
|
unrealizedGain?: number;
|
||||||
|
latestTradeAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Portfolio {
|
export interface Portfolio {
|
||||||
@@ -62,6 +65,12 @@ export interface Portfolio {
|
|||||||
dayChangePercent: number;
|
dayChangePercent: number;
|
||||||
totalGain: number;
|
totalGain: number;
|
||||||
totalGainPercent: number;
|
totalGainPercent: number;
|
||||||
|
cashBalance?: number;
|
||||||
|
investedCostBasis?: number;
|
||||||
|
realizedGain?: number;
|
||||||
|
unrealizedGain?: number;
|
||||||
|
holdingsCount?: number;
|
||||||
|
stalePricingSymbols?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewsItem {
|
export interface NewsItem {
|
||||||
|
|||||||
@@ -110,11 +110,35 @@ export interface CommandSuggestion {
|
|||||||
| 'dividends'
|
| 'dividends'
|
||||||
| 'earnings'
|
| 'earnings'
|
||||||
| 'portfolio'
|
| 'portfolio'
|
||||||
|
| 'cash'
|
||||||
| 'news'
|
| 'news'
|
||||||
| 'analysis'
|
| 'analysis'
|
||||||
| 'system';
|
| '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 {
|
export interface TerminalState {
|
||||||
history: TerminalEntry[];
|
history: TerminalEntry[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user