AshenH commited on
Commit
3ef1e5c
Β·
verified Β·
1 Parent(s): 9e0ef17

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +195 -85
app.py CHANGED
@@ -34,6 +34,7 @@ PRODUCT_SOF = [
34
  def connect_md() -> duckdb.DuckDBPyConnection:
35
  token = os.environ.get("MOTHERDUCK_TOKEN", "")
36
  if not token:
 
37
  raise RuntimeError("MOTHERDUCK_TOKEN is not set. Add it in Space β†’ Settings β†’ Secrets.")
38
  return duckdb.connect(f"md:?motherduck_token={token}")
39
 
@@ -66,6 +67,7 @@ def build_view_sql(existing_cols: List[str]) -> str:
66
  if c.lower() in existing_cols:
67
  sel.append(c)
68
  else:
 
69
  if c in ("Portfolio_value", "Interest_rate", "days_to_maturity", "months"):
70
  sel.append(f"CAST(NULL AS DOUBLE) AS {c}")
71
  else:
@@ -113,13 +115,14 @@ def plot_ladder(df: pd.DataFrame):
113
  ax.axis("off")
114
  return fig
115
  pivot = df.pivot(index="time_bucket", columns="bucket", values="Amount (LKR Mn)").fillna(0)
 
116
  order = ["T+1", "T+2..7", "T+8..30", "T+31+"]
117
  pivot = pivot.reindex(order)
118
  fig, ax = plt.subplots(figsize=(7, 4))
119
  assets = pivot["Assets"] if "Assets" in pivot.columns else zeros_like_index(pivot.index)
120
  sof = pivot["SoF"] if "SoF" in pivot.columns else zeros_like_index(pivot.index)
121
- ax.bar(pivot.index, assets, label="Assets")
122
- ax.bar(pivot.index, -sof, label="SoF")
123
  ax.axhline(0, color="gray", lw=1)
124
  ax.set_ylabel("LKR (Mn)")
125
  ax.set_title("Maturity Ladder (Assets vs SoF)")
@@ -142,7 +145,7 @@ SELECT
142
  COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS sof_t1,
143
  COALESCE(SUM(CASE WHEN bucket='Assets' AND days_to_maturity<=1 THEN Portfolio_value END),0)
144
  - COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS net_gap_t1
145
- FROM {VIEW_FQN};
146
  """
147
 
148
  LADDER_SQL = f"""
@@ -154,8 +157,8 @@ SELECT
154
  ELSE 'T+31+'
155
  END AS time_bucket,
156
  bucket,
157
- SUM(Portfolio_value) / 1000000.0 AS "Amount (LKR Mn)"
158
- FROM {VIEW_FQN}
159
  GROUP BY 1,2
160
  ORDER BY 1,2;
161
  """
@@ -164,76 +167,117 @@ GAP_DRIVERS_SQL = f"""
164
  SELECT
165
  product,
166
  bucket,
167
- SUM(Portfolio_value) / 1000000.0 AS "Amount (LKR Mn)"
168
- FROM {VIEW_FQN}
169
  WHERE days_to_maturity <= 1
170
  GROUP BY 1, 2
171
  ORDER BY 3 DESC;
172
  """
173
 
174
- def irr_sql(cols: List[str]) -> str:
 
 
175
  has_months = "months" in cols
176
  has_ir = "interest_rate" in cols
 
 
177
  t_expr = "CASE WHEN days_to_maturity IS NOT NULL THEN days_to_maturity/365.0"
178
  if has_months:
179
  t_expr += " WHEN months IS NOT NULL THEN months/12.0"
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, runoff_pct: float, rate_shock_bps_input: float) -> 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,
208
- explain_text, drivers_df
209
  """
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 = "positions_v_stressed"
217
  runoff_factor = 1.0
218
- rate_shock_bps = 0.0
219
 
220
  if scenario == "Liquidity Stress: High Deposit Runoff" and runoff_pct > 0:
221
  runoff_factor = (100.0 - runoff_pct) / 100.0
 
 
222
  elif scenario == "IRR Stress: Rate Shock" and rate_shock_bps_input != 0:
223
  rate_shock_bps = rate_shock_bps_input
 
 
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)
236
-
237
  # 2) As-of (optional)
