hwdevelops/equitylens
0
1"""
2app.py โ Streamlit web dashboard for EquityLens.
3
4A professional stock analysis web app combining technical screening,
5fundamental analysis, DCF valuation, Monte Carlo simulation,
6and FinBERT news sentiment analysis.
7"""
8
9import streamlit as st
10import plotly.graph_objects as go
11import pandas as pd
12import numpy as np
13
14from modules.screener import screen_ticker
15from modules.fundamentals import analyze_ticker, calculate_analyst_rating
16from modules.dcf import run_dcf_analysis, run_monte_carlo
17from modules.sentiment import analyze_sentiment
18
19# Page Configuration
20st.set_page_config(
21 page_title="EquityLens",
22 page_icon="๐",
23 layout="wide",
24 initial_sidebar_state="collapsed"
25)
26
27# Custom CSS
28st.markdown("""
29<style>
30 .stApp { background-color: #0a0e1a; color: #ffffff; }
31 .metric-card {
32 background: linear-gradient(135deg, #1a1f35 0%, #0d1225 100%);
33 border: 1px solid #2a3050; border-radius: 12px;
34 padding: 20px; text-align: center; margin: 5px;
35 }
36 .metric-label {
37 color: #8892b0; font-size: 12px; font-weight: 600;
38 text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;
39 }
40 .metric-value { color: #ffffff; font-size: 24px; font-weight: 700; }
41 .metric-positive { color: #00d4aa; }
42 .metric-negative { color: #ff4757; }
43 .metric-neutral { color: #ffd700; }
44 .signal-badge {
45 display: inline-block; padding: 8px 24px;
46 border-radius: 50px; font-size: 18px; font-weight: 800;
47 letter-spacing: 2px; text-transform: uppercase;
48 }
49 .signal-strong-buy { background: #00d4aa22; color: #00d4aa; border: 2px solid #00d4aa; }
50 .signal-buy { background: #00ff8822; color: #00ff88; border: 2px solid #00ff88; }
51 .signal-hold { background: #ffd70022; color: #ffd700; border: 2px solid #ffd700; }
52 .signal-sell { background: #ff475722; color: #ff4757; border: 2px solid #ff4757; }
53 .signal-strong-sell { background: #ff000022; color: #ff0000; border: 2px solid #ff0000; }
54 .section-header {
55 color: #64ffda; font-size: 14px; font-weight: 700;
56 text-transform: uppercase; letter-spacing: 2px;
57 border-bottom: 1px solid #2a3050; padding-bottom: 8px;
58 margin-bottom: 16px;
59 }
60 #MainMenu { visibility: hidden; }
61 footer { visibility: hidden; }
62 header { visibility: hidden; }
63</style>
64""", unsafe_allow_html=True)
65
66
67def get_signal_class(signal: str) -> str:
68 return "signal-" + signal.lower().replace(" ", "-")
69
70
71def get_overall_signal(screener, fundamentals, dcf):
72 score = 0
73 tech_signal = screener.get("overall_signal", "neutral")
74 if tech_signal == "strong_buy": score += 30
75 elif tech_signal == "buy": score += 15
76 elif tech_signal == "sell": score -= 15
77 elif tech_signal == "strong_sell": score -= 30
78
79 fund_score = fundamentals.get("fundamental_score", 3)
80 score += (fund_score - 3) * 10
81
82 base_mos = dcf.get("base", {}).get("margin_of_safety", 0)
83 if base_mos is not None:
84 if base_mos > 20: score += 40
85 elif base_mos > 0: score += 20
86 elif base_mos > -20: score -= 20
87 else: score -= 40
88
89 if score >= 30: return "STRONG BUY"
90 elif score >= 10: return "BUY"
91 elif score >= -10: return "HOLD"
92 elif score >= -30: return "SELL"
93 else: return "STRONG SELL"
94
95
96def render_header():
97 col1, col2, col3 = st.columns([1, 2, 1])
98 with col2:
99 st.markdown("""
100 <div style='text-align: center; padding: 40px 0 20px 0;'>
101 <h1 style='color: #64ffda; font-size: 48px; font-weight: 900;
102 letter-spacing: 4px; margin: 0;'>
103 EQUITY<span style='color: #ffffff;'>LENS</span>
104 </h1>
105 <p style='color: #8892b0; font-size: 14px; letter-spacing: 2px;
106 margin-top: 8px;'>
107 QUANTITATIVE STOCK ANALYSIS PLATFORM
108 </p>
109 </div>
110 """, unsafe_allow_html=True)
111
112 ticker = st.text_input(
113 "",
114 placeholder="Enter a stock ticker (e.g. AAPL, MSFT, NVDA)",
115 key="ticker_input"
116 ).upper().strip()
117
118 analyze = st.button("ANALYZE", use_container_width=True, type="primary")
119
120 return ticker, analyze
121
122
123def render_signal_banner(signal, ticker, price):
124 signal_class = get_signal_class(signal)
125 color = {
126 "STRONG BUY": "#00d4aa",
127 "BUY": "#00ff88",
128 "HOLD": "#ffd700",
129 "SELL": "#ff4757",
130 "STRONG SELL": "#ff0000"
131 }.get(signal, "#ffffff")
132
133 st.markdown(f"""
134 <div style='background: linear-gradient(135deg, #1a1f35, #0d1225);
135 border: 1px solid {color}33; border-left: 4px solid {color};
136 border-radius: 12px; padding: 24px 32px;
137 display: flex; align-items: center;
138 justify-content: space-between; margin: 20px 0;'>
139 <div>
140 <div style='color: #8892b0; font-size: 12px;
141 letter-spacing: 2px;'>ANALYZING</div>
142 <div style='color: #ffffff; font-size: 32px;
143 font-weight: 900;'>{ticker}</div>
144 </div>
145 <div style='text-align: center;'>
146 <div style='color: #8892b0; font-size: 12px;
147 letter-spacing: 2px;'>CURRENT PRICE</div>
148 <div style='color: #ffd700; font-size: 32px;
149 font-weight: 700;'>${price}</div>
150 </div>
151 <div style='text-align: right;'>
152 <div style='color: #8892b0; font-size: 12px;
153 letter-spacing: 2px; margin-bottom: 8px;'>
154 OVERALL SIGNAL</div>
155 <span class='signal-badge {signal_class}'>{signal}</span>
156 </div>
157 </div>
158 """, unsafe_allow_html=True)
159
160
161def render_price_chart(ticker: str):
162 """Render candlestick chart with MA and RSI panels."""
163 import yfinance as yf
164 from plotly.subplots import make_subplots
165
166 st.markdown(
167 "<div class='section-header'>Price History and Technical Chart</div>",
168 unsafe_allow_html=True
169 )
170
171 df = yf.Ticker(ticker).history(period="1y")
172 if df.empty:
173 st.warning("No price data available.")
174 return
175
176 df["MA50"] = df["Close"].rolling(50).mean()
177 df["MA200"] = df["Close"].rolling(200).mean()
178 delta = df["Close"].diff()
179 gain = delta.where(delta > 0, 0).rolling(14).mean()
180 loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
181 rs = gain / loss
182 df["RSI"] = 100 - (100 / (1 + rs))
183
184 fig = make_subplots(
185 rows=3, cols=1, shared_xaxes=True,
186 vertical_spacing=0.03, row_heights=[0.6, 0.2, 0.2]
187 )
188
189 fig.add_trace(go.Candlestick(
190 x=df.index, open=df["Open"], high=df["High"],
191 low=df["Low"], close=df["Close"], name=ticker,
192 increasing_line_color="#00d4aa",
193 decreasing_line_color="#ff4757"
194 ), row=1, col=1)
195
196 fig.add_trace(go.Scatter(
197 x=df.index, y=df["MA50"], name="50-Day MA",
198 line=dict(color="#ffd700", width=1.5)
199 ), row=1, col=1)
200
201 fig.add_trace(go.Scatter(
202 x=df.index, y=df["MA200"], name="200-Day MA",
203 line=dict(color="#00bfff", width=1.5)
204 ), row=1, col=1)
205
206 colors = ["#00d4aa" if c >= o else "#ff4757"
207 for c, o in zip(df["Close"], df["Open"])]
208 fig.add_trace(go.Bar(
209 x=df.index, y=df["Volume"], name="Volume",
210 marker_color=colors, opacity=0.7
211 ), row=2, col=1)
212
213 fig.add_trace(go.Scatter(
214 x=df.index, y=df["RSI"], name="RSI (14)",
215 line=dict(color="#a78bfa", width=1.5)
216 ), row=3, col=1)
217
218 fig.add_hline(y=70, line_dash="dash", line_color="#ff4757",
219 opacity=0.5, row=3, col=1)
220 fig.add_hline(y=30, line_dash="dash", line_color="#00d4aa",
221 opacity=0.5, row=3, col=1)
222
223 fig.update_layout(
224 paper_bgcolor="#0a0e1a", plot_bgcolor="#0a0e1a",
225 font_color="#ffffff", height=700, showlegend=True,
226 legend=dict(bgcolor="#1a1f35", bordercolor="#2a3050"),
227 xaxis_rangeslider_visible=False,
228 margin=dict(l=0, r=0, t=20, b=0)
229 )
230 fig.update_xaxes(gridcolor="#1a1f35", zerolinecolor="#2a3050")
231 fig.update_yaxes(gridcolor="#1a1f35", zerolinecolor="#2a3050")
232 st.plotly_chart(fig, use_container_width=True)
233
234
235def render_analyst_ratings(ticker: str):
236 """Display analyst consensus and price targets."""
237 import yfinance as yf
238
239 st.markdown(
240 "<div class='section-header'>Analyst Consensus</div>",
241 unsafe_allow_html=True
242 )
243
244 try:
245 info = yf.Ticker(ticker).info
246 current_price = info.get("currentPrice", 0)
247 target_mean = info.get("targetMeanPrice")
248 target_high = info.get("targetHighPrice")
249 target_low = info.get("targetLowPrice")
250 recommendation = info.get("recommendationKey", "N/A").upper()
251 num_analysts = info.get("numberOfAnalystOpinions", "N/A")
252
253 upside = ((target_mean - current_price) / current_price * 100
254 if target_mean else None)
255
256 rec_color = (
257 "#00d4aa" if recommendation in ["STRONG_BUY", "BUY"]
258 else "#ffd700" if recommendation == "HOLD"
259 else "#ff4757"
260 )
261
262 col1, col2, col3, col4 = st.columns(4)
263
264 with col1:
265 st.markdown(
266 f"<div class='metric-card'>"
267 f"<div class='metric-label'>Consensus</div>"
268 f"<div class='metric-value' style='color: {rec_color}; "
269 f"font-size: 18px;'>{recommendation}</div>"
270 f"<div style='color: #8892b0; font-size: 12px; "
271 f"margin-top: 4px;'>{num_analysts} analysts</div>"
272 f"</div>",
273 unsafe_allow_html=True
274 )
275
276 with col2:
277 st.markdown(
278 f"<div class='metric-card'>"
279 f"<div class='metric-label'>Price Target</div>"
280 f"<div class='metric-value'>${target_mean:.2f}</div>"
281 f"<div style='color: #8892b0; font-size: 12px; "
282 f"margin-top: 4px;'>Consensus mean</div>"
283 f"</div>",
284 unsafe_allow_html=True
285 )
286
287 with col3:
288 upside_color = "#00d4aa" if upside and upside > 0 else "#ff4757"
289 upside_text = f"{upside:+.1f}%" if upside else "N/A"
290 st.markdown(
291 f"<div class='metric-card'>"
292 f"<div class='metric-label'>Upside to Target</div>"
293 f"<div class='metric-value' style='color: {upside_color};'>"
294 f"{upside_text}</div>"
295 f"<div style='color: #8892b0; font-size: 12px; "
296 f"margin-top: 4px;'>vs current price</div>"
297 f"</div>",
298 unsafe_allow_html=True
299 )
300
301 with col4:
302 low = f"${target_low:.0f}" if target_low else "N/A"
303 high = f"${target_high:.0f}" if target_high else "N/A"
304 st.markdown(
305 f"<div class='metric-card'>"
306 f"<div class='metric-label'>Target Range</div>"
307 f"<div class='metric-value' style='font-size: 16px;'>"
308 f"{low} - {high}</div>"
309 f"<div style='color: #8892b0; font-size: 12px; "
310 f"margin-top: 4px;'>Low / High</div>"
311 f"</div>",
312 unsafe_allow_html=True
313 )
314
315 except Exception as e:
316 st.warning(f"Analyst data unavailable for {ticker}")
317
318
319def render_technical(screener: dict):
320 """Render the technical signals section."""
321 st.markdown(
322 "<div class='section-header'>Technical Signals</div>",
323 unsafe_allow_html=True
324 )
325
326 col1, col2, col3 = st.columns(3)
327
328 rsi = screener.get("rsi", "N/A")
329 rsi_signal = screener.get("rsi_signal", "neutral")
330 rsi_color = (
331 "metric-negative" if rsi_signal == "overbought"
332 else "metric-positive" if rsi_signal == "oversold"
333 else "metric-neutral"
334 )
335
336 ma_signal = screener.get("ma_signal", "neutral")
337 ma_color = (
338 "metric-positive" if ma_signal == "golden_cross"
339 else "metric-negative" if ma_signal == "death_cross"
340 else "metric-neutral"
341 )
342
343 vol_spike = screener.get("volume_spike", False)
344 vol_color = "metric-positive" if vol_spike else "metric-neutral"
345
346 with col1:
347 st.markdown(
348 f"<div class='metric-card'>"
349 f"<div class='metric-label'>RSI (14 Day)</div>"
350 f"<div class='metric-value {rsi_color}'>{rsi}</div>"
351 f"<div style='color: #8892b0; font-size: 12px; "
352 f"margin-top: 4px;'>{rsi_signal.upper()}</div>"
353 f"</div>",
354 unsafe_allow_html=True
355 )
356
357 with col2:
358 ma_label = (
359 "GOLDEN CROSS" if ma_signal == "golden_cross"
360 else "DEATH CROSS" if ma_signal == "death_cross"
361 else "NEUTRAL"
362 )
363 st.markdown(
364 f"<div class='metric-card'>"
365 f"<div class='metric-label'>Moving Average</div>"
366 f"<div class='metric-value {ma_color}' "
367 f"style='font-size: 16px;'>{ma_label}</div>"
368 f"<div style='color: #8892b0; font-size: 12px; "
369 f"margin-top: 4px;'>50d: {screener.get('ma_50')} / "
370 f"200d: {screener.get('ma_200')}</div>"
371 f"</div>",
372 unsafe_allow_html=True
373 )
374
375 with col3:
376 st.markdown(
377 f"<div class='metric-card'>"
378 f"<div class='metric-label'>Volume Spike</div>"
379 f"<div class='metric-value {vol_color}'>"
380 f"{'YES' if vol_spike else 'NO'}</div>"
381 f"<div style='color: #8892b0; font-size: 12px; "
382 f"margin-top: 4px;'>"
383 f"{int(screener.get('current_volume', 0)):,} shares</div>"
384 f"</div>",
385 unsafe_allow_html=True
386 )
387
388
389def render_fundamentals(fundamentals: dict):
390 """Render the fundamental analysis section."""
391 st.markdown(
392 "<div class='section-header'>Fundamental Analysis</div>",
393 unsafe_allow_html=True
394 )
395
396 metrics = [
397 ("P/E Ratio", fundamentals.get("pe_ratio"), ""),
398 ("EV/EBITDA", fundamentals.get("ev_ebitda"), ""),
399 ("Debt/Equity", fundamentals.get("debt_equity"), ""),
400 ("Current Ratio", fundamentals.get("current_ratio"), ""),
401 ("Gross Margin", fundamentals.get("gross_margin"), "%"),
402 ("Revenue CAGR", fundamentals.get("revenue_cagr"), "%"),
403 ("Beta", fundamentals.get("beta"), ""),
404 ]
405
406 cols = st.columns(7)
407 for i, (label, value, suffix) in enumerate(metrics):
408 with cols[i]:
409 display = f"{value}{suffix}" if value is not None else "N/A"
410 st.markdown(
411 f"<div class='metric-card'>"
412 f"<div class='metric-label'>{label}</div>"
413 f"<div class='metric-value' style='font-size: 20px;'>"
414 f"{display}</div>"
415 f"</div>",
416 unsafe_allow_html=True
417 )
418
419 score = fundamentals.get("fundamental_score", "N/A")
420 score_color = (
421 "#00d4aa" if isinstance(score, int) and score >= 4
422 else "#ffd700" if isinstance(score, int) and score == 3
423 else "#ff4757"
424 )
425 st.markdown(
426 f"<div style='text-align: center; margin-top: 16px;'>"
427 f"<span style='color: #8892b0; font-size: 12px; "
428 f"letter-spacing: 2px;'>FUNDAMENTAL SCORE </span>"
429 f"<span style='color: {score_color}; font-size: 24px; "
430 f"font-weight: 700;'>{score} / 5</span>"
431 f"</div>",
432 unsafe_allow_html=True
433 )
434
435
436def render_analyst_rating(rating: dict):
437 """Display the 0-10 analyst rating with category breakdown."""
438 if not rating:
439 return
440
441 score = rating.get("score", 0)
442 label = rating.get("label", "N/A")
443 breakdown = rating.get("breakdown", {})
444
445 color = (
446 "#00d4aa" if label in ["STRONG BUY", "BUY"]
447 else "#ffd700" if label == "HOLD"
448 else "#ff4757"
449 )
450
451 st.markdown(
452 "<div class='section-header'>EquityLens Analyst Rating</div>",
453 unsafe_allow_html=True
454 )
455
456 col1, col2 = st.columns([1, 2])
457
458 with col1:
459 st.markdown(
460 f"<div class='metric-card' style='border-top: 3px solid {color}; "
461 f"text-align: center; padding: 30px;'>"
462 f"<div class='metric-label'>ANALYST RATING</div>"
463 f"<div style='color: {color}; font-size: 64px; "
464 f"font-weight: 900; line-height: 1;'>{score}</div>"
465 f"<div style='color: #8892b0; font-size: 12px; "
466 f"margin: 4px 0;'>OUT OF 10</div>"
467 f"<div style='color: {color}; font-size: 18px; "
468 f"font-weight: 700; margin-top: 8px;'>{label}</div>"
469 f"</div>",
470 unsafe_allow_html=True
471 )
472
473 with col2:
474 categories = [
475 ("Valuation", breakdown.get("valuation", 0), 3),
476 ("Business Quality", breakdown.get("quality", 0), 3),
477 ("Momentum", breakdown.get("momentum", 0), 2),
478 ("Risk", breakdown.get("risk", 0), 2),
479 ]
480
481 for cat_name, cat_score, cat_max in categories:
482 fill = (cat_score / cat_max) * 100
483 bar_color = (
484 "#00d4aa" if fill >= 66
485 else "#ffd700" if fill >= 33
486 else "#ff4757"
487 )
488 st.markdown(
489 f"<div style='margin-bottom: 16px;'>"
490 f"<div style='display: flex; justify-content: space-between; "
491 f"margin-bottom: 4px;'>"
492 f"<span style='color: #8892b0; font-size: 12px; "
493 f"font-weight: 600; text-transform: uppercase; "
494 f"letter-spacing: 1px;'>{cat_name}</span>"
495 f"<span style='color: {bar_color}; font-size: 12px; "
496 f"font-weight: 700;'>{cat_score}/{cat_max}</span>"
497 f"</div>"
498 f"<div style='background: #1a1f35; border-radius: 4px; "
499 f"height: 8px; overflow: hidden;'>"
500 f"<div style='background: {bar_color}; width: {fill}%; "
501 f"height: 100%; border-radius: 4px;'></div>"
502 f"</div></div>",
503 unsafe_allow_html=True
504 )
505
506
507def render_dcf(dcf: dict):
508 """Render the DCF valuation section."""
509 st.markdown(
510 "<div class='section-header'>DCF Valuation</div>",
511 unsafe_allow_html=True
512 )
513
514 current_price = dcf.get("current_price", 0)
515 scenarios = ["bear", "base", "bull"]
516 colors = ["#ff4757", "#ffd700", "#00d4aa"]
517 labels = ["BEAR", "BASE", "BULL"]
518
519 cols = st.columns(3)
520 for i, scenario in enumerate(scenarios):
521 data = dcf.get(scenario, {})
522 iv = data.get("intrinsic_value", "N/A")
523 mos = data.get("margin_of_safety", "N/A")
524
525 mos_color = (
526 "#00d4aa" if isinstance(mos, float) and mos > 0
527 else "#ff4757"
528 )
529
530 with cols[i]:
531 st.markdown(
532 f"<div class='metric-card' "
533 f"style='border-color: {colors[i]}44; "
534 f"border-top: 3px solid {colors[i]};'>"
535 f"<div class='metric-label'>{labels[i]} CASE</div>"
536 f"<div class='metric-value' "
537 f"style='color: {colors[i]};'>${iv}</div>"
538 f"<div style='color: {mos_color}; font-size: 14px; "
539 f"margin-top: 8px; font-weight: 600;'>"
540 f"{mos}% vs current</div>"
541 f"</div>",
542 unsafe_allow_html=True
543 )
544
545
546def render_monte_carlo_chart(results: dict):
547 """Render the Monte Carlo histogram using Plotly."""
548 if "error" in results:
549 st.warning(f"Monte Carlo: {results['error']}")
550 return
551
552 st.markdown(
553 "<div class='section-header'>Monte Carlo Simulation "
554 "(10,000 Scenarios)</div>",
555 unsafe_allow_html=True
556 )
557
558 values = results["intrinsic_values"]
559 current_price = results["current_price"]
560 p10 = results["p10"]
561 p50 = results["p50"]
562 p90 = results["p90"]
563 prob = results["prob_undervalued"]
564
565 fig = go.Figure()
566
567 fig.add_trace(go.Histogram(
568 x=values[values < current_price], nbinsx=80,
569 marker_color="#ff4757", opacity=0.8,
570 name="Overvalued Scenarios"
571 ))
572 fig.add_trace(go.Histogram(
573 x=values[values >= current_price], nbinsx=80,
574 marker_color="#00d4aa", opacity=0.8,
575 name="Undervalued Scenarios"
576 ))
577
578 for val, color, label in [
579 (current_price, "#ffffff", f"Current Price ${current_price}"),
580 (p50, "#ffd700", f"Median ${p50}"),
581 (p10, "#ff8c00", f"P10 ${p10}"),
582 (p90, "#00bfff", f"P90 ${p90}"),
583 ]:
584 fig.add_vline(
585 x=val, line_color=color, line_width=2, line_dash="dash",
586 annotation_text=label, annotation_position="top",
587 annotation_font_color=color
588 )
589
590 fig.update_layout(
591 barmode="overlay",
592 paper_bgcolor="#0a0e1a", plot_bgcolor="#0a0e1a",
593 font_color="#ffffff",
594 title=dict(
595 text=f"Probability Undervalued: {prob}%",
596 font_size=16, font_color="#64ffda"
597 ),
598 xaxis=dict(
599 title="Intrinsic Value Per Share ($)",
600 gridcolor="#1a1f35", zerolinecolor="#2a3050"
601 ),
602 yaxis=dict(title="Number of Scenarios", gridcolor="#1a1f35"),
603 legend=dict(bgcolor="#1a1f35", bordercolor="#2a3050"),
604 height=400
605 )
606
607 st.plotly_chart(fig, use_container_width=True)
608
609 col1, col2, col3, col4, col5 = st.columns(5)
610 stats = [
611 ("P10 Deep Bear", f"${results['p10']}"),
612 ("P25 Bear", f"${results['p25']}"),
613 ("P50 Median", f"${results['p50']}"),
614 ("P75 Bull", f"${results['p75']}"),
615 ("P90 Deep Bull", f"${results['p90']}"),
616 ]
617 for col, (label, value) in zip(
618 [col1, col2, col3, col4, col5], stats
619 ):
620 with col:
621 st.markdown(
622 f"<div class='metric-card'>"
623 f"<div class='metric-label'>{label}</div>"
624 f"<div class='metric-value' style='font-size: 18px;'>"
625 f"{value}</div>"
626 f"</div>",
627 unsafe_allow_html=True
628 )
629
630
631def render_sentiment(sentiment: dict):
632 """Display FinBERT news sentiment analysis."""
633 if not sentiment or "error" in sentiment:
634 st.warning("Sentiment data unavailable.")
635 return
636
637 st.markdown(
638 "<div class='section-header'>News Sentiment Analysis</div>",
639 unsafe_allow_html=True
640 )
641
642 label = sentiment.get("sentiment_label", "neutral")
643 display_score = sentiment.get("display_score", 50)
644 headline_count = sentiment.get("headline_count", 0)
645 headlines = sentiment.get("headlines", [])
646
647 color = (
648 "#00d4aa" if label == "positive"
649 else "#ff4757" if label == "negative"
650 else "#ffd700"
651 )
652
653 col1, col2 = st.columns([1, 2])
654
655 with col1:
656 st.markdown(
657 f"<div class='metric-card' style='text-align: center; "
658 f"padding: 30px; border-top: 3px solid {color};'>"
659 f"<div class='metric-label'>SENTIMENT SCORE</div>"
660 f"<div style='color: {color}; font-size: 56px; "
661 f"font-weight: 900; line-height: 1;'>{display_score:.0f}</div>"
662 f"<div style='color: #8892b0; font-size: 12px; "
663 f"margin: 4px 0;'>OUT OF 100</div>"
664 f"<div style='color: {color}; font-size: 18px; "
665 f"font-weight: 700; margin-top: 8px;'>{label.upper()}</div>"
666 f"<div style='color: #8892b0; font-size: 11px; "
667 f"margin-top: 8px;'>Based on {headline_count} "
668 f"recent headlines</div></div>",
669 unsafe_allow_html=True
670 )
671
672 with col2:
673 st.markdown(
674 "<div style='color: #64ffda; font-size: 12px; "
675 "font-weight: 700; text-transform: uppercase; "
676 "letter-spacing: 1px; margin-bottom: 12px;'>"
677 "Recent Headlines</div>",
678 unsafe_allow_html=True
679 )
680
681 for item in headlines[:6]:
682 h_label = item.get("label", "neutral")
683 h_score = item.get("score", 0)
684 headline = item.get("headline", "")
685
686 h_color = (
687 "#00d4aa" if h_label == "positive"
688 else "#ff4757" if h_label == "negative"
689 else "#8892b0"
690 )
691 indicator = (
692 "+" if h_label == "positive"
693 else "-" if h_label == "negative"
694 else "o"
695 )
696
697 st.markdown(
698 f"<div style='display: flex; align-items: flex-start; "
699 f"margin-bottom: 10px; padding: 8px 12px; "
700 f"background: #1a1f35; border-radius: 8px; "
701 f"border-left: 3px solid {h_color};'>"
702 f"<span style='color: {h_color}; font-size: 14px; "
703 f"margin-right: 8px; flex-shrink: 0;'>{indicator}</span>"
704 f"<div><div style='color: #ffffff; font-size: 12px; "
705 f"line-height: 1.4;'>{headline}</div>"
706 f"<div style='color: {h_color}; font-size: 11px; "
707 f"margin-top: 2px;'>{h_label.upper()} "
708 f"confidence {h_score:.0%}</div></div></div>",
709 unsafe_allow_html=True
710 )
711
712
713def main():
714 ticker, analyze = render_header()
715
716 if analyze and ticker:
717 with st.spinner(f"Analyzing {ticker}..."):
718 screener = screen_ticker(ticker)
719 fundamentals = analyze_ticker(ticker)
720 dcf = run_dcf_analysis(ticker)
721 mc_results = run_monte_carlo(ticker)
722 sentiment = analyze_sentiment(ticker)
723
724 if "error" in screener:
725 st.error(f"Could not find data for {ticker}.")
726 return
727
728 import yfinance as yf
729 rec = yf.Ticker(ticker).info.get(
730 "recommendationKey", "N/A"
731 ).upper()
732
733 rating = calculate_analyst_rating(
734 gross_margin = fundamentals.get("gross_margin"),
735 revenue_cagr = fundamentals.get("revenue_cagr"),
736 fundamental_score = fundamentals.get("fundamental_score"),
737 rsi = screener.get("rsi"),
738 ma_signal = screener.get("ma_signal"),
739 base_mos = dcf.get("base", {}).get("margin_of_safety"),
740 prob_undervalued = mc_results.get("prob_undervalued"),
741 recommendation = rec,
742 beta = fundamentals.get("beta"),
743 current_ratio = fundamentals.get("current_ratio")
744 )
745
746 signal = get_overall_signal(screener, fundamentals, dcf)
747 price = fundamentals.get("price", "N/A")
748
749 render_signal_banner(signal, ticker, price)
750 render_analyst_rating(rating)
751 render_price_chart(ticker)
752 render_analyst_ratings(ticker)
753
754 st.markdown("<br>", unsafe_allow_html=True)
755 render_technical(screener)
756
757 st.markdown("<br>", unsafe_allow_html=True)
758 render_fundamentals(fundamentals)
759
760 st.markdown("<br>", unsafe_allow_html=True)
761 render_dcf(dcf)
762
763 st.markdown("<br>", unsafe_allow_html=True)
764 render_monte_carlo_chart(mc_results)
765
766 st.markdown("<br>", unsafe_allow_html=True)
767 render_sentiment(sentiment)
768
769 elif analyze and not ticker:
770 st.warning("Please enter a stock ticker.")
771
772
773if __name__ == "__main__":
774 main()