AshenH commited on
Commit
a2fac34
Β·
verified Β·
1 Parent(s): 56c4a12

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +85 -15
app.py CHANGED
@@ -180,17 +180,28 @@ def irr_sql(cols: List[str]) -> str:
180
  t_expr += " ELSE NULL END"
181
  y_expr = "(Interest_rate/100.0)" if has_ir else "0.0"
182
  return f"""
 
 
 
 
 
 
 
 
 
183
  SELECT
184
- bucket,
185
- SUM(Portfolio_value) / 1000000.0 AS "Portfolio Value (LKR Mn)"
186
- FROM {VIEW_FQN}
187
- GROUP BY bucket
 
 
188
  """
189
 
190
  # =========================
191
  # Dashboard callback
192
  # =========================
193
- def run_dashboard() -> Tuple[str, str, str, str, str, Any, pd.DataFrame, pd.DataFrame, str, pd.DataFrame]:
194
  """
195
  Returns:
196
  status, as_of, a1_text, a2_text, a3_text, figure, ladder_df, irr_df,
@@ -199,6 +210,26 @@ def run_dashboard() -> Tuple[str, str, str, str, str, Any, pd.DataFrame, pd.Data
199
  try:
200
  conn = connect_md()
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  # 1) Discover columns & build view
203
  cols = discover_columns(conn, TABLE_FQN)
204
  ensure_view(conn, cols)
@@ -211,20 +242,35 @@ def run_dashboard() -> Tuple[str, str, str, str, str, Any, pd.DataFrame, pd.Data
211
  as_of = str(tmp["d"].iloc[0])[:10]
212
 
213
  # 3) KPIs
214
- kpi = conn.execute(KPI_SQL).fetchdf()
 
 
215
  assets_t1 = safe_num(kpi["assets_t1"].iloc[0]) if not kpi.empty else 0.0
216
  sof_t1 = safe_num(kpi["sof_t1"].iloc[0]) if not kpi.empty else 0.0
217
  net_gap = safe_num(kpi["net_gap_t1"].iloc[0]) if not kpi.empty else 0.0
218
 
219
  # 4) Ladder, IRR, and Gap Drivers
220
- ladder = conn.execute(LADDER_SQL).fetchdf()
221
- irr = conn.execute(irr_sql(cols)).fetchdf()
222
- drivers = conn.execute(GAP_DRIVERS_SQL).fetchdf()
 
 
 
 
223
 
 
 
224
  if "Amount (LKR Mn)" in ladder.columns:
225
- ladder["Amount (LKR Mn)"] = ladder["Amount (LKR Mn)"].map('{:,.2f}'.format)
226
- if "Portfolio Value (LKR Mn)" in irr.columns:
227
- irr["Portfolio Value (LKR Mn)"] = irr["Portfolio Value (LKR Mn)"].map('{:,.2f}'.format)
 
 
 
 
 
 
 
228
  if "Amount (LKR Mn)" in drivers.columns:
229
  drivers_display = drivers.copy()
230
  drivers_display["Amount (LKR Mn)"] = drivers_display["Amount (LKR Mn)"].map('{:,.2f}'.format)
@@ -264,6 +310,14 @@ def run_dashboard() -> Tuple[str, str, str, str, str, Any, pd.DataFrame, pd.Data
264
  # Note: The data source does not contain features for seasonal analysis (e.g., day_of_week, is_month_end).
265
  explain_text += "* **Seasonal Pattern:** Analysis not possible without relevant time-series features in the source data."
266
 
 
 
 
 
 
 
 
 
267
  status = f"βœ… OK (as of {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')})"
268
  return (
269
  status,
@@ -272,8 +326,8 @@ def run_dashboard() -> Tuple[str, str, str, str, str, Any, pd.DataFrame, pd.Data
272
  a2_text,
273
  a3_text,
274
  fig,
275
- ladder,
276
- irr,
277
  explain_text,
278
  drivers_display,
279
  )
@@ -305,6 +359,18 @@ with gr.Blocks(title=APP_TITLE) as demo:
305
 
306
  with gr.Row():
307
  refresh_btn = gr.Button("πŸ”„ Refresh", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
  with gr.Row():
310
  as_of = gr.Textbox(label="As of date", interactive=False)
@@ -317,7 +383,10 @@ with gr.Blocks(title=APP_TITLE) as demo:
317
  with gr.Column(scale=2):
318
  chart = gr.Plot(label="Maturity Ladder")
319
  ladder_df = gr.Dataframe(label="Ladder Detail")
320
- irr_df = gr.Dataframe(label="Interest-Rate Risk (approx)")
 
 
 
321
  with gr.Column(scale=1):
322
  explain_text = gr.Markdown("Analysis of the T+1 gap will appear here...")
323
  drivers_df = gr.Dataframe(
@@ -327,6 +396,7 @@ with gr.Blocks(title=APP_TITLE) as demo:
327
 
328
  refresh_btn.click(
329
  fn=run_dashboard,
 
330
  outputs=[status, as_of, a1, a2, a3, chart, ladder_df, irr_df, explain_text, drivers_df],
331
  )
332
 
 
180
  t_expr += " ELSE NULL END"
181
  y_expr = "(Interest_rate/100.0)" if has_ir else "0.0"
182
  return f"""
