Files changed (1) hide show
  1. app.py +294 -170
app.py CHANGED
@@ -1,43 +1,59 @@
1
  # app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import gradio as gr
3
  import pandas as pd
4
  import networkx as nx
5
  import plotly.graph_objects as go
6
  import numpy as np
7
- from collections import defaultdict, Counter
 
8
 
9
  # ---------------------------
10
  # Sample dataset (editable)
11
  # ---------------------------
12
- # 10 AMCs (from your table) and ~15 companies extracted earlier.
13
- AMCS = [
14
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
15
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
16
  ]
17
 
18
- COMPANIES = [
19
  "HDFC Bank", "ICICI Bank", "Bajaj Finance", "Bajaj Finserv", "Adani Ports",
20
  "Tata Motors", "Shriram Finance", "HAL", "TCS", "AU Small Finance Bank",
21
  "Pearl Global", "Hindalco", "Tata Elxsi", "Cummins India", "Vedanta"
22
  ]
23
 
24
- # These are best-effort imputations from the newspaper table you provided.
25
- # BUY: AMC -> company
26
- BUY_MAP = {
27
  "SBI MF": ["Bajaj Finance", "AU Small Finance Bank"],
28
- "ICICI Pru MF": ["HDFC Bank", "NTPC"] if False else ["HDFC Bank"], # NTPC not in COMPANIES list here
29
  "HDFC MF": ["Tata Elxsi", "TCS"],
30
- "Nippon India MF": ["Colgate-Palmolive (India)"] if False else ["Hindalco"],
31
- "Kotak MF": ["Bajaj Finance", "Power Finance Corporation"] if False else ["Bajaj Finance"],
32
  "UTI MF": ["Adani Ports", "Shriram Finance"],
33
  "Axis MF": ["Tata Motors", "Shriram Finance"],
34
- "Aditya Birla SL MF": ["AU Small Finance Bank", "Tata Steel"] if False else ["AU Small Finance Bank"],
35
  "Mirae MF": ["Bajaj Finance", "HAL"],
36
  "DSP MF": ["Tata Motors", "Bajaj Finserv"]
37
  }
38
 
