Update app.py
Browse files
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 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
|
|
|
|
|
|
| 224 |
if "Amount (LKR Mn)" in ladder.columns:
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 276 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
|