Files changed (1) hide show
  1. app.py +190 -216
app.py CHANGED
@@ -1,7 +1,6 @@
1
  # app.py
2
- # Mutual Fund Churn Explorer — Smooth organic motion, short-lived (L1)
3
- # D3 + Plotly hybrid layout optimized for phones (simulation stops after ~0.8s)
4
- # Works in Hugging Face Spaces (Gradio)
5
 
6
  import gradio as gr
7
  import pandas as pd
@@ -12,7 +11,7 @@ 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",
@@ -66,21 +65,21 @@ COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
66
  FRESH_BUY = sanitize_map(FRESH_BUY)
67
 
68
  # ---------------------------
69
- # Graph building & transfer inference
70
  # ---------------------------
71
  def infer_amc_transfers(buy_map, sell_map):
72
  transfers = defaultdict(int)
73
- comp_sellers = defaultdict(list)
74
- comp_buyers = defaultdict(list)
75
  for amc, comps in sell_map.items():
76
  for c in comps:
77
- comp_sellers[c].append(amc)
78
  for amc, comps in buy_map.items():
79
  for c in comps:
80
- comp_buyers[c].append(amc)
81
- for c in set(comp_sellers.keys()) | set(comp_buyers.keys()):
82
- for s in comp_sellers[c]:
83
- for b in comp_buyers[c]:
84
  transfers[(s,b)] += 1
85
  out = []
86
  for (s,b), w in transfers.items():
@@ -91,12 +90,9 @@ transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
91
 
92
  def build_graph(include_transfers=True):
93
  G = nx.DiGraph()
94
- for a in AMCS:
95
- G.add_node(a, type="amc")
96
- for c in COMPANIES:
97
- G.add_node(c, type="company")
98
-
99
- # buys and sells
100
  for amc, comps in BUY_MAP.items():
101
  for c in comps:
102
  if G.has_edge(amc, c):
@@ -104,7 +100,7 @@ def build_graph(include_transfers=True):
104
  G[amc][c]["actions"].append("buy")
105
  else:
106
  G.add_edge(amc, c, weight=1, actions=["buy"])
107
-
108
  for amc, comps in SELL_MAP.items():
109
  for c in comps:
110
  if G.has_edge(amc, c):
@@ -112,7 +108,6 @@ def build_graph(include_transfers=True):
112
  G[amc][c]["actions"].append("sell")
113
  else:
114
  G.add_edge(amc, c, weight=1, actions=["sell"])
115
-
116
  # complete exits
117
  for amc, comps in COMPLETE_EXIT.items():
118
  for c in comps:
@@ -121,8 +116,7 @@ def build_graph(include_transfers=True):
121
  G[amc][c]["actions"].append("complete_exit")
122
  else:
123
  G.add_edge(amc, c, weight=3, actions=["complete_exit"])
124
-
125
- # fresh buys
126
  for amc, comps in FRESH_BUY.items():
127
  for c in comps:
128
  if G.has_edge(amc, c):
@@ -130,206 +124,176 @@ def build_graph(include_transfers=True):
130
  G[amc][c]["actions"].append("fresh_buy")
131
  else:
132
  G.add_edge(amc, c, weight=3, actions=["fresh_buy"])
133
-
134
  # inferred transfers
135
  if include_transfers:
136
  for s,b,attr in transfer_edges:
137
  if G.has_edge(s,b):
138
- G[s][b]["weight"] += attr["weight"]
139
  G[s][b]["actions"].append("transfer")
140
  else:
141
- G.add_edge(s,b, weight=attr["weight"], actions=["transfer"])
142
  return G
143
 
144
  # ---------------------------
145
- # Build plotly figure (positions are placeholders)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  # ---------------------------
147
- def build_plotly_figure(G,
148
- node_color_amc="#9EC5FF",
149
- node_color_company="#FFCF9E",
150
- edge_color_buy="#2ca02c",
151
- edge_color_sell="#d62728",
152
- edge_color_transfer="#888888",
153
- edge_thickness=1.4):
 
 
154
 
155
  node_names = []
156
  node_x = []
157
  node_y = []
158
- node_colors = []
159
- node_sizes = []
 
160
 
161
  for n, d in G.nodes(data=True):
162
  node_names.append(n)
163
- node_x.append(0)
164
- node_y.append(0)
 
165
  if d["type"] == "amc":
166
- node_colors.append(node_color_amc)
167
- node_sizes.append(36)
168
  else:
169
- node_colors.append(node_color_company)
170
- node_sizes.append(56)
171
 
 
172
  edge_traces = []
