AJAYKASU commited on
Commit
2cc4819
·
verified ·
1 Parent(s): fd23e75

Feature: Banker's Advisor Upgrade (V2)

Browse files
Files changed (1) hide show
  1. main.py +134 -89
main.py CHANGED
@@ -34,6 +34,11 @@ BENCHMARK = '^GSPC'
34
  # FINANCIAL LOGIC (Ported from Streamlit App)
35
  # ==============================================================================
36
 
 
 
 
 
 
37
  def get_session():
38
  import requests
39
  session = requests.Session()
@@ -42,32 +47,37 @@ def get_session():
42
  })
43
  return session
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def fetch_market_data(tickers):
46
  try:
47
  all_tickers = tickers + [BENCHMARK]
48
- # yfinance download with session
49
- # Use simple separate fetching if bulk fails, but bulk is better.
50
- # We need to monkeypatch or use the session parameter if available in newer yf
51
- # For compatibility with 0.2.x, we assume yf handles requests internally or we just retry.
52
-
53
- # NOTE: yfinance 0.2+ is strict. We use a context manager or explicit overrides if needed.
54
- # But for 'download', it uses shared session. We can try setting it globally if needed,
55
- # but passing it to Ticker is easier. for download(), it's harder.
56
-
57
- # Workaround: Use Ticker cluster or just standard download but catch empty.
58
- # The best fix for HF Spaces is usually just a user agent.
59
-
60
- # Let's try requests_cache or just standard requests patch if yf exposes it.
61
- # yf.download doesn't accept 'session' directly in all versions.
62
- # We will try to rely on the library's default behavior but with a retry/fallback.
63
-
64
- # FALLBACK STRATEGY: Fetch individually if bulk fails
65
  data = yf.download(all_tickers, period="6mo", progress=False)
66
 
67
- if data.empty:
68
- raise ValueError("Empty data returned")
69
 
70
- if 'Adj Close' in data.columns:
 
 
 
71
  prices = data['Adj Close']
72
  elif 'Close' in data.columns:
73
  prices = data['Close']
@@ -76,78 +86,75 @@ def fetch_market_data(tickers):
76
  return prices
77
  except Exception as e:
78
  print(f"Error fetching data: {e}")
79
- # Panic Fallback: Generate dummy data if real data fails (to prevent app crash on demo)
80
- # This keeps the UI alive ("Show must go on")
81
  dates = pd.date_range(end=datetime.today(), periods=120)
82
  dummy_data = {}
83
  for t in tickers + [BENCHMARK]:
84
- # Random walk
85
  start = 150 if t == BENCHMARK else 100
86
  returns = np.random.normal(0.001, 0.02, 120)
87
- price_path = start * (1 + returns).cumprod()
88
- dummy_data[t] = price_path
89
-
90
  return pd.DataFrame(dummy_data, index=dates)
91
 
92
  def get_fundamentals(tickers):
93
  metrics = []
94
 
95
- # Backup data for demo purposes (Snapshot as of late 2025/early 2026)
96
- # This ensures the table looks professional even if yfinance 'info' is blocked in the cloud
97
  BACKUP_DATA = {
98
- 'CRM': {'marketCap': 280e9, 'forwardPE': 28.5, 'revenueGrowth': 0.11, 'beta': 1.05},
99
- 'SNOW': {'marketCap': 55e9, 'forwardPE': 45.2, 'revenueGrowth': 0.22, 'beta': 1.45},
100
- 'HUBS': {'marketCap': 32e9, 'forwardPE': 65.0, 'revenueGrowth': 0.18, 'beta': 1.25},
101
- 'NET': {'marketCap': 35e9, 'forwardPE': 85.5, 'revenueGrowth': 0.28, 'beta': 1.35},
102
- 'DDOG': {'marketCap': 42e9, 'forwardPE': 55.8, 'revenueGrowth': 0.21, 'beta': 1.40},
103
- 'SQ': {'marketCap': 45e9, 'forwardPE': 25.0, 'revenueGrowth': 0.12, 'beta': 1.60},
104
- 'PYPL': {'marketCap': 70e9, 'forwardPE': 14.5, 'revenueGrowth': 0.08, 'beta': 1.15},
105
- 'NVDA': {'marketCap': 2500e9,'forwardPE': 35.0, 'revenueGrowth': 0.90, 'beta': 1.70},
106
  }
107
 
108
  for t in tickers:
109
  try:
110
- # Try fetching live
111
  info = yf.Ticker(t).info
112
 
113
- # Helper to safely get value or fallback
114
- def get_val(key, fallback_key=None):
115
  val = info.get(key)
