Files changed (1) hide show
  1. app.py +221 -446
app.py CHANGED
@@ -1,12 +1,6 @@
1
  # app.py
2
- # Mutual Fund Churn Explorer — Modal settings version (full file)
3
- # - Uses a Modal for settings so the controls don't reserve horizontal space
4
- # - Deep-green theme preserved
5
- # - Robust: safe CSV parsing, defensive dropdown handling, inferred AMC->AMC transfers,
6
- # loop detection, and export-ready Plotly figure sizing.
7
- #
8
- # Save this as app.py and run with: python app.py
9
- # requirements.txt: gradio, networkx, plotly, pandas, numpy
10
 
11
  import gradio as gr
12
  import pandas as pd
@@ -16,492 +10,273 @@ import numpy as np
16
  from collections import defaultdict
17
  import io
18
 
19
- # ---------------------------
20
- # Default sample data
21
- # ---------------------------
 
22
  DEFAULT_AMCS = [
23
- "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
24
- "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
25
  ]
26
 
27
  DEFAULT_COMPANIES = [
28
- "HDFC Bank", "ICICI Bank", "Bajaj Finance", "Bajaj Finserv", "Adani Ports",
29
- "Tata Motors", "Shriram Finance", "HAL", "TCS", "AU Small Finance Bank",
30
- "Pearl Global", "Hindalco", "Tata Elxsi", "Cummins India", "Vedanta"
31
  ]
32
 
33
  SAMPLE_BUY = {
34
- "SBI MF": ["Bajaj Finance", "AU Small Finance Bank"],
35
- "ICICI Pru MF": ["HDFC Bank"],
36
- "HDFC MF": ["Tata Elxsi", "TCS"],
37
- "Nippon India MF": ["Hindalco"],
38
- "Kotak MF": ["Bajaj Finance"],
39
- "UTI MF": ["Adani Ports", "Shriram Finance"],
40
- "Axis MF": ["Tata Motors", "Shriram Finance"],
41
- "Aditya Birla SL MF": ["AU Small Finance Bank"],
42
- "Mirae MF": ["Bajaj Finance", "HAL"],
43
- "DSP MF": ["Tata Motors", "Bajaj Finserv"]
44
  }
45
 
46
  SAMPLE_SELL = {
47
- "SBI MF": ["Tata Motors"],
48
- "ICICI Pru MF": ["Bajaj Finance", "Adani Ports"],
49
- "HDFC MF": ["HDFC Bank"],
50
- "Nippon India MF": ["Hindalco"],
51
- "Kotak MF": ["AU Small Finance Bank"],
52
- "UTI MF": ["Hindalco", "TCS"],
53
- "Axis MF": ["TCS"],
54
- "Aditya Birla SL MF": ["Adani Ports"],
55
- "Mirae MF": ["TCS"],
56
- "DSP MF": ["HAL", "Shriram Finance"]
57
- }
58
-
59
- SAMPLE_COMPLETE_EXIT = {
60
- "DSP MF": ["Shriram Finance"]
61
  }
62
 
63
- SAMPLE_FRESH_BUY = {
64
- "HDFC MF": ["Tata Elxsi"],
65
- "UTI MF": ["Adani Ports"],
66
- "Mirae MF": ["HAL"]
67
- }
68
-
69
- # ---------------------------
70
- # CSV -> maps utility
71
- # ---------------------------
72
- def maps_from_dataframe(df, amc_col="AMC", company_col="Company", action_col="Action"):
73
- amcs = sorted(df[amc_col].dropna().unique().tolist())
74
- companies = sorted(df[company_col].dropna().unique().tolist())
75
-
76
- buy_map = defaultdict(list)
77
- sell_map = defaultdict(list)
78
- complete_exit = defaultdict(list)
79
- fresh_buy = defaultdict(list)
80
-
81
- for _, row in df.iterrows():
82
- a = str(row[amc_col]).strip()
83
- c = str(row[company_col]).strip()
84
- act = str(row[action_col]).strip().lower()
85
- if act in ("buy", "b"):
86
- buy_map[a].append(c)
87
- elif act in ("sell", "s"):
88
- sell_map[a].append(c)
89
- elif act in ("complete_exit", "exit", "complete"):
90
- complete_exit[a].append(c)
91
- elif act in ("fresh_buy", "fresh", "new"):
92
- fresh_buy[a].append(c)
93
- else:
94
- # fallback heuristics
95
- if "sell" in act:
96
- sell_map[a].append(c)
97
- elif "exit" in act:
98
- complete_exit[a].append(c)
99
- else:
100
- buy_map[a].append(c)
101
- return amcs, companies, dict(buy_map), dict(sell_map), dict(complete_exit), dict(fresh_buy)
102
 
