Spaces:
Running
Running
Feature: Banker's Advisor Upgrade (V2)
Browse files
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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 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(
|
| 118 |
-
return val
|
| 119 |
|
| 120 |
m_cap = get_val('marketCap')
|
| 121 |
-
pe = get_val('forwardPE'
|
| 122 |
-
# If PE is still None, try trailing
|
| 123 |
-
if pe is None: pe = get_val('trailingPE')
|
| 124 |
-
|
| 125 |
growth = get_val('revenueGrowth')
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
metrics.append({
|
| 129 |
'ticker': t,
|
| 130 |
-
'market_cap': m_cap
|
| 131 |
-
'pe': pe
|
| 132 |
-
'
|
| 133 |
-
'
|
|
|
|
|
|
|
| 134 |
})
|
| 135 |
-
except Exception
|
| 136 |
-
#
|
| 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 |
-
|
| 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
|
| 189 |
-
|
| 190 |
-
|
| 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 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
else:
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
| 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.
|
| 237 |
-
|
| 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 |
-
#
|
| 252 |
response_data = {
|
| 253 |
'sector': sector_key,
|
| 254 |
-
'
|
| 255 |
-
|
| 256 |
-
|
| 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,
|
| 265 |
-
'comparables': fundamentals,
|
| 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 |
|