238
  as_of = "N/A"
239
  if "as_of_date" in cols:
@@ -241,50 +285,98 @@ def run_dashboard(scenario: str, runoff_pct: float, rate_shock_bps_input: float)
241
  if not tmp.empty and not pd.isna(tmp["d"].iloc[0]):
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)
277
  else:
278
  drivers_display = pd.DataFrame()
279
 
280
- # 5) Chart
281
  fig = plot_ladder(ladder)
282
 
283
- # 6) Explanations
284
  assets_t1_mn_str = f"{(assets_t1 / 1_000_000):,.2f}"
285
  sof_t1_mn_str = f"{(sof_t1 / 1_000_000):,.2f}"
286
  net_gap_mn_str = f"{(net_gap / 1_000_000):,.2f}"
287
- gap_sign_str = "positive" if net_gap >= 0 else "negative"
288
 
289
  a1_text = f"The amount of Assets maturing tomorrow (T+1) is **LKR {assets_t1_mn_str} Mn**."
290
  a2_text = f"The amount of Sources of Funds (SoF) maturing tomorrow (T+1) is **LKR {sof_t1_mn_str} Mn**."
@@ -296,27 +388,27 @@ def run_dashboard(scenario: str, runoff_pct: float, rate_shock_bps_input: float)
296
  top_sof_prod = sof_drivers.iloc[0] if not sof_drivers.empty else None
297
  top_asset_prod = asset_drivers.iloc[0] if not asset_drivers.empty else None
298
 
299
- explain_text = f"### Why is the T+1 Gap {gap_sign_str}?\n\n"
 
300
  if top_sof_prod is not None:
301
- explain_text += f"* **Largest Liability Maturity:** The largest outflow comes from `{top_sof_prod['product']}`, with **LKR {top_sof_prod['Amount (LKR Mn)']:,.2f} Mn** maturing.\n"
302
- else:
303
- explain_text += "* **Largest Liability Maturity:** No significant liabilities are maturing tomorrow.\n"
304
-
305
  if top_asset_prod is not None:
306
- explain_text += f"* **Largest Asset Inflow:** The largest inflow comes from `{top_asset_prod['product']}`, with **LKR {top_asset_prod['Amount (LKR Mn)']:,.2f} Mn** maturing.\n"
307
- else:
308
- explain_text += "* **Largest Asset Inflow:** No significant assets are maturing to provide inflows tomorrow.\n"
309
-
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" and rate_shock_bps != 0 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 (
@@ -327,7 +419,8 @@ def run_dashboard(scenario: str, runoff_pct: float, rate_shock_bps_input: float)
327
  a3_text,
328
  fig,
329
  ladder_display,
330
- irr_display,
 
331
  explain_text,
332
  drivers_display,
333
  )
@@ -345,6 +438,7 @@ def run_dashboard(scenario: str, runoff_pct: float, rate_shock_bps_input: float)
345
  fig,
346
  empty_df,
347
  empty_df,
 
348
  "Analysis could not be performed.",
349
  empty_df,
350
  )
@@ -358,7 +452,7 @@ with gr.Blocks(title=APP_TITLE) as demo:
358
  status = gr.Textbox(label="Status", interactive=False, lines=8)
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,
@@ -377,15 +471,21 @@ with gr.Blocks(title=APP_TITLE) as demo:
377
  with gr.Accordion("Stress Scenario Parameters", open=True):
378
  runoff_slider = gr.Slider(
379
  label="Deposit Runoff (%)",
380
- minimum=0, maximum=100, step=1, value=20,
381
  info="For Liquidity Stress: Percentage of key deposits that run off."
382
  )
383
  shock_slider = gr.Slider(
384
- label="Rate Shock (bps)",
385
  minimum=-500, maximum=500, step=25, value=200,
386
- info="For IRR Stress: Parallel shift in the yield curve in basis points."
 
 
 
 
 
387
  )
388
- explain_text = gr.Markdown("Analysis of the T+1 gap will appear here...")
 
389
 
390
  # --- Right Column: KPIs, Charts, and Tables ---
391
  with gr.Column(scale=3):
@@ -398,22 +498,32 @@ with gr.Blocks(title=APP_TITLE) as demo:
398
  chart = gr.Plot(label="Maturity Ladder")
