AshenH commited on
Commit
ef144b9
Β·
verified Β·
1 Parent(s): e521f8a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +162 -135
app.py CHANGED
@@ -6,40 +6,20 @@ from typing import Tuple, Any, List
6
  import duckdb
7
  import pandas as pd
8
  import numpy as np
 
 
9
  import matplotlib.pyplot as plt
10
  import gradio as gr
11
- from pydantic import BaseModel, ConfigDict
12
- from reportlab.lib.pagesizes import A4
13
- from reportlab.lib.units import mm
14
- from reportlab.pdfgen import canvas
15
 
16
- # -------------------------------------------------------------------
17
  # Basic configuration
18
- # -------------------------------------------------------------------
19
  APP_TITLE = "ALCO Liquidity & Interest-Rate Risk Dashboard"
20
  TABLE_FQN = "my_db.main.masterdataset_v" # your source table
21
  VIEW_FQN = "my_db.main.positions_v" # normalized view created by this app
22
  EXPORT_DIR = Path("exports")
23
  EXPORT_DIR.mkdir(exist_ok=True)
24
 
25
-
26
- # -------------------------------------------------------------------
27
- # MotherDuck connection
28
- # -------------------------------------------------------------------
29
- def connect_md() -> duckdb.DuckDBPyConnection:
30
- token = os.environ.get("MOTHERDUCK_TOKEN", "")
31
- if not token:
32
- raise RuntimeError("MOTHERDUCK_TOKEN is not set. Add it as a Space secret.")
33
- try:
34
- return duckdb.connect(f"md:?motherduck_token={token}")
35
- except Exception as e:
36
- print("❌ Connection failed:", e, file=sys.stderr)
37
- raise
38
-
39
-
40
- # -------------------------------------------------------------------
41
- # Column discovery & dynamic SQL
42
- # -------------------------------------------------------------------
43
  PRODUCT_ASSETS = [
44
  "loan", "overdraft", "advances", "bills", "bill",
45
  "tbond", "t-bond", "tbill", "t-bill", "repo_asset"
@@ -49,6 +29,29 @@ PRODUCT_SOF = [
49
  "call", "repo_liab"
50
  ]
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  def discover_columns(conn: duckdb.DuckDBPyConnection, table_fqn: str) -> List[str]:
53
  q = f"""
54
  SELECT lower(column_name) AS col
@@ -70,7 +73,7 @@ def build_view_sql(existing_cols: List[str]) -> str:
70
  if c.lower() in existing_cols:
71
  select_list.append(c)
72
  else:
73
- # fill missing columns with NULLs (typed)
74
  if c in ("Portfolio_value", "Interest_rate", "days_to_maturity", "months"):
75
  select_list.append(f"CAST(NULL AS DOUBLE) AS {c}")
76
  else:
@@ -94,51 +97,17 @@ def build_view_sql(existing_cols: List[str]) -> str:
94
  FROM {TABLE_FQN};
95
  """
96
 
97
-
98
- # -------------------------------------------------------------------
99
- # Data model (allow pandas types)
100
- # -------------------------------------------------------------------
101
- class DashboardResult(BaseModel):
102
- model_config = ConfigDict(arbitrary_types_allowed=True)
103
-
104
- as_of_date: str
105
- assets_t1: float
106
- sof_t1: float
107
- net_gap_t1: float
108
- ladder: Any # pandas.DataFrame
109
- irr: Any # pandas.DataFrame
110
-
111
-
112
- # -------------------------------------------------------------------
113
- # Core logic
114
- # -------------------------------------------------------------------
115
  def ensure_view(conn: duckdb.DuckDBPyConnection, existing_cols: List[str]):
116
- # Mandatory columns in source:
117
  required = {"product", "portfolio_value", "days_to_maturity"}
118
  if not required.issubset(set(existing_cols)):
119
  raise RuntimeError(
120
- f"Source table {TABLE_FQN} must contain {sorted(required)}; "
121
- f"found: {sorted(existing_cols)}"
122
  )
123
  conn.execute(build_view_sql(existing_cols))
124
 
125
- def fetch_all(conn: duckdb.DuckDBPyConnection) -> DashboardResult:
126
- cols = discover_columns(conn, TABLE_FQN)
127
- ensure_view(conn, cols)
128
-
129
- has_asof = "as_of_date" in cols
130
- has_ir = "interest_rate" in cols
131
- has_months = "months" in cols
132
-
133
- # As-of date or N/A
134
- if has_asof:
135
- asof_df = conn.execute(f"SELECT max(as_of_date) AS d FROM {VIEW_FQN}").fetchdf()
136
- as_of_date = "N/A" if asof_df.empty or pd.isna(asof_df["d"].iloc[0]) else str(asof_df["d"].iloc[0])[:10]
137
- else:
138
- as_of_date = "N/A"
139
-
140
- # KPIs
141
- kpi_sql = f"""
142
  SELECT
143
  COALESCE(SUM(CASE WHEN bucket='Assets' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS assets_t1,
144
  COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS sof_t1,
@@ -146,10 +115,13 @@ def fetch_all(conn: duckdb.DuckDBPyConnection) -> DashboardResult:
146
  - COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS net_gap_t1
147
  FROM {VIEW_FQN};
148
  """
149
- kpi = conn.execute(kpi_sql).fetchdf()
 
 
 
150
 
151
- # Ladder
152
- ladder_sql = f"""
153
  SELECT
154
  CASE
155
  WHEN days_to_maturity <= 1 THEN 'T+1'
@@ -163,16 +135,20 @@ def fetch_all(conn: duckdb.DuckDBPyConnection) -> DashboardResult:
163
  GROUP BY 1,2
164
  ORDER BY 1,2;
165
  """
166
- ladder = conn.execute(ladder_sql).fetchdf()
 
 
 
167
 
168
- # IRR (approx) β€” works with or without months/interest_rate
 
 
169
  t_expr = "CASE WHEN days_to_maturity IS NOT NULL THEN days_to_maturity/365.0"
170
  if has_months:
171
  t_expr += " WHEN months IS NOT NULL THEN months/12.0"
172
  t_expr += " ELSE NULL END"
173
  y_expr = "(Interest_rate/100.0)" if has_ir else "0.0"
174
-
175
- irr_sql = f"""
176
  SELECT
177
  bucket,
178
  SUM(Portfolio_value) AS pv_sum,
@@ -181,82 +157,133 @@ def fetch_all(conn: duckdb.DuckDBPyConnection) -> DashboardResult:
181
  FROM {VIEW_FQN}
182
  GROUP BY bucket;
183
  """
184
- irr = conn.execute(irr_sql).fetchdf()
185
-
186
- return DashboardResult(
187
- as_of_date=as_of_date,
188
- assets_t1=float(kpi["assets_t1"].iloc[0]),
189
- sof_t1=float(kpi["sof_t1"].iloc[0]),
190
- net_gap_t1=float(kpi["net_gap_t1"].iloc[0]),
191
- ladder=ladder,
192
- irr=irr,
193
- )
194
-
195
-
196
- # -------------------------------------------------------------------
197
- # Visualization
198
- # -------------------------------------------------------------------
199
- def _zeros_like_index(index) -> pd.Series:
200
- return pd.Series([0] * len(index), index=index)
201
 
202
  def plot_ladder(df: pd.DataFrame):
203
- pivot = df.pivot(index="time_bucket", columns="bucket", values="amount").fillna(0)
204
- order = ["T+1", "T+2..7", "T+8..30", "T+31+"]
205
- pivot = pivot.reindex(order)
206
- fig, ax = plt.subplots(figsize=(7, 4))
207
-
208
- assets = pivot["Assets"] if "Assets" in pivot.columns else _zeros_like_index(pivot.index)
209
- sof = pivot["SoF"] if "SoF" in pivot.columns else _zeros_like_index(pivot.index)
210
-
211
- ax.bar(pivot.index, assets, label="Assets")
212
- ax.bar(pivot.index, -sof, label="SoF")
213
- ax.axhline(0, color="gray", lw=1)
214
- ax.set_ylabel("LKR")
215
- ax.set_title("Maturity Ladder (Assets vs SoF)")
216
- ax.legend()
217
- fig.tight_layout()
218
- return fig
219
-
220
-
221
- # -------------------------------------------------------------------
222
- # Exports
223
- # -------------------------------------------------------------------
224
- def export_excel(res: DashboardResult) -> Path:
225
- out = EXPORT_DIR / f"alco_report_{res.as_of_date}.xlsx"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  with pd.ExcelWriter(out, engine="xlsxwriter") as xw:
227
  pd.DataFrame({
228
- "as_of_date": [res.as_of_date],
229
- "assets_t1": [res.assets_t1],
230
- "sof_t1": [res.sof_t1],
231
- "net_gap_t1": [res.net_gap_t1],
232
  }).to_excel(xw, index=False, sheet_name="kpis")
233
- res.ladder.to_excel(xw, index=False, sheet_name="ladder")
234
- res.irr.to_excel(xw, index=False, sheet_name="irr")
235
  return out
236
 
237
-
238
- # -------------------------------------------------------------------
239
- # Gradio UI
240
- # -------------------------------------------------------------------
241
  def run_dashboard():
242
- conn = connect_md()
243
- res = fetch_all(conn)
244
- fig = plot_ladder(res.ladder)
245
- xlsx = export_excel(res)
246
- return (
247
- res.as_of_date,
248
- res.assets_t1,
249
- res.sof_t1,
250
- res.net_gap_t1,
251
- fig,
252
- res.ladder,
253
- res.irr,
254
- str(xlsx),
255
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
 
 
 
257
  with gr.Blocks(title=APP_TITLE) as demo:
258
  gr.Markdown(f"# {APP_TITLE}\n_Source:_ `{TABLE_FQN}` β†’ `{VIEW_FQN}`")
259
 
 
 
260
  with gr.Row():
261
  refresh_btn = gr.Button("πŸ”„ Refresh", variant="primary")
262
 
@@ -274,7 +301,7 @@ with gr.Blocks(title=APP_TITLE) as demo:
274
 
275
  refresh_btn.click(
276
  fn=run_dashboard,
277
- outputs=[as_of, a1, a2, a3, chart, ladder_df, irr_df, excel_file],
278
  )
279
 
280
  if __name__ == "__main__":
 
6
  import duckdb
7
  import pandas as pd
8
  import numpy as np
9
+ import matplotlib
10
+ matplotlib.use("Agg") # headless backend for Spaces
11
  import matplotlib.pyplot as plt
12
  import gradio as gr
 
 
 
 
13
 
14
+ # =========================
15
  # Basic configuration
16
+ # =========================
17
  APP_TITLE = "ALCO Liquidity & Interest-Rate Risk Dashboard"
18
  TABLE_FQN = "my_db.main.masterdataset_v" # your source table
19
  VIEW_FQN = "my_db.main.positions_v" # normalized view created by this app
20
  EXPORT_DIR = Path("exports")
21
  EXPORT_DIR.mkdir(exist_ok=True)
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  PRODUCT_ASSETS = [
24
  "loan", "overdraft", "advances", "bills", "bill",
25
  "tbond", "t-bond", "tbill", "t-bill", "repo_asset"
 
29
  "call", "repo_liab"
30
  ]
31
 
32
+ # =========================
33
+ # Helpers
34
+ # =========================
35
+ def safe_float(x, default: float = 0.0) -> float:
36
+ try:
37
+ if x is None or (isinstance(x, float) and np.isnan(x)):
38
+ return default
39
+ return float(x)
40
+ except Exception:
41
+ return default
42
+
43
+ def zeros_like_index(index) -> pd.Series:
44
+ return pd.Series([0] * len(index), index=index)
45
+
46
+ def connect_md() -> duckdb.DuckDBPyConnection:
47
+ token = os.environ.get("MOTHERDUCK_TOKEN", "")
48
+ if not token:
49
+ raise RuntimeError("MOTHERDUCK_TOKEN is not set. Add it in your Space β†’ Settings β†’ Secrets.")
50
+ try:
51
+ return duckdb.connect(f"md:?motherduck_token={token}")
52
+ except Exception as e:
53
+ raise RuntimeError(f"MotherDuck connection failed: {e}") from e
54
+
55
  def discover_columns(conn: duckdb.DuckDBPyConnection, table_fqn: str) -> List[str]:
56
  q = f"""
57
  SELECT lower(column_name) AS col
 
73
  if c.lower() in existing_cols:
74
  select_list.append(c)
75
  else:
76
+ # fill missing columns with typed NULLs
77
  if c in ("Portfolio_value", "Interest_rate", "days_to_maturity", "months"):
78
  select_list.append(f"CAST(NULL AS DOUBLE) AS {c}")
79
  else:
 
97
  FROM {TABLE_FQN};
98
  """
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  def ensure_view(conn: duckdb.DuckDBPyConnection, existing_cols: List[str]):
 
101
  required = {"product", "portfolio_value", "days_to_maturity"}
102
  if not required.issubset(set(existing_cols)):
103
  raise RuntimeError(
104
+ f"Source table {TABLE_FQN} must contain columns {sorted(required)}; "
105
+ f"found {sorted(existing_cols)}"
106
  )
107
  conn.execute(build_view_sql(existing_cols))
108
 
109
+ def fetch_kpis(conn: duckdb.DuckDBPyConnection) -> Tuple[float, float, float]:
110
+ sql = f"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  SELECT
112
  COALESCE(SUM(CASE WHEN bucket='Assets' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS assets_t1,
113
  COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS sof_t1,
 
115
  - COALESCE(SUM(CASE WHEN bucket='SoF' AND days_to_maturity<=1 THEN Portfolio_value END),0) AS net_gap_t1
116
  FROM {VIEW_FQN};
117
  """
118
+ df = conn.execute(sql).fetchdf()
119
+ if df.empty:
120
+ return 0.0, 0.0, 0.0
121
+ return safe_float(df["assets_t1"].iloc[0]), safe_float(df["sof_t1"].iloc[0]), safe_float(df["net_gap_t1"].iloc[0])
122
 
123
+ def fetch_ladder(conn: duckdb.DuckDBPyConnection) -> pd.DataFrame:
124
+ sql = f"""
125
  SELECT
126
  CASE
127
  WHEN days_to_maturity <= 1 THEN 'T+1'
 
135
  GROUP BY 1,2
136
  ORDER BY 1,2;
137
  """
138
+ df = conn.execute(sql).fetchdf()
139
+ if df.empty:
140
+ return pd.DataFrame({"time_bucket": [], "bucket": [], "amount": []})
141
+ return df
142
 
143
+ def fetch_irr(conn: duckdb.DuckDBPyConnection, cols: List[str]) -> pd.DataFrame:
144
+ has_months = "months" in cols
145
+ has_ir = "interest_rate" in cols
146
  t_expr = "CASE WHEN days_to_maturity IS NOT NULL THEN days_to_maturity/365.0"
147
  if has_months:
148
  t_expr += " WHEN months IS NOT NULL THEN months/12.0"
149
  t_expr += " ELSE NULL END"
150
  y_expr = "(Interest_rate/100.0)" if has_ir else "0.0"
151
+ sql = f"""
 
152
  SELECT
153
  bucket,
154
  SUM(Portfolio_value) AS pv_sum,
 
157
  FROM {VIEW_FQN}
158
  GROUP BY bucket;
159
  """
160
+ df = conn.execute(sql).fetchdf()
161
+ if df.empty:
162
+ return pd.DataFrame({"bucket": [], "pv_sum": [], "dur_mac": [], "dur_mod": []})
163
+ return df
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
  def plot_ladder(df: pd.DataFrame):
166
+ try:
167
+ if df.empty:
168
+ fig, ax = plt.subplots(figsize=(7, 3))
169
+ ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=12)
170
+ ax.axis("off")
171
+ return fig
172
+
173
+ pivot = df.pivot(index="time_bucket", columns="bucket", values="amount").fillna(0)
174
+ order = ["T+1", "T+2..7", "T+8..30", "T+31+"]
175
+ pivot = pivot.reindex(order)
176
+ fig, ax = plt.subplots(figsize=(7, 4))
177
+
178
+ assets = pivot["Assets"] if "Assets" in pivot.columns else zeros_like_index(pivot.index)
179
+ sof = pivot["SoF"] if "SoF" in pivot.columns else zeros_like_index(pivot.index)
180
+
181
+ ax.bar(pivot.index, assets, label="Assets")
182
+ ax.bar(pivot.index, -sof, label="SoF")
183
+ ax.axhline(0, color="gray", lw=1)
184
+ ax.set_ylabel("LKR")
185
+ ax.set_title("Maturity Ladder (Assets vs SoF)")
186
+ ax.legend()
187
+ fig.tight_layout()
188
+ return fig
189
+ except Exception as e:
190
+ # Return a simple figure with the error rendered
191
+ fig, ax = plt.subplots(figsize=(7, 3))
192
+ ax.text(0.01, 0.8, "Chart Error:", fontsize=12, ha="left")
193
+ ax.text(0.01, 0.5, str(e), fontsize=10, ha="left", wrap=True)
194
+ ax.axis("off")
195
+ return fig
196
+
197
+ def export_excel(as_of_date: str,
198
+ assets_t1: float,
199
+ sof_t1: float,
200
+ net_gap_t1: float,
201
+ ladder: pd.DataFrame,
202
+ irr: pd.DataFrame) -> Path:
203
+ out = EXPORT_DIR / f"alco_report_{as_of_date}.xlsx"
204
  with pd.ExcelWriter(out, engine="xlsxwriter") as xw:
205
  pd.DataFrame({
206
+ "as_of_date": [as_of_date],
207
+ "assets_t1": [assets_t1],
208
+ "sof_t1": [sof_t1],
209
+ "net_gap_t1": [net_gap_t1],
210
  }).to_excel(xw, index=False, sheet_name="kpis")
211
+ ladder.to_excel(xw, index=False, sheet_name="ladder")
212
+ irr.to_excel(xw, index=False, sheet_name="irr")
213
  return out
214
 
215
+ # =========================
216
+ # Gradio UI logic
217
+ # =========================
 
218
  def run_dashboard():
219
+ """
220
+ Returns:
221
+ status (str),
222
+ as_of (str),
223
+ assets_t1 (float),
224
+ sof_t1 (float),
225
+ net_gap_t1 (float),
226
+ fig (matplotlib fig),
227
+ ladder_df (DataFrame),
228
+ irr_df (DataFrame),
229
+ excel_file (path str)
230
+ """
231
+ status = "βœ… OK"
232
+ try:
233
+ conn = connect_md()
234
+ cols = discover_columns(conn, TABLE_FQN) # lower-cased names
235
+ ensure_view(conn, cols)
236
+
237
+ # As-of when available (otherwise N/A)
238
+ as_of = "N/A"
239
+ if "as_of_date" in cols:
240
+ tmp = conn.execute(f"SELECT max(as_of_date) AS d FROM {VIEW_FQN}").fetchdf()
241
+ if not tmp.empty and not pd.isna(tmp["d"].iloc[0]):
242
+ as_of = str(tmp["d"].iloc[0])[:10]
243
+
244
+ assets_t1, sof_t1, net_gap_t1 = fetch_kpis(conn)
245
+ ladder = fetch_ladder(conn)
246
+ irr = fetch_irr(conn, cols)
247
+
248
+ fig = plot_ladder(ladder)
249
+ xlsx_path = export_excel(as_of, assets_t1, sof_t1, net_gap_t1, ladder, irr)
250
+
251
+ return (
252
+ status,
253
+ as_of,
254
+ assets_t1,
255
+ sof_t1,
256
+ net_gap_t1,
257
+ fig,
258
+ ladder,
259
+ irr,
260
+ str(xlsx_path),
261
+ )
262
+ except Exception as e:
263
+ # Swallow the error for the UI; show user-friendly message + zeros/empty placeholders
264
+ status = f"❌ Error: {e}"
265
+ empty_df = pd.DataFrame()
266
+ fig = plot_ladder(empty_df)
267
+ return (
268
+ status,
269
+ "N/A",
270
+ 0.0,
271
+ 0.0,
272
+ 0.0,
273
+ fig,
274
+ empty_df,
275
+ empty_df,
276
+ "",
277
+ )
278
 
279
+ # =========================
280
+ # Build Gradio UI
281
+ # =========================
282
  with gr.Blocks(title=APP_TITLE) as demo:
283
  gr.Markdown(f"# {APP_TITLE}\n_Source:_ `{TABLE_FQN}` β†’ `{VIEW_FQN}`")
284
 
285
+ status = gr.Textbox(label="Status", interactive=False)
286
+
287
  with gr.Row():
288
  refresh_btn = gr.Button("πŸ”„ Refresh", variant="primary")
289
 
 
301
 
302
  refresh_btn.click(
303
  fn=run_dashboard,
304
+ outputs=[status, as_of, a1, a2, a3, chart, ladder_df, irr_df, excel_file],
305
  )
306
 
307
  if __name__ == "__main__":