singhn9 commited on
Commit
32f74fe
·
verified ·
1 Parent(s): f033ff8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +162 -243
app.py CHANGED
@@ -1,7 +1,7 @@
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,10 +11,9 @@ import numpy as np
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,47 +65,45 @@ SELL_MAP = sanitize_map(SELL_MAP)
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:
@@ -142,13 +139,11 @@ def build_graph(include_transfers=True):
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",
@@ -181,52 +176,31 @@ def build_plotly_figure(G,
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,
@@ -239,19 +213,18 @@ def build_plotly_figure(G,
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>
@@ -263,211 +236,181 @@ def make_network_html(fig, meta, div_id="network-plot-div"):
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);
377
  if (t === idx) keep.add(s);
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 = []
@@ -475,60 +418,40 @@ def amc_transfer_summary(amc):
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
 
@@ -563,37 +486,33 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer — Liquid Motion")
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()
 
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
 
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"
 
65
  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():
87
+ out.append((s,b,{"action":"transfer","weight":w}))
88
  return out
89
 
90
  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):
103
+ G[amc][c]["weight"] += 1
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:
 
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",
 
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,
 
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>
 
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);
336
  if (t === idx) keep.add(s);
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, {{
361
+ "marker.opacity": [Array(N).fill(1.0)],
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
406
  })
 
407
  if df.empty:
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 = []
 
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)
 
422
  if df.empty:
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
 
 
486
  amc_plot = gr.Plot()
487
  amc_table = gr.DataFrame()
488
 
489
+ def update_network(node_color_company_val, node_color_amc_val,
490
+ edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
491
+ edge_thickness_val, include_transfers_val):
492
+ return build_network_html(node_color_company=node_color_company_val,
493
+ node_color_amc=node_color_amc_val,
494
+ edge_color_buy=edge_color_buy_val,
495
+ edge_color_sell=edge_color_sell_val,
496
+ edge_color_transfer=edge_color_transfer_val,
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)
508
+ return fig, df
509
 
510
  def on_amc(a):
511
+ fig, df = amc_transfer_summary(a)
512
+ return fig, df
 
 
 
513
 
514
+ select_company.change(on_company, inputs=[select_company], outputs=[company_plot, company_table])
515
+ select_amc.change(on_amc, inputs=[select_amc], outputs=[amc_plot, amc_table])
516
 
517
  if __name__ == "__main__":
518
  demo.launch()