399
 
400
  with gr.Tabs():
401
- with gr.TabItem("Ladder Detail"):
402
- ladder_df = gr.Dataframe()
 
 
 
403
  with gr.TabItem("T+1 Gap Drivers"):
404
  drivers_df = gr.Dataframe(
405
  headers=["Product", "Bucket", "Amount (LKR Mn)"],
 
406
  )
407
- with gr.TabItem("Interest-Rate Risk (BPV/DV01)"):
408
  irr_df = gr.Dataframe(
409
- headers=["Bucket", "Portfolio Value (LKR Mn)", "BPV (DV01)"]
 
 
 
 
 
 
410
  )
411
 
412
  refresh_btn.click(
413
  fn=run_dashboard,
414
- inputs=[scenario_dd, runoff_slider, shock_slider],
415
- outputs=[status, as_of, a1, a2, a3, chart, ladder_df, irr_df, explain_text, drivers_df],
416
  )
417
 
418
  if __name__ == "__main__":
419
- demo.launch()
 
34
  def connect_md() -> duckdb.DuckDBPyConnection:
35
  token = os.environ.get("MOTHERDUCK_TOKEN", "")
36
  if not token:
37
+ # In a real environment, this token should be securely managed
38
  raise RuntimeError("MOTHERDUCK_TOKEN is not set. Add it in Space β†’ Settings β†’ Secrets.")
39
  return duckdb.connect(f"md:?motherduck_token={token}")
40
 
 
67
  if c.lower() in existing_cols:
68
  sel.append(c)
69
  else:
70
+ # Cast nulls for consistency, assuming most positions have these columns
71
  if c in ("Portfolio_value", "Interest_rate", "days_to_maturity", "months"):
72
  sel.append(f"CAST(NULL AS DOUBLE) AS {c}")
73
  else:
 
115
  ax.axis("off")
116
  return fig
117
  pivot = df.pivot(index="time_bucket", columns="bucket", values="Amount (LKR Mn)").fillna(0)
118
+ # Re-order the standard liquidity buckets
119
  order = ["T+1", "T+2..7", "T+8..30", "T+31+"]
120
  pivot = pivot.reindex(order)
121
  fig, ax = plt.subplots(figsize=(7, 4))
122
  assets = pivot["Assets"] if "Assets" in pivot.columns else zeros_like_index(pivot.index)
123
  sof = pivot["SoF"] if "SoF" in pivot.columns else zeros_like_index(pivot.index)
124
+ ax.bar(pivot.index, assets, label="Assets", color="#4CAF50")
125
+ ax.bar(pivot.index, -sof, label="SoF", color="#FF9800")
126
  ax.axhline(0, color="gray", lw=1)
127
  ax.set_ylabel("LKR (Mn)")
128
  ax.set_title("Maturity Ladder (Assets vs SoF)")
 
145
  COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS sof_t1,
146
  COALESCE(SUM(CASE WHEN bucket='Assets' AND days_to_maturity<=1 THEN Portfolio_value END),0)
147
  - COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS net_gap_t1
148
+ FROM positions_v_stressed;
149
  """
150
 
151
  LADDER_SQL = f"""
 
157
  ELSE 'T+31+'
158
  END AS time_bucket,
159
  bucket,
160
+ SUM(stressed_pv) / 1000000.0 AS "Amount (LKR Mn)"
161
+ FROM positions_v_stressed
162
  GROUP BY 1,2
163
  ORDER BY 1,2;
164
  """
 
167
  SELECT
168
  product,
169
  bucket,
170
+ SUM(stressed_pv) / 1000000.0 AS "Amount (LKR Mn)"
171
+ FROM positions_v_stressed
172
  WHERE days_to_maturity <= 1
173
  GROUP BY 1, 2
174
  ORDER BY 3 DESC;
175
  """
176
 
177
+ def get_duration_components_sql(cols: List[str]) -> str:
178
+ """Calculates Modified Duration, Portfolio Value, and Weights for Assets/Liabilities."""
179
+ # Use days_to_maturity as the best proxy for repricing/duration tenor
180
  has_months = "months" in cols
181
  has_ir = "interest_rate" in cols