173
- src_idx = []
174
- tgt_idx = []
175
- e_colors = []
176
- e_widths = []
177
-
178
- for u, v, attrs in G.edges(data=True):
179
- edge_traces.append(go.Scatter(x=[0,0], y=[0,0], mode="lines",
180
- line=dict(color="#aaa", width=1), hoverinfo="none"))
181
- src_idx.append(node_names.index(u))
182
- tgt_idx.append(node_names.index(v))
183
 
 
 
184
  acts = attrs.get("actions", [])
185
  w = attrs.get("weight", 1)
186
  if "complete_exit" in acts:
187
- e_colors.append(edge_color_sell); e_widths.append(edge_thickness*3)
188
  elif "fresh_buy" in acts:
189
- e_colors.append(edge_color_buy); e_widths.append(edge_thickness*3)
190
  elif "transfer" in acts:
191
- e_colors.append(edge_color_transfer); e_widths.append(edge_thickness*(1+np.log1p(w)))
192
  elif "sell" in acts:
193
- e_colors.append(edge_color_sell); e_widths.append(edge_thickness*(1+np.log1p(w)))
194
  else:
195
- e_colors.append(edge_color_buy); e_widths.append(edge_thickness*(1+np.log1p(w)))
196
-
197
- node_trace = go.Scatter(x=node_x, y=node_y, mode="markers+text",
198
- marker=dict(color=node_colors, size=node_sizes, line=dict(width=2,color="#333")),
199
- text=node_names, textposition="top center", hoverinfo="text")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  fig = go.Figure(data=edge_traces + [node_trace])
202
- fig.update_layout(showlegend=False, autosize=True, margin=dict(l=5,r=5,t=30,b=5),
203
- xaxis=dict(visible=False), yaxis=dict(visible=False))
 
 
 
 
 
 
204
 
205
  meta = {
206
  "node_names": node_names,
207
- "edge_source_index": src_idx,
208
- "edge_target_index": tgt_idx,
209
- "edge_colors": e_colors,
210
- "edge_widths": e_widths,
211
- "node_sizes": node_sizes
 
212
  }
213
 
214
  return fig, meta
215
 
216
  # ---------------------------
217
- # HTML maker: D3 + short-lived smooth motion
218
  # ---------------------------
219
- def make_network_html(fig, meta, div_id="network-plot-div"):
220
  fig_json = json.dumps(fig.to_plotly_json())
221
  meta_json = json.dumps(meta)
