Spaces:
Sleeping
Sleeping
| # app.py | |
| # MBB-style chord diagram (mixed node order) for Mutual Fund churn | |
| # Uses D3 chord layout in browser, static layout (no physics). Mobile-friendly. | |
| import gradio as gr | |
| import pandas as pd | |
| import networkx as nx | |
| import numpy as np | |
| import json | |
| from collections import defaultdict | |
| # ------------------------- | |
| # DATA (same as before) | |
| # ------------------------- | |
| AMCS = [ | |
| "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF", | |
| "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF" | |
| ] | |
| COMPANIES = [ | |
| "HDFC Bank", "ICICI Bank", "Bajaj Finance", "Bajaj Finserv", "Adani Ports", | |
| "Tata Motors", "Shriram Finance", "HAL", "TCS", "AU Small Finance Bank", | |
| "Pearl Global", "Hindalco", "Tata Elxsi", "Cummins India", "Vedanta" | |
| ] | |
| BUY_MAP = { | |
| "SBI MF": ["Bajaj Finance", "AU Small Finance Bank"], | |
| "ICICI Pru MF": ["HDFC Bank"], | |
| "HDFC MF": ["Tata Elxsi", "TCS"], | |
| "Nippon India MF": ["Hindalco"], | |
| "Kotak MF": ["Bajaj Finance"], | |
| "UTI MF": ["Adani Ports", "Shriram Finance"], | |
| "Axis MF": ["Tata Motors", "Shriram Finance"], | |
| "Aditya Birla SL MF": ["AU Small Finance Bank"], | |
| "Mirae MF": ["Bajaj Finance", "HAL"], | |
| "DSP MF": ["Tata Motors", "Bajaj Finserv"] | |
| } | |
| SELL_MAP = { | |
| "SBI MF": ["Tata Motors"], | |
| "ICICI Pru MF": ["Bajaj Finance", "Adani Ports"], | |
| "HDFC MF": ["HDFC Bank"], | |
| "Nippon India MF": ["Hindalco"], | |
| "Kotak MF": ["AU Small Finance Bank"], | |
| "UTI MF": ["Hindalco", "TCS"], | |
| "Axis MF": ["TCS"], | |
| "Aditya Birla SL MF": ["Adani Ports"], | |
| "Mirae MF": ["TCS"], | |
| "DSP MF": ["HAL", "Shriram Finance"] | |
| } | |
| COMPLETE_EXIT = {"DSP MF": ["Shriram Finance"]} | |
| FRESH_BUY = {"HDFC MF": ["Tata Elxsi"], "UTI MF": ["Adani Ports"], "Mirae MF": ["HAL"]} | |
| def sanitize_map(m): | |
| out = {} | |
| for k, vals in m.items(): | |
| out[k] = [v for v in vals if v in COMPANIES] | |
| return out | |
| BUY_MAP = sanitize_map(BUY_MAP) | |
| SELL_MAP = sanitize_map(SELL_MAP) | |
| COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT) | |
| FRESH_BUY = sanitize_map(FRESH_BUY) | |
| # ------------------------- | |
| # Inferred AMC->AMC transfers (same heuristic) | |
| # ------------------------- | |
| def infer_amc_transfers(buy_map, sell_map): | |
| transfers = defaultdict(int) | |
| c2s = defaultdict(list) | |
| c2b = defaultdict(list) | |
| for amc, comps in sell_map.items(): | |
| for c in comps: | |
| c2s[c].append(amc) | |
| for amc, comps in buy_map.items(): | |
| for c in comps: | |
| c2b[c].append(amc) | |
| for c in set(c2s.keys()) | set(c2b.keys()): | |
| for s in c2s[c]: | |
| for b in c2b[c]: | |
| transfers[(s,b)] += 1 | |
| return transfers | |
| transfer_counts = infer_amc_transfers(BUY_MAP, SELL_MAP) | |
| # ------------------------- | |
| # Build mixed ordering (AMC, company, AMC, company...) | |
| # ------------------------- | |
| def build_mixed_ordering(amcs, companies): | |
| mixed = [] | |
| n = max(len(amcs), len(companies)) | |
| for i in range(n): | |
| if i < len(amcs): | |
| mixed.append(amcs[i]) | |
| if i < len(companies): | |
| mixed.append(companies[i]) | |
| return mixed | |
| NODES = build_mixed_ordering(AMCS, COMPANIES) | |
| # Node types map for styling | |
| NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES} | |
| # ------------------------- | |
| # Build flow matrix: nodes x nodes | |
| # Matrix interpretation: | |
| # - AMC -> Company for BUY | |
| # - Company -> AMC for SELL | |
| # - AMC -> AMC for inferred TRANSFER | |
| # Fresh buy and complete exit use higher weight | |
| # ------------------------- | |
| def build_flow_matrix(nodes): | |
| idx = {n:i for i,n in enumerate(nodes)} | |
| n = len(nodes) | |
| M = [[0]*n for _ in range(n)] | |
| # buys: AMC -> Company | |
| for amc, comps in BUY_MAP.items(): | |
| for c in comps: | |
| if amc in idx and c in idx: | |
| w = 1 | |
| if amc in FRESH_BUY and c in FRESH_BUY.get(amc, []): | |
| w = 3 | |
| M[idx[amc]][idx[c]] += w | |
| # sells: Company -> AMC | |
| for amc, comps in SELL_MAP.items(): | |
| for c in comps: | |
| if amc in idx and c in idx: | |
| w = 1 | |
| if amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc, []): | |
| w = 3 | |
| # represent sell as company -> amc | |
| M[idx[c]][idx[amc]] += w | |
| # inferred transfers: AMC -> AMC | |
| for (s,b), w in transfer_counts.items(): | |
| if s in idx and b in idx: | |
| M[idx[s]][idx[b]] += w | |
| return M | |
| MATRIX = build_flow_matrix(NODES) | |
| # ------------------------- | |
| # Helper summaries (unchanged) | |
| # ------------------------- | |
| def company_trade_summary(company): | |
| buyers = [a for a, cs in BUY_MAP.items() if company in cs] | |
| sellers = [a for a, cs in SELL_MAP.items() if company in cs] | |
| fresh = [a for a, cs in FRESH_BUY.items() if company in cs] | |
| exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs] | |
| df = pd.DataFrame({ | |
| "Role": (["Buyer"] * len(buyers)) + (["Seller"] * len(sellers)) + (["Fresh buy"] * len(fresh)) + (["Complete exit"] * len(exits)), | |
| "AMC": buyers + sellers + fresh + exits | |
| }) | |
| if df.empty: | |
| return None, pd.DataFrame([], columns=["Role","AMC"]) | |
| counts = df.groupby("Role").size().reset_index(name="Count") | |
| fig = { | |
| "data": [{"type":"bar","x": counts["Role"].tolist(), "y": counts["Count"].tolist()}], | |
| "layout":{"title":f"Trades for {company}"} | |
| } | |
| return fig, df | |
| def amc_transfer_summary(amc): | |
| sold = SELL_MAP.get(amc, []) | |
| transfers = [] | |
| for s in sold: | |
| buyers = [a for a, cs in BUY_MAP.items() if s in cs] | |
| for b in buyers: | |
| transfers.append({"security": s, "buyer_amc": b}) | |
| df = pd.DataFrame(transfers) | |
| if df.empty: | |
| return None, pd.DataFrame([], columns=["security","buyer_amc"]) | |
| counts = df["buyer_amc"].value_counts().reset_index() | |
| counts.columns = ["Buyer AMC","Count"] | |
| fig = { | |
| "data": [{"type":"bar","x": counts["Buyer AMC"].tolist(), "y": counts["Count"].tolist()}], | |
| "layout":{"title":f"Inferred transfers from {amc}"} | |
| } | |
| return fig, df | |
| # ------------------------- | |
| # Build HTML with D3 chord | |
| # ------------------------- | |
| def make_chord_html(nodes, matrix, node_type): | |
| nodes_json = json.dumps(nodes) | |
| mat_json = json.dumps(matrix) | |
| types_json = json.dumps(node_type) | |
| # D3 chord diagram: mixed nodes around circle, modern palette | |
| html = f""" | |
| <div id="chord-container" style="width:100%; height:640px;"></div> | |
| <div style="margin-top:8px;"> | |
| <button id="chord-reset" style="padding:8px 12px; border-radius:6px;">Reset</button> | |
| </div> | |
| <div style="margin-top:10px; font-family:sans-serif; font-size:13px;"> | |
| <b>Legend</b><br/> | |
| <span style="display:inline-block;width:12px;height:12px;background:#2b6fa6;margin-right:6px;border-radius:2px;"></span> AMC nodes<br/> | |
| <span style="display:inline-block;width:12px;height:12px;background:#f2c88d;margin-right:6px;border-radius:2px;"></span> Company nodes<br/> | |
| <em style="color:#666;">Note: TRANSFER connections are inferred from simultaneous buys/sells, not explicitly reported.</em> | |
| </div> | |
| <script src="https://d3js.org/d3.v7.min.js"></script> | |
| <script> | |
| const NODE_NAMES = {nodes_json}; | |
| const MATRIX = {mat_json}; | |
| const NODE_TYPE = {types_json}; | |
| // Dimensions responsive | |
| const container = document.getElementById("chord-container"); | |
| function draw() {{ | |
| container.innerHTML = ""; // clear | |
| const width = Math.min(900, container.clientWidth || 900); | |
| const height = Math.max(420, Math.min(700, Math.floor(width * 0.75))); | |
| const outerRadius = Math.min(width, height) * 0.45; | |
| const innerRadius = outerRadius * 0.86; | |
| const svg = d3.select(container) | |
| .append("svg") | |
| .attr("width", "100%") | |
| .attr("height", height) | |
| .attr("viewBox", [-width/2, -height/2, width, height].join(" ")); | |
| // color scheme | |
| const colorNode = d => (NODE_TYPE[d] === "amc") ? "#2b6fa6" : "#f2c88d"; // muted blue and amber | |
| const chord = d3.chord() | |
| .padAngle(0.02) | |
| .sortSubgroups(d3.descending) | |
| (MATRIX); | |
| const arc = d3.arc() | |
| .innerRadius(innerRadius) | |
| .outerRadius(outerRadius + 6); | |
| const ribbon = d3.ribbon() | |
| .radius(innerRadius) | |
| .padAngle(0.01); | |
| // groups (outer arcs) | |
| const group = svg.append("g") | |
| .selectAll("g") | |
| .data(chord.groups) | |
| .enter().append("g") | |
| .attr("class","group"); | |
| group.append("path") | |
| .style("fill", d => colorNode(NODE_NAMES[d.index])) | |
| .style("stroke", d => d3.color(colorNode(NODE_NAMES[d.index])).darker(0.6)) | |
| .attr("d", arc) | |
| .attr("cursor","pointer") | |
| .on("click", (e,d) => focusNode(d.index)); | |
| // labels | |
| group.append("text") | |
| .each(function(d) {{ | |
| const name = NODE_NAMES[d.index]; | |
| d.angle = (d.startAngle + d.endAngle) / 2; | |
| this._currentAngle = d.angle; | |
| }}) | |
| .attr("dy", ".35em") | |
| .attr("transform", function(d) {{ | |
| const angle = (d.startAngle + d.endAngle) / 2; | |
| const deg = angle * 180 / Math.PI - 90; | |
| const rotate = deg; | |
| const translate = outerRadius + 18; | |
| return "rotate(" + rotate + ") translate(" + translate + ")" + ( (deg > 90) ? " rotate(180)" : "" ); | |
| }}) | |
| .style("font-family", "sans-serif") | |
| .style("font-size", Math.max(10, Math.min(14, outerRadius*0.04))) | |
| .style("text-anchor", function(d) {{ | |
| const angle = (d.startAngle + d.endAngle) / 2; | |
| const deg = angle * 180 / Math.PI - 90; | |
| return (deg > 90) ? "end" : "start"; | |
| }}) | |
| .text(d => NODE_NAMES[d.index]); | |
| // ribbons (flows) | |
| const ribbons = svg.append("g") | |
| .attr("class","ribbons") | |
| .selectAll("path") | |
| .data(chord) | |
| .enter().append("path") | |
| .attr("d", ribbon) | |
| .style("fill", d => colorNode(NODE_NAMES[d.source.index]) ) | |
| .style("stroke", d => d3.color(colorNode(NODE_NAMES[d.source.index])).darker(0.6) ) | |
| .style("opacity", 0.85) | |
| .on("mouseover", function(e, d) {{ | |
| d3.select(this).transition().style("opacity", 1.0).style("filter","brightness(1.05)"); | |
| }}) | |
| .on("mouseout", function(e, d) {{ | |
| d3.select(this).transition().style("opacity", 0.85).style("filter",null); | |
| }}); | |
| // interactivity: focus/hide | |
| function focusNode(index) {{ | |
| // highlight groups and ribbons connected to index | |
| ribbons.transition().style("opacity", r => (r.source.index === index || r.target.index === index) ? 1.0 : 0.08); | |
| group.selectAll("path").transition().style("opacity", (g) => (g.index === index ? 1.0 : 0.4)); | |
| group.selectAll("text").transition().style("opacity", (g) => (g.index === index ? 1.0 : 0.45)); | |
| }} | |
| // reset function | |
| function resetView() {{ | |
| ribbons.transition().style("opacity", 0.85); | |
| group.selectAll("path").transition().style("opacity", 1.0); | |
| group.selectAll("text").transition().style("opacity", 1.0); | |
| }} | |
| // click outside to reset | |
| svg.on("click", (event) => {{ | |
| const target = event.target; | |
| if (target.tagName === "svg" || target.tagName === "g") {{ | |
| resetView(); | |
| }} | |
| }}); | |
| // expose reset button | |
| document.getElementById("chord-reset").onclick = resetView; | |
| // responsive text sizing: done via font-size above | |
| }} | |
| // initial draw and redraw on resize | |
| draw(); | |
| window.addEventListener("resize", () => {{ | |
| draw(); | |
| }}); | |
| </script> | |
| """ | |
| return html | |
| # ------------------------- | |
| # Build Gradio app | |
| # ------------------------- | |
| initial_html = make_chord_html(NODES, MATRIX, NODE_TYPE) | |
| with gr.Blocks(title="MBB-style chord diagram — Mutual Fund churn") as demo: | |
| gr.Markdown("## Mutual Fund Churn — Chord Diagram (consulting-grade)") | |
| gr.HTML(initial_html) | |
| gr.Markdown("### Inspect Company / AMC (unchanged)") | |
| select_company = gr.Dropdown(choices=COMPANIES, label="Select company") | |
| company_plot = gr.Plot() | |
| company_table = gr.DataFrame() | |
| select_amc = gr.Dropdown(choices=AMCS, label="Select AMC") | |
| amc_plot = gr.Plot() | |
| amc_table = gr.DataFrame() | |
| def on_company(c): | |
| fig, df = company_trade_summary(c) | |
| return fig, df | |
| def on_amc(a): | |
| fig, df = amc_transfer_summary(a) | |
| return fig, df | |
| select_company.change(on_company, inputs=[select_company], outputs=[company_plot, company_table]) | |
| select_amc.change(on_amc, inputs=[select_amc], outputs=[amc_plot, amc_table]) | |
| if __name__ == "__main__": | |
| demo.launch() |