182
+
183
+ # Time-to-Maturity (in years) used as proxy for Macaulay Duration (T)
184
  t_expr = "CASE WHEN days_to_maturity IS NOT NULL THEN days_to_maturity/365.0"
185
  if has_months:
186
  t_expr += " WHEN months IS NOT NULL THEN months/12.0"
187
+ t_expr += " ELSE 0.0001 END" # Avoid division by zero, use minimal time if unknown
188
+
189
+ # Yield (Interest Rate / 100)
190
+ y_expr = "(Interest_rate/100.0)" if has_ir else "0.05" # Assume 5% if rate missing
191
+
192
  return f"""
193
  WITH irr_calcs AS (
194
  SELECT
195
  bucket,
196
+ stressed_pv,
197
+ -- Approximate Modified Duration = (Time / (1 + Yield))
 
198
  ({t_expr}) / (1 + {y_expr}) AS mod_dur
199
+ FROM positions_v_stressed
200
+ WHERE bucket IN ('Assets', 'SoF')
201
  )
202
  SELECT
203
  bucket,
204
+ SUM(stressed_pv) AS total_pv,
205
+ SUM(stressed_pv * mod_dur) AS weighted_duration_sum
 
206
  FROM irr_calcs
207
  GROUP BY bucket;
208
  """
209
 
210
+ def get_nii_sensitivity_sql() -> str:
211
+ """
212
+ Calculates the 1-Year Repricing Gap (Assets vs. Liabilities repricing within 1 year).
213
+ This is a simplification used to estimate NII change (Delta NII).
214
+ """
215
+ return f"""
216
+ WITH repricing_volume AS (
217
+ SELECT
218
+ bucket,
219
+ -- Assume repricing happens within 1 year (365 days)
220
+ SUM(CASE WHEN days_to_maturity <= 365 THEN stressed_pv ELSE 0 END) AS repricing_pv
221
+ FROM positions_v_stressed
222
+ WHERE bucket IN ('Assets', 'SoF')
223
+ GROUP BY bucket
224
+ )
225
+ SELECT
226
+ COALESCE(SUM(CASE WHEN bucket = 'Assets' THEN repricing_pv ELSE 0 END), 0) AS assets_repricing_pv,
227
+ COALESCE(SUM(CASE WHEN bucket = 'SoF' THEN repricing_pv ELSE 0 END), 0) AS liabilities_repricing_pv,
228
+ -- Repricing Gap = Repricing Assets - Repricing Liabilities
229
+ (COALESCE(SUM(CASE WHEN bucket = 'Assets' THEN repricing_pv ELSE 0 END), 0) -
230
+ COALESCE(SUM(CASE WHEN bucket = 'SoF' THEN repricing_pv ELSE 0 END), 0)) AS repricing_gap
231
+ FROM repricing_volume;
232
+ """
233
+
234
  # =========================
235
  # Dashboard callback
236
  # =========================
237
+ def run_dashboard(scenario: str, runoff_pct: float, rate_shock_bps_input: float, nii_shock_bps: float) -> Tuple[str, str, str, str, str, Any, pd.DataFrame, pd.DataFrame, pd.DataFrame, str, pd.DataFrame]:
238
  """
239
  Returns:
240
+ status, as_of, a1_text, a2_text, a3_text, figure, ladder_df, irr_df (BPV),
241
+ nii_df, explain_text, drivers_df
242
  """
243
  try:
244
  conn = connect_md()
245
 
246
+ # 1) Discover columns & ensure view is created
247
+ cols = discover_columns(conn, TABLE_FQN)
248
+ ensure_view(conn, cols)
249
+
250
  # --- Scenario Application ---
 
 
251
  stressed_view_fqn = "positions_v_stressed"
252
  runoff_factor = 1.0
253
+ rate_shock_bps = 0.0 # Used for EVE (BPV) and NII sensitivity
254
 
255
  if scenario == "Liquidity Stress: High Deposit Runoff" and runoff_pct > 0:
256
  runoff_factor = (100.0 - runoff_pct) / 100.0
257
+ # Set shock to 0 for Liquidity stress
258
+ rate_shock_bps = 0.0
259
  elif scenario == "IRR Stress: Rate Shock" and rate_shock_bps_input != 0:
260
  rate_shock_bps = rate_shock_bps_input