222
-
223
- # Short-lived simulation parameters:
224
- # - run for about 0.8s (or until alpha cools)
225
- # - throttle Plotly updates for performance
226
  html = f"""
227
- <div id="{div_id}" style="width:100%; height:560px;"></div>
228
  <div style="margin-top:6px;">
229
  <button id="{div_id}-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
230
- <button id="{div_id}-stop" style="padding:8px 12px; margin-left:8px; border-radius:6px;">Stop Layout</button>
231
  </div>
232
 
233
- <script src="https://d3js.org/d3.v7.min.js"></script>
234
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
235
 
236
  <script>
237
  const fig = {fig_json};
238
  const meta = {meta_json};
239
  const container = document.getElementById("{div_id}");
 
 
240
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
241
 
242
  const nodeTraceIndex = fig.data.length - 1;
243
  const edgeCount = fig.data.length - 1;
244
 
245
- // build lightweight nodes and links
246
- const nodes = meta.node_names.map((name,i) => ({{
247
- id: i, name: name, r: meta.node_sizes[i] || 20,
248
- displayX: 0, displayY: 0, vx_smooth: 0, vy_smooth: 0
249
- }}));
250
- const links = meta.edge_source_index.map((s,i) => ({{
251
- source: s, target: meta.edge_target_index[i]
252
- }}));
253
-
254
- // Gentle simulation tuned to settle quickly
255
- const simulation = d3.forceSimulation(nodes)
256
- .force("link", d3.forceLink(links).id(d => d.id).distance(120).strength(0.32))
257
- .force("charge", d3.forceManyBody().strength(-40))
258
- .force("collision", d3.forceCollide().radius(d => d.r * 0.9))
259
- .force("center", d3.forceCenter(0,0))
260
- .velocityDecay(0.48);
261
-
262
- // Smoothing interpolation factor for organic motion
263
- const interp = 0.16;
264
-
265
- // Throttle updates to Plotly for performance
266
- let tickCounter = 0;
267
- const TICKS_PER_UPDATE = 3; // update Plotly every 3 ticks
268
- let frameCount = 0;
269
- const MAX_TICKS = 120; // safety cap (~0.8-1.0s depending on device)
270
- let stoppedManually = false;
271
-
272
- simulation.on("tick", () => {{
273
- frameCount++;
274
- tickCounter++;
275
-
276
- // apply smooth interpolation (organic)
277
- nodes.forEach(n => {{
278
- const tx = n.x || 0;
279
- const ty = n.y || 0;
280
-
281
- n.vx_smooth = n.vx_smooth * 0.80 + (tx - n.displayX) * interp;
282
- n.vy_smooth = n.vy_smooth * 0.80 + (ty - n.displayY) * interp;
283
-
284
- // mild damping
285
- n.vx_smooth *= 0.92;
286
- n.vy_smooth *= 0.92;
287
-
288
- n.displayX += n.vx_smooth;
289
- n.displayY += n.vy_smooth;
290
- }});
291
-
292
- if (tickCounter % TICKS_PER_UPDATE === 0) {{
293
- const xs = nodes.map(n => n.displayX);
294
- const ys = nodes.map(n => n.displayY);
295
- Plotly.restyle(container, {{ x: [xs], y: [ys] }}, [nodeTraceIndex]);
296
-
297
- for (let e = 0; e < edgeCount; e++) {{
298
- const s = meta.edge_source_index[e];
299
- const t = meta.edge_target_index[e];
300
- const sx = nodes[s].displayX || 0;
301
- const sy = nodes[s].displayY || 0;
302
- const tx = nodes[t].displayX || 0;
303
- const ty = nodes[t].displayY || 0;
304
- Plotly.restyle(container, {{
305
- x: [[sx, tx]],
306
- y: [[sy, ty]],
307
- "line.color": [meta.edge_colors[e]],
308
- "line.width": [meta.edge_widths[e]]
309
- }}, [e]);
310
- }}
311
- }}
312
-
313
- // stop conditions: either alpha cooled or reached tick cap or stopped manually
314
- if (simulation.alpha() < 0.03 || frameCount > MAX_TICKS || stoppedManually) {{
315
- simulation.stop();
316
- }}
317
- }});
318
-
319
- // Stop button
320
- document.getElementById("{div_id}-stop").addEventListener("click", () => {{
321
- stoppedManually = true;
322
- simulation.stop();
323
- }});
324
-
325
- // map name -> index
326
  const nameToIndex = {{}};
327
- meta.node_names.forEach((n,i) => nameToIndex[n] = i);
328
 
329
- // focus node: keep node + direct neighbors (Option A)
330
  function focusNode(name) {{
331
  const idx = nameToIndex[name];
332
  const keep = new Set([idx]);
 
333
  for (let e=0; e < meta.edge_source_index.length; e++) {{
334
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
335
  if (s === idx) keep.add(t);
@@ -337,24 +301,31 @@ function focusNode(name) {{
337
  }}
338
 
339
  const N = meta.node_names.length;
340
- const op = Array(N).fill(0.0);
341
- const txt = Array(N).fill("rgba(0,0,0,0)");
342
  for (let i=0;i<N;i++) {{
343
- if (keep.has(i)) {{ op[i] = 1.0; txt[i] = "black"; }}
344
  }}
345
- Plotly.restyle(container, {{ "marker.opacity": [op], "textfont.color": [txt] }}, [nodeTraceIndex]);
346
 
347
- for (let e=0; e<edgeCount; e++) {{
 
 
 
 
 
 
348
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
349
  const show = keep.has(s) && keep.has(t);
 
 
350
  Plotly.restyle(container, {{
351
- "line.color": [ show ? meta.edge_colors[e] : "rgba(0,0,0,0)" ],
352
- "line.width": [ show ? meta.edge_widths[e] : 0.1 ]
353
  }}, [e]);
354
  }}
355
  }}
356
 
357
- // reset view: restore everything and run a short settling simulation
358
  function resetView() {{
359
  const N = meta.node_names.length;
360
  Plotly.restyle(container, {{
@@ -362,44 +333,38 @@ function resetView() {{
362
  "textfont.color": [Array(N).fill("black")]
363
  }}, [nodeTraceIndex]);
364
 
365
- for (let e=0;e<edgeCount;e++) {{
366
  Plotly.restyle(container, {{
367
  "line.color": [meta.edge_colors[e]],
368
  "line.width": [meta.edge_widths[e]]
369
  }}, [e]);
370
  }}
371
-
372
- // restart a very short simulation to gently re-space nodes
373
- stoppedManually = false;
374
- frameCount = 0;
375
- simulation.alpha(0.6);
376
- simulation.restart();
377
  }}
378
 
379
- // click handler to focus
380
- container.on("plotly_click", (evt) => {{
381
  const p = evt.points && evt.points[0];
382
  if (p && p.curveNumber === nodeTraceIndex) {{
383
- const idx = p.pointNumber;
384
- const name = meta.node_names[idx];
385
  focusNode(name);
386
  }}
387
  }});
388
 
389
- // reset button hookup
390
  document.getElementById("{div_id}-reset").addEventListener("click", resetView);
391
  </script>
392
  """
