Files changed (1) hide show
  1. app.py +354 -259
app.py CHANGED
@@ -1,8 +1,7 @@
1
-
2
  # app.py
3
- # D3 physics (client-side) + Plotly visualization for MF churn explorer
4
- # Liquid "gel" motion (viscous, slow, ooze-like) - Option L2
5
- # Requirements: gradio, networkx, plotly, pandas, numpy
6
 
7
  import gradio as gr
8
  import pandas as pd
@@ -12,9 +11,10 @@ import numpy as np
12
  import json
13
  from collections import defaultdict
14
 
15
- # ---------------------------
16
  # DATA
17
- # ---------------------------
 
18
  AMCS = [
19
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
20
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
@@ -66,138 +66,195 @@ SELL_MAP = sanitize_map(SELL_MAP)
66
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
67
  FRESH_BUY = sanitize_map(FRESH_BUY)
68
 
69
- # ---------------------------
70
- # BUILD GRAPH
71
- # ---------------------------
72
- company_edges = []
73
- for amc, comps in BUY_MAP.items():
74
- for c in comps:
75
- company_edges.append((amc, c, {"action": "buy", "weight": 1}))
76
- for amc, comps in SELL_MAP.items():
77
- for c in comps:
78
- company_edges.append((amc, c, {"action": "sell", "weight": 1}))
79
- for amc, comps in COMPLETE_EXIT.items():
80
- for c in comps:
81
- company_edges.append((amc, c, {"action": "complete_exit", "weight": 3}))
82
- for amc, comps in FRESH_BUY.items():
83
- for c in comps:
84
- company_edges.append((amc, c, {"action": "fresh_buy", "weight": 3}))
85
 
86
  def infer_amc_transfers(buy_map, sell_map):
87
  transfers = defaultdict(int)
88
- company_to_sellers = defaultdict(list)
89
- company_to_buyers = defaultdict(list)
 
90
  for amc, comps in sell_map.items():
91
  for c in comps:
92
- company_to_sellers[c].append(amc)
 
93
  for amc, comps in buy_map.items():
94
  for c in comps:
95
- company_to_buyers[c].append(amc)
96
- for c in set(company_to_sellers.keys()) | set(company_to_buyers.keys()):
97
- sellers = company_to_sellers[c]
98
- buyers = company_to_buyers[c]
99
- for s in sellers:
100
- for b in buyers:
101
- transfers[(s,b)] += 1
102
  out = []
103
  for (s,b), w in transfers.items():
104
- out.append((s,b, {"action":"transfer","weight":w}))
105
  return out
106
 
107
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
108
 
109
  def build_graph(include_transfers=True):
110
  G = nx.DiGraph()
 
111
  for a in AMCS:
112
  G.add_node(a, type="amc")
113
  for c in COMPANIES:
114
  G.add_node(c, type="company")
115
- for u,v,attr in company_edges:
116
- if G.has_edge(u,v):
117
- G[u][v]["weight"] += attr["weight"]
118
- G[u][v]["actions"].append(attr["action"])
119
- else:
120
- G.add_edge(u,v,weight=attr["weight"], actions=[attr["action"]])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  if include_transfers:
122
  for s,b,attr in transfer_edges:
123
  if G.has_edge(s,b):
124
  G[s][b]["weight"] += attr["weight"]
125
  G[s][b]["actions"].append("transfer")
126
  else:
127
- G.add_edge(s,b,weight=attr["weight"], actions=["transfer"])
 
128
  return G
129
 
130
- # ---------------------------
131
- # BUILD PLOTLY FIGURE (placeholders for positions)
132
- # ---------------------------
 
133
  def build_plotly_figure(G,
134
  node_color_amc="#9EC5FF",
135
  node_color_company="#FFCF9E",
136
  edge_color_buy="#2ca02c",
137
  edge_color_sell="#d62728",
138
  edge_color_transfer="#888888",
139
- edge_thickness_base=1.4):
 
140
  node_names = []
141
  node_x = []
142
  node_y = []
143
- node_color = []
144
- node_size = []
145
- for n,d in G.nodes(data=True):
 
146
  node_names.append(n)
147
- node_x.append(0.0); node_y.append(0.0)
148
- if d["type"]=="amc":
149
- node_color.append(node_color_amc); node_size.append(36)
 
 
150
  else:
151
- node_color.append(node_color_company); node_size.append(56)
 
 
152
  edge_traces = []
153
- edge_src = []
154
- edge_tgt = []
155
- edge_colors = []
156
- edge_widths = []
157
- for u,v,attrs in G.edges(data=True):
158
- edge_traces.append(go.Scatter(x=[0,0], y=[0,0], mode="lines",
159
- line=dict(color="#888", width=1), hoverinfo="none"))
160
- edge_src.append(node_names.index(u))
161
- edge_tgt.append(node_names.index(v))
162
- acts = attrs.get("actions",[])
163
- weight = attrs.get("weight",1)
 
 
 
 
 
 
 
 
164
  if "complete_exit" in acts:
165
- edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*3)
 
166
  elif "fresh_buy" in acts:
167
- edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*3)
 
168
  elif "transfer" in acts:
169
- edge_colors.append(edge_color_transfer); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
 
170
  elif "sell" in acts:
171
- edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
 
172
  else:
173
- edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
174
- node_trace = go.Scatter(x=node_x, y=node_y, mode="markers+text",
175
- marker=dict(color=node_color, size=node_size, line=dict(width=2,color="#222")),
176
- text=node_names, textposition="top center", hoverinfo="text")
 
 
 
 
 
 
 
 
177
  fig = go.Figure(data=edge_traces + [node_trace])
178
- fig.update_layout(showlegend=False, autosize=True,
179
- margin=dict(l=8,r=8,t=36,b=8), xaxis=dict(visible=False), yaxis=dict(visible=False))
 
 
 
 
 
 
180
  meta = {
181
  "node_names": node_names,
182
- "edge_source_index": edge_src,
183
- "edge_target_index": edge_tgt,
184
- "edge_colors": edge_colors,
185
- "edge_widths": edge_widths,
186
- "node_sizes": node_size
187
  }
 
188
  return fig, meta
189
 
190
- # ---------------------------
191
- # Build HTML with D3 + viscous "gel" motion
192
- # ---------------------------
193
- def make_network_html_d3_gel(fig, meta, div_id="network-plot-div"):
 
 
 
194
  fig_json = json.dumps(fig.to_plotly_json())
195
  meta_json = json.dumps(meta)
 
196
  html = f"""
197
- <div id="{div_id}" style="width:100%;height:560px;"></div>
198
- <div style="margin-top:6px;margin-bottom:8px;">
199
- <button id="{div_id}-reset" style="padding:8px 12px;border-radius:6px;">Reset view</button>
200
- <button id="{div_id}-stop" style="padding:8px 12px;border-radius:6px;margin-left:8px;">Stop layout</button>
 
201
  </div>
202
 
203
  <script src="https://d3js.org/d3.v7.min.js"></script>
@@ -206,96 +263,114 @@ def make_network_html_d3_gel(fig, meta, div_id="network-plot-div"):
206
  <script>
207
  const fig = {fig_json};
208
  const meta = {meta_json};
 
209
  const container = document.getElementById("{div_id}");
 
210
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
211
 
212
- // index bookkeeping
213
  const nodeTraceIndex = fig.data.length - 1;
214
  const edgeCount = fig.data.length - 1;
215
 
216
- // build nodes and links for D3
217
- const nodes = meta.node_names.map((n,i)=>({{id:i, name:n, r: meta.node_sizes[i] || 20}}));
218
- const links = meta.edge_source_index.map((s,i)=>({{source:s, target: meta.edge_target_index[i], color: meta.edge_colors[i], width: meta.edge_widths[i] || 1}}));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
- // Viscous gel simulation parameters (softer, slower motion)
221
  const simulation = d3.forceSimulation(nodes)
222
- .force("link", d3.forceLink(links).id(d => d.id).distance(140).strength(0.35))
223
- .force("charge", d3.forceManyBody().strength(-40))
224
- .force("collision", d3.forceCollide().radius(d => d.r * 0.85))
225
  .force("center", d3.forceCenter(0,0))
226
  .velocityDecay(0.55);
227
 
228
- // We add per-node velocity smoothing variables to create "gel" feel
229
- nodes.forEach(n => {{
230
- n.vx_smooth = 0;
231
- n.vy_smooth = 0;
232
- n.displayX = n.x || 0;
233
- n.displayY = n.y || 0;
234
- }});
235
-
236
  let tickCount = 0;
237
- const maxTicks = 400; // safety cap
238
 
239
  simulation.on("tick", () => {{
240
  tickCount++;
241
- // On each tick, update the target positions from d3, then apply viscous smoothing
242
  nodes.forEach(n => {{
243
- // D3 provides n.x/n.y; we do gel smoothing on displayX/displayY using velocity
244
- const targetX = n.x || 0;
245
- const targetY = n.y || 0;
246
 
247
- // viscous velocity update (gel-like): vx_smooth integrates difference slowly
248
- n.vx_smooth = (n.vx_smooth * 0.82) + (targetX - n.displayX) * 0.06;
249
- n.vy_smooth = (n.vy_smooth * 0.82) + (targetY - n.displayY) * 0.06;
250
 
251
- // apply a small damping to give heavy 'gel' inertia
252
- n.vx_smooth *= 0.92;
253
- n.vy_smooth *= 0.92;
254
 
255
- // update display positions
256
  n.displayX += n.vx_smooth;
257
  n.displayY += n.vy_smooth;
 
 
 
 
 
258
  }});
259
 
260
- // prepare arrays for Plotly update using displayX/displayY
261
  const xs = nodes.map(n => n.displayX);
262
  const ys = nodes.map(n => n.displayY);
263
 
264
- // update node trace
265
- Plotly.restyle(container, {{ 'x': [xs], 'y': [ys] }}, [nodeTraceIndex]);
266
 
267
- // update each edge trace using display positions
268
  for (let e = 0; e < edgeCount; e++) {{
269
- const sIdx = meta.edge_source_index[e];
270
- const tIdx = meta.edge_target_index[e];
271
- const sx = nodes[sIdx].displayX || 0;
272
- const sy = nodes[sIdx].displayY || 0;
273
- const tx = nodes[tIdx].displayX || 0;
274
- const ty = nodes[tIdx].displayY || 0;
275
- Plotly.restyle(container, {{ 'x': [[sx, tx]], 'y': [[sy, ty]] }}, [e]);
276
- // set line style color/width (ensure visual matches original meta)
277
- Plotly.restyle(container, {{ 'line.color': [meta.edge_colors[e]], 'line.width': [meta.edge_widths[e]] }}, [e]);
 
 
 
278
  }}
279
 
280
- // Safety stop conditions
281
  if (simulation.alpha() < 0.02 || tickCount > maxTicks) {{
282
  simulation.stop();
283
  }}
284
  }});
285
 
286
- // stop button
287
- document.getElementById("{div_id}-stop").addEventListener('click', () => {{
288
  simulation.stop();
289
  }});
290
 
291
- // map name to index
292
  const nameToIndex = {{}};
293
- meta.node_names.forEach((n,i)=> nameToIndex[n]=i);
294
 
295
- // focus and reset functions (hide others on focus - Option A)
296
- function focusNode(nodeName) {{
297
- const idx = nameToIndex[nodeName];
298
  const keep = new Set([idx]);
 
299
  for (let e = 0; e < meta.edge_source_index.length; e++) {{
300
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
301
  if (s === idx) keep.add(t);
@@ -303,202 +378,222 @@ function focusNode(nodeName) {{
303
  }}
304
 
305
  const N = meta.node_names.length;
306
- const nodeOp = Array(N).fill(0.0);
307
- const textColors = Array(N).fill("rgba(0,0,0,0)");
308
- for (let i=0;i<N;i++) {{
309
- if (keep.has(i)) {{ nodeOp[i]=1.0; textColors[i]="black"; }}
 
 
 
 
310
  }}
311
- Plotly.restyle(container, {{ "marker.opacity": [nodeOp], "textfont.color": [textColors] }}, [nodeTraceIndex]);
312
 
313
- // edges
314
- for (let e=0;e<edgeCount;e++) {{
 
 
 
 
315
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
316
  const show = keep.has(s) && keep.has(t);
317
- const color = show ? meta.edge_colors[e] : 'rgba(0,0,0,0)';
318
- const width = show ? meta.edge_widths[e] : 0.1;
319
- Plotly.restyle(container, {{ 'line.color': [color], 'line.width': [width] }}, [e]);
320
- }}
321
 
322
- // zoom to bbox
323
- const nodesTrace = fig.data[nodeTraceIndex];
324
- const xs = [], ys = [];
325
- for (let j=0;j<meta.node_names.length;j++) {{
326
- if (keep.has(j)) {{ xs.push(nodesTrace.x[j]); ys.push(nodesTrace.y[j]); }}
327
- }}
328
- if (xs.length>0) {{
329
- const xmin = Math.min(...xs), xmax = Math.max(...xs);
330
- const ymin = Math.min(...ys), ymax = Math.max(...ys);
331
- const padX = (xmax - xmin) * 0.4 + 10;
332
- const padY = (ymax - ymin) * 0.4 + 10;
333
- Plotly.relayout(container, {{ xaxis: {{ range: [xmin - padX, xmax + padX] }}, yaxis: {{ range: [ymin - padY, ymax + padY] }} }});
334
  }}
335
  }}
336
 
337
- // reset
338
  function resetView() {{
339
  const N = meta.node_names.length;
340
- const nodeOp = Array(N).fill(1.0);
341
- const textColors = Array(N).fill("black");
342
- Plotly.restyle(container, {{ "marker.opacity": [nodeOp], "textfont.color": [textColors] }}, [nodeTraceIndex]);
343
- for (let e=0;e<edgeCount;e++) {{
344
- Plotly.restyle(container, {{ 'line.color': [meta.edge_colors[e]], 'line.width': [meta.edge_widths[e]] }}, [e]);
 
 
 
 
 
345
  }}
346
- Plotly.relayout(container, {{ xaxis: {{autorange:true}}, yaxis: {{autorange:true}} }});
347
- // restart a gentle simulation to re-space nodes
348
- tickCount = 0;
349
  simulation.alpha(0.5);
350
  simulation.restart();
351
  }}
352
 
353
- // click handler
354
- container.on('plotly_click', function(eventData) {{
355
- const p = eventData.points[0];
356
- if (p.curveNumber === nodeTraceIndex) {{
357
- const nodeIndex = p.pointNumber;
358
- const nodeName = meta.node_names[nodeIndex];
359
- focusNode(nodeName);
360
- }}
361
- }});
362
 
363
- // reset button
364
- document.getElementById("{div_id}-reset").addEventListener('click', function() {{
365
- resetView();
 
 
 
366
  }});
367
  </script>
368
  """
 
