Files changed (1) hide show
  1. app.py +235 -268
app.py CHANGED
@@ -1,6 +1,6 @@
1
  # app.py
2
  # Static weighted semi-layer arc diagram (L1 labels outside)
3
- # JS block injected safely (no Python f-strings inside JS)
4
 
5
  import gradio as gr
6
  import pandas as pd
@@ -63,19 +63,51 @@ COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
63
  FRESH_BUY = sanitize_map(FRESH_BUY)
64
 
65
  # ---------------------------
66
- # Infer AMC->AMC transfers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  # ---------------------------
68
  def infer_amc_transfers(buy_map, sell_map):
69
  transfers = defaultdict(int)
70
  c2s = defaultdict(list)
71
  c2b = defaultdict(list)
72
  for amc, comps in sell_map.items():
73
- for c in comps:
74
- c2s[c].append(amc)
75
  for amc, comps in buy_map.items():
76
- for c in comps:
77
- c2b[c].append(amc)
78
- for c in set(c2s.keys()) | set(c2b.keys()):
79
  for s in c2s[c]:
80
  for b in c2b[c]:
81
  transfers[(s, b)] += 1
@@ -90,10 +122,8 @@ def build_mixed_ordering(amcs, companies):
90
  mixed = []
91
  n = max(len(amcs), len(companies))
92
  for i in range(n):
93
- if i < len(amcs):
94
- mixed.append(amcs[i])
95
- if i < len(companies):
96
- mixed.append(companies[i])
97
  return mixed
98
 
99
  NODES = build_mixed_ordering(AMCS, COMPANIES)
@@ -103,73 +133,58 @@ NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
103
  # Build flows
104
  # ---------------------------
105
  def build_flows():
106
- buys = []
107
  for amc, comps in BUY_MAP.items():
108
  for c in comps:
109
- w = 3 if (amc in FRESH_BUY and c in FRESH_BUY.get(amc, [])) else 1
110
- buys.append((amc, c, w))
111
- sells = []
112
  for amc, comps in SELL_MAP.items():
113
  for c in comps:
114
- w = 3 if (amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc, [])) else 1
115
- sells.append((c, amc, w))
116
- transfers = []
117
- for (s,b), w in TRANSFER_COUNTS.items():
118
- transfers.append((s, b, w))
119
- loops = []
120
- # loops: a -> c -> b
121
- for a,c,w1 in buys:
122
- for c2,b,w2 in sells:
123
- if c == c2:
124
- loops.append((a, c, b))
125
- # dedupe
126
  loops = list({(a,c,b) for (a,c,b) in loops})
127
  return buys, sells, transfers, loops
128
 
129
  BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
130
 
131
  # ---------------------------
132
- # Inspector summaries
133
  # ---------------------------
134
  def company_trade_summary(company):
135
- buyers = [a for a, cs in BUY_MAP.items() if company in cs]
136
- sellers = [a for a, cs in SELL_MAP.items() if company in cs]
137
- fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
138
- exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
139
- df = pd.DataFrame({
140
- "Role": (["Buyer"] * len(buyers)) + (["Seller"] * len(sellers)) +
141
- (["Fresh buy"] * len(fresh)) + (["Complete exit"] * len(exits)),
142
- "AMC": buyers + sellers + fresh + exits
143
- })
144
- if df.empty:
145
- return None, pd.DataFrame([], columns=["Role", "AMC"])
146
  counts = df.groupby("Role").size().reset_index(name="Count")
147
- fig = {
148
- "data": [{"type":"bar", "x": counts["Role"].tolist(), "y": counts["Count"].tolist()}],
149
- "layout": {"title": f"Trades for {company}"}
150
- }
151
- return fig, df
152
 
153
  def amc_transfer_summary(amc):
154
- sold = SELL_MAP.get(amc, [])
155
- transfers = []
156
  for s in sold:
157
- buyers = [a for a, cs in BUY_MAP.items() if s in cs]
158
- for b in buyers:
159
- transfers.append({"security": s, "buyer_amc": b})
160
- df = pd.DataFrame(transfers)
161
- if df.empty:
162
- return None, pd.DataFrame([], columns=["security", "buyer_amc"])
163
- counts = df["buyer_amc"].value_counts().reset_index()
164
- counts.columns = ["Buyer AMC", "Count"]
165
- fig = {
166
- "data": [{"type":"bar", "x": counts["Buyer AMC"].tolist(), "y": counts["Count"].tolist()}],
167
- "layout": {"title": f"Inferred transfers from {amc}"}
168
- }
169
- return fig, df
170
 
171
  # ---------------------------
172
- # HTML template (JS inserted safely via replace)
173
  # ---------------------------