393
  return html
394
 
395
  # ---------------------------
396
- # Company / AMC summaries
397
  # ---------------------------
398
  def company_trade_summary(company):
399
- buyers = [a for a, cs in BUY_MAP.items() if company in cs]
400
- sellers = [a for a, cs in SELL_MAP.items() if company in cs]
401
- fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
402
- exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
 
403
  df = pd.DataFrame({
404
  "Role": ["Buyer"]*len(buyers) + ["Seller"]*len(sellers) + ["Fresh buy"]*len(fresh) + ["Complete exit"]*len(exits),
405
  "AMC": buyers + sellers + fresh + exits
@@ -408,14 +373,14 @@ def company_trade_summary(company):
408
  return None, pd.DataFrame([], columns=["Role","AMC"])
409
  counts = df.groupby("Role").size().reset_index(name="Count")
410
  fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=["green","red","orange","black"][:len(counts)]))
411
- fig.update_layout(title_text=f"Trade summary for {company}", autosize=True, margin=dict(t=30,b=10))
412
  return fig, df
413
 
414
  def amc_transfer_summary(amc):
415
  sold = SELL_MAP.get(amc, [])
416
  transfers = []
417
  for s in sold:
418
- buyers = [a for a, cs in BUY_MAP.items() if s in cs]
419
  for b in buyers:
420
  transfers.append({"security": s, "buyer_amc": b})
421
  df = pd.DataFrame(transfers)
@@ -423,35 +388,44 @@ def amc_transfer_summary(amc):
423
  return None, pd.DataFrame([], columns=["security","buyer_amc"])
424
  counts = df["buyer_amc"].value_counts().reset_index()
425
  counts.columns = ["Buyer AMC","Count"]
426
- fig = go.Figure(go.Bar(x=counts["Buyer AMC"], y=counts["Count"], marker_color="lightslategray"))
427
- fig.update_layout(title_text=f"Inferred transfers from {amc}", autosize=True, margin=dict(t=30,b=10))
428
  return fig, df
429
 
430
  # ---------------------------
431
- # Glue: build initial html & Gradio UI
432
  # ---------------------------
433
- def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
434
- edge_color_buy="#2ca02c", edge_color_sell="#d62728",
435
- edge_color_transfer="#888888", edge_thickness=1.4, include_transfers=True):
 
 
 
 
436
  G = build_graph(include_transfers=include_transfers)
437
- fig, meta = build_plotly_figure(G,
438
- node_color_amc=node_color_amc,
439
- node_color_company=node_color_company,
440
- edge_color_buy=edge_color_buy,
441
- edge_color_sell=edge_color_sell,
442
- edge_color_transfer=edge_color_transfer,
443
- edge_thickness=edge_thickness)
444
- return make_network_html(fig, meta)
 
 
445
 
446
  initial_html = build_network_html()
447
 
 
 
 
448
  responsive_css = """
449
  .js-plotly-plot { height:560px !important; }
450
- @media(max-width:780px){ .js-plotly-plot{ height:540px !important; } }
451
  """
452
 
453
- with gr.Blocks(css=responsive_css, title="MF Churn Explorer (Smooth Short Motion)") as demo:
454
- gr.Markdown("## Mutual Fund Churn Explorer — Smooth organic motion (short-lived)")
455
 
456
  network_html = gr.HTML(value=initial_html)
457
 
@@ -461,27 +435,27 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (Smooth Short Motion
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)</div>
464
- <div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY</div>
465
- <div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT</div>
466
  </div>
