[OQI-004] feat: Add 4 advanced investment components

- CreateAccountWizard: Multi-step wizard for account creation (620 LOC)
- RiskAnalysisPanel: Risk metrics with VaR, Sharpe, Sortino (480 LOC)
- PortfolioOptimizerWidget: Portfolio allocation optimizer (520 LOC)
- AccountTransferModal: Modal for inter-account transfers (450 LOC)

Updates OQI-004 progress from 35% to 55%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 23:20:40 -06:00
parent fc99c34749
commit 339c645036
5 changed files with 2468 additions and 0 deletions

View File

@ -0,0 +1,563 @@
/**
* AccountTransferModal Component
* Modal for transferring funds between investment accounts
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import {
ArrowRightLeft,
Wallet,
AlertCircle,
CheckCircle,
Info,
X,
ChevronDown,
Loader2,
ArrowRight,
} from 'lucide-react';
// Types
export interface TransferAccount {
id: string;
name: string;
productName: string;
balance: number;
availableBalance: number;
status: 'active' | 'pending' | 'locked';
color: string;
}
export interface TransferFee {
type: 'fixed' | 'percentage';
value: number;
minFee?: number;
maxFee?: number;
}
export interface TransferData {
sourceAccountId: string;
destinationAccountId: string;
amount: number;
fee: number;
note?: string;
}
export interface AccountTransferModalProps {
isOpen: boolean;
accounts: TransferAccount[];
defaultSourceId?: string;
defaultDestinationId?: string;
transferFee?: TransferFee;
minTransfer?: number;
maxTransfer?: number;
onTransfer?: (data: TransferData) => void;
onClose?: () => void;
isSubmitting?: boolean;
}
const AccountTransferModal: React.FC<AccountTransferModalProps> = ({
isOpen,
accounts,
defaultSourceId,
defaultDestinationId,
transferFee = { type: 'fixed', value: 0 },
minTransfer = 100,
maxTransfer = 100000,
onTransfer,
onClose,
isSubmitting = false,
}) => {
const [sourceAccountId, setSourceAccountId] = useState<string>(defaultSourceId || '');
const [destinationAccountId, setDestinationAccountId] = useState<string>(defaultDestinationId || '');
const [amount, setAmount] = useState<string>('');
const [note, setNote] = useState<string>('');
const [showSourceDropdown, setShowSourceDropdown] = useState(false);
const [showDestDropdown, setShowDestDropdown] = useState(false);
const [step, setStep] = useState<'form' | 'confirm' | 'success'>('form');
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setSourceAccountId(defaultSourceId || '');
setDestinationAccountId(defaultDestinationId || '');
setAmount('');
setNote('');
setStep('form');
}
}, [isOpen, defaultSourceId, defaultDestinationId]);
// Get selected accounts
const sourceAccount = useMemo(() => {
return accounts.find((acc) => acc.id === sourceAccountId);
}, [accounts, sourceAccountId]);
const destinationAccount = useMemo(() => {
return accounts.find((acc) => acc.id === destinationAccountId);
}, [accounts, destinationAccountId]);
// Calculate fee
const calculatedFee = useMemo(() => {
const transferAmount = parseFloat(amount) || 0;
if (transferFee.type === 'fixed') {
return transferFee.value;
}
let fee = transferAmount * (transferFee.value / 100);
if (transferFee.minFee) fee = Math.max(fee, transferFee.minFee);
if (transferFee.maxFee) fee = Math.min(fee, transferFee.maxFee);
return fee;
}, [amount, transferFee]);
// Calculate total deduction
const totalDeduction = useMemo(() => {
return (parseFloat(amount) || 0) + calculatedFee;
}, [amount, calculatedFee]);
// Validation
const validation = useMemo(() => {
const transferAmount = parseFloat(amount) || 0;
const errors: string[] = [];
if (!sourceAccountId) {
errors.push('Select a source account');
}
if (!destinationAccountId) {
errors.push('Select a destination account');
}
if (sourceAccountId && destinationAccountId && sourceAccountId === destinationAccountId) {
errors.push('Source and destination must be different');
}
if (transferAmount < minTransfer) {
errors.push(`Minimum transfer is $${minTransfer.toLocaleString()}`);
}
if (transferAmount > maxTransfer) {
errors.push(`Maximum transfer is $${maxTransfer.toLocaleString()}`);
}
if (sourceAccount && totalDeduction > sourceAccount.availableBalance) {
errors.push('Insufficient available balance');
}
if (sourceAccount && sourceAccount.status !== 'active') {
errors.push('Source account is not active');
}
if (destinationAccount && destinationAccount.status !== 'active') {
errors.push('Destination account is not active');
}
return {
isValid: errors.length === 0 && transferAmount > 0,
errors,
};
}, [sourceAccountId, destinationAccountId, amount, sourceAccount, destinationAccount, totalDeduction, minTransfer, maxTransfer]);
// Handle transfer
const handleTransfer = useCallback(() => {
if (!validation.isValid) return;
const transferData: TransferData = {
sourceAccountId,
destinationAccountId,
amount: parseFloat(amount),
fee: calculatedFee,
note: note || undefined,
};
onTransfer?.(transferData);
}, [validation.isValid, sourceAccountId, destinationAccountId, amount, calculatedFee, note, onTransfer]);
// Handle confirm step
const handleProceed = useCallback(() => {
if (step === 'form' && validation.isValid) {
setStep('confirm');
} else if (step === 'confirm') {
handleTransfer();
}
}, [step, validation.isValid, handleTransfer]);
// Swap accounts
const handleSwapAccounts = useCallback(() => {
const temp = sourceAccountId;
setSourceAccountId(destinationAccountId);
setDestinationAccountId(temp);
}, [sourceAccountId, destinationAccountId]);
// Quick amount buttons
const quickAmounts = useMemo(() => {
if (!sourceAccount) return [];
const balance = sourceAccount.availableBalance;
return [
{ label: '25%', value: Math.floor(balance * 0.25) },
{ label: '50%', value: Math.floor(balance * 0.5) },
{ label: '75%', value: Math.floor(balance * 0.75) },
{ label: 'Max', value: Math.floor(balance - calculatedFee) },
].filter((q) => q.value >= minTransfer);
}, [sourceAccount, calculatedFee, minTransfer]);
// Render account selector
const renderAccountSelector = (
type: 'source' | 'destination',
selectedId: string,
onSelect: (id: string) => void,
isOpen: boolean,
setIsOpen: (open: boolean) => void
) => {
const selectedAccount = accounts.find((acc) => acc.id === selectedId);
const availableAccounts = accounts.filter((acc) => {
if (type === 'source') return acc.id !== destinationAccountId && acc.status === 'active';
return acc.id !== sourceAccountId;
});
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 text-left hover:border-slate-600 transition-colors"
>
{selectedAccount ? (
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: selectedAccount.color }}
/>
<div>
<p className="text-white font-medium">{selectedAccount.name}</p>
<p className="text-slate-400 text-sm">
Available: ${selectedAccount.availableBalance.toLocaleString()}
</p>
</div>
</div>
) : (
<span className="text-slate-400">Select {type} account</span>
)}
<ChevronDown className={`w-5 h-5 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute z-10 w-full mt-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden">
{availableAccounts.map((account) => (
<button
key={account.id}
onClick={() => {
onSelect(account.id);
setIsOpen(false);
}}
disabled={account.status !== 'active'}
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-700 transition-colors ${
account.status !== 'active' ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: account.color }}
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="text-white font-medium">{account.name}</p>
{account.status !== 'active' && (
<span className="text-xs text-amber-400 capitalize">{account.status}</span>
)}
</div>
<p className="text-slate-400 text-sm">{account.productName}</p>
<p className="text-slate-500 text-xs">
Balance: ${account.balance.toLocaleString()} |
Available: ${account.availableBalance.toLocaleString()}
</p>
</div>
</button>
))}
{availableAccounts.length === 0 && (
<p className="px-4 py-3 text-slate-400 text-sm">No accounts available</p>
)}
</div>
)}
</div>
);
};
// Render form step
const renderFormStep = () => (
<div className="space-y-6">
{/* Source Account */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">From Account</label>
{renderAccountSelector(
'source',
sourceAccountId,
setSourceAccountId,
showSourceDropdown,
setShowSourceDropdown
)}
</div>
{/* Swap Button */}
<div className="flex justify-center">
<button
onClick={handleSwapAccounts}
disabled={!sourceAccountId || !destinationAccountId}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Swap accounts"
>
<ArrowRightLeft className="w-5 h-5" />
</button>
</div>
{/* Destination Account */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">To Account</label>
{renderAccountSelector(
'destination',
destinationAccountId,
setDestinationAccountId,
showDestDropdown,
setShowDestDropdown
)}
</div>
{/* Amount */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">Amount</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500">$</span>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
min={minTransfer}
max={maxTransfer}
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-8 pr-4 py-3 text-white text-lg focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex justify-between text-xs text-slate-500 mt-1">
<span>Min: ${minTransfer.toLocaleString()}</span>
<span>Max: ${maxTransfer.toLocaleString()}</span>
</div>
{/* Quick Amount Buttons */}
{quickAmounts.length > 0 && (
<div className="flex gap-2 mt-3">
{quickAmounts.map((quick) => (
<button
key={quick.label}
onClick={() => setAmount(quick.value.toString())}
className="px-3 py-1.5 bg-slate-800 text-slate-300 text-sm rounded-lg hover:bg-slate-700 transition-colors"
>
{quick.label}
</button>
))}
</div>
)}
</div>
{/* Note */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">Note (Optional)</label>
<input
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Add a note for this transfer"
maxLength={100}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
{/* Summary */}
{parseFloat(amount) > 0 && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-400">Transfer Amount</span>
<span className="text-white">${parseFloat(amount).toLocaleString()}</span>
</div>
{calculatedFee > 0 && (
<div className="flex justify-between text-sm">
<span className="text-slate-400">Transfer Fee</span>
<span className="text-slate-300">${calculatedFee.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-sm pt-2 border-t border-slate-700">
<span className="text-slate-300 font-medium">Total Deduction</span>
<span className="text-white font-medium">${totalDeduction.toLocaleString()}</span>
</div>
</div>
)}
{/* Validation Errors */}
{validation.errors.length > 0 && parseFloat(amount) > 0 && (
<div className="space-y-2">
{validation.errors.map((error, index) => (
<p key={index} className="text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</p>
))}
</div>
)}
</div>
);
// Render confirm step
const renderConfirmStep = () => (
<div className="space-y-6">
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-blue-600/20 flex items-center justify-center mx-auto mb-4">
<ArrowRightLeft className="w-8 h-8 text-blue-400" />
</div>
<h3 className="text-white text-xl font-semibold">Confirm Transfer</h3>
<p className="text-slate-400 mt-1">Please review the details below</p>
</div>
{/* Transfer Visual */}
<div className="flex items-center justify-between gap-4">
{/* Source */}
<div className="flex-1 bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="flex items-center gap-2 mb-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: sourceAccount?.color }}
/>
<span className="text-slate-400 text-sm">From</span>
</div>
<p className="text-white font-medium">{sourceAccount?.name}</p>
<p className="text-red-400 text-sm mt-1">-${totalDeduction.toLocaleString()}</p>
</div>
<ArrowRight className="w-6 h-6 text-slate-500 flex-shrink-0" />
{/* Destination */}
<div className="flex-1 bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="flex items-center gap-2 mb-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: destinationAccount?.color }}
/>
<span className="text-slate-400 text-sm">To</span>
</div>
<p className="text-white font-medium">{destinationAccount?.name}</p>
<p className="text-emerald-400 text-sm mt-1">+${parseFloat(amount).toLocaleString()}</p>
</div>
</div>
{/* Summary */}
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 space-y-3">
<div className="flex justify-between">
<span className="text-slate-400">Transfer Amount</span>
<span className="text-white font-medium">${parseFloat(amount).toLocaleString()}</span>
</div>
{calculatedFee > 0 && (
<div className="flex justify-between">
<span className="text-slate-400">Fee</span>
<span className="text-slate-300">${calculatedFee.toFixed(2)}</span>
</div>
)}
{note && (
<div className="flex justify-between">
<span className="text-slate-400">Note</span>
<span className="text-slate-300 text-right max-w-[200px] truncate">{note}</span>
</div>
)}
<div className="flex justify-between pt-3 border-t border-slate-700">
<span className="text-white font-medium">Total Deduction</span>
<span className="text-white font-semibold">${totalDeduction.toLocaleString()}</span>
</div>
</div>
{/* Info */}
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-slate-300 text-sm">
The transfer will be processed immediately. Funds will be available in the destination
account within minutes. This action cannot be undone.
</p>
</div>
</div>
</div>
);
// Render success step
const renderSuccessStep = () => (
<div className="text-center py-8">
<div className="w-20 h-20 rounded-full bg-emerald-600/20 flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-10 h-10 text-emerald-400" />
</div>
<h3 className="text-white text-xl font-semibold">Transfer Complete!</h3>
<p className="text-slate-400 mt-2">
${parseFloat(amount).toLocaleString()} has been transferred from{' '}
<span className="text-white">{sourceAccount?.name}</span> to{' '}
<span className="text-white">{destinationAccount?.name}</span>
</p>
<button
onClick={onClose}
className="mt-8 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Done
</button>
</div>
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-lg bg-slate-900 rounded-xl border border-slate-800 shadow-2xl mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<div className="flex items-center gap-3">
<Wallet className="w-6 h-6 text-blue-400" />
<h2 className="text-white text-lg font-semibold">Transfer Funds</h2>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6">
{step === 'form' && renderFormStep()}
{step === 'confirm' && renderConfirmStep()}
{step === 'success' && renderSuccessStep()}
</div>
{/* Footer */}
{step !== 'success' && (
<div className="flex justify-between gap-4 p-6 border-t border-slate-800">
<button
onClick={step === 'form' ? onClose : () => setStep('form')}
className="px-4 py-2 text-slate-300 hover:text-white transition-colors"
>
{step === 'form' ? 'Cancel' : 'Back'}
</button>
<button
onClick={handleProceed}
disabled={!validation.isValid || isSubmitting}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : step === 'form' ? (
'Continue'
) : (
'Confirm Transfer'
)}
</button>
</div>
)}
</div>
</div>
);
};
export default AccountTransferModal;