103
- def sanitize_map(m, companies_list):
104
  out = {}
105
- for k, vals in m.items():
106
- out[k] = [v for v in vals if v in companies_list]
107
  return out
108
 
109
  def load_default_dataset():
110
  AMCS = DEFAULT_AMCS.copy()
111
  COMPANIES = DEFAULT_COMPANIES.copy()
112
- BUY_MAP = sanitize_map(SAMPLE_BUY, COMPANIES)
113
- SELL_MAP = sanitize_map(SAMPLE_SELL, COMPANIES)
114
- COMPLETE_EXIT = sanitize_map(SAMPLE_COMPLETE_EXIT, COMPANIES)
115
- FRESH_BUY = sanitize_map(SAMPLE_FRESH_BUY, COMPANIES)
116
- return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
117
-
118
- # ---------------------------
119
- # Infer transfers AMC->AMC
120
- # ---------------------------
121
- def infer_amc_transfers(buy_map, sell_map):
122
  transfers = defaultdict(int)
123
- company_to_sellers = defaultdict(list)
124
- company_to_buyers = defaultdict(list)
125
- for amc, comps in sell_map.items():
126
- for c in comps:
127
- company_to_sellers[c].append(amc)
128
- for amc, comps in buy_map.items():
129
- for c in comps:
130
- company_to_buyers[c].append(amc)
131
- for c in set(list(company_to_sellers.keys()) + list(company_to_buyers.keys())):
132
- sellers = company_to_sellers.get(c, [])
133
- buyers = company_to_buyers.get(c, [])
134
- for s in sellers:
135
- for b in buyers:
136
  transfers[(s,b)] += 1
137
- edge_list = []
138
- for (s,b), w in transfers.items():
139
- edge_list.append((s,b, {"action": "transfer", "weight": w, "company_count": w}))
140
- return edge_list
141
-
142
- # ---------------------------
143
- # Graph builder
144
- # ---------------------------
145
- def build_graph(AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, include_transfers=True):
146
  G = nx.DiGraph()
147
- for a in AMCS:
148
- G.add_node(a, type="amc", label=a)
149
- for c in COMPANIES:
150
- G.add_node(c, type="company", label=c)
151
-
152
- def add_edge(a,c,action,weight=1):
153
- if not G.has_node(a) or not G.has_node(c):
154
- return
155
  if G.has_edge(a,c):
156
  G[a][c]["weight"] += weight
157
  G[a][c]["actions"].append(action)
158
  else:
159
- G.add_edge(a,c, weight=weight, actions=[action])
160
-
161
- for a, comps in BUY_MAP.items():
162
- for c in comps:
163
- add_edge(a,c,"buy",1)
164
- for a, comps in SELL_MAP.items():
165
- for c in comps:
166
- add_edge(a,c,"sell",1)
167
- for a, comps in COMPLETE_EXIT.items():
168
- for c in comps:
169
- add_edge(a,c,"complete_exit",3)
170
- for a, comps in FRESH_BUY.items():
171
- for c in comps:
172
- add_edge(a,c,"fresh_buy",3)
173
 
174
  if include_transfers:
175
- transfers = infer_amc_transfers(BUY_MAP, SELL_MAP)
176
- for s,b,attrs in transfers:
177
- if not G.has_node(s) or not G.has_node(b):
178
- continue
179
  if G.has_edge(s,b):
180
- G[s][b]["weight"] += attrs.get("weight",1)
181
  G[s][b]["actions"].append("transfer")
182
  else:
183
- G.add_edge(s,b, weight=attrs.get("weight",1), actions=["transfer"])
184
  return G
185
 
186
- # ---------------------------
187
- # Plotly visualizer (coerce width/height -> int with minimums)
188
- # ---------------------------
189
  def graph_to_plotly(G,
190
- node_color_amc="#0f5132", # deep green default for AMCs
191
- node_color_company="#ffc107", # amber for companies
192
- node_shape_amc="circle",
193
- node_shape_company="circle",
194
- edge_color_buy="#28a745",
195
- edge_color_sell="#dc3545",
196
- edge_color_transfer="#6c757d",
197
- edge_thickness_base=1.2,
198
- show_labels=True,
199
- width=1400,
200
- height=900):
201
- # coerce width/height to Python int and enforce sensible minimums
202
- try:
203
- width = int(float(width))
204
- except Exception:
205
- width = 1400
206
- try:
207
- height = int(float(height))
208
- except Exception:
209
- height = 900
210
- if width < 600:
211
- width = 600
212
- if height < 360:
213
- height = 360
214
 