174
  JS_TEMPLATE = """
175
  <div id="arc-container" style="width:100%; height:720px;"></div>
@@ -179,21 +194,24 @@ JS_TEMPLATE = """
179
 
180
  <div style="margin-top:10px; font-family:sans-serif; font-size:13px;">
181
  <b>Legend</b><br/>
182
- <span style="display:inline-block;width:12px;height:8px;background:#2e8540;margin-right:6px;"></span> BUY (green solid)<br/>
183
- <span style="display:inline-block;width:12px;height:8px;background:#c0392b;margin-right:6px;border-bottom:3px dotted #c0392b;"></span> SELL (red dotted)<br/>
184
- <span style="display:inline-block;width:12px;height:8px;background:#7d7d7d;margin-right:6px;"></span> TRANSFER (grey, inferred)<br/>
185
- <span style="display:inline-block;width:12px;height:8px;background:#227a6d;margin-right:6px;"></span> LOOP (external arc)<br/>
186
- <div style="margin-top:6px;color:#666;font-size:12px;">Note: Transfers are inferred by matching sells and buys across AMCs. Thickness shows relative weight.</div>
187
  </div>
188
 
189
  <script src="https://d3js.org/d3.v7.min.js"></script>
190
  <script>
191
- const NODES = __NODES__ ;
192
- const NODE_TYPE = __NODE_TYPE__ ;
193
- const BUYS = __BUYS__ ;
194
- const SELLS = __SELLS__ ;
195
- const TRANSFERS = __TRANSFERS__ ;
196
- const LOOPS = __LOOPS__ ;
 
 
 
197
 