183
+ WITH irr_calcs AS (
184
+ SELECT
185
+ bucket,
186
+ Portfolio_value AS pv,
187
+ -- Modified Duration = Macaulay Duration / (1 + yield)
188
+ -- We approximate Macaulay Duration with time-to-maturity in years (t_expr)
189
+ ({t_expr}) / (1 + {y_expr}) AS mod_dur
190
+ FROM {VIEW_FQN}
191
+ )
192
  SELECT
193
+ bucket,
194
+ SUM(pv) / 1000000.0 AS "Portfolio Value (LKR Mn)",
195
+ -- BPV (DV01) = SUM(Portfolio Value * Modified Duration * 0.0001)
196
+ SUM(pv * mod_dur * 0.0001) AS "BPV (DV01)"
197
+ FROM irr_calcs
198
+ GROUP BY bucket;
199
  """
200
 
201
  # =========================
202
  # Dashboard callback
203
  # =========================
204
+ def run_dashboard(scenario: str) -> Tuple[str, str, str, str, str, Any, pd.DataFrame, pd.DataFrame, str, pd.DataFrame]:
205
  """
206
  Returns:
207
  status, as_of, a1_text, a2_text, a3_text, figure, ladder_df, irr_df,
 
210
  try:
211
  conn = connect_md()
212
 
213
+ # --- Scenario Application ---
214
+ # Create a temporary view with scenario adjustments.
215
+ # Subsequent queries will use this stressed view.
216
+ stressed_view_fqn = f"{VIEW_FQN}_stressed"
217
+ runoff_factor = 1.0
218
+ rate_shock_bps = 0.0
219
+
220
+ if scenario == "Liquidity Stress: High Deposit Runoff":
221
+ runoff_factor = 0.8 # 20% runoff
222
+ elif scenario == "IRR Stress: Rate Shock (+200bps)":
223
+ rate_shock_bps = 200.0
224
+
225
+ scenario_sql = f"""
226
+ CREATE OR REPLACE TEMP VIEW {stressed_view_fqn} AS
227
+ SELECT *,
228
+ CASE WHEN lower(product) IN ('savings', 'fd', 'td', 'term_deposit') THEN Portfolio_value * {runoff_factor} ELSE Portfolio_value END AS stressed_pv
229
+ FROM {VIEW_FQN};
230
+ """
231
+ conn.execute(scenario_sql)
232
+
233
  # 1) Discover columns & build view
234
  cols = discover_columns(conn, TABLE_FQN)
235
  ensure_view(conn, cols)
 
242
  as_of = str(tmp["d"].iloc[0])[:10]
243
 
244
  # 3) KPIs
245
+ # Modify queries to use the stressed view and value column
246
+ kpi_sql_stressed = KPI_SQL.replace(f"FROM {VIEW_FQN}", f"FROM {stressed_view_fqn}").replace("Portfolio_value", "stressed_pv")
247
+ kpi = conn.execute(kpi_sql_stressed).fetchdf()
248
  assets_t1 = safe_num(kpi["assets_t1"].iloc[0]) if not kpi.empty else 0.0
249
  sof_t1 = safe_num(kpi["sof_t1"].iloc[0]) if not kpi.empty else 0.0
250
  net_gap = safe_num(kpi["net_gap_t1"].iloc[0]) if not kpi.empty else 0.0
251
 
252
  # 4) Ladder, IRR, and Gap Drivers
