# app.py # Mutual Fund Churn Explorer - Gradio app (full, fixed version) # - Option B style: infer AMC->AMC transfers when one sells and another buys the same security # - Interactive: node/company color, shape, edge color/thickness # - Select company -> shows buyers/sellers; select AMC -> shows inferred transfers # - Supports optional CSV upload to replace built-in sample dataset # # Usage: # pip install -r requirements.txt # python app.py # # requirements.txt (example) # gradio>=3.0 # networkx>=2.6 # plotly>=5.0 # numpy # pandas # kaleido # optional if you want to export static images import gradio as gr import pandas as pd import networkx as nx import plotly.graph_objects as go import numpy as np from collections import defaultdict import io # --------------------------- # Sample dataset (editable) # --------------------------- DEFAULT_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" ] DEFAULT_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" ] # Best-effort sample mappings (you can replace by uploading CSV) SAMPLE_BUY = { "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"] } SAMPLE_SELL = { "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"] } SAMPLE_COMPLETE_EXIT = { "DSP MF": ["Shriram Finance"] } SAMPLE_FRESH_BUY = { "HDFC MF": ["Tata Elxsi"], "UTI MF": ["Adani Ports"], "Mirae MF": ["HAL"] } # --------------------------- # Utilities: build maps from CSV or defaults # --------------------------- def maps_from_dataframe(df, amc_col="AMC", company_col="Company", action_col="Action"): """ Expected actions (case-insensitive): buy, sell, complete_exit, fresh_buy Returns: (amcs, companies, buy_map, sell_map, complete_exit_map, fresh_buy_map) """ amcs = sorted(df[amc_col].dropna().unique().tolist()) companies = sorted(df[company_col].dropna().unique().tolist()) buy_map = defaultdict(list) sell_map = defaultdict(list) complete_exit = defaultdict(list) fresh_buy = defaultdict(list) for _, row in df.iterrows(): a = str(row[amc_col]).strip() c = str(row[company_col]).strip() act = str(row[action_col]).strip().lower() if act in ("buy", "b"): buy_map[a].append(c) elif act in ("sell", "s"): sell_map[a].append(c) elif act in ("complete_exit", "exit", "complete"): complete_exit[a].append(c) elif act in ("fresh_buy", "fresh", "new"): fresh_buy[a].append(c) else: # try to infer from words if "sell" in act: sell_map[a].append(c) elif "buy" in act: buy_map[a].append(c) elif "exit" in act: complete_exit[a].append(c) else: # default to buy if unclear buy_map[a].append(c) # ensure dict -> normal dict return amcs, companies, dict(buy_map), dict(sell_map), dict(complete_exit), dict(fresh_buy) def sanitize_map(m, companies_list): out = {} for k, vals in m.items(): out[k] = [v for v in vals if v in companies_list] return out # default dataset packaging function def load_default_dataset(): AMCS = DEFAULT_AMCS.copy() COMPANIES = DEFAULT_COMPANIES.copy() BUY_MAP = sanitize_map(SAMPLE_BUY, COMPANIES) SELL_MAP = sanitize_map(SAMPLE_SELL, COMPANIES) COMPLETE_EXIT = sanitize_map(SAMPLE_COMPLETE_EXIT, COMPANIES) FRESH_BUY = sanitize_map(SAMPLE_FRESH_BUY, COMPANIES) return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY # --------------------------- # Inference: AMC->AMC transfers # --------------------------- def infer_amc_transfers(buy_map, sell_map): transfers = defaultdict(int) company_to_sellers = defaultdict(list) company_to_buyers = defaultdict(list) for amc, comps in sell_map.items(): for c in comps: company_to_sellers[c].append(amc) for amc, comps in buy_map.items(): for c in comps: company_to_buyers[c].append(amc) for c in set(list(company_to_sellers.keys()) + list(company_to_buyers.keys())): sellers = company_to_sellers.get(c, []) buyers = company_to_buyers.get(c, []) for s in sellers: for b in buyers: # infer s -> b transfer for this company transfers[(s,b)] += 1 edge_list = [] for (s,b), w in transfers.items(): edge_list.append((s,b, {"action": "transfer", "weight": w, "company_count": w})) return edge_list # --------------------------- # Graph builder # --------------------------- def build_graph(AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, include_transfers=True): G = nx.DiGraph() # add nodes for a in AMCS: G.add_node(a, type="amc", label=a) for c in COMPANIES: G.add_node(c, type="company", label=c) # add AMC->company edges def add_edge(a, c, action, weight=1): if not G.has_node(a) or not G.has_node(c): return if G.has_edge(a,c): G[a][c]["weight"] += weight G[a][c]["actions"].append(action) else: G.add_edge(a, c, weight=weight, actions=[action]) for a, comps in BUY_MAP.items(): for c in comps: add_edge(a, c, "buy", weight=1) for a, comps in SELL_MAP.items(): for c in comps: add_edge(a, c, "sell", weight=1) for a, comps in COMPLETE_EXIT.items(): for c in comps: add_edge(a, c, "complete_exit", weight=3) for a, comps in FRESH_BUY.items(): for c in comps: add_edge(a, c, "fresh_buy", weight=3) # inferred transfers (AMC->AMC) if include_transfers: transfers = infer_amc_transfers(BUY_MAP, SELL_MAP) for s,b,attrs in transfers: if not G.has_node(s) or not G.has_node(b): continue if G.has_edge(s,b): G[s][b]["weight"] += attrs.get("weight",1) G[s][b]["actions"].append("transfer") else: G.add_edge(s, b, weight=attrs.get("weight",1), actions=["transfer"]) return G # --------------------------- # Plotly visualization # --------------------------- def graph_to_plotly(G, node_color_amc="#9EC5FF", node_color_company="#FFCF9E", node_shape_amc="circle", node_shape_company="circle", edge_color_buy="#2ca02c", edge_color_sell="#d62728", edge_color_transfer="#888888", edge_thickness_base=1.2, show_labels=True, width=1400, height=900): # position: spring layout with fixed seed for reproducibility pos = nx.spring_layout(G, seed=42, k=1.4) # nodes node_x = [] node_y = [] node_text = [] node_color = [] node_size = [] for n, d in G.nodes(data=True): x, y = pos[n] node_x.append(x) node_y.append(y) node_text.append(n) if d["type"] == "amc": node_color.append(node_color_amc) node_size.append(44) else: node_color.append(node_color_company) node_size.append(64) node_trace = go.Scatter( x=node_x, y=node_y, mode='markers+text' if show_labels else 'markers', marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")), text=node_text if show_labels else None, textposition="top center", hoverinfo='text' ) # edges - draw each edge as a separate trace for styling edge_traces = [] for u, v, attrs in G.edges(data=True): x0, y0 = pos[u] x1, y1 = pos[v] actions = attrs.get("actions", []) weight = attrs.get("weight", 1) # priority styling if "complete_exit" in actions: color = edge_color_sell dash = "solid" width = max(edge_thickness_base * 3.5, 3) elif "fresh_buy" in actions: color = edge_color_buy dash = "solid" width = max(edge_thickness_base * 3.5, 3) elif "transfer" in actions: color = edge_color_transfer dash = "dash" width = max(edge_thickness_base * (1 + np.log1p(weight)), 1.5) elif "sell" in actions: color = edge_color_sell dash = "dot" width = max(edge_thickness_base * (1 + np.log1p(weight)), 1) else: # buy or default color = edge_color_buy dash = "solid" width = max(edge_thickness_base * (1 + np.log1p(weight)), 1) edge_traces.append( go.Scatter( x=[x0, x1, None], y=[y0, y1, None], mode='lines', line=dict(width=width, color=color, dash=dash), hoverinfo='text', text=", ".join(actions) ) ) fig = go.Figure(data=edge_traces + [node_trace], layout=go.Layout( title_text="Mutual Fund Churn Network (AMCs: blue, Companies: orange)", title_x=0.5, showlegend=False, margin=dict(b=20,l=5,r=5,t=40), xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), yaxis=dict(showgrid=False, zeroline=False, showticklabels=False), height=height, width=width )) return fig # --------------------------- # Analysis helpers # --------------------------- def company_trade_summary(company_name, BUY_MAP, SELL_MAP, FRESH_BUY, COMPLETE_EXIT): buyers = [amc for amc, comps in BUY_MAP.items() if company_name in comps] sellers = [amc for amc, comps in SELL_MAP.items() if company_name in comps] fresh = [amc for amc, comps in FRESH_BUY.items() if company_name in comps] exits = [amc for amc, comps in COMPLETE_EXIT.items() if company_name in comps] rows = [] for b in buyers: rows.append({"Role": "Buyer", "AMC": b}) for s in sellers: rows.append({"Role": "Seller", "AMC": s}) for f in fresh: rows.append({"Role": "Fresh Buy", "AMC": f}) for e in exits: rows.append({"Role": "Complete Exit", "AMC": e}) df = pd.DataFrame(rows) if df.empty: return None, pd.DataFrame([], columns=["Role","AMC"]) counts = df['Role'].value_counts().reindex(["Buyer","Seller","Fresh Buy","Complete Exit"]).fillna(0) colors = {"Buyer":"green","Seller":"red","Fresh Buy":"orange","Complete Exit":"black"} bar = go.Figure() bar.add_trace(go.Bar(x=counts.index, y=counts.values, marker_color=[colors.get(i,"grey") for i in counts.index])) bar.update_layout(title=f"Trade Summary for {company_name}", height=360, width=700) return bar, df def amc_transfer_summary(amc_name, BUY_MAP, SELL_MAP): sold = SELL_MAP.get(amc_name, []) transfers = [] for s in sold: buyers = [amc for amc, comps in BUY_MAP.items() if s in comps] 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 = go.Figure(go.Bar(x=counts['Buyer AMC'], y=counts['Count'], marker_color='lightslategray')) fig.update_layout(title_text=f"Inferred transfers from {amc_name}", height=360, width=700) return fig, df # loop detection in inferred AMC->AMC graph (simple cycles up to length n) def detect_loops(G, max_length=6): # extract only nodes that are AMCs amc_nodes = [n for n,d in G.nodes(data=True) if d['type']=='amc'] loops = [] # Work on a directed graph of only amc nodes with transfer edges H = nx.DiGraph() for u,v,d in G.edges(data=True): if u in amc_nodes and v in amc_nodes and "transfer" in d.get("actions",[]): H.add_edge(u,v, weight=d.get("weight",1)) # use simple cycle detection (may find many cycles) try: cycles = list(nx.simple_cycles(H)) except Exception: cycles = [] # filter by max_length for c in cycles: if 2 <= len(c) <= max_length: loops.append(c) return loops # --------------------------- # Gradio interface # --------------------------- def build_initial_graph_and_data(): AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY = load_default_dataset() G = build_graph(AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, include_transfers=True) fig = graph_to_plotly(G) return (AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, G, fig) # Prepare initial data (AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, G_initial, initial_fig) = build_initial_graph_and_data() with gr.Blocks() as demo: gr.Markdown("# Mutual Fund Churn Explorer (inferred AMC→AMC transfers)") with gr.Row(): with gr.Column(scale=3): gr.Markdown("## Controls") csv_uploader = gr.File(label="Upload CSV (optional). Columns: AMC,Company,Action", file_types=['.csv']) node_color_company = gr.ColorPicker(value="#FFCF9E", label="Company node color") node_color_amc = gr.ColorPicker(value="#9EC5FF", label="AMC node color") node_shape_company = gr.Dropdown(choices=["circle","square","diamond"], value="circle", label="Company node shape") node_shape_amc = gr.Dropdown(choices=["circle","square","diamond"], value="circle", label="AMC node shape") edge_color_buy = gr.ColorPicker(value="#2ca02c", label="BUY edge color") edge_color_sell = gr.ColorPicker(value="#d62728", label="SELL edge color") edge_color_transfer = gr.ColorPicker(value="#888888", label="Transfer edge color") edge_thickness = gr.Slider(minimum=0.5, maximum=8.0, value=1.4, step=0.1, label="Edge thickness base") include_transfers_chk = gr.Checkbox(value=True, label="Infer AMC→AMC transfers (show loops)") update_btn = gr.Button("Update network") gr.Markdown("## Inspect") company_selector = gr.Dropdown(choices=COMPANIES, label="Select Company (show buyers/sellers)") amc_selector = gr.Dropdown(choices=AMCS, label="Select AMC (inferred transfers)") with gr.Column(scale=7): network_plot = gr.Plot(value=initial_fig, label="Network graph (drag to zoom)") # outputs company_plot = gr.Plot(label="Company trade summary") company_table = gr.Dataframe(headers=["Role","AMC"], interactive=False, label="Trades (company)") amc_plot = gr.Plot(label="AMC inferred transfers") amc_table = gr.Dataframe(headers=["security","buyer_amc"], interactive=False, label="Inferred transfers (AMC)") loops_text = gr.Markdown() # function to load CSV if provided and build maps def load_dataset_from_csv(file_obj): if file_obj is None: return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY try: raw = file_obj.read() if isinstance(raw, bytes): raw = raw.decode('utf-8', errors='ignore') df = pd.read_csv(io.StringIO(raw)) # expect columns: AMC, Company, Action # normalize column names cols = [c.strip().lower() for c in df.columns] col_map = {} for c in df.columns: if c.strip().lower() in ("amc","fund","manager"): col_map[c] = "AMC" elif c.strip().lower() in ("company","security","stock"): col_map[c] = "Company" elif c.strip().lower() in ("action","trade","type"): col_map[c] = "Action" df = df.rename(columns=col_map) required = {"AMC","Company","Action"} if not required.issubset(set(df.columns)): # can't parse - fallback to default return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = maps_from_dataframe(df, "AMC", "Company", "Action") # sanitize - ensure company nodes exist return amcs, companies, buy_map, sell_map, complete_exit, fresh_buy except Exception as e: print("CSV load error:", e) return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY # callback to rebuild network def on_update(csv_file, node_color_company_val, node_color_amc_val, node_shape_company_val, node_shape_amc_val, edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val, edge_thickness_val, include_transfers_val): # load dataset (possibly replaced by CSV) amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file) # ensure inputs for dropdowns updated - but here we just create fig G = build_graph(amcs, companies, buy_map, sell_map, complete_exit, fresh_buy, include_transfers=include_transfers_val) fig = graph_to_plotly(G, node_color_amc=node_color_amc_val, node_color_company=node_color_company_val, node_shape_amc=node_shape_amc_val, node_shape_company=node_shape_company_val, edge_color_buy=edge_color_buy_val, edge_color_sell=edge_color_sell_val, edge_color_transfer=edge_color_transfer_val, edge_thickness_base=edge_thickness_val, show_labels=True) # detect loops and prepare a small markdown summary loops = detect_loops(G, max_length=6) if loops: loops_md = "### Detected AMC transfer loops (inferred):\n" for i, loop in enumerate(loops, 1): loops_md += f"- Loop {i}: " + " → ".join(loop) + "\n" else: loops_md = "No small transfer loops detected (based on current inferred transfer edges)." # return fig and loops text plus update choices for dropdowns (we will update lists client-side) return fig, loops_md, companies, amcs update_btn.click(on_update, inputs=[csv_uploader, node_color_company, node_color_amc, node_shape_company, node_shape_amc, edge_color_buy, edge_color_sell, edge_color_transfer, edge_thickness, include_transfers_chk], outputs=[network_plot, loops_text, company_selector, amc_selector]) # company select callback def on_company_sel(company_name, csv_file): amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file) fig, df = company_trade_summary(company_name, buy_map, sell_map, fresh_buy, complete_exit) if fig is None: return None, pd.DataFrame([], columns=["Role","AMC"]) return fig, df company_selector.change(on_company_sel, inputs=[company_selector, csv_uploader], outputs=[company_plot, company_table]) # amc select callback def on_amc_sel(amc_name, csv_file): amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file) fig, df = amc_transfer_summary(amc_name, buy_map, sell_map) if fig is None: return None, pd.DataFrame([], columns=["security","buyer_amc"]) return fig, df amc_selector.change(on_amc_sel, inputs=[amc_selector, csv_uploader], outputs=[amc_plot, amc_table]) gr.Markdown("---") gr.Markdown("**Notes:** This app *infers* direct AMC→AMC transfers when one fund sells a security and another buys the same security in the dataset. That inference is not proof of a direct bilateral trade, but it describes likely liquidity flows used to exit or absorb positions.") if __name__ == "__main__": demo.queue().launch(share=False)