diff --git a/src/components/dashboard/PortfolioRebalancer.tsx b/src/components/dashboard/PortfolioRebalancer.tsx index 1b0d1217..acaab63e 100644 --- a/src/components/dashboard/PortfolioRebalancer.tsx +++ b/src/components/dashboard/PortfolioRebalancer.tsx @@ -1,67 +1,164 @@ -import React, { useEffect, useState } from 'react'; -import { Sliders, CheckCircle, AlertTriangle } from 'lucide-react'; +import React, { useEffect, useState, useMemo } from 'react'; +import { + Sliders, CheckCircle, AlertTriangle, TrendingUp, TrendingDown, + Info, Sparkles, Plus, Trash2, Play, Key, RefreshCw, Zap, ArrowRight, ShieldCheck +} from 'lucide-react'; import { useStore } from '../../lib/store'; import { suggestRebalancing } from '../../lib/defiAnalytics'; import { fetchPrices, calculatePortfolioValue } from '../../lib/priceFeed'; -import { getServer } from '../../lib/stellar'; +import { getServer, NETWORKS } from '../../lib/stellar'; +import { buildTransaction, signAndSubmitTransaction, simulateTransaction } from '../../lib/transactionBuilder'; +import { + LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, + Legend, CartesianGrid +} from 'recharts'; interface Suggestion { asset: string; action: 'buy' | 'sell'; - amount: number; + amount: number; // in USD currentPct: number; targetPct: number; } -function fmt(n: number, d = 2) { return n.toLocaleString('en-US', { maximumFractionDigits: d }); } +interface Holding { + asset: string; + value: number; + amount: number; + price: number; + assetType: string; + assetIssuer: string; +} const DEFAULT_TARGETS: Record = { XLM: 0.5, USDC: 0.3, AQUA: 0.2 }; +const SUPPORTED_ASSETS = [ + { code: 'XLM', name: 'Stellar Lumens', type: 'native', issuer: '' }, + { code: 'USDC', name: 'USD Coin', type: 'credit_alphanum4', issuer: 'GBBD47R2HS7ND7TSBSCOCCFD43TCJJMOND547KUNA3ISP52QDWCCCEQD' }, + { code: 'AQUA', name: 'Aquarius', type: 'credit_alphanum4', issuer: 'GBNZ4J2NEJX2Z7R32JTC7CRM7QISD6Q5WFBEX4QR2FOA57PI57QQ7ZOO' }, + { code: 'BTC', name: 'Bitcoin', type: 'credit_alphanum4', issuer: 'GDPJTL4K55W5KUBFND4NLAWTLO2TWCKNN3W77W5YSMCCCK3IN6X4N6N6' }, + { code: 'ETH', name: 'Ethereum', type: 'credit_alphanum4', issuer: 'GBDEVU63M6N7Z2V5E333333333333333333333333333333333333333' } +]; + +const AI_PRESETS = [ + { name: 'Conservative', description: 'Low volatility, high stability', targets: { XLM: 0.3, USDC: 0.6, AQUA: 0.1 } }, + { name: 'Moderate', description: 'Balanced growth and stability', targets: { XLM: 0.5, USDC: 0.3, AQUA: 0.2 } }, + { name: 'Aggressive', description: 'High growth potential', targets: { XLM: 0.6, USDC: 0.1, AQUA: 0.3 } }, + { name: 'Yield-Optimized', description: 'Focused on AMM incentives', targets: { XLM: 0.4, USDC: 0.2, AQUA: 0.4 } }, +]; + +function fmt(n: number, d = 2) { + return n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); +} + +const COINGECKO_BASE = 'https://api.coingecko.com/api/v3'; +const ASSET_ID_MAP: Record = { + XLM: 'stellar', + native: 'stellar', + USDC: 'usd-coin', + BTC: 'bitcoin', + ETH: 'ethereum', + AQUA: 'aquarius', +}; + export default function PortfolioRebalancer() { - const { connectedAddress, network } = useStore(); + const { connectedAddress, network, accountData, setAccountData } = useStore(); + const [holdings, setHoldings] = useState([]); const [suggestions, setSuggestions] = useState([]); - const [targets, setTargets] = useState(DEFAULT_TARGETS); - const [targetInput, setTargetInput] = useState( - Object.entries(DEFAULT_TARGETS).map(([a, w]) => `${a}:${(w * 100).toFixed(0)}`).join(', ') - ); + const [targets, setTargets] = useState>(DEFAULT_TARGETS); + const [approved, setApproved] = useState>({}); const [loading, setLoading] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); const [error, setError] = useState(''); - const [totalWeight, setTotalWeight] = useState(100); - - function parseTargets(input: string) { - const map: Record = {}; - let sum = 0; - for (const part of input.split(',')) { - const [asset, pct] = part.trim().split(':'); - if (asset && pct) { - const w = parseFloat(pct) / 100; - if (!isNaN(w) && w > 0) { map[asset.trim()] = w; sum += w; } - } - } - setTotalWeight(Math.round(sum * 100)); - return map; - } + const [successMsg, setSuccessMsg] = useState(''); + + // Execution states + const [secretKey, setSecretKey] = useState(''); + const [showExecution, setShowExecution] = useState(false); + const [executing, setExecuting] = useState(false); + const [simulationResult, setSimulationResult] = useState(null); + const [showXDR, setShowXDR] = useState(false); + // Chart data + const [historyData, setHistoryData] = useState([]); + const [timeRange, setTimeRange] = useState(30); + + // New asset selection + const [newAssetCode, setNewAssetCode] = useState(''); + + // Total weight helper + const totalWeight = useMemo(() => { + return Math.round(Object.values(targets).reduce((sum, w) => sum + w, 0) * 100); + }, [targets]); + + // Fetch prices and analyze current portfolio async function analyze() { - if (!connectedAddress) { setError('Connect an account first.'); return; } + if (!connectedAddress) { + setError('Connect an account first.'); + return; + } setLoading(true); setError(''); + setSuccessMsg(''); + setSimulationResult(null); try { const server = getServer(network); const account = await server.loadAccount(connectedAddress); - const assetCodes = account.balances.map((b: Record) => - b.asset_type === 'native' ? 'XLM' : b.asset_code - ); + + const targetAssets = Object.keys(targets); + const assetCodes = Array.from(new Set([ + ...account.balances.map((b: any) => b.asset_type === 'native' ? 'XLM' : b.asset_code), + ...targetAssets + ])); + const prices = await fetchPrices(assetCodes); const portfolio = calculatePortfolioValue(account.balances, prices); - const holdings = (portfolio?.items || []).map((item: Record) => ({ - asset: item.code as string, - value: (item.valueUsd as number) ?? 0, - })).filter((h: { value: number }) => h.value > 0); - const t = parseTargets(targetInput); - setTargets(t); - setSuggestions(suggestRebalancing(holdings, t) as Suggestion[]); + + const activeHoldings = (portfolio?.items || []).map((item: any) => { + const orig = account.balances.find((b: any) => + b.asset_type === 'native' ? item.code === 'XLM' : b.asset_code === item.code + ); + return { + asset: item.code, + value: item.valueUsd ?? 0, + amount: item.amount ?? 0, + price: item.priceUsd ?? 0, + assetType: orig?.asset_type || 'credit_alphanum4', + assetIssuer: orig?.asset_issuer || '', + }; + }); + + // Include target assets that are not in the current portfolio with 0 balance + targetAssets.forEach(asset => { + if (!activeHoldings.some(h => h.asset === asset)) { + const supported = SUPPORTED_ASSETS.find(s => s.code === asset); + const price = prices[asset]?.usd ?? 0; + activeHoldings.push({ + asset, + value: 0, + amount: 0, + price, + assetType: supported?.type || 'credit_alphanum4', + assetIssuer: supported?.issuer || '', + }); + } + }); + + setHoldings(activeHoldings); + + // Generate suggestions + const cleanHoldings = activeHoldings.filter(h => h.value > 0 || targets[h.asset] > 0); + const rebalSuggestions = suggestRebalancing(cleanHoldings, targets) as Suggestion[]; + setSuggestions(rebalSuggestions); + + // Auto-approve all suggestions initially + const initialApproved: Record = {}; + rebalSuggestions.forEach(s => { + initialApproved[s.asset] = true; + }); + setApproved(initialApproved); + } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to analyze portfolio'); } finally { @@ -69,113 +166,984 @@ export default function PortfolioRebalancer() { } } - const actionColor = (a: string) => a === 'buy' ? '#22c55e' : '#ef4444'; + // Load portfolio on mount or address change + useEffect(() => { + if (connectedAddress) { + analyze(); + } + }, [connectedAddress, network]); + + // Re-generate suggestions when targets change + useEffect(() => { + if (holdings.length > 0) { + const cleanHoldings = holdings.filter(h => h.value > 0 || targets[h.asset] > 0); + const rebalSuggestions = suggestRebalancing(cleanHoldings, targets) as Suggestion[]; + setSuggestions(rebalSuggestions); + } + }, [targets, holdings]); + + // Fetch or simulate historical prices + async function fetchHistoricalPrices(assetCodes: string[], days: number): Promise> { + const history: Record = {}; + + for (const code of assetCodes) { + const coinId = ASSET_ID_MAP[code]; + if (!coinId) { + history[code] = generateSimulatedPrices(code, days); + continue; + } + + try { + const res = await fetch(`${COINGECKO_BASE}/coins/${coinId}/market_chart?vs_currency=usd&days=${days}&interval=daily`); + if (!res.ok) throw new Error('Rate limit'); + const data = await res.json(); + history[code] = data.prices.map((p: any) => p[1]); + } catch (e) { + history[code] = generateSimulatedPrices(code, days); + } + } + return history; + } + + function generateSimulatedPrices(code: string, days: number): number[] { + const currentHolding = holdings.find(h => h.asset === code); + const currentPrice = currentHolding?.price || (code === 'USDC' ? 1.0 : code === 'XLM' ? 0.12 : 0.05); + const prices = []; + let price = currentPrice; + const dailyVolatility = code === 'USDC' ? 0.001 : code === 'XLM' ? 0.03 : 0.05; + const trend = code === 'USDC' ? 0 : 0.0003; + + for (let i = 0; i <= days; i++) { + prices.push(price); + const change = 1 + (Math.random() - 0.5) * dailyVolatility - trend; + price = price / change; + } + return prices.reverse(); + } + + // Fetch history data for comparison chart + useEffect(() => { + if (holdings.length === 0) return; + + let active = true; + async function loadHistory() { + setHistoryLoading(true); + try { + const assetCodes = holdings.map(h => h.asset); + Object.keys(targets).forEach(asset => { + if (!assetCodes.includes(asset)) { + assetCodes.push(asset); + } + }); + + const pricesHistory = await fetchHistoricalPrices(assetCodes, timeRange); + + if (!active) return; + + const chartData = []; + const initialValue = 10000; + + const currentAllocations: Record = {}; + const totalCurrentValue = holdings.reduce((sum, h) => sum + h.value, 0); + + if (totalCurrentValue > 0) { + holdings.forEach(h => { + currentAllocations[h.asset] = h.value / totalCurrentValue; + }); + } else { + currentAllocations['XLM'] = 1.0; + } + + for (let day = 0; day <= timeRange; day++) { + let currentVal = 0; + let targetVal = 0; + + for (const asset of assetCodes) { + const history = pricesHistory[asset] || []; + const price0 = history[0] || 1; + const priceT = history[day] || price0; + const priceRatio = priceT / price0; + + const currentAlloc = currentAllocations[asset] || 0; + currentVal += initialValue * currentAlloc * priceRatio; + + const targetAlloc = targets[asset] || 0; + targetVal += initialValue * targetAlloc * priceRatio; + } + + const date = new Date(); + date.setDate(date.getDate() - (timeRange - day)); + const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + + chartData.push({ + date: dateStr, + Current: Math.round(currentVal), + Target: Math.round(targetVal), + 'Current ROI (%)': +(((currentVal - initialValue) / initialValue) * 100).toFixed(2), + 'Target ROI (%)': +(((targetVal - initialValue) / initialValue) * 100).toFixed(2), + }); + } + + setHistoryData(chartData); + } catch (err) { + console.error('Error generating historical comparison:', err); + } finally { + if (active) setHistoryLoading(false); + } + } + + loadHistory(); + return () => { + active = false; + }; + }, [holdings, targets, timeRange]); + + // AI Assistant Reasoning + const aiReasoning = useMemo(() => { + if (holdings.length === 0) return null; + + const totalVal = holdings.reduce((sum, h) => sum + h.value, 0); + const allocations = holdings.map(h => h.value / (totalVal || 1)); + const hhi = allocations.reduce((sum, a) => sum + a * a, 0); + const diversificationScore = Math.round((1 - hhi) * 100); + + const concentrated = holdings.filter(h => h.value / (totalVal || 1) > 0.4); + + let totalDeviation = 0; + Object.entries(targets).forEach(([asset, targetPct]) => { + const currentHolding = holdings.find(h => h.asset === asset); + const currentPct = currentHolding ? (currentHolding.value / (totalVal || 1)) : 0; + totalDeviation += Math.abs(currentPct - targetPct); + }); + + const isHighDeviation = totalDeviation > 0.15; + + let riskLevel = 'Moderate'; + let expectedVolatility = 'Medium'; + let primaryBenefit = 'Optimized Risk-Adjusted Returns'; + + const targetXlm = targets['XLM'] || 0; + const targetUsdc = targets['USDC'] || 0; + const targetAqua = targets['AQUA'] || 0; + + if (targetUsdc > 0.45) { + riskLevel = 'Conservative'; + expectedVolatility = 'Low'; + primaryBenefit = 'Capital Preservation & Volatility Reduction'; + } else if (targetXlm > 0.55 || targetAqua > 0.25) { + riskLevel = 'Aggressive'; + expectedVolatility = 'High'; + primaryBenefit = 'Capital Appreciation & Yield Generation'; + } else { + riskLevel = 'Balanced'; + expectedVolatility = 'Moderate'; + primaryBenefit = 'Balanced Growth & Defensive Stability'; + } + + const points = []; + if (diversificationScore < 45) { + points.push(`Your current portfolio diversification is low (${diversificationScore}/100), creating concentration risk. Rebalancing will spread risk.`); + } else { + points.push(`Your current portfolio is well-diversified (${diversificationScore}/100). Rebalancing will maintain this balanced structure.`); + } + + if (concentrated.length > 0) { + points.push(`Currently, ${concentrated.map(h => h.asset).join(', ')} represents over 40% of your holdings, exposing you heavily to its individual price movement.`); + } + + if (isHighDeviation) { + points.push(`Drift from your target allocation is significant (${Math.round(totalDeviation * 100)}%). Rebalancing restores your intended risk profile.`); + } + + points.push(`The proposed ${riskLevel} target allocation offers ${expectedVolatility.toLowerCase()} volatility. The primary benefit is ${primaryBenefit.toLowerCase()}.`); + + return { + riskLevel, + expectedVolatility, + primaryBenefit, + points, + summary: `AI recommends rebalancing to a ${riskLevel} stance to achieve ${primaryBenefit.toLowerCase()}.` + }; + }, [holdings, targets]); + + // Adjust sliders to sum to 100% + function autoBalance() { + const sum = Object.values(targets).reduce((s, w) => s + w, 0); + if (sum === 0) return; + const normalized: Record = {}; + Object.entries(targets).forEach(([asset, w]) => { + normalized[asset] = +(w / sum).toFixed(4); + }); + setTargets(normalized); + } + + // Handle slider changes + const handleSliderChange = (asset: string, val: number) => { + setTargets(prev => ({ + ...prev, + [asset]: val / 100 + })); + }; + + // Add new asset to targets + const addAssetToTargets = () => { + if (!newAssetCode || targets[newAssetCode] !== undefined) return; + setTargets(prev => ({ + ...prev, + [newAssetCode]: 0.1 + })); + setNewAssetCode(''); + }; + + // Remove asset from targets + const removeAssetFromTargets = (asset: string) => { + if (asset === 'XLM') return; // XLM is required + const newTargets = { ...targets }; + delete newTargets[asset]; + setTargets(newTargets); + }; + + // Select AI Preset + const selectPreset = (presetTargets: Record) => { + setTargets(presetTargets); + }; + + // Build trade operations + function buildRebalanceOperations() { + const totalVal = holdings.reduce((sum, h) => sum + h.value, 0); + const ops: any[] = []; + const xlmHolding = holdings.find(h => h.asset === 'XLM'); + const xlmPrice = xlmHolding?.price || 0.12; + + // Process sells first (converts assets to XLM) + suggestions + .filter(s => s.action === 'sell' && approved[s.asset]) + .forEach(s => { + const holding = holdings.find(h => h.asset === s.asset); + if (!holding || holding.asset === 'XLM') return; + + const amountToSell = s.amount / holding.price; + const priceInXlm = holding.price / xlmPrice; + + ops.push({ + type: 'manageSellOffer', + params: { + sellingAssetType: holding.assetType, + sellingAssetCode: holding.asset, + sellingAssetIssuer: holding.assetIssuer, + buyingAssetType: 'native', + buyingAssetCode: 'XLM', + buyingAssetIssuer: '', + amount: amountToSell.toFixed(7), + price: priceInXlm.toFixed(7), + } + }); + }); + + // Process buys next (converts XLM to assets) + suggestions + .filter(s => s.action === 'buy' && approved[s.asset]) + .forEach(s => { + const holding = holdings.find(h => h.asset === s.asset); + if (!holding || holding.asset === 'XLM') return; + + const priceInAsset = xlmPrice / holding.price; + const amountXlmToSell = s.amount / xlmPrice; + + ops.push({ + type: 'manageSellOffer', + params: { + sellingAssetType: 'native', + sellingAssetCode: 'XLM', + sellingAssetIssuer: '', + buyingAssetType: holding.assetType, + buyingAssetCode: holding.asset, + buyingAssetIssuer: holding.assetIssuer, + amount: amountXlmToSell.toFixed(7), + price: priceInAsset.toFixed(7), + } + }); + }); + + return ops; + } + + // Simulate rebalancing transaction + async function handleSimulate() { + if (!connectedAddress) return; + setError(''); + setSimulationResult(null); + setExecuting(true); + + try { + const ops = buildRebalanceOperations(); + if (ops.length === 0) { + throw new Error('No trades selected or approved.'); + } + + const simResult = await simulateTransaction({ + sourceAccount: connectedAddress, + operations: ops, + network, + baseFee: '100', + }); + + setSimulationResult(simResult); + + if (!simResult.success) { + setError('Simulation failed: ' + simResult.errors.join(', ')); + } + } catch (e: any) { + setError(e.message || 'Simulation failed.'); + } finally { + setExecuting(false); + } + } + + // Execute Mock Rebalance (Updates UI state locally) + function handleMockExecute() { + if (!connectedAddress || !accountData) return; + setExecuting(true); + setError(''); + + setTimeout(() => { + try { + const totalVal = holdings.reduce((sum, h) => sum + h.value, 0); + + // Compute new balances based on target weights + const newBalances = accountData.balances.map((b: any) => { + const code = b.asset_type === 'native' ? 'XLM' : b.asset_code; + const targetPct = targets[code] ?? 0; + const holding = holdings.find(h => h.asset === code); + const price = holding?.price || 1.0; + const newBalanceValue = totalVal * targetPct; + const newAmount = newBalanceValue / price; + + return { + ...b, + balance: newAmount.toFixed(7) + }; + }); + + // Add any target assets that weren't in the wallet before + Object.entries(targets).forEach(([code, targetPct]) => { + const exists = newBalances.some((b: any) => + b.asset_type === 'native' ? code === 'XLM' : b.asset_code === code + ); + if (!exists && targetPct > 0) { + const supported = SUPPORTED_ASSETS.find(s => s.code === code); + const price = holdings.find(h => h.asset === code)?.price || 1.0; + const newAmount = (totalVal * targetPct) / price; + + newBalances.push({ + asset_type: supported?.type || 'credit_alphanum4', + asset_code: code, + asset_issuer: supported?.issuer || '', + balance: newAmount.toFixed(7) + }); + } + }); + + setAccountData({ + ...accountData, + balances: newBalances + }); + + setSuccessMsg('Portfolio successfully rebalanced (Simulated)! The dashboard has been updated to reflect the new allocations.'); + setShowExecution(false); + setSimulationResult(null); + setSecretKey(''); + + // Trigger a fresh analysis based on the new local state + setTimeout(() => analyze(), 100); + + } catch (e: any) { + setError('Mock execution failed: ' + e.message); + } finally { + setExecuting(false); + } + }, 1500); + } + + // Execute Live Rebalance on network + async function handleLiveExecute() { + if (!connectedAddress || !secretKey) return; + setExecuting(true); + setError(''); + setSuccessMsg(''); + + try { + const ops = buildRebalanceOperations(); + if (ops.length === 0) { + throw new Error('No trades selected.'); + } + + // Build + const tx = await buildTransaction({ + sourceAccount: connectedAddress, + operations: ops, + network, + baseFee: '100', + }); + + // Sign & Submit + const result = await signAndSubmitTransaction(tx, secretKey, network); + + if (result.successful) { + setSuccessMsg(`Live rebalancing transaction submitted successfully! Hash: ${result.hash.slice(0, 16)}...`); + setShowExecution(false); + setSimulationResult(null); + setSecretKey(''); + // Reload account details from Horizon + analyze(); + } else { + throw new Error('Transaction submission was not successful.'); + } + } catch (e: any) { + setError(e.message || 'Live execution failed.'); + } finally { + setExecuting(false); + } + } return ( -
-

- Portfolio Rebalancer -

- -
-

Target Allocation

-

- Enter comma-separated ASSET:PERCENT pairs (e.g. XLM:50, USDC:30, AQUA:20) -

-