diff --git a/src/modules/investment/components/AccountTransferModal.tsx b/src/modules/investment/components/AccountTransferModal.tsx new file mode 100644 index 0000000..ae75371 --- /dev/null +++ b/src/modules/investment/components/AccountTransferModal.tsx @@ -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 = ({ + isOpen, + accounts, + defaultSourceId, + defaultDestinationId, + transferFee = { type: 'fixed', value: 0 }, + minTransfer = 100, + maxTransfer = 100000, + onTransfer, + onClose, + isSubmitting = false, +}) => { + const [sourceAccountId, setSourceAccountId] = useState(defaultSourceId || ''); + const [destinationAccountId, setDestinationAccountId] = useState(defaultDestinationId || ''); + const [amount, setAmount] = useState(''); + const [note, setNote] = useState(''); + 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 ( +
+ + + {isOpen && ( +
+ {availableAccounts.map((account) => ( + + ))} + {availableAccounts.length === 0 && ( +

No accounts available

+ )} +
+ )} +
+ ); + }; + + // Render form step + const renderFormStep = () => ( +
+ {/* Source Account */} +
+ + {renderAccountSelector( + 'source', + sourceAccountId, + setSourceAccountId, + showSourceDropdown, + setShowSourceDropdown + )} +
+ + {/* Swap Button */} +
+ +
+ + {/* Destination Account */} +
+ + {renderAccountSelector( + 'destination', + destinationAccountId, + setDestinationAccountId, + showDestDropdown, + setShowDestDropdown + )} +
+ + {/* Amount */} +
+ +
+ $ + 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" + /> +
+
+ Min: ${minTransfer.toLocaleString()} + Max: ${maxTransfer.toLocaleString()} +
+ + {/* Quick Amount Buttons */} + {quickAmounts.length > 0 && ( +
+ {quickAmounts.map((quick) => ( + + ))} +
+ )} +
+ + {/* Note */} +
+ + 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" + /> +
+ + {/* Summary */} + {parseFloat(amount) > 0 && ( +
+
+ Transfer Amount + ${parseFloat(amount).toLocaleString()} +
+ {calculatedFee > 0 && ( +
+ Transfer Fee + ${calculatedFee.toFixed(2)} +
+ )} +
+ Total Deduction + ${totalDeduction.toLocaleString()} +
+
+ )} + + {/* Validation Errors */} + {validation.errors.length > 0 && parseFloat(amount) > 0 && ( +
+ {validation.errors.map((error, index) => ( +

+ + {error} +

+ ))} +
+ )} +
+ ); + + // Render confirm step + const renderConfirmStep = () => ( +
+
+
+ +
+

Confirm Transfer

+

Please review the details below

+
+ + {/* Transfer Visual */} +
+ {/* Source */} +
+
+
+ From +
+

{sourceAccount?.name}

+

-${totalDeduction.toLocaleString()}

+
+ + + + {/* Destination */} +
+
+
+ To +
+

{destinationAccount?.name}

+

+${parseFloat(amount).toLocaleString()}

+
+
+ + {/* Summary */} +
+
+ Transfer Amount + ${parseFloat(amount).toLocaleString()} +
+ {calculatedFee > 0 && ( +
+ Fee + ${calculatedFee.toFixed(2)} +
+ )} + {note && ( +
+ Note + {note} +
+ )} +
+ Total Deduction + ${totalDeduction.toLocaleString()} +
+
+ + {/* Info */} +
+
+ +

+ The transfer will be processed immediately. Funds will be available in the destination + account within minutes. This action cannot be undone. +

+
+
+
+ ); + + // Render success step + const renderSuccessStep = () => ( +
+
+ +
+

Transfer Complete!

+

+ ${parseFloat(amount).toLocaleString()} has been transferred from{' '} + {sourceAccount?.name} to{' '} + {destinationAccount?.name} +

+ +
+ ); + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+ +

Transfer Funds