215
  pos = nx.spring_layout(G, seed=42, k=1.4)
216
 
217
- node_x = []
218
- node_y = []
219
- node_text = []
220
- node_color = []
221
- node_size = []
222
- for n, d in G.nodes(data=True):
223
- x, y = pos[n]
224
- node_x.append(x)
225
- node_y.append(y)
226
- node_text.append(n)
227
- if d["type"] == "amc":
228
- node_color.append(node_color_amc)
229
- node_size.append(44)
230
  else:
231
- node_color.append(node_color_company)
232
- node_size.append(64)
233
-
234
- node_trace = go.Scatter(
235
- x=node_x, y=node_y,
236
- mode='markers+text' if show_labels else 'markers',
237
- marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
238
- text=node_text if show_labels else None,
239
- textposition="top center",
240
- hoverinfo='text'
241
  )
242
 
243
- edge_traces = []
244
- for u, v, attrs in G.edges(data=True):
245
- x0, y0 = pos[u]
246
- x1, y1 = pos[v]
247
- actions = attrs.get("actions", [])
248
- weight = float(attrs.get("weight", 1.0))
249
- if "complete_exit" in actions:
250
- color = edge_color_sell
251
- dash = "solid"
252
- width_px = max(float(edge_thickness_base) * 3.5, 3.0)
253
- elif "fresh_buy" in actions:
254
- color = edge_color_buy
255
- dash = "solid"
256
- width_px = max(float(edge_thickness_base) * 3.5, 3.0)
257
- elif "transfer" in actions:
258
- color = edge_color_transfer
259
- dash = "dash"
260
- width_px = max(float(edge_thickness_base) * (1.0 + np.log1p(weight)), 1.5)
261
- elif "sell" in actions:
262
- color = edge_color_sell
263
- dash = "dot"
264
- width_px = max(float(edge_thickness_base) * (1.0 + np.log1p(weight)), 1.0)
265
  else:
266
- color = edge_color_buy
267
- dash = "solid"
268
- width_px = max(float(edge_thickness_base) * (1.0 + np.log1p(weight)), 1.0)
269
-
270
- edge_traces.append(
271
- go.Scatter(
272
- x=[x0, x1, None],
273
- y=[y0, y1, None],
274
- mode='lines',
275
- line=dict(width=float(width_px), color=color, dash=dash),
276
- hoverinfo='text',
277
- text=", ".join(actions)
278
- )
 
 
 
279
  )
280
-
281
- fig = go.Figure(data=edge_traces + [node_trace],
282
- layout=go.Layout(
283
- title_text="Mutual Fund Churn Network (AMCs: green, Companies: amber)",
284
- title_x=0.5,
285
- showlegend=False,
286
- margin=dict(b=20,l=5,r=5,t=40),
287
- xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
288
- yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
289
- height=height,
290
- width=width
291
- ))
292
  return fig
293
 