116
  if val is None and t in BACKUP_DATA:
117
- return BACKUP_DATA[t].get(fallback_key or key)
118
- return val
119
 
120
  m_cap = get_val('marketCap')
121
- pe = get_val('forwardPE', 'forwardPE') # Fallback if None
122
- # If PE is still None, try trailing
123
- if pe is None: pe = get_val('trailingPE')
124
-
125
  growth = get_val('revenueGrowth')
126
- beta = get_val('beta')
 
 
 
 
 
127
 
128
  metrics.append({
129
  'ticker': t,
130
- 'market_cap': m_cap if m_cap else 0,
131
- 'pe': pe if pe else 0,
132
- 'growth': growth if growth else 0,
133
- 'beta': beta if beta else 1.0
 
 
134
  })
135
- except Exception as e:
136
- # Use full backup if fetch fails
137
  if t in BACKUP_DATA:
138
  bk = BACKUP_DATA[t]
 
139
  metrics.append({
140
  'ticker': t,
141
  'market_cap': bk['marketCap'],
142
  'pe': bk['forwardPE'],
 
 
143
  'growth': bk['revenueGrowth'],
144
  'beta': bk['beta']
145
  })
146
  else:
147
- metrics.append({
148
- 'ticker': t,
149
- 'market_cap': 0, 'pe': 0, 'growth': 0, 'beta': 1.0
150
- })
151
 
152
  return metrics
153
 
@@ -155,24 +162,19 @@ def calculate_signals(prices_df, sector_tickers):
155
  signals = {}
156
  returns = prices_df.pct_change().dropna()
157
 
158
- # Check data sufficiency
159
- if len(prices_df) < 30:
160
- return {}
161
 
162
  # Benchmark returns
163
  sp500_ret = returns[BENCHMARK] if BENCHMARK in returns.columns else pd.Series(0, index=returns.index)
164
  momentum_spx = (prices_df[BENCHMARK].iloc[-1] / prices_df[BENCHMARK].iloc[-30] - 1) * 100 if BENCHMARK in prices_df.columns else 0
165
 
166
- # Volatility
167
  volatility = returns.rolling(window=30).std() * np.sqrt(252) * 100
168
  current_vol = volatility.iloc[-1]
169
-
170
- # Momentum 30d
171
  momentum = (prices_df.iloc[-1] / prices_df.iloc[-30] - 1) * 100
172
 
173
  for t in sector_tickers:
174
  if t in returns.columns:
175
- # Beta
176
  cov = returns[t].cov(sp500_ret)
177
  var = sp500_ret.var()
178
  beta = cov / var if var != 0 else 1.0
@@ -185,19 +187,59 @@ def calculate_signals(prices_df, sector_tickers):
185
  }
186
  return signals
187
 
188
- def generate_recommendation(signals):
189
- if not signals:
190
- return 28.0, 32.0, "NEUTRAL"
191
-
 
 
192
  avg_mom = np.mean([v['momentum'] for v in signals.values()])
193
  avg_vol = np.mean([v['volatility'] for v in signals.values()])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
- if avg_mom > 5 and avg_vol < 40:
196
- return 32.0, 36.0, "BULLISH"
197
- elif avg_mom < -5:
198
- return 24.0, 28.0, "BEARISH"
 
 
 
 
 
 
 
 
199
  else:
200
- return 28.0, 32.0, "NEUTRAL"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
  # ==============================================================================
203
  # ROUTES
@@ -212,7 +254,9 @@ async def health_check():
212
  return {"status": "ok"}
213
 
214
  @app.post("/analyze")
215
- async def analyze(request: Request, query: str = Form(...), sector_override: str = Form(None)):
 
 
216
 
217
  # 1. Determine Sector
218
  sector_key = 'SaaS'
@@ -223,20 +267,22 @@ async def analyze(request: Request, query: str = Form(...), sector_override: str
223
 
224
  target_tickers = SECTOR_PROXIES.get(sector_key, SECTOR_PROXIES['SaaS'])
225
 
226
- # 2. Fetch Data
227
  prices = fetch_market_data(target_tickers)
 
 
228
  if prices.empty:
229
  return JSONResponse(status_code=500, content={"error": "Failed to fetch market data"})
230
 
231
- # 3. Calculations
232
  signals = calculate_signals(prices, target_tickers)
233
- low, high, sentiment = generate_recommendation(signals)
234
  fundamentals = get_fundamentals(target_tickers)
235
 
236
- # 4. Prepare Chart Data (Time Series)
237
- # Normalize to 100
238
- normalized = prices / prices.iloc[0] * 100
239
 
 
 
240
  chart_data = []
241
  for col in normalized.columns:
242
  if col in target_tickers or col == BENCHMARK:
@@ -248,21 +294,20 @@ async def analyze(request: Request, query: str = Form(...), sector_override: str
248
  'mode': 'lines'
249
  })
