""" Feature Store — Central registry for computing and caching all features. Single entry point for the signal engine to get a complete feature set. """ import logging import time import pandas as pd from backend.data.market_data import fetch_ohlcv from backend.features.fundamental import compute_fundamental_features from backend.features.momentum import compute_momentum_features from backend.features.technical import compute_technical_features from backend.features.volatility import compute_volatility_features logger = logging.getLogger(__name__) def compute_all_features(ticker: str, period: str = "1y") -> dict: """ Compute ALL features for a single ticker. Returns a dict with: - 'df': Full feature DataFrame (time series) - 'latest': Latest row as a flat dict (for signal engine) - 'fundamentals': Fundamental metrics dict - 'ticker': The ticker symbol - 'computed_at': Timestamp """ start = time.time() logger.info(f"{ticker}: Computing all features...") # 1. Fetch OHLCV ohlcv = fetch_ohlcv(ticker, period=period) if ohlcv.empty: logger.error(f"{ticker}: No OHLCV data — cannot compute features") return {"ticker": ticker, "error": "no_data"} # 2. Technical features df = compute_technical_features(ohlcv, ticker) # 3. Momentum features df = compute_momentum_features(df, ticker) # 4. Volatility features df = compute_volatility_features(df, ticker) # 5. Fundamentals (separate — not time-series) fundamentals = compute_fundamental_features(ticker) # 6. Extract latest row as flat dict latest = {} if not df.empty: last_row = df.iloc[-1] for k, v in last_row.items(): try: if pd.notna(v): latest[k] = round(float(v), 4) if isinstance(v, (int, float)) else str(v) else: latest[k] = None except (TypeError, ValueError): latest[k] = str(v) if v is not None else None # Add fundamental score to latest latest["fundamental_score"] = fundamentals.get("fundamental_score", 50.0) latest["sector"] = fundamentals.get("sector", "Unknown") latest["industry"] = fundamentals.get("industry", "Unknown") latest["market_cap"] = fundamentals.get("market_cap") latest["pe_ratio"] = fundamentals.get("pe_ratio") elapsed = round(time.time() - start, 2) logger.info(f"{ticker}: All features computed in {elapsed}s ({len(df)} rows, {len(df.columns)} cols)") return { "ticker": ticker, "df": df, "latest": latest, "fundamentals": fundamentals, "computed_at": time.time(), "elapsed_seconds": elapsed, } def compute_universe_features(tickers: list[str], period: str = "1y") -> list[dict]: """ Compute features for an entire universe of tickers. Returns a list of feature dicts (one per ticker). """ results = [] total = len(tickers) for i, ticker in enumerate(tickers): try: result = compute_all_features(ticker, period) if "error" not in result: results.append(result) else: logger.warning(f"{ticker}: Skipped — {result.get('error')}") except Exception as e: logger.error(f"{ticker}: Feature computation failed: {e}") if (i + 1) % 10 == 0: logger.info(f"Universe progress: {i + 1}/{total} tickers processed") logger.info(f"Universe features complete: {len(results)}/{total} successful") return results def get_cross_sectional_matrix(feature_results: list[dict], columns: list[str] | None = None) -> pd.DataFrame: """ Build a cross-sectional matrix (tickers × features) from computed results. Useful for ranking and comparison. """ rows = [] for result in feature_results: latest = result.get("latest", {}) latest["ticker"] = result["ticker"] rows.append(latest) df = pd.DataFrame(rows) if columns and not df.empty: available = [c for c in columns if c in df.columns] df = df[["ticker"] + available] return df