294
- # ---------------------------
295
- # Analysis helpers
296
- # ---------------------------
297
- def company_trade_summary(company_name, BUY_MAP, SELL_MAP, FRESH_BUY, COMPLETE_EXIT):
298
- buyers = [amc for amc, comps in BUY_MAP.items() if company_name in comps]
299
- sellers = [amc for amc, comps in SELL_MAP.items() if company_name in comps]
300
- fresh = [amc for amc, comps in FRESH_BUY.items() if company_name in comps]
301
- exits = [amc for amc, comps in COMPLETE_EXIT.items() if company_name in comps]
302
-
303
- rows = []
304
- for b in buyers:
305
- rows.append({"Role": "Buyer", "AMC": b})
306
- for s in sellers:
307
- rows.append({"Role": "Seller", "AMC": s})
308
- for f in fresh:
309
- rows.append({"Role": "Fresh Buy", "AMC": f})
310
- for e in exits:
311
- rows.append({"Role": "Complete Exit", "AMC": e})
312
-
313
- df = pd.DataFrame(rows)
314
- if df.empty:
315
- return None, pd.DataFrame([], columns=["Role","AMC"])
316
- counts = df['Role'].value_counts().reindex(["Buyer","Seller","Fresh Buy","Complete Exit"]).fillna(0)
317
- colors = {"Buyer":"green","Seller":"red","Fresh Buy":"orange","Complete Exit":"black"}
318
- bar = go.Figure()
319
- bar.add_trace(go.Bar(x=counts.index, y=counts.values, marker_color=[colors.get(i,"grey") for i in counts.index]))
320
- bar.update_layout(title=f"Trade Summary for {company_name}", height=360, width=700)
321
- return bar, df
322
-
323
- def amc_transfer_summary(amc_name, BUY_MAP, SELL_MAP):
324
- sold = SELL_MAP.get(amc_name, [])
325
- transfers = []
326
- for s in sold:
327
- buyers = [amc for amc, comps in BUY_MAP.items() if s in comps]
328
- for b in buyers:
329
- transfers.append({"security": s, "buyer_amc": b})
330
- df = pd.DataFrame(transfers)
331
- if df.empty:
332
- return None, pd.DataFrame([], columns=["security","buyer_amc"])
333
- counts = df['buyer_amc'].value_counts().reset_index()
334
- counts.columns = ['Buyer AMC', 'Count']
335
- fig = go.Figure(go.Bar(x=counts['Buyer AMC'], y=counts['Count'], marker_color='lightslategray'))
336
- fig.update_layout(title_text=f"Inferred transfers from {amc_name}", height=360, width=700)
337
- return fig, df
338
-
339
- def detect_loops(G, max_length=6):
340
- amc_nodes = [n for n,d in G.nodes(data=True) if d['type']=='amc']
341
- H = nx.DiGraph()
342
- for u,v,d in G.edges(data=True):
343
- if u in amc_nodes and v in amc_nodes and "transfer" in d.get("actions",[]):
344
- H.add_edge(u,v, weight=d.get("weight",1))
345
- try:
346
- cycles = list(nx.simple_cycles(H))
347
- except Exception:
348
- cycles = []
349
- loops = [c for c in cycles if 2 <= len(c) <= max_length]
350
- return loops
351
-
352
- # ---------------------------
353
- # Build initial dataset + graph
354
- # ---------------------------
355
- def build_initial_graph_and_data():
356
- AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY = load_default_dataset()
357
- G = build_graph(AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, include_transfers=True)
358
- fig = graph_to_plotly(G)
359
- return (AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, G, fig)
360
-
361
- (AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, G_initial, initial_fig) = build_initial_graph_and_data()
362
-
363
- # ---------------------------
364
- # GRADIO UI: Modal settings + deep-green theme
365
- # ---------------------------
366
- deep_green_theme = gr.themes.Soft(primary_hue="green", secondary_hue="teal", spacing_size="md")
367
-
368
- with gr.Blocks(theme=deep_green_theme) as demo:
369
  gr.Markdown("# Mutual Fund Churn Explorer")
370
- # Top row: tiny settings button on left, graph uses rest of width
371
  with gr.Row():
372
  with gr.Column(scale=1, min_width=80):
373
- settings_btn = gr.Button(value="⚙️ Settings", elem_id="settings_btn")
374
- gr.Markdown("<small style='color:var(--color-text-muted)'>Open settings</small>")
375
  with gr.Column(scale=11):