250
 
251
- # 5. Prepare Response
252
  response_data = {
253
  'sector': sector_key,
254
- 'recommendation': {
255
- 'low': low,
256
- 'high': high,
257
- 'sentiment': sentiment
258
- },
259
- 'metrics': { # Averages for cards
260
  'avg_momentum': np.mean([s['momentum'] for s in signals.values()]) if signals else 0,
261
  'avg_beta': np.mean([s['beta'] for s in signals.values()]) if signals else 0,
262
- 'avg_vol': np.mean([s['volatility'] for s in signals.values()]) if signals else 0
 
 
263
  },
264
- 'chart_json': chart_data, # Passed raw to Plotly.js
265
- 'comparables': fundamentals, # Table data
266
  'signals': signals
267
  }
268
 
 
34
  # FINANCIAL LOGIC (Ported from Streamlit App)
35
  # ==============================================================================
36
 
37
+
38
+ # ==============================================================================
39
+ # FINANCIAL LOGIC
40
+ # ==============================================================================
41
+
42
  def get_session():
43
  import requests
44
  session = requests.Session()
 
47
  })
48
  return session
49
 
50
+ def fetch_macro_data():
51
+ """Fetches VIX and 10Y Treasury Yield"""
52
+ try:
53
+ # ^VIX: Volatility, ^TNX: 10Y Yield
54
+ macro = yf.download(['^VIX', '^TNX'], period="5d", progress=False)
55
+ if macro.empty: return {'vix': 15.0, 'tnx': 4.0} # Fallback
56
+
57
+ # Handle MultiIndex columns if present
58
+ if isinstance(macro.columns, pd.MultiIndex):
59
+ vix = macro['Adj Close']['^VIX'].iloc[-1] if '^VIX' in macro['Adj Close'] else 15.0
60
+ tnx = macro['Adj Close']['^TNX'].iloc[-1] if '^TNX' in macro['Adj Close'] else 4.0
61
+ else:
62
+ # Fallback for flat structure/failures
63
+ vix = macro['Adj Close'].iloc[-1] if 'Adj Close' in macro else 15.0
64
+ tnx = 4.0
65
+
66
+ return {'vix': float(vix), 'tnx': float(tnx)}
67
+ except:
68
+ return {'vix': 18.5, 'tnx': 4.2} # Safe defaults
69
+
70
  def fetch_market_data(tickers):
71
  try:
72
  all_tickers = tickers + [BENCHMARK]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  data = yf.download(all_tickers, period="6mo", progress=False)
74
 
75
+ if data.empty: raise ValueError("Empty data returned")
 
76
 
77
+ # Handle yfinance 0.2+ MultiIndex columns
78
+ if isinstance(data.columns, pd.MultiIndex):
79
+ prices = data['Adj Close']
80
+ elif 'Adj Close' in data.columns:
81
  prices = data['Adj Close']
82
  elif 'Close' in data.columns:
83
  prices = data['Close']
 
86
  return prices
87
  except Exception as e:
88
  print(f"Error fetching data: {e}")
89
+ # Panic Fallback
 
90
  dates = pd.date_range(end=datetime.today(), periods=120)
91
  dummy_data = {}
92
  for t in tickers + [BENCHMARK]:
 
93
  start = 150 if t == BENCHMARK else 100
94
  returns = np.random.normal(0.001, 0.02, 120)
95
+ dummy_data[t] = start * (1 + returns).cumprod()
 
 
96
  return pd.DataFrame(dummy_data, index=dates)
97
 
98
  def get_fundamentals(tickers):
99
  metrics = []
100
 
101
+ # ADVANCED BACKUP DATA (Rule of 40, EV/Rev included)
 