467
  """)
468
 
469
- with gr.Accordion("Customize Network", open=False):
470
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
471
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
472
  edge_color_buy = gr.ColorPicker("#2ca02c", label="BUY edge color")
473
  edge_color_sell = gr.ColorPicker("#d62728", label="SELL edge color")
474
  edge_color_transfer = gr.ColorPicker("#888888", label="Transfer edge color")
475
- edge_thickness = gr.Slider(0.5, 6.0, 1.4, step=0.1, label="Edge thickness")
476
- include_transfers = gr.Checkbox(True, label="Show inferred AMC→AMC transfers")
477
- update_btn = gr.Button("Update Graph")
478
 
479
- gr.Markdown("### Company Summary")
480
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
481
  company_plot = gr.Plot()
482
  company_table = gr.DataFrame()
483
 
484
- gr.Markdown("### AMC Summary (Inferred Transfers)")
485
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
486
  amc_plot = gr.Plot()
487
  amc_table = gr.DataFrame()
@@ -497,11 +471,11 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (Smooth Short Motion
497
  edge_thickness=edge_thickness_val,
498
  include_transfers=include_transfers_val)
499
 
500
- update_btn.click(fn=update_network,
501
- inputs=[node_color_company, node_color_amc,
502
- edge_color_buy, edge_color_sell, edge_color_transfer,
503
- edge_thickness, include_transfers],
504
- outputs=[network_html])
505
 
506
  def on_company(c):
507
  fig, df = company_trade_summary(c)
 
1
  # app.py
2
+ # Static bipartite network for Mutual Fund Churn Explorer
3
+ # Left = AMCs, Right = Companies. Static positions (no animation). Mobile-safe.
 
4
 
5
  import gradio as gr
6
  import pandas as pd
 
11
  from collections import defaultdict
12
 
13
  # ---------------------------
14
+ # DATA
15
  # ---------------------------
16
  AMCS = [
17
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
 
65
  FRESH_BUY = sanitize_map(FRESH_BUY)
66
 
67
  # ---------------------------
68
+ # Build graph + inferred transfers
69
  # ---------------------------
70
  def infer_amc_transfers(buy_map, sell_map):
71
  transfers = defaultdict(int)
72
+ c2s = defaultdict(list)
73
+ c2b = defaultdict(list)
74
  for amc, comps in sell_map.items():
75
  for c in comps:
76
+ c2s[c].append(amc)
77
  for amc, comps in buy_map.items():
78
  for c in comps:
79
+ c2b[c].append(amc)
80
+ for c in set(c2s.keys()) | set(c2b.keys()):
81
+ for s in c2s[c]:
82
+ for b in c2b[c]:
83
  transfers[(s,b)] += 1
84
  out = []
85
  for (s,b), w in transfers.items():
 
90
 
91
  def build_graph(include_transfers=True):
92
  G = nx.DiGraph()
93
+ for a in AMCS: G.add_node(a, type="amc")
94
+ for c in COMPANIES: G.add_node(c, type="company")
95
+ # buys
 
 
 
96
  for amc, comps in BUY_MAP.items():
97
  for c in comps:
98
  if G.has_edge(amc, c):
 
100
  G[amc][c]["actions"].append("buy")
101
  else:
102
  G.add_edge(amc, c, weight=1, actions=["buy"])
103
+ # sells
104
  for amc, comps in SELL_MAP.items():
105
  for c in comps:
106
  if G.has_edge(amc, c):
 
108
  G[amc][c]["actions"].append("sell")
109
  else:
110
  G.add_edge(amc, c, weight=1, actions=["sell"])
 
111
  # complete exits
112
  for amc, comps in COMPLETE_EXIT.items():
113
  for c in comps:
 
116
  G[amc][c]["actions"].append("complete_exit")
117
  else:
118
  G.add_edge(amc, c, weight=3, actions=["complete_exit"])
119
+ # fresh buy
 
120
  for amc, comps in FRESH_BUY.items():
121
  for c in comps:
122
  if G.has_edge(amc, c):
 
124
  G[amc][c]["actions"].append("fresh_buy")
125
  else:
126
  G.add_edge(amc, c, weight=3, actions=["fresh_buy"])
 
127
  # inferred transfers
128
  if include_transfers:
129
  for s,b,attr in transfer_edges:
130
  if G.has_edge(s,b):
131
+ G[s][b]["weight"] += attr.get("weight",1)
132
  G[s][b]["actions"].append("transfer")
133
  else:
134
+ G.add_edge(s,b, weight=attr.get("weight",1), actions=["transfer"])
135
  return G
136
 
137
  # ---------------------------
138
+ # Static bipartite layout generator
139
+ # ---------------------------
140
+ def bipartite_positions(G, left_nodes, right_nodes, x_left=-1.0, x_right=1.0, y_pad=0.1):
141
+ """
142
+ Place left_nodes at x_left and right_nodes at x_right.
143
+ Spread nodes vertically from -1..1 with padding y_pad.
144
+ Returns dict {node: (x,y)}
145
+ """
146
+ pos = {}
147
+ # left column
148
+ nL = len(left_nodes)
149
+ if nL == 1:
150
+ ysL = [0.0]
151
+ else:
152
+ span = 2.0 - 2*y_pad
153
+ ysL = [ -1 + y_pad + i * (span/(nL-1)) for i in range(nL) ]
154
+ for n, y in zip(left_nodes, ysL):
155
+ pos[n] = (x_left, y)
156
+ # right column
157
+ nR = len(right_nodes)
158
+ if nR == 1:
159
+ ysR = [0.0]
160
+ else:
161
+ span = 2.0 - 2*y_pad
162
+ ysR = [ -1 + y_pad + i * (span/(nR-1)) for i in range(nR) ]
163
+ for n, y in zip(right_nodes, ysR):
164
+ pos[n] = (x_right, y)
165
+ return pos
166
+
167
+ # ---------------------------
168
+ # Build static Plotly figure
169
  # ---------------------------
170
+ def build_plotly_static_figure(G,
171
+ node_color_amc="#9EC5FF",
172
+ node_color_company="#FFCF9E",
173
+ edge_color_buy="#2ca02c",
174
+ edge_color_sell="#d62728",
175
+ edge_color_transfer="#888888",
176
+ edge_thickness=1.6):
177
+ # positions: left=AMCS, right=COMPANIES
178
+ pos = bipartite_positions(G, AMCS, COMPANIES, x_left=-1.0, x_right=1.0, y_pad=0.06)
179
 
180
  node_names = []
181
  node_x = []
182
  node_y = []
183
+ node_color = []
184
+ node_size = []
185
+ node_type = []
186
 
187
  for n, d in G.nodes(data=True):
188
  node_names.append(n)
189
+ x,y = pos[n]
190
+ node_x.append(x)
191
+ node_y.append(y)
192
  if d["type"] == "amc":
193
+ node_color.append(node_color_amc); node_size.append(36); node_type.append("amc")
 
194
  else:
195
+ node_color.append(node_color_company); node_size.append(52); node_type.append("company")
 
196
 
197
+ # create edge traces (one per edge for easy restyle)
198
  edge_traces = []
199
+ edge_src_idx = []
200
+ edge_tgt_idx = []
201
+ edge_colors = []
202
+ edge_widths = []
 
 
 
 
 
 
203
 
204
+ for u,v,attrs in G.edges(data=True):
205
+ x0,y0 = pos[u]; x1,y1 = pos[v]
206
  acts = attrs.get("actions", [])
207
  w = attrs.get("weight", 1)
208
  if "complete_exit" in acts:
209
+ color = edge_color_sell; width = edge_thickness * 3; dash = "solid"
210
  elif "fresh_buy" in acts:
211
+ color = edge_color_buy; width = edge_thickness * 3; dash = "solid"
212
  elif "transfer" in acts:
213
+ color = edge_color_transfer; width = edge_thickness * (1 + np.log1p(w)); dash = "dash"
214
  elif "sell" in acts:
215
+ color = edge_color_sell; width = edge_thickness * (1 + np.log1p(w)); dash = "dot"
216
  else:
217
+ color = edge_color_buy; width = edge_thickness * (1 + np.log1p(w)); dash = "solid"
218
+
219
+ edge_traces.append(go.Scatter(
220
+ x=[x0, x1], y=[y0, y1],
221
+ mode="lines",
222
+ line=dict(color=color, width=width, dash=dash),
223
+ hoverinfo="text",
224
+ text=f"{u} → {v} ({', '.join(acts)})"
225
+ ))
226
+ edge_src_idx.append(node_names.index(u))
227
+ edge_tgt_idx.append(node_names.index(v))
228
+ edge_colors.append(color)
229
+ edge_widths.append(width)
230
+
231
+ node_trace = go.Scatter(
232
+ x=node_x, y=node_y,
233
+ mode="markers+text",
234
+ marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
235
+ text=node_names,
236
+ textposition="middle right",
237
+ hoverinfo="text"
238
+ )
239
 
240
  fig = go.Figure(data=edge_traces + [node_trace])
241
+ fig.update_layout(
242
+ title="Mutual Fund Churn — Static Bipartite Layout",
243
+ showlegend=False,
244
+ autosize=True,
245
+ margin=dict(l=10, r=10, t=40, b=10),
246
+ xaxis=dict(visible=False),
247
+ yaxis=dict(visible=False)
248
+ )
249
 
250
  meta = {
251
  "node_names": node_names,
252
+ "edge_source_index": edge_src_idx,
253
+ "edge_target_index": edge_tgt_idx,
254
+ "edge_colors": edge_colors,
255
+ "edge_widths": edge_widths,
256
+ "node_x": node_x,
257
+ "node_y": node_y,
258
  }
259
 
260
  return fig, meta
261
 
262
  # ---------------------------
263
+ # Make HTML (static) with JS click handlers
264
  # ---------------------------
265
+ def make_static_html(fig, meta, div_id="network-plot-div"):
266
  fig_json = json.dumps(fig.to_plotly_json())
267
  meta_json = json.dumps(meta)
268
+ # NOTE: inside this f-string we must double braces for JS object blocks
 
 
 
269
  html = f"""