369
  return html
370
 
371
- # ---------------------------
372
- # Company / AMC summaries (unchanged)
373
- # ---------------------------
374
- def company_trade_summary(company_name):
375
- buyers = [a for a, comps in BUY_MAP.items() if company_name in comps]
376
- sellers = [a for a, comps in SELL_MAP.items() if company_name in comps]
377
- fresh = [a for a, comps in FRESH_BUY.items() if company_name in comps]
378
- exits = [a for a, comps in COMPLETE_EXIT.items() if company_name in comps]
 
 
379
  df = pd.DataFrame({
380
- "Role": ["Buyer"]*len(buyers) + ["Seller"]*len(sellers) + ["Fresh buy"]*len(fresh) + ["Complete exit"]*len(exits),
 
 
 
381
  "AMC": buyers + sellers + fresh + exits
382
  })
 
383
  if df.empty:
384
  return None, pd.DataFrame([], columns=["Role","AMC"])
 
385
  counts = df.groupby("Role").size().reset_index(name="Count")
386
- fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=["green","red","orange","black"][:len(counts)]))
387
- fig.update_layout(title_text=f"Trade summary for {company_name}", autosize=True, margin=dict(t=30,b=10))
 
 
 
 
 
388
  return fig, df
389
 
390
- def amc_transfer_summary(amc_name):
391
- sold = SELL_MAP.get(amc_name, [])
 