261
+ # Use only run-off factor 1.0 (no liquidity stress)
262
+ runoff_factor = 1.0
263
 
264
+ # Create temporary view with scenario adjustments for both PV and Rate
265
+ # NOTE: Rate shock is currently only applied to derived metrics, not stored PV
266
  scenario_sql = f"""
267
  CREATE OR REPLACE TEMP VIEW {stressed_view_fqn} AS
268
+ SELECT
269
+ *,
270
+ -- Apply runoff only to liabilities (SoF)
271
+ CASE WHEN lower(product) IN ({', '.join([f"'{p}'" for p in PRODUCT_SOF])})
272
+ THEN Portfolio_value * {runoff_factor}
273
+ ELSE Portfolio_value
274
+ END AS stressed_pv,
275
+ -- Apply rate shock to Interest_rate for NII/Duration modeling (optional, but good practice)
276
+ Interest_rate + ({rate_shock_bps} / 100.0) AS stressed_ir
277
  FROM {VIEW_FQN};
278
  """
279
  conn.execute(scenario_sql)
280
 
 
 
 
 
281
  # 2) As-of (optional)
282
  as_of = "N/A"
283
  if "as_of_date" in cols:
 
285
  if not tmp.empty and not pd.isna(tmp["d"].iloc[0]):
286
  as_of = str(tmp["d"].iloc[0])[:10]
287
 
288
+ # 3) KPIs (Liquidity Gap)
289
+ kpi = conn.execute(KPI_SQL).fetchdf()
 
 
290
  assets_t1 = safe_num(kpi["assets_t1"].iloc[0]) if not kpi.empty else 0.0
291
  sof_t1 = safe_num(kpi["sof_t1"].iloc[0]) if not kpi.empty else 0.0
292
  net_gap = safe_num(kpi["net_gap_t1"].iloc[0]) if not kpi.empty else 0.0
293
 
294
+ # 4) Ladder and Gap Drivers
295
+ ladder = conn.execute(LADDER_SQL).fetchdf()
296
+ drivers = conn.execute(GAP_DRIVERS_SQL).fetchdf()
297
+
298
+ # 5) Duration Gap & BPV (IRR - EVE)
299
+ duration_components = conn.execute(get_duration_components_sql(cols)).fetchdf()
300
+
301
+ # Calculate Modified Duration (D_A, D_L) and L/A Ratio
302
+ pv_assets = duration_components[duration_components['bucket'] == 'Assets']['total_pv'].sum()
303
+ pv_liab = duration_components[duration_components['bucket'] == 'SoF']['total_pv'].sum()
304
+
305
+ wd_assets = duration_components[duration_components['bucket'] == 'Assets']['weighted_duration_sum'].sum()
306
+ wd_liab = duration_components[duration_components['bucket'] == 'SoF']['weighted_duration_sum'].sum()
307
+
308
+ mod_dur_assets = wd_assets / pv_assets if pv_assets > 0 else 0.0
309
+ mod_dur_liab = wd_liab / pv_liab if pv_liab > 0 else 0.0
310
+
311
+ # L/A Ratio (Liabilities / Assets)
312
+ l_a_ratio = pv_liab / pv_assets if pv_assets > 0 else 0.0
313
+
314
+ # Duration Gap = D_A – D_L Γ— (L/A)
315
+ duration_gap = mod_dur_assets - (mod_dur_liab * l_a_ratio)
316
+
317
+ # BPV (Basis Point Value) / DV01 (Dollar Value of 01)
318
+ # BPV is the combined sensitivity (SUM(PV * Mod_Dur)) * 0.0001
319
+ net_bpv = (wd_assets - wd_liab) * 0.0001
320
+
321
+ # Calculate EVE Impact
322
+ eve_impact = net_bpv * rate_shock_bps
323
+
324
+ # Create EVE/BPV display table
325
+ irr_df = pd.DataFrame({
326
+ "Metric": ["Assets Mod. Duration (Yrs)", "Liabilities Mod. Duration (Yrs)", "Duration Gap (Yrs)", "Net BPV (LKR)"],
327
+ "Value": [mod_dur_assets, mod_dur_liab, duration_gap, net_bpv]
328
+ })
329
+ irr_df['Value'] = irr_df['Value'].map('{:,.4f}'.format)
330
+
331
+
332
+ # 6) NII Sensitivity (IRR - NII)
333
+ nii_data = conn.execute(get_nii_sensitivity_sql()).fetchdf()
334
+
335
+ assets_repricing_pv = safe_num(nii_data["assets_repricing_pv"].iloc[0])
336
+ liabilities_repricing_pv = safe_num(nii_data["liabilities_repricing_pv"].iloc[0])
337
+ repricing_gap = safe_num(nii_data["repricing_gap"].iloc[0])
338
+
339
+ # NII Delta = Repricing Gap * (Rate Shock / 10000)
340
+ nii_delta = repricing_gap * (nii_shock_bps / 10000.0)
341
+
342
+ # Create NII display table (in Mn)
343
+ nii_df = pd.DataFrame({
344
+ "Metric": [
345
+ "Assets Repricing (LKR Mn)",
346
+ "Liabilities Repricing (LKR Mn)",
347
+ "1-Year Repricing Gap (LKR Mn)",
348
+ f"NII Delta (+{nii_shock_bps:.0f}bps Shock) (LKR Mn)"
349
+ ],
350
+ "Value": [
351
+ assets_repricing_pv / 1000000.0,
352
+ liabilities_repricing_pv / 1000000.0,
353
+ repricing_gap / 1000000.0,
354
+ nii_delta / 1000000.0
355
+ ]
356
+ })
357
+ nii_df['Value'] = nii_df['Value'].map('{:,.2f}'.format)
358
+
359
+ # 7) Format output dataframes for UI
360
  ladder_display = ladder.copy()