270
+ <div id="{div_id}" style="width:100%; height:580px;"></div>
271
  <div style="margin-top:6px;">
272
  <button id="{div_id}-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
 
273
  </div>
274
 
 
275
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
276
 
277
  <script>
278
  const fig = {fig_json};
279
  const meta = {meta_json};
280
  const container = document.getElementById("{div_id}");
281
+
282
+ // Render plotly figure (static positions embedded)
283
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
284
 
285
  const nodeTraceIndex = fig.data.length - 1;
286
  const edgeCount = fig.data.length - 1;
287
 
288
+ // Map name -> index
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  const nameToIndex = {{}};
290
+ meta.node_names.forEach((n,i) => nameToIndex[n]=i);
291
 
292
+ // Focus node: show only node + neighbors, hide others (including labels)
293
  function focusNode(name) {{
294
  const idx = nameToIndex[name];
295
  const keep = new Set([idx]);
296
+
297
  for (let e=0; e < meta.edge_source_index.length; e++) {{
298
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
299
  if (s === idx) keep.add(t);
 
301
  }}
302
 
303
  const N = meta.node_names.length;
304
+ const nodeOp = Array(N).fill(0.0);
305
+ const textColors = Array(N).fill("rgba(0,0,0,0)");
306
  for (let i=0;i<N;i++) {{
307
+ if (keep.has(i)) {{ nodeOp[i]=1.0; textColors[i]="black"; }}
308
  }}
 
309
 
310
+ Plotly.restyle(container, {{
311
+ "marker.opacity": [nodeOp],
312
+ "textfont.color": [textColors]
313
+ }}, [nodeTraceIndex]);
314
+
315
+ // edges: show only those connecting kept nodes
316
+ for (let e=0; e < edgeCount; e++) {{
317
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
318
  const show = keep.has(s) && keep.has(t);
319
+ const color = show ? meta.edge_colors[e] : "rgba(0,0,0,0)";
320
+ const width = show ? meta.edge_widths[e] : 0.1;
321
  Plotly.restyle(container, {{
322
+ "line.color": [color],
323
+ "line.width": [width]
324
  }}, [e]);
325
  }}
326
  }}
