Spaces:
Running
Running
| """ | |
| 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 | |
| } | |