361
  if "Amount (LKR Mn)" in ladder.columns:
362
  ladder_display["Amount (LKR Mn)"] = ladder_display["Amount (LKR Mn)"].map('{:,.2f}'.format)
363
  else:
364
  ladder_display = pd.DataFrame()
365
 
366
+ drivers_display = drivers.copy()
 
 
 
 
 
367
  if "Amount (LKR Mn)" in drivers.columns:
 
368
  drivers_display["Amount (LKR Mn)"] = drivers_display["Amount (LKR Mn)"].map('{:,.2f}'.format)
369
  else:
370
  drivers_display = pd.DataFrame()
371
 
372
+ # 8) Chart
373
  fig = plot_ladder(ladder)
374
 
375
+ # 9) Explanations
376
  assets_t1_mn_str = f"{(assets_t1 / 1_000_000):,.2f}"
377
  sof_t1_mn_str = f"{(sof_t1 / 1_000_000):,.2f}"
378
  net_gap_mn_str = f"{(net_gap / 1_000_000):,.2f}"
379
+ gap_sign_str = "positive (surplus)" if net_gap >= 0 else "negative (deficit)"
380
 
381
  a1_text = f"The amount of Assets maturing tomorrow (T+1) is **LKR {assets_t1_mn_str} Mn**."
382
  a2_text = f"The amount of Sources of Funds (SoF) maturing tomorrow (T+1) is **LKR {sof_t1_mn_str} Mn**."
 
388
  top_sof_prod = sof_drivers.iloc[0] if not sof_drivers.empty else None
389
  top_asset_prod = asset_drivers.iloc[0] if not asset_drivers.empty else None
390
 
391
+ explain_text = f"### Liquidity Gap Analysis (T+1)\n"
392
+ explain_text += f"The T+1 Net Liquidity Gap is **LKR {net_gap_mn_str} Mn** ({gap_sign_str}).\n\n"
393
  if top_sof_prod is not None:
394
+ explain_text += f"* **Largest Outflow:** From `{top_sof_prod['product']}` at **LKR {top_sof_prod['Amount (LKR Mn)']:,.2f} Mn**.\n"
 
 
 
395
  if top_asset_prod is not None:
396
+ explain_text += f"* **Largest Inflow:** From `{top_asset_prod['product']}` at **LKR {top_asset_prod['Amount (LKR Mn)']:,.2f} Mn**.\n"
397
+
398
+ # Add EVE/NII analysis to explanation
399
+ explain_text += f"\n### Interest Rate Risk (IRR) Analysis\n"
400
+
401
+ # NII Explain
402
+ nii_delta_mn = safe_num(nii_delta / 1000000.0)
403
+ repricing_gap_mn = safe_num(repricing_gap / 1000000.0)
404
+ explain_text += f"* **NII Sensitivity:** Based on the 1-Year Repricing Gap (LKR {repricing_gap_mn:,.2f} Mn), a **+{nii_shock_bps:.0f} bps** rate shock suggests a **LKR {nii_delta_mn:,.2f} Mn** change in 1-year Net Interest Income.\n"
405
+
406
+ # EVE Explain
407
+ eve_impact_mn = safe_num(eve_impact / 1000000.0)
408
+ explain_text += f"* **EVE Sensitivity:** The Duration Gap is **{duration_gap:,.2f} years**. A **+{rate_shock_bps:.0f} bps** parallel rate shock is projected to change the portfolio's Economic Value (EVE) by **LKR {eve_impact_mn:,.2f} Mn**."
409
+
410
+ if scenario != "Baseline":
411
+ explain_text += f"\n\n**SCENARIO ACTIVE:** Results reflect the '{scenario}' scenario."
412
 
413
  status = f"βœ… OK (as of {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')})"
414
  return (
 
419
  a3_text,
420
  fig,
421
  ladder_display,
422
+ irr_df,
423
+ nii_df,
424
  explain_text,
425
  drivers_display,
426
  )
 
438
  fig,
439
  empty_df,
440
  empty_df,
441
+ empty_df,
442
  "Analysis could not be performed.",
443
  empty_df,
444
  )
 
452
  status = gr.Textbox(label="Status", interactive=False, lines=8)
453
 
454
  with gr.Row():
455
+ refresh_btn = gr.Button("πŸ”„ Refresh/Calculate", variant="primary")
456
  theme_btn = gr.Button("πŸŒ— Toggle Theme")
457
  theme_btn.click(
458
  None,
 
471
  with gr.Accordion("Stress Scenario Parameters", open=True):
472
  runoff_slider = gr.Slider(
473
  label="Deposit Runoff (%)",
474
+ minimum=0, maximum=100, step=5, value=20,
475
  info="For Liquidity Stress: Percentage of key deposits that run off."
476
  )
477
  shock_slider = gr.Slider(
478
+ label="EVE Rate Shock (bps)",
479
  minimum=-500, maximum=500, step=25, value=200,
480
+ info="For IRR Stress: Parallel shift in the yield curve for EVE (Duration) calculation."
481
+ )
482
+ nii_shock_slider = gr.Slider(
483
+ label="NII Rate Shock (bps)",
484
+ minimum=-500, maximum=500, step=25, value=100,
485
+ info="For NII Sensitivity: Shock applied to 1-Year Repricing Gap."
486
  )
487
+
488
+ explain_text = gr.Markdown("Analysis of the T+1 gap and IRR will appear here...")
489
 
490
  # --- Right Column: KPIs, Charts, and Tables ---
491
  with gr.Column(scale=3):
 
498
  chart = gr.Plot(label="Maturity Ladder")
499
 
500
  with gr.Tabs():
501
+ with gr.TabItem("Liquidity Gap Detail"):
502
+ ladder_df = gr.Dataframe(
503
+ headers=["Time Bucket", "Bucket", "Amount (LKR Mn)"],
504
+ type="pandas"
505
+ )
506
  with gr.TabItem("T+1 Gap Drivers"):
507
  drivers_df = gr.Dataframe(
508
  headers=["Product", "Bucket", "Amount (LKR Mn)"],
509
+ type="pandas"
510
  )
511
+ with gr.TabItem("IRR - EVE (Duration Gap)"):
512
  irr_df = gr.Dataframe(
513
+ headers=["Metric", "Value"],
514
+ type="pandas"
515
+ )
516
+ with gr.TabItem("IRR - NII (Repricing Gap)"):
517
+ nii_df = gr.Dataframe(
518
+ headers=["Metric", "Value"],
519
+ type="pandas"
520
  )
521
 
522
  refresh_btn.click(
523
  fn=run_dashboard,
524
+ inputs=[scenario_dd, runoff_slider, shock_slider, nii_shock_slider],
525
+ outputs=[status, as_of, a1, a2, a3, chart, ladder_df, irr_df, nii_df, explain_text, drivers_df],
526
  )
527
 
528
  if __name__ == "__main__":
529
+ demo.launch()