198
  function draw() {
199
  const container = document.getElementById("arc-container");
@@ -201,230 +219,191 @@ function draw() {
201
  const w = Math.min(920, container.clientWidth || 820);
202
  const h = Math.max(420, Math.floor(w * 0.75));
203
  const svg = d3.select(container).append("svg")
204
- .attr("width", "100%")
205
- .attr("height", h)
206
- .attr("viewBox", [-w/2, -h/2, w, h].join(" "));
207
-
208
- const radius = Math.min(w, h) * 0.36;
209
-
210
- // node positions around circle
211
- const n = NODES.length;
212
- function angleFor(i) { return (i / n) * 2 * Math.PI; }
213
- const nodePos = NODES.map((name,i) => {
214
- const ang = angleFor(i) - Math.PI/2; // start at top
215
- return { name: name, angle: ang, x: Math.cos(ang)*radius, y: Math.sin(ang)*radius };
216
- });
217
 
218
- const nameToIndex = {};
219
- NODES.forEach((nm,i)=> nameToIndex[nm]=i);
220
 
221
- // small node circles and labels outside
222
- const group = svg.append("g").selectAll("g").data(nodePos).enter().append("g")
223
- .attr("transform", d => `translate(${d.x},${d.y})`);
 
 
 
 
 
 
 
 
224
 
225
  group.append("circle")
226
- .attr("r", 16)
227
- .style("fill", d => NODE_TYPE[d.name] === "amc" ? "#2b6fa6" : "#f2c88d")
228
- .style("stroke", "#222")
229
- .style("stroke-width", 1)
230
- .style("cursor", "pointer");
231
 
232
  group.append("text")
233
- .attr("x", d => Math.cos(d.angle) * (radius + 26))
234
- .attr("y", d => Math.sin(d.angle) * (radius + 26))
235
- .attr("dy", "0.35em")
236
- .style("font-family", "sans-serif")
237
- .style("font-size", Math.max(10, Math.min(14, radius*0.04)))
238
- .style("text-anchor", d => {
239
- const deg = (d.angle * 180 / Math.PI);
240
- return (deg > -90 && deg < 90) ? "start" : "end";
241
- })
242
- .attr("transform", d => {
243
- const deg = (d.angle * 180 / Math.PI);
244
- const flip = (deg > 90 || deg < -90) ? 180 : 0;
245
- return `rotate(${deg}) translate(${radius + 26}) rotate(${flip})`;
246
  })
247
  .style("cursor","pointer")
248
- .text(d => d.name);
249
-
250
- // bezier helper
251
- function bezierPath(x0,y0,x1,y1,above=true) {
252
- const mx = (x0 + x1)/2;
253
- const my = (y0 + y1)/2;
254
- const dx = mx;
255
- const dy = my;
256
- const len = Math.sqrt(dx*dx + dy*dy) || 1;
257
- const ux = dx/len, uy = dy/len;
258
- const offset = (above ? -1 : 1) * Math.max(30, radius*0.9);
259
- const cx = mx + ux * offset;
260
- const cy = my + uy * offset;
261
  return `M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}`;
262
  }
263
 
264
- // stroke width scale
265
- const allW = [].concat(BUYS.map(d=>d[2]), SELLS.map(d=>d[2]), TRANSFERS.map(d=>d[2]));
266
- const wmin = Math.min(...(allW.length?allW:[1]));
267
- const wmax = Math.max(...(allW.length?allW:[1]));
268
- const stroke = d3.scaleLinear().domain([wmin, Math.max(wmax,1)]).range([1.0, 6.0]);
269
-
270
- // buys (top)
271
- const buyGroup = svg.append("g").attr("class","buys");
272
- BUYS.forEach(b => {
273
- const a = b[0], c = b[1], wt = b[2];
274
- if (!(a in nameToIndex) || !(c in nameToIndex)) return;
275
- const s = nodePos[nameToIndex[a]];
276
- const t = nodePos[nameToIndex[c]];
277
- const path = bezierPath(s.x,s.y,t.x,t.y,true);
278
- buyGroup.append("path")
279
- .attr("d", path)
280
  .attr("fill","none")
281
  .attr("stroke","#2e8540")
282
- .attr("stroke-width", stroke(wt))
283
- .attr("stroke-linecap","round")
284
- .attr("opacity", 0.92)
285
- .attr("data-src", a)
286
- .attr("data-tgt", c)
287
- .on("mouseover", function() { d3.select(this).attr("opacity",1); })
288
- .on("mouseout", function() { d3.select(this).attr("opacity",0.92); });
289
  });
290
 
291
- // sells (bottom)
292
- const sellGroup = svg.append("g").attr("class","sells");
293
- SELLS.forEach(s => {
294
- const c = s[0], a = s[1], wt = s[2];
295
- if (!(c in nameToIndex) || !(a in nameToIndex)) return;
296
- const sp = nodePos[nameToIndex[c]];
297
- const tp = nodePos[nameToIndex[a]];
298
- const path = bezierPath(sp.x,sp.y,tp.x,tp.y,false);
299
- sellGroup.append("path")
300
- .attr("d", path)
301
  .attr("fill","none")
302
  .attr("stroke","#c0392b")
303
- .attr("stroke-width", stroke(wt))
304
- .attr("stroke-linecap","round")
305
  .attr("stroke-dasharray","4,3")
306
  .attr("opacity",0.86)
307
- .attr("data-src", c)
308
- .attr("data-tgt", a)
309
- .on("mouseover", function() { d3.select(this).attr("opacity",1); })
310
- .on("mouseout", function() { d3.select(this).attr("opacity",0.86); });
311
  });
312
 
313
- // transfers (grey chords)
314
- const transferGroup = svg.append("g").attr("class","transfers");
315
- TRANSFERS.forEach(tr => {
316
- const sname = tr[0], tname = tr[1], wt = tr[2];
317
- if (!(sname in nameToIndex) || !(tname in nameToIndex)) return;
318
- const sp = nodePos[nameToIndex[sname]];
319
- const tp = nodePos[nameToIndex[tname]];
320
- const mx = (sp.x + tp.x)/2;
321
- const my = (sp.y + tp.y)/2;
322
- const cx = mx * 0.3, cy = my * 0.3;
323
- const path = `M ${sp.x} ${sp.y} Q ${cx} ${cy} ${tp.x} ${tp.y}`;
324
- transferGroup.append("path")
325
- .attr("d", path)
326
  .attr("fill","none")
327
  .attr("stroke","#7d7d7d")
328
- .attr("stroke-width", stroke(wt))
329
  .attr("opacity",0.7)
330
- .attr("data-src", sname)
331
- .attr("data-tgt", tname)
332
- .on("mouseover", function() { d3.select(this).attr("opacity",1); })
333
- .on("mouseout", function() { d3.select(this).attr("opacity",0.7); });
334
  });
335
 
336
- // loops (external arcs)
337
- const loopGroup = svg.append("g").attr("class","loops");
338
- LOOPS.forEach(lp => {
339
- const a = lp[0], c = lp[1], b = lp[2];
340
- if (!(a in nameToIndex) || !(b in nameToIndex)) return;
341
- const sa = nodePos[nameToIndex[a]];
342
- const sb = nodePos[nameToIndex[b]];
343
- const mx = (sa.x + sb.x)/2;
344
- const my = (sa.y + sb.y)/2;
345
- const len = Math.sqrt((sa.x - sb.x)*(sa.x - sb.x) + (sa.y - sb.y)*(sa.y - sb.y));
346
- const outward = Math.max(40, radius*0.28 + len * 0.12);
347
- const ndx = mx, ndy = my;
348
- const nlen = Math.sqrt(ndx*ndx + ndy*ndy) || 1;
349
- const ux = ndx/nlen, uy = ndy/nlen;
350
- const cx = mx + ux * outward;
351
- const cy = my + uy * outward;
352
- const path = `M ${sa.x} ${sa.y} Q ${cx} ${cy} ${sb.x} ${sb.y}`;
353
- loopGroup.append("path")
354
- .attr("d", path)
355
- .attr("fill", "none")
356
- .attr("stroke", "#227a6d")
357
- .attr("stroke-width", 2.8)
358
- .attr("opacity",0.95)
359
- .on("mouseover", function() { d3.select(this).attr("opacity",1); })
360
- .on("mouseout", function() { d3.select(this).attr("opacity",0.95); });
361
  });
362
 
363
- // interactivity: focus on node
364
- function setOpacityFor(nodeName) {
365
- group.selectAll("circle").style("opacity", d => (d.name === nodeName ? 1.0 : 0.18));
366
- group.selectAll("text").style("opacity", d => (d.name === nodeName ? 1.0 : 0.28));
367
- buyGroup.selectAll("path").style("opacity", function() {
368
- const src = this.getAttribute("data-src");
369
- const tgt = this.getAttribute("data-tgt");
370
- return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
 
371
  });
372
- sellGroup.selectAll("path").style("opacity", function() {
373
- const src = this.getAttribute("data-src");
374
- const tgt = this.getAttribute("data-tgt");
375
- return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
376
  });
377
- transferGroup.selectAll("path").style("opacity", function() {
378
- const src = this.getAttribute("data-src");
379
- const tgt = this.getAttribute("data-tgt");
380
- return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
381
  });
382
- loopGroup.selectAll("path").style("opacity", 0.95);
383
  }
384
 
385
- function resetOpacity() {
386
- group.selectAll("circle").style("opacity", 1.0);
387
- group.selectAll("text").style("opacity", 1.0);
388
- buyGroup.selectAll("path").style("opacity", 0.92);
389
- sellGroup.selectAll("path").style("opacity", 0.86);
390
- transferGroup.selectAll("path").style("opacity", 0.7);
391
- loopGroup.selectAll("path").style("opacity", 0.95);
 
392
  }
393
 
394
- group.selectAll("circle").style("cursor","pointer").on("click", function(e,d) {
395
  setOpacityFor(d.name);
396
- if (e && e.stopPropagation) e.stopPropagation();
397
  });
398
- group.selectAll("text").style("cursor","pointer").on("click", function(e,d) {
399
  setOpacityFor(d.name);
400
- if (e && e.stopPropagation) e.stopPropagation();
401
  });
402
 
403
- document.getElementById("arc-reset").onclick = resetOpacity;
404
- svg.on("click", function(event) {
405
- if (event.target.tagName === "svg") resetOpacity();
406
- });
407
  }
408
 
409
  draw();
410
- window.addEventListener("resize", draw);
411
  </script>
412
  """
413
 
 
 
 
414
  def make_arc_html(nodes, node_type, buys, sells, transfers, loops):
415
- # prepare JSON strings
416
- nodes_json = json.dumps(nodes)
417
- node_type_json = json.dumps(node_type)
418
- buys_json = json.dumps(buys)
419
- sells_json = json.dumps(sells)
420
- transfers_json = json.dumps(transfers)
421
- loops_json = json.dumps(loops)
422
- html = JS_TEMPLATE.replace("__NODES__", nodes_json) \
423
- .replace("__NODE_TYPE__", node_type_json) \
424
- .replace("__BUYS__", buys_json) \
425
- .replace("__SELLS__", sells_json) \
426
- .replace("__TRANSFERS__", transfers_json) \
427
- .replace("__LOOPS__", loops_json)
428
  return html
429
 
430
  initial_html = make_arc_html(NODES, NODE_TYPE, BUYS, SELLS, TRANSFERS, LOOPS)
@@ -432,33 +411,21 @@ initial_html = make_arc_html(NODES, NODE_TYPE, BUYS, SELLS, TRANSFERS, LOOPS)
432
  # ---------------------------
433
  # Gradio UI
434
  # ---------------------------
435
- responsive_css = """
436
- #arc-container { padding:0; margin:0; }
437
- svg { font-family: sans-serif; }
438
- """
439
-
440
- with gr.Blocks(css=responsive_css, title="MF Churn — Semi-layer Arc Diagram (L1 labels)") as demo:
441
- gr.Markdown("## Mutual Fund Churn — Weighted Arcs (BUY top / SELL bottom) — labels outside (L1)")
442
  gr.HTML(initial_html)
443
 
444
  gr.Markdown("### Inspect Company / AMC")
445
- select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
446
  company_plot = gr.Plot()
447
  company_table = gr.DataFrame()
448
- select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
 
449
  amc_plot = gr.Plot()
450
  amc_table = gr.DataFrame()
451
 
452
- def on_company(c):
453
- fig, df = company_trade_summary(c)
454
- return fig, df
455
-
456
- def on_amc(a):
457
- fig, df = amc_transfer_summary(a)
458
- return fig, df
459
-
460
- select_company.change(on_company, inputs=[select_company], outputs=[company_plot, company_table])
461
- select_amc.change(on_amc, inputs=[select_amc], outputs=[amc_plot, amc_table])
462
 
463
  if __name__ == "__main__":
464
- demo.launch()
 
1
  # app.py
2
  # Static weighted semi-layer arc diagram (L1 labels outside)
3
+ # With short labels by default & full label on click
4
 
5
  import gradio as gr
6
  import pandas as pd
 
63
  FRESH_BUY = sanitize_map(FRESH_BUY)
64
 
65
  # ---------------------------
66
+ # Label maps (NEW)
67
+ # ---------------------------
68
+ SHORT_LABEL = {
69
+ "SBI MF": "SBI",
70
+ "ICICI Pru MF": "ICICI",
71
+ "HDFC MF": "HDFC",
72
+ "Nippon India MF": "NIP",
73
+ "Kotak MF": "KOTAK",
74
+ "UTI MF": "UTI",
75
+ "Axis MF": "AXIS",
76
+ "Aditya Birla SL MF": "ABSL",
77
+ "Mirae MF": "MIRAE",
78
+ "DSP MF": "DSP",
79
+
80
+ "HDFC Bank": "HDFC Bk",
81
+ "ICICI Bank": "ICICI Bk",
82
+ "Bajaj Finance": "Bajaj Fin",
83
+ "Bajaj Finserv": "Bajaj Fsrv",
84
+ "Adani Ports": "AdaniPt",
85
+ "Tata Motors": "TataMot",
86
+ "Shriram Finance": "Shriram",
87
+ "HAL": "HAL",
88
+ "TCS": "TCS",
89
+ "AU Small Finance Bank": "AU SFB",
90
+ "Pearl Global": "PearlG",
91
+ "Hindalco": "Hindalco",
92
+ "Tata Elxsi": "Elxsi",
93
+ "Cummins India": "Cummins",
94
+ "Vedanta": "Vedanta"
95
+ }
96
+
97
+ FULL_LABEL = {k: k for k in SHORT_LABEL}
98
+
99
+ # ---------------------------
100
+ # Infer AMC→AMC transfers
101
  # ---------------------------
102
  def infer_amc_transfers(buy_map, sell_map):
103
  transfers = defaultdict(int)
104
  c2s = defaultdict(list)
105
  c2b = defaultdict(list)
106
  for amc, comps in sell_map.items():
107
+ for c in comps: c2s[c].append(amc)
 
108
  for amc, comps in buy_map.items():
109
+ for c in comps: c2b[c].append(amc)
110
+ for c in set(c2s) | set(c2b):
 
111
  for s in c2s[c]:
112
  for b in c2b[c]:
113
  transfers[(s, b)] += 1
 
122
  mixed = []
123
  n = max(len(amcs), len(companies))
124
  for i in range(n):
125
+ if i < len(amcs): mixed.append(amcs[i])
126
+ if i < len(companies): mixed.append(companies[i])
 
 
127
  return mixed
128
 
129
  NODES = build_mixed_ordering(AMCS, COMPANIES)
 
133
  # Build flows
134
  # ---------------------------
135
  def build_flows():
136
+ buys, sells, transfers, loops = [], [], [], []
137
  for amc, comps in BUY_MAP.items():
138
  for c in comps:
139
+ w = 3 if (amc in FRESH_BUY and c in FRESH_BUY.get(amc,[])) else 1
140
+ buys.append((amc,c,w))
 
141
  for amc, comps in SELL_MAP.items():
142
  for c in comps:
143
+ w = 3 if (amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc,[])) else 1
144
+ sells.append((c,amc,w))
145
+ for (s,b),w in TRANSFER_COUNTS.items():
146
+ transfers.append((s,b,w))
147
+ for a,c,_ in buys:
148
+ for c2,b,_ in sells:
149
+ if c==c2: loops.append((a,c,b))
 
 
 
 
 
150
  loops = list({(a,c,b) for (a,c,b) in loops})
151
  return buys, sells, transfers, loops
152
 
153
  BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
154
 
155
  # ---------------------------
156
+ # Inspect panels
157
  # ---------------------------
158
  def company_trade_summary(company):
159
+ buyers = [a for a,cs in BUY_MAP.items() if company in cs]
160
+ sellers = [a for a,cs in SELL_MAP.items() if company in cs]
161
+ fresh = [a for a,cs in FRESH_BUY.items() if company in cs]
162
+ exits = [a for a,cs in COMPLETE_EXIT.items() if company in cs]
163
+ df = pd.DataFrame({"Role": (["Buyer"]*len(buyers))+(["Seller"]*len(sellers))+
164
+ (["Fresh buy"]*len(fresh))+(["Complete exit"]*len(exits)),
165
+ "AMC": buyers+sellers+fresh+exits})
166
+ if df.empty: return None, df
 
 
 
167
  counts = df.groupby("Role").size().reset_index(name="Count")
168
+ fig = {"data":[{"type":"bar","x":counts["Role"].tolist(),"y":counts["Count"].tolist()}],
169
+ "layout":{"title":f"Trades for {company}"}}
170
+ return fig,df
 
 
171
 
172
  def amc_transfer_summary(amc):
173
+ sold = SELL_MAP.get(amc,[])
174
+ transfers=[]
175
  for s in sold:
176
+ buyers=[a for a,cs in BUY_MAP.items() if s in cs]
177
+ for b in buyers: transfers.append({"security":s,"buyer_amc":b})
178
+ df=pd.DataFrame(transfers)
179
+ if df.empty:return None,df
180
+ counts=df["buyer_amc"].value_counts().reset_index()
181
+ counts.columns=["Buyer AMC","Count"]
182
+ fig={"data":[{"type":"bar","x":counts["Buyer AMC"].tolist(),"y":counts["Count"].tolist()}],
183
+ "layout":{"title":f"Inferred transfers from {amc}"}}
184
+ return fig,df
 
 
 
 
185
 
186
  # ---------------------------
187
+ # HTML template: safe, no f-strings
188
  # ---------------------------
189
  JS_TEMPLATE = """
190
  <div id="arc-container" style="width:100%; height:720px;"></div>
 
194
 
195
  <div style="margin-top:10px; font-family:sans-serif; font-size:13px;">
196
  <b>Legend</b><br/>
197
+ BUY = green solid<br/>
198
+ SELL = red dotted<br/>
199
+ TRANSFER = grey<br/>
200
+ LOOP = teal external arc<br/>
201
+ <div style="margin-top:6px;color:#666;font-size:12px;">Labels: short by default. Clicking a node shows full name.</div>
202
  </div>
203
 
204
  <script src="https://d3js.org/d3.v7.min.js"></script>
205
  <script>
206
+ const NODES = __NODES__;
207
+ const NODE_TYPE = __NODE_TYPE__;
208
+ const BUYS = __BUYS__;
209
+ const SELLS = __SELLS__;
210
+ const TRANSFERS = __TRANSFERS__;
211
+ const LOOPS = __LOOPS__;
212
+
213
+ const SHORT_LABEL_JS = __SHORT_LABEL__;
214
+ const FULL_LABEL_JS = __FULL_LABEL__;
215
 
216
  function draw() {
217
  const container = document.getElementById("arc-container");
 
219
  const w = Math.min(920, container.clientWidth || 820);
220
  const h = Math.max(420, Math.floor(w * 0.75));
221
  const svg = d3.select(container).append("svg")
222
+ .attr("width","100%")
223
+ .attr("height",h)
224
+ .attr("viewBox",[-w/2,-h/2,w,h].join(" "));
 
 
 
 
 
 
 
 
 
 
225
 
226
+ const radius = Math.min(w,h)*0.36;
 
227
 
228
+ const n=NODES.length;
229
+ function angleFor(i){return (i/n)*2*Math.PI;}
230
+ const nodePos = NODES.map((name,i)=>{
231
+ const ang=angleFor(i)-Math.PI/2;
232
+ return {name,angle:ang,x:Math.cos(ang)*radius,y:Math.sin(ang)*radius};
233
+ });
234
+ const nameToIndex={};
235
+ NODES.forEach((nm,i)=>nameToIndex[nm]=i);
236
+
237
+ const group=svg.append("g").selectAll("g").data(nodePos).enter().append("g")
238
+ .attr("transform", d=>`translate(${d.x},${d.y})`);
239
 
240
  group.append("circle")
241
+ .attr("r",16)
242
+ .style("fill",d=>NODE_TYPE[d.name]==="amc"?"#2b6fa6":"#f2c88d")
243
+ .style("stroke","#222")
244
+ .style("stroke-width",1)
245
+ .style("cursor","pointer");
246
 
247
  group.append("text")
248
+ .attr("x",d=>Math.cos(d.angle)*(radius+26))
249
+ .attr("y",d=>Math.sin(d.angle)*(radius+26))
250
+ .attr("dy","0.35em")
251
+ .style("font-family","sans-serif")
252
+ .style("font-size",Math.max(10,Math.min(14,radius*0.04)))
253
+ .style("text-anchor",d=>{
254
+ const deg=d.angle*180/Math.PI;
255
+ return (deg>-90 && deg<90)?"start":"end";
 
 
 
 
 
256
  })
257
  .style("cursor","pointer")
258
+ .text(d=>SHORT_LABEL_JS[d.name]||d.name);
259
+
260
+ function bezierPath(x0,y0,x1,y1,above=true){
261
+ const mx=(x0+x1)/2, my=(y0+y1)/2;
262
+ const dx=mx, dy=my;
263
+ const len=Math.sqrt(dx*dx+dy*dy)||1;
264
+ const ux=dx/len, uy=dy/len;
265
+ const offset=(above?-1:1)*Math.max(30,radius*0.9);
266
+ const cx=mx+ux*offset, cy=my+uy*offset;
 
 
 
 
267
  return `M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}`;
268
  }
269
 
270
+ const allW=[].concat(BUYS.map(d=>d[2]),SELLS.map(d=>d[2]),TRANSFERS.map(d=>d[2]));
271
+ const stroke=d3.scaleLinear().domain([1,Math.max(...allW,1)]).range([1.0,6.0]);
272
+
273
+ // BUYS top
274
+ const buyG=svg.append("g");
275
+ BUYS.forEach(b=>{
276
+ const a=b[0], c=b[1], wt=b[2];
277
+ if(!(a in nameToIndex)||!(c in nameToIndex))return;
278
+ const s=nodePos[nameToIndex[a]], t=nodePos[nameToIndex[c]];
279
+ buyG.append("path")
280
+ .attr("d",bezierPath(s.x,s.y,t.x,t.y,true))
 
 
 
 
 
281
  .attr("fill","none")
282
  .attr("stroke","#2e8540")
283
+ .attr("stroke-width",stroke(wt))
284
+ .attr("opacity",0.92)
285
+ .attr("data-src",a)
286
+ .attr("data-tgt",c);
 
 
 
287
  });
288
 
289
+ // SELLS bottom
290
+ const sellG=svg.append("g");
291
+ SELLS.forEach(s=>{
292
+ const c=s[0], a=s[1], wt=s[2];
293
+ if(!(c in nameToIndex)||!(a in nameToIndex))return;
294
+ const sp=nodePos[nameToIndex[c]], tp=nodePos[nameToIndex[a]];
295
+ sellG.append("path")
296
+ .attr("d",bezierPath(sp.x,sp.y,tp.x,tp.y,false))
 
 
297
  .attr("fill","none")
298
  .attr("stroke","#c0392b")
299
+ .attr("stroke-width",stroke(wt))
 
300
  .attr("stroke-dasharray","4,3")
301
  .attr("opacity",0.86)
302
+ .attr("data-src",c)
303
+ .attr("data-tgt",a);
 
 
304
  });
305
 
306
+ // transfers
307
+ const trG=svg.append("g");
308
+ TRANSFERS.forEach(t=>{
309
+ const s=t[0], b=t[1], wt=t[2];
310
+ if(!(s in nameToIndex)||!(b in nameToIndex))return;
311
+ const sp=nodePos[nameToIndex[s]], tp=nodePos[nameToIndex[b]];
312
+ const mx=(sp.x+tp.x)/2, my=(sp.y+tp.y)/2;
313
+ const path=`M ${sp.x} ${sp.y} Q ${mx*0.3} ${my*0.3} ${tp.x} ${tp.y}`;
314
+ trG.append("path")
315
+ .attr("d",path)
 
 
 
316
  .attr("fill","none")
317
  .attr("stroke","#7d7d7d")
318
+ .attr("stroke-width",stroke(wt))
319
  .attr("opacity",0.7)
320
+ .attr("data-src",s)
321
+ .attr("data-tgt",b);
 
 
322
  });
323
 
324
+ // loops
325
+ const loopG=svg.append("g");
326
+ LOOPS.forEach(lp=>{
327
+ const a=lp[0], c=lp[1], b=lp[2];
328
+ if(!(a in nameToIndex)||!(b in nameToIndex))return;
329
+ const sa=nodePos[nameToIndex[a]], sb=nodePos[nameToIndex[b]];
330
+ const mx=(sa.x+sb.x)/2, my=(sa.y+sb.y)/2;
331
+ const len=Math.sqrt((sa.x-sb.x)**2+(sa.y-sb.y)**2);
332
+ const outward=Math.max(40,radius*0.28+len*0.12);
333
+ const ndx=mx, ndy=my;
334
+ const nlen=Math.sqrt(ndx*ndx+ndy*ndy)||1;
335
+ const ux=ndx/nlen, uy=ndy/nlen;
336
+ const cx=mx+ux*outward, cy=my+uy*outward;
337
+ const path=`M ${sa.x} ${sa.y} Q ${cx} ${cy} ${sb.x} ${sb.y}`;
338
+ loopG.append("path")
339
+ .attr("d",path)
340
+ .attr("fill","none")
341
+ .attr("stroke","#227a6d")
342
+ .attr("stroke-width",2.8)
343
+ .attr("opacity",0.95);
 
 
 
 
 
344
  });
345
 
346
+ // click -> highlight + full label
347
+ function setOpacityFor(nodeName){
348
+ group.selectAll("circle").style("opacity",d=>d.name===nodeName?1.0:0.18);
349
+ group.selectAll("text")
350
+ .style("opacity",d=>d.name===nodeName?1.0:0.28)
351
+ .text(d=>d.name===nodeName?FULL_LABEL_JS[d.name]:SHORT_LABEL_JS[d.name]);
352
+ buyG.selectAll("path").style("opacity",function(){
353
+ return (this.getAttribute("data-src")===nodeName||
354
+ this.getAttribute("data-tgt")===nodeName)?0.98:0.06;
355
  });
356
+ sellG.selectAll("path").style("opacity",function(){
357
+ return (this.getAttribute("data-src")===nodeName||
358
+ this.getAttribute("data-tgt")===nodeName)?0.98:0.06;
 
359
  });
360
+ trG.selectAll("path").style("opacity",function(){
361
+ return (this.getAttribute("data-src")===nodeName||
362
+ this.getAttribute("data-tgt")===nodeName)?0.98:0.06;
 
363
  });
 
364
  }
365
 
366
+ function resetOpacity(){
367
+ group.selectAll("circle").style("opacity",1.0);
368
+ group.selectAll("text").style("opacity",1.0)
369
+ .text(d=>SHORT_LABEL_JS[d.name]);
370
+ buyG.selectAll("path").style("opacity",0.92);
371
+ sellG.selectAll("path").style("opacity",0.86);
372
+ trG.selectAll("path").style("opacity",0.7);
373
+ loopG.selectAll("path").style("opacity",0.95);
374
  }
375
 
376
+ group.selectAll("circle").on("click",function(e,d){
377
  setOpacityFor(d.name);
378
+ e.stopPropagation();
379
  });
380
+ group.selectAll("text").on("click",function(e,d){
381
  setOpacityFor(d.name);
382
+ e.stopPropagation();
383
  });
384
 
385
+ document.getElementById("arc-reset").onclick=resetOpacity;
386
+ svg.on("click",()=>resetOpacity());
 
 
387
  }
388
 
389
  draw();
390
+ window.addEventListener("resize",draw);
391
  </script>
392
  """
393
 
394
+ # ---------------------------
395
+ # Build final HTML
396
+ # ---------------------------
397
  def make_arc_html(nodes, node_type, buys, sells, transfers, loops):
398
+ html = JS_TEMPLATE
399
+ html = html.replace("__NODES__", json.dumps(nodes))
400
+ html = html.replace("__NODE_TYPE__", json.dumps(node_type))
401
+ html = html.replace("__BUYS__", json.dumps(buys))
402
+ html = html.replace("__SELLS__", json.dumps(sells))
403
+ html = html.replace("__TRANSFERS__", json.dumps(transfers))
404
+ html = html.replace("__LOOPS__", json.dumps(loops))
405
+ html = html.replace("__SHORT_LABEL__", json.dumps(SHORT_LABEL))
406
+ html = html.replace("__FULL_LABEL__", json.dumps(FULL_LABEL))
 
 
 
 
407
  return html
408
 
409
  initial_html = make_arc_html(NODES, NODE_TYPE, BUYS, SELLS, TRANSFERS, LOOPS)
 
411
  # ---------------------------
412
  # Gradio UI
413
  # ---------------------------
414
+ with gr.Blocks(title="MF Churn — Arc Diagram (Short→Full Label on Click)") as demo:
415
+ gr.Markdown("## Mutual Fund Churn — Weighted Arc Diagram (Short labels → Full label on click)")
 
 
 
 
 
416
  gr.HTML(initial_html)
417
 
418
  gr.Markdown("### Inspect Company / AMC")
419
+ select_company = gr.Dropdown(COMPANIES, label="Select company")
420
  company_plot = gr.Plot()
421
  company_table = gr.DataFrame()
422
+
423
+ select_amc = gr.Dropdown(AMCS, label="Select AMC")
424
  amc_plot = gr.Plot()
425
  amc_table = gr.DataFrame()
426
 
427
+ select_company.change(company_trade_summary, inputs=[select_company], outputs=[company_plot, company_table])
428
+ select_amc.change(amc_transfer_summary, inputs=[select_amc], outputs=[amc_plot, amc_table])
 
 
 
 
 
 
 
 
429
 
430
  if __name__ == "__main__":
431
+ demo.launch()