Files changed (1) hide show
  1. app.py +230 -278
app.py CHANGED
@@ -1,9 +1,7 @@
1
  # app.py
2
- # Interactive MF churn explorer Plotly graph with node click-to-focus
3
- # + Legend
4
- # + Fixed JS (labels hide properly)
5
- # + Mobile-friendly
6
- # + HF iframe safe
7
 
8
  import gradio as gr
9
  import pandas as pd
@@ -13,10 +11,9 @@ import numpy as np
13
  import json
14
  from collections import defaultdict
15
 
16
- # ============================================================
17
  # DATA
18
- # ============================================================
19
-
20
  AMCS = [
21
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
22
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
@@ -57,111 +54,91 @@ SELL_MAP = {
57
  COMPLETE_EXIT = {"DSP MF": ["Shriram Finance"]}
58
  FRESH_BUY = {"HDFC MF": ["Tata Elxsi"], "UTI MF": ["Adani Ports"], "Mirae MF": ["HAL"]}
59
 
60
-
61
  def sanitize_map(m):
62
  out = {}
63
  for k, vals in m.items():
64
  out[k] = [v for v in vals if v in COMPANIES]
65
  return out
66
 
67
-
68
  BUY_MAP = sanitize_map(BUY_MAP)
69
  SELL_MAP = sanitize_map(SELL_MAP)
70
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
71
  FRESH_BUY = sanitize_map(FRESH_BUY)
72
 
73
- # ============================================================
74
  # GRAPH BUILDING
75
- # ============================================================
76
-
77
  company_edges = []
78
  for amc, comps in BUY_MAP.items():
79
  for c in comps:
80
  company_edges.append((amc, c, {"action": "buy", "weight": 1}))
81
-
82
  for amc, comps in SELL_MAP.items():
83
  for c in comps:
84
  company_edges.append((amc, c, {"action": "sell", "weight": 1}))
85
-
86
  for amc, comps in COMPLETE_EXIT.items():
87
  for c in comps:
88
  company_edges.append((amc, c, {"action": "complete_exit", "weight": 3}))
89
-
90
  for amc, comps in FRESH_BUY.items():
91
  for c in comps:
92
  company_edges.append((amc, c, {"action": "fresh_buy", "weight": 3}))
93
 
94
-
95
  def infer_amc_transfers(buy_map, sell_map):
96
  transfers = defaultdict(int)
97
- c2s = defaultdict(list)
98
- c2b = defaultdict(list)
99
-
100
  for amc, comps in sell_map.items():
101
  for c in comps:
102
- c2s[c].append(amc)
103
-
104
  for amc, comps in buy_map.items():
105
  for c in comps:
106
- c2b[c].append(amc)
107
-
108
- for c in set(c2s.keys()) | set(c2b.keys()):
109
- for s in c2s[c]:
110
- for b in c2b[c]:
111
- transfers[(s, b)] += 1
112
-
113
- output = []
114
- for (s, b), w in transfers.items():
115
- output.append((s, b, {"action": "transfer", "weight": w}))
116
- return output
117
-
118
 
119
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
120
 
121
-
122
  def build_graph(include_transfers=True):
123
  G = nx.DiGraph()
124
-
125
  for a in AMCS:
126
  G.add_node(a, type="amc")
127
-
128
  for c in COMPANIES:
129
  G.add_node(c, type="company")
130
-
131
- # company edges
132
- for u, v, attr in company_edges:
133
- if G.has_edge(u, v):
134
- G[u][v]["weight"] += attr["weight"]
135
- G[u][v]["actions"].append(attr["action"])
136
- else:
137
- G.add_edge(u, v, weight=attr["weight"], actions=[attr["action"]])
138
-
139
- # inferred transfer edges
140
- if include_transfers:
141
- for s, b, attr in transfer_edges:
142
- if G.has_edge(s, b):
143
- G[s][b]["weight"] += attr["weight"]
144
- G[s][b]["actions"].append("transfer")
145
  else:
146
- G.add_edge(s, b, weight=attr["weight"], actions=["transfer"])
147
-
 
 
 
 
 
 
 
148
  return G
149
 
150
- # ============================================================
151
- # PLOTLY FIGURE
152
- # ============================================================
153
-
154
- def build_plotly_figure(
155
- G,
156
- node_color_amc="#9EC5FF",
157
- node_color_company="#FFCF9E",
158
- edge_color_buy="#2ca02c",
159
- edge_color_sell="#d62728",
160
- edge_color_transfer="#888888",
161
- edge_thickness_base=1.4
162
- ):
163
- pos = nx.spring_layout(G, seed=42, k=1.2)
164
-
165
  node_names = []
166
  node_x = []
167
  node_y = []
@@ -170,97 +147,61 @@ def build_plotly_figure(
170
 
171
  for n, d in G.nodes(data=True):
172
  node_names.append(n)
173
- x, y = pos[n]
174
- node_x.append(x)
175
- node_y.append(y)
176
-
177
  if d["type"] == "amc":
178
- node_color.append(node_color_amc)
179
- node_size.append(36)
180
  else:
181
- node_color.append(node_color_company)
182
- node_size.append(56)
183
 
184
  edge_traces = []
185
- edge_source = []
186
- edge_target = []
187
  edge_colors = []
188
  edge_widths = []
189
-
190
  for u, v, attrs in G.edges(data=True):
191
- x0, y0 = pos[u]
192
- x1, y1 = pos[v]
193
- acts = attrs["actions"]
194
- weight = attrs["weight"]
195
-
 
 
196
  if "complete_exit" in acts:
197
- color = edge_color_sell
198
- width = edge_thickness_base * 3
199
- dash = "solid"
200
  elif "fresh_buy" in acts:
201
- color = edge_color_buy
202
- width = edge_thickness_base * 3
203
- dash = "solid"
204
  elif "transfer" in acts:
205
- color = edge_color_transfer
206
- width = edge_thickness_base * (1 + np.log1p(weight))
207
- dash = "dash"
208
  elif "sell" in acts:
209
- color = edge_color_sell
210
- width = edge_thickness_base * (1 + np.log1p(weight))
211
- dash = "dot"
212
  else:
213
- color = edge_color_buy
214
- width = edge_thickness_base * (1 + np.log1p(weight))
215
- dash = "solid"
216
-
217
- edge_traces.append(
218
- go.Scatter(
219
- x=[x0, x1],
220
- y=[y0, y1],
221
- mode="lines",
222
- line=dict(color=color, width=width, dash=dash),
223
- hoverinfo="none",
224
- opacity=1.0
225
- )
226
- )
227
-
228
- edge_source.append(node_names.index(u))
229
- edge_target.append(node_names.index(v))
230
- edge_colors.append(color)
231
- edge_widths.append(width)
232
-
233
- node_trace = go.Scatter(
234
- x=node_x,
235
- y=node_y,
236
- mode="markers+text",
237
- marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#333")),
238
- text=node_names,
239
- textposition="top center",
240
- hoverinfo="text"
241
- )
242
 
243
- fig = go.Figure(data=edge_traces + [node_trace])
244
- fig.update_layout(
245
- showlegend=False,
246
- autosize=True,
247
- margin=dict(l=8, r=8, t=36, b=8),
248
- xaxis=dict(visible=False),
249
- yaxis=dict(visible=False)
250
- )
251
 
 
 
 
 
252
  meta = {
253
  "node_names": node_names,
254
- "edge_source_index": edge_source,
255
- "edge_target_index": edge_target,
256
  "edge_colors": edge_colors,
257
- "edge_widths": edge_widths
 
 
258
  }
259
-
260
  return fig, meta
261
- # ================= PART 2 / 3 =================
262
- # HTML builder and JS (with escaped braces for f-string)
263
- def make_network_html(fig, meta, div_id="network-plot-div"):
 
 
 
264
  fig_json = json.dumps(fig.to_plotly_json())
265
  meta_json = json.dumps(meta)
266
 
@@ -268,108 +209,163 @@ def make_network_html(fig, meta, div_id="network-plot-div"):
268
  <div id="{div_id}" style="width:100%;height:520px;"></div>
269
  <div style="margin-top:6px;margin-bottom:8px;">
270
  <button id="{div_id}-reset" style="padding:8px 12px;border-radius:6px;">Reset view</button>
 
271
  </div>
272
 
 
 
273
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
274
 
275
  <script>
 
276
  const fig = {fig_json};
277
  const meta = {meta_json};
278
 
 
279
  const container = document.getElementById("{div_id}");
280
-
281
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
282
 
 
283
  const nodeTraceIndex = fig.data.length - 1;
284
  const edgeCount = fig.data.length - 1;
285
 
286
- const nameToIndex = {{}};
287
- meta.node_names.forEach((n,i) => nameToIndex[n]=i);
 
 
288
 
289
- // focusNode: show only clicked node + its direct neighbors (Option A)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  function focusNode(nodeName) {{
291
  const idx = nameToIndex[nodeName];
292
- const keep = new Set([idx]);
293
-
294
  for (let e = 0; e < meta.edge_source_index.length; e++) {{
295
  const s = meta.edge_source_index[e];
296
  const t = meta.edge_target_index[e];
297
- if (s === idx) {{ keep.add(t); }}
298
- if (t === idx) {{ keep.add(s); }}
299
  }}
300
 
301
- // Update nodes (hide others + hide labels)
302
  const N = meta.node_names.length;
303
  const nodeOp = Array(N).fill(0.0);
304
  const textColors = Array(N).fill("rgba(0,0,0,0)");
305
-
306
  for (let i = 0; i < N; i++) {{
307
- if (keep.has(i)) {{
308
  nodeOp[i] = 1.0;
309
  textColors[i] = "black";
310
  }}
311
  }}
312
-
313
  Plotly.restyle(container, {{
314
  "marker.opacity": [nodeOp],
315
  "textfont.color": [textColors]
316
  }}, [nodeTraceIndex]);
317
 
318
- // Update edges: show only edges connecting kept nodes
319
  for (let e = 0; e < edgeCount; e++) {{
320
  const s = meta.edge_source_index[e];
321
  const t = meta.edge_target_index[e];
322
- const show = (keep.has(s) && keep.has(t));
323
  const color = show ? meta.edge_colors[e] : 'rgba(0,0,0,0)';
324
  const width = show ? meta.edge_widths[e] : 0.1;
325
- Plotly.restyle(container, {{
326
- 'line.color': [color],
327
- 'line.width': [width]
328
- }}, [e]);
329
  }}
330
 
331
  // zoom to bounding box of kept nodes
332
- const nodes = fig.data[nodeTraceIndex];
333
  const xs = [], ys = [];
334
  for (let j = 0; j < meta.node_names.length; j++) {{
335
- if (keep.has(j)) {{
336
- xs.push(nodes.x[j]); ys.push(nodes.y[j]);
 
337
  }}
338
  }}
339
  if (xs.length > 0) {{
340
  const xmin = Math.min(...xs), xmax = Math.max(...xs);
341
  const ymin = Math.min(...ys), ymax = Math.max(...ys);
342
- const padX = (xmax - xmin) * 0.4 + 0.05;
343
- const padY = (ymax - ymin) * 0.4 + 0.05;
344
- Plotly.relayout(container, {{
345
- xaxis: {{ range: [xmin - padX, xmax + padX] }},
346
- yaxis: {{ range: [ymin - padY, ymax + padY] }}
347
- }});
348
  }}
349
  }}
350
 
351
- // reset view: restore nodes and edges
352
  function resetView() {{
353
  const N = meta.node_names.length;
354
  const nodeOp = Array(N).fill(1.0);
355
  const textColors = Array(N).fill("black");
356
-
357
- Plotly.restyle(container, {{
358
- "marker.opacity": [nodeOp],
359
- "textfont.color": [textColors]
360
- }}, [nodeTraceIndex]);
361
 
362
  for (let e = 0; e < edgeCount; e++) {{
363
- Plotly.restyle(container, {{
364
- 'line.color': [meta.edge_colors[e]],
365
- 'line.width': [meta.edge_widths[e]]
366
- }}, [e]);
367
  }}
368
-
369
  Plotly.relayout(container, {{ xaxis: {{autorange:true}}, yaxis: {{autorange:true}} }});
 
 
 
 
 
370
  }}
371
 
372
- // attach click handler
373
  container.on('plotly_click', function(eventData) {{
374
  const p = eventData.points[0];
375
  if (p.curveNumber === nodeTraceIndex) {{
@@ -379,77 +375,71 @@ container.on('plotly_click', function(eventData) {{
379
  }}
380
  }});
381
 
382
- // reset button
383
  document.getElementById("{div_id}-reset").addEventListener('click', function() {{
384
  resetView();
385
  }});
 
386
  </script>
387
  """
388
  return html
389
 
390
- # helper to build final html block
391
- def build_network_html(node_color_company="#FFCF9E",
392
- node_color_amc="#9EC5FF",
393
- edge_color_buy="#2ca02c",
394
- edge_color_sell="#d62728",
395
- edge_color_transfer="#888888",
396
- edge_thickness=1.4,
397
- include_transfers=True):
398
- G = build_graph(include_transfers=include_transfers)
399
- fig, meta = build_plotly_figure(
400
- G,
401
- node_color_amc=node_color_amc,
402
- node_color_company=node_color_company,
403
- edge_color_buy=edge_color_buy,
404
- edge_color_sell=edge_color_sell,
405
- edge_color_transfer=edge_color_transfer,
406
- edge_thickness_base=edge_thickness
407
- )
408
- return make_network_html(fig, meta)
409
-
410
- # initial HTML
411
- initial_html = build_network_html()
412
- # ================= PART 3 / 3 =================
413
- # company & amc summaries, UI and callbacks
414
-
415
- def company_trade_summary(company):
416
- buyers = [a for a, cs in BUY_MAP.items() if company in cs]
417
- sellers = [a for a, cs in SELL_MAP.items() if company in cs]
418
- fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
419
- exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
420
-
421
  df = pd.DataFrame({
422
- "Role": (["Buyer"] * len(buyers)) + (["Seller"] * len(sellers)) +
423
- (["Fresh buy"] * len(fresh)) + (["Complete exit"] * len(exits)),
424
  "AMC": buyers + sellers + fresh + exits
425
  })
426
-
427
  if df.empty:
428
- return None, pd.DataFrame([], columns=["Role", "AMC"])
429
-
430
  counts = df.groupby("Role").size().reset_index(name="Count")
431
- colors = ["green", "red", "orange", "black"][:len(counts)]
432
- fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=colors))
433
- fig.update_layout(title_text=f"Trade summary for {company}", autosize=True, margin=dict(t=30, b=10))
434
  return fig, df
435
 
436
- def amc_transfer_summary(amc):
437
- sold = SELL_MAP.get(amc, [])
438
  transfers = []
439
  for s in sold:
440
- buyers = [a for a, cs in BUY_MAP.items() if s in cs]
441
  for b in buyers:
442
  transfers.append({"security": s, "buyer_amc": b})
443
  df = pd.DataFrame(transfers)
444
  if df.empty:
445
- return None, pd.DataFrame([], columns=["security", "buyer_amc"])
446
  counts = df["buyer_amc"].value_counts().reset_index()
447
- counts.columns = ["Buyer AMC", "Count"]
448
  fig = go.Figure(go.Bar(x=counts["Buyer AMC"], y=counts["Count"], marker_color="lightslategray"))
449
- fig.update_layout(title_text=f"Inferred transfers from {amc}", autosize=True, margin=dict(t=30, b=10))
450
  return fig, df
451
 
452
- # Mobile-friendly CSS (minimal)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  responsive_css = """
454
  .gradio-container { padding:0 !important; margin:0 !important; }
455
  .plotly-graph-div, .js-plotly-plot, .output_plot { width:100% !important; max-width:100% !important; }
@@ -458,56 +448,25 @@ responsive_css = """
458
  body, html { overflow-x:hidden !important; }
459
  """
460
 
461
- # Build UI
462
- with gr.Blocks(css=responsive_css, title="MF Churn Explorer") as demo:
463
- gr.Markdown("## Mutual Fund Churn Explorer — Interactive Graph")
464
 
465
- # Chart HTML (interactive client-side)
466
  network_html = gr.HTML(value=initial_html)
467
 
468
- # Legend (ONLY addition)
469
  legend_html = gr.HTML(value="""
470
- <div style='
471
- font-family: sans-serif;
472
- font-size: 14px;
473
- margin-top: 10px;
474
- line-height: 1.6;
475
- '>
476
- <b>Legend</b><br>
477
-
478
- <div>
479
- <span style="display:inline-block;width:28px;
480
- border-bottom:3px solid #2ca02c;"></span>
481
- BUY (green solid)
482
- </div>
483
-
484
- <div>
485
- <span style="display:inline-block;width:28px;
486
- border-bottom:3px dotted #d62728;"></span>
487
- SELL (red dotted)
488
- </div>
489
-
490
- <div>
491
- <span style="display:inline-block;width:28px;
492
- border-bottom:3px dashed #888;"></span>
493
- TRANSFER (grey dashed — inferred, not actual reported transfer)
494
- </div>
495
-
496
- <div>
497
- <span style="display:inline-block;width:28px;
498
- border-bottom:5px solid #2ca02c;"></span>
499
- FRESH BUY (thick green)
500
- </div>
501
-
502
- <div>
503
- <span style="display:inline-block;width:28px;
504
- border-bottom:5px solid #d62728;"></span>
505
- COMPLETE EXIT (thick red)
506
- </div>
507
  </div>
508
  """)
509
 
510
- # Controls (collapsed by default)
511
  with gr.Accordion("Network Customization — expand to edit", open=False):
512
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
513
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
@@ -518,23 +477,17 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer") as demo:
518
  include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
519
  update_button = gr.Button("Update Network Graph")
520
 
521
- # Company inspect (unchanged)
522
  gr.Markdown("### Inspect Company (buyers / sellers)")
523
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
524
  company_plot = gr.Plot()
525
  company_table = gr.DataFrame()
526
 
527
- # AMC inspect (unchanged)
528
  gr.Markdown("### Inspect AMC (inferred transfers)")
529
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
530
  amc_plot = gr.Plot()
531
  amc_table = gr.DataFrame()
532
 
533
- # Place legend right after the chart (no layout changes beyond that)
534
- # We add both components so legend appears below the chart area.
535
- # Note: the order of declaration in Blocks determines visual order.
536
- # legend_html.update(value=legend_html.value) # ensure added
537
-
538
  # Callbacks
539
  def update_network_html(node_color_company_val, node_color_amc_val,
540
  edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
@@ -568,6 +521,5 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer") as demo:
568
  select_company.change(fn=on_company_select, inputs=[select_company], outputs=[company_plot, company_table])
569
  select_amc.change(fn=on_amc_select, inputs=[select_amc], outputs=[amc_plot, amc_table])
570
 
571
- # Run
572
  if __name__ == "__main__":
573
  demo.launch()
 
1
  # app.py
2
+ # D3 physics (client-side) + Plotly visualization for MF churn explorer
3
+ # Option A: Replace Python layout with D3 force simulation in browser
4
+ # Requirements: gradio, networkx, plotly, pandas, numpy
 
 
5
 
6
  import gradio as gr
7
  import pandas as pd
 
11
  import json
12
  from collections import defaultdict
13
 
14
+ # ---------------------------
15
  # DATA
16
+ # ---------------------------
 
17
  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"
 
54
  COMPLETE_EXIT = {"DSP MF": ["Shriram Finance"]}
55
  FRESH_BUY = {"HDFC MF": ["Tata Elxsi"], "UTI MF": ["Adani Ports"], "Mirae MF": ["HAL"]}
56
 
 
57
  def sanitize_map(m):
58
  out = {}
59
  for k, vals in m.items():
60
  out[k] = [v for v in vals if v in COMPANIES]
61
  return out
62
 
 
63
  BUY_MAP = sanitize_map(BUY_MAP)
64
  SELL_MAP = sanitize_map(SELL_MAP)
65
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
66
  FRESH_BUY = sanitize_map(FRESH_BUY)
67
 
68
+ # ---------------------------
69
  # GRAPH BUILDING
70
+ # ---------------------------
 
71
  company_edges = []
72
  for amc, comps in BUY_MAP.items():
73
  for c in comps:
74
  company_edges.append((amc, c, {"action": "buy", "weight": 1}))
 
75
  for amc, comps in SELL_MAP.items():
76
  for c in comps:
77
  company_edges.append((amc, c, {"action": "sell", "weight": 1}))
 
78
  for amc, comps in COMPLETE_EXIT.items():
79
  for c in comps:
80
  company_edges.append((amc, c, {"action": "complete_exit", "weight": 3}))
 
81
  for amc, comps in FRESH_BUY.items():
82
  for c in comps:
83
  company_edges.append((amc, c, {"action": "fresh_buy", "weight": 3}))
84
 
 
85
  def infer_amc_transfers(buy_map, sell_map):
86
  transfers = defaultdict(int)
87
+ company_to_sellers = defaultdict(list)
88
+ company_to_buyers = defaultdict(list)
 
89
  for amc, comps in sell_map.items():
90
  for c in comps:
91
+ company_to_sellers[c].append(amc)
 
92
  for amc, comps in buy_map.items():
93
  for c in comps:
94
+ company_to_buyers[c].append(amc)
95
+ for c in set(company_to_sellers.keys()) | set(company_to_buyers.keys()):
96
+ sellers = company_to_sellers[c]
97
+ buyers = company_to_buyers[c]
98
+ for s in sellers:
99
+ for b in buyers:
100
+ transfers[(s,b)] += 1
101
+ edge_list = []
102
+ for (s,b), w in transfers.items():
103
+ edge_list.append((s,b, {"action": "transfer", "weight": w}))
104
+ return edge_list
 
105
 
106
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
107
 
 
108
  def build_graph(include_transfers=True):
109
  G = nx.DiGraph()
 
110
  for a in AMCS:
111
  G.add_node(a, type="amc")
 
112
  for c in COMPANIES:
113
  G.add_node(c, type="company")
114
+ for u, v, attrs in company_edges:
115
+ if u in G.nodes and v in G.nodes:
116
+ if G.has_edge(u, v):
117
+ G[u][v]["weight"] += attrs.get("weight",1)
118
+ G[u][v]["actions"].append(attrs["action"])
 
 
 
 
 
 
 
 
 
 
119
  else:
120
+ G.add_edge(u, v, weight=attrs.get("weight",1), actions=[attrs["action"]])
121
+ if include_transfers:
122
+ for s,b,attrs in transfer_edges:
123
+ if s in G.nodes and b in G.nodes:
124
+ if G.has_edge(s,b):
125
+ G[s][b]["weight"] += attrs.get("weight",1)
126
+ G[s][b]["actions"].append("transfer")
127
+ else:
128
+ G.add_edge(s,b,weight=attrs.get("weight",1), actions=["transfer"])
129
  return G
130
 
131
+ # ---------------------------
132
+ # Build Plotly figure (positions will be set by D3 in browser)
133
+ # ---------------------------
134
+ def build_plotly_figure(G,
135
+ node_color_amc="#9EC5FF",
136
+ node_color_company="#FFCF9E",
137
+ edge_color_buy="#2ca02c",
138
+ edge_color_sell="#d62728",
139
+ edge_color_transfer="#888888",
140
+ edge_thickness_base=1.4):
141
+ # For D3 we don't need Python positions. Use zeros placeholder
 
 
 
 
142
  node_names = []
143
  node_x = []
144
  node_y = []
 
147
 
148
  for n, d in G.nodes(data=True):
149
  node_names.append(n)
150
+ node_x.append(0.0)
151
+ node_y.append(0.0)
 
 
152
  if d["type"] == "amc":
153
+ node_color.append(node_color_amc); node_size.append(36)
 
154
  else:
155
+ node_color.append(node_color_company); node_size.append(56)
 
156
 
157
  edge_traces = []
158
+ edge_source_index = []
159
+ edge_target_index = []
160
  edge_colors = []
161
  edge_widths = []
 
162
  for u, v, attrs in G.edges(data=True):
163
+ # placeholder coordinates, will be updated by D3
164
+ edge_traces.append(go.Scatter(x=[0,0], y=[0,0], mode="lines",
165
+ line=dict(color="#888", width=1), hoverinfo="none", opacity=1.0))
166
+ edge_source_index.append(node_names.index(u))
167
+ edge_target_index.append(node_names.index(v))
168
+ acts = attrs.get("actions", [])
169
+ weight = attrs.get("weight",1)
170
  if "complete_exit" in acts:
171
+ edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*3)
 
 
172
  elif "fresh_buy" in acts:
173
+ edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*3)
 
 
174
  elif "transfer" in acts:
175
+ edge_colors.append(edge_color_transfer); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
 
 
176
  elif "sell" in acts:
177
+ edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
 
 
178
  else:
179
+ edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
+ node_trace = go.Scatter(x=node_x, y=node_y, mode="markers+text",
182
+ marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
183
+ text=node_names, textposition="top center", hoverinfo="text")
 
 
 
 
 
184
 
185
+ fig = go.Figure(data=edge_traces + [node_trace])
186
+ fig.update_layout(showlegend=False, autosize=True,
187
+ margin=dict(l=8, r=8, t=36, b=8),
188
+ xaxis=dict(visible=False), yaxis=dict(visible=False))
189
  meta = {
190
  "node_names": node_names,
191
+ "edge_source_index": edge_source_index,
192
+ "edge_target_index": edge_target_index,
193
  "edge_colors": edge_colors,
194
+ "edge_widths": edge_widths,
195
+ "node_colors": node_color,
196
+ "node_sizes": node_size
197
  }
 
198
  return fig, meta
199
+
200
+ def make_network_html_d3(fig, meta, div_id="network-plot-div"):
201
+ """
202
+ Build HTML embedding Plotly figure and D3 physics logic.
203
+ Important: all { and } inside the JS template below are doubled {{ }} so f-string stays valid.
204
+ """
205
  fig_json = json.dumps(fig.to_plotly_json())
206
  meta_json = json.dumps(meta)
207
 
 
209
  <div id="{div_id}" style="width:100%;height:520px;"></div>
210
  <div style="margin-top:6px;margin-bottom:8px;">
211
  <button id="{div_id}-reset" style="padding:8px 12px;border-radius:6px;">Reset view</button>
212
+ <button id="{div_id}-stop" style="padding:8px 12px;border-radius:6px;margin-left:8px;">Stop layout</button>
213
  </div>
214
 
215
+ <!-- load libs -->
216
+ <script src="https://d3js.org/d3.v7.min.js"></script>
217
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
218
 
219
  <script>
220
+ // Embed figure and metadata
221
  const fig = {fig_json};
222
  const meta = {meta_json};
223
 
224
+ // create plot
225
  const container = document.getElementById("{div_id}");
 
226
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
227
 
228
+ // indices
229
  const nodeTraceIndex = fig.data.length - 1;
230
  const edgeCount = fig.data.length - 1;
231
 
232
+ // build nodes array for D3
233
+ const nodes = meta.node_names.map((n, i) => {{
234
+ return {{id: i, name: n, r: meta.node_sizes[i] || 20}};
235
+ }});
236
 
237
+ // build links array
238
+ const links = meta.edge_source_index.map((s, i) => {{
239
+ return {{source: s, target: meta.edge_target_index[i], color: meta.edge_colors[i], width: meta.edge_widths[i] || 1}};
240
+ }});
241
+
242
+ // D3 force simulation parameters tuned for mobile friendliness
243
+ const simulation = d3.forceSimulation(nodes)
244
+ .force("link", d3.forceLink(links).id(d => d.id).distance(80).strength(0.8))
245
+ .force("charge", d3.forceManyBody().strength(-150))
246
+ .force("collision", d3.forceCollide().radius(d => d.r * 0.6))
247
+ .force("center", d3.forceCenter(0,0));
248
+
249
+ // Keep track of whether to keep sim running
250
+ let stopSimulation = false;
251
+ let lastTickTime = Date.now();
252
+ let frameSkip = 0;
253
+
254
+ // throttle Plotly updates: update every N ticks for performance
255
+ let tickCounter = 0;
256
+ simulation.on("tick", () => {{
257
+ tickCounter++;
258
+ // throttle updates - every 2 ticks (adjustable)
259
+ if (tickCounter % 2 !== 0) return;
260
+
261
+ // update node coordinates arrays
262
+ const xs = nodes.map(n => n.x || 0);
263
+ const ys = nodes.map(n => n.y || 0);
264
+
265
+ // update node trace position
266
+ Plotly.restyle(container, {{ 'x': [xs], 'y': [ys] }}, [nodeTraceIndex]);
267
+
268
+ // update each edge trace
269
+ for (let e = 0; e < edgeCount; e++) {{
270
+ const sIdx = meta.edge_source_index[e];
271
+ const tIdx = meta.edge_target_index[e];
272
+ const sx = nodes[sIdx].x || 0;
273
+ const sy = nodes[sIdx].y || 0;
274
+ const tx = nodes[tIdx].x || 0;
275
+ const ty = nodes[tIdx].y || 0;
276
+ Plotly.restyle(container, {{ 'x': [[sx, tx]], 'y': [[sy, ty]] }}, [e]);
277
+ }}
278
+
279
+ // stop the simulation gracefully after it's cooled
280
+ if (simulation.alpha() < 0.03 || stopSimulation) {{
281
+ simulation.stop();
282
+ }}
283
+ }});
284
+
285
+ // allow explicit stop
286
+ document.getElementById("{div_id}-stop").addEventListener('click', () => {{
287
+ stopSimulation = true;
288
+ }});
289
+
290
+ // Map node name -> index for click focus
291
+ const nameToIndex = {{}};
292
+ meta.node_names.forEach((n,i) => nameToIndex[n] = i);
293
+
294
+ // focusNode: hides everything except node + neighbors
295
  function focusNode(nodeName) {{
296
  const idx = nameToIndex[nodeName];
297
+ const keepSet = new Set([idx]);
298
+ // find neighbors
299
  for (let e = 0; e < meta.edge_source_index.length; e++) {{
300
  const s = meta.edge_source_index[e];
301
  const t = meta.edge_target_index[e];
302
+ if (s === idx) keepSet.add(t);
303
+ if (t === idx) keepSet.add(s);
304
  }}
305
 
306
+ // node opacity and label colors
307
  const N = meta.node_names.length;
308
  const nodeOp = Array(N).fill(0.0);
309
  const textColors = Array(N).fill("rgba(0,0,0,0)");
 
310
  for (let i = 0; i < N; i++) {{
311
+ if (keepSet.has(i)) {{
312
  nodeOp[i] = 1.0;
313
  textColors[i] = "black";
314
  }}
315
  }}
 
316
  Plotly.restyle(container, {{
317
  "marker.opacity": [nodeOp],
318
  "textfont.color": [textColors]
319
  }}, [nodeTraceIndex]);
320
 
321
+ // edges: show only those connecting kept nodes
322
  for (let e = 0; e < edgeCount; e++) {{
323
  const s = meta.edge_source_index[e];
324
  const t = meta.edge_target_index[e];
325
+ const show = keepSet.has(s) && keepSet.has(t);
326
  const color = show ? meta.edge_colors[e] : 'rgba(0,0,0,0)';
327
  const width = show ? meta.edge_widths[e] : 0.1;
328
+ Plotly.restyle(container, {{ 'line.color': [color], 'line.width': [width] }}, [e]);
 
 
 
329
  }}
330
 
331
  // zoom to bounding box of kept nodes
332
+ const nodesTrace = fig.data[nodeTraceIndex];
333
  const xs = [], ys = [];
334
  for (let j = 0; j < meta.node_names.length; j++) {{
335
+ if (keepSet.has(j)) {{
336
+ xs.push(nodesTrace.x[j]);
337
+ ys.push(nodesTrace.y[j]);
338
  }}
339
  }}
340
  if (xs.length > 0) {{
341
  const xmin = Math.min(...xs), xmax = Math.max(...xs);
342
  const ymin = Math.min(...ys), ymax = Math.max(...ys);
343
+ const padX = (xmax - xmin) * 0.4 + 10;
344
+ const padY = (ymax - ymin) * 0.4 + 10;
345
+ Plotly.relayout(container, {{ xaxis: {{ range: [xmin - padX, xmax + padX] }}, yaxis: {{ range: [ymin - padY, ymax + padY] }} }});
 
 
 
346
  }}
347
  }}
348
 
349
+ // reset view function: restore everything and restart a short simulation to settle
350
  function resetView() {{
351
  const N = meta.node_names.length;
352
  const nodeOp = Array(N).fill(1.0);
353
  const textColors = Array(N).fill("black");
354
+ Plotly.restyle(container, {{ "marker.opacity": [nodeOp], "textfont.color": [textColors] }}, [nodeTraceIndex]);
 
 
 
 
355
 
356
  for (let e = 0; e < edgeCount; e++) {{
357
+ Plotly.restyle(container, {{ 'line.color': [meta.edge_colors[e]], 'line.width': [meta.edge_widths[e]] }}, [e]);
 
 
 
358
  }}
359
+ // autorange
360
  Plotly.relayout(container, {{ xaxis: {{autorange:true}}, yaxis: {{autorange:true}} }});
361
+
362
+ // restart a short simulation to re-space nodes
363
+ stopSimulation = false;
364
+ simulation.alpha(0.6);
365
+ simulation.restart();
366
  }}
367
 
368
+ // click handler: only react if node trace clicked
369
  container.on('plotly_click', function(eventData) {{
370
  const p = eventData.points[0];
371
  if (p.curveNumber === nodeTraceIndex) {{
 
375
  }}
376
  }});
377
 
378
+ // reset button hookup
379
  document.getElementById("{div_id}-reset").addEventListener('click', function() {{
380
  resetView();
381
  }});
382
+
383
  </script>
384
  """
385
  return html
386
 
387
+ # ---------------------------
388
+ # Company / AMC inspection helpers (unchanged)
389
+ # ---------------------------
390
+ def company_trade_summary(company_name):
391
+ buyers = [a for a, comps in BUY_MAP.items() if company_name in comps]
392
+ sellers = [a for a, comps in SELL_MAP.items() if company_name in comps]
393
+ fresh = [a for a, comps in FRESH_BUY.items() if company_name in comps]
394
+ exits = [a for a, comps in COMPLETE_EXIT.items() if company_name in comps]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  df = pd.DataFrame({
396
+ "Role": ["Buyer"]*len(buyers) + ["Seller"]*len(sellers) + ["Fresh buy"]*len(fresh) + ["Complete exit"]*len(exits),
 
397
  "AMC": buyers + sellers + fresh + exits
398
  })
 
399
  if df.empty:
400
+ return None, pd.DataFrame([], columns=["Role","AMC"])
 
401
  counts = df.groupby("Role").size().reset_index(name="Count")
402
+ fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=["green","red","orange","black"][:len(counts)]))
403
+ fig.update_layout(title_text=f"Trade summary for {company_name}", autosize=True, margin=dict(t=30,b=10))
 
404
  return fig, df
405
 
406
+ def amc_transfer_summary(amc_name):
407
+ sold = SELL_MAP.get(amc_name, [])
408
  transfers = []
409
  for s in sold:
410
+ buyers = [a for a, comps in BUY_MAP.items() if s in comps]
411
  for b in buyers:
412
  transfers.append({"security": s, "buyer_amc": b})
413
  df = pd.DataFrame(transfers)
414
  if df.empty:
415
+ return None, pd.DataFrame([], columns=["security","buyer_amc"])
416
  counts = df["buyer_amc"].value_counts().reset_index()
417
+ counts.columns = ["Buyer AMC","Count"]
418
  fig = go.Figure(go.Bar(x=counts["Buyer AMC"], y=counts["Count"], marker_color="lightslategray"))
419
+ fig.update_layout(title_text=f"Inferred transfers from {amc_name}", autosize=True, margin=dict(t=30,b=10))
420
  return fig, df
421
 
422
+ # ---------------------------
423
+ # Build the initial HTML (Plotly + D3)
424
+ # ---------------------------
425
+ def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
426
+ edge_color_buy="#2ca02c", edge_color_sell="#d62728",
427
+ edge_color_transfer="#888888", edge_thickness=1.4, include_transfers=True):
428
+ G = build_graph(include_transfers=include_transfers)
429
+ fig, meta = build_plotly_figure(G,
430
+ node_color_amc=node_color_amc,
431
+ node_color_company=node_color_company,
432
+ edge_color_buy=edge_color_buy,
433
+ edge_color_sell=edge_color_sell,
434
+ edge_color_transfer=edge_color_transfer,
435
+ edge_thickness_base=edge_thickness)
436
+ return make_network_html_d3(fig, meta)
437
+
438
+ initial_html = build_network_html()
439
+
440
+ # ---------------------------
441
+ # Mobile CSS and Gradio UI
442
+ # ---------------------------
443
  responsive_css = """
444
  .gradio-container { padding:0 !important; margin:0 !important; }
445
  .plotly-graph-div, .js-plotly-plot, .output_plot { width:100% !important; max-width:100% !important; }
 
448
  body, html { overflow-x:hidden !important; }
449
  """
450
 
451
+ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (D3 physics)") as demo:
452
+ gr.Markdown("## Mutual Fund Churn Explorer — D3 force-directed layout (mobile friendly)")
 
453
 
454
+ # interactive chart (HTML block)
455
  network_html = gr.HTML(value=initial_html)
456
 
457
+ # Legend (updated with inferred note)
458
  legend_html = gr.HTML(value="""
459
+ <div style='font-family:sans-serif;font-size:14px;margin-top:10px;line-height:1.6;'>
460
+ <b>Legend</b><br>
461
+ <div><span style="display:inline-block;width:28px;border-bottom:3px solid #2ca02c;"></span> BUY (green solid)</div>
462
+ <div><span style="display:inline-block;width:28px;border-bottom:3px dotted #d62728;"></span> SELL (red dotted)</div>
463
+ <div><span style="display:inline-block;width:28px;border-bottom:3px dashed #888;"></span> TRANSFER (grey dashed — inferred, not actual reported transfer)</div>
464
+ <div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY (thick green)</div>
465
+ <div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT (thick red)</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  </div>
467
  """)
468
 
469
+ # Controls (unchanged)
470
  with gr.Accordion("Network Customization — expand to edit", open=False):
471
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
472
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
 
477
  include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
478
  update_button = gr.Button("Update Network Graph")
479
 
480
+ # Company & AMC inspect (unchanged)
481
  gr.Markdown("### Inspect Company (buyers / sellers)")
482
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
483
  company_plot = gr.Plot()
484
  company_table = gr.DataFrame()
485
 
 
486
  gr.Markdown("### Inspect AMC (inferred transfers)")
487
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
488
  amc_plot = gr.Plot()
489
  amc_table = gr.DataFrame()
490
 
 
 
 
 
 
491
  # Callbacks
492
  def update_network_html(node_color_company_val, node_color_amc_val,
493
  edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
 
521
  select_company.change(fn=on_company_select, inputs=[select_company], outputs=[company_plot, company_table])
522
  select_amc.change(fn=on_amc_select, inputs=[select_amc], outputs=[amc_plot, amc_table])
523
 
 
524
  if __name__ == "__main__":
525
  demo.launch()