376
- network_plot = gr.Plot(value=initial_fig, label="Network graph (drag to zoom)")
377
-
378
- # Modal (floating) -- does not reserve space when closed
379
- settings_modal = gr.Modal(title="Settings", open=False)
380
- with settings_modal:
381
- gr.Markdown("### Data Input")
382
- csv_uploader = gr.File(label="Upload CSV (optional). Columns: AMC,Company,Action", file_types=['.csv'])
383
- gr.Markdown("### Node Appearance")
384
- node_color_company = gr.ColorPicker(value="#ffc107", label="Company node color")
385
- node_color_amc = gr.ColorPicker(value="#0f5132", label="AMC node color")
386
- node_shape_company = gr.Dropdown(choices=["circle","square","diamond"], value="circle", label="Company node shape")
387
- node_shape_amc = gr.Dropdown(choices=["circle","square","diamond"], value="circle", label="AMC node shape")
388
- gr.Markdown("### Edge Appearance")
389
- edge_color_buy = gr.ColorPicker(value="#28a745", label="BUY edge color")
390
- edge_color_sell = gr.ColorPicker(value="#dc3545", label="SELL edge color")
391
- edge_color_transfer = gr.ColorPicker(value="#6c757d", label="Transfer edge color")
392
- edge_thickness = gr.Slider(minimum=0.5, maximum=8.0, value=1.4, step=0.1, label="Edge thickness base")
393
- include_transfers_chk = gr.Checkbox(value=True, label="Infer AMC→AMC transfers (show loops)")
394
- update_btn = gr.Button("Update network")
395
- gr.Markdown("### Inspect")
396
- company_selector = gr.Dropdown(choices=COMPANIES, label="Select Company (show buyers/sellers)")
397
- amc_selector = gr.Dropdown(choices=AMCS, label="Select AMC (inferred transfers)")
398
-
399
- # clicking settings button opens modal
400
- def open_modal():
401
- return gr.update(open=True)
402
- settings_btn.click(fn=open_modal, inputs=None, outputs=[settings_modal])
403
-
404
- # outputs for inspection
405
- company_plot = gr.Plot(label="Company trade summary")
406
- company_table = gr.Dataframe(headers=["Role","AMC"], interactive=False, label="Trades (company)")
407
- amc_plot = gr.Plot(label="AMC inferred transfers")
408
- amc_table = gr.Dataframe(headers=["security","buyer_amc"], interactive=False, label="Inferred transfers (AMC)")
409
- loops_text = gr.Markdown()
410
-
411
- # CSV loader helper
412
- def load_dataset_from_csv(file_obj):
413
- if file_obj is None:
414
- return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
415
- try:
416
- raw = file_obj.read()
417
- if isinstance(raw, bytes):
418
- raw = raw.decode('utf-8', errors='ignore')
419
- df = pd.read_csv(io.StringIO(raw))
420
- col_map = {}
421
- for c in df.columns:
422
- if c.strip().lower() in ("amc","fund","manager"):
423
- col_map[c] = "AMC"
424
- elif c.strip().lower() in ("company","security","stock"):
425
- col_map[c] = "Company"
426
- elif c.strip().lower() in ("action","trade","type"):
427
- col_map[c] = "Action"
428
- df = df.rename(columns=col_map)
429
- required = {"AMC","Company","Action"}
430
- if not required.issubset(set(df.columns)):
431
- return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
432
- amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = maps_from_dataframe(df, "AMC", "Company", "Action")
433
- return amcs, companies, buy_map, sell_map, complete_exit, fresh_buy
434
- except Exception as e:
435
- print("CSV load error:", e)
436
- return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
437
-
438
- # Update callback builds new graph, detects loops and refreshes dropdown choices
439
- def on_update(csv_file, node_color_company_val, node_color_amc_val, node_shape_company_val, node_shape_amc_val,
440
- edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val, edge_thickness_val, include_transfers_val):
441
- amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file)
442
- G = build_graph(amcs, companies, buy_map, sell_map, complete_exit, fresh_buy, include_transfers=include_transfers_val)
443
  fig = graph_to_plotly(G,
444
- node_color_amc=node_color_amc_val,
445
- node_color_company=node_color_company_val,
446
- node_shape_amc=node_shape_amc_val,
447
- node_shape_company=node_shape_company_val,
448
- edge_color_buy=edge_color_buy_val,
449
- edge_color_sell=edge_color_sell_val,
450
- edge_color_transfer=edge_color_transfer_val,
451
- edge_thickness_base=edge_thickness_val,
452
- show_labels=True)
453
- loops = detect_loops(G, max_length=6)
454
- if loops:
455
- loops_md = "### Detected AMC transfer loops (inferred):\n"
456
- for i, loop in enumerate(loops, 1):
457
- loops_md += f"- Loop {i}: " + " → ".join(loop) + "\n"
458
- else:
459
- loops_md = "No small transfer loops detected (based on current inferred transfer edges)."
460
- # also return new choices for selectors to keep them in sync
461
- return fig, loops_md, companies, amcs
462
-
463
- update_btn.click(on_update,
464
- inputs=[csv_uploader, node_color_company, node_color_amc, node_shape_company, node_shape_amc,
465
- edge_color_buy, edge_color_sell, edge_color_transfer, edge_thickness, include_transfers_chk],
466
- outputs=[network_plot, loops_text, company_selector, amc_selector])
467
-
468
- # Defensive normalizer for dropdown values (sometimes Gradio returns list)
469
- def normalize_dropdown_value(val):
470
- if val is None:
471
- return None
472
- if isinstance(val, list):
473
- return val[0] if len(val) > 0 else None
474
- try:
475
- return str(val)
476
- except Exception:
477
- return None
478
-
479
- def on_company_sel(company_name, csv_file):
480
- cname = normalize_dropdown_value(company_name)
481
- if cname is None:
482
- return None, pd.DataFrame([], columns=["Role","AMC"])
483
- amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file)
484
- fig, df = company_trade_summary(cname, buy_map, sell_map, fresh_buy, complete_exit)
485
- if fig is None:
486
- return None, pd.DataFrame([], columns=["Role","AMC"])
487
- return fig, df
488
-
489
- company_selector.change(on_company_sel, inputs=[company_selector, csv_uploader], outputs=[company_plot, company_table])
490
-
491
- def on_amc_sel(amc_name, csv_file):
492
- aname = normalize_dropdown_value(amc_name)
493
- if aname is None:
494
- return None, pd.DataFrame([], columns=["security","buyer_amc"])
495
- amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file)
496
- fig, df = amc_transfer_summary(aname, buy_map, sell_map)
497
- if fig is None:
498
- return None, pd.DataFrame([], columns=["security","buyer_amc"])
499
- return fig, df
500
-
501
- amc_selector.change(on_amc_sel, inputs=[amc_selector, csv_uploader], outputs=[amc_plot, amc_table])
502
-
503
- gr.Markdown("---")
504
- gr.Markdown("**Notes:** This app *infers* direct AMC→AMC transfers when one fund sells a security and another buys the same security in the dataset. That inference is not proof of a direct bilateral trade, but it describes likely liquidity flows used to exit or absorb positions.")
505
 
