Files changed (1) hide show
  1. app.py +237 -365
app.py CHANGED
@@ -1,18 +1,17 @@
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
7
  import networkx as nx
8
- import plotly.graph_objects as go
9
  import numpy as np
10
  import json
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",
18
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
@@ -64,9 +63,9 @@ SELL_MAP = sanitize_map(SELL_MAP)
64
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
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)
@@ -81,306 +80,95 @@ def infer_amc_transfers(buy_map, sell_map):
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():
86
- out.append((s,b,{"action":"transfer","weight":w}))
87
- return out
88
-
89
- transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
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):
99
- G[amc][c]["weight"] += 1
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):
107
- G[amc][c]["weight"] += 1
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:
114
- if G.has_edge(amc, c):
115
- G[amc][c]["weight"] += 3
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):
123
- G[amc][c]["weight"] += 3
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);
300
- if (t === idx) keep.add(s);
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, {{
332
- "marker.opacity": [Array(N).fill(1.0)],
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
371
  })
372
  if df.empty:
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,95 +176,179 @@ def amc_transfer_summary(amc):
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
 
432
- legend_html = gr.HTML("""
433
- <div style='font-family:sans-serif;font-size:14px;margin-top:10px;line-height:1.6;'>
434
- <b>Legend</b><br>
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()
462
 
463
- def update_network(node_color_company_val, node_color_amc_val,
464
- edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
465
- edge_thickness_val, include_transfers_val):
466
- return build_network_html(node_color_company=node_color_company_val,
467
- node_color_amc=node_color_amc_val,
468
- edge_color_buy=edge_color_buy_val,
469
- edge_color_sell=edge_color_sell_val,
470
- edge_color_transfer=edge_color_transfer_val,
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)
482
  return fig, df
 
1
  # app.py
2
+ # MBB-style chord diagram (mixed node order) for Mutual Fund churn
3
+ # Uses D3 chord layout in browser, static layout (no physics). Mobile-friendly.
4
 
5
  import gradio as gr
6
  import pandas as pd
7
  import networkx as nx
 
8
  import numpy as np
9
  import json
10
  from collections import defaultdict
11
 
12
+ # -------------------------
13
+ # DATA (same as before)
14
+ # -------------------------
15
  AMCS = [
16
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
17
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
 
63
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
64
  FRESH_BUY = sanitize_map(FRESH_BUY)
65
 
66
+ # -------------------------
67
+ # Inferred AMC->AMC transfers (same heuristic)
68
+ # -------------------------
69
  def infer_amc_transfers(buy_map, sell_map):
70
  transfers = defaultdict(int)
71
  c2s = defaultdict(list)
 
80
  for s in c2s[c]:
81
  for b in c2b[c]:
82
  transfers[(s,b)] += 1
83
+ return transfers
84
+
85
+ transfer_counts = infer_amc_transfers(BUY_MAP, SELL_MAP)
86
+
87
+ # -------------------------
88
+ # Build mixed ordering (AMC, company, AMC, company...)
89
+ # -------------------------
90
+ def build_mixed_ordering(amcs, companies):
91
+ mixed = []
92
+ n = max(len(amcs), len(companies))
93
+ for i in range(n):
94
+ if i < len(amcs):
95
+ mixed.append(amcs[i])
96
+ if i < len(companies):
97
+ mixed.append(companies[i])
98
+ return mixed
99
+
100
+ NODES = build_mixed_ordering(AMCS, COMPANIES)
101
+
102
+ # Node types map for styling
103
+ NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
104
+
105
+ # -------------------------
106
+ # Build flow matrix: nodes x nodes
107
+ # Matrix interpretation:
108
+ # - AMC -> Company for BUY
109
+ # - Company -> AMC for SELL
110
+ # - AMC -> AMC for inferred TRANSFER
111
+ # Fresh buy and complete exit use higher weight
112
+ # -------------------------
113
+ def build_flow_matrix(nodes):
114
+ idx = {n:i for i,n in enumerate(nodes)}
115
+ n = len(nodes)
116
+ M = [[0]*n for _ in range(n)]
117
+
118
+ # buys: AMC -> Company
119
  for amc, comps in BUY_MAP.items():
120
  for c in comps:
121
+ if amc in idx and c in idx:
122
+ w = 1
123
+ if amc in FRESH_BUY and c in FRESH_BUY.get(amc, []):
124
+ w = 3
125
+ M[idx[amc]][idx[c]] += w
126
+
127
+ # sells: Company -> AMC
128
  for amc, comps in SELL_MAP.items():
129
  for c in comps:
130
+ if amc in idx and c in idx:
131
+ w = 1
132
+ if amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc, []):
133
+ w = 3
134
+ # represent sell as company -> amc
135
+ M[idx[c]][idx[amc]] += w
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
+ # inferred transfers: AMC -> AMC
138
+ for (s,b), w in transfer_counts.items():
139
+ if s in idx and b in idx:
140
+ M[idx[s]][idx[b]] += w
141
 
142
+ return M
 
 
143
 
144
+ MATRIX = build_flow_matrix(NODES)
 
 
 
145
 
146
+ # -------------------------
147
+ # Helper summaries (unchanged)
148
+ # -------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def company_trade_summary(company):
150
+ buyers = [a for a, cs in BUY_MAP.items() if company in cs]
151
+ sellers = [a for a, cs in SELL_MAP.items() if company in cs]
152
+ fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
153
+ exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
 
154
  df = pd.DataFrame({
155
+ "Role": (["Buyer"] * len(buyers)) + (["Seller"] * len(sellers)) + (["Fresh buy"] * len(fresh)) + (["Complete exit"] * len(exits)),
156
  "AMC": buyers + sellers + fresh + exits
157
  })
158
  if df.empty:
159
  return None, pd.DataFrame([], columns=["Role","AMC"])
160
  counts = df.groupby("Role").size().reset_index(name="Count")
161
+ fig = {
162
+ "data": [{"type":"bar","x": counts["Role"].tolist(), "y": counts["Count"].tolist()}],
163
+ "layout":{"title":f"Trades for {company}"}
164
+ }
165
  return fig, df
166
 
167
  def amc_transfer_summary(amc):
168
  sold = SELL_MAP.get(amc, [])
169
  transfers = []
170
  for s in sold:
171
+ buyers = [a for a, cs in BUY_MAP.items() if s in cs]
172
  for b in buyers:
173
  transfers.append({"security": s, "buyer_amc": b})
174
  df = pd.DataFrame(transfers)
 
176
  return None, pd.DataFrame([], columns=["security","buyer_amc"])
177
  counts = df["buyer_amc"].value_counts().reset_index()
178
  counts.columns = ["Buyer AMC","Count"]
179
+ fig = {
180
+ "data": [{"type":"bar","x": counts["Buyer AMC"].tolist(), "y": counts["Count"].tolist()}],
181
+ "layout":{"title":f"Inferred transfers from {amc}"}
182
+ }
183
  return fig, df
184
 
185
+ # -------------------------
186
+ # Build HTML with D3 chord
187
+ # -------------------------
188
+ def make_chord_html(nodes, matrix, node_type):
189
+ nodes_json = json.dumps(nodes)
190
+ mat_json = json.dumps(matrix)
191
+ types_json = json.dumps(node_type)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
+ # D3 chord diagram: mixed nodes around circle, modern palette
194
+ html = f"""
195
+ <div id="chord-container" style="width:100%; height:640px;"></div>
196
+ <div style="margin-top:8px;">
197
+ <button id="chord-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
198
+ </div>
199
 
200
+ <div style="margin-top:10px; font-family:sans-serif; font-size:13px;">
201
+ <b>Legend</b><br/>
202
+ <span style="display:inline-block;width:12px;height:12px;background:#2b6fa6;margin-right:6px;border-radius:2px;"></span> AMC nodes<br/>
203
+ <span style="display:inline-block;width:12px;height:12px;background:#f2c88d;margin-right:6px;border-radius:2px;"></span> Company nodes<br/>
204
+ <em style="color:#666;">Note: TRANSFER connections are inferred from simultaneous buys/sells, not explicitly reported.</em>
 
 
 
205
  </div>
206
+
207
+ <script src="https://d3js.org/d3.v7.min.js"></script>
208
+ <script>
209
+ const NODE_NAMES = {nodes_json};
210
+ const MATRIX = {mat_json};
211
+ const NODE_TYPE = {types_json};
212
+
213
+ // Dimensions responsive
214
+ const container = document.getElementById("chord-container");
215
+ function draw() {{
216
+ container.innerHTML = ""; // clear
217
+ const width = Math.min(900, container.clientWidth || 900);
218
+ const height = Math.max(420, Math.min(700, Math.floor(width * 0.75)));
219
+ const outerRadius = Math.min(width, height) * 0.45;
220
+ const innerRadius = outerRadius * 0.86;
221
+
222
+ const svg = d3.select(container)
223
+ .append("svg")
224
+ .attr("width", "100%")
225
+ .attr("height", height)
226
+ .attr("viewBox", [-width/2, -height/2, width, height].join(" "));
227
+
228
+ // color scheme
229
+ const colorNode = d => (NODE_TYPE[d] === "amc") ? "#2b6fa6" : "#f2c88d"; // muted blue and amber
230
+ const chord = d3.chord()
231
+ .padAngle(0.02)
232
+ .sortSubgroups(d3.descending)
233
+ (MATRIX);
234
+
235
+ const arc = d3.arc()
236
+ .innerRadius(innerRadius)
237
+ .outerRadius(outerRadius + 6);
238
+
239
+ const ribbon = d3.ribbon()
240
+ .radius(innerRadius)
241
+ .padAngle(0.01);
242
+
243
+ // groups (outer arcs)
244
+ const group = svg.append("g")
245
+ .selectAll("g")
246
+ .data(chord.groups)
247
+ .enter().append("g")
248
+ .attr("class","group");
249
+
250
+ group.append("path")
251
+ .style("fill", d => colorNode(NODE_NAMES[d.index]))
252
+ .style("stroke", d => d3.color(colorNode(NODE_NAMES[d.index])).darker(0.6))
253
+ .attr("d", arc)
254
+ .attr("cursor","pointer")
255
+ .on("click", (e,d) => focusNode(d.index));
256
+
257
+ // labels
258
+ group.append("text")
259
+ .each(function(d) {{
260
+ const name = NODE_NAMES[d.index];
261
+ d.angle = (d.startAngle + d.endAngle) / 2;
262
+ this._currentAngle = d.angle;
263
+ }})
264
+ .attr("dy", ".35em")
265
+ .attr("transform", function(d) {{
266
+ const angle = (d.startAngle + d.endAngle) / 2;
267
+ const deg = angle * 180 / Math.PI - 90;
268
+ const rotate = deg;
269
+ const translate = outerRadius + 18;
270
+ return "rotate(" + rotate + ") translate(" + translate + ")" + ( (deg > 90) ? " rotate(180)" : "" );
271
+ }})
272
+ .style("font-family", "sans-serif")
273
+ .style("font-size", Math.max(10, Math.min(14, outerRadius*0.04)))
274
+ .style("text-anchor", function(d) {{
275
+ const angle = (d.startAngle + d.endAngle) / 2;
276
+ const deg = angle * 180 / Math.PI - 90;
277
+ return (deg > 90) ? "end" : "start";
278
+ }})
279
+ .text(d => NODE_NAMES[d.index]);
280
+
281
+ // ribbons (flows)
282
+ const ribbons = svg.append("g")
283
+ .attr("class","ribbons")
284
+ .selectAll("path")
285
+ .data(chord)
286
+ .enter().append("path")
287
+ .attr("d", ribbon)
288
+ .style("fill", d => colorNode(NODE_NAMES[d.source.index]) )
289
+ .style("stroke", d => d3.color(colorNode(NODE_NAMES[d.source.index])).darker(0.6) )
290
+ .style("opacity", 0.85)
291
+ .on("mouseover", function(e, d) {{
292
+ d3.select(this).transition().style("opacity", 1.0).style("filter","brightness(1.05)");
293
+ }})
294
+ .on("mouseout", function(e, d) {{
295
+ d3.select(this).transition().style("opacity", 0.85).style("filter",null);
296
+ }});
297
+
298
+ // interactivity: focus/hide
299
+ function focusNode(index) {{
300
+ // highlight groups and ribbons connected to index
301
+ ribbons.transition().style("opacity", r => (r.source.index === index || r.target.index === index) ? 1.0 : 0.08);
302
+ group.selectAll("path").transition().style("opacity", (g) => (g.index === index ? 1.0 : 0.4));
303
+ group.selectAll("text").transition().style("opacity", (g) => (g.index === index ? 1.0 : 0.45));
304
+ }}
305
+
306
+ // reset function
307
+ function resetView() {{
308
+ ribbons.transition().style("opacity", 0.85);
309
+ group.selectAll("path").transition().style("opacity", 1.0);
310
+ group.selectAll("text").transition().style("opacity", 1.0);
311
+ }}
312
+
313
+ // click outside to reset
314
+ svg.on("click", (event) => {{
315
+ const target = event.target;
316
+ if (target.tagName === "svg" || target.tagName === "g") {{
317
+ resetView();
318
+ }}
319
+ }});
320
+
321
+ // expose reset button
322
+ document.getElementById("chord-reset").onclick = resetView;
323
+
324
+ // responsive text sizing: done via font-size above
325
+ }}
326
+
327
+ // initial draw and redraw on resize
328
+ draw();
329
+ window.addEventListener("resize", () => {{
330
+ draw();
331
+ }});
332
+ </script>
333
+ """
334
+ return html
335
+
336
+ # -------------------------
337
+ # Build Gradio app
338
+ # -------------------------
339
+ initial_html = make_chord_html(NODES, MATRIX, NODE_TYPE)
340
+
341
+ with gr.Blocks(title="MBB-style chord diagram — Mutual Fund churn") as demo:
342
+ gr.Markdown("## Mutual Fund Churn — Chord Diagram (consulting-grade)")
343
+ gr.HTML(initial_html)
344
+ gr.Markdown("### Inspect Company / AMC (unchanged)")
345
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
346
  company_plot = gr.Plot()
347
  company_table = gr.DataFrame()
 
 
348
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
349
  amc_plot = gr.Plot()
350
  amc_table = gr.DataFrame()
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  def on_company(c):
353
  fig, df = company_trade_summary(c)
354
  return fig, df