+
+ +
+ + {/* Content */} +
+ {step === 'form' && renderFormStep()} + {step === 'confirm' && renderConfirmStep()} + {step === 'success' && renderSuccessStep()} +
+ + {/* Footer */} + {step !== 'success' && ( +
+ + +
+ )} +
+
+ ); +}; + +export default AccountTransferModal; diff --git a/src/modules/investment/components/CreateAccountWizard.tsx b/src/modules/investment/components/CreateAccountWizard.tsx new file mode 100644 index 0000000..e43065a --- /dev/null +++ b/src/modules/investment/components/CreateAccountWizard.tsx @@ -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 = ({ + product, + onComplete, + onCancel, + isSubmitting = false, + minInvestment = 1000, + maxInvestment = 1000000, + requiresKYC = true, +}) => { + const [currentStep, setCurrentStep] = useState('amount'); + const [amount, setAmount] = useState(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({ + 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 = () => ( +
+ {availableSteps.map((step, index) => { + const StepIcon = step.icon; + const isActive = step.id === currentStep; + const isCompleted = index < currentStepIndex; + + return ( + +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {step.title} + +
+ {index < availableSteps.length - 1 && ( +
+ )} + + ); + })} +
+ ); + + // Render amount step + const renderAmountStep = () => ( +
+ {/* Product Summary */} +
+
+
+ +
+
+

{product.name}

+

+ Managed by {product.tradingAgent} Agent +

+
+
+
+
+ Expected Return + {product.expectedReturn} +
+
+ Risk Level + + {product.riskLevel} + +
+
+ Min. Investment + ${product.minInvestment.toLocaleString()} +
+
+
+ + {/* Amount Input */} +
+ +
+ + 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" + /> +
+
+ Min: ${minInvestment.toLocaleString()} + Max: ${maxInvestment.toLocaleString()} +
+ {(amount < minInvestment || amount > maxInvestment) && ( +

+ + Amount must be between ${minInvestment.toLocaleString()} and ${maxInvestment.toLocaleString()} +

+ )} +
+ + {/* Quick Amount Buttons */} +
+ {[1000, 5000, 10000, 25000, 50000, 100000].map((quickAmount) => ( + + ))} +
+ + {/* Projected Returns */} +
+
+ + Projected Returns +
+
+
+ Monthly (Est.) + + ${projectedReturns.monthly.toLocaleString(undefined, { maximumFractionDigits: 2 })} + +
+
+ Yearly (Est.) + + ${projectedReturns.yearly.toLocaleString(undefined, { maximumFractionDigits: 2 })} + +
+
+

+ + Past performance does not guarantee future results +

+
+ + {/* Distribution Settings */} +
+
+
+ +

Automatically reinvest your earnings

+
+ +
+ + {!autoReinvest && ( +
+ + +
+ )} +
+
+ ); + + // Render KYC step + const renderKYCStep = () => ( +
+
+
+ + Identity Verification Required +
+

+ For regulatory compliance, we need to verify your identity before opening an investment account. +

+
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {RISK_TOLERANCE_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ ); + + // Render terms step + const renderTermsStep = () => ( +
+ {/* Terms & Conditions */} +
+
+

+ + Terms & Conditions +

+
+
+

By opening an investment account with Trading Platform, you agree to the following terms:

+
    +
  • You are at least 18 years old and legally able to enter into contracts.
  • +
  • The information you have provided is accurate and complete.
  • +
  • You understand that all investments carry risk, including the potential loss of principal.
  • +
  • You agree to our fee schedule as outlined in the product documentation.
  • +
  • You authorize us to manage your investment according to the selected product strategy.
  • +
  • You may request withdrawals subject to our withdrawal policy and any applicable lock-up periods.
  • +
  • Account statements will be provided monthly via email and accessible through the platform.
  • +
+
+
+ +
+
+ + {/* Risk Disclosure */} +
+
+

+ + Risk Disclosure +

+
+
+

Important Risk Information:

+
    +
  • Past performance is not indicative of future results.
  • +
  • The value of investments can go down as well as up.
  • +
  • You may receive back less than your original investment.
  • +
  • Trading strategies involve significant risks including market, liquidity, and operational risks.
  • +
  • Automated trading systems may experience technical failures or delays.
  • +
  • Currency fluctuations may affect the value of international investments.
  • +
  • There is no guarantee that investment objectives will be achieved.
  • +
+

+ Only invest money that you can afford to lose. If you are unsure about the suitability + of this investment, please seek independent financial advice. +

+
+
+ +
+
+
+ ); + + // Render confirm step + const renderConfirmStep = () => ( +
+
+
+ +
+

Review Your Investment

+

Please confirm the details below

+
+ + {/* Summary Card */} +
+
+

Product

+
+
+ +
+
+

{product.name}

+

{product.tradingAgent} Agent

+
+
+
+ +
+
+ Investment Amount + ${amount.toLocaleString()} +
+
+ Expected Return + {product.expectedReturn} +
+
+ +
+
+ Auto-Reinvest + {autoReinvest ? 'Yes' : 'No'} +
+
+ Distribution + {autoReinvest ? 'N/A' : distributionFrequency} +
+
+ + {requiresKYC && ( +
+

Account Holder

+

{kyc.fullName}

+

{kyc.city}, {kyc.country}

+
+ )} + +
+
+ + Terms & Conditions accepted +
+
+ + Risk disclosure acknowledged +
+
+
+ + {/* Processing Time Notice */} +
+
+ + Processing Time +
+

+ Your account will be created immediately. Funds will be allocated to the trading strategy + within 1-2 business days after your deposit is confirmed. +

+
+
+ ); + + // 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 ( +
+ {/* Header */} +
+

Create Investment Account

+

+ Complete the steps below to open your new investment account +

+
+ + {/* Step Indicator */} +
+ {renderStepIndicator()} +
+ + {/* Step Content */} +
+ {renderStepContent()} +
+ + {/* Navigation */} +
+ + + {currentStep === 'confirm' ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default CreateAccountWizard; diff --git a/src/modules/investment/components/PortfolioOptimizerWidget.tsx b/src/modules/investment/components/PortfolioOptimizerWidget.tsx new file mode 100644 index 0000000..cd06415 --- /dev/null +++ b/src/modules/investment/components/PortfolioOptimizerWidget.tsx @@ -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 = ({ + accounts, + totalValue, + currentResult, + onOptimize, + onApplyRebalance, + isOptimizing = false, + isApplying = false, + showSimulation = true, +}) => { + const [selectedStrategy, setSelectedStrategy] = useState('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 ( + + {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 ( + + ); + })} + {/* Center hole */} + + + ); + }; + + // Render strategy selector + const renderStrategySelector = () => ( +
+ {STRATEGY_OPTIONS.map((strategy) => { + const Icon = strategy.icon; + const isSelected = selectedStrategy === strategy.value; + + return ( + + ); + })} +
+ ); + + // Render allocation comparison + const renderAllocationComparison = () => ( +
+ {/* Current Allocation */} +
+

Current Allocation

+
+ {renderAllocationChart('current')} +
+
+ + {/* Recommended Allocation */} +
+

Recommended

+
+ {renderAllocationChart('recommended')} + {isOptimizing && ( +
+ +
+ )} +
+
+
+ ); + + // Render allocation legend + const renderAllocationLegend = () => ( +
+ {accountsWithDrift.map((account) => ( +
+
+
+ {account.accountName} +
+
+ + {account.currentAllocation.toFixed(1)}% + + + + {account.recommendedAllocation.toFixed(1)}% + + {account.drift > 0.5 && ( + + {account.driftDirection === 'over' ? '-' : '+'}{account.drift.toFixed(1)}% + + )} +
+
+ ))} +
+ ); + + // 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 ( +
+ {metrics.map((metric) => ( +
+ {metric.label} +
+ {metric.value} + + {metric.change} + +
+
+ ))} +
+ ); + }; + + // Render rebalance actions + const renderRebalanceActions = () => { + if (totalDrift < 1) { + return ( +
+ +
+

