AMCAnalysis / app.py
singhn9's picture
Update app.py
2d5d72d verified
raw
history blame
21.2 kB
# app.py
# D3 physics (client-side) + Plotly visualization for MF churn explorer
# Option A: Replace Python layout with D3 force simulation in browser
# Requirements: gradio, networkx, plotly, pandas, numpy
import gradio as gr
import pandas as pd
import networkx as nx
import plotly.graph_objects as go
import numpy as np
import json
from collections import defaultdict
# ---------------------------
# DATA
# ---------------------------
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)
# ---------------------------
# GRAPH BUILDING
# ---------------------------
company_edges = []
for amc, comps in BUY_MAP.items():
for c in comps:
company_edges.append((amc, c, {"action": "buy", "weight": 1}))
for amc, comps in SELL_MAP.items():
for c in comps:
company_edges.append((amc, c, {"action": "sell", "weight": 1}))
for amc, comps in COMPLETE_EXIT.items():
for c in comps:
company_edges.append((amc, c, {"action": "complete_exit", "weight": 3}))
for amc, comps in FRESH_BUY.items():
for c in comps:
company_edges.append((amc, c, {"action": "fresh_buy", "weight": 3}))
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(company_to_sellers.keys()) | set(company_to_buyers.keys()):
sellers = company_to_sellers[c]
buyers = company_to_buyers[c]
for s in sellers:
for b in buyers:
transfers[(s,b)] += 1
edge_list = []
for (s,b), w in transfers.items():
edge_list.append((s,b, {"action": "transfer", "weight": w}))
return edge_list
transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
def build_graph(include_transfers=True):
G = nx.DiGraph()
for a in AMCS:
G.add_node(a, type="amc")
for c in COMPANIES:
G.add_node(c, type="company")
for u, v, attrs in company_edges:
if u in G.nodes and v in G.nodes:
if G.has_edge(u, v):
G[u][v]["weight"] += attrs.get("weight",1)
G[u][v]["actions"].append(attrs["action"])
else:
G.add_edge(u, v, weight=attrs.get("weight",1), actions=[attrs["action"]])
if include_transfers:
for s,b,attrs in transfer_edges:
if s in G.nodes and b in G.nodes:
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
# ---------------------------
# Build Plotly figure (positions will be set by D3 in browser)
# ---------------------------
def build_plotly_figure(G,
node_color_amc="#9EC5FF",
node_color_company="#FFCF9E",
edge_color_buy="#2ca02c",
edge_color_sell="#d62728",
edge_color_transfer="#888888",
edge_thickness_base=1.4):
# For D3 we don't need Python positions. Use zeros placeholder
node_names = []
node_x = []
node_y = []
node_color = []
node_size = []
for n, d in G.nodes(data=True):
node_names.append(n)
node_x.append(0.0)
node_y.append(0.0)
if d["type"] == "amc":
node_color.append(node_color_amc); node_size.append(36)
else:
node_color.append(node_color_company); node_size.append(56)
edge_traces = []
edge_source_index = []
edge_target_index = []
edge_colors = []
edge_widths = []
for u, v, attrs in G.edges(data=True):
# placeholder coordinates, will be updated by D3
edge_traces.append(go.Scatter(x=[0,0], y=[0,0], mode="lines",
line=dict(color="#888", width=1), hoverinfo="none", opacity=1.0))
edge_source_index.append(node_names.index(u))
edge_target_index.append(node_names.index(v))
acts = attrs.get("actions", [])
weight = attrs.get("weight",1)
if "complete_exit" in acts:
edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*3)
elif "fresh_buy" in acts:
edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*3)
elif "transfer" in acts:
edge_colors.append(edge_color_transfer); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
elif "sell" in acts:
edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
else:
edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
node_trace = go.Scatter(x=node_x, y=node_y, mode="markers+text",
marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
text=node_names, textposition="top center", hoverinfo="text")
fig = go.Figure(data=edge_traces + [node_trace])
fig.update_layout(showlegend=False, autosize=True,
margin=dict(l=8, r=8, t=36, b=8),
xaxis=dict(visible=False), yaxis=dict(visible=False))
meta = {
"node_names": node_names,
"edge_source_index": edge_source_index,
"edge_target_index": edge_target_index,
"edge_colors": edge_colors,
"edge_widths": edge_widths,
"node_colors": node_color,
"node_sizes": node_size
}
return fig, meta
def make_network_html_d3(fig, meta, div_id="network-plot-div"):
"""
Build HTML embedding Plotly figure and D3 physics logic.
Important: all { and } inside the JS template below are doubled {{ }} so f-string stays valid.
"""
fig_json = json.dumps(fig.to_plotly_json())
meta_json = json.dumps(meta)
html = f"""
<div id="{div_id}" style="width:100%;height:520px;"></div>
<div style="margin-top:6px;margin-bottom:8px;">
<button id="{div_id}-reset" style="padding:8px 12px;border-radius:6px;">Reset view</button>
<button id="{div_id}-stop" style="padding:8px 12px;border-radius:6px;margin-left:8px;">Stop layout</button>
</div>
<!-- load libs -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<script>
// Embed figure and metadata
const fig = {fig_json};
const meta = {meta_json};
// create plot
const container = document.getElementById("{div_id}");
Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
// indices
const nodeTraceIndex = fig.data.length - 1;
const edgeCount = fig.data.length - 1;
// build nodes array for D3
const nodes = meta.node_names.map((n, i) => {{
return {{id: i, name: n, r: meta.node_sizes[i] || 20}};
}});
// build links array
const links = meta.edge_source_index.map((s, i) => {{
return {{source: s, target: meta.edge_target_index[i], color: meta.edge_colors[i], width: meta.edge_widths[i] || 1}};
}});
// D3 force simulation parameters tuned for mobile friendliness
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id).distance(130).strength(0.4))
.force("charge", d3.forceManyBody().strength(-60))
.force("collision", d3.forceCollide().radius(d => d.r * 0.6))
.force("center", d3.forceCenter(0,0))
.velocityDecay(0.45);
// Keep track of whether to keep sim running
let stopSimulation = false;
let lastTickTime = Date.now();
let frameSkip = 0;
// throttle Plotly updates: update every N ticks for performance
let tickCounter = 0;
simulation.on("tick", () => {{
tickCounter++;
// throttle updates - every 2 ticks (adjustable)
if (tickCounter % 2 !== 0) return;
// update node coordinates arrays
const xs = nodes.map(n => n.x || 0);
const ys = nodes.map(n => n.y || 0);
// update node trace position
Plotly.restyle(container, {{ 'x': [xs], 'y': [ys] }}, [nodeTraceIndex]);
// update each edge trace
for (let e = 0; e < edgeCount; e++) {{
const sIdx = meta.edge_source_index[e];
const tIdx = meta.edge_target_index[e];
const sx = nodes[sIdx].x || 0;
const sy = nodes[sIdx].y || 0;
const tx = nodes[tIdx].x || 0;
const ty = nodes[tIdx].y || 0;
Plotly.restyle(container, {{ 'x': [[sx, tx]], 'y': [[sy, ty]] }}, [e]);
}}
// stop the simulation gracefully after it's cooled
if (simulation.alpha() < 0.03 || stopSimulation) {{
simulation.stop();
}}
}});
// allow explicit stop
document.getElementById("{div_id}-stop").addEventListener('click', () => {{
stopSimulation = true;
}});
// Map node name -> index for click focus
const nameToIndex = {{}};
meta.node_names.forEach((n,i) => nameToIndex[n] = i);
// focusNode: hides everything except node + neighbors
function focusNode(nodeName) {{
const idx = nameToIndex[nodeName];
const keepSet = new Set([idx]);
// find neighbors
for (let e = 0; e < meta.edge_source_index.length; e++) {{
const s = meta.edge_source_index[e];
const t = meta.edge_target_index[e];
if (s === idx) keepSet.add(t);
if (t === idx) keepSet.add(s);
}}
// node opacity and label colors
const N = meta.node_names.length;
const nodeOp = Array(N).fill(0.0);
const textColors = Array(N).fill("rgba(0,0,0,0)");
for (let i = 0; i < N; i++) {{
if (keepSet.has(i)) {{
nodeOp[i] = 1.0;
textColors[i] = "black";
}}
}}
Plotly.restyle(container, {{
"marker.opacity": [nodeOp],
"textfont.color": [textColors]
}}, [nodeTraceIndex]);
// edges: show only those connecting kept nodes
for (let e = 0; e < edgeCount; e++) {{
const s = meta.edge_source_index[e];
const t = meta.edge_target_index[e];
const show = keepSet.has(s) && keepSet.has(t);
const color = show ? meta.edge_colors[e] : 'rgba(0,0,0,0)';
const width = show ? meta.edge_widths[e] : 0.1;
Plotly.restyle(container, {{ 'line.color': [color], 'line.width': [width] }}, [e]);
}}
// zoom to bounding box of kept nodes
const nodesTrace = fig.data[nodeTraceIndex];
const xs = [], ys = [];
for (let j = 0; j < meta.node_names.length; j++) {{
if (keepSet.has(j)) {{
xs.push(nodesTrace.x[j]);
ys.push(nodesTrace.y[j]);
}}
}}
if (xs.length > 0) {{
const xmin = Math.min(...xs), xmax = Math.max(...xs);
const ymin = Math.min(...ys), ymax = Math.max(...ys);
const padX = (xmax - xmin) * 0.4 + 10;
const padY = (ymax - ymin) * 0.4 + 10;
Plotly.relayout(container, {{ xaxis: {{ range: [xmin - padX, xmax + padX] }}, yaxis: {{ range: [ymin - padY, ymax + padY] }} }});
}}
}}
// reset view function: restore everything and restart a short simulation to settle
function resetView() {{
const N = meta.node_names.length;
const nodeOp = Array(N).fill(1.0);
const textColors = Array(N).fill("black");
Plotly.restyle(container, {{ "marker.opacity": [nodeOp], "textfont.color": [textColors] }}, [nodeTraceIndex]);
for (let e = 0; e < edgeCount; e++) {{
Plotly.restyle(container, {{ 'line.color': [meta.edge_colors[e]], 'line.width': [meta.edge_widths[e]] }}, [e]);
}}
// autorange
Plotly.relayout(container, {{ xaxis: {{autorange:true}}, yaxis: {{autorange:true}} }});
// restart a short simulation to re-space nodes
stopSimulation = false;
simulation.alpha(0.6);
simulation.restart();
}}
// click handler: only react if node trace clicked
container.on('plotly_click', function(eventData) {{
const p = eventData.points[0];
if (p.curveNumber === nodeTraceIndex) {{
const nodeIndex = p.pointNumber;
const nodeName = meta.node_names[nodeIndex];
focusNode(nodeName);
}}
}});
// reset button hookup
document.getElementById("{div_id}-reset").addEventListener('click', function() {{
resetView();
}});
</script>
"""
return html
# ---------------------------
# Company / AMC inspection helpers (unchanged)
# ---------------------------
def company_trade_summary(company_name):
buyers = [a for a, comps in BUY_MAP.items() if company_name in comps]
sellers = [a for a, comps in SELL_MAP.items() if company_name in comps]
fresh = [a for a, comps in FRESH_BUY.items() if company_name in comps]
exits = [a for a, comps in COMPLETE_EXIT.items() if company_name in comps]
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 = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=["green","red","orange","black"][:len(counts)]))
fig.update_layout(title_text=f"Trade summary for {company_name}", autosize=True, margin=dict(t=30,b=10))
return fig, df
def amc_transfer_summary(amc_name):
sold = SELL_MAP.get(amc_name, [])
transfers = []
for s in sold:
buyers = [a for a, 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}", autosize=True, margin=dict(t=30,b=10))
return fig, df
# ---------------------------
# Build the initial HTML (Plotly + D3)
# ---------------------------
def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
edge_color_buy="#2ca02c", edge_color_sell="#d62728",
edge_color_transfer="#888888", edge_thickness=1.4, include_transfers=True):
G = build_graph(include_transfers=include_transfers)
fig, meta = build_plotly_figure(G,
node_color_amc=node_color_amc,
node_color_company=node_color_company,
edge_color_buy=edge_color_buy,
edge_color_sell=edge_color_sell,
edge_color_transfer=edge_color_transfer,
edge_thickness_base=edge_thickness)
return make_network_html_d3(fig, meta)
initial_html = build_network_html()
# ---------------------------
# Mobile CSS and Gradio UI
# ---------------------------
responsive_css = """
.gradio-container { padding:0 !important; margin:0 !important; }
.plotly-graph-div, .js-plotly-plot, .output_plot { width:100% !important; max-width:100% !important; }
.js-plotly-plot { height:460px !important; }
@media(max-width:780px){ .js-plotly-plot{ height:420px !important; } }
body, html { overflow-x:hidden !important; }
"""
with gr.Blocks(css=responsive_css, title="MF Churn Explorer (D3 physics)") as demo:
gr.Markdown("## Mutual Fund Churn Explorer — D3 force-directed layout (mobile friendly)")
# interactive chart (HTML block)
network_html = gr.HTML(value=initial_html)
# Legend (updated with inferred note)
legend_html = gr.HTML(value="""
<div style='font-family:sans-serif;font-size:14px;margin-top:10px;line-height:1.6;'>
<b>Legend</b><br>
<div><span style="display:inline-block;width:28px;border-bottom:3px solid #2ca02c;"></span> BUY (green solid)</div>
<div><span style="display:inline-block;width:28px;border-bottom:3px dotted #d62728;"></span> SELL (red dotted)</div>
<div><span style="display:inline-block;width:28px;border-bottom:3px dashed #888;"></span> TRANSFER (grey dashed — inferred, not actual reported transfer)</div>
<div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY (thick green)</div>
<div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT (thick red)</div>
</div>
""")
# Controls (unchanged)
with gr.Accordion("Network Customization — expand to edit", open=False):
node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
edge_color_buy = gr.ColorPicker("#2ca02c", label="BUY edge color")
edge_color_sell = gr.ColorPicker("#d62728", label="SELL edge color")
edge_color_transfer = gr.ColorPicker("#888888", label="Transfer edge color")
edge_thickness = gr.Slider(0.5, 6.0, value=1.4, step=0.1, label="Edge thickness base")
include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
update_button = gr.Button("Update Network Graph")
# Company & AMC inspect (unchanged)
gr.Markdown("### Inspect Company (buyers / sellers)")
select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
company_plot = gr.Plot()
company_table = gr.DataFrame()
gr.Markdown("### Inspect AMC (inferred transfers)")
select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
amc_plot = gr.Plot()
amc_table = gr.DataFrame()
# Callbacks
def update_network_html(node_color_company_val, node_color_amc_val,
edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
edge_thickness_val, include_transfers_val):
return build_network_html(node_color_company=node_color_company_val,
node_color_amc=node_color_amc_val,
edge_color_buy=edge_color_buy_val,
edge_color_sell=edge_color_sell_val,
edge_color_transfer=edge_color_transfer_val,
edge_thickness=edge_thickness_val,
include_transfers=include_transfers_val)
def on_company_select(cname):
fig, df = company_trade_summary(cname)
if fig is None:
return None, pd.DataFrame([], columns=["Role", "AMC"])
return fig, df
def on_amc_select(aname):
fig, df = amc_transfer_summary(aname)
if fig is None:
return None, pd.DataFrame([], columns=["security", "buyer_amc"])
return fig, df
update_button.click(fn=update_network_html,
inputs=[node_color_company, node_color_amc,
edge_color_buy, edge_color_sell, edge_color_transfer,
edge_thickness, include_transfers],
outputs=[network_html])
select_company.change(fn=on_company_select, inputs=[select_company], outputs=[company_plot, company_table])
select_amc.change(fn=on_amc_select, inputs=[select_amc], outputs=[amc_plot, amc_table])
if __name__ == "__main__":
demo.launch()