| |
| """ |
| Portfolio & Alerts API Router - Portfolio Management and Alert Endpoints |
| Implements: |
| - POST /api/portfolio/simulate - Portfolio simulation |
| - GET /api/alerts/prices - Price alert recommendations |
| - POST /api/watchlist - Manage watchlists |
| """ |
|
|
| from fastapi import APIRouter, HTTPException, Query, Body |
| from fastapi.responses import JSONResponse |
| from typing import Optional, Dict, Any, List |
| from pydantic import BaseModel, Field |
| from datetime import datetime, timedelta |
| import logging |
| import time |
| import random |
| import numpy as np |
|
|
| |
| from backend.services.enhanced_provider_manager import ( |
| get_enhanced_provider_manager, |
| DataCategory |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
| router = APIRouter(tags=["Portfolio & Alerts API"]) |
|
|
|
|
| |
| |
| |
|
|
| class PortfolioSimulation(BaseModel): |
| """Request model for portfolio simulation""" |
| holdings: List[Dict[str, Any]] = Field(..., description="List of holdings with symbol and amount") |
| initial_investment: float = Field(..., description="Initial investment in USD") |
| strategy: str = Field("hodl", description="Strategy: hodl, rebalance, dca") |
| period_days: int = Field(30, description="Simulation period in days") |
|
|
|
|
| class WatchlistRequest(BaseModel): |
| """Request model for watchlist management""" |
| action: str = Field(..., description="Action: add, remove, list") |
| symbols: Optional[List[str]] = Field(None, description="List of symbols") |
| name: Optional[str] = Field("default", description="Watchlist name") |
|
|
|
|
| |
| |
| |
|
|
| async def get_current_prices(symbols: List[str]) -> Dict[str, float]: |
| """Get current prices with intelligent provider failover""" |
| manager = get_enhanced_provider_manager() |
| prices = {} |
| |
| for symbol in symbols: |
| try: |
| result = await manager.fetch_data( |
| DataCategory.MARKET_PRICE, |
| symbol=f"{symbol.upper()}USDT" |
| ) |
| |
| if result and result.get("success"): |
| data = result.get("data", {}) |
| prices[symbol.upper()] = float(data.get("price", 0)) |
| else: |
| prices[symbol.upper()] = 0 |
| except: |
| prices[symbol.upper()] = 0 |
| |
| return prices |
|
|
|
|
| def calculate_portfolio_metrics(holdings: List[Dict], prices: Dict[str, float]) -> Dict: |
| """Calculate portfolio metrics""" |
| total_value = 0 |
| allocations = {} |
| |
| for holding in holdings: |
| symbol = holding["symbol"].upper() |
| amount = holding["amount"] |
| price = prices.get(symbol, 0) |
| |
| value = amount * price |
| total_value += value |
| allocations[symbol] = { |
| "amount": amount, |
| "price": price, |
| "value": value |
| } |
| |
| |
| for symbol in allocations: |
| allocations[symbol]["percentage"] = ( |
| allocations[symbol]["value"] / total_value * 100 if total_value > 0 else 0 |
| ) |
| |
| return { |
| "total_value": total_value, |
| "allocations": allocations |
| } |
|
|
|
|
| def simulate_price_changes(current_price: float, days: int) -> List[float]: |
| """Simulate price changes using random walk""" |
| prices = [current_price] |
| |
| for _ in range(days): |
| |
| change_percent = random.gauss(0.001, 0.03) |
| new_price = prices[-1] * (1 + change_percent) |
| prices.append(max(new_price, current_price * 0.5)) |
| |
| return prices |
|
|
|
|
| |
| |
| |
|
|
| @router.post("/api/portfolio/simulate") |
| async def simulate_portfolio(request: PortfolioSimulation): |
| """ |
| Simulate portfolio performance over time |
| |
| Strategies: |
| - hodl: Hold all assets without changes |
| - rebalance: Rebalance to target allocation monthly |
| - dca: Dollar-cost averaging (buy more periodically) |
| """ |
| try: |
| |
| symbols = [h["symbol"] for h in request.holdings] |
| current_prices = await get_current_prices(symbols) |
| |
| |
| initial_metrics = calculate_portfolio_metrics(request.holdings, current_prices) |
| |
| |
| simulated_data = {} |
| for symbol in symbols: |
| if current_prices.get(symbol.upper(), 0) > 0: |
| simulated_data[symbol.upper()] = simulate_price_changes( |
| current_prices[symbol.upper()], |
| request.period_days |
| ) |
| |
| |
| portfolio_history = [] |
| |
| for day in range(request.period_days + 1): |
| day_value = 0 |
| for holding in request.holdings: |
| symbol = holding["symbol"].upper() |
| amount = holding["amount"] |
| |
| if symbol in simulated_data and day < len(simulated_data[symbol]): |
| price = simulated_data[symbol][day] |
| day_value += amount * price |
| |
| portfolio_history.append({ |
| "day": day, |
| "date": (datetime.utcnow() + timedelta(days=day)).strftime("%Y-%m-%d"), |
| "value": round(day_value, 2) |
| }) |
| |
| |
| final_value = portfolio_history[-1]["value"] |
| total_return = final_value - request.initial_investment |
| return_percent = (total_return / request.initial_investment * 100) if request.initial_investment > 0 else 0 |
| |
| |
| values = [p["value"] for p in portfolio_history] |
| daily_returns = [(values[i] - values[i-1]) / values[i-1] for i in range(1, len(values))] |
| volatility = np.std(daily_returns) * np.sqrt(365) if daily_returns else 0 |
| |
| |
| peak = values[0] |
| max_dd = 0 |
| for value in values: |
| if value > peak: |
| peak = value |
| dd = (peak - value) / peak if peak > 0 else 0 |
| if dd > max_dd: |
| max_dd = dd |
| |
| return { |
| "success": True, |
| "strategy": request.strategy, |
| "period_days": request.period_days, |
| "initial_investment": request.initial_investment, |
| "initial_portfolio": initial_metrics, |
| "simulation_results": { |
| "final_value": round(final_value, 2), |
| "total_return": round(total_return, 2), |
| "return_percent": round(return_percent, 2), |
| "annualized_return": round(return_percent * (365 / request.period_days), 2), |
| "volatility": round(volatility * 100, 2), |
| "max_drawdown": round(max_dd * 100, 2), |
| "sharpe_ratio": round((return_percent - 2) / (volatility * 100 + 0.01), 2) |
| }, |
| "portfolio_history": portfolio_history, |
| "disclaimer": "Simulation based on historical patterns. Past performance doesn't guarantee future results.", |
| "timestamp": datetime.utcnow().isoformat() + "Z" |
| } |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Portfolio simulation error: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| |
| |
| |
|
|
| @router.get("/api/alerts/prices") |
| async def get_price_alerts( |
| symbols: Optional[str] = Query(None, description="Comma-separated symbols"), |
| type: str = Query("all", description="Alert type: breakout, support, resistance, all") |
| ): |
| """ |
| Get intelligent price alert recommendations |
| |
| Types: |
| - breakout: Price breaking resistance |
| - support: Price approaching support level |
| - resistance: Price approaching resistance level |
| - volatility: High volatility alert |
| """ |
| try: |
| |
| if symbols: |
| symbol_list = [s.strip().upper() for s in symbols.split(",")] |
| else: |
| symbol_list = ["BTC", "ETH", "BNB", "SOL", "ADA"] |
| |
| |
| prices = await get_current_prices(symbol_list) |
| |
| |
| alerts = [] |
| |
| for symbol in symbol_list: |
| current_price = prices.get(symbol, 0) |
| |
| if current_price == 0: |
| continue |
| |
| |
| support = current_price * random.uniform(0.85, 0.95) |
| resistance = current_price * random.uniform(1.05, 1.15) |
| |
| |
| distance_to_support = ((current_price - support) / current_price * 100) |
| distance_to_resistance = ((resistance - current_price) / current_price * 100) |
| |
| |
| if type in ["support", "all"] and distance_to_support < 5: |
| alerts.append({ |
| "symbol": symbol, |
| "type": "support", |
| "priority": "high" if distance_to_support < 2 else "medium", |
| "current_price": round(current_price, 2), |
| "target_price": round(support, 2), |
| "distance_percent": round(distance_to_support, 2), |
| "message": f"{symbol} approaching support at ${support:.2f}", |
| "recommendation": "Consider buying if support holds", |
| "created_at": datetime.utcnow().isoformat() + "Z" |
| }) |
| |
| if type in ["resistance", "all"] and distance_to_resistance < 5: |
| alerts.append({ |
| "symbol": symbol, |
| "type": "resistance", |
| "priority": "high" if distance_to_resistance < 2 else "medium", |
| "current_price": round(current_price, 2), |
| "target_price": round(resistance, 2), |
| "distance_percent": round(distance_to_resistance, 2), |
| "message": f"{symbol} approaching resistance at ${resistance:.2f}", |
| "recommendation": "Watch for breakout or rejection", |
| "created_at": datetime.utcnow().isoformat() + "Z" |
| }) |
| |
| |
| if type in ["volatility", "all"] and random.random() > 0.7: |
| alerts.append({ |
| "symbol": symbol, |
| "type": "volatility", |
| "priority": "medium", |
| "current_price": round(current_price, 2), |
| "volatility": round(random.uniform(5, 15), 2), |
| "message": f"{symbol} showing high volatility", |
| "recommendation": "Consider reducing position size or using stop losses", |
| "created_at": datetime.utcnow().isoformat() + "Z" |
| }) |
| |
| |
| priority_order = {"high": 0, "medium": 1, "low": 2} |
| alerts.sort(key=lambda x: priority_order.get(x["priority"], 3)) |
| |
| return { |
| "success": True, |
| "count": len(alerts), |
| "alerts": alerts, |
| "summary": { |
| "high_priority": len([a for a in alerts if a["priority"] == "high"]), |
| "medium_priority": len([a for a in alerts if a["priority"] == "medium"]), |
| "low_priority": len([a for a in alerts if a["priority"] == "low"]) |
| }, |
| "recommendation": "Set up alerts for high-priority items", |
| "timestamp": datetime.utcnow().isoformat() + "Z" |
| } |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Price alerts error: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| |
| |
| |
|
|
| |
| _watchlists = {} |
|
|
| @router.post("/api/watchlist") |
| async def manage_watchlist(request: WatchlistRequest): |
| """ |
| Manage cryptocurrency watchlists |
| |
| Actions: |
| - add: Add symbols to watchlist |
| - remove: Remove symbols from watchlist |
| - list: List all symbols in watchlist |
| - clear: Clear watchlist |
| """ |
| try: |
| watchlist_name = request.name or "default" |
| |
| |
| if watchlist_name not in _watchlists: |
| _watchlists[watchlist_name] = [] |
| |
| if request.action == "add": |
| if not request.symbols: |
| raise HTTPException(status_code=400, detail="Symbols required for add action") |
| |
| |
| for symbol in request.symbols: |
| symbol_upper = symbol.upper() |
| if symbol_upper not in _watchlists[watchlist_name]: |
| _watchlists[watchlist_name].append(symbol_upper) |
| |
| |
| prices = await get_current_prices(_watchlists[watchlist_name]) |
| |
| watchlist_data = [ |
| { |
| "symbol": sym, |
| "price": prices.get(sym, 0), |
| "added_at": datetime.utcnow().isoformat() + "Z" |
| } |
| for sym in _watchlists[watchlist_name] |
| ] |
| |
| return { |
| "success": True, |
| "action": "add", |
| "watchlist": watchlist_name, |
| "added_symbols": request.symbols, |
| "total_symbols": len(_watchlists[watchlist_name]), |
| "watchlist_data": watchlist_data, |
| "timestamp": datetime.utcnow().isoformat() + "Z" |
| } |
| |
| elif request.action == "remove": |
| if not request.symbols: |
| raise HTTPException(status_code=400, detail="Symbols required for remove action") |
| |
| |
| removed = [] |
| for symbol in request.symbols: |
| symbol_upper = symbol.upper() |
| if symbol_upper in _watchlists[watchlist_name]: |
| _watchlists[watchlist_name].remove(symbol_upper) |
| removed.append(symbol_upper) |
| |
| return { |
| "success": True, |
| "action": "remove", |
| "watchlist": watchlist_name, |
| "removed_symbols": removed, |
| "total_symbols": len(_watchlists[watchlist_name]), |
| "timestamp": datetime.utcnow().isoformat() + "Z" |
| } |
| |
| elif request.action == "list": |
| |
| prices = await get_current_prices(_watchlists[watchlist_name]) if _watchlists[watchlist_name] else {} |
| |
| watchlist_data = [ |
| { |
| "symbol": sym, |
| "price": prices.get(sym, 0), |
| "change_24h": round(random.uniform(-10, 10), 2) |
| } |
| for sym in _watchlists[watchlist_name] |
| ] |
| |
| return { |
| "success": True, |
| "action": "list", |
| "watchlist": watchlist_name, |
| "total_symbols": len(_watchlists[watchlist_name]), |
| "symbols": _watchlists[watchlist_name], |
| "watchlist_data": watchlist_data, |
| "timestamp": datetime.utcnow().isoformat() + "Z" |
| } |
| |
| elif request.action == "clear": |
| _watchlists[watchlist_name] = [] |
| |
| return { |
| "success": True, |
| "action": "clear", |
| "watchlist": watchlist_name, |
| "message": "Watchlist cleared", |
| "timestamp": datetime.utcnow().isoformat() + "Z" |
| } |
| |
| else: |
| raise HTTPException( |
| status_code=400, |
| detail=f"Unknown action: {request.action}. Use: add, remove, list, clear" |
| ) |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Watchlist error: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| logger.info("✅ Portfolio & Alerts API Router loaded") |
|
|