39
- # SELL: AMC -> company
40
- SELL_MAP = {
41
  "SBI MF": ["Tata Motors"],
42
  "ICICI Pru MF": ["Bajaj Finance", "Adani Ports"],
43
  "HDFC MF": ["HDFC Bank"],
@@ -50,50 +66,80 @@ SELL_MAP = {
50
  "DSP MF": ["HAL", "Shriram Finance"]
51
  }
52
 
53
- # COMPLETE EXITs (one-way big exit)
54
- COMPLETE_EXIT = {
55
- "DSP MF": ["Shriram Finance"], # DSP completed exit of Shriram (example)
56
  }
57
 
58
- # FRESH_BUY (first time or notable fresh buy)
59
- FRESH_BUY = {
60
  "HDFC MF": ["Tata Elxsi"],
61
  "UTI MF": ["Adani Ports"],
62
  "Mirae MF": ["HAL"]
63
  }
64
 
65
- # sanitize maps (remove any items not in COMPANIES)
66
- def sanitize_map(m):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  out = {}
68
  for k, vals in m.items():
69
- out[k] = [v for v in vals if v in COMPANIES]
70
  return out
71
 
72
- BUY_MAP = sanitize_map(BUY_MAP)
73
- SELL_MAP = sanitize_map(SELL_MAP)
74
- COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
75
- FRESH_BUY = sanitize_map(FRESH_BUY)
76
-
77
- # Build edge lists (AMC -> company) with attributes
78
- company_edges = []
79
- for amc, comps in BUY_MAP.items():
80
- for c in comps:
81
- company_edges.append((amc, c, {"action": "buy", "weight": 1}))
82
- for amc, comps in SELL_MAP.items():
83
- for c in comps:
84
- company_edges.append((amc, c, {"action": "sell", "weight": 1}))
85
- for amc, comps in COMPLETE_EXIT.items():
86
- for c in comps:
87
- company_edges.append((amc, c, {"action": "complete_exit", "weight": 3}))
88
- for amc, comps in FRESH_BUY.items():
89
- for c in comps:
90
- company_edges.append((amc, c, {"action": "fresh_buy", "weight": 3}))
91
-
92
- # Inferred AMC->AMC transfers: if AMC A sells company X and AMC B buys company X,
93
- # infer A -> B transfer (transfer volume increments with multiple shared tickers)
94
  def infer_amc_transfers(buy_map, sell_map):
95
  transfers = defaultdict(int)
96
- # for each company, find sellers and buyers
97
  company_to_sellers = defaultdict(list)
98
  company_to_buyers = defaultdict(list)
99
  for amc, comps in sell_map.items():
@@ -109,36 +155,46 @@ def infer_amc_transfers(buy_map, sell_map):
109
  for b in buyers:
110
  # infer s -> b transfer for this company
111
  transfers[(s,b)] += 1
112
- # convert to list of edges
113
  edge_list = []
114
  for (s,b), w in transfers.items():
115
  edge_list.append((s,b, {"action": "transfer", "weight": w, "company_count": w}))
116
  return edge_list
117
 
118
- transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
119
-
120
- # Combined graph builder
121
- def build_graph(include_transfers=True):
122
  G = nx.DiGraph()
123
- # add AMC nodes
124
  for a in AMCS:
125
  G.add_node(a, type="amc", label=a)
126
- # add company nodes
127
  for c in COMPANIES:
128
  G.add_node(c, type="company", label=c)
129
- # add company edges (amc->company)
130
- for a, c, attrs in company_edges:
131
  if not G.has_node(a) or not G.has_node(c):
132
- continue
133
- # use unique key if multiple edges to same target: accumulate weight
134
  if G.has_edge(a,c):
135
- G[a][c]["weight"] += attrs.get("weight",1)
136
- G[a][c]["actions"].append(attrs["action"])
137
  else:
138
- G.add_edge(a, c, weight=attrs.get("weight",1), actions=[attrs["action"]])
139
- # add transfers
 
 
 
 
 
 
 
 
 
 
 
 
140
  if include_transfers:
141
- for s,b,attrs in transfer_edges:
 
142
  if not G.has_node(s) or not G.has_node(b):
143
  continue
144
  if G.has_edge(s,b):
@@ -149,7 +205,7 @@ def build_graph(include_transfers=True):
149
  return G
150
 
151
  # ---------------------------
152
- # Visualization helpers
153
  # ---------------------------
154
  def graph_to_plotly(G,
155
  node_color_amc="#9EC5FF",
@@ -160,16 +216,17 @@ def graph_to_plotly(G,
160
  edge_color_sell="#d62728",
161
  edge_color_transfer="#888888",
162
  edge_thickness_base=1.2,
163
- show_labels=True):
164
- # Layout
165
- pos = nx.spring_layout(G, seed=42, k=1.2)
166
- # Build traces
 
 
167
  node_x = []
168
  node_y = []
169
  node_text = []
170
  node_color = []
171
  node_size = []
172
- marker_symbols = []
173
  for n, d in G.nodes(data=True):
174
  x, y = pos[n]
175
  node_x.append(x)
@@ -177,43 +234,36 @@ def graph_to_plotly(G,
177
  node_text.append(n)
178
  if d["type"] == "amc":
179
  node_color.append(node_color_amc)
180
- node_size.append(40)
181
- marker_symbols.append(node_shape_amc)
182
  else:
183
  node_color.append(node_color_company)
184
- node_size.append(60)
185
- marker_symbols.append(node_shape_company)
186
 
187
  node_trace = go.Scatter(
188
  x=node_x, y=node_y,
189
  mode='markers+text' if show_labels else 'markers',
190
- marker=dict(
191
- color=node_color,
192
- size=node_size,
193
- line=dict(width=2, color="#222")
194
- ),
195
  text=node_text if show_labels else None,
196
  textposition="top center",
197
  hoverinfo='text'
198
  )
199
 
200
- # Edge traces (separate traces for buy, sell, transfers for color/thickness control)
201
  edge_traces = []
202
  for u, v, attrs in G.edges(data=True):
203
- # get style by action mix - priority: complete_exit/fresh_buy > transfer > sell > buy
204
- actions = attrs.get("actions",[])
205
- weight = attrs.get("weight",1)
206
  x0, y0 = pos[u]
207
  x1, y1 = pos[v]
208
- # choose color & dash & width
 
 
209
  if "complete_exit" in actions:
210
  color = edge_color_sell
211
  dash = "solid"
212
- width = max(edge_thickness_base * 3, 3)
213
  elif "fresh_buy" in actions:
214
  color = edge_color_buy
215
  dash = "solid"
216
- width = max(edge_thickness_base * 3, 3)
217
  elif "transfer" in actions:
218
  color = edge_color_transfer
219
  dash = "dash"
@@ -222,65 +272,66 @@ def graph_to_plotly(G,
222
  color = edge_color_sell
223
  dash = "dot"
224
  width = max(edge_thickness_base * (1 + np.log1p(weight)), 1)
225
- else: # buy
226
  color = edge_color_buy
227
  dash = "solid"
228
  width = max(edge_thickness_base * (1 + np.log1p(weight)), 1)
229
- edge_trace = go.Scatter(
230
- x=[x0, x1, None],
231
- y=[y0, y1, None],
232
- line=dict(width=width, color=color, dash=dash),
233
- hoverinfo='none',
234
- mode='lines'
 
 
 
 
235
  )
236
- edge_traces.append(edge_trace)
237
 
238
- # Create figure
239
  fig = go.Figure(data=edge_traces + [node_trace],
240
  layout=go.Layout(
 
 
241
  showlegend=False,
242
  margin=dict(b=20,l=5,r=5,t=40),
243
  xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
244
  yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
245
- height=1000,
246
- width=1400,
247
  ))
248
  return fig
249
 
250
  # ---------------------------
251
- # Analysis helpers (what user requested)
252
  # ---------------------------
253
- def company_trade_summary(company_name):
254
- """Return a small bar chart / dataframe of who bought vs sold the given company."""
255
- # build counts
256
- buyers = []
257
- sellers = []
258
- for amc, comps in BUY_MAP.items():
259
- if company_name in comps:
260
- buyers.append(amc)
261
- for amc, comps in SELL_MAP.items():
262
- if company_name in comps:
263
- sellers.append(amc)
264
- # include complete exits and fresh buys
265
  fresh = [amc for amc, comps in FRESH_BUY.items() if company_name in comps]
266
  exits = [amc for amc, comps in COMPLETE_EXIT.items() if company_name in comps]
267
- df = pd.DataFrame({
268
- "Role": ["Buyer"]*len(buyers) + ["Seller"]*len(sellers) + ["Fresh buy"]*len(fresh) + ["Complete exit"]*len(exits),
269
- "AMC": buyers + sellers + fresh + exits
270
- })
 
 
 
 
 
 
 
 
271
  if df.empty:
272
- return "No visible trades for this company in dataset."
273
- # make simple counts bar chart
274
- counts = df.groupby("Role").size().reset_index(name="Count")
275
- fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=["green","red","orange","black"][:len(counts)]))
276
- fig.update_layout(title_text=f"Trade summary for {company_name}", height=350, width=600)
277
- return fig, df
 
278
 
279
- def amc_transfer_summary(amc_name):
280
- """For a selected AMC, show which securities were sold to which other AMC (inferred)"""
281
- # securities sold by this AMC
282
  sold = SELL_MAP.get(amc_name, [])
283
- # who bought those securities
284
  transfers = []
285
  for s in sold:
286
  buyers = [amc for amc, comps in BUY_MAP.items() if s in comps]
@@ -288,23 +339,52 @@ def amc_transfer_summary(amc_name):
288
  transfers.append({"security": s, "buyer_amc": b})
289
  df = pd.DataFrame(transfers)
290
  if df.empty:
291
- return "No inferred transfers for this AMC in dataset."
292
- # return table and a simple count chart (buyers count)
293
  counts = df['buyer_amc'].value_counts().reset_index()
294
  counts.columns = ['Buyer AMC', 'Count']
295
  fig = go.Figure(go.Bar(x=counts['Buyer AMC'], y=counts['Count'], marker_color='lightslategray'))
296
- fig.update_layout(title_text=f"Inferred transfers from {amc_name} (sold securities -> buyer AMCs)", height=350, width=600)
297
  return fig, df
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  # ---------------------------
300
- # Gradio UI
301
  # ---------------------------
 
 
 
 
 
 
 
 
 
302
  with gr.Blocks() as demo:
303
- gr.Markdown("## Mutual Fund Churn explorer interactive network + transfer analysis")
304
  with gr.Row():
305
  with gr.Column(scale=3):
306
- # Graph controls
307
- gr.Markdown("### Network customization")
308
  node_color_company = gr.ColorPicker(value="#FFCF9E", label="Company node color")
309
  node_color_amc = gr.ColorPicker(value="#9EC5FF", label="AMC node color")
310
  node_shape_company = gr.Dropdown(choices=["circle","square","diamond"], value="circle", label="Company node shape")
@@ -312,26 +392,62 @@ with gr.Blocks() as demo:
312
  edge_color_buy = gr.ColorPicker(value="#2ca02c", label="BUY edge color")
313
  edge_color_sell = gr.ColorPicker(value="#d62728", label="SELL edge color")
314
  edge_color_transfer = gr.ColorPicker(value="#888888", label="Transfer edge color")
315
- edge_thickness = gr.Slider(minimum=0.5, maximum=6.0, value=1.4, step=0.1, label="Edge thickness base")
316
- include_transfers = gr.Checkbox(value=True, label="Infer AMC AMC transfers (yes = show direct loops)")
317
- update_button = gr.Button("Update network")
 
 
 
318
 
319
- gr.Markdown("### Inspect specific node")
320
- select_company = gr.Dropdown(choices=COMPANIES, label="Select company (show buyers/sellers)")
321
- select_amc = gr.Dropdown(choices=AMCS, label="Select AMC (show inferred transfers)")
322
  with gr.Column(scale=7):
323
- network_plot = gr.Plot(label="Network graph (drag to zoom)")
324
-
325
- # outputs for selections
326
- company_out_plot = gr.Plot(label="Company trade summary")
327
- company_out_table = gr.DataFrame(label="Trades (company)")
328
- amc_out_plot = gr.Plot(label="AMC inferred transfers")
329
- amc_out_table = gr.DataFrame(label="Inferred transfers (AMC)")
330
-
331
- def update_network(node_color_company_val, node_color_amc_val, node_shape_company_val, node_shape_amc_val,
332
- edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val, edge_thickness_val,
333
- include_transfers_val):
334
- G = build_graph(include_transfers=include_transfers_val)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  fig = graph_to_plotly(G,
336
  node_color_amc=node_color_amc_val,
337
  node_color_company=node_color_company_val,
@@ -342,36 +458,44 @@ with gr.Blocks() as demo:
342
  edge_color_transfer=edge_color_transfer_val,
343
  edge_thickness_base=edge_thickness_val,
344
  show_labels=True)
345
- return fig
346
-
347
- def on_company_select(cname):
348
- res = company_trade_summary(cname)
349
- if isinstance(res, tuple):
350
- fig, df = res
351
- return fig, df
352
  else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  return None, pd.DataFrame([], columns=["Role","AMC"])
 
354
 
355
- def on_amc_select(aname):
356
- res = amc_transfer_summary(aname)
357
- if isinstance(res, tuple):
358
- fig, df = res
359
- return fig, df
360
- else:
 
361
  return None, pd.DataFrame([], columns=["security","buyer_amc"])
 
362
 
363
- update_button.click(fn=update_network,
364
- inputs=[node_color_company, node_color_amc, node_shape_company, node_shape_amc,
365
- edge_color_buy, edge_color_sell, edge_color_transfer, edge_thickness, include_transfers],
366
- outputs=[network_plot])
367
- select_company.change(fn=on_company_select, inputs=[select_company], outputs=[company_out_plot, company_out_table])
368
- select_amc.change(fn=on_amc_select, inputs=[select_amc], outputs=[amc_out_plot, amc_out_table])
369
 
370
- # initial network
371
- network_plot.update(value=update_network(node_color_company.value, node_color_amc.value,
372
- node_shape_company.value, node_shape_amc.value,
373
- edge_color_buy.value, edge_color_sell.value, edge_color_transfer.value,
374
- edge_thickness.value, include_transfers.value))
375
 
376
  if __name__ == "__main__":
377
- demo.launch()
 
1
  # app.py
2
+ # Mutual Fund Churn Explorer - Gradio app (full, fixed version)
3
+ # - Option B style: infer AMC->AMC transfers when one sells and another buys the same security
4
+ # - Interactive: node/company color, shape, edge color/thickness
5
+ # - Select company -> shows buyers/sellers; select AMC -> shows inferred transfers
6
+ # - Supports optional CSV upload to replace built-in sample dataset
7
+ #
8
+ # Usage:
9
+ # pip install -r requirements.txt
10
+ # python app.py
11
+ #
12
+ # requirements.txt (example)
13
+ # gradio>=3.0
14
+ # networkx>=2.6
15
+ # plotly>=5.0
16
+ # numpy
17
+ # pandas
18
+ # kaleido # optional if you want to export static images
19
+
20
  import gradio as gr
21
  import pandas as pd
22
  import networkx as nx
23
  import plotly.graph_objects as go
24
  import numpy as np
25
+ from collections import defaultdict
26
+ import io
27
 
28
  # ---------------------------
29
  # Sample dataset (editable)
30
  # ---------------------------
31
+ DEFAULT_AMCS = [
 
32
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
33
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
34
  ]
35
 
36
+ DEFAULT_COMPANIES = [
37
  "HDFC Bank", "ICICI Bank", "Bajaj Finance", "Bajaj Finserv", "Adani Ports",
38
  "Tata Motors", "Shriram Finance", "HAL", "TCS", "AU Small Finance Bank",
39
  "Pearl Global", "Hindalco", "Tata Elxsi", "Cummins India", "Vedanta"
40
  ]
41
 
42
+ # Best-effort sample mappings (you can replace by uploading CSV)
43
+ SAMPLE_BUY = {
 
44
  "SBI MF": ["Bajaj Finance", "AU Small Finance Bank"],
45
+ "ICICI Pru MF": ["HDFC Bank"],
46
  "HDFC MF": ["Tata Elxsi", "TCS"],
47
+ "Nippon India MF": ["Hindalco"],
48
+ "Kotak MF": ["Bajaj Finance"],
49
  "UTI MF": ["Adani Ports", "Shriram Finance"],
50
  "Axis MF": ["Tata Motors", "Shriram Finance"],
51
+ "Aditya Birla SL MF": ["AU Small Finance Bank"],
52
  "Mirae MF": ["Bajaj Finance", "HAL"],
53
  "DSP MF": ["Tata Motors", "Bajaj Finserv"]
54
  }
55
 
56
+ SAMPLE_SELL = {
 
57
  "SBI MF": ["Tata Motors"],
58
  "ICICI Pru MF": ["Bajaj Finance", "Adani Ports"],
59
  "HDFC MF": ["HDFC Bank"],
 
66
  "DSP MF": ["HAL", "Shriram Finance"]
67
  }
68
 
69
+ SAMPLE_COMPLETE_EXIT = {
70
+ "DSP MF": ["Shriram Finance"]
 
71
  }
72
 
73
+ SAMPLE_FRESH_BUY = {
 
74
  "HDFC MF": ["Tata Elxsi"],
75
  "UTI MF": ["Adani Ports"],
76
  "Mirae MF": ["HAL"]
77
  }
78
 
79
+ # ---------------------------
80
+ # Utilities: build maps from CSV or defaults
81
+ # ---------------------------
82
+ def maps_from_dataframe(df, amc_col="AMC", company_col="Company", action_col="Action"):
83
+ """
84
+ Expected actions (case-insensitive): buy, sell, complete_exit, fresh_buy
85
+ Returns: (amcs, companies, buy_map, sell_map, complete_exit_map, fresh_buy_map)
86
+ """
87
+ amcs = sorted(df[amc_col].dropna().unique().tolist())
88
+ companies = sorted(df[company_col].dropna().unique().tolist())
89
+
90
+ buy_map = defaultdict(list)
91
+ sell_map = defaultdict(list)
92
+ complete_exit = defaultdict(list)
93
+ fresh_buy = defaultdict(list)
94
+
95
+ for _, row in df.iterrows():
96
+ a = str(row[amc_col]).strip()
97
+ c = str(row[company_col]).strip()
98
+ act = str(row[action_col]).strip().lower()
99
+ if act in ("buy", "b"):
100
+ buy_map[a].append(c)
101
+ elif act in ("sell", "s"):
102
+ sell_map[a].append(c)
103
+ elif act in ("complete_exit", "exit", "complete"):
104
+ complete_exit[a].append(c)
105
+ elif act in ("fresh_buy", "fresh", "new"):
106
+ fresh_buy[a].append(c)
107
+ else:
108
+ # try to infer from words
109
+ if "sell" in act:
110
+ sell_map[a].append(c)
111
+ elif "buy" in act:
112
+ buy_map[a].append(c)
113
+ elif "exit" in act:
114
+ complete_exit[a].append(c)
115
+ else:
116
+ # default to buy if unclear
117
+ buy_map[a].append(c)
118
+
119
+ # ensure dict -> normal dict
120
+ return amcs, companies, dict(buy_map), dict(sell_map), dict(complete_exit), dict(fresh_buy)
121
+
122
+ def sanitize_map(m, companies_list):
123
  out = {}
124
  for k, vals in m.items():
125
+ out[k] = [v for v in vals if v in companies_list]
126
  return out
127
 
128
+ # default dataset packaging function
129
+ def load_default_dataset():
130
+ AMCS = DEFAULT_AMCS.copy()
131
+ COMPANIES = DEFAULT_COMPANIES.copy()
132
+ BUY_MAP = sanitize_map(SAMPLE_BUY, COMPANIES)
133
+ SELL_MAP = sanitize_map(SAMPLE_SELL, COMPANIES)
134
+ COMPLETE_EXIT = sanitize_map(SAMPLE_COMPLETE_EXIT, COMPANIES)
135
+ FRESH_BUY = sanitize_map(SAMPLE_FRESH_BUY, COMPANIES)
136
+ return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
137
+
138
+ # ---------------------------
139
+ # Inference: AMC->AMC transfers
140
+ # ---------------------------
 
 
 
 
 
 
 
 
 
141
  def infer_amc_transfers(buy_map, sell_map):
142
  transfers = defaultdict(int)
 
143
  company_to_sellers = defaultdict(list)
144
  company_to_buyers = defaultdict(list)
145
  for amc, comps in sell_map.items():
 
155
  for b in buyers:
156
  # infer s -> b transfer for this company
157
  transfers[(s,b)] += 1
 
158
  edge_list = []
159
  for (s,b), w in transfers.items():
160
  edge_list.append((s,b, {"action": "transfer", "weight": w, "company_count": w}))
161
  return edge_list
162
 
163
+ # ---------------------------
164
+ # Graph builder
165
+ # ---------------------------
166
+ def build_graph(AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, include_transfers=True):
167
  G = nx.DiGraph()
168
+ # add nodes
169
  for a in AMCS:
170
  G.add_node(a, type="amc", label=a)
 
171
  for c in COMPANIES:
172
  G.add_node(c, type="company", label=c)
173
+ # add AMC->company edges
174
+ def add_edge(a, c, action, weight=1):
175
  if not G.has_node(a) or not G.has_node(c):
176
+ return
 
177
  if G.has_edge(a,c):
178
+ G[a][c]["weight"] += weight
179
+ G[a][c]["actions"].append(action)
180
  else:
181
+ G.add_edge(a, c, weight=weight, actions=[action])
182
+ for a, comps in BUY_MAP.items():
183
+ for c in comps:
184
+ add_edge(a, c, "buy", weight=1)
185
+ for a, comps in SELL_MAP.items():
186
+ for c in comps:
187
+ add_edge(a, c, "sell", weight=1)
188
+ for a, comps in COMPLETE_EXIT.items():
189
+ for c in comps:
190
+ add_edge(a, c, "complete_exit", weight=3)
191
+ for a, comps in FRESH_BUY.items():
192
+ for c in comps:
193
+ add_edge(a, c, "fresh_buy", weight=3)
194
+ # inferred transfers (AMC->AMC)
195
  if include_transfers:
196
+ transfers = infer_amc_transfers(BUY_MAP, SELL_MAP)
197
+ for s,b,attrs in transfers:
198
  if not G.has_node(s) or not G.has_node(b):
199
  continue
200
  if G.has_edge(s,b):
 
205
  return G
206
 
207
  # ---------------------------
208
+ # Plotly visualization
209
  # ---------------------------
210
  def graph_to_plotly(G,
211
  node_color_amc="#9EC5FF",
 
216
  edge_color_sell="#d62728",
217
  edge_color_transfer="#888888",
218
  edge_thickness_base=1.2,
219
+ show_labels=True,
220
+ width=1400,
221
+ height=900):
222
+ # position: spring layout with fixed seed for reproducibility
223
+ pos = nx.spring_layout(G, seed=42, k=1.4)
224
+ # nodes
225
  node_x = []
226
  node_y = []
227
  node_text = []
228
  node_color = []
229
  node_size = []
 
230
  for n, d in G.nodes(data=True):
231
  x, y = pos[n]
232
  node_x.append(x)
 
234
  node_text.append(n)
235
  if d["type"] == "amc":
236
  node_color.append(node_color_amc)
237
+ node_size.append(44)
 
238
  else:
239
  node_color.append(node_color_company)
240
+ node_size.append(64)
 
241
 
242
  node_trace = go.Scatter(
243
  x=node_x, y=node_y,
244
  mode='markers+text' if show_labels else 'markers',
245
+ marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
 
 
 
 
246
  text=node_text if show_labels else None,
247
  textposition="top center",
248
  hoverinfo='text'
249
  )
250
 
251
+ # edges - draw each edge as a separate trace for styling
252
  edge_traces = []
253
  for u, v, attrs in G.edges(data=True):
 
 
 
254
  x0, y0 = pos[u]
255
  x1, y1 = pos[v]
256
+ actions = attrs.get("actions", [])
257
+ weight = attrs.get("weight", 1)
258
+ # priority styling
259
  if "complete_exit" in actions:
260
  color = edge_color_sell
261
  dash = "solid"
262
+ width = max(edge_thickness_base * 3.5, 3)
263
  elif "fresh_buy" in actions:
264
  color = edge_color_buy
265
  dash = "solid"
266
+ width = max(edge_thickness_base * 3.5, 3)
267
  elif "transfer" in actions:
268
  color = edge_color_transfer
269
  dash = "dash"
 
272
  color = edge_color_sell
273
  dash = "dot"
274
  width = max(edge_thickness_base * (1 + np.log1p(weight)), 1)
275
+ else: # buy or default
276
  color = edge_color_buy
277
  dash = "solid"
278
  width = max(edge_thickness_base * (1 + np.log1p(weight)), 1)
279
+
280
+ edge_traces.append(
281
+ go.Scatter(
282
+ x=[x0, x1, None],
283
+ y=[y0, y1, None],
284
+ mode='lines',
285
+ line=dict(width=width, color=color, dash=dash),
286
+ hoverinfo='text',
287
+ text=", ".join(actions)
288
+ )
289
  )
 
290
 
 
291
  fig = go.Figure(data=edge_traces + [node_trace],
292
  layout=go.Layout(
293
+ title_text="Mutual Fund Churn Network (AMCs: blue, Companies: orange)",
294
+ title_x=0.5,
295
  showlegend=False,
296
  margin=dict(b=20,l=5,r=5,t=40),
297
  xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
298
  yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
299
+ height=height,
300
+ width=width
301
  ))
302
  return fig
303
 
304
  # ---------------------------
305
+ # Analysis helpers
306
  # ---------------------------
307
+ def company_trade_summary(company_name, BUY_MAP, SELL_MAP, FRESH_BUY, COMPLETE_EXIT):
308
+ buyers = [amc for amc, comps in BUY_MAP.items() if company_name in comps]
309
+ sellers = [amc for amc, comps in SELL_MAP.items() if company_name in comps]
 
 
 
 
 
 
 
 
 
310
  fresh = [amc for amc, comps in FRESH_BUY.items() if company_name in comps]
311
  exits = [amc for amc, comps in COMPLETE_EXIT.items() if company_name in comps]
312
+
313
+ rows = []
314
+ for b in buyers:
315
+ rows.append({"Role": "Buyer", "AMC": b})
316
+ for s in sellers:
317
+ rows.append({"Role": "Seller", "AMC": s})
318
+ for f in fresh:
319
+ rows.append({"Role": "Fresh Buy", "AMC": f})
320
+ for e in exits:
321
+ rows.append({"Role": "Complete Exit", "AMC": e})
322
+
323
+ df = pd.DataFrame(rows)
324
  if df.empty:
325
+ return None, pd.DataFrame([], columns=["Role","AMC"])
326
+ counts = df['Role'].value_counts().reindex(["Buyer","Seller","Fresh Buy","Complete Exit"]).fillna(0)
327
+ colors = {"Buyer":"green","Seller":"red","Fresh Buy":"orange","Complete Exit":"black"}
328
+ bar = go.Figure()
329
+ bar.add_trace(go.Bar(x=counts.index, y=counts.values, marker_color=[colors.get(i,"grey") for i in counts.index]))
330
+ bar.update_layout(title=f"Trade Summary for {company_name}", height=360, width=700)
331
+ return bar, df
332
 
333
+ def amc_transfer_summary(amc_name, BUY_MAP, SELL_MAP):
 
 
334
  sold = SELL_MAP.get(amc_name, [])
 
335
  transfers = []
336
  for s in sold:
337
  buyers = [amc for amc, comps in BUY_MAP.items() if s in comps]
 
339
  transfers.append({"security": s, "buyer_amc": b})
340
  df = pd.DataFrame(transfers)
341
  if df.empty:
342
+ return None, pd.DataFrame([], columns=["security","buyer_amc"])
 
343
  counts = df['buyer_amc'].value_counts().reset_index()
344
  counts.columns = ['Buyer AMC', 'Count']
345
  fig = go.Figure(go.Bar(x=counts['Buyer AMC'], y=counts['Count'], marker_color='lightslategray'))
346
+ fig.update_layout(title_text=f"Inferred transfers from {amc_name}", height=360, width=700)
347
  return fig, df
348
 
349
+ # loop detection in inferred AMC->AMC graph (simple cycles up to length n)
350
+ def detect_loops(G, max_length=6):
351
+ # extract only nodes that are AMCs
352
+ amc_nodes = [n for n,d in G.nodes(data=True) if d['type']=='amc']
353
+ loops = []
354
+ # Work on a directed graph of only amc nodes with transfer edges
355
+ H = nx.DiGraph()
356
+ for u,v,d in G.edges(data=True):
357
+ if u in amc_nodes and v in amc_nodes and "transfer" in d.get("actions",[]):
358
+ H.add_edge(u,v, weight=d.get("weight",1))
359
+ # use simple cycle detection (may find many cycles)
360
+ try:
361
+ cycles = list(nx.simple_cycles(H))
362
+ except Exception:
363
+ cycles = []
364
+ # filter by max_length
365
+ for c in cycles:
366
+ if 2 <= len(c) <= max_length:
367
+ loops.append(c)
368
+ return loops
369
+
370
  # ---------------------------
371
+ # Gradio interface
372
  # ---------------------------
373
+ def build_initial_graph_and_data():
374
+ AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY = load_default_dataset()
375
+ G = build_graph(AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, include_transfers=True)
376
+ fig = graph_to_plotly(G)
377
+ return (AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, G, fig)
378
+
379
+ # Prepare initial data
380
+ (AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY, G_initial, initial_fig) = build_initial_graph_and_data()
381
+
382
  with gr.Blocks() as demo:
383
+ gr.Markdown("# Mutual Fund Churn Explorer (inferred AMC→AMC transfers)")
384
  with gr.Row():
385
  with gr.Column(scale=3):
386
+ gr.Markdown("## Controls")
387
+ csv_uploader = gr.File(label="Upload CSV (optional). Columns: AMC,Company,Action", file_types=['.csv'])
388
  node_color_company = gr.ColorPicker(value="#FFCF9E", label="Company node color")
389
  node_color_amc = gr.ColorPicker(value="#9EC5FF", label="AMC node color")
390
  node_shape_company = gr.Dropdown(choices=["circle","square","diamond"], value="circle", label="Company node shape")
 
392
  edge_color_buy = gr.ColorPicker(value="#2ca02c", label="BUY edge color")
393
  edge_color_sell = gr.ColorPicker(value="#d62728", label="SELL edge color")
394
  edge_color_transfer = gr.ColorPicker(value="#888888", label="Transfer edge color")
395
+ edge_thickness = gr.Slider(minimum=0.5, maximum=8.0, value=1.4, step=0.1, label="Edge thickness base")
396
+ include_transfers_chk = gr.Checkbox(value=True, label="Infer AMC→AMC transfers (show loops)")
397
+ update_btn = gr.Button("Update network")
398
+ gr.Markdown("## Inspect")
399
+ company_selector = gr.Dropdown(choices=COMPANIES, label="Select Company (show buyers/sellers)")
400
+ amc_selector = gr.Dropdown(choices=AMCS, label="Select AMC (inferred transfers)")
401
 
 
 
 
402
  with gr.Column(scale=7):
403
+ network_plot = gr.Plot(value=initial_fig, label="Network graph (drag to zoom)")
404
+
405
+ # outputs
406
+ company_plot = gr.Plot(label="Company trade summary")
407
+ company_table = gr.Dataframe(headers=["Role","AMC"], interactive=False, label="Trades (company)")
408
+ amc_plot = gr.Plot(label="AMC inferred transfers")
409
+ amc_table = gr.Dataframe(headers=["security","buyer_amc"], interactive=False, label="Inferred transfers (AMC)")
410
+ loops_text = gr.Markdown()
411
+
412
+ # function to load CSV if provided and build maps
413
+ def load_dataset_from_csv(file_obj):
414
+ if file_obj is None:
415
+ return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
416
+ try:
417
+ raw = file_obj.read()
418
+ if isinstance(raw, bytes):
419
+ raw = raw.decode('utf-8', errors='ignore')
420
+ df = pd.read_csv(io.StringIO(raw))
421
+ # expect columns: AMC, Company, Action
422
+ # normalize column names
423
+ cols = [c.strip().lower() for c in df.columns]
424
+ col_map = {}
425
+ for c in df.columns:
426
+ if c.strip().lower() in ("amc","fund","manager"):
427
+ col_map[c] = "AMC"
428
+ elif c.strip().lower() in ("company","security","stock"):
429
+ col_map[c] = "Company"
430
+ elif c.strip().lower() in ("action","trade","type"):
431
+ col_map[c] = "Action"
432
+ df = df.rename(columns=col_map)
433
+ required = {"AMC","Company","Action"}
434
+ if not required.issubset(set(df.columns)):
435
+ # can't parse - fallback to default
436
+ return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
437
+ amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = maps_from_dataframe(df, "AMC", "Company", "Action")
438
+ # sanitize - ensure company nodes exist
439
+ return amcs, companies, buy_map, sell_map, complete_exit, fresh_buy
440
+ except Exception as e:
441
+ print("CSV load error:", e)
442
+ return AMCS, COMPANIES, BUY_MAP, SELL_MAP, COMPLETE_EXIT, FRESH_BUY
443
+
444
+ # callback to rebuild network
445
+ def on_update(csv_file, node_color_company_val, node_color_amc_val, node_shape_company_val, node_shape_amc_val,
446
+ edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val, edge_thickness_val, include_transfers_val):
447
+ # load dataset (possibly replaced by CSV)
448
+ amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file)
449
+ # ensure inputs for dropdowns updated - but here we just create fig
450
+ G = build_graph(amcs, companies, buy_map, sell_map, complete_exit, fresh_buy, include_transfers=include_transfers_val)
451
  fig = graph_to_plotly(G,
452
  node_color_amc=node_color_amc_val,
453
  node_color_company=node_color_company_val,
 
458
  edge_color_transfer=edge_color_transfer_val,
459
  edge_thickness_base=edge_thickness_val,
460
  show_labels=True)
461
+ # detect loops and prepare a small markdown summary
462
+ loops = detect_loops(G, max_length=6)
463
+ if loops:
464
+ loops_md = "### Detected AMC transfer loops (inferred):\n"
465
+ for i, loop in enumerate(loops, 1):
466
+ loops_md += f"- Loop {i}: " + " → ".join(loop) + "\n"
 
467
  else:
468
+ loops_md = "No small transfer loops detected (based on current inferred transfer edges)."
469
+ # return fig and loops text plus update choices for dropdowns (we will update lists client-side)
470
+ return fig, loops_md, companies, amcs
471
+
472
+ update_btn.click(on_update,
473
+ inputs=[csv_uploader, node_color_company, node_color_amc, node_shape_company, node_shape_amc,
474
+ edge_color_buy, edge_color_sell, edge_color_transfer, edge_thickness, include_transfers_chk],
475
+ outputs=[network_plot, loops_text, company_selector, amc_selector])
476
+
477
+ # company select callback
478
+ def on_company_sel(company_name, csv_file):
479
+ amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file)
480
+ fig, df = company_trade_summary(company_name, buy_map, sell_map, fresh_buy, complete_exit)
481
+ if fig is None:
482
  return None, pd.DataFrame([], columns=["Role","AMC"])
483
+ return fig, df
484
 
485
+ company_selector.change(on_company_sel, inputs=[company_selector, csv_uploader], outputs=[company_plot, company_table])
486
+
487
+ # amc select callback
488
+ def on_amc_sel(amc_name, csv_file):
489
+ amcs, companies, buy_map, sell_map, complete_exit, fresh_buy = load_dataset_from_csv(csv_file)
490
+ fig, df = amc_transfer_summary(amc_name, buy_map, sell_map)
491
+ if fig is None:
492
  return None, pd.DataFrame([], columns=["security","buyer_amc"])
493
+ return fig, df
494
 
495
+ amc_selector.change(on_amc_sel, inputs=[amc_selector, csv_uploader], outputs=[amc_plot, amc_table])
 
 
 
 
 
496
 
497
+ gr.Markdown("---")
498
+ 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.")
 
 
 
499
 
500
  if __name__ == "__main__":
501
+ demo.queue().launch(share=False)