392
  transfers = []
393
  for s in sold:
394
- buyers = [a for a, comps in BUY_MAP.items() if s in comps]
395
  for b in buyers:
396
  transfers.append({"security": s, "buyer_amc": b})
 
397
  df = pd.DataFrame(transfers)
 
398
  if df.empty:
399
  return None, pd.DataFrame([], columns=["security","buyer_amc"])
 
400
  counts = df["buyer_amc"].value_counts().reset_index()
401
  counts.columns = ["Buyer AMC","Count"]
402
- fig = go.Figure(go.Bar(x=counts["Buyer AMC"], y=counts["Count"], marker_color="lightslategray"))
403
- fig.update_layout(title_text=f"Inferred transfers from {amc_name}", autosize=True, margin=dict(t=30,b=10))
 
 
 
 
 
404
  return fig, df
405
 
406
- # ---------------------------
407
- # Build initial HTML
408
- # ---------------------------
 
 
409
  def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
410
  edge_color_buy="#2ca02c", edge_color_sell="#d62728",
411
- edge_color_transfer="#888888", edge_thickness=1.4, include_transfers=True):
 
 
412
  G = build_graph(include_transfers=include_transfers)
413
- fig, meta = build_plotly_figure(G,
414
- node_color_amc=node_color_amc,
415
- node_color_company=node_color_company,
416
- edge_color_buy=edge_color_buy,
417
- edge_color_sell=edge_color_sell,
418
- edge_color_transfer=edge_color_transfer,
419
- edge_thickness_base=edge_thickness)
420
- return make_network_html_d3_gel(fig, meta)
 
 
421
 
422
  initial_html = build_network_html()
423
 
424
- # ---------------------------
425
- # Mobile CSS & UI
426
- # ---------------------------
 
 
427
  responsive_css = """
428
- .gradio-container { padding:0 !important; margin:0 !important; }
429
- .plotly-graph-div, .js-plotly-plot, .output_plot { width:100% !important; max-width:100% !important; }
430
- .js-plotly-plot { height:560px !important; }
431
- @media(max-width:780px){ .js-plotly-plot{ height:520px !important; } }
432
- body, html { overflow-x:hidden !important; }
433
  """
434
 
435
- with gr.Blocks(css=responsive_css, title="MF Churn Explorer (Gel Motion)") as demo:
436
- gr.Markdown("## Mutual Fund Churn Explorer — Gel-like liquid motion (L2)")
 
437
 
438
  network_html = gr.HTML(value=initial_html)
439
 