Portfolio is Optimized

+

Current allocation is within optimal range

+
+
+ ); + } + + return ( +
+ {/* Drift Warning */} +
+ +
+

Rebalancing Recommended

+

+ Your portfolio has drifted {totalDrift.toFixed(1)}% from the optimal allocation. + Rebalancing can help maintain your risk profile and improve returns. +

+
+
+ + {/* Rebalance Details */} + + + {showDetails && ( +
+

Required Adjustments

+ {accountsWithDrift + .filter((acc) => Math.abs(acc.adjustmentAmount) > 10) + .map((account) => ( +
+ {account.accountName} + 0 ? 'text-emerald-400' : 'text-red-400'}> + {account.adjustmentAmount > 0 ? '+' : ''} + ${Math.abs(account.adjustmentAmount).toLocaleString(undefined, { maximumFractionDigits: 0 })} + +
+ ))} +
+

+ + Funds will be transferred between accounts automatically +

+
+
+ )} + + {/* Confirm/Apply Button */} +
+ {confirmRebalance && ( + + )} + +
+
+ ); + }; + + // 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 ( +
+

5-Year Projection (${totalValue.toLocaleString()})

+ +
+ + {/* Grid lines */} + {[0, 25, 50, 75, 100].map((y) => ( + + ))} + + {/* Conservative line */} + + `${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.conservative * scale)}` + ).join(' ')} + /> + + {/* Balanced line */} + + `${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.balanced * scale)}` + ).join(' ')} + /> + + {/* Aggressive line */} + + `${(i / (SIMULATION_DATA.length - 1)) * 100},${100 - (d.aggressive * scale)}` + ).join(' ')} + /> + +
+ + {/* Legend */} +
+
+
+ Conservative +
+
+
+ Balanced +
+
+
+ Aggressive +
+
+ +

+ Projections are based on historical performance and do not guarantee future results +

+
+ ); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

Portfolio Optimizer

+

+ Total Value: ${totalValue.toLocaleString()} +

+
+
+
+ + {/* Content */} +
+ {/* Strategy Selector */} +
+

Optimization Strategy

+ {renderStrategySelector()} +
+ + {/* Allocation Comparison */} +
+

Allocation Comparison

