""" Backend API for Quantum Hybrid Portfolio Dashboard Provides REST API endpoints for the React frontend """ from __future__ import annotations from flask import Flask, request, jsonify, g from flask_cors import CORS from flask_limiter import Limiter from flask_limiter.util import get_remote_address from functools import wraps import numpy as np import pandas as pd import json import os import uuid import time import hashlib import sqlite3 import threading from urllib.request import Request, urlopen from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone import logging from pythonjsonlogger.json import JsonFormatter as jsonlogger_JsonFormatter from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST # Import portfolio optimization (unified service routes to methods) from core.portfolio_optimizer import ( run_optimization, OBJECTIVES, compute_efficient_frontier, _portfolio_metrics, ) from methods.vqe import MAX_IBM_QUBITS, vqe_weights_ibm_strict from config.api_config import OBJECTIVES_CONFIG, PRESETS_CONFIG from services.constraints import PortfolioConstraints from services import ibm_quantum as ibm_quantum_service from services import lab_run_service from services.auth import ( init_jwt, create_access_token, create_refresh_token, verify_token, revoke_token, get_current_user, hash_api_key, generate_api_key, JWT_AVAILABLE, ) # Import market data service (multi-provider: yfinance, Alpaca, Polygon with fallback) from services.data_provider_v2 import MarketDataProvider, fetch_market_data from services.market_data import validate_tickers # Import backtesting service from services.backtest import run_backtest as run_backtesting from services.data_provider import load_market_payload # ─── Logging (JSON for prod/aggregation, console for dev readability) ─── LOG_FORMAT = os.getenv('LOG_FORMAT', 'json') log_handler = logging.StreamHandler() if LOG_FORMAT == 'console': log_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(name)s %(message)s')) else: log_handler.setFormatter(jsonlogger_JsonFormatter( '%(asctime)s %(name)s %(levelname)s %(message)s', rename_fields={'asctime': 'timestamp', 'levelname': 'level', 'name': 'logger'}, )) logging.root.handlers = [] logging.root.addHandler(log_handler) logging.root.setLevel(getattr(logging, os.getenv('LOG_LEVEL', 'INFO').upper(), logging.INFO)) logger = logging.getLogger(__name__) # ─── Prometheus Metrics ─── REQUEST_COUNT = Counter( 'http_requests_total', 'Total HTTP requests', ['method', 'endpoint', 'status'] ) REQUEST_LATENCY = Histogram( 'http_request_duration_seconds', 'HTTP request latency', ['method', 'endpoint'], buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0] ) OPTIMIZATION_LATENCY = Histogram( 'optimization_duration_seconds', 'Portfolio optimization duration', ['objective'], buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0] ) MARKET_DATA_LATENCY = Histogram( 'market_data_fetch_duration_seconds', 'Market data fetch duration', buckets=[0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0] ) # ─── In-Memory Cache for Market Data ─── _market_data_cache = {} CACHE_TTL = int(os.getenv('CACHE_TTL', 3600)) # 1 hour default API_DB_PATH = os.getenv('API_DB_PATH', os.path.join(os.path.dirname(__file__), 'data', 'api.sqlite3')) # ─── Async Job Runtime ─── _executor = ThreadPoolExecutor(max_workers=int(os.getenv("JOB_WORKERS", "4"))) _jobs = {} _jobs_lock = threading.Lock() def _cache_key(tickers, start_date, end_date): raw = f"{','.join(sorted(tickers))}:{start_date}:{end_date}" return hashlib.md5(raw.encode()).hexdigest() def cache_get(key): entry = _market_data_cache.get(key) if entry and time.time() - entry['ts'] < CACHE_TTL: return entry['data'] if entry: del _market_data_cache[key] return None def cache_set(key, data): # Evict old entries if cache grows too large (max 100 entries) if len(_market_data_cache) >= 100: oldest = min(_market_data_cache, key=lambda k: _market_data_cache[k]['ts']) del _market_data_cache[oldest] _market_data_cache[key] = {'data': data, 'ts': time.time()} app = Flask(__name__) def _ensure_runtime_tables() -> None: """Create local runtime tables for API keys, audit logs, and async jobs metadata.""" os.makedirs(os.path.dirname(API_DB_PATH), exist_ok=True) conn = sqlite3.connect(API_DB_PATH) try: cur = conn.cursor() cur.execute( """ CREATE TABLE IF NOT EXISTS api_keys ( key_hash TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, key_name TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_used_at TEXT, usage_count INTEGER NOT NULL DEFAULT 0 ) """ ) cur.execute( """ CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT, tenant_id TEXT, action TEXT, endpoint TEXT, method TEXT, payload_json TEXT, response_status INTEGER, duration_ms REAL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) cur.execute("CREATE INDEX IF NOT EXISTS idx_audit_request_id ON audit_log(request_id)") cur.execute("CREATE INDEX IF NOT EXISTS idx_audit_tenant_id ON audit_log(tenant_id)") cur.execute( """ CREATE TABLE IF NOT EXISTS tenant_integration_secrets ( tenant_id TEXT NOT NULL, provider TEXT NOT NULL, secret_enc TEXT NOT NULL, metadata_json TEXT, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (tenant_id, provider) ) """ ) conn.commit() finally: conn.close() def _db_conn(): return sqlite3.connect(API_DB_PATH) _ensure_runtime_tables() ibm_quantum_service.set_db_conn_factory(_db_conn) lab_run_service.set_db_conn_factory(_db_conn) lab_run_service.ensure_table() # Initialize JWT authentication if JWT_AVAILABLE: init_jwt(app) logger.info("JWT authentication enabled") else: logger.warning("JWT authentication disabled - install flask-jwt-extended to enable") def log_business_audit(action: str, payload: dict, result: dict, status: int = 200): """Persist business-level audit record including request payload and result summary.""" try: conn = _db_conn() cur = conn.cursor() merged_payload = { "request": payload or {}, "result_summary": result or {}, } cur.execute( """ INSERT INTO audit_log ( request_id, tenant_id, action, endpoint, method, payload_json, response_status, duration_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( getattr(g, "request_id", ""), getattr(g, "tenant_id", "anonymous"), action, request.path if request else action, request.method if request else "INTERNAL", json.dumps(merged_payload), int(status), 0.0, ), ) conn.commit() conn.close() except Exception as exc: logger.warning(f"business_audit_write_failed: {exc}") def log_async_audit(tenant_id: str, action: str, payload: dict, result: dict, status: int): """Audit logger for async workers where request context is unavailable.""" try: conn = _db_conn() cur = conn.cursor() cur.execute( """ INSERT INTO audit_log ( request_id, tenant_id, action, endpoint, method, payload_json, response_status, duration_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( "", tenant_id or "anonymous", action, action, "ASYNC", json.dumps({"request": payload or {}, "result_summary": result or {}}), int(status), 0.0, ), ) conn.commit() conn.close() except Exception as exc: logger.warning(f"async_audit_write_failed: {exc}") # ─── Security Configuration ─── # Request size limit (1 MB) app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 # CORS: restrict to configured origins cors_origins = os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',') CORS(app, origins=[o.strip() for o in cors_origins]) # Rate limiting limiter = Limiter( app=app, key_func=get_remote_address, default_limits=["100 per minute"], storage_uri=os.getenv('RATELIMIT_STORAGE_URL', 'memory://'), ) # API key authentication API_KEY = os.getenv('API_KEY', '') API_KEY_REQUIRED = os.getenv('API_KEY_REQUIRED', 'false').lower() == 'true' def _hash_api_key(key: str) -> str: return hashlib.sha256(key.encode("utf-8")).hexdigest() def _lookup_tenant_by_key(key: str): """Lookup tenant by API key hash in local auth table.""" if not key: return None key_hash = _hash_api_key(key) conn = _db_conn() try: cur = conn.cursor() cur.execute( """ SELECT tenant_id, key_name, usage_count FROM api_keys WHERE key_hash = ? AND is_active = 1 """, (key_hash,), ) row = cur.fetchone() if not row: return None tenant_id, key_name, usage_count = row cur.execute( """ UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP, usage_count = ? WHERE key_hash = ? """, (int(usage_count or 0) + 1, key_hash), ) conn.commit() return {"tenant_id": tenant_id, "key_name": key_name} finally: conn.close() def _create_api_key(tenant_id: str, key_name: str = "") -> str: """Create and store a tenant API key, returning plaintext once.""" plain = hashlib.sha256(f"{uuid.uuid4()}:{tenant_id}:{time.time()}".encode()).hexdigest() key_hash = _hash_api_key(plain) conn = _db_conn() try: cur = conn.cursor() cur.execute( """ INSERT OR REPLACE INTO api_keys (key_hash, tenant_id, key_name, is_active, created_at) VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP) """, (key_hash, tenant_id, key_name or ""), ) conn.commit() finally: conn.close() return plain def integration_effective_tenant_id(): """ Tenant id for integration credentials (IBM, Braket metadata). - Static API_KEY + same header value: X-Tenant-Id selects enterprise. - Database API key: tenant from key (ignores X-Tenant-Id). - Anonymous: default. """ header_tid = (request.headers.get("X-Tenant-Id") or "").strip() client_key = request.headers.get("X-API-Key", "") if API_KEY and client_key == API_KEY: return header_tid or "default" tenant = _lookup_tenant_by_key(client_key) if client_key else None if tenant: return str(tenant["tenant_id"]) gid = getattr(g, "tenant_id", "anonymous") if gid not in ("anonymous", None, ""): return str(gid) return "default" def require_api_key(f): """ Multi-tenant API key auth. Priority: 1) If static API_KEY env is set, it must match. 2) Else if key exists in api_keys table, use tenant mapping. 3) Else allow only when API_KEY_REQUIRED is false. """ @wraps(f) def decorated(*args, **kwargs): key = request.headers.get('X-API-Key', '') if API_KEY: if key != API_KEY: return error_response('Unauthorized. Provide a valid X-API-Key header.', code='UNAUTHORIZED', status=401) g.tenant_id = "default" return f(*args, **kwargs) tenant = _lookup_tenant_by_key(key) if key else None if tenant: g.tenant_id = tenant["tenant_id"] g.api_key_name = tenant["key_name"] return f(*args, **kwargs) if API_KEY_REQUIRED: return error_response('Unauthorized. Provide a valid X-API-Key header.', code='UNAUTHORIZED', status=401) g.tenant_id = "anonymous" return f(*args, **kwargs) return decorated # ─── Standardized Response Helpers ─── def success_response(data, status=200, extra_meta=None): """Wrap successful payload in standard envelope.""" meta = { "request_id": getattr(g, "request_id", ""), "duration_ms": round((time.time() - getattr(g, "start_time", time.time())) * 1000, 2), } if extra_meta: meta.update(extra_meta) return jsonify({"data": data, "meta": meta}), status def error_response(message, code="ERROR", status=400): """Wrap error in standard envelope.""" return jsonify({ "error": {"code": code, "message": str(message)}, "meta": {"request_id": getattr(g, "request_id", "")}, }), status # ─── Request Lifecycle Hooks ─── @app.before_request def before_request_hook(): g.request_id = str(uuid.uuid4()) g.start_time = time.time() @app.after_request def after_request_hook(response): # Metrics duration = time.time() - getattr(g, 'start_time', time.time()) endpoint = request.endpoint or 'unknown' REQUEST_COUNT.labels(method=request.method, endpoint=endpoint, status=response.status_code).inc() REQUEST_LATENCY.labels(method=request.method, endpoint=endpoint).observe(duration) # Structured log logger.info('request_completed', extra={ 'request_id': getattr(g, 'request_id', ''), 'tenant_id': getattr(g, 'tenant_id', 'anonymous'), 'method': request.method, 'path': request.path, 'status': response.status_code, 'duration_ms': round(duration * 1000, 2), 'remote_addr': request.remote_addr, }) # Persist audit trail (best-effort) try: payload = None if request.method in ("POST", "PUT", "PATCH"): payload = request.get_json(silent=True) conn = _db_conn() cur = conn.cursor() cur.execute( """ INSERT INTO audit_log ( request_id, tenant_id, action, endpoint, method, payload_json, response_status, duration_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( getattr(g, "request_id", ""), getattr(g, "tenant_id", "anonymous"), request.endpoint or "unknown", request.path, request.method, json.dumps(payload) if payload is not None else None, int(response.status_code), float(round(duration * 1000, 2)), ), ) conn.commit() conn.close() except Exception as audit_exc: logger.warning(f"audit_log_write_failed: {audit_exc}") # Security headers response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['X-Request-Id'] = getattr(g, 'request_id', '') if os.getenv('HF_SPACES') == '1': response.headers['Content-Security-Policy'] = ( "default-src 'self' https://*.hf.space; " "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " "style-src 'self' 'unsafe-inline'; " "font-src 'self' https://fonts.gstatic.com data:; " "connect-src 'self' https://*.hf.space; " "frame-ancestors 'self' https://huggingface.co https://*.hf.space" ) else: response.headers['X-Frame-Options'] = 'DENY' response.headers['Content-Security-Policy'] = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; " "font-src 'self' https://fonts.gstatic.com; " "connect-src 'self'" ) return response def generate_mock_data(n_assets, regime): """Generate mock market data for demonstration.""" # Asset names names = ["AAPL","MSFT","GOOGL","AMZN","META","NVDA","TSLA","JPM","V","JNJ", "PG","UNH","HD","MA","BAC","DIS","NFLX","KO","PFE","CVX","WMT", "MRK","ABT","ADBE","NKE","PEP","T","VZ","PYPL","BRK"] sectors = ["Tech","Tech","Tech","Tech","Tech","Tech","Tech","Finance","Finance","Health", "Consumer","Health","Consumer","Finance","Finance","Consumer","Tech","Consumer", "Health","Energy","Consumer","Health","Health","Tech","Consumer","Consumer", "Telecom","Telecom","Tech","Finance"] # Adjust regime parameters regime_params = { 'bull': {'drift': 0.0008, 'vol': 0.012, 'corr_base': 0.3}, 'bear': {'drift': -0.0003, 'vol': 0.022, 'corr_base': 0.6}, 'volatile': {'drift': 0.0002, 'vol': 0.028, 'corr_base': 0.45}, 'normal': {'drift': 0.0004, 'vol': 0.015, 'corr_base': 0.35}, } params = regime_params.get(regime, regime_params['normal']) assets = [] for i in range(n_assets): # Generate returns and volatility drift = params['drift'] + (np.random.random() - 0.4) * 0.001 vol = params['vol'] * (0.7 + np.random.random() * 0.6) # Create artificial returns for the past year (252 trading days) returns = np.random.normal(drift, vol, 252) ann_return = np.mean(returns) * 252 ann_vol = np.std(returns) * np.sqrt(252) sharpe = ann_return / ann_vol if ann_vol != 0 else 0 assets.append({ 'name': names[i] if i < len(names) else f'ASSET_{i}', 'sector': sectors[i] if i < len(sectors) else 'Other', 'ann_return': ann_return, 'ann_vol': ann_vol, 'sharpe': sharpe }) # Create correlation matrix corr = np.eye(n_assets) for i in range(n_assets): for j in range(i+1, n_assets): # Same sector gets slightly higher correlation same_sector_bonus = 0.1 if assets[i]['sector'] == assets[j]['sector'] else 0 base_corr = params['corr_base'] random_corr = np.random.uniform(-0.2, 0.4) corr_val = np.clip(base_corr + same_sector_bonus + random_corr, -0.3, 0.95) corr[i, j] = corr_val corr[j, i] = corr_val return assets, corr # ─── Input Validation Helpers ─── import re def sanitize_ticker(ticker): """Sanitize a ticker symbol to alphanumeric + dots/hyphens only.""" cleaned = re.sub(r'[^A-Za-z0-9.\-]', '', str(ticker).strip().upper()) return cleaned[:10] # Max 10 chars def validate_tickers_input(data, field='tickers'): """Validate and sanitize a list of tickers from request data. Returns (tickers, error).""" raw = data.get(field) if not raw: return None, f"'{field}' is required" if not isinstance(raw, list): return None, f"'{field}' must be a list" if len(raw) > 50: return None, f"Maximum 50 tickers allowed, got {len(raw)}" tickers = [sanitize_ticker(t) for t in raw if t] tickers = [t for t in tickers if t] # Remove empty after sanitization if not tickers: return None, "No valid tickers provided after sanitization" return tickers, None def validate_date(date_str, field_name='date'): """Validate a date string in YYYY-MM-DD format. Returns (date_str, error).""" if not date_str: return None, None # Optional try: datetime.strptime(str(date_str), '%Y-%m-%d') return str(date_str), None except ValueError: return None, f"'{field_name}' must be in YYYY-MM-DD format, got '{date_str}'" def validate_request(data, required_fields=None, numeric_ranges=None): """ Validate request data with required fields and numeric range checks. Returns (cleaned_data, error_message). """ if data is None: return None, "Request body is required (JSON)" errors = [] if required_fields: for field in required_fields: if field not in data or data[field] is None: errors.append(f"'{field}' is required") if numeric_ranges: for field, (lo, hi) in numeric_ranges.items(): val = data.get(field) if val is not None: try: val = float(val) if val < lo or val > hi: errors.append(f"'{field}' must be between {lo} and {hi}, got {val}") except (ValueError, TypeError): errors.append(f"'{field}' must be a number") if errors: return None, '; '.join(errors) return data, None @app.route('/api/market-data', methods=['GET', 'POST']) @require_api_key @limiter.limit("10 per minute") def get_market_data(): """Endpoint to fetch real market data using yfinance.""" try: # Handle both GET and POST requests if request.method == 'GET': # Get parameters from query string tickers_param = request.args.get('tickers', '') start_date = request.args.get('start', None) end_date = request.args.get('end', None) if not tickers_param: return error_response('Tickers parameter is required', code='BAD_REQUEST', status=400) tickers = [t.strip().upper() for t in tickers_param.split(',')] else: # POST # Get parameters from request body data = request.json if not data: return error_response('Request body is required for POST requests', code='BAD_REQUEST', status=400) tickers = data.get('tickers', []) start_date = data.get('start_date', None) end_date = data.get('end_date', None) if not tickers: return error_response('Tickers parameter is required', code='BAD_REQUEST', status=400) # Validate tickers if not isinstance(tickers, list) or len(tickers) == 0: return error_response('Tickers must be a non-empty list', code='BAD_REQUEST', status=400) # Limit number of tickers to prevent abuse if len(tickers) > 50: return error_response('Maximum of 50 tickers allowed', code='BAD_REQUEST', status=400) # Validate dates if provided if start_date: try: pd.to_datetime(start_date) except: return error_response('Invalid start date format. Use YYYY-MM-DD.', code='BAD_REQUEST', status=400) if end_date: try: pd.to_datetime(end_date) except: return error_response('Invalid end date format. Use YYYY-MM-DD.', code='BAD_REQUEST', status=400) # Check cache first ck = _cache_key(tickers, start_date or '', end_date or '') cached = cache_get(ck) if cached is not None: logger.info('market_data_cache_hit', extra={'cache_key': ck, 'tickers': tickers}) return success_response(cached) # Fetch market data (with latency tracking) with MARKET_DATA_LATENCY.time(): market_data = fetch_market_data( tickers=tickers, start_date=start_date, end_date=end_date ) # Store in cache cache_set(ck, market_data) return success_response(market_data) except ValueError as e: return error_response(str(e), code='BAD_REQUEST', status=400) except Exception as e: return error_response(f'Internal server error: {str(e)}', code='INTERNAL_ERROR', status=500) @app.route('/api/portfolio/backtest', methods=['POST']) @require_api_key @limiter.limit("10 per minute") def run_backtest(): """Endpoint to run portfolio backtesting.""" try: data = request.json backtest_results = _run_backtest_payload(data) log_business_audit( action="backtest_run", payload=data or {}, result={ "summary_metrics": backtest_results.get("summary_metrics", {}), "points": len(backtest_results.get("results", [])), }, status=200, ) return success_response(backtest_results) except ValueError as e: return error_response(str(e), code='BAD_REQUEST', status=400) except Exception as e: logger.error(f"Backtest endpoint error: {str(e)}", exc_info=True) return error_response(f'Internal server error: {str(e)}', code='INTERNAL_ERROR', status=500) def _run_backtest_payload(data): """Shared backtest execution used by sync and async endpoints.""" if not data: raise ValueError('Request body is required') tickers = data.get('tickers', []) start_date = data.get('start_date', None) end_date = data.get('end_date', None) rebalance_frequency = data.get('rebalance_frequency', 'monthly') objective = data.get('objective', 'max_sharpe') target_return = data.get('target_return', None) strategy_preset = data.get('strategy_preset', 'balanced') constraints = PortfolioConstraints.from_dict(data.get('constraints')) if not tickers: raise ValueError('Tickers parameter is required') if not start_date or not end_date: raise ValueError('Both start_date and end_date are required') start_dt = datetime.strptime(start_date, '%Y-%m-%d') end_dt = datetime.strptime(end_date, '%Y-%m-%d') if start_dt >= end_dt: raise ValueError('Start date must be before end date') valid_freqs = ['weekly', 'monthly', 'quarterly', 'yearly'] if rebalance_frequency not in valid_freqs: raise ValueError(f'Invalid rebalance frequency. Valid options: {valid_freqs}') valid_objectives = ['max_sharpe', 'min_variance', 'target_return', 'risk_parity', 'hrp', 'braket_annealing'] if objective not in valid_objectives: raise ValueError(f'Invalid objective. Valid options: {valid_objectives}') return run_backtesting( tickers=tickers, start_date=start_date, end_date=end_date, rebalance_frequency=rebalance_frequency, objective=objective, target_return=target_return, strategy_preset=strategy_preset, constraints=constraints ) def calculate_portfolio_metrics(weights, returns, covariance_matrix): """Calculate portfolio metrics from weights, returns and covariance.""" # Expected return portfolio_return = np.dot(weights, returns) # Volatility portfolio_variance = np.dot(weights, np.dot(covariance_matrix, weights)) portfolio_volatility = np.sqrt(portfolio_variance) # Sharpe ratio (assuming 0 risk-free rate) sharpe_ratio = portfolio_return / portfolio_volatility if portfolio_volatility != 0 else 0 # Effective number of assets n_assets_effective = 1 / np.sum(weights ** 2) if np.sum(weights ** 2) != 0 else 0 # Number of active assets (with meaningful weight) n_active = np.sum(weights > 0.005) return { 'expected_return': float(portfolio_return), 'volatility': float(portfolio_volatility), 'sharpe_ratio': float(sharpe_ratio), 'n_active': int(n_active), 'n_effective': float(n_assets_effective) } def compute_var(assets, weights, confidence=0.95, n_simulations=2000): """Compute Value at Risk (VaR) and Conditional VaR.""" n_assets = len(weights) # Simple VaR calculation using historical simulation # Generate scenario returns based on asset correlations np.random.seed(123) # For consistency scenario_returns = [] # Use the correlation structure from the assets # Create a basic correlation matrix and simulate returns corr_matrix = [] for i, asset1 in enumerate(assets): row = [] for j, asset2 in enumerate(assets): # Approximate correlation if i == j: row.append(1.0) elif i < j: row.append(0.3) # Placeholder correlation else: row.append(corr_matrix[j][i]) corr_matrix.append(row) corr_matrix = np.array(corr_matrix) # Generate correlated random returns for sim in range(n_simulations): # Generate correlated random shocks using Cholesky decomposition shocks = np.random.multivariate_normal( mean=[0] * n_assets, cov=corr_matrix * 0.01 # Adjusted for typical daily volatility ) # Calculate portfolio return for this scenario portfolio_return = 0 for i, weight in enumerate(weights): # Add random shock scaled by asset volatility asset_vol = assets[i]['ann_vol'] / np.sqrt(252) # Daily volatility asset_return = shocks[i] * asset_vol portfolio_return += weight * asset_return scenario_returns.append(portfolio_return) scenario_returns.sort() # Calculate VaR var_idx = int(n_simulations * confidence) var_95 = abs(scenario_returns[var_idx]) if var_idx < len(scenario_returns) else 0 # Calculate CVaR (Conditional VaR) tail_losses = scenario_returns[var_idx:] cvar = abs(sum(tail_losses) / len(tail_losses)) if tail_losses else 0 return { 'var_95': var_95 * 100, # Convert to percentage 'cvar': cvar * 100 # Convert to percentage } def run_benchmark_comparison(assets_data): """Run benchmark algorithms for comparison.""" n = len(assets_data) # Convert to numpy arrays returns = np.array([a['ann_return'] for a in assets_data]) vols = np.array([a['ann_vol'] for a in assets_data]) sharpes = np.array([a['sharpe'] for a in assets_data]) def calc_metrics(w): r = np.dot(w, returns) # Simplified correlation matrix for benchmark calculations corr_matrix = np.full((n, n), 0.3) # Average correlation np.fill_diagonal(corr_matrix, 1.0) # Calculate portfolio variance with correlation portfolio_variance = 0 for i in range(n): for j in range(n): portfolio_variance += w[i] * w[j] * corr_matrix[i, j] * vols[i] * vols[j] vol = np.sqrt(portfolio_variance) sharpe = r / vol if vol != 0 else 0 n_active = int(np.sum(w > 0.005)) return { 'weights': [float(x) for x in w], 'expected_return': float(r), 'volatility': float(vol), 'sharpe_ratio': float(sharpe), 'n_active': n_active } # Equal weight ew = np.ones(n) / n # Inverse volatility (min variance approximation) inv_vols = 1 / (vols + 1e-8) iv = inv_vols / np.sum(inv_vols) # Risk parity (equal risk contribution approximation) inv_risk = 1 / (vols**2 + 1e-8) rp = inv_risk / np.sum(inv_risk) # Max Sharpe pos_sharpes = np.maximum(sharpes, 0) ms = pos_sharpes / np.sum(pos_sharpes) if np.sum(pos_sharpes) > 0 else ew.copy() # Hierarchical Risk Parity from core.optimizers.hrp import hrp_weights cov_approx = np.outer(vols, vols) * (np.full((n, n), 0.3)) np.fill_diagonal(cov_approx, vols ** 2) hrp_w = hrp_weights(cov_approx) return { 'equal_weight': {"name": "Equal Weight", **calc_metrics(ew)}, 'min_variance': {"name": "Min Variance", **calc_metrics(iv)}, 'risk_parity': {"name": "Risk Parity", **calc_metrics(rp)}, 'max_sharpe': {"name": "Max Sharpe", **calc_metrics(ms)}, 'hrp': {"name": "Hierarchical Risk Parity", **calc_metrics(hrp_w)} } def serialize_numpy(obj): """Convert numpy objects to JSON serializable types.""" if isinstance(obj, np.integer): return int(obj) elif isinstance(obj, np.floating): return float(obj) elif isinstance(obj, np.ndarray): return obj.tolist() elif isinstance(obj, np.bool_): return bool(obj) raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") def _safe_serialize_metrics(obj): """Recursively convert metrics dict to JSON-serializable types.""" if isinstance(obj, (np.integer, np.int32, np.int64)): return int(obj) if isinstance(obj, (np.floating, np.float32, np.float64)): v = float(obj) return v if np.isfinite(v) else None if isinstance(obj, np.ndarray): return obj.tolist() if isinstance(obj, dict): return {k: _safe_serialize_metrics(v) for k, v in obj.items()} if isinstance(obj, (list, tuple)): return [_safe_serialize_metrics(v) for v in obj] if isinstance(obj, (int, float, str, bool, type(None))): return obj return str(obj) @app.route('/api/portfolio/optimize', methods=['POST']) @require_api_key @limiter.limit("10 per minute") def optimize_portfolio(): """Portfolio optimisation endpoint — routes by objective to notebook methods.""" try: data = request.json if not data: return error_response('Request body is required', code='BAD_REQUEST', status=400) # ── Market data: prefer direct matrix, fallback to ticker fetch ───── market_payload = load_market_payload(data) assets = market_payload.assets returns = market_payload.returns covariance = market_payload.covariance tickers = market_payload.tickers # ── Objective & method params ──────────────────────────────────────── objective = data.get('objective', 'hybrid') # Backward compatibility: legacy objective names braket_fallback = objective == 'braket_annealing' objective = { 'max_sharpe': 'markowitz', 'risk_parity': 'hrp', 'braket_annealing': 'qubo_sa', }.get(objective, objective) target_return = data.get('targetReturn') or data.get('target_return') # Cardinality params (QUBO-SA and Hybrid) K = data.get('K') K_screen = data.get('K_screen') K_select = data.get('K_select') # QUBO tuning lambda_risk = float(data.get('lambda_risk', 1.0)) gamma = float(data.get('gamma', 8.0)) # VQE tuning n_layers = int(data.get('n_layers', 3)) n_restarts = int(data.get('n_restarts', 8)) # Weight bounds weight_min = float(data.get('weight_min', data.get('minWeight', 0.005))) weight_max = float(data.get('weight_max', data.get('maxWeight', 0.30))) # Reproducibility seed = int(data.get('seed', 42)) # ── Validate objective ─────────────────────────────────────────────── if objective not in OBJECTIVES: return error_response( f"Unknown objective '{objective}'. Valid: {list(OBJECTIVES.keys())}", code='BAD_REQUEST', status=400 ) # ── Run optimisation ───────────────────────────────────────────────── asset_names = [a['name'] for a in assets] sectors_list = [a['sector'] for a in assets] t0 = time.time() if LOG_FORMAT == 'console': tickers_preview = tickers[:5] if tickers and len(tickers) > 5 else tickers logger.info( "optimization_start objective=%s n_assets=%d weight_min=%s weight_max=%s tickers=%s", objective, len(asset_names), weight_min, weight_max, tickers_preview, ) else: logger.info("optimization_start", extra={ "objective": objective, "n_assets": len(asset_names), "weight_min": weight_min, "weight_max": weight_max, "tickers": tickers[:5] if tickers and len(tickers) > 5 else tickers, }) with OPTIMIZATION_LATENCY.labels(objective=objective).time(): result = run_optimization( returns=returns, covariance=covariance, objective=objective, target_return=float(target_return) if target_return is not None else None, asset_names=asset_names, K=int(K) if K is not None else None, K_screen=int(K_screen) if K_screen is not None else None, K_select=int(K_select) if K_select is not None else None, lambda_risk=lambda_risk, gamma=gamma, n_layers=n_layers, n_restarts=n_restarts, weight_min=weight_min, weight_max=weight_max, seed=seed, ) duration_ms = round((time.time() - t0) * 1000, 2) # ── Build holdings list ────────────────────────────────────────────── holdings = [ { 'name': asset_names[i], 'sector': sectors_list[i] if i < len(sectors_list) else 'Unknown', 'weight': float(result.weights[i]), } for i in range(len(asset_names)) if result.weights[i] > 1e-4 ] sorted_holdings = sorted(holdings, key=lambda x: -x['weight']) # ── Sector aggregation ──────────────────────────────────────────────── sector_alloc = {} for i, (name, sector) in enumerate(zip(asset_names, sectors_list)): w = float(result.weights[i]) if w > 1e-4: sector_alloc[sector] = sector_alloc.get(sector, 0.0) + w sector_data = [{'sector': s, 'weight': round(w, 6)} for s, w in sector_alloc.items()] # ── Log results (format-aware) ──────────────────────────────────────── if LOG_FORMAT == 'console': logger.info( "optimization_completed objective=%s sharpe=%.4f return=%.4f vol=%.4f n_active=%d duration=%.0fms", objective, result.sharpe_ratio, result.expected_return, result.volatility, result.n_active, duration_ms, ) holdings_cap = sorted_holdings[:50] logger.info("Holdings (%d):", len(holdings_cap)) for h in holdings_cap: logger.info(" %s: %.1f%% (%s)", h['name'], h['weight'] * 100, h['sector']) sector_str = ", ".join("%s %.0f%%" % (s, w * 100) for s, w in sorted(sector_alloc.items(), key=lambda x: -x[1])) logger.info("Sectors: %s", sector_str) else: logger.info("optimization_completed", extra={ "objective": objective, "sharpe_ratio": round(result.sharpe_ratio, 4), "n_active": result.n_active, "expected_return": round(result.expected_return, 6), "volatility": round(result.volatility, 6), "duration_ms": duration_ms, }) full_holdings = [(h['name'], round(h['weight'] * 100, 1), h['sector']) for h in sorted_holdings[:50]] logger.info("optimization_holdings", extra={ "n_holdings": len(holdings), "holdings": full_holdings, "sector_allocation": [{"sector": s, "weight_pct": round(w * 100, 1)} for s, w in sector_alloc.items()], }) # ── Risk metrics (VaR / CVaR approximation) ─────────────────────────── port_vol = result.volatility var_95 = -1.645 * port_vol / (252 ** 0.5) cvar_95 = -2.063 * port_vol / (252 ** 0.5) # ── Benchmark comparison (light classical methods for reference) ──────── try: from core.optimizers.equal_weight import equal_weight from core.optimizers.markowitz import min_variance, markowitz_max_sharpe from core.optimizers.hrp import hrp_weights def _metrics(w): r = float(w @ returns) v = float(np.sqrt(w @ covariance @ w)) sr = r / v if v > 1e-10 else 0.0 return {'weights': w.tolist(), 'sharpe': round(sr, 4), 'expected_return': round(r, 6), 'volatility': round(v, 6)} benchmarks = { 'equal_weight': _metrics(equal_weight(returns, covariance)), 'min_variance': _metrics(min_variance(returns, covariance)), 'markowitz': _metrics(markowitz_max_sharpe(returns, covariance, weight_bounds=(weight_min, weight_max))), 'hrp': _metrics(hrp_weights(returns, covariance)), } except Exception: benchmarks = {} # ── Assemble response (backward-compatible shape) ──────────────────── response_payload = { 'backend_type': 'classical_qubo' if braket_fallback else None, 'qsw_result': { 'weights': result.weights.tolist(), 'sharpe_ratio': round(result.sharpe_ratio, 4), 'expected_return': round(result.expected_return, 6), 'volatility': round(result.volatility, 6), 'n_active': result.n_active, 'objective': result.objective, }, 'weights': result.weights.tolist(), 'sharpe_ratio': round(result.sharpe_ratio, 4), 'expected_return': round(result.expected_return, 6), 'volatility': round(result.volatility, 6), 'n_active': result.n_active, 'objective': result.objective, 'holdings': holdings, 'sector_allocation': sector_data, 'risk_metrics': {'var_95': round(var_95, 6), 'cvar': round(cvar_95, 6)}, 'benchmarks': benchmarks, 'stage_info': result.stage_info, 'assets': [ { 'name': asset_names[i], 'sector': sectors_list[i] if i < len(sectors_list) else 'Unknown', 'return': round(float(returns[i]), 6), 'volatility': round(float(covariance[i][i] ** 0.5), 6), 'sharpe': round( float(returns[i]) / max(float(covariance[i][i] ** 0.5), 1e-10), 4 ), } for i in range(len(asset_names)) ], 'metadata': { 'tickers': tickers, 'n_assets': len(asset_names), 'objective': objective, 'weight_min': weight_min, 'weight_max': weight_max, }, } # Include correlation matrix if tickers provided if tickers: vols = np.sqrt(np.maximum(np.diag(covariance), 1e-10)) correlation = covariance / np.outer(vols, vols) response_payload['correlation_matrix'] = [[float(c) for c in row] for row in correlation.tolist()] log_business_audit( action='portfolio_optimize', payload={'objective': objective, 'n_assets': len(asset_names), 'tickers': tickers}, result={'sharpe': result.sharpe_ratio, 'n_active': result.n_active}, status=200, ) return success_response(_safe_serialize_metrics(response_payload)) except ValueError as e: return error_response(str(e), code='BAD_REQUEST', status=400) except Exception as e: logger.error(f"optimize_portfolio error: {str(e)}", exc_info=True) return error_response(f'Internal server error: {str(e)}', code='INTERNAL_ERROR', status=500) def _post_webhook(url: str, body: dict): """Best-effort webhook callback.""" try: payload = json.dumps(body).encode("utf-8") req = Request( url, data=payload, method="POST", headers={"Content-Type": "application/json"}, ) with urlopen(req, timeout=10): pass except Exception as exc: logger.warning(f"webhook_delivery_failed: {exc}") def _run_job_endpoint(path: str, payload: dict): """Execute existing endpoint logic in-process using Flask test client.""" with app.test_client() as client: headers = {} inherited_key = payload.pop("__api_key", None) if inherited_key: headers["X-API-Key"] = inherited_key resp = client.post(path, json=payload, headers=headers) data = resp.get_json(silent=True) or {} if resp.status_code >= 400: err = data.get("error") msg = err.get("message", str(err)) if isinstance(err, dict) else (err or f"{path} failed with {resp.status_code}") raise ValueError(msg) return data.get("data", data) def _submit_async_job(job_type: str, payload: dict, webhook_url: str = ""): job_id = str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() with _jobs_lock: _jobs[job_id] = { "job_id": job_id, "job_type": job_type, "status": "queued", "created_at": now, "started_at": None, "finished_at": None, "error": None, "result": None, "webhook_url": webhook_url or None, "tenant_id": getattr(g, "tenant_id", "anonymous"), } def _runner(): with _jobs_lock: _jobs[job_id]["status"] = "running" _jobs[job_id]["started_at"] = datetime.now(timezone.utc).isoformat() try: if job_type == "optimize": result = _run_job_endpoint("/api/portfolio/optimize", dict(payload)) elif job_type == "backtest": result = _run_job_endpoint("/api/portfolio/backtest", dict(payload)) else: raise ValueError(f"Unsupported job type: {job_type}") with _jobs_lock: _jobs[job_id]["status"] = "completed" _jobs[job_id]["result"] = result _jobs[job_id]["finished_at"] = datetime.now(timezone.utc).isoformat() log_async_audit( tenant_id=_jobs[job_id].get("tenant_id", "anonymous"), action=f"{job_type}_job_completed", payload=payload, result={"job_id": job_id, "status": "completed"}, status=200, ) if webhook_url: _post_webhook( webhook_url, { "job_id": job_id, "status": "completed", "result": result, }, ) except Exception as exc: with _jobs_lock: _jobs[job_id]["status"] = "failed" _jobs[job_id]["error"] = str(exc) _jobs[job_id]["finished_at"] = datetime.now(timezone.utc).isoformat() log_async_audit( tenant_id=_jobs[job_id].get("tenant_id", "anonymous"), action=f"{job_type}_job_failed", payload=payload, result={"job_id": job_id, "status": "failed", "error": str(exc)}, status=500, ) if webhook_url: _post_webhook( webhook_url, { "job_id": job_id, "status": "failed", "error": str(exc), }, ) _executor.submit(_runner) return _jobs[job_id] @app.route('/api/portfolio/optimize/batch', methods=['POST']) @require_api_key @limiter.limit("3 per minute") def optimize_portfolio_batch(): """ Batch optimization endpoint. Body: {"requests":[{...},{...}], "stop_on_error": false} """ data = request.get_json(silent=True) or {} reqs = data.get("requests", []) stop_on_error = bool(data.get("stop_on_error", False)) if not isinstance(reqs, list) or len(reqs) == 0: return error_response("requests must be a non-empty list", code='BAD_REQUEST', status=400) if len(reqs) > 100: return error_response("maximum 100 optimization requests per batch", code='BAD_REQUEST', status=400) api_key = request.headers.get("X-API-Key", "") results = [] for idx, payload in enumerate(reqs): try: if not isinstance(payload, dict): raise ValueError("each request payload must be an object") local_payload = dict(payload) if api_key: local_payload["__api_key"] = api_key result = _run_job_endpoint("/api/portfolio/optimize", local_payload) results.append({"index": idx, "status": "ok", "result": result}) except Exception as exc: results.append({"index": idx, "status": "error", "error": str(exc)}) if stop_on_error: break return success_response({"count": len(results), "results": results}) @app.route('/api/portfolio/backtest/batch', methods=['POST']) @require_api_key @limiter.limit("3 per minute") def backtest_portfolio_batch(): """ Batch backtest endpoint. Body: {"requests":[{tickers, start_date, end_date, ...}, ...], "stop_on_error": false} Returns: {"count": N, "results": [{"index": 0, "status": "ok"|"error", "result"|"error": ...}, ...]} """ data = request.get_json(silent=True) or {} reqs = data.get("requests", []) stop_on_error = bool(data.get("stop_on_error", False)) if not isinstance(reqs, list) or len(reqs) == 0: return error_response("requests must be a non-empty list", code='BAD_REQUEST', status=400) if len(reqs) > 50: return error_response("maximum 50 backtest requests per batch", code='BAD_REQUEST', status=400) results = [] for idx, payload in enumerate(reqs): try: if not isinstance(payload, dict): raise ValueError("each request payload must be an object") result = _run_backtest_payload(payload) results.append({"index": idx, "status": "ok", "result": result}) except Exception as exc: results.append({"index": idx, "status": "error", "error": str(exc)}) if stop_on_error: break return success_response({"count": len(results), "results": results}) @app.route('/api/jobs/optimize', methods=['POST']) @require_api_key @limiter.limit("10 per minute") def submit_optimize_job(): """Submit optimization job for async execution.""" data = request.get_json(silent=True) or {} payload = data.get("payload", data) webhook_url = data.get("webhook_url", "") if not isinstance(payload, dict): return error_response("payload must be an object", code='BAD_REQUEST', status=400) payload["__api_key"] = request.headers.get("X-API-Key", "") job = _submit_async_job("optimize", payload, webhook_url) return success_response({"job_id": job["job_id"], "status": job["status"]}, status=202) @app.route('/api/jobs/backtest', methods=['POST']) @require_api_key @limiter.limit("10 per minute") def submit_backtest_job(): """Submit backtest job for async execution.""" data = request.get_json(silent=True) or {} payload = data.get("payload", data) webhook_url = data.get("webhook_url", "") if not isinstance(payload, dict): return error_response("payload must be an object", code='BAD_REQUEST', status=400) payload["__api_key"] = request.headers.get("X-API-Key", "") job = _submit_async_job("backtest", payload, webhook_url) return success_response({"job_id": job["job_id"], "status": job["status"]}, status=202) @app.route('/api/jobs/', methods=['GET']) @require_api_key def get_job_status(job_id): """Get async job status and result.""" with _jobs_lock: job = _jobs.get(job_id) if not job: return error_response("job not found", code='NOT_FOUND', status=404) if job.get("tenant_id") != getattr(g, "tenant_id", "anonymous"): return error_response("forbidden", code='FORBIDDEN', status=403) return success_response(job) # ─── Lab Runs (durable experiment registry) ───────────────────────────────── def _build_optimize_spec(data: dict) -> dict: """Extract and normalise the optimization spec from a run request body.""" objective = data.get("objective", "hybrid") objective = { "max_sharpe": "markowitz", "risk_parity": "hrp", "braket_annealing": "qubo_sa", }.get(objective, objective) bn = data.get("backend_name") if isinstance(bn, str): bn = bn.strip() or None return { "objective": objective, "weight_min": float(data.get("weight_min", data.get("minWeight", 0.005))), "weight_max": float(data.get("weight_max", data.get("maxWeight", 0.30))), "seed": int(data.get("seed", 42)), "K": data.get("K"), "K_screen": data.get("K_screen"), "K_select": data.get("K_select"), "lambda_risk": float(data.get("lambda_risk", 1.0)), "gamma": float(data.get("gamma", 8.0)), "n_layers": int(data.get("n_layers", 3)), "n_restarts": int(data.get("n_restarts", 8)), "data_mode": data.get("data_mode", "synthetic"), "regime": data.get("regime"), "tickers": data.get("tickers") or data.get("asset_names"), "n_assets": len(data.get("returns", [])) if data.get("returns") else None, "backend_name": bn, "ibm_backend_mode": str(data.get("ibm_backend_mode") or "auto").lower(), } def _lab_run_holdings_and_payload( weights: np.ndarray, metrics: dict, assets: list, *, stage_info: dict | None = None, quantum_metadata: dict | None = None, objective: str | None = None, ) -> dict: sectors = [a.get("sector", "Unknown") for a in assets] asset_names = [a["name"] for a in assets] holdings = [ { "name": asset_names[i], "sector": sectors[i] if i < len(sectors) else "Unknown", "weight": float(weights[i]), } for i in range(len(weights)) if weights[i] > 0.005 ] out = { "weights": [float(w) for w in weights], "sharpe_ratio": float(metrics["sharpe"]), "expected_return": float(metrics["return"]), "volatility": float(metrics["volatility"]), "n_active": int(metrics["n_active"]), "holdings": holdings, "stage_info": stage_info, } if objective is not None: out["objective"] = objective if quantum_metadata is not None: out["quantum_metadata"] = quantum_metadata return out def _run_optimize_for_run(run_id: str, tenant_id: str, data: dict, execution_kind: str = "async_optimize") -> None: """Background worker: execute optimization and persist result on the run row.""" lab_run_service.update_status(run_id, "running") try: market_payload = load_market_payload(data) spec = _build_optimize_spec(data) assets = market_payload.assets if execution_kind == "ibm_runtime": w, qmeta = vqe_weights_ibm_strict( market_payload.returns, market_payload.covariance, n_layers=spec["n_layers"], n_restarts=spec["n_restarts"], weight_min=spec["weight_min"], weight_max=spec["weight_max"], seed=spec["seed"], backend_name=spec.get("backend_name"), backend_mode=spec.get("ibm_backend_mode") or "auto", ) qmeta = dict(qmeta) qmeta["execution_kind"] = "ibm_runtime" metrics = _portfolio_metrics(w, market_payload.returns, market_payload.covariance) result_payload = _lab_run_holdings_and_payload( w, metrics, assets, stage_info=None, quantum_metadata=qmeta, objective="vqe", ) else: result = run_optimization( returns=market_payload.returns, covariance=market_payload.covariance, objective=spec["objective"], target_return=float(data["target_return"]) if data.get("target_return") else None, asset_names=[a["name"] for a in market_payload.assets], K=int(spec["K"]) if spec.get("K") is not None else None, K_screen=int(spec["K_screen"]) if spec.get("K_screen") is not None else None, K_select=int(spec["K_select"]) if spec.get("K_select") is not None else None, lambda_risk=spec["lambda_risk"], gamma=spec["gamma"], n_layers=spec["n_layers"], n_restarts=spec["n_restarts"], weight_min=spec["weight_min"], weight_max=spec["weight_max"], seed=spec["seed"], ) metrics = { "sharpe": result.sharpe_ratio, "return": result.expected_return, "volatility": result.volatility, "n_active": result.n_active, } result_payload = _lab_run_holdings_and_payload( result.weights, metrics, assets, stage_info=result.stage_info, quantum_metadata=None, objective=None, ) result_payload["objective"] = spec["objective"] lab_run_service.update_status(run_id, "completed", result=result_payload) log_async_audit(tenant_id, "run_completed", {"run_id": run_id}, {"status": "completed"}, 200) except Exception as exc: logger.error("run_failed run_id=%s: %s", run_id, exc, exc_info=True) lab_run_service.update_status(run_id, "failed", error=str(exc)) log_async_audit(tenant_id, "run_failed", {"run_id": run_id}, {"error": str(exc)}, 500) @app.route('/api/runs', methods=['POST']) @require_api_key @limiter.limit("10 per minute") def create_run(): """Create a durable lab run — enqueues optimization in background.""" data = request.get_json(silent=True) or {} payload = data.get("payload", data) if not isinstance(payload, dict): return error_response("payload must be an object", code='BAD_REQUEST', status=400) execution_kind = str(data.get("execution_kind", "async_optimize")) spec = _build_optimize_spec(payload) if spec["objective"] not in OBJECTIVES: return error_response( f"Unknown objective '{spec['objective']}'. Valid: {list(OBJECTIVES.keys())}", code='BAD_REQUEST', status=400, ) ibm_mode = spec.get("ibm_backend_mode") or "auto" if ibm_mode not in ("auto", "simulator", "hardware"): return error_response( f"Invalid ibm_backend_mode {ibm_mode!r}; use auto, simulator, or hardware", code='BAD_REQUEST', status=400, ) if execution_kind == "ibm_runtime": if spec["objective"] != "vqe": return error_response( "execution_kind ibm_runtime requires objective vqe", code='BAD_REQUEST', status=400, ) if not ibm_quantum_service.is_configured(): return error_response( "execution_kind ibm_runtime requires IBM Quantum to be configured " "(POST /api/config/ibm-quantum with a valid token)", code='BAD_REQUEST', status=400, ) na = spec.get("n_assets") if na is not None and na > MAX_IBM_QUBITS: return error_response( f"Universe size {na} exceeds MAX_IBM_QUBITS ({MAX_IBM_QUBITS}) for IBM Runtime VQE", code='BAD_REQUEST', status=400, ) tenant_id = getattr(g, "tenant_id", "anonymous") run = lab_run_service.create_run(tenant_id, spec, execution_kind) _executor.submit(_run_optimize_for_run, run["id"], tenant_id, payload, execution_kind) log_business_audit("run_created", {"run_id": run["id"], "spec": spec}, {"status": "queued"}, 202) return success_response({"run_id": run["id"], "status": "queued"}, status=202) @app.route('/api/runs/', methods=['GET']) @require_api_key def get_run(run_id): """Fetch a lab run by id (tenant-scoped).""" tenant_id = getattr(g, "tenant_id", "anonymous") run = lab_run_service.get_run(run_id, tenant_id) if not run: return error_response("run not found", code='NOT_FOUND', status=404) return success_response(run) @app.route('/api/runs', methods=['GET']) @require_api_key def list_runs(): """List recent lab runs for the current tenant.""" tenant_id = getattr(g, "tenant_id", "anonymous") limit = request.args.get("limit", 20, type=int) runs = lab_run_service.list_runs(tenant_id, limit=limit) return success_response({"runs": runs, "count": len(runs)}) def _admin_authorized() -> bool: admin_key = os.getenv("ADMIN_API_KEY", "") if not admin_key: return False return request.headers.get("X-Admin-Key", "") == admin_key @app.route('/api/admin/api-keys', methods=['POST']) @limiter.limit("30 per minute") def admin_create_api_key(): """Admin endpoint to create tenant API keys.""" if not _admin_authorized(): return error_response("unauthorized", code='UNAUTHORIZED', status=401) data = request.get_json(silent=True) or {} tenant_id = str(data.get("tenant_id", "")).strip() key_name = str(data.get("key_name", "")).strip() if not tenant_id: return error_response("tenant_id is required", code='BAD_REQUEST', status=400) plain_key = _create_api_key(tenant_id=tenant_id, key_name=key_name) return success_response({"tenant_id": tenant_id, "key_name": key_name, "api_key": plain_key}, status=201) @app.route('/api/admin/api-keys', methods=['GET']) @limiter.limit("60 per minute") def admin_list_api_keys(): """Admin endpoint to list tenants and key usage.""" if not _admin_authorized(): return error_response("unauthorized", code='UNAUTHORIZED', status=401) conn = _db_conn() try: cur = conn.cursor() cur.execute( """ SELECT tenant_id, key_name, is_active, created_at, last_used_at, usage_count FROM api_keys ORDER BY created_at DESC """ ) rows = cur.fetchall() finally: conn.close() return success_response( { "keys": [ { "tenant_id": r[0], "key_name": r[1], "is_active": bool(r[2]), "created_at": r[3], "last_used_at": r[4], "usage_count": int(r[5] or 0), } for r in rows ] } ) @app.route('/api/config/objectives', methods=['GET']) @limiter.exempt def get_config_objectives(): """Return available optimization objectives.""" return success_response({ 'objectives': [{'id': k, **v} for k, v in OBJECTIVES_CONFIG.items()] }) @app.route('/api/config/constraints', methods=['GET']) @require_api_key def get_constraints_schema(): """Return schema for Phase 2 portfolio constraints.""" return success_response({ 'sector_limits': { 'type': 'object', 'description': 'Max weight per sector, e.g. {"Technology": 0.30, "Finance": 0.25}', 'example': {'Technology': 0.30} }, 'sector_min': { 'type': 'object', 'description': 'Min weight per sector', 'example': {'Healthcare': 0.05} }, 'max_sector_weight': { 'type': 'number', 'description': 'Global cap for any sector (e.g. 0.40 = 40%)' }, 'cardinality': { 'type': 'integer', 'description': 'Exact number of positions (top-k heuristic)' }, 'min_cardinality': {'type': 'integer', 'description': 'Minimum number of positions'}, 'max_cardinality': {'type': 'integer', 'description': 'Maximum number of positions'}, 'blacklist': { 'type': 'array', 'items': {'type': 'string'}, 'description': 'Tickers to exclude' }, 'whitelist': { 'type': 'array', 'items': {'type': 'string'}, 'description': 'If non-empty, only these tickers allowed' }, 'turnover_budget': {'type': 'number', 'description': 'Max turnover per rebalance (e.g. 0.20)'} }) @app.route('/api/config/presets', methods=['GET']) @limiter.exempt def get_config_presets(): """Return available strategy presets.""" return success_response({ 'presets': [{'id': k, **v} for k, v in PRESETS_CONFIG.items()] }) @app.route('/api/config/ibm-quantum', methods=['POST']) @require_api_key def set_ibm_quantum_token(): """ Store an IBM Quantum API token and verify connectivity. Body: {"token": ""} Tenant: integration_effective_tenant_id() (X-Tenant-Id when using static API_KEY). On success returns {"ok": true, "backends": ["ibm_kyiv", ...]} On failure returns {"ok": false, "error": "..."} with HTTP 400. """ body = request.get_json(silent=True) or {} token = body.get('token', '') if not token: return error_response('token field is required', 400) tid = integration_effective_tenant_id() result = ibm_quantum_service.set_token(tid, token) if not result.get('ok'): return error_response(result.get('error', 'IBM Quantum connection failed'), 400) try: log_business_audit( "ibm_quantum_connect", {"tenant_id": tid}, {"ok": True, "n_backends": len(result.get("backends") or [])}, 200, ) except Exception: pass return success_response({**result, "tenant_id": tid}) @app.route('/api/config/ibm-quantum', methods=['DELETE']) @require_api_key def clear_ibm_quantum_token(): """Remove the stored IBM Quantum token for the effective tenant.""" tid = integration_effective_tenant_id() ibm_quantum_service.clear_token(tid) try: log_business_audit("ibm_quantum_disconnect", {"tenant_id": tid}, {"cleared": True}, 200) except Exception: pass return success_response({'cleared': True, 'tenant_id': tid}) @app.route('/api/config/ibm-quantum/status', methods=['GET']) @limiter.exempt def get_ibm_quantum_status(): """Return IBM Quantum connection status for the effective tenant.""" tid = integration_effective_tenant_id() return success_response(ibm_quantum_service.get_status(tid)) @app.route('/api/config/ibm-quantum/workloads', methods=['GET']) @require_api_key def get_ibm_quantum_workloads(): """ List recent IBM Quantum Runtime jobs for the effective tenant (requires API key). Query: limit (default 20, max 100). """ raw_limit = request.args.get('limit', default=20, type=int) if raw_limit is None: raw_limit = 20 tid = integration_effective_tenant_id() result = ibm_quantum_service.list_runtime_workloads(tid, limit=raw_limit) if not result.get('ok'): err = result.get('error') or 'Unable to list IBM Quantum workloads' err_l = err.lower() if 'not installed' in err_l: http_status = 503 elif not result.get('configured'): http_status = 400 else: http_status = 502 return error_response( err, code='IBM_WORKLOADS_UNAVAILABLE', status=http_status, ) try: log_business_audit( 'ibm_quantum_workloads_list', {'tenant_id': tid}, {'count': len(result.get('workloads') or [])}, 200, ) except Exception: pass return success_response(result) @app.route('/api/config/tenants', methods=['GET']) @limiter.exempt def list_integration_tenants(): """List tenant ids available for integration switching (admin static key) or current tenant.""" client_key = request.headers.get("X-API-Key", "") if API_KEY and client_key == API_KEY: from services.tenant_integrations import list_tenant_ids tenants = list_tenant_ids(_db_conn) return success_response( {"tenants": [{"id": t, "label": t} for t in tenants]} ) tenant = _lookup_tenant_by_key(client_key) if client_key else None if tenant: t = str(tenant["tenant_id"]) return success_response({"tenants": [{"id": t, "label": t}]}) return success_response({"tenants": [{"id": "default", "label": "Default"}]}) @app.route('/api/config/integrations', methods=['GET']) @limiter.exempt def get_integrations_catalog(): """IBM + Braket (and env) status for the effective tenant.""" from services.tenant_integrations import load_braket_metadata tid = integration_effective_tenant_id() ibm = ibm_quantum_service.get_status(tid) braket_env = os.getenv("BRAKET_ENABLED", "false").lower() == "true" br_meta = load_braket_metadata(_db_conn, tid) or {} return success_response( { "tenant_id": tid, "providers": [ { "id": "ibm", "label": "IBM Quantum", "configured": ibm.get("configured"), "backends": ibm.get("backends") or [], "error": ibm.get("error"), "tenant_id": ibm.get("tenant_id", tid), }, { "id": "braket", "label": "AWS Braket", "configured": braket_env, "env_enabled": braket_env, "tenant_preferences": br_meta, "note": "Annealing path uses BRAKET_* env; optional per-tenant JSON in DB.", }, ], } ) # ───────────────────────────────────────────────────────────────────────────── # JWT Authentication Endpoints # ───────────────────────────────────────────────────────────────────────────── @app.route('/api/auth/login', methods=['POST']) @limiter.limit("20 per minute") def login(): """ Authenticate user and return JWT tokens. For MVP: accepts any valid API key or demo credentials. In production: integrate with proper auth provider. """ data = request.json if not data: return error_response('Request body required', code='BAD_REQUEST', status=400) username = data.get('username', '') password = data.get('password', '') api_key = data.get('api_key', '') # Demo mode: accept any non-empty credentials if os.getenv('DEMO_MODE', 'true').lower() == 'true': if username or api_key: tenant_id = data.get('tenant_id', 'default') access = create_access_token(user_id=username or 'demo_user', tenant_id=tenant_id) refresh = create_refresh_token(user_id=username or 'demo_user', tenant_id=tenant_id) return success_response({ 'access_token': access, 'refresh_token': refresh, 'token_type': 'Bearer', 'expires_in': 3600, # 1 hour }) # Production mode: validate API key if api_key: tenant_info = _lookup_tenant_by_key(api_key) if tenant_info: access = create_access_token( user_id=tenant_info['tenant_id'], tenant_id=tenant_info['tenant_id'] ) refresh = create_refresh_token( user_id=tenant_info['tenant_id'], tenant_id=tenant_info['tenant_id'] ) return success_response({ 'access_token': access, 'refresh_token': refresh, 'token_type': 'Bearer', 'expires_in': 3600, }) return error_response('Invalid credentials', code='UNAUTHORIZED', status=401) @app.route('/api/auth/refresh', methods=['POST']) @limiter.limit("20 per minute") def refresh(): """Refresh access token using refresh token.""" data = request.json if not data: return error_response('Request body required', code='BAD_REQUEST', status=400) refresh_token = data.get('refresh_token') if not refresh_token: return error_response('Refresh token required', code='BAD_REQUEST', status=400) # Verify refresh token payload = verify_token(refresh_token) if not payload or payload.get('type') != 'refresh': return error_response('Invalid or expired refresh token', code='UNAUTHORIZED', status=401) user_id = payload.get('sub') tenant_id = payload.get('tenant_id', 'default') # Create new access token access = create_access_token(user_id=user_id, tenant_id=tenant_id) return success_response({ 'access_token': access, 'token_type': 'Bearer', 'expires_in': 3600, }) @app.route('/api/auth/logout', methods=['POST']) def logout(): """Logout and revoke current token.""" auth_header = request.headers.get('Authorization', '') if not auth_header or not auth_header.startswith('Bearer '): return error_response('Authorization header required', code='BAD_REQUEST', status=400) token = auth_header.split(' ')[1] if revoke_token(token): return success_response({'message': 'Successfully logged out'}) else: return error_response('Failed to revoke token', code='INTERNAL_ERROR', status=500) @app.route('/api/auth/me', methods=['GET']) def get_current_user_info(): """Get current authenticated user information.""" # Try JWT first if JWT_AVAILABLE: try: user = get_current_user() if user: return success_response(user) except Exception: pass # Fallback to API key auth api_key = request.headers.get('X-API-Key', '') if api_key: tenant_info = _lookup_tenant_by_key(api_key) if tenant_info: return success_response({ 'user_id': tenant_info['tenant_id'], 'tenant_id': tenant_info['tenant_id'], 'auth_method': 'api_key', }) return error_response('Not authenticated', code='UNAUTHORIZED', status=401) @app.route('/api/auth/api-keys', methods=['POST']) @require_api_key def create_api_key_endpoint(): """Create a new API key for tenant.""" data = request.json or {} tenant_id = data.get('tenant_id', getattr(g, 'tenant_id', 'default')) key_name = data.get('key_name', '') # Generate new API key plain_key = generate_api_key(tenant_id, key_name) key_hash = hash_api_key(plain_key) # Store in database try: conn = _db_conn() cur = conn.cursor() cur.execute( """ INSERT INTO api_keys (key_hash, tenant_id, key_name, is_active, created_at) VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP) """, (key_hash, tenant_id, key_name or "") ) conn.commit() conn.close() return success_response({ 'api_key': plain_key, 'key_name': key_name, 'tenant_id': tenant_id, 'message': 'Store this key securely - it cannot be retrieved again', }) except Exception as e: logger.error(f"Failed to create API key: {e}") return error_response(f'Failed to create API key: {str(e)}', code='INTERNAL_ERROR', status=500) # ───────────────────────────────────────────────────────────────────────────── # Ticker catalog & search # ───────────────────────────────────────────────────────────────────────────── _TICKER_CATALOG = [ {"symbol":"SPY","name":"SPDR S&P 500 ETF","sector":"Broad Market","type":"etf"}, {"symbol":"QQQ","name":"Invesco Nasdaq 100 ETF","sector":"Broad Market","type":"etf"}, {"symbol":"DIA","name":"SPDR Dow Jones Industrial ETF","sector":"Broad Market","type":"etf"}, {"symbol":"IWM","name":"iShares Russell 2000 ETF","sector":"Broad Market","type":"etf"}, {"symbol":"VTI","name":"Vanguard Total Stock Market ETF","sector":"Broad Market","type":"etf"}, {"symbol":"VOO","name":"Vanguard S&P 500 ETF","sector":"Broad Market","type":"etf"}, {"symbol":"VEA","name":"Vanguard FTSE Developed Markets","sector":"International","type":"etf"}, {"symbol":"VWO","name":"Vanguard FTSE Emerging Markets","sector":"International","type":"etf"}, {"symbol":"BND","name":"Vanguard Total Bond Market ETF","sector":"Fixed Income","type":"etf"}, {"symbol":"AGG","name":"iShares Core US Aggregate Bond","sector":"Fixed Income","type":"etf"}, {"symbol":"TLT","name":"iShares 20+ Year Treasury Bond","sector":"Fixed Income","type":"etf"}, {"symbol":"GLD","name":"SPDR Gold Shares","sector":"Commodities","type":"etf"}, {"symbol":"XLK","name":"Technology Select Sector SPDR","sector":"Technology","type":"etf"}, {"symbol":"XLF","name":"Financial Select Sector SPDR","sector":"Financials","type":"etf"}, {"symbol":"XLE","name":"Energy Select Sector SPDR","sector":"Energy","type":"etf"}, {"symbol":"XLV","name":"Health Care Select Sector SPDR","sector":"Healthcare","type":"etf"}, {"symbol":"AAPL","name":"Apple Inc.","sector":"Technology","type":"stock"}, {"symbol":"MSFT","name":"Microsoft Corporation","sector":"Technology","type":"stock"}, {"symbol":"GOOGL","name":"Alphabet Inc.","sector":"Technology","type":"stock"}, {"symbol":"AMZN","name":"Amazon.com Inc.","sector":"Consumer Disc","type":"stock"}, {"symbol":"NVDA","name":"NVIDIA Corporation","sector":"Technology","type":"stock"}, {"symbol":"META","name":"Meta Platforms Inc.","sector":"Communication","type":"stock"}, {"symbol":"TSLA","name":"Tesla Inc.","sector":"Consumer Disc","type":"stock"}, {"symbol":"JPM","name":"JPMorgan Chase & Co.","sector":"Financials","type":"stock"}, {"symbol":"V","name":"Visa Inc.","sector":"Financials","type":"stock"}, {"symbol":"JNJ","name":"Johnson & Johnson","sector":"Healthcare","type":"stock"}, {"symbol":"UNH","name":"UnitedHealth Group Inc.","sector":"Healthcare","type":"stock"}, {"symbol":"HD","name":"Home Depot Inc.","sector":"Consumer Disc","type":"stock"}, {"symbol":"PG","name":"Procter & Gamble Co.","sector":"Consumer Stpl","type":"stock"}, {"symbol":"MA","name":"Mastercard Inc.","sector":"Financials","type":"stock"}, {"symbol":"BAC","name":"Bank of America Corp.","sector":"Financials","type":"stock"}, {"symbol":"XOM","name":"Exxon Mobil Corporation","sector":"Energy","type":"stock"}, {"symbol":"CVX","name":"Chevron Corporation","sector":"Energy","type":"stock"}, {"symbol":"WMT","name":"Walmart Inc.","sector":"Consumer Stpl","type":"stock"}, {"symbol":"KO","name":"Coca-Cola Company","sector":"Consumer Stpl","type":"stock"}, {"symbol":"PFE","name":"Pfizer Inc.","sector":"Healthcare","type":"stock"}, {"symbol":"ABBV","name":"AbbVie Inc.","sector":"Healthcare","type":"stock"}, {"symbol":"MRK","name":"Merck & Co. Inc.","sector":"Healthcare","type":"stock"}, {"symbol":"NFLX","name":"Netflix Inc.","sector":"Communication","type":"stock"}, {"symbol":"DIS","name":"Walt Disney Company","sector":"Communication","type":"stock"}, {"symbol":"ADBE","name":"Adobe Inc.","sector":"Technology","type":"stock"}, {"symbol":"CRM","name":"Salesforce Inc.","sector":"Technology","type":"stock"}, {"symbol":"AMD","name":"Advanced Micro Devices Inc.","sector":"Technology","type":"stock"}, {"symbol":"INTC","name":"Intel Corporation","sector":"Technology","type":"stock"}, {"symbol":"CSCO","name":"Cisco Systems Inc.","sector":"Technology","type":"stock"}, {"symbol":"NKE","name":"Nike Inc.","sector":"Consumer Disc","type":"stock"}, {"symbol":"PEP","name":"PepsiCo Inc.","sector":"Consumer Stpl","type":"stock"}, {"symbol":"T","name":"AT&T Inc.","sector":"Communication","type":"stock"}, {"symbol":"VZ","name":"Verizon Communications Inc.","sector":"Communication","type":"stock"}, {"symbol":"PYPL","name":"PayPal Holdings Inc.","sector":"Financials","type":"stock"}, ] @app.route('/api/tickers/search', methods=['GET']) @require_api_key @limiter.limit("30 per minute") def search_tickers(): """ Search the ticker catalog by query string. Query params: q (search query), limit (max results, default 15). Returns matches sorted: exact symbol > symbol prefix > name/sector match. """ q = request.args.get('q', '').strip() limit = min(int(request.args.get('limit', 15)), 50) if not q: return success_response({"results": _TICKER_CATALOG[:limit]}) q_upper = q.upper() q_lower = q.lower() exact, prefix, name_match = [], [], [] for entry in _TICKER_CATALOG: if entry["symbol"] == q_upper: exact.append(entry) elif entry["symbol"].startswith(q_upper): prefix.append(entry) elif q_lower in entry["name"].lower() or q_lower in entry["sector"].lower(): name_match.append(entry) results = (exact + prefix + name_match)[:limit] if not results and len(q_upper) <= 10 and q_upper.isalpha(): results = [{"symbol": q_upper, "name": q_upper, "sector": "Unknown", "type": "custom"}] return success_response({"results": results}) @app.route('/api/portfolio/efficient-frontier', methods=['POST']) @require_api_key @limiter.limit("10 per minute") def get_efficient_frontier(): """Endpoint to compute the efficient frontier.""" try: data = request.json if not data: return error_response('Request body is required', code='BAD_REQUEST', status=400) # Extract parameters tickers = data.get('tickers', []) start_date = data.get('start_date', None) end_date = data.get('end_date', None) n_points = data.get('n_points', 15) # Number of points on the frontier # Prefer direct matrix input for production; support ticker fallback. if data.get("returns") is not None and data.get("covariance") is not None: payload = load_market_payload(data) returns = payload.returns covariance = payload.covariance tickers = payload.tickers else: if not tickers: return error_response('Tickers parameter is required (or provide returns/covariance)', code='BAD_REQUEST', status=400) # Validate dates if provided if start_date and end_date: try: start_dt = datetime.strptime(start_date, '%Y-%m-%d') end_dt = datetime.strptime(end_date, '%Y-%m-%d') except ValueError: return error_response('Invalid date format. Use YYYY-MM-DD.', code='BAD_REQUEST', status=400) if start_dt >= end_dt: return error_response('Start date must be before end date', code='BAD_REQUEST', status=400) # Fetch market data (dates default to 1y if not provided) market_data = fetch_market_data( tickers=tickers, start_date=start_date, end_date=end_date ) returns = np.array(market_data['returns']) covariance = np.array(market_data['covariance']) # Calculate efficient frontier using the portfolio optimizer function from core.portfolio_optimizer import compute_efficient_frontier frontier_points = compute_efficient_frontier(returns, covariance, n_points) # Calculate min and max possible returns for the range min_return = np.min([point['target_return'] for point in frontier_points]) if frontier_points else 0 max_return = np.max([point['target_return'] for point in frontier_points]) if frontier_points else 0 return success_response({ 'frontier_points': frontier_points, 'min_return': float(min_return), 'max_return': float(max_return), 'tickers': tickers }) except Exception as e: logger.error(f"Efficient frontier endpoint error: {str(e)}", exc_info=True) return error_response(f'Internal server error: {str(e)}', code='INTERNAL_ERROR', status=500) @app.route('/api/health', methods=['GET']) @limiter.exempt def health_check(): """Enhanced health check -- reports status of API and optional dependencies.""" import importlib checks = {'api': 'ok'} overall = 'healthy' details = { 'version': '1.0.0', 'timestamp': datetime.now(timezone.utc).isoformat(), } # Check Redis if configured redis_host = os.getenv('REDIS_HOST') if redis_host: try: import redis r = redis.Redis(host=redis_host, port=int(os.getenv('REDIS_PORT', 6379)), socket_timeout=2) r.ping() checks['redis'] = 'ok' details['redis_host'] = redis_host except Exception as e: checks['redis'] = 'unavailable' overall = 'degraded' details['redis_error'] = str(e) # Check DB if configured db_url = os.getenv('DATABASE_URL') if db_url: try: if 'postgresql' in db_url: import psycopg2 conn = psycopg2.connect(db_url, connect_timeout=2) conn.close() checks['database'] = 'ok' elif 'sqlite' in db_url: # SQLite - just check file exists or can be created db_path = db_url.replace('sqlite:///', '') if db_path: checks['database'] = 'ok' if os.path.exists(os.path.dirname(db_path)) or db_path == ':memory:' else 'unavailable' else: checks['database'] = 'ok' except Exception as e: checks['database'] = 'unavailable' overall = 'degraded' details['database_error'] = str(e) # Check Braket availability try: import braket checks['braket_sdk'] = 'installed' details['braket_enabled'] = os.getenv('BRAKET_ENABLED', 'false').lower() == 'true' except ImportError: checks['braket_sdk'] = 'not_installed' details['braket_enabled'] = False # Check critical dependencies critical_deps = ['numpy', 'pandas', 'scipy', 'flask', 'sklearn'] missing_deps = [] for dep in critical_deps: try: importlib.import_module(dep) except ImportError: missing_deps.append(dep) overall = 'degraded' if missing_deps: checks['dependencies'] = 'missing' details['missing_dependencies'] = missing_deps else: checks['dependencies'] = 'ok' # Check market data providers try: mdp = MarketDataProvider() available = mdp.get_available_providers() checks['market_data'] = 'available' details['data_provider'] = mdp.primary_provider details['available_providers'] = available except Exception: checks['market_data'] = 'degraded' details['data_provider_note'] = 'Provider initialization failed' # System info import psutil details['system'] = { 'cpu_percent': psutil.cpu_percent(interval=0.1), 'memory_percent': psutil.virtual_memory().percent, } return success_response({ 'status': overall, 'checks': checks, 'details': details, 'cache_entries': len(_market_data_cache), 'message': 'Quantum Portfolio Backend is running', }) @app.route('/metrics', methods=['GET']) @limiter.exempt def prometheus_metrics(): """Expose Prometheus metrics.""" from flask import Response return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST) @app.route('/api/docs/openapi', methods=['GET']) @limiter.exempt def openapi_spec(): """Serve OpenAPI specification document.""" spec_path = os.path.join(os.path.dirname(__file__), "docs", "openapi.yaml") if not os.path.exists(spec_path): return error_response("OpenAPI spec not found", code='NOT_FOUND', status=404) with open(spec_path, "r", encoding="utf-8") as f: content = f.read() from flask import Response return Response(content, mimetype="application/yaml") # ─── Export & Audit Endpoints ─── @app.route('/api/export/audit-log', methods=['GET']) @require_api_key @limiter.limit("5 per minute") def export_audit_log(): """Export audit_log rows as JSON array. Query params: limit (default 100), offset (default 0).""" try: limit = min(int(request.args.get('limit', 100)), 1000) offset = int(request.args.get('offset', 0)) db_path = os.path.join(os.path.dirname(__file__), "data", "api.sqlite3") conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, offset), ).fetchall() conn.close() return success_response([dict(r) for r in rows]) except Exception as exc: return error_response(str(exc), code='EXPORT_ERROR', status=500) @app.route('/api/export/audit-log/csv', methods=['GET']) @require_api_key @limiter.limit("5 per minute") def export_audit_log_csv(): """Export audit_log as CSV download.""" import csv import io try: limit = min(int(request.args.get('limit', 500)), 5000) db_path = os.path.join(os.path.dirname(__file__), "data", "api.sqlite3") conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?", (limit,), ).fetchall() conn.close() output = io.StringIO() if rows: writer = csv.DictWriter(output, fieldnames=rows[0].keys()) writer.writeheader() for r in rows: writer.writerow(dict(r)) from flask import Response return Response( output.getvalue(), mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename=audit_log.csv'}, ) except Exception as exc: return error_response(str(exc), code='EXPORT_ERROR', status=500) @app.route('/api/export/config', methods=['GET']) @limiter.exempt def export_config_manifest(): """Export current objectives and presets as a YAML-style JSON manifest.""" return success_response({ "objectives": OBJECTIVES_CONFIG, "presets": PRESETS_CONFIG, "version": "1.0", "engine": "quantum_ledger", }) if __name__ == '__main__': port = int(os.getenv('PORT', 5000)) app.run(debug=True, host='0.0.0.0', port=port)