327
 
328
+ // Reset view
329
  function resetView() {{
330
  const N = meta.node_names.length;
331
  Plotly.restyle(container, {{
 
333
  "textfont.color": [Array(N).fill("black")]
334
  }}, [nodeTraceIndex]);
335
 
336
+ for (let e=0; e < edgeCount; e++) {{
337
  Plotly.restyle(container, {{
338
  "line.color": [meta.edge_colors[e]],
339
  "line.width": [meta.edge_widths[e]]
340
  }}, [e]);
341
  }}
 
 
 
 
 
 
342
  }}
343
 
344
+ // Hook click
345
+ container.on('plotly_click', function(evt) {{
346
  const p = evt.points && evt.points[0];
347
  if (p && p.curveNumber === nodeTraceIndex) {{
348
+ const name = meta.node_names[p.pointNumber];
 
349
  focusNode(name);
350
  }}
351
  }});
352
 
353
+ // Hook reset button
354
  document.getElementById("{div_id}-reset").addEventListener("click", resetView);
355
  </script>
356
  """
357
  return html
358
 
359
  # ---------------------------
360
+ # Company & AMC summaries (unchanged)
361
  # ---------------------------
362
  def company_trade_summary(company):
363
+ buyers = [a for a,cs in BUY_MAP.items() if company in cs]
364
+ sellers = [a for a,cs in SELL_MAP.items() if company in cs]
365
+ fresh = [a for a,cs in FRESH_BUY.items() if company in cs]
366
+ exits = [a for a,cs in COMPLETE_EXIT.items() if company in cs]
367
+
368
  df = pd.DataFrame({
369
  "Role": ["Buyer"]*len(buyers) + ["Seller"]*len(sellers) + ["Fresh buy"]*len(fresh) + ["Complete exit"]*len(exits),
370
  "AMC": buyers + sellers + fresh + exits
 
373
  return None, pd.DataFrame([], columns=["Role","AMC"])
374
  counts = df.groupby("Role").size().reset_index(name="Count")
375
  fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=["green","red","orange","black"][:len(counts)]))
376
+ fig.update_layout(title=f"Trade summary for {company}", margin=dict(t=30,b=10))
377
  return fig, df
378
 
379
  def amc_transfer_summary(amc):
380
  sold = SELL_MAP.get(amc, [])
381
  transfers = []
382
  for s in sold:
383
+ buyers = [a for a,cs in BUY_MAP.items() if s in cs]
384
  for b in buyers:
385
  transfers.append({"security": s, "buyer_amc": b})
386
  df = pd.DataFrame(transfers)
 
388
  return None, pd.DataFrame([], columns=["security","buyer_amc"])
389
  counts = df["buyer_amc"].value_counts().reset_index()
390
  counts.columns = ["Buyer AMC","Count"]
391
+ fig = go.Figure(go.Bar(x=counts["Buyer AMC"], y=counts["Count"], marker_color="gray"))
392
+ fig.update_layout(title=f"Inferred transfers from {amc}", margin=dict(t=30,b=10))
393
  return fig, df
394
 
395
  # ---------------------------
396
+ # Build static figure & meta
397
  # ---------------------------
398
+ def build_network_html(node_color_company="#FFCF9E",
399
+ node_color_amc="#9EC5FF",
400
+ edge_color_buy="#2ca02c",
401
+ edge_color_sell="#d62728",
402
+ edge_color_transfer="#888888",
403
+ edge_thickness=1.6,
404
+ include_transfers=True):
405
  G = build_graph(include_transfers=include_transfers)
406
+ fig, meta = build_plotly_static_figure(
407
+ G,
408
+ node_color_amc=node_color_amc,
409
+ node_color_company=node_color_company,
410
+ edge_color_buy=edge_color_buy,
411
+ edge_color_sell=edge_color_sell,
412
+ edge_color_transfer=edge_color_transfer,
413
+ edge_thickness=edge_thickness
414
+ )
415
+ return make_static_html(fig, meta)
416
 
417
  initial_html = build_network_html()
418
 
419
+ # ---------------------------
420
+ # Gradio UI
421
+ # ---------------------------
422
  responsive_css = """