102
  BACKUP_DATA = {
103
+ 'CRM': {'marketCap': 280e9, 'forwardPE': 28.5, 'revenueGrowth': 0.11, 'margins': 0.18, 'evToRev': 6.5, 'beta': 1.05},
104
+ 'SNOW': {'marketCap': 55e9, 'forwardPE': 45.2, 'revenueGrowth': 0.22, 'margins': -0.05, 'evToRev': 12.0, 'beta': 1.45},
105
+ 'HUBS': {'marketCap': 32e9, 'forwardPE': 65.0, 'revenueGrowth': 0.18, 'margins': -0.02, 'evToRev': 9.5, 'beta': 1.25},
106
+ 'NET': {'marketCap': 35e9, 'forwardPE': 85.5, 'revenueGrowth': 0.28, 'margins': -0.03, 'evToRev': 14.2, 'beta': 1.35},
107
+ 'DDOG': {'marketCap': 42e9, 'forwardPE': 55.8, 'revenueGrowth': 0.21, 'margins': 0.02, 'evToRev': 11.5, 'beta': 1.40},
108
+ 'SQ': {'marketCap': 45e9, 'forwardPE': 25.0, 'revenueGrowth': 0.12, 'margins': 0.05, 'evToRev': 3.5, 'beta': 1.60},
109
+ 'PYPL': {'marketCap': 70e9, 'forwardPE': 14.5, 'revenueGrowth': 0.08, 'margins': 0.16, 'evToRev': 2.8, 'beta': 1.15},
110
+ 'NVDA': {'marketCap': 2500e9,'forwardPE': 35.0, 'revenueGrowth': 0.90, 'margins': 0.55, 'evToRev': 25.0, 'beta': 1.70},
111
  }
112
 
113
  for t in tickers:
114
  try:
 
115
  info = yf.Ticker(t).info
116
 
117
+ def get_val(key, default=0.0):
 
118
  val = info.get(key)
119
  if val is None and t in BACKUP_DATA:
120
+ return BACKUP_DATA[t].get(key, default)
121
+ return val if val is not None else default
122
 
123
  m_cap = get_val('marketCap')
124
+ pe = get_val('forwardPE') or get_val('trailingPE')
 
 
 
125
  growth = get_val('revenueGrowth')
126
+ margins = get_val('profitMargins')
127
+ ev_rev = get_val('enterpriseToRevenue')
128
+ beta = get_val('beta', 1.0)
129
+
130
+ # Rule of 40: Growth % + Margin % (e.g., 0.20 + 0.10 => 30)
131
+ rule_40 = (growth + margins) * 100
132
 
133
  metrics.append({
134
  'ticker': t,
135
+ 'market_cap': m_cap,
136
+ 'pe': pe,
137
+ 'ev_rev': ev_rev,
138
+ 'rule_40': rule_40,
139
+ 'growth': growth,
140
+ 'beta': beta
141
  })
142
+ except Exception:
143
+ # Fallback
144
  if t in BACKUP_DATA:
145
  bk = BACKUP_DATA[t]
146
+ rule_40 = (bk['revenueGrowth'] + bk['margins']) * 100
147
  metrics.append({
148
  'ticker': t,
149
  'market_cap': bk['marketCap'],
150
  'pe': bk['forwardPE'],
151
+ 'ev_rev': bk['evToRev'],
152
+ 'rule_40': rule_40,
153
  'growth': bk['revenueGrowth'],
154
  'beta': bk['beta']
155
  })
156
  else:
157
+ metrics.append({'ticker': t, 'market_cap': 0, 'pe': 0, 'ev_rev': 0, 'rule_40': 0, 'growth': 0, 'beta': 1.0})
 
 
 
158
 
159
  return metrics
160
 
 
162
  signals = {}
163
  returns = prices_df.pct_change().dropna()
164
 
165
+ if len(prices_df) < 30: return {}
 
 
166
 
167
  # Benchmark returns
168
  sp500_ret = returns[BENCHMARK] if BENCHMARK in returns.columns else pd.Series(0, index=returns.index)
169
  momentum_spx = (prices_df[BENCHMARK].iloc[-1] / prices_df[BENCHMARK].iloc[-30] - 1) * 100 if BENCHMARK in prices_df.columns else 0
170
 
171
+ # Volatility & Momentum
172
  volatility = returns.rolling(window=30).std() * np.sqrt(252) * 100
173
  current_vol = volatility.iloc[-1]
 
 
174
  momentum = (prices_df.iloc[-1] / prices_df.iloc[-30] - 1) * 100
175
 
176
  for t in sector_tickers:
177
  if t in returns.columns:
 
178
  cov = returns[t].cov(sp500_ret)
179
  var = sp500_ret.var()
180
  beta = cov / var if var != 0 else 1.0
 
187
  }
188
  return signals
189
 
190
+ def generate_advisory(signals, macro, fundamentals, last_private_price):
191
+ """
192
+ The Brain: Generates the Executive Commentary and Pricing Advice
193
+ """
194
+ if not signals: return {}
195
+
196
  avg_mom = np.mean([v['momentum'] for v in signals.values()])