+ {renderAllocationComparison()} +
+ {renderAllocationLegend()} +
+
+ + {/* Metrics */} + {currentResult && ( +
+

Expected Impact

+ {renderMetricsComparison()} +
+ )} + + {/* Simulation */} + {renderSimulationChart()} + + {/* Rebalance Actions */} +
+

Rebalance

+ {renderRebalanceActions()} +
+
+
+ ); +}; + +export default PortfolioOptimizerWidget; diff --git a/src/modules/investment/components/RiskAnalysisPanel.tsx b/src/modules/investment/components/RiskAnalysisPanel.tsx new file mode 100644 index 0000000..a48c5b8 --- /dev/null +++ b/src/modules/investment/components/RiskAnalysisPanel.tsx @@ -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 = ({ + 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 = () => ( +
+ + {/* Background circle */} + + {/* Score arc */} + + +
+ {score.overall} + {overallRisk.label} Risk +
+
+ ); + + // 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 ( +
+ {risks.map((risk) => { + const level = getRiskLevel(risk.value); + const Icon = risk.icon; + return ( +
+
+
+ + {risk.label} +
+ {risk.value} +
+
+
+
+
+ ); + })} +
+ ); + }; + + // 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 ( +
+ {keyMetrics.map((metric) => { + const Icon = metric.icon; + return ( +
+
+ + {metric.label} +
+

{metric.value}

+

{metric.description}

+
+ ); + })} +
+ ); + }; + + // Render VaR metrics + const renderVaRMetrics = () => ( +
+
+ +

Value at Risk (VaR)

+ +
+ +
+
+ VaR (95%) + + ${Math.abs(metrics.var95).toLocaleString()} + + + 5% chance of loss exceeding this + +
+
+ VaR (99%) + + ${Math.abs(metrics.var99).toLocaleString()} + + + 1% chance of loss exceeding this + +
+
+ CVaR (ES) + + ${Math.abs(metrics.cvar).toLocaleString()} + + + Expected loss in worst 5% + +
+
+
+ ); + + // Render benchmark comparison + const renderBenchmarkComparison = () => ( +
+
+

vs {benchmarkName}

+
+ +
+
+ Alpha +
+ {metrics.alpha >= 0 ? ( + + ) : ( + + )} + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {formatPercent(metrics.alpha)} + +
+ + {metrics.alpha >= 0 ? 'Outperforming' : 'Underperforming'} benchmark + +
+
+ Beta + {metrics.beta.toFixed(2)} + + {metrics.beta < 1 ? 'Lower' : metrics.beta > 1 ? 'Higher' : 'Same'} market sensitivity + +
+
+
+ ); + + // 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 ( +
+

Recommendations

+ {recommendations.map((rec) => { + const styles = getRecommendationStyles(rec.type); + const Icon = styles.icon; + return ( +
+
+ +
+

{rec.title}

+

{rec.description}

+ {rec.action && ( + + )} +
+
+
+ ); + })} +
+ ); + }; + + // Loading state + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + // Compact view + if (compact) { + return ( +
+
+
+ +

Risk Analysis

+
+
+ {overallRisk.label} Risk ({score.overall}) +
+
+ +
+
+ Sharpe + + {metrics.sharpeRatio.toFixed(2)} + +
+
+ Max DD + + {formatPercent(-metrics.maxDrawdown)} + +
+
+ VaR 95% + ${Math.abs(metrics.var95).toLocaleString()} +
+
+ Beta + {metrics.beta.toFixed(2)} +
+
+ + {onViewDetails && ( + + )} +
+ ); + } + + // Full view + return ( +
+ {/* Header */} +
+
+
+ +
+

Risk Analysis

+

{accountName}

+
+
+ + {/* Period Selector */} +
+ {PERIOD_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + {/* Content */} +
+ {/* Risk Score Section */} +
+
+ {renderRiskGauge()} +
+
+ {renderRiskBreakdown()} +
+
+ + {/* Key Metrics */} +
+

Key Risk Metrics

+ {renderKeyMetrics()} +
+ + {/* VaR Section */} + {renderVaRMetrics()} + + {/* Benchmark Comparison */} + {renderBenchmarkComparison()} + + {/* Additional Metrics */} +
+

Additional Metrics

+
+
+ Downside Deviation + {formatPercent(metrics.downsideDeviation)} +
+
+ Calmar Ratio + + {metrics.calmarRatio.toFixed(2)} + +
+
+ Recovery Factor + + {(metrics.calmarRatio * 0.8).toFixed(2)} + +
+
+
+ + {/* Recommendations */} + {renderRecommendations()} +
+
+ ); +}; + +export default RiskAnalysisPanel; diff --git a/src/modules/investment/components/index.ts b/src/modules/investment/components/index.ts index 9d9a4c9..0994b14 100644 --- a/src/modules/investment/components/index.ts +++ b/src/modules/investment/components/index.ts @@ -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';