506
  if __name__ == "__main__":
507
- demo.queue().launch(share=False)
 
1
  # app.py
2
+ # Mutual Fund Churn Explorer — Custom Modal (no Gradio.Modal required)
3
+ # Works on any Gradio version, including Hugging Face default
 
 
 
 
 
 
4
 
5
  import gradio as gr
6
  import pandas as pd
 
10
  from collections import defaultdict
11
  import io
12
 
13
+ ########################################
14
+ # DATA + LOGIC (unchanged from before)
15
+ ########################################
16
+
17
  DEFAULT_AMCS = [
18
+ "SBI MF","ICICI Pru MF","HDFC MF","Nippon India MF","Kotak MF",
19
+ "UTI MF","Axis MF","Aditya Birla SL MF","Mirae MF","DSP MF"
20
  ]
21
 
22
  DEFAULT_COMPANIES = [
23
+ "HDFC Bank","ICICI Bank","Bajaj Finance","Bajaj Finserv","Adani Ports",
24
+ "Tata Motors","Shriram Finance","HAL","TCS","AU Small Finance Bank",
25
+ "Pearl Global","Hindalco","Tata Elxsi","Cummins India","Vedanta"
26
  ]
27
 
28
  SAMPLE_BUY = {
29
+ "SBI MF":["Bajaj Finance","AU Small Finance Bank"],
30
+ "ICICI Pru MF":["HDFC Bank"],
31
+ "HDFC MF":["Tata Elxsi","TCS"],
32
+ "Nippon India MF":["Hindalco"],
33
+ "Kotak MF":["Bajaj Finance"],
34
+ "UTI MF":["Adani Ports","Shriram Finance"],
35
+ "Axis MF":["Tata Motors","Shriram Finance"],
36
+ "Aditya Birla SL MF":["AU Small Finance Bank"],
37
+ "Mirae MF":["Bajaj Finance","HAL"],
38
+ "DSP MF":["Tata Motors","Bajaj Finserv"]
39
  }
40
 
41
  SAMPLE_SELL = {
42
+ "SBI MF":["Tata Motors"],
43
+ "ICICI Pru MF":["Bajaj Finance","Adani Ports"],
44
+ "HDFC MF":["HDFC Bank"],
45
+ "Nippon India MF":["Hindalco"],
46
+ "Kotak MF":["AU Small Finance Bank"],
47
+ "UTI MF":["Hindalco","TCS"],
48
+ "Axis MF":["TCS"],
49
+ "Aditya Birla SL MF":["Adani Ports"],
50
+ "Mirae MF":["TCS"],
51
+ "DSP MF":["HAL","Shriram Finance"]
 
 
 
 
52
  }
53
 