197
  avg_vol = np.mean([v['volatility'] for v in signals.values()])
198
+ avg_ev_rev = np.mean([f['ev_rev'] for f in fundamentals if f['ev_rev'] > 0])
199
+
200
+ # 1. PRICING LOGIC
201
+ # Base multiple derived from average peer EV/Rev with a hairut
202
+ # Rough calc: If market expects x10, IPO discount is usually 10-15%
203
+ # This is a synthetic range for demo
204
+ base_price = 30.0 # Anchor
205
+
206
+ if avg_mom > 5: base_price += 4.0
207
+ if avg_vol > 40: base_price -= 3.0
208
+ if macro['vix'] > 20: base_price -= 3.0
209
+ if macro['tnx'] > 4.5: base_price -= 2.0
210
+
211
+ low_px = base_price - 2.0
212
+ high_px = base_price + 2.0
213
 
214
+ # 2. MARKET WINDOW LOGIC
215
+ window_status = "OPEN"
216
+ window_color = "green"
217
+
218
+ if macro['vix'] > 25:
219
+ window_status = "CLOSED"
220
+ window_color = "red"
221
+ advice_text = "Market volatility (VIX > 25) indicates a closed issuance window. Severe price dislocation risk."
222
+ elif avg_mom < -5 or macro['vix'] > 20:
223
+ window_status = "CAUTION"
224
+ window_color = "orange"
225
+ advice_text = "Headwinds present. Buy-side demand is highly selective. Recommend widening the price talk."
226
  else:
227
+ advice_text = "Constructive backdrop. Strong peer momentum and stable volatility support a premium valuation."
228
+
229
+ # 3. DOWN-ROUND DETECTOR
230
+ down_round_alert = False
231
+ if last_private_price and float(last_private_price) > high_px:
232
+ down_round_alert = True
233
+ advice_text += f"<br><br><b>⚠️ DOWN-ROUND RISK:</b> Implied range (${low_px}-${high_px}) is below last private mark (${last_private_price}). Expect significant cap table friction."
234
+
235
+ return {
236
+ 'low': round(low_px, 2),
237
+ 'high': round(high_px, 2),
238
+ 'sentiment': window_status,
239
+ 'color': window_color,
240
+ 'commentary': advice_text,
241
+ 'avg_ev_rev': avg_ev_rev
242
+ }
243
 
244
  # ==============================================================================
245
  # ROUTES
 
254
  return {"status": "ok"}
255
 
256
  @app.post("/analyze")
257
+ async def analyze(request: Request,
258
+ query: str = Form(...),
259
+ last_private: str = Form(None)):
260
 
261
  # 1. Determine Sector
262
  sector_key = 'SaaS'
 
267
 
268
  target_tickers = SECTOR_PROXIES.get(sector_key, SECTOR_PROXIES['SaaS'])
269
 
270
+ # 2. Fetch Data (Market + Macro)
271
  prices = fetch_market_data(target_tickers)
272
+ macro = fetch_macro_data()
273
+
274
  if prices.empty:
275
  return JSONResponse(status_code=500, content={"error": "Failed to fetch market data"})
276
 
277
+ # 3. Core Calculations
278
  signals = calculate_signals(prices, target_tickers)
 
279
  fundamentals = get_fundamentals(target_tickers)
280
 
281
+ # 4. The Advisor Engine
282
+ advisory = generate_advisory(signals, macro, fundamentals, last_private)
 
283
 
284
+ # 5. Chart Data
285
+ normalized = prices / prices.iloc[0] * 100
286
  chart_data = []
287
  for col in normalized.columns:
288
  if col in target_tickers or col == BENCHMARK:
 
294
  'mode': 'lines'
295
  })
296
 
297
+ # 6. Response
298
  response_data = {
299
  'sector': sector_key,
300
+ 'advisory': advisory,
301
+ 'macro': macro,
302
+ 'metrics': {
 
 
 
303
  'avg_momentum': np.mean([s['momentum'] for s in signals.values()]) if signals else 0,
304
  'avg_beta': np.mean([s['beta'] for s in signals.values()]) if signals else 0,
305
+ 'avg_vol': np.mean([s['volatility'] for s in signals.values()]) if signals else 0,
306
+ 'avg_ev_rev': advisory['avg_ev_rev'],
307
+ 'avg_rule_40': np.mean([f['rule_40'] for f in fundamentals]) if fundamentals else 0
308
  },
309
+ 'chart_json': chart_data,
310
+ 'comparables': fundamentals,
311
  'signals': signals
312
  }
313