440
- legend_html = gr.HTML(value=\"\"\"
441
  <div style='font-family:sans-serif;font-size:14px;margin-top:10px;line-height:1.6;'>
442
  <b>Legend</b><br>
443
  <div><span style="display:inline-block;width:28px;border-bottom:3px solid #2ca02c;"></span> BUY (green solid)</div>
444
  <div><span style="display:inline-block;width:28px;border-bottom:3px dotted #d62728;"></span> SELL (red dotted)</div>
445
- <div><span style="display:inline-block;width:28px;border-bottom:3px dashed #888;"></span> TRANSFER (grey dashed — inferred, not actual reported transfer)</div>
446
- <div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY (thick green)</div>
447
- <div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT (thick red)</div>
448
  </div>
449
- \"\"\")
450
 
451
- with gr.Accordion("Network Customization — expand to edit", open=False):
452
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
453
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
454
  edge_color_buy = gr.ColorPicker("#2ca02c", label="BUY edge color")
455
  edge_color_sell = gr.ColorPicker("#d62728", label="SELL edge color")
456
  edge_color_transfer = gr.ColorPicker("#888888", label="Transfer edge color")
457
- edge_thickness = gr.Slider(0.5, 6.0, value=1.4, step=0.1, label="Edge thickness base")
458
- include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
459
- update_button = gr.Button("Update Network Graph")
460
 
461
- gr.Markdown("### Inspect Company (buyers / sellers)")
462
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
463
  company_plot = gr.Plot()
464
  company_table = gr.DataFrame()
465
 
466
- gr.Markdown("### Inspect AMC (inferred transfers)")
467
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
468
  amc_plot = gr.Plot()
469
  amc_table = gr.DataFrame()
470
 
471
- def update_network_html(node_color_company_val, node_color_amc_val,
472
- edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
473
- edge_thickness_val, include_transfers_val):
474
- return build_network_html(node_color_company=node_color_company_val,
475
- node_color_amc=node_color_amc_val,
476
- edge_color_buy=edge_color_buy_val,
477
- edge_color_sell=edge_color_sell_val,
478
- edge_color_transfer=edge_color_transfer_val,
479
- edge_thickness=edge_thickness_val,
480
- include_transfers=include_transfers_val)
481
-
482
- def on_company_select(cname):
483
- fig, df = company_trade_summary(cname)
484
- if fig is None:
485
- return None, pd.DataFrame([], columns=["Role", "AMC"])
486
- return fig, df
487
-
488
- def on_amc_select(aname):
489
- fig, df = amc_transfer_summary(aname)
490
- if fig is None:
491
- return None, pd.DataFrame([], columns=["security", "buyer_amc"])
492
- return fig, df
493
-
494
- update_button.click(fn=update_network_html,
495
- inputs=[node_color_company, node_color_amc,
496
- edge_color_buy, edge_color_sell, edge_color_transfer,
497
- edge_thickness, include_transfers],
498
- outputs=[network_html])
499
-
500
- select_company.change(fn=on_company_select, inputs=[select_company], outputs=[company_plot, company_table])
501
- select_amc.change(fn=on_amc_select, inputs=[select_amc], outputs=[amc_plot, amc_table])
502
 
503
  if __name__ == "__main__":
504
- demo.launch()
 
 
1
  # app.py
2
+ # Mutual Fund Churn Explorer with Gel + Wave Liquid Motion (Option D)
3
+ # D3 + Plotly hybrid layout
4
+ # Designed for Hugging Face Spaces (Gradio)
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
+
18
  AMCS = [
19
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
20
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
 
66
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
67
  FRESH_BUY = sanitize_map(FRESH_BUY)
68
 
69
+ # ============================================================
70
+ # GRAPH BUILDING
71
+ # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  def infer_amc_transfers(buy_map, sell_map):
74
  transfers = defaultdict(int)
75
+ comp_sellers = defaultdict(list)
76
+ comp_buyers = defaultdict(list)
77
+
78
  for amc, comps in sell_map.items():
79
  for c in comps:
80
+ comp_sellers[c].append(amc)
81
+
82
  for amc, comps in buy_map.items():
83
  for c in comps:
84
+ comp_buyers[c].append(amc)
85
+
86
+ for c in set(comp_sellers.keys()) | set(comp_buyers.keys()):
87
+ for s in comp_sellers[c]:
88
+ for b in comp_buyers[c]:
89
+ transfers[(s, b)] += 1
90
+
91
  out = []
92
  for (s,b), w in transfers.items():
93
+ out.append((s, b, {"action":"transfer","weight":w}))
94
  return out
95
 
96
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
97
 
98
  def build_graph(include_transfers=True):
99
  G = nx.DiGraph()
100
+
101
  for a in AMCS:
102
  G.add_node(a, type="amc")
103
  for c in COMPANIES:
104
  G.add_node(c, type="company")
105
+
106
+ # BUY/SELL edges
107
+ for amc, comps in BUY_MAP.items():
108
+ for c in comps:
109
+ G.add_edge(amc, c, weight=1, actions=["buy"])
110
+
111
+ for amc, comps in SELL_MAP.items():
112
+ for c in comps:
113
+ if G.has_edge(amc, c):
114
+ G[amc][c]["weight"] += 1
115
+ G[amc][c]["actions"].append("sell")
116
+ else:
117
+ G.add_edge(amc, c, weight=1, actions=["sell"])
118
+
119
+ # complete exits
120
+ for amc, comps in COMPLETE_EXIT.items():
121
+ for c in comps:
122
+ if G.has_edge(amc, c):
123
+ G[amc][c]["weight"] += 3
124
+ G[amc][c]["actions"].append("complete_exit")
125
+ else:
126
+ G.add_edge(amc, c, weight=3, actions=["complete_exit"])
127
+
128
+ # fresh buys
129
+ for amc, comps in FRESH_BUY.items():
130
+ for c in comps:
131
+ if G.has_edge(amc, c):
132
+ G[amc][c]["weight"] += 3
133
+ G[amc][c]["actions"].append("fresh_buy")
134
+ else:
135
+ G.add_edge(amc, c, weight=3, actions=["fresh_buy"])
136
+
137
+ # inferred transfers
138
  if include_transfers:
139
  for s,b,attr in transfer_edges:
140
  if G.has_edge(s,b):
141
  G[s][b]["weight"] += attr["weight"]
142
  G[s][b]["actions"].append("transfer")
143
  else:
144
+ G.add_edge(s,b, weight=attr["weight"], actions=["transfer"])
145
+
146
  return G
147
 
148
+ # ============================================================
149
+ # BUILD FIGURE (placeholders positions will be set by D3)
150
+ # ============================================================
151
+
152
  def build_plotly_figure(G,
153
  node_color_amc="#9EC5FF",
154
  node_color_company="#FFCF9E",
155
  edge_color_buy="#2ca02c",
156
  edge_color_sell="#d62728",
157
  edge_color_transfer="#888888",
158
+ edge_thickness=1.4):
159
+
160
  node_names = []
161
  node_x = []
162
  node_y = []
163
+ node_colors = []
164
+ node_sizes = []
165
+
166
+ for n, d in G.nodes(data=True):
167
  node_names.append(n)
168
+ node_x.append(0)
169
+ node_y.append(0)
170
+ if d["type"] == "amc":
171
+ node_colors.append(node_color_amc)
172
+ node_sizes.append(36)
173
  else:
174
+ node_colors.append(node_color_company)
175
+ node_sizes.append(56)
176
+
177
  edge_traces = []
178
+ src_idx = []
179
+ tgt_idx = []
180
+ e_colors = []
181
+ e_widths = []
182
+
183
+ for u, v, attrs in G.edges(data=True):
184
+ edge_traces.append(
185
+ go.Scatter(
186
+ x=[0,0], y=[0,0], mode="lines",
187
+ line=dict(color="#aaa", width=1),
188
+ hoverinfo="none"
189
+ )
190
+ )
191
+ src_idx.append(node_names.index(u))
192
+ tgt_idx.append(node_names.index(v))
193
+
194
+ acts = attrs.get("actions", [])
195
+ w = attrs.get("weight", 1)
196
+
197
  if "complete_exit" in acts:
198
+ e_colors.append(edge_color_sell)
199
+ e_widths.append(edge_thickness * 3)
200
  elif "fresh_buy" in acts:
201
+ e_colors.append(edge_color_buy)
202
+ e_widths.append(edge_thickness * 3)
203
  elif "transfer" in acts:
204
+ e_colors.append(edge_color_transfer)
205
+ e_widths.append(edge_thickness * (1 + np.log1p(w)))
206
  elif "sell" in acts:
207
+ e_colors.append(edge_color_sell)
208
+ e_widths.append(edge_thickness * (1 + np.log1p(w)))
209
  else:
210
+ e_colors.append(edge_color_buy)
211
+ e_widths.append(edge_thickness * (1 + np.log1p(w)))
212
+
213
+ node_trace = go.Scatter(
214
+ x=node_x, y=node_y,
215
+ mode="markers+text",
216
+ marker=dict(color=node_colors, size=node_sizes, line=dict(width=2,color="#333")),
217
+ text=node_names,
218
+ textposition="top center",
219
+ hoverinfo="text"
220
+ )
221
+
222
  fig = go.Figure(data=edge_traces + [node_trace])
223
+ fig.update_layout(
224
+ autosize=True,
225
+ showlegend=False,
226
+ margin=dict(l=5, r=5, t=30, b=5),
227
+ xaxis=dict(visible=False),
228
+ yaxis=dict(visible=False)
229
+ )
230
+
231
  meta = {
232
  "node_names": node_names,
233
+ "edge_source_index": src_idx,
234
+ "edge_target_index": tgt_idx,
235
+ "edge_colors": e_colors,
236
+ "edge_widths": e_widths,
237
+ "node_sizes": node_sizes
238
  }
239
+
240
  return fig, meta
241
 
242
+
243
+ # ============================================================
244
+ # D3 + GEL + WAVE Motion Renderer
245
+ # ============================================================
246
+
247
+ def make_network_html(fig, meta, div_id="network-plot-div"):
248
+
249
  fig_json = json.dumps(fig.to_plotly_json())
250
  meta_json = json.dumps(meta)
251
+
252
  html = f"""
253
+ <div id="{div_id}" style="width:100%; height:620px;"></div>
254
+
255
+ <div style="margin-top:6px;">
256
+ <button id="{div_id}-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
257
+ <button id="{div_id}-stop" style="padding:8px 12px; margin-left:8px; border-radius:6px;">Stop Layout</button>
258
  </div>
259
 
260
  <script src="https://d3js.org/d3.v7.min.js"></script>
 
263
  <script>
264
  const fig = {fig_json};
265
  const meta = {meta_json};
266
+
267
  const container = document.getElementById("{div_id}");
268
+
269
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
270
 
 
271
  const nodeTraceIndex = fig.data.length - 1;
272
  const edgeCount = fig.data.length - 1;
273
 
274
+ // Build nodes for D3
275
+ const nodes = meta.node_names.map((name, i) => {{
276
+ return {{
277
+ id: i,
278
+ name: name,
279
+ r: meta.node_sizes[i] || 20,
280
+ displayX: 0,
281
+ displayY: 0,
282
+ vx_smooth: 0,
283
+ vy_smooth: 0
284
+ }};
285
+ }});
286
+
287
+ // Build links
288
+ const links = meta.edge_source_index.map((s, i) => {{
289
+ return {{
290
+ source: s,
291
+ target: meta.edge_target_index[i],
292
+ color: meta.edge_colors[i],
293
+ width: meta.edge_widths[i]
294
+ }};
295
+ }});
296
 
297
+ // D3 simulation
298
  const simulation = d3.forceSimulation(nodes)
299
+ .force("link", d3.forceLink(links).id(d => d.id).distance(150).strength(0.35))
300
+ .force("charge", d3.forceManyBody().strength(-50))
301
+ .force("collision", d3.forceCollide().radius(d => d.r * 0.9))
302
  .force("center", d3.forceCenter(0,0))
303
  .velocityDecay(0.55);
304
 
 
 
 
 
 
 
 
 
305
  let tickCount = 0;
306
+ const maxTicks = 400;
307
 
308
  simulation.on("tick", () => {{
309
  tickCount++;
310
+
311
  nodes.forEach(n => {{
312
+ const tx = n.x || 0;
313
+ const ty = n.y || 0;
 
314
 
315
+ // Gel viscosity smoothing
316
+ n.vx_smooth = n.vx_smooth * 0.82 + (tx - n.displayX) * 0.06;
317
+ n.vy_smooth = n.vy_smooth * 0.82 + (ty - n.displayY) * 0.06;
318
 
319
+ // Heavy damping
320
+ n.vx_smooth *= 0.90;
321
+ n.vy_smooth *= 0.90;
322
 
323
+ // Update display positions
324
  n.displayX += n.vx_smooth;
325
  n.displayY += n.vy_smooth;
326
+
327
+ // Wave pulse (gentle breathing)
328
+ const t = Date.now() * 0.001;
329
+ n.displayX += Math.sin(t + n.id) * 0.12;
330
+ n.displayY += Math.cos(t + n.id) * 0.12;
331
  }});
332
 
333
+ // Node arrays
334
  const xs = nodes.map(n => n.displayX);
335
  const ys = nodes.map(n => n.displayY);
336
 
337
+ // Update node trace
338
+ Plotly.restyle(container, {{x: [xs], y: [ys]}}, [nodeTraceIndex]);
339
 
340
+ // Update edges
341
  for (let e = 0; e < edgeCount; e++) {{
342
+ const s = meta.edge_source_index[e];
343
+ const t = meta.edge_target_index[e];
344
+
345
+ Plotly.restyle(container,
346
+ {{
347
+ x: [[nodes[s].displayX, nodes[t].displayX]],
348
+ y: [[nodes[s].displayY, nodes[t].displayY]],
349
+ "line.color": [meta.edge_colors[e]],
350
+ "line.width": [meta.edge_widths[e]]
351
+ }},
352
+ [e]
353
+ );
354
  }}
355
 
 
356
  if (simulation.alpha() < 0.02 || tickCount > maxTicks) {{
357
  simulation.stop();
358
  }}
359
  }});
360
 
361
+ // STOP button
362
+ document.getElementById("{div_id}-stop").addEventListener("click", () => {{
363
  simulation.stop();
364
  }});
365
 
366
+ // FOCUS and RESET
367
  const nameToIndex = {{}};
368
+ meta.node_names.forEach((n,i)=> nameToIndex[n] = i);
369
 
370
+ function focusNode(name) {{
371
+ const idx = nameToIndex[name];
 
372
  const keep = new Set([idx]);
373
+
374
  for (let e = 0; e < meta.edge_source_index.length; e++) {{
375
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
376
  if (s === idx) keep.add(t);
 
378
  }}
379
 
380
  const N = meta.node_names.length;
381
+ const op = Array(N).fill(0);
382
+ const colors = Array(N).fill("rgba(0,0,0,0)");
383
+
384
+ for (let i = 0; i < N; i++) {{
385
+ if (keep.has(i)) {{
386
+ op[i] = 1;
387
+ colors[i] = "black";
388
+ }}
389
  }}
 
390
 
391
+ Plotly.restyle(container, {{
392
+ "marker.opacity": [op],
393
+ "textfont.color": [colors]
394
+ }}, [nodeTraceIndex]);
395
+
396
+ for (let e = 0; e < edgeCount; e++) {{
397
  const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
398
  const show = keep.has(s) && keep.has(t);
 
 
 
 
399
 
400
+ Plotly.restyle(container, {{
401
+ "line.color": [show ? meta.edge_colors[e] : "rgba(0,0,0,0)"],
402
+ "line.width": [show ? meta.edge_widths[e] : 0.1]
403
+ }}, [e]);
 
 
 
 
 
 
 
 
404
  }}
405
  }}
406
 
 
407
  function resetView() {{
408
  const N = meta.node_names.length;
409
+ Plotly.restyle(container, {{
410
+ "marker.opacity": [Array(N).fill(1)],
411
+ "textfont.color": [Array(N).fill("black")]
412
+ }}, [nodeTraceIndex]);
413
+
414
+ for (let e = 0; e < edgeCount; e++) {{
415
+ Plotly.restyle(container, {{
416
+ "line.color": [meta.edge_colors[e]],
417
+ "line.width": [meta.edge_widths[e]]
418
+ }}, [e]);
419
  }}
420
+
 
 
421
  simulation.alpha(0.5);
422
  simulation.restart();
423
  }}
424
 
425
+ document.getElementById("{div_id}-reset").addEventListener("click", resetView);
 
 
 
 
 
 
 
 
426
 
427
+ container.on("plotly_click", (e) => {{
428
+ const p = e.points[0];
429
+ if (p && p.curveNumber === nodeTraceIndex) {{
430
+ const name = meta.node_names[p.pointNumber];
431
+ focusNode(name);
432
+ }}
433
  }});
434
  </script>
435
  """
436
+
437
  return html
438
 
439
+ # ============================================================
440
+ # COMPANY / AMC SUMMARY
441
+ # ============================================================
442
+
443
+ def company_trade_summary(company):
444
+ buyers = [a for a, cs in BUY_MAP.items() if company in cs]
445
+ sellers = [a for a, cs in SELL_MAP.items() if company in cs]
446
+ fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
447
+ exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
448
+
449
  df = pd.DataFrame({
450
+ "Role": ["Buyer"] * len(buyers)
451
+ + ["Seller"] * len(sellers)
452
+ + ["Fresh buy"] * len(fresh)
453
+ + ["Complete exit"] * len(exits),
454
  "AMC": buyers + sellers + fresh + exits
455
  })
456
+
457
  if df.empty:
458
  return None, pd.DataFrame([], columns=["Role","AMC"])
459
+
460
  counts = df.groupby("Role").size().reset_index(name="Count")
461
+
462
+ fig = go.Figure(go.Bar(
463
+ x=counts["Role"], y=counts["Count"],
464
+ marker_color=["green","red","orange","black"][:len(counts)]
465
+ ))
466
+ fig.update_layout(title=f"Trades for {company}", margin=dict(t=30,b=5))
467
+
468
  return fig, df
469
 
470
+
471
+ def amc_transfer_summary(amc):
472
+ sold = SELL_MAP.get(amc, [])
473
  transfers = []
474
  for s in sold:
475
+ buyers = [a for a, cs in BUY_MAP.items() if s in cs]
476
  for b in buyers:
477
  transfers.append({"security": s, "buyer_amc": b})
478
+
479
  df = pd.DataFrame(transfers)
480
+
481
  if df.empty:
482
  return None, pd.DataFrame([], columns=["security","buyer_amc"])
483
+
484
  counts = df["buyer_amc"].value_counts().reset_index()
485
  counts.columns = ["Buyer AMC","Count"]
486
+
487
+ fig = go.Figure(go.Bar(
488
+ x=counts["Buyer AMC"], y=counts["Count"],
489
+ marker_color="gray"
490
+ ))
491
+ fig.update_layout(title=f"Inferred transfers from {amc}", margin=dict(t=30,b=5))
492
+
493
  return fig, df
494
 
495
+
496
+ # ============================================================
497
+ # FINAL NETWORK HTML BUILDER
498
+ # ============================================================
499
+
500
  def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
501
  edge_color_buy="#2ca02c", edge_color_sell="#d62728",
502
+ edge_color_transfer="#888888", edge_thickness=1.4,
503
+ include_transfers=True):
504
+
505
  G = build_graph(include_transfers=include_transfers)
506
+ fig, meta = build_plotly_figure(
507
+ G,
508
+ node_color_amc=node_color_amc,
509
+ node_color_company=node_color_company,
510
+ edge_color_buy=edge_color_buy,
511
+ edge_color_sell=edge_color_sell,
512
+ edge_color_transfer=edge_color_transfer,
513
+ edge_thickness=edge_thickness
514
+ )
515
+ return make_network_html(fig, meta)
516
 
517
  initial_html = build_network_html()
518
 
519
+
520
+ # ============================================================
521
+ # UI LAYOUT
522
+ # ============================================================
523
+
524
  responsive_css = """
525
+ .js-plotly-plot { height:620px !important; }
526
+ @media(max-width:780px){ .js-plotly-plot{ height:600px !important; } }
 
 
 
527
  """
528
 
529
+ with gr.Blocks(css=responsive_css, title="MF Churn Explorer Liquid Motion") as demo:
530
+
531
+ gr.Markdown("## Mutual Fund Churn Explorer — Liquid Gel + Wave Motion (L2 + Rhythm)")
532
 
533
  network_html = gr.HTML(value=initial_html)
534
 
535
+ legend_html = gr.HTML("""
536
  <div style='font-family:sans-serif;font-size:14px;margin-top:10px;line-height:1.6;'>
537
  <b>Legend</b><br>
538
  <div><span style="display:inline-block;width:28px;border-bottom:3px solid #2ca02c;"></span> BUY (green solid)</div>
539
  <div><span style="display:inline-block;width:28px;border-bottom:3px dotted #d62728;"></span> SELL (red dotted)</div>
540
+ <div><span style="display:inline-block;width:28px;border-bottom:3px dashed #888;"></span> TRANSFER (grey dashed — inferred)</div>
541
+ <div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY</div>
542
+ <div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT</div>
543
  </div>
544
+ """)
545
 
546
+ with gr.Accordion("Customize Network", open=False):
547
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
548
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
549
  edge_color_buy = gr.ColorPicker("#2ca02c", label="BUY edge color")
550
  edge_color_sell = gr.ColorPicker("#d62728", label="SELL edge color")
551
  edge_color_transfer = gr.ColorPicker("#888888", label="Transfer edge color")
552
+ edge_thickness = gr.Slider(0.5, 6.0, 1.4, step=0.1, label="Edge thickness")
553
+ include_transfers = gr.Checkbox(True, label="Show inferred AMC→AMC transfers")
554
+ update_btn = gr.Button("Update Graph")
555
 
556
+ gr.Markdown("### Company Summary")
557
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
558
  company_plot = gr.Plot()
559
  company_table = gr.DataFrame()
560
 
561
+ gr.Markdown("### AMC Summary (Inferred Transfers)")
562
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
563
  amc_plot = gr.Plot()
564
  amc_table = gr.DataFrame()
565
 
566
+ # Callbacks
567
+ def update_net(c1,c2,buy,sell,trans,thick,inc):
568
+ return build_network_html(
569
+ node_color_company=c1,
570
+ node_color_amc=c2,
571
+ edge_color_buy=buy,
572
+ edge_color_sell=sell,
573
+ edge_color_transfer=trans,
574
+ edge_thickness=thick,
575
+ include_transfers=inc
576
+ )
577
+
578
+ update_btn.click(
579
+ update_net,
580
+ inputs=[node_color_company,node_color_amc,
581
+ edge_color_buy,edge_color_sell,edge_color_transfer,
582
+ edge_thickness,include_transfers],
583
+ outputs=[network_html]
584
+ )
585
+
586
+ def on_company(c):
587
+ fig,df = company_trade_summary(c)
588
+ return fig,df
589
+
590
+ def on_amc(a):
591
+ fig,df = amc_transfer_summary(a)
592
+ return fig,df
593
+
594
+ select_company.change(on_company, inputs=[select_company], outputs=[company_plot,company_table])
595
+ select_amc.change(on_amc, inputs=[select_amc], outputs=[amc_plot,amc_table])
596
+
597
 
598
  if __name__ == "__main__":
599
+ demo.launch()