techatcreated commited on
Commit
5b3602b
·
verified ·
1 Parent(s): fe0876b

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +172 -0
  2. config.py +106 -0
  3. logic.py +458 -0
  4. requirements.txt +7 -0
app.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------- APP ----------------
2
+ # This is the main file to run the Gradio application.
3
+ # It imports logic from logic.py and configuration from config.py
4
+
5
+ import gradio as gr
6
+
7
+ # Import constants needed for the UI
8
+ from config import CRISIS_PERIODS, EXAMPLE_PORTFOLIOS, CRISIS_SUMMARY
9
+
10
+ # Import the main simulation function
11
+ from logic import run_crisis_simulation
12
+
13
+ # --- UI Helper Functions ---
14
+
15
+ def load_example(example_name):
16
+ """Updates ticker and weight textboxes based on selection."""
17
+ portfolio = EXAMPLE_PORTFOLIOS.get(example_name, {"tickers": "", "weights": ""})
18
+ return gr.update(value=portfolio["tickers"]), gr.update(value=portfolio["weights"])
19
+
20
+ def update_crisis_summary(crisis_name):
21
+ """Updates the crisis summary text when a crisis is selected."""
22
+ summary = CRISIS_SUMMARY.get(crisis_name, "")
23
+ if summary:
24
+ return f"**Crisis summary:** _{summary}_"
25
+ return ""
26
+
27
+ # ---------------- UI DEFINITION ----------------
28
+
29
+ with gr.Blocks(theme=gr.themes.Soft(), title="Crisis Lens (India)") as demo:
30
+ gr.Markdown(
31
+ """
32
+ # 🇮🇳 Crisis Lens — Indian Stock Stress Simulator
33
+ How would your portfolio have performed during a major market crisis?
34
+ Select a historical crisis and your portfolio to find out.
35
+ """
36
+ )
37
+
38
+ with gr.Row():
39
+ with gr.Column(scale=2):
40
+ crisis_dd = gr.Dropdown(
41
+ list(CRISIS_PERIODS.keys()),
42
+ label="Select Crisis",
43
+ value="COVID-19 Crash (India)",
44
+ )
45
+ crisis_info = gr.Markdown(
46
+ f"**Crisis summary:** _{CRISIS_SUMMARY['COVID-19 Crash (India)']}_",
47
+ elem_classes="crisis-summary",
48
+ )
49
+ with gr.Column(scale=1):
50
+ example_loader_dd = gr.Dropdown(
51
+ list(EXAMPLE_PORTFOLIOS.keys()),
52
+ label="Load Example Portfolio",
53
+ value="Select Example...", # will be overridden by .load()
54
+ )
55
+
56
+ with gr.Row():
57
+ with gr.Column(scale=1):
58
+ gr.Markdown("### 1. Define Your Portfolio")
59
+ upload_csv = gr.File(
60
+ label="Upload Portfolio CSV (Ticker,Weight)",
61
+ file_types=[".csv"]
62
+ )
63
+ gr.Markdown("...or enter manually below:")
64
+
65
+ # Set default values to blank, they will be filled by the load_example function
66
+ tickers_input = gr.Textbox(
67
+ label="Tickers (comma separated)",
68
+ value=""
69
+ )
70
+ weights_input = gr.Textbox(
71
+ label="Weights (comma separated)",
72
+ value=""
73
+ )
74
+
75
+ add_etf_cb = gr.Checkbox(
76
+ label="Add 5% NIFTYBEES.NS to portfolio (diversify)",
77
+ value=False
78
+ )
79
+
80
+ gr.Markdown("---")
81
+ gr.Markdown("### 🤖 2. AI Insights (Optional)")
82
+ gemini_api_key_in = gr.Textbox(
83
+ label="Gemini API Key (optional, not stored)",
84
+ placeholder="Paste your Gemini API key here to get AI-generated insights",
85
+ type="password",
86
+ )
87
+ gemini_extra_prompt_in = gr.Textbox(
88
+ label="Extra instructions for AI (optional)",
89
+ placeholder="e.g., Focus more on risk, or write in simple language, etc.",
90
+ lines=2,
91
+ )
92
+
93
+ gr.Markdown("---")
94
+ run_btn = gr.Button("Run Simulation", variant="primary")
95
+ logs_txt = gr.Textbox(label="Status / Logs", interactive=False)
96
+
97
+ with gr.Column(scale=3):
98
+ gr.Markdown("### 3. Analyze the Results")
99
+ plot_performance = gr.Plot()
100
+ with gr.Row():
101
+ metrics_output = gr.Markdown()
102
+ pie_chart = gr.Plot()
103
+ with gr.Row():
104
+ sector_plot_output = gr.Plot()
105
+ insights_output = gr.Markdown()
106
+
107
+ gr.Markdown("### 🤖 AI-Generated Insights")
108
+ gemini_insights_md = gr.Markdown(
109
+ value="*AI insights will appear here after running simulation with a Gemini API key.*"
110
+ )
111
+
112
+ # ---------------- EVENT HANDLERS ----------------
113
+
114
+ # Update crisis summary when crisis selection changes
115
+ crisis_dd.change(
116
+ fn=update_crisis_summary,
117
+ inputs=[crisis_dd],
118
+ outputs=[crisis_info],
119
+ )
120
+
121
+ # Connect the example loader dropdown to the textboxes
122
+ example_loader_dd.change(
123
+ fn=load_example,
124
+ inputs=[example_loader_dd],
125
+ outputs=[tickers_input, weights_input],
126
+ )
127
+
128
+ # Connect the "Run" button to the main simulation function
129
+ run_btn.click(
130
+ run_crisis_simulation,
131
+ inputs=[
132
+ crisis_dd,
133
+ upload_csv,
134
+ tickers_input,
135
+ weights_input,
136
+ add_etf_cb,
137
+ gemini_api_key_in,
138
+ gemini_extra_prompt_in,
139
+ ],
140
+ outputs=[
141
+ plot_performance,
142
+ metrics_output,
143
+ sector_plot_output,
144
+ insights_output,
145
+ pie_chart,
146
+ logs_txt,
147
+ gemini_insights_md,
148
+ ],
149
+ )
150
+
151
+ def load_default_example():
152
+ default_name = "Large-Cap (Default)"
153
+ portfolio = EXAMPLE_PORTFOLIOS.get(default_name, {"tickers": "", "weights": ""})
154
+ summary = CRISIS_SUMMARY.get("COVID-19 Crash (India)", "")
155
+ return (
156
+ gr.update(value=default_name),
157
+ gr.update(value=portfolio["tickers"]),
158
+ gr.update(value=portfolio["weights"]),
159
+ f"**Crisis summary:** _{summary}_",
160
+ )
161
+
162
+ demo.load(
163
+ fn=load_default_example,
164
+ inputs=None,
165
+ outputs=[example_loader_dd, tickers_input, weights_input, crisis_info],
166
+ )
167
+
168
+
169
+ # ---------------- LAUNCH ----------------
170
+
171
+ if __name__ == "__main__":
172
+ demo.launch(share=True, debug=True)
config.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------- CONFIG ----------------
2
+ # This file contains all static configuration data for the application.
3
+
4
+ CRISIS_PERIODS = {
5
+ "2008 Global Financial Crisis (India)": ("2008-01-01", "2009-03-31"),
6
+ "COVID-19 Crash (India)": ("2020-02-01", "2020-11-30"),
7
+ "Demonetization Shock (2016)": ("2016-11-01", "2017-02-28"),
8
+ "IL&FS Debt Crisis (2018)": ("2018-08-01", "2018-12-31"),
9
+ "Ketan Parekh / Dot-com Burst (India)": ("2000-03-01", "2001-09-30"),
10
+ }
11
+
12
+ BENCHMARK_TICKER = "^NSEI"
13
+ BENCHMARK_NAME = "NIFTY 50"
14
+ RECOVERY_DAYS = 90
15
+
16
+ CRISIS_SUMMARY = {
17
+ "2008 Global Financial Crisis (India)": "Global credit and liquidity crisis; banks and financials hit hard; heavy foreign outflows.",
18
+ "COVID-19 Crash (India)": "Rapid market fall in Mar 2020 due to pandemic lockdowns; tech & pharma were more resilient.",
19
+ "Demonetization Shock (2016)": "Policy shock in Nov 2016 causing short-term consumption & liquidity effects.",
20
+ "IL&FS Debt Crisis (2018)": "Infrastructure debt/default scare leading to liquidity risk concerns for NBFCs and financials.",
21
+ "Ketan Parekh / Dot-com Burst (India)": "Late 1990s / early 2000s Indian IT/tech boom and subsequent bust after financial irregularities.",
22
+ }
23
+
24
+ CRISIS_INSIGHTS = {
25
+ "COVID-19 Crash (India)": {
26
+ "Information Technology": "IT companies showed resilience as global digital adoption rose; services exports continued.",
27
+ "Banks": "Banks faced short-term credit stress and moratoriums; credit growth slowed.",
28
+ "Energy": "Lower demand due to lockdowns hit energy & fuel consumption.",
29
+ "Pharmaceuticals": "Pharma and healthcare often outperformed due to demand for medical supplies.",
30
+ },
31
+ "2008 Global Financial Crisis (India)": {
32
+ "Finance": "Banks and NBFCs suffered due to global liquidity freeze and foreign capital outflows.",
33
+ "Metals & Mining": "Commodity demand slump affected exporters and metal companies.",
34
+ },
35
+ "Demonetization Shock (2016)": {
36
+ "Consumer Discretionary": "Spending fell short-term; small businesses faced cash shortages.",
37
+ "Banking": "Short-term spike in deposits but transactional disruption.",
38
+ },
39
+ "IL&FS Debt Crisis (2018)": {
40
+ "Financial Services": "NBFCs and related sectors were directly impacted due to counterparty risk.",
41
+ },
42
+ "Ketan Parekh / Dot-com Burst (India)": {
43
+ "Information Technology": "Large re-rating and correction after speculative run-up in tech/IT names.",
44
+ },
45
+ }
46
+
47
+ EXAMPLE_PORTFOLIOS = {
48
+ "Large-Cap (Default)": {
49
+ "tickers": "RELIANCE.NS, TCS.NS, HDFCBANK.NS",
50
+ "weights": "0.4, 0.3, 0.3",
51
+ },
52
+ "IT / Tech Focus": {
53
+ "tickers": "TCS.NS, INFY.NS, WIPRO.NS, HCLTECH.NS",
54
+ "weights": "0.3, 0.3, 0.2, 0.2",
55
+ },
56
+ "Banking Focus": {
57
+ "tickers": "HDFCBANK.NS, ICICIBANK.NS, SBIN.NS, KOTAKBANK.NS",
58
+ "weights": "0.3, 0.3, 0.2, 0.2",
59
+ },
60
+ "Diversified (Mock)": {
61
+ "tickers": "RELIANCE.NS, HDFCBANK.NS, INFY.NS, HINDUNILVR.NS, ITC.NS",
62
+ "weights": "0.2, 0.2, 0.2, 0.2, 0.2",
63
+ },
64
+ "Pharma Focus": {
65
+ "tickers": "SUNPHARMA.NS, DRREDDY.NS, CIPLA.NS, DIVISLAB.NS",
66
+ "weights": "0.25, 0.25, 0.25, 0.25",
67
+ },
68
+ "Select Example...": {"tickers": "", "weights": ""}, # Placeholder
69
+ }
70
+
71
+ # -------------------------
72
+ # Gemini / LLM Config
73
+ # -------------------------
74
+ GEMINI_MODEL_NAME = "gemini-2.0-flash-lite-001"
75
+
76
+ GEMINI_SYSTEM_PROMPT = """
77
+ You are a senior Indian equity and portfolio analyst.
78
+ You explain historical crisis behaviour of portfolios in short, clear bullet-point insights.
79
+
80
+ Guidelines:
81
+ - Focus on Indian equity context and the specific crisis mentioned.
82
+ - Use simple language but keep it financially accurate.
83
+ - Use only the metrics scorecard provided (no assumptions about individual sectors or stocks).
84
+ - Be concise: 5–7 bullet points, maximum ~200 words.
85
+ - Avoid giving investment advice; stay descriptive and educational.
86
+ """
87
+
88
+ GEMINI_USER_PROMPT_TEMPLATE = """
89
+ You are analysing how an investor's portfolio behaved during the historical crisis: {crisis_name}.
90
+
91
+ Here is a scorecard comparing their portfolio vs NIFTY during that crisis window:
92
+
93
+ {metrics_text}
94
+
95
+ TASK:
96
+ - Write 5–7 short bullet points.
97
+ - Explain:
98
+ - How the portfolio did vs NIFTY overall.
99
+ - What the volatility and max drawdown imply.
100
+ - Whether the portfolio looked relatively resilient or vulnerable in that period.
101
+ - Keep it under 200 words.
102
+ - Do NOT mention that you are an AI model or talk about prompts.
103
+
104
+ Extra style/emphasis from the user (if any):
105
+ {extra_instructions}
106
+ """
logic.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------- LOGIC ----------------
2
+ # This file contains all the data processing and simulation logic.
3
+
4
+ import pandas as pd
5
+ import numpy as np
6
+ import yfinance as yf
7
+ import plotly.express as px
8
+ import plotly.graph_objects as go
9
+ import matplotlib.pyplot as plt
10
+ import warnings
11
+ from datetime import timedelta
12
+
13
+ # Import configuration variables
14
+ from config import (
15
+ CRISIS_PERIODS,
16
+ BENCHMARK_TICKER,
17
+ BENCHMARK_NAME,
18
+ RECOVERY_DAYS,
19
+ CRISIS_SUMMARY,
20
+ CRISIS_INSIGHTS,
21
+ GEMINI_MODEL_NAME,
22
+ GEMINI_SYSTEM_PROMPT,
23
+ GEMINI_USER_PROMPT_TEMPLATE,
24
+ )
25
+
26
+ warnings.filterwarnings("ignore")
27
+
28
+ try:
29
+ import google.generativeai as genai
30
+ except ImportError:
31
+ genai = None
32
+
33
+ # ---------------- UTILS ----------------
34
+
35
+ def _ensure_ns_suffix(t):
36
+ """Ensures a ticker has the .NS suffix for Indian stocks."""
37
+ t = t.strip().upper()
38
+ if t.startswith("^") or "." in t:
39
+ return t
40
+ return t + ".NS"
41
+
42
+ def _fetch_prices(tickers, start, end):
43
+ """Fetches historical price data from yfinance."""
44
+ raw = yf.download(tickers, start=start, end=end, progress=False, auto_adjust=True)
45
+ if "Adj Close" in raw:
46
+ df = raw["Adj Close"]
47
+ elif "Close" in raw:
48
+ df = raw["Close"]
49
+ else:
50
+ df = raw
51
+ if isinstance(df, pd.Series):
52
+ df = df.to_frame()
53
+ # Handle single ticker download which doesn't have multi-index cols
54
+ if not isinstance(df.columns, pd.MultiIndex):
55
+ df.columns = [c.upper() for c in df.columns]
56
+ return df
57
+
58
+ def calc_metrics(series, benchmark_returns=None):
59
+ """Calculates key performance metrics for a time series."""
60
+ returns = series.pct_change().dropna()
61
+ if returns.empty:
62
+ return {
63
+ "total_return": 0,
64
+ "volatility": 0,
65
+ "VaR_95": 0,
66
+ "CAGR": 0,
67
+ "max_drawdown": 0,
68
+ "beta": None,
69
+ }
70
+
71
+ total_return = (series.iloc[-1] / series.iloc[0]) - 1
72
+ vol = returns.std() * np.sqrt(252)
73
+ VaR_95 = returns.quantile(0.05)
74
+ days = (series.index[-1] - series.index[0]).days
75
+ years = max(days / 365.25, 1 / 365.25)
76
+ CAGR = (series.iloc[-1] / series.iloc[0]) ** (1 / years) - 1
77
+ drawdown = (series / series.cummax()) - 1
78
+ max_dd = drawdown.min()
79
+ beta = None
80
+ if benchmark_returns is not None:
81
+ rr, br = returns.align(benchmark_returns, join="inner")
82
+ if len(rr) > 10:
83
+ cov = np.cov(rr, br)[0, 1]
84
+ varb = np.var(br)
85
+ beta = cov / varb if varb != 0 else np.nan
86
+ return {
87
+ "total_return": total_return,
88
+ "volatility": vol,
89
+ "VaR_95": VaR_95,
90
+ "CAGR": CAGR,
91
+ "max_drawdown": max_dd,
92
+ "beta": beta,
93
+ }
94
+
95
+ def sector_from_ticker(t):
96
+ """Fetches sector and industry info for a ticker."""
97
+ try:
98
+ info = yf.Ticker(t).info
99
+ return info.get("sector", "Unknown"), info.get("industry", "Unknown")
100
+ except Exception:
101
+ return "Unknown", "Unknown"
102
+
103
+ def format_pct(x):
104
+ """Formats a float as a percentage string."""
105
+ if x is None or (isinstance(x, float) and np.isnan(x)):
106
+ return "N/A"
107
+ return f"{x * 100:.2f}%"
108
+
109
+ # ---------------- GEMINI AI HELPER ----------------
110
+
111
+ def generate_gemini_insights(
112
+ api_key: str,
113
+ crisis_name: str,
114
+ metrics_md: str,
115
+ extra_instructions: str = "",
116
+ ) -> str:
117
+ """Call Gemini to get AI-generated insights based on metrics."""
118
+ if not api_key:
119
+ return "ℹ️ To see AI-generated insights, please paste a valid Gemini API key."
120
+
121
+ if genai is None:
122
+ return "⚠️ google-generativeai is not installed. Run `pip install google-generativeai` and retry."
123
+
124
+ user_prompt = GEMINI_USER_PROMPT_TEMPLATE.format(
125
+ crisis_name=crisis_name,
126
+ metrics_text=metrics_md,
127
+ extra_instructions=extra_instructions or "None.",
128
+ )
129
+
130
+ try:
131
+ genai.configure(api_key=api_key)
132
+ model = genai.GenerativeModel(
133
+ GEMINI_MODEL_NAME,
134
+ system_instruction=GEMINI_SYSTEM_PROMPT.strip(),
135
+ generation_config={"max_output_tokens": 256},
136
+ )
137
+ response = model.generate_content(user_prompt)
138
+ text = getattr(response, "text", "") or ""
139
+ if not text.strip():
140
+ return "⚠️ Gemini did not return any text. Please check your API key, quota, or try again."
141
+ return text.strip()
142
+ except Exception as e:
143
+ return f"⚠️ Gemini call failed: {e}"
144
+
145
+ # ---------------- SIMULATION ----------------
146
+
147
+ def run_crisis_simulation(
148
+ crisis,
149
+ uploaded,
150
+ tickers_str,
151
+ weights_str,
152
+ include_etf,
153
+ gemini_api_key="",
154
+ gemini_extra_prompt="",
155
+ ):
156
+ """
157
+ The main simulation function.
158
+ Takes user inputs, processes the portfolio, fetches data,
159
+ and returns all outputs for the Gradio interface.
160
+ """
161
+
162
+ # --- 1. Parse Portfolio ---
163
+ if uploaded is not None:
164
+ try:
165
+ df = pd.read_csv(uploaded.name if hasattr(uploaded, "name") else uploaded)
166
+ except Exception as e:
167
+ return (
168
+ None,
169
+ f"Error reading CSV: {e}",
170
+ None,
171
+ None,
172
+ None,
173
+ "",
174
+ "No AI insights (CSV error).",
175
+ )
176
+ else:
177
+ try:
178
+ tickers = [t.strip() for t in tickers_str.split(",") if t.strip()]
179
+ weights = [float(w) for w in weights_str.split(",") if w.strip()]
180
+ if not tickers or not weights or len(tickers) != len(weights):
181
+ return (
182
+ None,
183
+ "Error: Mismatch between tickers and weights, or fields are empty.",
184
+ None,
185
+ None,
186
+ None,
187
+ "",
188
+ "No AI insights (input mismatch).",
189
+ )
190
+ df = pd.DataFrame({"Ticker": tickers, "Weight": weights})
191
+ except ValueError:
192
+ return (
193
+ None,
194
+ "Error: Weights must be numbers.",
195
+ None,
196
+ None,
197
+ None,
198
+ "",
199
+ "No AI insights (weights error).",
200
+ )
201
+
202
+ if df.empty or "Ticker" not in df or "Weight" not in df:
203
+ return (
204
+ None,
205
+ "Error: Invalid portfolio. Check inputs.",
206
+ None,
207
+ None,
208
+ None,
209
+ "",
210
+ "No AI insights (invalid portfolio).",
211
+ )
212
+
213
+ df["Ticker"] = df["Ticker"].apply(_ensure_ns_suffix)
214
+
215
+ # --- 2. Normalize Weights (with ETF logic) ---
216
+ try:
217
+ if include_etf:
218
+ # Scale user's portfolio to 95%
219
+ df["Weight"] = (
220
+ df["Weight"].astype(float) / df["Weight"].astype(float).sum()
221
+ ) * 0.95
222
+ # Add the 5% ETF
223
+ etf_row = pd.DataFrame([{"Ticker": "NIFTYBEES.NS", "Weight": 0.05}])
224
+ df = pd.concat([df, etf_row], ignore_index=True)
225
+ else:
226
+ # Normalize user's portfolio to 100%
227
+ df["Weight"] = df["Weight"].astype(float) / df["Weight"].astype(float).sum()
228
+ except ZeroDivisionError:
229
+ return (
230
+ None,
231
+ "Error: Portfolio weights sum to zero.",
232
+ None,
233
+ None,
234
+ None,
235
+ "",
236
+ "No AI insights (weights zero).",
237
+ )
238
+
239
+ # --- 3. Fetch Data ---
240
+ start, end = CRISIS_PERIODS[crisis]
241
+ recovery_end = pd.to_datetime(end) + pd.Timedelta(days=RECOVERY_DAYS)
242
+
243
+ tickers = list(df["Ticker"].unique()) + [BENCHMARK_TICKER]
244
+
245
+ prices = _fetch_prices(tickers, start, recovery_end)
246
+ if prices.empty:
247
+ return (
248
+ None,
249
+ "No data found. Some tickers may not exist historically.",
250
+ None,
251
+ None,
252
+ None,
253
+ "",
254
+ "No AI insights (no data).",
255
+ )
256
+
257
+ # Ensure all required tickers were fetched
258
+ fetched_tickers = [c.upper() for c in prices.columns]
259
+ required_tickers = [t.upper() for t in df["Ticker"]] + [BENCHMARK_TICKER.upper()]
260
+
261
+ missing = [t for t in required_tickers if t not in fetched_tickers]
262
+ if missing:
263
+ return (
264
+ None,
265
+ f"Error: Could not fetch data for: {', '.join(missing)}",
266
+ None,
267
+ None,
268
+ None,
269
+ "",
270
+ "No AI insights (missing tickers).",
271
+ )
272
+
273
+ prices.ffill(inplace=True)
274
+ crisis_window = prices.loc[start:end]
275
+
276
+ if BENCHMARK_TICKER not in crisis_window.columns:
277
+ return (
278
+ None,
279
+ f"Error: Could not fetch benchmark {BENCHMARK_NAME} data for this period.",
280
+ None,
281
+ None,
282
+ None,
283
+ "",
284
+ "No AI insights (benchmark error).",
285
+ )
286
+
287
+ bench = crisis_window[BENCHMARK_TICKER]
288
+
289
+ # --- 4. Calculate Portfolio Performance ---
290
+ df_aligned = df.set_index("Ticker")
291
+ df_aligned.index = df_aligned.index.str.upper()
292
+
293
+ # Filter price columns to only those in our portfolio
294
+ portfolio_prices = crisis_window[df_aligned.index]
295
+
296
+ norm = (portfolio_prices / portfolio_prices.iloc[0]) * 100
297
+ weighted = (norm * df_aligned["Weight"]).sum(axis=1)
298
+ weighted.name = "Portfolio"
299
+ bench_norm = (bench / bench.iloc[0]) * 100
300
+
301
+ port_m = calc_metrics(weighted, bench.pct_change())
302
+ bench_m = calc_metrics(bench_norm)
303
+
304
+ # --- 5. Generate Outputs (Metrics Table) ---
305
+ beta_val = port_m["beta"]
306
+ if beta_val is None or (isinstance(beta_val, float) and np.isnan(beta_val)):
307
+ beta_str = "N/A"
308
+ else:
309
+ beta_str = f"{beta_val:.2f}"
310
+
311
+ metrics_md = f"""### Simulation: {crisis}
312
+ | Metric | Portfolio | {BENCHMARK_NAME} |
313
+ |:---|---:|---:|
314
+ | **Total Return** | **{format_pct(port_m['total_return'])}** | **{format_pct(bench_m['total_return'])}** |
315
+ | Max Drawdown | {format_pct(port_m['max_drawdown'])} | {format_pct(bench_m['max_drawdown'])} |
316
+ | Volatility (Ann.) | {format_pct(port_m['volatility'])} | {format_pct(bench_m['volatility'])} |
317
+ | CAGR | {format_pct(port_m['CAGR'])} | {format_pct(bench_m['CAGR'])} |
318
+ | Beta | {beta_str} | - |
319
+ | VaR (95%, Daily) | {format_pct(port_m['VaR_95'])} | {format_pct(bench_m['VaR_95'])} |
320
+ """
321
+
322
+ # --- 6. Generate Outputs (Performance Plot) ---
323
+ fig = go.Figure()
324
+ fig.add_trace(
325
+ go.Scatter(
326
+ x=weighted.index,
327
+ y=weighted.values,
328
+ name="Portfolio",
329
+ mode="lines",
330
+ line=dict(width=3, color="#1E88E5"),
331
+ )
332
+ )
333
+ fig.add_trace(
334
+ go.Scatter(
335
+ x=bench_norm.index,
336
+ y=bench_norm.values,
337
+ name=BENCHMARK_NAME,
338
+ mode="lines",
339
+ line=dict(width=2, color="#FFC107", dash="dot"),
340
+ )
341
+ )
342
+ fig.update_layout(
343
+ title=f"<b>{crisis}</b>: Portfolio vs Benchmark Performance",
344
+ template="plotly_white",
345
+ xaxis_title="Date",
346
+ yaxis_title="Normalized Value (Base 100)",
347
+ height=450,
348
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
349
+ )
350
+
351
+ # --- 7. Generate Outputs (Sector Analysis) ---
352
+ df["Sector"], df["Industry"] = zip(*df["Ticker"].map(sector_from_ticker))
353
+ sector_dd = []
354
+ for t in df.Ticker:
355
+ if t.upper() in crisis_window.columns:
356
+ ser = crisis_window[t.upper()]
357
+ dd = (ser / ser.cummax() - 1).min()
358
+ sector_dd.append(dd)
359
+ else:
360
+ sector_dd.append(0) # Ticker wasn't in crisis window
361
+
362
+ df["Drawdown"] = sector_dd
363
+
364
+ # Aggregate weighted drawdown by sector
365
+ sec_agg = df.groupby("Sector").apply(
366
+ lambda d: np.average(d["Drawdown"], weights=d["Weight"] / d["Weight"].sum())
367
+ )
368
+ sec_agg = sec_agg.sort_values()
369
+
370
+ sec_fig = px.bar(
371
+ sec_agg * 100,
372
+ y=sec_agg.index,
373
+ x=sec_agg.values,
374
+ orientation="h",
375
+ title="Weighted Max Drawdown by Sector",
376
+ labels={"x": "Max Drawdown (%)", "y": "Sector"},
377
+ )
378
+ sec_fig.update_layout(
379
+ template="plotly_white",
380
+ yaxis={"categoryorder": "total ascending"},
381
+ )
382
+
383
+ # --- 8. Generate Outputs (Insights & Pie Chart) ---
384
+ ins = [
385
+ f"### Insights for: {crisis}",
386
+ f"_{CRISIS_SUMMARY.get(crisis, 'No summary available.')}_",
387
+ ]
388
+ if crisis in CRISIS_INSIGHTS:
389
+ for s, txt in CRISIS_INSIGHTS[crisis].items():
390
+ ins.append(f"- **{s}**: {txt}")
391
+ insights_md = "\n".join(ins)
392
+
393
+ # --- 8. Generate Outputs (Insights & Pie Chart) ---
394
+ ins = [
395
+ f"### Insights for: {crisis}",
396
+ f"_{CRISIS_SUMMARY.get(crisis, 'No summary available.')}_",
397
+ ]
398
+ if crisis in CRISIS_INSIGHTS:
399
+ for s, txt in CRISIS_INSIGHTS[crisis].items():
400
+ ins.append(f"- **{s}**: {txt}")
401
+ insights_md = "\n".join(ins)
402
+
403
+ # --- 8. Generate Outputs (Insights & Pie Chart) ---
404
+ ins = [
405
+ f"### Insights for: {crisis}",
406
+ f"_{CRISIS_SUMMARY.get(crisis, 'No summary available.')}_",
407
+ ]
408
+ if crisis in CRISIS_INSIGHTS:
409
+ for s, txt in CRISIS_INSIGHTS[crisis].items():
410
+ ins.append(f"- **{s}**: {txt}")
411
+ insights_md = "\n".join(ins)
412
+
413
+ # --- 8. Generate Outputs (Insights & Pie Chart) ---
414
+ ins = [
415
+ f"### Insights for: {crisis}",
416
+ f"_{CRISIS_SUMMARY.get(crisis, 'No summary available.')}_",
417
+ ]
418
+ if crisis in CRISIS_INSIGHTS:
419
+ for s, txt in CRISIS_INSIGHTS[crisis].items():
420
+ ins.append(f"- **{s}**: {txt}")
421
+ insights_md = "\n".join(ins)
422
+
423
+ # --- Pie chart: final portfolio weights (including ETF if added) ---
424
+ pie_df = df[["Ticker", "Weight"]].copy()
425
+ pie_df["Ticker"] = pie_df["Ticker"].astype(str)
426
+ pie_df["Weight"] = pd.to_numeric(pie_df["Weight"], errors="raise")
427
+
428
+ wsum = pie_df["Weight"].sum()
429
+ if wsum <= 0:
430
+ raise ValueError(f"Pie chart error: portfolio weights sum to {wsum}.")
431
+ pie_df["Weight"] = pie_df["Weight"] / wsum
432
+
433
+ print("DEBUG pie_df for pie chart:\n", pie_df)
434
+ print("DEBUG weight sum:", pie_df["Weight"].sum())
435
+
436
+ # Matplotlib pie chart
437
+ fig_pie, ax = plt.subplots(figsize=(4, 4))
438
+ ax.pie(
439
+ pie_df["Weight"].values,
440
+ labels=pie_df["Ticker"].values,
441
+ autopct="%1.1f%%",
442
+ startangle=90,
443
+ )
444
+ ax.set_title("Final Portfolio Allocation")
445
+ ax.axis("equal")
446
+
447
+ # --- 9. Logs & AI Insights ---
448
+ log_message = f"✅ Simulation Complete. Received weights: '{weights_str}'"
449
+
450
+ gemini_insights = generate_gemini_insights(
451
+ api_key=gemini_api_key or "",
452
+ crisis_name=crisis,
453
+ metrics_md=metrics_md,
454
+ extra_instructions=gemini_extra_prompt or "",
455
+ )
456
+
457
+ return fig, metrics_md, sec_fig, insights_md, fig_pie, log_message, gemini_insights
458
+
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ pandas
2
+ numpy
3
+ yfinance
4
+ plotly
5
+ gradio
6
+ matplotlib
7
+ google-generativeai