View File

@ -0,0 +1,780 @@
/**
* CreateAccountWizard Component
* Multi-step wizard for creating new investment accounts
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Wallet,
CheckCircle,
AlertCircle,
ArrowRight,
ArrowLeft,
Shield,
FileText,
DollarSign,
User,
Clock,
TrendingUp,
Info,
Loader2,
} from 'lucide-react';
// Types
export interface ProductSelection {
id: string;
name: string;
minInvestment: number;
expectedReturn: string;
riskLevel: 'low' | 'medium' | 'high';
tradingAgent: string;
}
export interface KYCData {
fullName: string;
dateOfBirth: string;
address: string;
city: string;
country: string;
taxId: string;
sourceOfFunds: string;
investmentExperience: string;
riskTolerance: string;
}
export interface AccountCreationData {
product: ProductSelection;
initialAmount: number;
kyc: KYCData;
termsAccepted: boolean;
autoReinvest: boolean;
distributionFrequency: 'monthly' | 'quarterly' | 'annually';
}
export interface CreateAccountWizardProps {
product: ProductSelection;
onComplete?: (data: AccountCreationData) => void;
onCancel?: () => void;
isSubmitting?: boolean;
minInvestment?: number;
maxInvestment?: number;
requiresKYC?: boolean;
}
type WizardStep = 'amount' | 'kyc' | 'terms' | 'confirm';
const STEPS: { id: WizardStep; title: string; icon: React.ElementType }[] = [
{ id: 'amount', title: 'Investment Amount', icon: DollarSign },
{ id: 'kyc', title: 'Verification', icon: User },
{ id: 'terms', title: 'Terms & Conditions', icon: FileText },
{ id: 'confirm', title: 'Confirmation', icon: CheckCircle },
];
const SOURCE_OF_FUNDS_OPTIONS = [
'Employment Income',
'Business Revenue',
'Investments',
'Inheritance',
'Savings',
'Other',
];
const INVESTMENT_EXPERIENCE_OPTIONS = [
'None',
'Beginner (< 1 year)',
'Intermediate (1-3 years)',
'Advanced (3-5 years)',
'Expert (5+ years)',
];
const RISK_TOLERANCE_OPTIONS = [
{ value: 'conservative', label: 'Conservative', desc: 'Preserve capital, lower returns' },
{ value: 'moderate', label: 'Moderate', desc: 'Balanced risk and reward' },
{ value: 'aggressive', label: 'Aggressive', desc: 'Higher risk for higher returns' },
];
const CreateAccountWizard: React.FC<CreateAccountWizardProps> = ({
product,
onComplete,
onCancel,
isSubmitting = false,
minInvestment = 1000,
maxInvestment = 1000000,
requiresKYC = true,
}) => {
const [currentStep, setCurrentStep] = useState<WizardStep>('amount');
const [amount, setAmount] = useState<number>(minInvestment);
const [autoReinvest, setAutoReinvest] = useState(true);
const [distributionFrequency, setDistributionFrequency] = useState<'monthly' | 'quarterly' | 'annually'>('monthly');
const [termsAccepted, setTermsAccepted] = useState(false);
const [riskDisclosureAccepted, setRiskDisclosureAccepted] = useState(false);
const [kyc, setKyc] = useState<KYCData>({
fullName: '',
dateOfBirth: '',
address: '',
city: '',
country: '',
taxId: '',
sourceOfFunds: '',
investmentExperience: '',
riskTolerance: 'moderate',
});
// Get current step index
const currentStepIndex = useMemo(() => {
const steps = requiresKYC ? STEPS : STEPS.filter(s => s.id !== 'kyc');
return steps.findIndex(s => s.id === currentStep);
}, [currentStep, requiresKYC]);
// Get available steps
const availableSteps = useMemo(() => {
return requiresKYC ? STEPS : STEPS.filter(s => s.id !== 'kyc');
}, [requiresKYC]);
// Calculate projected returns
const projectedReturns = useMemo(() => {
const returnRate = parseFloat(product.expectedReturn) / 100;
const monthly = amount * (returnRate / 12);
const yearly = amount * returnRate;
return { monthly, yearly };
}, [amount, product.expectedReturn]);
// Validate current step
const isStepValid = useCallback((): boolean => {
switch (currentStep) {
case 'amount':
return amount >= minInvestment && amount <= maxInvestment;
case 'kyc':
return (
kyc.fullName.length > 2 &&
kyc.dateOfBirth !== '' &&
kyc.address.length > 5 &&
kyc.city.length > 2 &&
kyc.country !== '' &&
kyc.sourceOfFunds !== '' &&
kyc.investmentExperience !== '' &&
kyc.riskTolerance !== ''
);
case 'terms':
return termsAccepted && riskDisclosureAccepted;
case 'confirm':
return true;
default:
return false;
}
}, [currentStep, amount, minInvestment, maxInvestment, kyc, termsAccepted, riskDisclosureAccepted]);
// Handle navigation
const goToNextStep = useCallback(() => {
const currentIndex = availableSteps.findIndex(s => s.id === currentStep);
if (currentIndex < availableSteps.length - 1) {
setCurrentStep(availableSteps[currentIndex + 1].id);
}
}, [currentStep, availableSteps]);
const goToPrevStep = useCallback(() => {
const currentIndex = availableSteps.findIndex(s => s.id === currentStep);
if (currentIndex > 0) {
setCurrentStep(availableSteps[currentIndex - 1].id);
}
}, [currentStep, availableSteps]);
// Handle form submission
const handleSubmit = useCallback(() => {
const data: AccountCreationData = {
product,
initialAmount: amount,
kyc,
termsAccepted,
autoReinvest,
distributionFrequency,
};
onComplete?.(data);
}, [product, amount, kyc, termsAccepted, autoReinvest, distributionFrequency, onComplete]);
// Update KYC field
const updateKyc = useCallback((field: keyof KYCData, value: string) => {
setKyc(prev => ({ ...prev, [field]: value }));
}, []);
// Get risk level color
const getRiskColor = (level: string) => {
switch (level) {
case 'low':
return 'text-emerald-400';
case 'medium':
return 'text-amber-400';
case 'high':
return 'text-red-400';
default:
return 'text-slate-400';
}
};
// Render step indicator
const renderStepIndicator = () => (
<div className="flex items-center justify-center mb-8">
{availableSteps.map((step, index) => {
const StepIcon = step.icon;
const isActive = step.id === currentStep;
const isCompleted = index < currentStepIndex;
return (
<React.Fragment key={step.id}>
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${
isActive
? 'bg-blue-600 text-white'
: isCompleted
? 'bg-emerald-600 text-white'
: 'bg-slate-700 text-slate-400'
}`}
>
{isCompleted ? (
<CheckCircle className="w-5 h-5" />
) : (
<StepIcon className="w-5 h-5" />
)}
</div>
<span className={`text-xs mt-2 ${isActive ? 'text-white' : 'text-slate-400'}`}>
{step.title}
</span>
</div>
{index < availableSteps.length - 1 && (
<div
className={`w-16 h-0.5 mx-2 ${
isCompleted ? 'bg-emerald-600' : 'bg-slate-700'
}`}
/>
)}
</React.Fragment>
);
})}
</div>
);
// Render amount step
const renderAmountStep = () => (
<div className="space-y-6">
{/* Product Summary */}
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-blue-600/20 flex items-center justify-center">
<Wallet className="w-6 h-6 text-blue-400" />
</div>
<div>
<h3 className="text-white font-medium">{product.name}</h3>
<p className="text-slate-400 text-sm">
Managed by {product.tradingAgent} Agent
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-slate-500 block">Expected Return</span>
<span className="text-emerald-400 font-medium">{product.expectedReturn}</span>
</div>
<div>
<span className="text-slate-500 block">Risk Level</span>
<span className={`font-medium capitalize ${getRiskColor(product.riskLevel)}`}>
{product.riskLevel}
</span>
</div>
<div>
<span className="text-slate-500 block">Min. Investment</span>
<span className="text-white">${product.minInvestment.toLocaleString()}</span>
</div>
</div>
</div>
{/* Amount Input */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
Investment Amount
</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="number"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min={minInvestment}
max={maxInvestment}
className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-3 text-white text-lg focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex justify-between text-xs text-slate-500 mt-1">
<span>Min: ${minInvestment.toLocaleString()}</span>
<span>Max: ${maxInvestment.toLocaleString()}</span>
</div>
{(amount < minInvestment || amount > maxInvestment) && (
<p className="text-red-400 text-sm mt-2 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
Amount must be between ${minInvestment.toLocaleString()} and ${maxInvestment.toLocaleString()}
</p>
)}
</div>
{/* Quick Amount Buttons */}
<div className="flex flex-wrap gap-2">
{[1000, 5000, 10000, 25000, 50000, 100000].map((quickAmount) => (
<button
key={quickAmount}
onClick={() => setAmount(quickAmount)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
amount === quickAmount
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
}`}
>
${quickAmount.toLocaleString()}
</button>
))}
</div>
{/* Projected Returns */}
<div className="bg-emerald-900/20 border border-emerald-800/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-5 h-5 text-emerald-400" />
<span className="text-emerald-400 font-medium">Projected Returns</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-slate-400 text-sm block">Monthly (Est.)</span>
<span className="text-white text-lg font-semibold">
${projectedReturns.monthly.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
</div>
<div>
<span className="text-slate-400 text-sm block">Yearly (Est.)</span>
<span className="text-white text-lg font-semibold">
${projectedReturns.yearly.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
</div>
</div>
<p className="text-slate-500 text-xs mt-3 flex items-center gap-1">
<Info className="w-3 h-3" />
Past performance does not guarantee future results
</p>
</div>
{/* Distribution Settings */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-slate-300 font-medium">Auto-Reinvest Returns</label>
<p className="text-slate-500 text-sm">Automatically reinvest your earnings</p>
</div>
<button
onClick={() => setAutoReinvest(!autoReinvest)}
className={`w-12 h-6 rounded-full transition-colors ${
autoReinvest ? 'bg-blue-600' : 'bg-slate-700'
}`}
>
<div
className={`w-5 h-5 bg-white rounded-full transition-transform ${
autoReinvest ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{!autoReinvest && (
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
Distribution Frequency
</label>
<select
value={distributionFrequency}
onChange={(e) => setDistributionFrequency(e.target.value as 'monthly' | 'quarterly' | 'annually')}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="annually">Annually</option>
</select>
</div>
)}
</div>
</div>
);
// Render KYC step
const renderKYCStep = () => (
<div className="space-y-4">
<div className="bg-amber-900/20 border border-amber-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-400" />
<span className="text-amber-400 font-medium">Identity Verification Required</span>
</div>
<p className="text-slate-400 text-sm mt-2">
For regulatory compliance, we need to verify your identity before opening an investment account.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-slate-300 text-sm font-medium mb-1">Full Legal Name</label>
<input
type="text"
value={kyc.fullName}
onChange={(e) => updateKyc('fullName', e.target.value)}
placeholder="John Doe"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-slate-300 text-sm font-medium mb-1">Date of Birth</label>
<input
type="date"
value={kyc.dateOfBirth}
onChange={(e) => updateKyc('dateOfBirth', e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-slate-300 text-sm font-medium mb-1">Tax ID / SSN</label>
<input
type="text"
value={kyc.taxId}
onChange={(e) => updateKyc('taxId', e.target.value)}
placeholder="XXX-XX-XXXX"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="col-span-2">
<label className="block text-slate-300 text-sm font-medium mb-1">Address</label>
<input
type="text"
value={kyc.address}
onChange={(e) => updateKyc('address', e.target.value)}
placeholder="123 Main Street"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-slate-300 text-sm font-medium mb-1">City</label>
<input
type="text"
value={kyc.city}
onChange={(e) => updateKyc('city', e.target.value)}
placeholder="New York"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-slate-300 text-sm font-medium mb-1">Country</label>
<select
value={kyc.country}
onChange={(e) => updateKyc('country', e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Select country</option>
<option value="US">United States</option>
<option value="MX">Mexico</option>
<option value="CA">Canada</option>
<option value="GB">United Kingdom</option>
<option value="DE">Germany</option>
<option value="ES">Spain</option>
</select>
</div>
<div>
<label className="block text-slate-300 text-sm font-medium mb-1">Source of Funds</label>
<select
value={kyc.sourceOfFunds}
onChange={(e) => updateKyc('sourceOfFunds', e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Select source</option>
{SOURCE_OF_FUNDS_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="block text-slate-300 text-sm font-medium mb-1">Investment Experience</label>
<select
value={kyc.investmentExperience}
onChange={(e) => updateKyc('investmentExperience', e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Select experience</option>
{INVESTMENT_EXPERIENCE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-slate-300 text-sm font-medium mb-2">Risk Tolerance</label>
<div className="grid grid-cols-3 gap-3">
{RISK_TOLERANCE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => updateKyc('riskTolerance', option.value)}
className={`p-3 rounded-lg border text-left transition-colors ${
kyc.riskTolerance === option.value
? 'border-blue-500 bg-blue-900/20'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
}`}
>
<span className="text-white font-medium block">{option.label}</span>
<span className="text-slate-400 text-xs">{option.desc}</span>
</button>
))}
</div>
</div>
</div>
</div>
);
// Render terms step
const renderTermsStep = () => (
<div className="space-y-6">
{/* Terms & Conditions */}
<div className="bg-slate-800/50 rounded-lg border border-slate-700">
<div className="p-4 border-b border-slate-700">
<h3 className="text-white font-medium flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-400" />
Terms & Conditions
</h3>
</div>
<div className="p-4 max-h-48 overflow-y-auto text-slate-400 text-sm space-y-3">
<p>By opening an investment account with Trading Platform, you agree to the following terms:</p>
<ul className="list-disc list-inside space-y-2">
<li>You are at least 18 years old and legally able to enter into contracts.</li>
<li>The information you have provided is accurate and complete.</li>
<li>You understand that all investments carry risk, including the potential loss of principal.</li>
<li>You agree to our fee schedule as outlined in the product documentation.</li>
<li>You authorize us to manage your investment according to the selected product strategy.</li>
<li>You may request withdrawals subject to our withdrawal policy and any applicable lock-up periods.</li>
<li>Account statements will be provided monthly via email and accessible through the platform.</li>
</ul>
</div>
<div className="p-4 border-t border-slate-700">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="w-5 h-5 rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-blue-500"
/>
<span className="text-slate-300">
I have read and agree to the Terms & Conditions
</span>
</label>
</div>
</div>
{/* Risk Disclosure */}
<div className="bg-slate-800/50 rounded-lg border border-slate-700">
<div className="p-4 border-b border-slate-700">
<h3 className="text-white font-medium flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-amber-400" />
Risk Disclosure
</h3>
</div>
<div className="p-4 max-h-48 overflow-y-auto text-slate-400 text-sm space-y-3">
<p><strong className="text-white">Important Risk Information:</strong></p>
<ul className="list-disc list-inside space-y-2">
<li>Past performance is not indicative of future results.</li>
<li>The value of investments can go down as well as up.</li>
<li>You may receive back less than your original investment.</li>
<li>Trading strategies involve significant risks including market, liquidity, and operational risks.</li>
<li>Automated trading systems may experience technical failures or delays.</li>
<li>Currency fluctuations may affect the value of international investments.</li>
<li>There is no guarantee that investment objectives will be achieved.</li>
</ul>
<p className="text-amber-400">
Only invest money that you can afford to lose. If you are unsure about the suitability
of this investment, please seek independent financial advice.
</p>
</div>
<div className="p-4 border-t border-slate-700">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={riskDisclosureAccepted}
onChange={(e) => setRiskDisclosureAccepted(e.target.checked)}
className="w-5 h-5 rounded border-slate-600 bg-slate-800 text-blue-600 focus:ring-blue-500"
/>
<span className="text-slate-300">
I understand and accept the risks involved
</span>
</label>
</div>
</div>
</div>
);
// Render confirm step
const renderConfirmStep = () => (
<div className="space-y-6">
<div className="text-center mb-6">
<div className="w-16 h-16 rounded-full bg-blue-600/20 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-blue-400" />
</div>
<h3 className="text-white text-xl font-semibold">Review Your Investment</h3>
<p className="text-slate-400 mt-1">Please confirm the details below</p>
</div>
{/* Summary Card */}
<div className="bg-slate-800/50 rounded-lg border border-slate-700 divide-y divide-slate-700">
<div className="p-4">
<h4 className="text-slate-400 text-sm mb-3">Product</h4>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-600/20 flex items-center justify-center">
<Wallet className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-white font-medium">{product.name}</p>
<p className="text-slate-400 text-sm">{product.tradingAgent} Agent</p>
</div>
</div>
</div>
<div className="p-4 grid grid-cols-2 gap-4">
<div>
<span className="text-slate-400 text-sm block">Investment Amount</span>
<span className="text-white text-lg font-semibold">${amount.toLocaleString()}</span>
</div>
<div>
<span className="text-slate-400 text-sm block">Expected Return</span>
<span className="text-emerald-400 text-lg font-semibold">{product.expectedReturn}</span>
</div>
</div>
<div className="p-4 grid grid-cols-2 gap-4">
<div>
<span className="text-slate-400 text-sm block">Auto-Reinvest</span>
<span className="text-white">{autoReinvest ? 'Yes' : 'No'}</span>
</div>
<div>
<span className="text-slate-400 text-sm block">Distribution</span>
<span className="text-white capitalize">{autoReinvest ? 'N/A' : distributionFrequency}</span>
</div>
</div>
{requiresKYC && (
<div className="p-4">
<h4 className="text-slate-400 text-sm mb-2">Account Holder</h4>
<p className="text-white">{kyc.fullName}</p>
<p className="text-slate-400 text-sm">{kyc.city}, {kyc.country}</p>
</div>
)}
<div className="p-4">
<div className="flex items-center gap-2 text-emerald-400">
<CheckCircle className="w-4 h-4" />
<span className="text-sm">Terms & Conditions accepted</span>
</div>
<div className="flex items-center gap-2 text-emerald-400 mt-1">
<CheckCircle className="w-4 h-4" />
<span className="text-sm">Risk disclosure acknowledged</span>
</div>
</div>
</div>
{/* Processing Time Notice */}
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-blue-400" />
<span className="text-blue-400 font-medium">Processing Time</span>
</div>
<p className="text-slate-400 text-sm mt-2">
Your account will be created immediately. Funds will be allocated to the trading strategy
within 1-2 business days after your deposit is confirmed.
</p>
</div>
</div>
);
// Render current step content
const renderStepContent = () => {
switch (currentStep) {
case 'amount':
return renderAmountStep();
case 'kyc':
return renderKYCStep();
case 'terms':
return renderTermsStep();
case 'confirm':
return renderConfirmStep();
default:
return null;
}
};
return (
<div className="bg-slate-900 rounded-xl border border-slate-800 max-w-2xl mx-auto">
{/* Header */}
<div className="p-6 border-b border-slate-800">
<h2 className="text-white text-xl font-semibold">Create Investment Account</h2>
<p className="text-slate-400 text-sm mt-1">
Complete the steps below to open your new investment account
</p>
</div>
{/* Step Indicator */}
<div className="p-6 border-b border-slate-800">
{renderStepIndicator()}
</div>
{/* Step Content */}
<div className="p-6">
{renderStepContent()}
</div>
{/* Navigation */}
<div className="p-6 border-t border-slate-800 flex justify-between">
<button
onClick={currentStepIndex === 0 ? onCancel : goToPrevStep}
className="flex items-center gap-2 px-4 py-2 text-slate-300 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{currentStepIndex === 0 ? 'Cancel' : 'Back'}
</button>
{currentStep === 'confirm' ? (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating Account...
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
Create Account
</>
)}
</button>
) : (
<button
onClick={goToNextStep}
disabled={!isStepValid()}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Continue
<ArrowRight className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};
export default CreateAccountWizard;

View File

@ -0,0 +1,591 @@
/**
* PortfolioOptimizerWidget Component
* Interactive portfolio allocation optimizer with Markowitz-based recommendations
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
PieChart,
TrendingUp,
Scale,
Shield,
Zap,
RefreshCw,
CheckCircle,
AlertTriangle,
Info,
ArrowRight,
Loader2,
ChevronDown,
ChevronUp,
} from 'lucide-react';
// Types
export interface AccountAllocation {
accountId: string;
accountName: string;
productName: string;
currentValue: number;
currentAllocation: number; // percentage
recommendedAllocation: number; // percentage
riskLevel: 'low' | 'medium' | 'high';
expectedReturn: number;
color: string;
}
export interface OptimizationResult {
strategy: OptimizationStrategy;
accounts: AccountAllocation[];
metrics: {
expectedReturn: number;
expectedVolatility: number;
sharpeRatio: number;
maxDrawdown: number;
};
rebalanceRequired: boolean;
totalDrift: number; // percentage drift from optimal
}
export interface PortfolioSimulation {
year: number;
conservative: number;
balanced: number;
aggressive: number;
}
export type OptimizationStrategy = 'conservative' | 'balanced' | 'aggressive' | 'custom';
export interface PortfolioOptimizerWidgetProps {
accounts: AccountAllocation[];
totalValue: number;
currentResult?: OptimizationResult;
onOptimize?: (strategy: OptimizationStrategy) => void;
onApplyRebalance?: (allocations: AccountAllocation[]) => void;
isOptimizing?: boolean;
isApplying?: boolean;
showSimulation?: boolean;
}
const STRATEGY_OPTIONS: {
value: OptimizationStrategy;
label: string;
description: string;
icon: React.ElementType;
color: string;
}[] = [
{
value: 'conservative',
label: 'Conservative',
description: 'Minimize risk, preserve capital',
icon: Shield,
color: 'text-emerald-400',
},
{
value: 'balanced',
label: 'Balanced',
description: 'Balance risk and return',
icon: Scale,
color: 'text-blue-400',
},
{
value: 'aggressive',
label: 'Aggressive',
description: 'Maximize returns, higher risk',
icon: Zap,
color: 'text-amber-400',
},
];
// Sample simulation data for visualization
const SIMULATION_DATA: PortfolioSimulation[] = [
{ year: 0, conservative: 100000, balanced: 100000, aggressive: 100000 },
{ year: 1, conservative: 106000, balanced: 110000, aggressive: 115000 },
{ year: 2, conservative: 112360, balanced: 121000, aggressive: 126500 },
{ year: 3, conservative: 119102, balanced: 133100, aggressive: 139150 },
{ year: 4, conservative: 126248, balanced: 146410, aggressive: 153065 },
{ year: 5, conservative: 133823, balanced: 161051, aggressive: 168372 },
];
const PortfolioOptimizerWidget: React.FC<PortfolioOptimizerWidgetProps> = ({
accounts,
totalValue,
currentResult,
onOptimize,
onApplyRebalance,
isOptimizing = false,
isApplying = false,
showSimulation = true,
}) => {
const [selectedStrategy, setSelectedStrategy] = useState<OptimizationStrategy>('balanced');
const [showDetails, setShowDetails] = useState(false);
const [confirmRebalance, setConfirmRebalance] = useState(false);
// Calculate drift for each account
const accountsWithDrift = useMemo(() => {
return accounts.map((account) => ({
...account,
drift: Math.abs(account.currentAllocation - account.recommendedAllocation),
driftDirection: account.currentAllocation > account.recommendedAllocation ? 'over' : 'under',
adjustmentAmount: totalValue * ((account.recommendedAllocation - account.currentAllocation) / 100),
}));
}, [accounts, totalValue]);
// Total drift
const totalDrift = useMemo(() => {
return accountsWithDrift.reduce((sum, acc) => sum + acc.drift, 0) / 2; // Divide by 2 because drift is counted twice
}, [accountsWithDrift]);
// Handle strategy selection
const handleStrategySelect = useCallback((strategy: OptimizationStrategy) => {
setSelectedStrategy(strategy);
onOptimize?.(strategy);
}, [onOptimize]);
// Handle apply rebalance
const handleApplyRebalance = useCallback(() => {
if (confirmRebalance) {
onApplyRebalance?.(accounts);
setConfirmRebalance(false);
} else {
setConfirmRebalance(true);
}
}, [confirmRebalance, accounts, onApplyRebalance]);
// Get risk level color
const getRiskColor = (level: string) => {
switch (level) {
case 'low':
return 'text-emerald-400';
case 'medium':
return 'text-amber-400';
case 'high':
return 'text-red-400';
default:
return 'text-slate-400';
}
};
// Render allocation pie chart (simplified SVG)
const renderAllocationChart = (type: 'current' | 'recommended') => {
let cumulativeAngle = 0;
return (
<svg viewBox="0 0 100 100" className="w-full h-full">
{accounts.map((account, index) => {
const allocation = type === 'current' ? account.currentAllocation : account.recommendedAllocation;
const angle = (allocation / 100) * 360;
const startAngle = cumulativeAngle;
cumulativeAngle += angle;
// Calculate arc path
const startRad = (startAngle - 90) * (Math.PI / 180);
const endRad = (startAngle + angle - 90) * (Math.PI / 180);
const x1 = 50 + 40 * Math.cos(startRad);
const y1 = 50 + 40 * Math.sin(startRad);
const x2 = 50 + 40 * Math.cos(endRad);
const y2 = 50 + 40 * Math.sin(endRad);
const largeArc = angle > 180 ? 1 : 0;
if (allocation < 0.5) return null;
return (
<path
key={`${type}-${account.accountId}`}
d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${largeArc} 1 ${x2} ${y2} Z`}
fill={account.color}
stroke="#1e293b"
strokeWidth="1"
opacity={0.9}
/>
);
})}
{/* Center hole */}
<circle cx="50" cy="50" r="25" fill="#0f172a" />
</svg>
);
};
// Render strategy selector
const renderStrategySelector = () => (
<div className="grid grid-cols-3 gap-3">
{STRATEGY_OPTIONS.map((strategy) => {
const Icon = strategy.icon;
const isSelected = selectedStrategy === strategy.value;
return (
<button
key={strategy.value}
onClick={() => handleStrategySelect(strategy.value)}
disabled={isOptimizing}
className={`p-4 rounded-lg border text-left transition-all ${
isSelected
? 'border-blue-500 bg-blue-900/20'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
} ${isOptimizing ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Icon className={`w-6 h-6 ${strategy.color} mb-2`} />
<h4 className="text-white font-medium">{strategy.label}</h4>
<p className="text-slate-400 text-xs mt-1">{strategy.description}</p>
</button>
);
})}
</div>
);
// Render allocation comparison
const renderAllocationComparison = () => (
<div className="grid grid-cols-2 gap-6">
{/* Current Allocation */}
<div>
<h4 className="text-slate-400 text-sm text-center mb-3">Current Allocation</h4>
<div className="w-32 h-32 mx-auto">
{renderAllocationChart('current')}
</div>
</div>
{/* Recommended Allocation */}
<div>
<h4 className="text-slate-400 text-sm text-center mb-3">Recommended</h4>
<div className="w-32 h-32 mx-auto relative">
{renderAllocationChart('recommended')}
{isOptimizing && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-900/50 rounded-full">
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
</div>
)}
</div>
</div>
</div>
);
// Render allocation legend
const renderAllocationLegend = () => (
<div className="space-y-2">
{accountsWithDrift.map((account) => (
<div
key={account.accountId}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: account.color }}
/>
<span className="text-slate-300">{account.accountName}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-slate-500 w-12 text-right">
{account.currentAllocation.toFixed(1)}%
</span>
<ArrowRight className="w-4 h-4 text-slate-600" />
<span className="text-white w-12 text-right">
{account.recommendedAllocation.toFixed(1)}%
</span>
{account.drift > 0.5 && (
<span className={`text-xs px-1.5 py-0.5 rounded ${
account.driftDirection === 'over' ? 'bg-red-900/50 text-red-400' : 'bg-emerald-900/50 text-emerald-400'
}`}>
{account.driftDirection === 'over' ? '-' : '+'}{account.drift.toFixed(1)}%
</span>
)}
</div>
</div>
))}
</div>
);
// Render metrics comparison
const renderMetricsComparison = () => {
if (!currentResult) return null;
const metrics = [
{
label: 'Expected Return',
value: `${currentResult.metrics.expectedReturn.toFixed(1)}%`,
change: '+2.3%',
positive: true,
},
{
label: 'Expected Volatility',
value: `${currentResult.metrics.expectedVolatility.toFixed(1)}%`,
change: '-1.5%',
positive: true,
},
{
label: 'Sharpe Ratio',
value: currentResult.metrics.sharpeRatio.toFixed(2),
change: '+0.15',
positive: true,
},
{
label: 'Max Drawdown',
value: `${currentResult.metrics.maxDrawdown.toFixed(1)}%`,
change: '-2.1%',
positive: true,
},
];
return (
<div className="grid grid-cols-2 gap-3">
{metrics.map((metric) => (
<div
key={metric.label}
className="bg-slate-800/50 rounded-lg p-3 border border-slate-700"
>
<span className="text-slate-400 text-xs">{metric.label}</span>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-white font-semibold">{metric.value}</span>
<span className={`text-xs ${metric.positive ? 'text-emerald-400' : 'text-red-400'}`}>
{metric.change}
</span>
</div>
</div>
))}
</div>
);
};
// Render rebalance actions
const renderRebalanceActions = () => {
if (totalDrift < 1) {
return (
<div className="bg-emerald-900/20 border border-emerald-800/50 rounded-lg p-4 flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-emerald-400" />
<div>
<p className="text-emerald-400 font-medium">Portfolio is Optimized</p>
<p className="text-slate-400 text-sm">Current allocation is within optimal range</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Drift Warning */}
<div className="bg-amber-900/20 border border-amber-800/50 rounded-lg p-4 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-amber-400 font-medium">Rebalancing Recommended</p>
<p className="text-slate-400 text-sm">
Your portfolio has drifted {totalDrift.toFixed(1)}% from the optimal allocation.
Rebalancing can help maintain your risk profile and improve returns.
</p>
</div>
</div>
{/* Rebalance Details */}
<button
onClick={() => setShowDetails(!showDetails)}
className="w-full flex items-center justify-between text-slate-300 hover:text-white"
>
<span className="text-sm">View rebalance details</span>
{showDetails ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{showDetails && (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 space-y-3">
<h4 className="text-white font-medium text-sm">Required Adjustments</h4>
{accountsWithDrift
.filter((acc) => Math.abs(acc.adjustmentAmount) > 10)
.map((account) => (
<div
key={account.accountId}
className="flex items-center justify-between text-sm"
>
<span className="text-slate-300">{account.accountName}</span>
<span className={account.adjustmentAmount > 0 ? 'text-emerald-400' : 'text-red-400'}>
{account.adjustmentAmount > 0 ? '+' : ''}
${Math.abs(account.adjustmentAmount).toLocaleString(undefined, { maximumFractionDigits: 0 })}
</span>
</div>
))}
<div className="pt-3 border-t border-slate-700">
<p className="text-slate-500 text-xs flex items-center gap-1">
<Info className="w-3 h-3" />
Funds will be transferred between accounts automatically
</p>
</div>
</div>
)}
{/* Confirm/Apply Button */}
<div className="flex gap-3">
{confirmRebalance && (
<button
onClick={() => setConfirmRebalance(false)}
className="flex-1 px-4 py-2 text-slate-300 hover:text-white transition-colors"
>
Cancel
</button>
)}
<button
onClick={handleApplyRebalance}
disabled={isApplying}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg transition-colors ${
confirmRebalance
? 'bg-amber-600 hover:bg-amber-500 text-white'
: 'bg-blue-600 hover:bg-blue-500 text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isApplying ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Rebalancing...
</>
) : confirmRebalance ? (
<>
<CheckCircle className="w-4 h-4" />
Confirm Rebalance
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Apply Rebalance
</>
)}
</button>
</div>
</div>
);
};
// Render simulation chart (simplified)
const renderSimulationChart = () => {
if (!showSimulation) return null;
const maxValue = Math.max(...SIMULATION_DATA.map((d) => Math.max(d.conservative, d.balanced, d.aggressive)));
const scale = 100 / maxValue;
return (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<h4 className="text-white font-medium mb-4">5-Year Projection (${totalValue.toLocaleString()})</h4>
<div className="h-32 relative">
<svg className="w-full h-full" preserveAspectRatio="none" viewBox="0 0 100 100">
{/* Grid lines */}
{[0, 25, 50, 75, 100].map((y) => (
<line
key={y}
x1="0"
y1={100 - y}
x2="100"
y2={100 - y}
stroke="#334155"
strokeWidth="0.5"
/>
))}
{/* Conservative line */}
<polyline
fill="none"
stroke="#10b981"
strokeWidth="2"
points={SIMULATION_DATA.map((d, i) =>
`${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.conservative * scale)}`
).join(' ')}
/>
{/* Balanced line */}
<polyline
fill="none"
stroke="#3b82f6"
strokeWidth="2"
points={SIMULATION_DATA.map((d, i) =>
`${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.balanced * scale)}`
).join(' ')}
/>
{/* Aggressive line */}
<polyline
fill="none"
stroke="#f59e0b"
strokeWidth="2"
points={SIMULATION_DATA.map((d, i) =>
`${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.aggressive * scale)}`
).join(' ')}
/>
</svg>
</div>
{/* Legend */}
<div className="flex justify-center gap-6 mt-4">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500" />
<span className="text-slate-400 text-xs">Conservative</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-slate-400 text-xs">Balanced</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-slate-400 text-xs">Aggressive</span>
</div>
</div>
<p className="text-slate-500 text-xs text-center mt-3">
Projections are based on historical performance and do not guarantee future results
</p>
</div>
);
};
return (
<div className="bg-slate-900 rounded-xl border border-slate-800">
{/* Header */}
<div className="p-6 border-b border-slate-800">
<div className="flex items-center gap-3">
<PieChart className="w-6 h-6 text-blue-400" />
<div>
<h2 className="text-white text-lg font-semibold">Portfolio Optimizer</h2>
<p className="text-slate-400 text-sm">
Total Value: ${totalValue.toLocaleString()}
</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Strategy Selector */}
<div>
<h3 className="text-white font-medium mb-3">Optimization Strategy</h3>
{renderStrategySelector()}
</div>
{/* Allocation Comparison */}
<div>
<h3 className="text-white font-medium mb-3">Allocation Comparison</h3>
{renderAllocationComparison()}
<div className="mt-4">
{renderAllocationLegend()}
</div>
</div>
{/* Metrics */}
{currentResult && (
<div>
<h3 className="text-white font-medium mb-3">Expected Impact</h3>
{renderMetricsComparison()}
</div>
)}
{/* Simulation */}
{renderSimulationChart()}
{/* Rebalance Actions */}
<div>
<h3 className="text-white font-medium mb-3">Rebalance</h3>
{renderRebalanceActions()}
</div>
</div>
</div>
);
};
export default PortfolioOptimizerWidget;

View File

@ -0,0 +1,519 @@
/**
* RiskAnalysisPanel Component
* Display comprehensive risk metrics and analysis for investment accounts
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useMemo } from 'react';
import {
Shield,
AlertTriangle,
TrendingDown,
Activity,
BarChart3,
Target,
Info,
ChevronRight,
ArrowUpRight,
ArrowDownRight,
} from 'lucide-react';
// Types
export interface RiskMetrics {
var95: number; // Value at Risk 95%
var99: number; // Value at Risk 99%
cvar: number; // Conditional VaR (Expected Shortfall)
sharpeRatio: number; // Risk-adjusted return
sortinoRatio: number; // Downside risk-adjusted return
maxDrawdown: number; // Maximum drawdown percentage
maxDrawdownDuration: number; // Days in drawdown
beta: number; // Market correlation
alpha: number; // Excess return over benchmark
volatility: number; // Standard deviation of returns
downsideDeviation: number;
calmarRatio: number; // Return / Max Drawdown
}
export interface RiskScore {
overall: number; // 0-100
marketRisk: number;
liquidityRisk: number;
operationalRisk: number;
concentrationRisk: number;
}
export interface RiskRecommendation {
id: string;
type: 'warning' | 'suggestion' | 'info';
title: string;
description: string;
action?: string;
}
export interface RiskAnalysisPanelProps {
accountId: string;
accountName: string;
metrics: RiskMetrics;
score: RiskScore;
recommendations?: RiskRecommendation[];
benchmarkName?: string;
period?: '1M' | '3M' | '6M' | '1Y' | 'ALL';
onPeriodChange?: (period: string) => void;
onViewDetails?: () => void;
compact?: boolean;
isLoading?: boolean;
}
const PERIOD_OPTIONS = [
{ value: '1M', label: '1 Month' },
{ value: '3M', label: '3 Months' },
{ value: '6M', label: '6 Months' },
{ value: '1Y', label: '1 Year' },
{ value: 'ALL', label: 'All Time' },
];
const RiskAnalysisPanel: React.FC<RiskAnalysisPanelProps> = ({
accountId,
accountName,
metrics,
score,
recommendations = [],
benchmarkName = 'S&P 500',
period = '1Y',
onPeriodChange,
onViewDetails,
compact = false,
isLoading = false,
}) => {
// Get risk level from score
const getRiskLevel = (scoreValue: number): { label: string; color: string; bgColor: string } => {
if (scoreValue <= 30) return { label: 'Low', color: 'text-emerald-400', bgColor: 'bg-emerald-400' };
if (scoreValue <= 60) return { label: 'Moderate', color: 'text-amber-400', bgColor: 'bg-amber-400' };
if (scoreValue <= 80) return { label: 'High', color: 'text-orange-400', bgColor: 'bg-orange-400' };
return { label: 'Very High', color: 'text-red-400', bgColor: 'bg-red-400' };
};
// Get metric color based on value
const getMetricColor = (value: number, thresholds: { good: number; warning: number }, inverse = false) => {
if (inverse) {
if (value <= thresholds.good) return 'text-emerald-400';
if (value <= thresholds.warning) return 'text-amber-400';
return 'text-red-400';
}
if (value >= thresholds.good) return 'text-emerald-400';
if (value >= thresholds.warning) return 'text-amber-400';
return 'text-red-400';
};
// Format percentage
const formatPercent = (value: number, decimals = 2) => {
return `${value >= 0 ? '+' : ''}${value.toFixed(decimals)}%`;
};
// Overall risk level
const overallRisk = useMemo(() => getRiskLevel(score.overall), [score.overall]);
// Render risk score gauge
const renderRiskGauge = () => (
<div className="relative w-32 h-32">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
{/* Background circle */}
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="currentColor"
strokeWidth="12"
className="text-slate-700"
/>
{/* Score arc */}
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="currentColor"
strokeWidth="12"
strokeDasharray={`${(score.overall / 100) * 251.2} 251.2`}
strokeLinecap="round"
className={overallRisk.color}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-white">{score.overall}</span>
<span className={`text-xs font-medium ${overallRisk.color}`}>{overallRisk.label} Risk</span>
</div>
</div>
);
// Render risk breakdown bars
const renderRiskBreakdown = () => {
const risks = [
{ label: 'Market Risk', value: score.marketRisk, icon: TrendingDown },
{ label: 'Liquidity Risk', value: score.liquidityRisk, icon: Activity },
{ label: 'Operational Risk', value: score.operationalRisk, icon: Shield },
{ label: 'Concentration Risk', value: score.concentrationRisk, icon: Target },
];
return (
<div className="space-y-3">
{risks.map((risk) => {
const level = getRiskLevel(risk.value);
const Icon = risk.icon;
return (
<div key={risk.label}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-slate-500" />
<span className="text-sm text-slate-300">{risk.label}</span>
</div>
<span className={`text-sm font-medium ${level.color}`}>{risk.value}</span>
</div>
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${level.bgColor}`}
style={{ width: `${risk.value}%` }}
/>
</div>
</div>
);
})}
</div>
);
};
// Render key metrics
const renderKeyMetrics = () => {
const keyMetrics = [
{
label: 'Sharpe Ratio',
value: metrics.sharpeRatio.toFixed(2),
description: 'Risk-adjusted return',
color: getMetricColor(metrics.sharpeRatio, { good: 1.5, warning: 1.0 }),
icon: BarChart3,
},
{
label: 'Sortino Ratio',
value: metrics.sortinoRatio.toFixed(2),
description: 'Downside risk-adjusted',
color: getMetricColor(metrics.sortinoRatio, { good: 2.0, warning: 1.0 }),
icon: TrendingDown,
},
{
label: 'Max Drawdown',
value: formatPercent(-metrics.maxDrawdown),
description: `${metrics.maxDrawdownDuration} days duration`,
color: getMetricColor(metrics.maxDrawdown, { good: 10, warning: 20 }, true),
icon: ArrowDownRight,
},
{
label: 'Volatility',
value: formatPercent(metrics.volatility),
description: 'Annualized std. dev.',
color: getMetricColor(metrics.volatility, { good: 15, warning: 25 }, true),
icon: Activity,
},
];
return (
<div className="grid grid-cols-2 gap-4">
{keyMetrics.map((metric) => {
const Icon = metric.icon;
return (
<div
key={metric.label}
className="bg-slate-800/50 rounded-lg p-3 border border-slate-700"
>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4 text-slate-500" />
<span className="text-slate-400 text-sm">{metric.label}</span>
</div>
<p className={`text-xl font-semibold ${metric.color}`}>{metric.value}</p>
<p className="text-slate-500 text-xs mt-1">{metric.description}</p>
</div>
);
})}
</div>
);
};
// Render VaR metrics
const renderVaRMetrics = () => (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="w-5 h-5 text-amber-400" />
<h4 className="text-white font-medium">Value at Risk (VaR)</h4>
<button className="ml-auto text-slate-500 hover:text-slate-300">
<Info className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<span className="text-slate-400 text-sm block mb-1">VaR (95%)</span>
<span className="text-red-400 text-lg font-semibold">
${Math.abs(metrics.var95).toLocaleString()}
</span>
<span className="text-slate-500 text-xs block">
5% chance of loss exceeding this
</span>
</div>
<div>
<span className="text-slate-400 text-sm block mb-1">VaR (99%)</span>
<span className="text-red-400 text-lg font-semibold">
${Math.abs(metrics.var99).toLocaleString()}
</span>
<span className="text-slate-500 text-xs block">
1% chance of loss exceeding this
</span>
</div>
<div>
<span className="text-slate-400 text-sm block mb-1">CVaR (ES)</span>
<span className="text-red-400 text-lg font-semibold">
${Math.abs(metrics.cvar).toLocaleString()}
</span>
<span className="text-slate-500 text-xs block">
Expected loss in worst 5%
</span>
</div>
</div>
</div>
);
// Render benchmark comparison
const renderBenchmarkComparison = () => (
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<div className="flex items-center justify-between mb-4">
<h4 className="text-white font-medium">vs {benchmarkName}</h4>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<span className="text-slate-400 text-sm block mb-1">Alpha</span>
<div className="flex items-center gap-2">
{metrics.alpha >= 0 ? (
<ArrowUpRight className="w-5 h-5 text-emerald-400" />
) : (
<ArrowDownRight className="w-5 h-5 text-red-400" />
)}
<span className={`text-xl font-semibold ${metrics.alpha >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{formatPercent(metrics.alpha)}
</span>
</div>
<span className="text-slate-500 text-xs">
{metrics.alpha >= 0 ? 'Outperforming' : 'Underperforming'} benchmark
</span>
</div>
<div>
<span className="text-slate-400 text-sm block mb-1">Beta</span>
<span className="text-white text-xl font-semibold">{metrics.beta.toFixed(2)}</span>
<span className="text-slate-500 text-xs block">
{metrics.beta < 1 ? 'Lower' : metrics.beta > 1 ? 'Higher' : 'Same'} market sensitivity
</span>
</div>
</div>
</div>
);
// Render recommendations
const renderRecommendations = () => {
if (recommendations.length === 0) return null;
const getRecommendationStyles = (type: RiskRecommendation['type']) => {
switch (type) {
case 'warning':
return { icon: AlertTriangle, color: 'text-amber-400', bg: 'bg-amber-900/20', border: 'border-amber-800/50' };
case 'suggestion':
return { icon: Target, color: 'text-blue-400', bg: 'bg-blue-900/20', border: 'border-blue-800/50' };
case 'info':
default:
return { icon: Info, color: 'text-slate-400', bg: 'bg-slate-800/50', border: 'border-slate-700' };
}
};
return (
<div className="space-y-3">
<h4 className="text-white font-medium">Recommendations</h4>
{recommendations.map((rec) => {
const styles = getRecommendationStyles(rec.type);
const Icon = styles.icon;
return (
<div
key={rec.id}
className={`rounded-lg p-3 border ${styles.bg} ${styles.border}`}
>
<div className="flex items-start gap-3">
<Icon className={`w-5 h-5 ${styles.color} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
<p className="text-white font-medium text-sm">{rec.title}</p>
<p className="text-slate-400 text-sm mt-1">{rec.description}</p>
{rec.action && (
<button className="text-blue-400 text-sm mt-2 flex items-center gap-1 hover:text-blue-300">
{rec.action}
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
);
};
// Loading state
if (isLoading) {
return (
<div className="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-slate-800 rounded w-1/3" />
<div className="h-32 bg-slate-800 rounded" />
<div className="grid grid-cols-2 gap-4">
<div className="h-24 bg-slate-800 rounded" />
<div className="h-24 bg-slate-800 rounded" />
</div>
</div>
</div>
);
}
// Compact view
if (compact) {
return (
<div className="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-blue-400" />
<h3 className="text-white font-medium">Risk Analysis</h3>
</div>
<div className={`px-2 py-1 rounded text-xs font-medium ${overallRisk.color} bg-slate-800`}>
{overallRisk.label} Risk ({score.overall})
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-center">
<div>
<span className="text-slate-500 text-xs block">Sharpe</span>
<span className={`font-semibold ${getMetricColor(metrics.sharpeRatio, { good: 1.5, warning: 1.0 })}`}>
{metrics.sharpeRatio.toFixed(2)}
</span>
</div>
<div>
<span className="text-slate-500 text-xs block">Max DD</span>
<span className={`font-semibold ${getMetricColor(metrics.maxDrawdown, { good: 10, warning: 20 }, true)}`}>
{formatPercent(-metrics.maxDrawdown)}
</span>
</div>
<div>
<span className="text-slate-500 text-xs block">VaR 95%</span>
<span className="text-red-400 font-semibold">${Math.abs(metrics.var95).toLocaleString()}</span>
</div>
<div>
<span className="text-slate-500 text-xs block">Beta</span>
<span className="text-white font-semibold">{metrics.beta.toFixed(2)}</span>
</div>
</div>
{onViewDetails && (
<button
onClick={onViewDetails}
className="w-full mt-4 text-blue-400 text-sm flex items-center justify-center gap-1 hover:text-blue-300"
>
View Full Analysis
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
);
}
// Full view
return (
<div className="bg-slate-900 rounded-xl border border-slate-800">
{/* Header */}
<div className="p-6 border-b border-slate-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-blue-400" />
<div>
<h2 className="text-white text-lg font-semibold">Risk Analysis</h2>
<p className="text-slate-400 text-sm">{accountName}</p>
</div>
</div>
{/* Period Selector */}
<div className="flex items-center gap-2">
{PERIOD_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onPeriodChange?.(opt.value)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
period === opt.value
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Risk Score Section */}
<div className="flex items-start gap-8">
<div className="flex-shrink-0">
{renderRiskGauge()}
</div>
<div className="flex-1">
{renderRiskBreakdown()}
</div>
</div>
{/* Key Metrics */}
<div>
<h4 className="text-white font-medium mb-4">Key Risk Metrics</h4>
{renderKeyMetrics()}
</div>
{/* VaR Section */}
{renderVaRMetrics()}
{/* Benchmark Comparison */}
{renderBenchmarkComparison()}
{/* Additional Metrics */}
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
<h4 className="text-white font-medium mb-4">Additional Metrics</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<span className="text-slate-400 text-sm block">Downside Deviation</span>
<span className="text-white font-semibold">{formatPercent(metrics.downsideDeviation)}</span>
</div>
<div>
<span className="text-slate-400 text-sm block">Calmar Ratio</span>
<span className={`font-semibold ${getMetricColor(metrics.calmarRatio, { good: 2.0, warning: 1.0 })}`}>
{metrics.calmarRatio.toFixed(2)}
</span>
</div>
<div>
<span className="text-slate-400 text-sm block">Recovery Factor</span>
<span className="text-white font-semibold">
{(metrics.calmarRatio * 0.8).toFixed(2)}
</span>
</div>
</div>
</div>
{/* Recommendations */}
{renderRecommendations()}
</div>
</div>
);
};
export default RiskAnalysisPanel;

View File

@ -1,6 +1,7 @@
/**
* Investment Module Components
* Barrel export for all investment-related components
* Epic: OQI-004 Cuentas de Inversion
*/
// Form Components
@ -19,3 +20,17 @@ export type { PerformanceDataPoint } from './PerformanceWidgetChart';
export { default as AccountSettingsPanel } from './AccountSettingsPanel';
export type { AccountSettings, AccountForSettings } from './AccountSettingsPanel';
// Account Creation & Management (OQI-004)
export { default as CreateAccountWizard } from './CreateAccountWizard';
export type { ProductSelection, KYCData, AccountCreationData } from './CreateAccountWizard';
export { default as AccountTransferModal } from './AccountTransferModal';
export type { TransferAccount, TransferFee, TransferData } from './AccountTransferModal';
// Risk & Optimization Components (OQI-004)
export { default as RiskAnalysisPanel } from './RiskAnalysisPanel';
export type { RiskMetrics, RiskScore, RiskRecommendation } from './RiskAnalysisPanel';
export { default as PortfolioOptimizerWidget } from './PortfolioOptimizerWidget';
export type { AccountAllocation, OptimizationResult, PortfolioSimulation, OptimizationStrategy } from './PortfolioOptimizerWidget';