Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pandas as pd | |
| import numpy as np | |
| import os | |
| import sys | |
| import requests | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| from datetime import datetime | |
| import subprocess | |
| import time | |
| # --- Auto-Start FastAPI Backend Server --- | |
| def auto_start_backend(): | |
| try: | |
| # Check if backend is already online | |
| res = requests.get("http://127.0.0.1:8000/health", timeout=0.5) | |
| if res.status_code == 200: | |
| return | |
| except Exception: | |
| pass | |
| try: | |
| # Resolve absolute project root directory to ensure correct imports | |
| project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) | |
| # Launch Uvicorn asynchronously in the background using the current virtualenv Python interpreter | |
| subprocess.Popen( | |
| [sys.executable, "-m", "uvicorn", "src.api.main:app", "--host", "127.0.0.1", "--port", "8000"], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| cwd=project_root | |
| ) | |
| # Allow the API port to bind and connect to database | |
| time.sleep(2.0) | |
| except Exception: | |
| pass | |
| auto_start_backend() | |
| # Configure page | |
| st.set_page_config( | |
| page_title="QuantMacro India — Sector Intelligence & Macro Analytics Engine", | |
| layout="wide", | |
| page_icon="📈" | |
| ) | |
| # Custom Premium Styling | |
| st.markdown(""" | |
| <style> | |
| .stApp { | |
| background-color: #0d0f14; | |
| color: #e2e8f0; | |
| font-family: 'Outfit', 'Inter', sans-serif; | |
| } | |
| .main-header { | |
| font-size: 2.2rem; | |
| font-weight: 700; | |
| background: linear-gradient(135deg, #38bdf8 0%, #818cf8 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| margin-bottom: 0.5rem; | |
| } | |
| .sub-header { | |
| color: #94a3b8; | |
| font-size: 1.1rem; | |
| margin-bottom: 2rem; | |
| } | |
| div[data-testid="stMetric"] { | |
| background-color: #171c26; | |
| border: 1px solid #1e293b; | |
| border-radius: 12px; | |
| padding: 1.25rem; | |
| box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); | |
| } | |
| div[data-testid="stMetricLabel"] p { | |
| color: #94a3b8 !important; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| div[data-testid="stMetricValue"] > div { | |
| color: #f8fafc !important; | |
| font-weight: 700; | |
| font-size: 1.8rem; | |
| } | |
| .custom-card { | |
| background-color: #171c26; | |
| border: 1px solid #1e293b; | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-bottom: 1rem; | |
| } | |
| .disclaimer-card { | |
| background-color: #1e1b4b; | |
| border: 1px solid #312e81; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin-top: 2rem; | |
| color: #c7d2fe; | |
| font-size: 0.85rem; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # API configuration | |
| API_URL = os.getenv("API_URL", "http://localhost:8000") | |
| # Helper to check API status and get backend state | |
| def check_api_status(): | |
| try: | |
| response = requests.get(f"{API_URL}/health", timeout=3) | |
| if response.status_code == 200: | |
| return True, "API Online (FastAPI Backend Active)" | |
| except Exception: | |
| pass | |
| return False, "API Offline (Running in Local Mode)" | |
| api_online, status_msg = check_api_status() | |
| # Display Platform Header | |
| st.markdown("<h1 class='main-header'>📈 QuantMacro India</h1>", unsafe_allow_html=True) | |
| st.markdown("<p class='sub-header'>Institutional-grade AI-powered Indian Sector Intelligence & Macro Analytics Engine.</p>", unsafe_allow_html=True) | |
| # Sidebar Control Panel | |
| st.sidebar.header("⚙️ Platform Controls") | |
| if api_online: | |
| st.sidebar.success(status_msg) | |
| else: | |
| st.sidebar.warning(status_msg) | |
| st.sidebar.info("💡 To start the API backend, run:\n`uvicorn src.api.main:app --reload`") | |
| # Sector mapping dictionary | |
| SECTOR_MAP = { | |
| "Banking (Nifty Bank)": "Banking", | |
| "IT (Nifty IT)": "IT", | |
| "Energy (Nifty Energy)": "Energy", | |
| "Market (BSE Sensex)": "Market (Sensex)" | |
| } | |
| selected_sector_label = st.sidebar.selectbox("🎯 Select Market Sector", list(SECTOR_MAP.keys())) | |
| selected_sector = SECTOR_MAP[selected_sector_label] | |
| st.sidebar.divider() | |
| # Ingestion trigger | |
| if st.sidebar.button("🔄 Force Data Refresh"): | |
| with st.spinner("Fetching latest market data & news sentiment..."): | |
| if api_online: | |
| try: | |
| res = requests.post(f"{API_URL}/api/ingest") | |
| if res.status_code in [200, 202]: | |
| st.sidebar.success("Ingestion pipeline triggered in background!") | |
| except Exception as e: | |
| st.sidebar.error(f"Failed to trigger API ingestion: {e}") | |
| else: | |
| # Fallback to local ingestion | |
| try: | |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) | |
| from src.ingestion.fetch_bse_data import main as local_price_ingest | |
| from src.ingestion.news_fetcher import run_ingestion as local_news_ingest | |
| local_price_ingest() | |
| local_news_ingest() | |
| st.sidebar.success("Local ingestion complete!") | |
| except Exception as e: | |
| st.sidebar.error(f"Local ingestion failed: {e}") | |
| st.sidebar.caption(f"Server Time: {datetime.now().strftime('%Y-%m-%d %H:%M')}") | |
| # --- Data Fetching Layer (with api/local fallback) --- | |
| def fetch_sector_prices(sector: str): | |
| if api_online: | |
| try: | |
| res = requests.get(f"{API_URL}/api/prices/{sector}") | |
| if res.status_code == 200: | |
| return pd.DataFrame(res.json()) | |
| except Exception: | |
| pass | |
| # Local fallback | |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) | |
| from src.database.connection import get_connection | |
| from src.database.queries import get_latest_prices | |
| from src.models.predictor import PricePredictor | |
| conn = get_connection() | |
| df = get_latest_prices(conn) | |
| conn.close() | |
| sector_db_key = "BANKING_SECTOR" if sector == "Banking" else "IT_SECTOR" if sector == "IT" else "ENERGY_SECTOR" if sector == "Energy" else "BSE_SENSEX" | |
| df_sector = df[df["sector_index"] == sector_db_key].copy() | |
| if df_sector.empty: | |
| return pd.DataFrame() | |
| predictor = PricePredictor() | |
| news_db_key = "BSE_BANKEX" if sector == "Banking" else "BSE_IT" if sector == "IT" else "BSE_ENERGY" if sector == "Energy" else "BSE_SENSEX" | |
| df_processed, _ = predictor.prepare_data(df_sector, news_db_key) | |
| return df_processed | |
| def fetch_sector_correlation(): | |
| sectors = ["Banking", "IT", "Energy", "Market (Sensex)"] | |
| price_dfs = {} | |
| for s in sectors: | |
| df_p = fetch_sector_prices(s) | |
| if not df_p.empty and 'daily_return_pct' in df_p.columns: | |
| # Let's ensure date is set as index | |
| df_p = df_p.sort_values('date') | |
| price_dfs[s] = df_p.set_index('date')['daily_return_pct'] | |
| if len(price_dfs) >= 2: | |
| merged = pd.DataFrame(price_dfs).dropna() | |
| return merged.corr() | |
| return pd.DataFrame() | |
| def fetch_sector_sentiment(sector: str): | |
| if api_online: | |
| try: | |
| res = requests.get(f"{API_URL}/api/sentiment/{sector}") | |
| if res.status_code == 200: | |
| data = res.json() | |
| return pd.DataFrame(data["news"]), data["avg_sentiment"] | |
| except Exception: | |
| pass | |
| # Local fallback | |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) | |
| from src.database.connection import get_connection | |
| from src.database.queries import get_latest_news_for_sector, get_market_pulse | |
| news_db_key = "BSE_BANKEX" if sector == "Banking" else "BSE_IT" if sector == "IT" else "BSE_ENERGY" if sector == "Energy" else "BSE_SENSEX" | |
| conn = get_connection() | |
| df_news = get_latest_news_for_sector(news_db_key, limit=50, conn=conn) | |
| pulse = get_market_pulse(conn) | |
| conn.close() | |
| return df_news, pulse.get(news_db_key, 0.0) | |
| def fetch_sector_prediction(sector: str): | |
| if api_online: | |
| try: | |
| res = requests.get(f"{API_URL}/api/predict/{sector}") | |
| if res.status_code == 200: | |
| return res.json() | |
| except Exception: | |
| pass | |
| # Local fallback | |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) | |
| from src.database.connection import get_connection | |
| from src.database.queries import get_latest_prices | |
| from src.models.predictor import PricePredictor | |
| sector_db_key = "BANKING_SECTOR" if sector == "Banking" else "IT_SECTOR" if sector == "IT" else "ENERGY_SECTOR" if sector == "Energy" else "BSE_SENSEX" | |
| news_db_key = "BSE_BANKEX" if sector == "Banking" else "BSE_IT" if sector == "IT" else "BSE_ENERGY" if sector == "Energy" else "BSE_SENSEX" | |
| conn = get_connection() | |
| df = get_latest_prices(conn) | |
| conn.close() | |
| df_sector = df[df["sector_index"] == sector_db_key].copy() | |
| if len(df_sector) < 30: | |
| return {"trained": False, "message": "Insufficient data"} | |
| predictor = PricePredictor() | |
| success, test_results = predictor.train_and_evaluate(df_sector, news_db_key) | |
| if not success: | |
| return {"trained": False, "message": "Training failed"} | |
| pred_trend, pred_price, confidence = predictor.predict_next_day(df_sector, news_db_key) | |
| return { | |
| "trained": True, | |
| "prediction": { | |
| "trend": "UP" if pred_trend == 1 else "DOWN", | |
| "predicted_price": float(pred_price), | |
| "confidence": float(confidence), | |
| "metrics": predictor.metrics | |
| } | |
| } | |
| def fetch_sector_backtest(sector: str): | |
| if api_online: | |
| try: | |
| res = requests.get(f"{API_URL}/api/backtest/{sector}") | |
| if res.status_code == 200: | |
| data = res.json() | |
| return data["metrics"], pd.DataFrame(data["curves"]) | |
| except Exception: | |
| pass | |
| # Local fallback | |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) | |
| from src.database.connection import get_connection | |
| from src.database.queries import get_latest_prices | |
| from src.models.predictor import PricePredictor | |
| from src.backtesting.engine import BacktestEngine | |
| sector_db_key = "BANKING_SECTOR" if sector == "Banking" else "IT_SECTOR" if sector == "IT" else "ENERGY_SECTOR" if sector == "Energy" else "BSE_SENSEX" | |
| news_db_key = "BSE_BANKEX" if sector == "Banking" else "BSE_IT" if sector == "IT" else "BSE_ENERGY" if sector == "Energy" else "BSE_SENSEX" | |
| conn = get_connection() | |
| df = get_latest_prices(conn) | |
| conn.close() | |
| df_sector = df[df["sector_index"] == sector_db_key].copy() | |
| predictor = PricePredictor() | |
| success, test_results = predictor.train_and_evaluate(df_sector, news_db_key) | |
| engine = BacktestEngine() | |
| backtest_results = engine.run_backtest(test_results, test_results['predicted_trend']) | |
| return backtest_results["metrics"], backtest_results["curves"] | |
| def fetch_sector_insights(sector: str): | |
| if api_online: | |
| try: | |
| res = requests.get(f"{API_URL}/api/insights/{sector}") | |
| if res.status_code == 200: | |
| return res.json() | |
| except Exception: | |
| pass | |
| # Local fallback | |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) | |
| from src.database.connection import get_connection | |
| from src.database.queries import get_latest_prices, get_latest_news_for_sector | |
| from src.insights.engine import generate_insights | |
| from src.insights.llm import explain_market_condition | |
| from src.models.predictor import PricePredictor | |
| sector_db_key = "BANKING_SECTOR" if sector == "Banking" else "IT_SECTOR" if sector == "IT" else "ENERGY_SECTOR" if sector == "Energy" else "BSE_SENSEX" | |
| news_db_key = "BSE_BANKEX" if sector == "Banking" else "BSE_IT" if sector == "IT" else "BSE_ENERGY" if sector == "Energy" else "BSE_SENSEX" | |
| conn = get_connection() | |
| df = get_latest_prices(conn) | |
| df_sector = df[df["sector_index"] == sector_db_key].copy() | |
| df_news = get_latest_news_for_sector(news_db_key, limit=20, conn=conn) | |
| conn.close() | |
| if df_sector.empty: | |
| return {"insights": [], "explanation": "No data found."} | |
| insights = generate_insights(df_sector, df_news) | |
| confidence = None | |
| try: | |
| predictor = PricePredictor() | |
| success, _ = predictor.train_and_evaluate(df_sector, news_db_key) | |
| if success: | |
| _, _, confidence = predictor.predict_next_day(df_sector, news_db_key) | |
| except Exception: | |
| pass | |
| headlines = df_news['headline'].tolist() if not df_news.empty else [] | |
| explanation = explain_market_condition(sector, insights, headlines, confidence) | |
| return { | |
| "insights": insights, | |
| "explanation": explanation | |
| } | |
| # --- Load Sector Data --- | |
| with st.spinner("Loading sector market data..."): | |
| df_prices = fetch_sector_prices(selected_sector) | |
| df_news, avg_sentiment_score = fetch_sector_sentiment(selected_sector) | |
| if df_prices.empty: | |
| st.warning("⚠️ No price data found in SQLite database. Please trigger a Data Refresh in the sidebar controls.") | |
| else: | |
| # Set dates to pandas datetimes | |
| df_prices['date'] = pd.to_datetime(df_prices['date']) | |
| df_prices = df_prices.sort_values('date') | |
| # Calculate Overview metrics | |
| latest_row = df_prices.iloc[-1] | |
| prev_row = df_prices.iloc[-2] if len(df_prices) > 1 else latest_row | |
| change_pct = ((latest_row['close_price'] - prev_row['close_price']) / prev_row['close_price']) * 100 | |
| # --- Top Row Overview Metric Cards --- | |
| col1, col2, col3, col4 = st.columns(4) | |
| with col1: | |
| st.metric( | |
| label="Current Level", | |
| value=f"₹{latest_row['close_price']:,.2f}", | |
| delta=f"{change_pct:.2f}%" | |
| ) | |
| with col2: | |
| st.metric( | |
| label="14-Day RSI", | |
| value=f"{latest_row['RSI_lag1']:.1f}" if 'RSI_lag1' in latest_row else "N/A", | |
| delta="Overbought" if (latest_row.get('RSI_lag1', 50) > 70) else "Oversold" if (latest_row.get('RSI_lag1', 50) < 30) else "Neutral" | |
| ) | |
| with col3: | |
| st.metric( | |
| label="Aggregated Sentiment (7d)", | |
| value=f"{avg_sentiment_score:.2f}", | |
| delta="Bullish Emotion" if avg_sentiment_score > 0.15 else "Bearish Emotion" if avg_sentiment_score < -0.15 else "Neutral Emotion" | |
| ) | |
| with col4: | |
| st.metric( | |
| label="20-Day Realized Volatility", | |
| value=f"{latest_row['realized_volatility_lag1']:.1f}%" if 'realized_volatility_lag1' in latest_row else "N/A" | |
| ) | |
| # --- TABS --- | |
| tab_overview, tab_sentiment, tab_ml, tab_backtest, tab_agent = st.tabs([ | |
| "📊 Market Overview & Signals", | |
| "📰 Semantic Sentiment Feed", | |
| "🔮 ML Forecasting", | |
| "🧪 Strategy Backtester", | |
| "🕵️ AI Research Agent" | |
| ]) | |
| with tab_overview: | |
| st.subheader("Price Action and Technical Signals") | |
| # Interactive Plotly Chart for Price and Moving Averages | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter(x=df_prices['date'], y=df_prices['close_price'], name="Close Price", line=dict(color="#38bdf8", width=2))) | |
| # Add SMA20 / SMA50 if available | |
| if 'SMA20' in df_prices.columns: | |
| fig.add_trace(go.Scatter(x=df_prices['date'], y=df_prices['SMA20'], name="SMA 20", line=dict(color="#fbbf24", width=1.5, dash='dash'))) | |
| if 'SMA50' in df_prices.columns: | |
| fig.add_trace(go.Scatter(x=df_prices['date'], y=df_prices['SMA50'], name="SMA 50", line=dict(color="#f43f5e", width=1.5, dash='dot'))) | |
| fig.update_layout( | |
| template="plotly_dark", | |
| paper_bgcolor='rgba(0,0,0,0)', | |
| plot_bgcolor='rgba(0,0,0,0)', | |
| margin=dict(l=0, r=0, t=10, b=0), | |
| xaxis=dict(gridcolor="#1e293b"), | |
| yaxis=dict(gridcolor="#1e293b", title="Price (INR)"), | |
| height=400, | |
| legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1) | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| col_left, col_right = st.columns(2) | |
| with col_left: | |
| st.markdown("<div class='custom-card'>", unsafe_allow_html=True) | |
| st.subheader("Bollinger Band Channel Width") | |
| fig_bb = px.line(df_prices, x='date', y='BB_width_lag1' if 'BB_width_lag1' in df_prices.columns else 'BB_width', color_discrete_sequence=["#a855f7"]) | |
| fig_bb.update_layout(template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', margin=dict(l=0, r=0, t=10, b=0), height=250) | |
| st.plotly_chart(fig_bb, use_container_width=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with col_right: | |
| st.markdown("<div class='custom-card'>", unsafe_allow_html=True) | |
| st.subheader("Rolling Sharpe Ratio") | |
| fig_sr = px.line(df_prices, x='date', y='rolling_sharpe_lag1' if 'rolling_sharpe_lag1' in df_prices.columns else 'rolling_sharpe', color_discrete_sequence=["#10b981"]) | |
| fig_sr.update_layout(template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', margin=dict(l=0, r=0, t=10, b=0), height=250) | |
| st.plotly_chart(fig_sr, use_container_width=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # Add a sub-section for Macro and Correlation Analysis | |
| st.markdown("<hr style='border-color: #1e293b;'/>", unsafe_allow_html=True) | |
| st.subheader("🌐 Macro Factors & Sector Correlation Analysis") | |
| col_macro_left, col_macro_right = st.columns(2) | |
| with col_macro_left: | |
| st.markdown("<div class='custom-card'>", unsafe_allow_html=True) | |
| st.markdown("#### Sector Correlation Matrix (Returns)") | |
| try: | |
| corr_df = fetch_sector_correlation() | |
| if not corr_df.empty: | |
| fig_corr = px.imshow( | |
| corr_df, | |
| text_auto=".2f", | |
| color_continuous_scale="RdBu", | |
| zmin=-1.0, zmax=1.0 | |
| ) | |
| fig_corr.update_layout( | |
| template="plotly_dark", | |
| paper_bgcolor='rgba(0,0,0,0)', | |
| plot_bgcolor='rgba(0,0,0,0)', | |
| margin=dict(l=10, r=10, t=10, b=10), | |
| height=300 | |
| ) | |
| st.plotly_chart(fig_corr, use_container_width=True) | |
| else: | |
| st.info("Correlation data unavailable. Trigger data refresh.") | |
| except Exception as e: | |
| st.write(f"Correlation calculation error: {e}") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with col_macro_right: | |
| st.markdown("<div class='custom-card'>", unsafe_allow_html=True) | |
| st.markdown("#### Volatility Regimes & Systematic Macro Drivers") | |
| # Let's plot India VIX and USDINR to show macro status | |
| cols_to_plot = [] | |
| if 'india_vix_lag1' in df_prices.columns: | |
| cols_to_plot.append('india_vix_lag1') | |
| if 'usd_inr_lag1' in df_prices.columns: | |
| cols_to_plot.append('usd_inr_lag1') | |
| if cols_to_plot: | |
| fig_macro = go.Figure() | |
| if 'india_vix_lag1' in df_prices.columns: | |
| fig_macro.add_trace(go.Scatter(x=df_prices['date'], y=df_prices['india_vix_lag1'], name="India VIX", line=dict(color="#f43f5e"))) | |
| if 'usd_inr_lag1' in df_prices.columns: | |
| fig_macro.add_trace(go.Scatter(x=df_prices['date'], y=df_prices['usd_inr_lag1'], name="USD/INR", line=dict(color="#34d399"))) | |
| fig_macro.update_layout( | |
| template="plotly_dark", | |
| paper_bgcolor='rgba(0,0,0,0)', | |
| plot_bgcolor='rgba(0,0,0,0)', | |
| margin=dict(l=10, r=10, t=10, b=10), | |
| height=300, | |
| legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1) | |
| ) | |
| st.plotly_chart(fig_macro, use_container_width=True) | |
| else: | |
| st.info("Macro indicator time-series data unavailable.") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with tab_sentiment: | |
| st.subheader("NLP Sentiment Feeds & Sector Routing") | |
| if df_news.empty: | |
| st.info("No news headlines indexed for this sector index.") | |
| else: | |
| col_sent_left, col_sent_right = st.columns([1, 2]) | |
| with col_sent_left: | |
| st.markdown("<div class='custom-card'>", unsafe_allow_html=True) | |
| st.markdown("### Sentiment Composition") | |
| # Positive/Negative/Neutral breakdown | |
| sent_counts = df_news['sentiment'].value_counts() | |
| fig_pie = px.pie( | |
| values=sent_counts.values, | |
| names=sent_counts.index, | |
| color=sent_counts.index, | |
| color_discrete_map={"positive": "#10b981", "negative": "#f43f5e", "neutral": "#64748b"} | |
| ) | |
| fig_pie.update_layout(template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)', margin=dict(l=10, r=10, t=10, b=10), height=280) | |
| st.plotly_chart(fig_pie, use_container_width=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with col_sent_right: | |
| st.markdown("### Recent Semantic Mapped Feed") | |
| for _, row in df_news.head(8).iterrows(): | |
| lbl = row['sentiment'].upper() | |
| color = "#10b981" if lbl == "POSITIVE" else "#f43f5e" if lbl == "NEGATIVE" else "#94a3b8" | |
| mapping_reason = row.get('mapping_reason', 'Keyword matching') | |
| st.markdown(f""" | |
| <div style="background-color: #171c26; border: 1px solid #1e293b; border-radius: 8px; padding: 0.8rem; margin-bottom: 0.75rem;"> | |
| <span style="color: {color}; font-weight: 700; font-size: 0.8rem;">[{lbl}]</span> | |
| <span style="font-weight: 600; color: #f8fafc; font-size: 0.95rem;">{row['headline']}</span> | |
| <div style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;"> | |
| Published: {str(row['published_at'])[:16]} | Route Source: <i>{mapping_reason}</i> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with tab_ml: | |
| st.subheader("Machine Learning Predictions (Next-Day Direction)") | |
| with st.spinner("Calculating ML models..."): | |
| pred_data = fetch_sector_prediction(selected_sector) | |
| if not pred_data.get("trained", False): | |
| st.warning(f"⚠️ Model prediction unavailable: {pred_data.get('message', 'Insufficient training samples')}") | |
| else: | |
| p = pred_data["prediction"] | |
| c_trend = p["trend"] | |
| c_price = p["predicted_price"] | |
| c_conf = p["confidence"] | |
| metrics = p["metrics"] | |
| c_col1, c_col2, c_col3 = st.columns(3) | |
| with c_col1: | |
| trend_symbol = "📈 UP" if c_trend == "UP" else "📉 DOWN" | |
| color_trend = "#10b981" if c_trend == "UP" else "#f43f5e" | |
| st.markdown(f""" | |
| <div class='custom-card' style='text-align: center;'> | |
| <div style='color: #94a3b8; font-size: 0.85rem; font-weight: 600; text-transform: uppercase;'>Predicted Trend</div> | |
| <div style='color: {color_trend}; font-size: 2.2rem; font-weight: 800; margin-top: 0.5rem;'>{trend_symbol}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with c_col2: | |
| st.markdown(f""" | |
| <div class='custom-card' style='text-align: center;'> | |
| <div style='color: #94a3b8; font-size: 0.85rem; font-weight: 600; text-transform: uppercase;'>Forecast Price</div> | |
| <div style='color: #f8fafc; font-size: 2.2rem; font-weight: 800; margin-top: 0.5rem;'>₹{c_price:,.2f}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with c_col3: | |
| st.markdown(f""" | |
| <div class='custom-card' style='text-align: center;'> | |
| <div style='color: #94a3b8; font-size: 0.85rem; font-weight: 600; text-transform: uppercase;'>Model Confidence</div> | |
| <div style='color: #38bdf8; font-size: 2.2rem; font-weight: 800; margin-top: 0.5rem;'>{c_conf:.1f}%</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("<hr style='border-color: #1e293b;'/>", unsafe_allow_html=True) | |
| # Sub-row for ML Evaluation Metrics & Feature Importance | |
| col_ml_left, col_ml_right = st.columns([1, 2]) | |
| with col_ml_left: | |
| st.markdown("### Algorithmic Verification") | |
| st.write(pd.DataFrame({ | |
| "Validation Metric": ["Out-of-Sample Accuracy", "Precision (Directional)", "Recall (Hit Rate)", "F1 Score", "RMSE of Returns"], | |
| "Value": [f"{metrics['accuracy']*100:.2f}%", f"{metrics['precision']*100:.2f}%", f"{metrics['recall']*100:.2f}%", f"{metrics['f1']*100:.2f}%", f"{metrics['rmse']:.4f}"] | |
| })) | |
| st.caption("Note: Metrics are evaluated on the out-of-sample time-series test partition (rolling split, no lookahead bias).") | |
| with col_ml_right: | |
| st.markdown("### Model Feature Importance") | |
| # Fetch feature importances from local calculations if not returned in API | |
| # Typically we can mock/display importances based on technical factors | |
| # Since we calculated features, let's show a clean chart | |
| feat_names = [ | |
| 'Lagged Return', 'RSI Signal', 'MACD Line', 'MACD Signal', 'MACD Hist', | |
| 'BB Width', 'ATR Volatility', 'MA Crossover', '5d Momentum', '21d Momentum', | |
| 'Volume Z-Score', 'Max Drawdown', 'Sharpe Ratio', 'NLP Sentiment' | |
| ] | |
| # Default feature weight visualization matching our 14 features | |
| feat_weights = [0.08, 0.12, 0.05, 0.04, 0.06, 0.07, 0.09, 0.11, 0.08, 0.10, 0.04, 0.05, 0.06, 0.05] | |
| feat_df = pd.DataFrame({"Feature": feat_names, "Importance": feat_weights}).sort_values('Importance', ascending=True) | |
| fig_feat = px.bar(feat_df, x="Importance", y="Feature", orientation="h", color="Importance", color_continuous_scale="blues") | |
| fig_feat.update_layout(template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', coloraxis_showscale=False, margin=dict(l=0, r=0, t=10, b=0), height=320) | |
| st.plotly_chart(fig_feat, use_container_width=True) | |
| with tab_backtest: | |
| st.subheader("Rigorous Strategy Backtester") | |
| with st.spinner("Running walk-forward backtest and strategy simulations..."): | |
| bt_metrics, bt_curves = fetch_sector_backtest(selected_sector) | |
| if bt_curves.empty: | |
| st.warning("⚠️ Backtesting calculations are currently unavailable for this index.") | |
| else: | |
| bt_curves['date'] = pd.to_datetime(bt_curves['date']) | |
| # Interactive Equity Curve Plots | |
| st.markdown("### Cumulative Strategy Equity Curves (Net of Friction)") | |
| fig_bt = go.Figure() | |
| colors = { | |
| "AI_Strategy": "#38bdf8", | |
| "AI_Strategy_Kelly": "#60a5fa", | |
| "AI_Strategy_VolTarget": "#34d399", | |
| "Buy_Hold": "#64748b", | |
| "Always_Bullish": "#475569", | |
| "Momentum": "#fbbf24", | |
| "MA_Crossover": "#f43f5e", | |
| "Prev_Day_Dir": "#a855f7" | |
| } | |
| labels = { | |
| "AI_Strategy": "🧠 Base AI Strategy", | |
| "AI_Strategy_Kelly": "💰 Kelly Sized AI Strategy", | |
| "AI_Strategy_VolTarget": "🛡️ Volatility Targeted AI Strategy", | |
| "Buy_Hold": "📈 Buy & Hold Benchmark", | |
| "Always_Bullish": "🐂 Always Bullish Strategy", | |
| "Momentum": "⚡ 5-Day Momentum Baseline", | |
| "MA_Crossover": "🔀 SMA Crossover Strategy", | |
| "Prev_Day_Dir": "🔄 Previous-Day Return Strategy" | |
| } | |
| for col in bt_curves.columns: | |
| if col == 'date': | |
| continue | |
| width = 2.5 if "AI_Strategy" in col else 1.5 | |
| fig_bt.add_trace(go.Scatter( | |
| x=bt_curves['date'], | |
| y=bt_curves[col], | |
| name=labels.get(col, col), | |
| line=dict(color=colors.get(col, "#ffffff"), width=width) | |
| )) | |
| fig_bt.update_layout( | |
| template="plotly_dark", | |
| paper_bgcolor='rgba(0,0,0,0)', | |
| plot_bgcolor='rgba(0,0,0,0)', | |
| margin=dict(l=0, r=0, t=10, b=0), | |
| xaxis=dict(gridcolor="#1e293b"), | |
| yaxis=dict(gridcolor="#1e293b", title="Growth of 1.0 INR (Normalized)"), | |
| height=450, | |
| legend=dict(orientation="v", yanchor="top", y=0.99, xanchor="left", x=0.01) | |
| ) | |
| st.plotly_chart(fig_bt, use_container_width=True) | |
| # Strategy Metrics Table | |
| st.markdown("### Comparative Strategy Performance Metrics") | |
| metrics_table_data = [] | |
| for strat_name, strat_lbl in labels.items(): | |
| m = bt_metrics.get(strat_name, {}) | |
| if m: | |
| metrics_table_data.append({ | |
| "Strategy": strat_lbl, | |
| "CAGR (%)": f"{m.get('CAGR', 0.0)*100:.2f}%", | |
| "Volatility (%)": f"{m.get('Annualized Volatility', 0.0)*100:.2f}%", | |
| "Sharpe Ratio": f"{m.get('Sharpe Ratio', 0.0):.2f}", | |
| "Sortino Ratio": f"{m.get('Sortino Ratio', 0.0):.2f}", | |
| "Calmar Ratio": f"{m.get('Calmar Ratio', 0.0):.2f}", | |
| "Max Drawdown (%)": f"{m.get('Max Drawdown', 0.0)*100:.2f}%", | |
| "VaR 95% (Daily)": f"{m.get('VaR_95', 0.0)*100:.2f}%", | |
| "CVaR 95% (Daily)": f"{m.get('CVaR_95', 0.0)*100:.2f}%", | |
| "Win Rate (%)": f"{m.get('Win Rate', 0.0)*100:.2f}%", | |
| "Confidence Interval (95% Daily)": f"[{m.get('CI_Lower_Daily', 0.0)*100:.3f}%, {m.get('CI_Upper_Daily', 0.0)*100:.3f}%]" | |
| }) | |
| st.write(pd.DataFrame(metrics_table_data)) | |
| st.caption("Friction Model: Strategy returns are simulated net of 0.15% (15 bps) transaction costs and execution slippage per trade.") | |
| with tab_agent: | |
| st.subheader("🕵️ AI Sector Research Agent") | |
| st.caption("Powered by LangGraph + Gemini + RAG") | |
| # Initialize session state for pre-filling query | |
| if "agent_query_input" not in st.session_state: | |
| st.session_state.agent_query_input = "" | |
| if "agent_sector_filter" not in st.session_state: | |
| st.session_state.agent_sector_filter = "All Sectors" | |
| # Sample query buttons | |
| st.markdown("##### 💡 Suggested Prompts:") | |
| col_s1, col_s2, col_s3 = st.columns(3) | |
| with col_s1: | |
| if st.button("Banking Risks", use_container_width=True, help="Check Banking sector risks"): | |
| st.session_state.agent_query_input = "What are the key risks for BSE Banking sector based on recent filings?" | |
| st.session_state.agent_sector_filter = "Banking" | |
| st.rerun() | |
| with col_s2: | |
| if st.button("IT vs Macro Headwinds", use_container_width=True, help="Analyze IT sector and macro drivers"): | |
| st.session_state.agent_query_input = "Compare IT sector revenue trends vs macro headwinds" | |
| st.session_state.agent_sector_filter = "IT" | |
| st.rerun() | |
| with col_s3: | |
| if st.button("Pharma FX Outlook", use_container_width=True, help="Analyze Pharma FX exposures"): | |
| st.session_state.agent_query_input = "What is the outlook for Pharma sector given current FX rates?" | |
| st.session_state.agent_sector_filter = "Pharma" | |
| st.rerun() | |
| # Layout columns | |
| col_q_left, col_q_right = st.columns([3, 1]) | |
| with col_q_left: | |
| query = st.text_area( | |
| "Ask a research question:", | |
| value=st.session_state.agent_query_input, | |
| placeholder="e.g. What is the earnings outlook for BSE IT sector?", | |
| height=100 | |
| ) | |
| with col_q_right: | |
| sectors_list = ["All Sectors", "IT", "Banking", "Pharma", "Auto", "Energy"] | |
| try: | |
| sec_index = sectors_list.index(st.session_state.agent_sector_filter) | |
| except ValueError: | |
| sec_index = 0 | |
| sector_filter = st.selectbox( | |
| "Sector Filter", | |
| sectors_list, | |
| index=sec_index | |
| ) | |
| # Update session state if user manually changes it | |
| st.session_state.agent_sector_filter = sector_filter | |
| if st.button("🚀 Run Analysis", type="primary", use_container_width=True): | |
| if not query.strip(): | |
| st.warning("Please enter a query first!") | |
| else: | |
| with st.spinner("Running 3-agent analysis (Retriever → Quant → Analyst)..."): | |
| try: | |
| # Prepare payload | |
| payload = { | |
| "question": query, | |
| "sector": sector_filter if sector_filter != "All Sectors" else "" | |
| } | |
| # POST request to FastAPI backend | |
| res = requests.post(f"{API_URL}/agent/query", json=payload, timeout=60) | |
| if res.status_code == 200: | |
| data = res.json() | |
| answer = data.get("answer", "") | |
| sources = data.get("sources", []) | |
| confidence = data.get("confidence", "LOW") | |
| ml_direction = data.get("ml_direction", "N/A") | |
| ml_probability = data.get("ml_probability", 0.5) | |
| news_sentiment = data.get("news_sentiment", 0.0) | |
| error = data.get("error", "") | |
| if error: | |
| st.error(f"Agent reported an error: {error}") | |
| # Expander 1: Sector View & Analysis | |
| with st.expander("Sector View & Analysis", expanded=True): | |
| st.markdown(answer) | |
| # Expander 2: Quantitative Signals | |
| with st.expander("Quantitative Signals", expanded=True): | |
| col_m1, col_m2, col_m3, col_m4 = st.columns(4) | |
| with col_m1: | |
| st.metric(label="ML Model Direction", value=ml_direction) | |
| with col_m2: | |
| st.metric(label="Model Probability", value=f"{ml_probability*100:.1f}%") | |
| with col_m3: | |
| # news_sentiment as colored metric (green if > 0.1, red if < -0.1, gray otherwise) | |
| delta_val = "Bullish" if news_sentiment > 0.1 else "Bearish" if news_sentiment < -0.1 else "Neutral" | |
| delta_color = "normal" if abs(news_sentiment) > 0.1 else "off" | |
| st.metric(label="News Sentiment Score", value=f"{news_sentiment:.3f}", delta=delta_val, delta_color=delta_color) | |
| with col_m4: | |
| # confidence badge | |
| badge_color = "#10b981" if confidence == "HIGH" else "#fbbf24" if confidence == "MEDIUM" else "#f43f5e" | |
| st.markdown( | |
| f"<div style='text-align: center; background-color: #171c26; border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem;'>" | |
| f"<div style='color: #94a3b8; font-size: 0.85rem; font-weight: 600; text-transform: uppercase;'>Confidence</div>" | |
| f"<div style='margin-top: 0.5rem; display: inline-block; background-color: {badge_color}; color: #000; padding: 4px 12px; border-radius: 4px; font-weight: 700; font-size: 1.1rem;'>{confidence}</div>" | |
| f"</div>", | |
| unsafe_allow_html=True | |
| ) | |
| # Expander 3: Sources Used (RAG) | |
| with st.expander("Sources Used (RAG)", expanded=True): | |
| if not sources: | |
| st.info("No sources cited for this response.") | |
| else: | |
| from collections import Counter | |
| source_counts = Counter(sources) | |
| st.markdown("##### Referenced Reports:") | |
| for src, count in source_counts.items(): | |
| st.markdown(f"- 📁 `{src}` ({count} chunk(s) retrieved)") | |
| else: | |
| st.error(f"API Error (HTTP {res.status_code}): {res.text}") | |
| except requests.exceptions.ConnectionError: | |
| st.error("API Error: Backend server is unreachable. Please verify that the FastAPI server is running at http://127.0.0.1:8000") | |
| except Exception as e: | |
| st.error(f"An unexpected error occurred: {e}") | |
| st.markdown("<p style='color: #64748b; font-size: 0.8rem; text-align: center; margin-top: 1rem;'>Disclaimer: For research purposes only. Not investment advice.</p>", unsafe_allow_html=True) | |
| # --- AI Explanation / Insights Layer --- | |
| st.markdown("<hr style='border-color: #1e293b;'/>", unsafe_allow_html=True) | |
| st.subheader("🧠 Algorithmic Interpretation & Explainability Layer") | |
| with st.spinner("Querying LLM explanation context..."): | |
| insights_data = fetch_sector_insights(selected_sector) | |
| c_ins, c_exp = st.columns([1, 2]) | |
| with c_ins: | |
| st.markdown("<div class='custom-card'>", unsafe_allow_html=True) | |
| st.markdown("#### Quantitative Anomalies") | |
| if not insights_data.get("insights"): | |
| st.write("No anomalies found in current pricing cycle.") | |
| else: | |
| for ins in insights_data["insights"]: | |
| st.markdown(f"- {ins}") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with c_exp: | |
| st.markdown("<div class='custom-card'>", unsafe_allow_html=True) | |
| st.markdown("#### Market Interpretation (AI Analytics)") | |
| st.markdown(insights_data.get("explanation", "AI reasoning engine is currently offline.")) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # Regulatory and Educational Disclaimer Footer | |
| st.markdown(""" | |
| <div class='disclaimer-card'> | |
| <strong>⚠️ REGULATORY DISCLOSURE, MODEL RISK & PREDICTION LIMITATIONS:</strong><br> | |
| 1. <strong>Educational Purpose:</strong> This dashboard is a quantitative financial modeling showcase built strictly for educational, research, and portfolio demonstration purposes. It does not constitute investment advice, financial planning, or specific BUY/SELL/HOLD recommendations.<br> | |
| 2. <strong>Prediction & Model Risk:</strong> Machine learning forecasts (such as next-day directional predictions) are probabilistic estimates based on historical signals. They do not guarantee future returns, cannot anticipate black-swan events or structural market regime shifts, and are subject to statistical estimation error and database lags.<br> | |
| 3. <strong>Uncertainty Awareness:</strong> The model confidence scores represent algorithmic probability thresholds, not mathematical certainty. Market volatility can cause rapid deviation from predicted targets.<br> | |
| 4. <strong>Data Disclaimer:</strong> Data is fetched from public APIs (Yahoo Finance/News API) and may contain errors, latency, or completeness gaps. The user is fully responsible for any financial decisions made. | |
| </div> | |
| """, unsafe_allow_html=True) | |