253
+ ladder_sql_stressed = LADDER_SQL.replace(f"FROM {VIEW_FQN}", f"FROM {stressed_view_fqn}").replace("Portfolio_value", "stressed_pv")
254
+ drivers_sql_stressed = GAP_DRIVERS_SQL.replace(f"FROM {VIEW_FQN}", f"FROM {stressed_view_fqn}").replace("Portfolio_value", "stressed_pv")
255
+ irr_sql_stressed = irr_sql(cols).replace(f"FROM {VIEW_FQN}", f"FROM {stressed_view_fqn}").replace("Portfolio_value", "stressed_pv")
256
+
257
+ ladder = conn.execute(ladder_sql_stressed).fetchdf()
258
+ irr = conn.execute(irr_sql_stressed).fetchdf()
259
+ drivers = conn.execute(drivers_sql_stressed).fetchdf()
260
 
261
+ # Create display copies of dataframes and format them for the UI
262
+ ladder_display = ladder.copy()
263
  if "Amount (LKR Mn)" in ladder.columns:
264
+ ladder_display["Amount (LKR Mn)"] = ladder_display["Amount (LKR Mn)"].map('{:,.2f}'.format)
265
+ else:
266
+ ladder_display = pd.DataFrame()
267
+
268
+ # Format IRR table
269
+ irr_display = irr.copy()
270
+ if not irr_display.empty:
271
+ irr_display["Portfolio Value (LKR Mn)"] = irr_display["Portfolio Value (LKR Mn)"].map('{:,.2f}'.format)
272
+ irr_display["BPV (DV01)"] = irr_display["BPV (DV01)"].map('{:,.2f}'.format)
273
+
274
  if "Amount (LKR Mn)" in drivers.columns:
275
  drivers_display = drivers.copy()
276
  drivers_display["Amount (LKR Mn)"] = drivers_display["Amount (LKR Mn)"].map('{:,.2f}'.format)
 
310
  # Note: The data source does not contain features for seasonal analysis (e.g., day_of_week, is_month_end).
311
  explain_text += "* **Seasonal Pattern:** Analysis not possible without relevant time-series features in the source data."
312
 
313
+ # Add scenario explanation for IRR stress
314
+ if scenario == "IRR Stress: Rate Shock (+200bps)" and not irr.empty:
315
+ net_bpv = irr["BPV (DV01)"].sum()
316
+ eve_impact = net_bpv * rate_shock_bps
317
+ eve_impact_mn = eve_impact / 1_000_000
318
+ explain_text += f"\n\n### IRR Stress Scenario Impact\n* A **+{rate_shock_bps:.0f} bps** rate shock is projected to change the portfolio's Economic Value by **LKR {eve_impact_mn:,.2f} Mn**."
319
+
320
+
321
  status = f"βœ… OK (as of {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')})"
322
  return (
323
  status,
 
326
  a2_text,
327
  a3_text,
328
  fig,
329
+ ladder_display,
330
+ irr_display,
331
  explain_text,
332
  drivers_display,
333
  )
 
359
 
360
  with gr.Row():
361
  refresh_btn = gr.Button("πŸ”„ Refresh", variant="primary")
362
+ theme_btn = gr.Button("πŸŒ— Toggle Theme")
363
+ theme_btn.click(
364
+ None,
365
+ None,
366
+ _js="() => { document.querySelector('html').classList.toggle('dark'); }"
367
+ )
368
+
369
+ scenario_dd = gr.Dropdown(
370
+ label="Select Stress Scenario",
371
+ choices=["Baseline", "Liquidity Stress: High Deposit Runoff", "IRR Stress: Rate Shock (+200bps)"],
372
+ value="Baseline"
373
+ )
374
 
375
  with gr.Row():
376
  as_of = gr.Textbox(label="As of date", interactive=False)
 
383
  with gr.Column(scale=2):
384
  chart = gr.Plot(label="Maturity Ladder")
385
  ladder_df = gr.Dataframe(label="Ladder Detail")
386
+ irr_df = gr.Dataframe(
387
+ label="Interest-Rate Risk (BPV/DV01)",
388
+ headers=["Bucket", "Portfolio Value (LKR Mn)", "BPV (DV01)"]
389
+ )
390
  with gr.Column(scale=1):
391
  explain_text = gr.Markdown("Analysis of the T+1 gap will appear here...")
392
  drivers_df = gr.Dataframe(
 
396
 
397
  refresh_btn.click(
398
  fn=run_dashboard,
399
+ inputs=[scenario_dd],
400
  outputs=[status, as_of, a1, a2, a3, chart, ladder_df, irr_df, explain_text, drivers_df],
401
  )
402