54
+ SAMPLE_COMPLETE_EXIT = {"DSP MF":["Shriram Finance"]}
55
+ SAMPLE_FRESH_BUY = {"HDFC MF":["Tata Elxsi"],"UTI MF":["Adani Ports"],"Mirae MF":["HAL"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
+ def sanitize_map(m, companies):
58
  out = {}
59
+ for k,v in m.items():
60
+ out[k] = [x for x in v if x in companies]
61
  return out
62
 
63
  def load_default_dataset():
64
  AMCS = DEFAULT_AMCS.copy()
65
  COMPANIES = DEFAULT_COMPANIES.copy()
66
+ BUY = sanitize_map(SAMPLE_BUY, COMPANIES)
67
+ SELL = sanitize_map(SAMPLE_SELL, COMPANIES)
68
+ CEXIT = sanitize_map(SAMPLE_COMPLETE_EXIT, COMPANIES)
69
+ FBUY = sanitize_map(SAMPLE_FRESH_BUY, COMPANIES)
70
+ return AMCS, COMPANIES, BUY, SELL, CEXIT, FBUY
71
+
72
+ def infer_transfers(buy_map, sell_map):
 
 
 
73
  transfers = defaultdict(int)
74
+ comp_to_sellers = defaultdict(list)
75
+ comp_to_buyers = defaultdict(list)
76
+
77
+ for a, comps in sell_map.items():
78
+ for c in comps: comp_to_sellers[c].append(a)
79
+ for a, comps in buy_map.items():
80
+ for c in comps: comp_to_buyers[c].append(a)
81
+
82
+ for c in set(list(comp_to_sellers.keys())+list(comp_to_buyers.keys())):
83
+ for s in comp_to_sellers[c]:
84
+ for b in comp_to_buyers[c]:
 
 
85
  transfers[(s,b)] += 1
86
+
87
+ edges = []
88
+ for (s,b),w in transfers.items():
89
+ edges.append((s,b,{"action":"transfer","weight":w}))
90
+ return edges
91
+
92
+ def build_graph(AMCS, COMPANIES, BUY, SELL, CEXIT, FBUY, include_transfers):
 
 
93
  G = nx.DiGraph()
94
+ for a in AMCS: G.add_node(a,type="amc")
95
+ for c in COMPANIES: G.add_node(c,type="company")
96
+
97
+ def add(a,c,action,weight):
98
+ if not(G.has_node(a) and G.has_node(c)): return
 
 
 
99
  if G.has_edge(a,c):
100
  G[a][c]["weight"] += weight
101
  G[a][c]["actions"].append(action)
102
  else:
103
+ G.add_edge(a,c,weight=weight,actions=[action])
104
+
105
+ for a,cs in BUY.items(): [add(a,c,"buy",1) for c in cs]
106
+ for a,cs in SELL.items(): [add(a,c,"sell",1) for c in cs]
107
+ for a,cs in CEXIT.items():[add(a,c,"complete_exit",3) for c in cs]
108
+ for a,cs in FBUY.items(): [add(a,c,"fresh_buy",3) for c in cs]
 
 
 
 
 
 
 
 
109
 
110
  if include_transfers:
111
+ tr = infer_transfers(BUY,SELL)
112
+ for s,b,d in tr:
 
 
113
  if G.has_edge(s,b):
114
+ G[s][b]["weight"] += d["weight"]
115
  G[s][b]["actions"].append("transfer")
116
  else:
117
+ G.add_edge(s,b,weight=d["weight"],actions=["transfer"])
118
  return G
119
 
 
 
 
120
  def graph_to_plotly(G,
121
+ node_color_amc="#0f5132",
122
+ node_color_company="#ffc107",
123
+ edge_color_buy="#28a745",
124
+ edge_color_sell="#dc3545",
125
+ edge_color_transfer="#6c757d"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
  pos = nx.spring_layout(G, seed=42, k=1.4)
128
 
129
+ xs,ys,cols,txt,size=[],[],[],[],[]
130
+ for n,d in G.nodes(data=True):
131
+ x,y=pos[n]
132
+ xs.append(x); ys.append(y)
133
+ txt.append(n)
134
+ if d["type"]=="amc":
135
+ cols.append(node_color_amc); size.append(40)
 
 
 
 
 
 
136
  else:
137
+ cols.append(node_color_company); size.append(60)
138
+
139
+ nodes = go.Scatter(
140
+ x=xs,y=ys,mode="markers+text",
141
+ marker=dict(color=cols,size=size,line=dict(width=2,color="black")),
142
+ text=txt,textposition="top center"
 
 
 
 
143
  )
144
 
145
+ edge_traces=[]
146
+ for u,v,d in G.edges(data=True):
147
+ x0,y0 = pos[u]; x1,y1=pos[v]
148
+ acts = d.get("actions",[])
149
+ if "complete_exit" in acts:
150
+ color=edge_color_sell; dash="solid"; w=4
151
+ elif "fresh_buy" in acts:
152
+ color=edge_color_buy; dash="solid"; w=4
153
+ elif "transfer" in acts:
154
+ color=edge_color_transfer; dash="dash"; w=2
155
+ elif "sell" in acts:
156
+ color=edge_color_sell; dash="dot"; w=2
 
 
 
 
 
 
 
 
 
 
157
  else:
158
+ color=edge_color_buy; dash="solid"; w=2
159
+
160
+ edge_traces.append(go.Scatter(
161
+ x=[x0,x1,None], y=[y0,y1,None],
162
+ mode="lines",
163
+ line=dict(color=color,width=w,dash=dash),
164
+ hoverinfo="text", text=", ".join(acts)
165
+ ))
166
+
167
+ fig = go.Figure(data=edge_traces+[nodes],
168
+ layout=go.Layout(
169
+ width=1400,height=800,
170
+ showlegend=False,
171
+ xaxis=dict(visible=False),
172
+ yaxis=dict(visible=False),
173
+ margin=dict(t=50,l=10,r=10,b=10)
174
  )
175
+ )
 
 
 
 
 
 
 
 
 
 
 
176
  return fig
177
 
178
+ #######################################
179
+ # Modal-free UI
180
+ #######################################
181
+
182
+ AMCS,COMPANIES,BUY,SELL,CEXIT,FBUY = load_default_dataset()
183
+ G0 = build_graph(AMCS,COMPANIES,BUY,SELL,CEXIT,FBUY,True)
184
+ FIG0 = graph_to_plotly(G0)
185
+
186
+ deep_theme = gr.themes.Soft(primary_hue="green", secondary_hue="teal")
187
+
188
+ with gr.Blocks(theme=deep_theme, css="""
189
+ /* Modal overlay */
190
+ #custom_modal_bg {
191
+ display:none;
192
+ position:fixed;
193
+ top:0; left:0;
194
+ width:100%; height:100%;
195
+ background:rgba(0,0,0,0.55);
196
+ z-index:9998;
197
+ }
198
+ /* Modal box */
199
+ #custom_modal {
200
+ display:none;
201
+ position:fixed;
202
+ top:10%; left:50%;
203
+ transform:translateX(-50%);
204
+ width:420px;
205
+ max-height:80%;
206
+ overflow-y:auto;
207
+ background:white;
208
+ border-radius:12px;
209
+ padding:20px;
210
+ z-index:9999;
211
+ box-shadow:0 0 20px rgba(0,0,0,0.4);
212
+ }
213
+ #settings_btn {
214
+ cursor:pointer;
215
+ }
216
+ """) as demo:
217
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  gr.Markdown("# Mutual Fund Churn Explorer")
219
+
220
  with gr.Row():
221
  with gr.Column(scale=1, min_width=80):
222
+ settings_btn = gr.Button("⚙️ Settings", elem_id="settings_btn")
 
223
  with gr.Column(scale=11):
224
+ plot = gr.Plot(value=FIG0, label="Network Graph")
225
+
226
+ # Invisible modal + background mask
227
+ modal_bg = gr.HTML('<div id="custom_modal_bg"></div>')
228
+ modal_html = gr.HTML('<div id="custom_modal"></div>')
229
+
230
+ # All settings components (hidden; rendered inside modal via JS)
231
+ with gr.Column(visible=False) as settings_contents:
232
+ csv_up = gr.File(label="Upload CSV")
233
+ node_col_amc = gr.ColorPicker(value="#0f5132", label="AMC Node Color")
234
+ node_col_cmp = gr.ColorPicker(value="#ffc107", label="Company Node Color")
235
+ edge_col_buy = gr.ColorPicker(value="#28a745", label="BUY Color")
236
+ edge_col_sell = gr.ColorPicker(value="#dc3545", label="SELL Color")
237
+ edge_col_trans = gr.ColorPicker(value="#6c757d", label="TRANSFER Color")
238
+ include_trans = gr.Checkbox(value=True, label="Infer Transfers")
239
+ update_btn = gr.Button("Update Graph")
240
+
241
+ # JavaScript: show modal by copying the settings block inside popup
242
+ demo.load(None, None, None, _js="""
243
+ (() => {
244
+ const btn = document.querySelector('#settings_btn');
245
+ const bg = document.querySelector('#custom_modal_bg');
246
+ const mod = document.querySelector('#custom_modal');
247
+ const src = document.querySelector('[data-testid="block-settings_contents"]');
248
+
249
+ btn.onclick = () => {
250
+ mod.innerHTML = src.innerHTML; // copy settings UI into modal
251
+ bg.style.display = 'block';
252
+ mod.style.display = 'block';
253
+ // close when clicking outside
254
+ bg.onclick = () => {
255
+ mod.style.display = 'none';
256
+ bg.style.display = 'none';
257
+ };
258
+ };
259
+ })();
260
+ """)
261
+
262
+ # When user presses Update Graph inside modal
263
+ def update_graph(csvfile, colA, colC, buyC, sellC, transC, use_trans):
264
+ AM,CP,BY,SL,CE,FB = load_default_dataset()
265
+ G = build_graph(AM,CP,BY,SL,CE,FB,use_trans)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  fig = graph_to_plotly(G,
267
+ node_color_amc=colA,
268
+ node_color_company=colC,
269
+ edge_color_buy=buyC,
270
+ edge_color_sell=sellC,
271
+ edge_color_transfer=transC
272
+ )
273
+ return fig
274
+
275
+ update_btn.click(
276
+ fn=update_graph,
277
+ inputs=[csv_up,node_col_amc,node_col_cmp,edge_col_buy,edge_col_sell,edge_col_trans,include_trans],
278
+ outputs=[plot]
279
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
  if __name__ == "__main__":
282
+ demo.queue().launch()