""" Phase 3 — Performance Engine Reads all history JSON files, calculates BUY signal returns vs. Nifty 50, and computes a composite AI Score. """ import os import json import re import pandas as pd from datetime import datetime try: from modules.price_fetcher import fetch_current_prices, fetch_nifty50_current except ImportError: from price_fetcher import fetch_current_prices, fetch_nifty50_current # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def _parse_nifty_from_market_summary(market_summary: list) -> float | None: """ Extract a numeric Nifty 50 value from the market_summary list. Example entry: {"label": "Nifty 50", "value": "23,050.20 (+0.25%)"} """ for item in market_summary: label = item.get("label", "").lower() if "nifty 50" in label or "nifty50" in label: raw = item.get("value", "") # Strip commas, extract first numeric value match = re.search(r"[\d,]+\.?\d*", raw.replace(",", "")) if match: try: return float(match.group()) except ValueError: pass return None def _load_signal_prices_from_snapshot(data: dict) -> dict: """ Build _signal_prices by cross-referencing portfolio signals with _portfolio_snapshot LTPs. Used for backfilling old history files that lack _signal_prices. Returns dict: { symbol: { signal, price_on_day, qty, nifty_on_day } } """ snapshot = data.get("_portfolio_snapshot", []) portfolio_signals = data.get("portfolio", []) market_summary = data.get("market_summary", []) ltp_map = {} qty_map = {} for item in snapshot: sym = item.get("symbol") if sym: ltp_map[sym] = float(item.get("ltp") or 0) qty_map[sym] = float(item.get("qty") or 0) nifty_on_day = _parse_nifty_from_market_summary(market_summary) result = {} for entry in portfolio_signals: sym = entry.get("symbol") sig = entry.get("signal", "") if sym and sig == "BUY" and sym in ltp_map and ltp_map[sym] > 0: result[sym] = { "signal": sig, "price_on_day": ltp_map[sym], "qty": qty_map.get(sym, 1.0), "nifty_on_day": nifty_on_day, } return result # ───────────────────────────────────────────────────────────────────────────── # Core Loader # ───────────────────────────────────────────────────────────────────────────── def load_all_buy_signals(history_dir: str = "history") -> list[dict]: """ Read every history JSON or database record and collect all BUY signals with entry prices. Returns list of dicts: { date, symbol, signal, price_on_day, qty, nifty_on_day } """ # Try DB load first try: from modules.db import load_all_buy_signals_from_db db_rows = load_all_buy_signals_from_db() if db_rows: return db_rows except Exception as e: print(f"[performance] DB load failed, falling back to files: {e}") if not os.path.exists(history_dir): return [] files = sorted([f for f in os.listdir(history_dir) if f.endswith(".json")]) rows = [] for filename in files: date_str = filename.replace(".json", "") path = os.path.join(history_dir, filename) try: with open(path, "r", encoding="utf-8") as fp: data = json.load(fp) except Exception as e: print(f"[performance] Could not load {filename}: {e}") continue # Use stored _signal_prices if present, else derive from snapshot signal_prices = data.get("_signal_prices") if not signal_prices: signal_prices = _load_signal_prices_from_snapshot(data) for sym, sp in signal_prices.items(): if sp.get("signal") == "BUY" and sp.get("price_on_day", 0) > 0: rows.append({ "date": date_str, "symbol": sym, "signal": "BUY", "price_on_day": float(sp["price_on_day"]), "qty": float(sp.get("qty", 1.0)), "nifty_on_day": sp.get("nifty_on_day"), # may be None }) return rows # ───────────────────────────────────────────────────────────────────────────── # Main Computation # ───────────────────────────────────────────────────────────────────────────── def compute_performance(history_dir: str = "history") -> tuple[pd.DataFrame, dict]: """ Full performance computation. Returns: (signals_df, summary_dict) signals_df columns: date, symbol, signal, price_on_day, qty, nifty_on_day, current_price, current_nifty, stock_return_pct, nifty_return_pct, alpha_pct, beat_market, invested_value, current_value summary_dict keys: ai_score, buy_win_rate, avg_alpha, total_signals, total_invested, total_current_value, total_gain_loss, portfolio_return_pct, nifty_return_pct """ rows = load_all_buy_signals(history_dir) if not rows: return pd.DataFrame(), {} df = pd.DataFrame(rows) # ── Fetch current prices ────────────────────────────────────────────────── all_symbols = df["symbol"].unique().tolist() current_prices = fetch_current_prices(all_symbols) current_nifty = fetch_nifty50_current() df["current_price"] = df["symbol"].map(current_prices) df["current_nifty"] = current_nifty # ── Drop rows where we couldn't get live prices ─────────────────────────── df = df.dropna(subset=["current_price"]) df = df[df["current_price"] > 0] if df.empty: return pd.DataFrame(), {} # ── Return calculations ─────────────────────────────────────────────────── df["stock_return_pct"] = ((df["current_price"] - df["price_on_day"]) / df["price_on_day"]) * 100 # Nifty return: use stored nifty_on_day if available, else default to None df["nifty_return_pct"] = df.apply( lambda r: ((current_nifty - r["nifty_on_day"]) / r["nifty_on_day"]) * 100 if (r["nifty_on_day"] and current_nifty and r["nifty_on_day"] > 0) else None, axis=1 ) df["alpha_pct"] = df.apply( lambda r: r["stock_return_pct"] - r["nifty_return_pct"] if r["nifty_return_pct"] is not None else None, axis=1 ) df["beat_market"] = df.apply( lambda r: r["alpha_pct"] > 0 if r["alpha_pct"] is not None else None, axis=1 ) # ── Portfolio value tracking (qty-weighted) ─────────────────────────────── df["invested_value"] = df["qty"] * df["price_on_day"] df["current_value"] = df["qty"] * df["current_price"] # ── Summary metrics ─────────────────────────────────────────────────────── valid_alpha = df.dropna(subset=["alpha_pct"]) beats = valid_alpha[valid_alpha["beat_market"] == True] buy_win_rate = (len(beats) / len(valid_alpha) * 100) if len(valid_alpha) > 0 else 0 avg_alpha = float(valid_alpha["alpha_pct"].mean()) if len(valid_alpha) > 0 else 0 total_invested = float(df["invested_value"].sum()) total_current_val = float(df["current_value"].sum()) total_gain_loss = total_current_val - total_invested portfolio_return = ((total_current_val - total_invested) / total_invested * 100) if total_invested > 0 else 0 # Nifty return — use average nifty_on_day across signals weighted by invested value weighted_nifty_rows = df.dropna(subset=["nifty_on_day"]) if current_nifty and not weighted_nifty_rows.empty: total_w = weighted_nifty_rows["invested_value"].sum() weighted_nifty_base = ( (weighted_nifty_rows["nifty_on_day"] * weighted_nifty_rows["invested_value"]).sum() / total_w if total_w > 0 else weighted_nifty_rows["nifty_on_day"].mean() ) nifty_return = ((current_nifty - weighted_nifty_base) / weighted_nifty_base) * 100 else: nifty_return = None # AI Score: buy win rate (60%) + avg alpha clamped (40%) alpha_score = min(max((avg_alpha + 5) / 10 * 100, 0), 100) # +5% alpha → 100, -5% → 0 ai_score = (buy_win_rate * 0.6) + (alpha_score * 0.4) ai_score = round(min(max(ai_score, 0), 100), 1) summary = { "ai_score": ai_score, "buy_win_rate": round(buy_win_rate, 1), "avg_alpha": round(avg_alpha, 2), "total_signals": int(len(df)), "signals_beating": int(len(beats)), "total_invested": round(total_invested, 2), "total_current_value": round(total_current_val, 2), "total_gain_loss": round(total_gain_loss, 2), "portfolio_return_pct": round(portfolio_return, 2), "nifty_return_pct": round(float(nifty_return), 2) if nifty_return is not None else None, "current_nifty": round(float(current_nifty), 2) if current_nifty else None, "last_updated": datetime.now().isoformat(), } return df, summary # ───────────────────────────────────────────────────────────────────────────── # Cumulative Chart Data # ───────────────────────────────────────────────────────────────────────────── def build_cumulative_chart_data(df: pd.DataFrame, summary: dict) -> pd.DataFrame: """ Build a DataFrame for the dual-line cumulative return chart. Each row = one unique signal date, with: date, portiq_cumulative_pct, nifty_cumulative_pct Strategy: equal-time-point comparison — for each date, compute the average stock return of BUYs issued on or before that date. """ if df.empty: return pd.DataFrame() dates = sorted(df["date"].unique()) chart_rows = [] for d in dates: # All BUY signals issued on or before this date subset = df[df["date"] <= d] if subset.empty: continue avg_stock_ret = float(subset["stock_return_pct"].mean()) nifty_vals = subset.dropna(subset=["nifty_return_pct"]) avg_nifty_ret = float(nifty_vals["nifty_return_pct"].mean()) if not nifty_vals.empty else None chart_rows.append({ "date": d, "portiq_return_pct": round(avg_stock_ret, 2), "nifty_return_pct": round(avg_nifty_ret, 2) if avg_nifty_ret is not None else None, }) return pd.DataFrame(chart_rows) # ───────────────────────────────────────────────────────────────────────────── # Cache persistence # ───────────────────────────────────────────────────────────────────────────── def save_performance_cache(df: pd.DataFrame, summary: dict): """Persist performance results to PostgreSQL database for fast re-loads.""" from modules.db import get_db_connection, get_owner_user_id, clean_nans from psycopg2.extras import Json payload = { "summary": summary, "signals": df.to_dict(orient="records") if not df.empty else [], } payload = clean_nans(payload) owner_id = get_owner_user_id() conn = get_db_connection() try: with conn.cursor() as cur: cur.execute( """ INSERT INTO performance_cache (user_id, payload, updated_at) VALUES (%s, %s, CURRENT_TIMESTAMP) ON CONFLICT (user_id) DO UPDATE SET payload = EXCLUDED.payload, updated_at = CURRENT_TIMESTAMP; """, (owner_id, Json(payload)) ) conn.commit() print("[performance] Saved performance metrics cache to database.") except Exception as e: conn.rollback() print(f"[performance] Failed to save DB cache: {e}") finally: conn.close() def load_performance_cache() -> tuple[pd.DataFrame, dict]: """Load cached performance data from PostgreSQL if it exists and is fresh (< 4 hours old).""" from modules.db import get_db_connection, get_owner_user_id from datetime import datetime, timezone owner_id = get_owner_user_id() conn = get_db_connection() try: with conn.cursor() as cur: cur.execute( "SELECT payload, updated_at FROM performance_cache WHERE user_id = %s;", (owner_id,) ) row = cur.fetchone() if not row: return pd.DataFrame(), {} payload, updated_at = row # Calculate cache age from timezone-aware updated_at age = (datetime.now(timezone.utc) - updated_at).total_seconds() if age > 4 * 3600: # Stale after 4 hours return pd.DataFrame(), {} summary = payload.get("summary", {}) signals = payload.get("signals", []) df = pd.DataFrame(signals) if signals else pd.DataFrame() return df, summary except Exception as e: print(f"[performance] Database cache load failed: {e}") return pd.DataFrame(), {} finally: conn.close() def compute_actual_trades_performance(owner_id) -> dict: """ Computes performance benchmarking for all actual trades. Compares trade return vs nifty return, gets comparison list of skipped AI suggestions, and calculates portfolio metrics. """ from modules.db import load_actual_trades_from_db, get_ai_suggestions_for_date trades = load_actual_trades_from_db(owner_id) if not trades: return { "trades": [], "comparison": [], "summary": { "total_trades": 0, "total_invested": 0.0, "total_current_value": 0.0, "total_gain_loss": 0.0, "portfolio_return_pct": 0.0, "nifty_return_pct": 0.0, "alpha_pct": 0.0, "win_rate": 0.0, "ai_suggested_count": 0, "you_bought_count": 0, "coverage_pct": 0.0, } } # Get current prices for all trade symbols trade_symbols = list(set(t["symbol"] for t in trades)) # Also find all AI suggestions on trade dates to include in comparison & coverage unique_dates = list(set(t["trade_date"] for t in trades)) ai_suggestions_by_date = {} all_comparison_symbols = set(trade_symbols) for d in unique_dates: sugs = get_ai_suggestions_for_date(owner_id, d) # Keep only BUY signals buy_sugs = [s for s in sugs if s["signal"] == "BUY"] ai_suggestions_by_date[d] = buy_sugs for s in buy_sugs: all_comparison_symbols.add(s["symbol"]) # Fetch live prices for all comparison symbols all_symbols_list = list(all_comparison_symbols) current_prices = fetch_current_prices(all_symbols_list) current_nifty = fetch_nifty50_current() or 23000.0 # Calculate returns for actual trades processed_trades = [] total_invested = 0.0 total_current_value = 0.0 winning_trades_count = 0 trades_with_nifty_count = 0 sum_alpha = 0.0 # Map trade_date and symbol to the trade for easy checking trade_map = {(t["trade_date"], t["symbol"]): t for t in trades} for t in trades: sym = t["symbol"] qty = t["qty_bought"] buy_price = t["buy_price"] nifty_entry = t["nifty_on_trade_day"] curr_price = current_prices.get(sym) if curr_price is None or curr_price <= 0: curr_price = buy_price stock_ret = ((curr_price - buy_price) / buy_price) * 100 if nifty_entry and nifty_entry > 0: nifty_ret = ((current_nifty - nifty_entry) / nifty_entry) * 100 alpha = stock_ret - nifty_ret beat_market = alpha > 0 trades_with_nifty_count += 1 sum_alpha += alpha else: nifty_ret = None alpha = None beat_market = None invested = qty * buy_price curr_val = qty * curr_price gain_loss = curr_val - invested total_invested += invested total_current_value += curr_val if beat_market: winning_trades_count += 1 processed_trades.append({ "trade_date": t["trade_date"], "symbol": sym, "qty_bought": qty, "buy_price": buy_price, "current_price": curr_price, "nifty_on_trade_day": nifty_entry, "stock_return_pct": round(stock_ret, 2), "nifty_return_pct": round(nifty_ret, 2) if nifty_ret is not None else None, "alpha_pct": round(alpha, 2) if alpha is not None else None, "beat_market": beat_market, "invested": round(invested, 2), "current_value": round(curr_val, 2), "gain_loss": round(gain_loss, 2) }) # Calculate comparison table comparison_list = [] ai_suggested_count = 0 you_bought_count = 0 for d in unique_dates: buy_sugs = ai_suggestions_by_date.get(d, []) for sug in buy_sugs: sym = sug["symbol"] price_on_day = sug["price_on_day"] ai_suggested_count += 1 trade_key = (d, sym) bought = trade_key in trade_map curr_price = current_prices.get(sym) or price_on_day # Fetch nifty_on_day for the date from the trade (if any trade has it) nifty_entry = None trades_on_date = [tr for tr in trades if tr["trade_date"] == d] if trades_on_date: nifty_entry = trades_on_date[0]["nifty_on_trade_day"] if bought: you_bought_count += 1 tr = trade_map[trade_key] qty = tr["qty_bought"] buy_price = tr["buy_price"] nifty_entry = tr["nifty_on_trade_day"] or nifty_entry stock_ret = ((curr_price - buy_price) / buy_price) * 100 invested = qty * buy_price curr_val = qty * curr_price gain_loss = curr_val - invested else: qty = 0.0 buy_price = None stock_ret = ((curr_price - price_on_day) / price_on_day) * 100 invested = 0.0 curr_val = 0.0 gain_loss = 0.0 if nifty_entry and nifty_entry > 0: nifty_ret = ((current_nifty - nifty_entry) / nifty_entry) * 100 alpha = stock_ret - nifty_ret else: nifty_ret = None alpha = None comparison_list.append({ "date": d, "symbol": sym, "signal": "BUY", "price_on_day": price_on_day, "nifty_on_day": nifty_entry, "bought": bought, "qty_bought": qty, "buy_price": buy_price, "current_price": curr_price, "stock_return_pct": round(stock_ret, 2), "nifty_return_pct": round(nifty_ret, 2) if nifty_ret is not None else None, "alpha_pct": round(alpha, 2) if alpha is not None else None, "invested": round(invested, 2), "current_value": round(curr_val, 2), "gain_loss": round(gain_loss, 2) }) total_gain_loss = total_current_value - total_invested portfolio_return = (total_gain_loss / total_invested * 100) if total_invested > 0 else 0.0 nifty_return = None trades_with_nifty = [pt for pt in processed_trades if pt["nifty_on_trade_day"] is not None] if current_nifty and trades_with_nifty: total_w = sum(pt["invested"] for pt in trades_with_nifty) if total_w > 0: weighted_nifty_base = sum(pt["nifty_on_trade_day"] * pt["invested"] for pt in trades_with_nifty) / total_w else: weighted_nifty_base = sum(pt["nifty_on_trade_day"] for pt in trades_with_nifty) / len(trades_with_nifty) nifty_return = ((current_nifty - weighted_nifty_base) / weighted_nifty_base) * 100 win_rate = (winning_trades_count / len(processed_trades) * 100) if processed_trades else 0.0 coverage_pct = (you_bought_count / ai_suggested_count * 100) if ai_suggested_count > 0 else 0.0 avg_alpha = (sum_alpha / trades_with_nifty_count) if trades_with_nifty_count > 0 else 0.0 summary = { "total_trades": len(processed_trades), "total_invested": round(total_invested, 2), "total_current_value": round(total_current_value, 2), "total_gain_loss": round(total_gain_loss, 2), "portfolio_return_pct": round(portfolio_return, 2), "nifty_return_pct": round(nifty_return, 2) if nifty_return is not None else None, "alpha_pct": round(avg_alpha, 2), "win_rate": round(win_rate, 1), "ai_suggested_count": ai_suggested_count, "you_bought_count": you_bought_count, "coverage_pct": round(coverage_pct, 1), } return { "trades": processed_trades, "comparison": comparison_list, "summary": summary }