423
  .js-plotly-plot { height:560px !important; }
424
+ @media(max-width:780px){ .js-plotly-plot{ height:520px !important; } }
425
  """
426
 
427
+ with gr.Blocks(css=responsive_css, title="MF Churn Explorer Static Bipartite") as demo:
428
+ gr.Markdown("## Mutual Fund Churn Explorer — Static Bipartite Layout (mobile-friendly)")
429
 
430
  network_html = gr.HTML(value=initial_html)
431
 
 
435
  <div><span style="display:inline-block;width:28px;border-bottom:3px solid #2ca02c;"></span> BUY (green solid)</div>
436
  <div><span style="display:inline-block;width:28px;border-bottom:3px dotted #d62728;"></span> SELL (red dotted)</div>
437
  <div><span style="display:inline-block;width:28px;border-bottom:3px dashed #888;"></span> TRANSFER (grey dashed — inferred)</div>
438
+ <div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY (thick green)</div>
439
+ <div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT (thick red)</div>
440
  </div>
441
  """)
442
 
443
+ with gr.Accordion("Customize Network (static)", open=False):
444
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
445
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
446
  edge_color_buy = gr.ColorPicker("#2ca02c", label="BUY edge color")
447
  edge_color_sell = gr.ColorPicker("#d62728", label="SELL edge color")
448
  edge_color_transfer = gr.ColorPicker("#888888", label="Transfer edge color")
449
+ edge_thickness = gr.Slider(0.5, 6.0, value=1.6, step=0.1, label="Edge thickness")
450
+ include_transfers = gr.Checkbox(value=True, label="Show inferred AMC→AMC transfers")
451
+ update_button = gr.Button("Update Graph")
452
 
453
+ gr.Markdown("### Inspect Company (buyers / sellers)")
454
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
455
  company_plot = gr.Plot()
456
  company_table = gr.DataFrame()
457
 
458
+ gr.Markdown("### Inspect AMC (inferred transfers)")
459
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
460
  amc_plot = gr.Plot()
461
  amc_table = gr.DataFrame()
 
471
  edge_thickness=edge_thickness_val,
472
  include_transfers=include_transfers_val)
473
 
474
+ update_button.click(update_network,
475
+ inputs=[node_color_company, node_color_amc,
476
+ edge_color_buy, edge_color_sell, edge_color_transfer,
477
+ edge_thickness, include_transfers],
478
+ outputs=[network_html])
479
 
480
  def on_company(c):
481
  fig, df = company_trade_summary(c)