| """News display components for the financial dashboard.""" |
|
|
| import streamlit as st |
| import pandas as pd |
| from datetime import datetime |
| import html as html_module |
|
|
|
|
| def display_tradingview_news_card(news_item: dict): |
| """Display a single news card with TradingView-inspired styling.""" |
|
|
| |
| time_diff = datetime.now() - news_item['timestamp'] |
| if time_diff.seconds < 60: |
| time_ago = f"{time_diff.seconds}s ago" |
| elif time_diff.seconds < 3600: |
| time_ago = f"{time_diff.seconds // 60}m ago" |
| else: |
| hours = time_diff.seconds // 3600 |
| time_ago = f"{hours}h ago" if hours < 24 else f"{time_diff.days}d ago" |
|
|
| |
| impact_colors = { |
| 'high': '#F23645', |
| 'medium': '#FF9800', |
| 'low': '#089981' |
| } |
|
|
| |
| sentiment_colors = { |
| 'positive': '#089981', |
| 'negative': '#F23645', |
| 'neutral': '#787B86' |
| } |
|
|
| impact_color = impact_colors.get(news_item['impact'], '#787B86') |
| sentiment_color = sentiment_colors.get(news_item['sentiment'], '#787B86') |
|
|
| |
| summary = html_module.escape(news_item.get('summary', '').strip()) |
| source = html_module.escape(news_item['source']) |
| category = html_module.escape(news_item['category']) |
| url = html_module.escape(news_item['url']) |
|
|
| |
| card_html = f""" |
| <div style=" |
| background: linear-gradient(135deg, #1E222D 0%, #131722 100%); |
| border: 1px solid #2A2E39; |
| border-radius: 8px; |
| padding: 16px; |
| margin-bottom: 12px; |
| transition: all 0.2s ease; |
| cursor: pointer; |
| position: relative; |
| overflow: hidden; |
| " onmouseover="this.style.borderColor='#3861FB'; this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(56, 97, 251, 0.15)';" |
| onmouseout="this.style.borderColor='#2A2E39'; this.style.transform='translateY(0)'; this.style.boxShadow='none';"> |
| |
| <!-- Left colored indicator bar --> |
| <div style=" |
| position: absolute; |
| left: 0; |
| top: 0; |
| bottom: 0; |
| width: 3px; |
| background: {impact_color}; |
| "></div> |
| |
| <!-- Header row --> |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; margin-left: 8px;"> |
| <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;"> |
| <span style=" |
| color: #3861FB; |
| font-weight: 600; |
| font-size: 13px; |
| font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; |
| ">{source}</span> |
| |
| <span style=" |
| background: {impact_color}; |
| color: white; |
| padding: 2px 8px; |
| border-radius: 4px; |
| font-size: 10px; |
| font-weight: 700; |
| letter-spacing: 0.5px; |
| ">{news_item['impact'].upper()}</span> |
| |
| <span style=" |
| color: {sentiment_color}; |
| font-size: 11px; |
| font-weight: 600; |
| padding: 2px 6px; |
| border: 1px solid {sentiment_color}; |
| border-radius: 4px; |
| ">{'▲' if news_item['sentiment'] == 'positive' else '▼' if news_item['sentiment'] == 'negative' else '●'} {news_item['sentiment'].upper()}</span> |
| |
| <span style=" |
| color: #787B86; |
| font-size: 11px; |
| background: rgba(120, 123, 134, 0.1); |
| padding: 2px 6px; |
| border-radius: 4px; |
| ">#{category}</span> |
| </div> |
| |
| <span style="color: #787B86; font-size: 11px; white-space: nowrap;">{time_ago}</span> |
| </div> |
| |
| <!-- News summary --> |
| <div style=" |
| color: #D1D4DC; |
| font-size: 14px; |
| line-height: 1.5; |
| margin-bottom: 8px; |
| margin-left: 8px; |
| font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; |
| ">{summary}</div> |
| |
| <!-- Read more link --> |
| <a href="{url}" target="_blank" style=" |
| color: #3861FB; |
| font-size: 12px; |
| text-decoration: none; |
| margin-left: 8px; |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| font-weight: 500; |
| " onmouseover="this.style.color='#5880FF';" onmouseout="this.style.color='#3861FB';"> |
| Read Full Story → |
| </a> |
| </div> |
| """ |
|
|
| st.markdown(card_html, unsafe_allow_html=True) |
|
|
|
|
| def display_news_card(news_item: dict): |
| """Wrapper to maintain compatibility - calls TradingView-style card.""" |
| display_tradingview_news_card(news_item) |
|
|
|
|
| def display_scrollable_news_section(df: pd.DataFrame, section_title: str, section_icon: str, |
| section_subtitle: str, max_items: int = 20, height: str = "600px"): |
| """Display a scrollable news section with TradingView styling.""" |
|
|
| if df.empty: |
| st.markdown(f""" |
| <div style=" |
| background: linear-gradient(135deg, #1E222D 0%, #131722 100%); |
| border: 1px solid #2A2E39; |
| border-radius: 8px; |
| padding: 20px; |
| text-align: center; |
| color: #787B86; |
| "> |
| <p style="font-size: 16px; margin: 0;">📭 No news available for this section</p> |
| </div> |
| """, unsafe_allow_html=True) |
| return |
|
|
| |
| st.markdown(f""" |
| <div style=" |
| background: linear-gradient(135deg, #2A2E39 0%, #1E222D 100%); |
| border: 1px solid #363A45; |
| border-radius: 8px 8px 0 0; |
| padding: 16px 20px; |
| margin-bottom: 0; |
| "> |
| <div style="display: flex; justify-content: space-between; align-items: center;"> |
| <div> |
| <h3 style=" |
| color: #D1D4DC; |
| margin: 0; |
| font-size: 18px; |
| font-weight: 600; |
| font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; |
| ">{section_icon} {section_title}</h3> |
| <p style=" |
| color: #787B86; |
| margin: 4px 0 0 0; |
| font-size: 12px; |
| ">{section_subtitle}</p> |
| </div> |
| <div style=" |
| background: rgba(56, 97, 251, 0.15); |
| color: #3861FB; |
| padding: 6px 12px; |
| border-radius: 6px; |
| font-size: 13px; |
| font-weight: 600; |
| ">{len(df.head(max_items))} stories</div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| st.markdown(f""" |
| <style> |
| .news-scroll-container {{ |
| height: {height}; |
| overflow-y: auto; |
| background: #0D0E13; |
| border: 1px solid #2A2E39; |
| border-top: none; |
| border-radius: 0 0 8px 8px; |
| padding: 16px; |
| }} |
| |
| /* TradingView-style scrollbar */ |
| .news-scroll-container::-webkit-scrollbar {{ |
| width: 8px; |
| }} |
| |
| .news-scroll-container::-webkit-scrollbar-track {{ |
| background: #1E222D; |
| border-radius: 4px; |
| }} |
| |
| .news-scroll-container::-webkit-scrollbar-thumb {{ |
| background: #363A45; |
| border-radius: 4px; |
| }} |
| |
| .news-scroll-container::-webkit-scrollbar-thumb:hover {{ |
| background: #434651; |
| }} |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| st.markdown('<div class="news-scroll-container">', unsafe_allow_html=True) |
|
|
| |
| for idx, row in df.head(max_items).iterrows(): |
| display_tradingview_news_card(row.to_dict()) |
|
|
| |
| st.markdown('</div>', unsafe_allow_html=True) |
|
|
|
|
| def display_news_feed(df: pd.DataFrame, max_items: int = 20): |
| """Display a feed of news items (legacy compatibility).""" |
|
|
| if df.empty: |
| st.info("📭 No news available. Adjust your filters or refresh the feed.") |
| return |
|
|
| |
| for idx, row in df.head(max_items).iterrows(): |
| display_tradingview_news_card(row.to_dict()) |
|
|
|
|
| def display_news_statistics(stats: dict): |
| """Display news feed statistics in metric cards.""" |
|
|
| col1, col2, col3, col4 = st.columns(4) |
|
|
| with col1: |
| st.metric( |
| "Total Stories", |
| f"{stats['total']}", |
| help="Total news items in feed" |
| ) |
|
|
| with col2: |
| st.metric( |
| "High Impact", |
| f"{stats['high_impact']}", |
| delta=f"{(stats['high_impact']/max(stats['total'], 1)*100):.0f}%", |
| help="High-impact market-moving news" |
| ) |
|
|
| with col3: |
| st.metric( |
| "Breaking News", |
| f"{stats['breaking']}", |
| delta="LIVE" if stats['breaking'] > 0 else None, |
| help="Breaking news alerts" |
| ) |
|
|
| with col4: |
| st.metric( |
| "Last Update", |
| stats['last_update'], |
| help="Time of last news fetch" |
| ) |
|
|
|
|
| def display_category_breakdown(stats: dict): |
| """Display news breakdown by category using Streamlit components.""" |
|
|
| if 'by_category' not in stats: |
| return |
|
|
| st.markdown("### 📊 News by Category") |
|
|
| categories = stats['by_category'] |
| total = sum(categories.values()) |
|
|
| if total == 0: |
| st.info("No categorized news available") |
| return |
|
|
| col1, col2, col3 = st.columns(3) |
|
|
| with col1: |
| macro_count = categories.get('macro', 0) |
| macro_pct = (macro_count / total) * 100 |
| with st.container(): |
| st.markdown("**:blue[📈 MACRO]**") |
| st.markdown(f"# {macro_count}") |
| st.caption(f"{macro_pct:.1f}% of total") |
|
|
| with col2: |
| geo_count = categories.get('geopolitical', 0) |
| geo_pct = (geo_count / total) * 100 |
| with st.container(): |
| st.markdown("**:orange[🌍 GEOPOLITICAL]**") |
| st.markdown(f"# {geo_count}") |
| st.caption(f"{geo_pct:.1f}% of total") |
|
|
| with col3: |
| markets_count = categories.get('markets', 0) |
| markets_pct = (markets_count / total) * 100 |
| with st.container(): |
| st.markdown("**:green[💹 MARKETS]**") |
| st.markdown(f"# {markets_count}") |
| st.caption(f"{markets_pct:.1f}% of total") |
|
|
|
|
| def display_breaking_news_banner(df: pd.DataFrame): |
| """Display breaking news banner at the top with TradingView styling.""" |
|
|
| breaking = df[df['is_breaking'] == True] if not df.empty and 'is_breaking' in df.columns else pd.DataFrame() |
|
|
| if not breaking.empty: |
| latest = breaking.iloc[0] |
|
|
| |
| summary = html_module.escape(latest.get('summary', '').strip()) |
| source = html_module.escape(latest['source']) |
| url = html_module.escape(latest['url']) |
|
|
| |
| time_diff = datetime.now() - latest['timestamp'] |
| if time_diff.seconds < 60: |
| time_ago = f"{time_diff.seconds}s ago" |
| elif time_diff.seconds < 3600: |
| time_ago = f"{time_diff.seconds // 60}m ago" |
| else: |
| hours = time_diff.seconds // 3600 |
| time_ago = f"{hours}h ago" if hours < 24 else f"{time_diff.days}d ago" |
|
|
| |
| banner_html = f""" |
| <style> |
| @keyframes pulse-glow {{ |
| 0%, 100% {{ box-shadow: 0 0 20px rgba(242, 54, 69, 0.6); }} |
| 50% {{ box-shadow: 0 0 30px rgba(242, 54, 69, 0.9); }} |
| }} |
| |
| @keyframes slide-in {{ |
| from {{ transform: translateX(-10px); opacity: 0; }} |
| to {{ transform: translateX(0); opacity: 1; }} |
| }} |
| </style> |
| |
| <div style=" |
| background: linear-gradient(135deg, #F23645 0%, #C91B28 100%); |
| border: 2px solid #FF6B78; |
| border-radius: 12px; |
| padding: 20px 24px; |
| margin-bottom: 24px; |
| animation: pulse-glow 2s ease-in-out infinite; |
| position: relative; |
| overflow: hidden; |
| "> |
| <!-- Animated background pattern --> |
| <div style=" |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: repeating-linear-gradient( |
| 45deg, |
| transparent, |
| transparent 10px, |
| rgba(255, 255, 255, 0.03) 10px, |
| rgba(255, 255, 255, 0.03) 20px |
| ); |
| pointer-events: none; |
| "></div> |
| |
| <!-- Content --> |
| <div style="position: relative; z-index: 1;"> |
| <div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;"> |
| <!-- Animated icon --> |
| <div style=" |
| font-size: 32px; |
| animation: pulse-glow 1s ease-in-out infinite; |
| filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); |
| ">🚨</div> |
| |
| <!-- Header --> |
| <div style="flex: 1;"> |
| <div style=" |
| color: white; |
| font-size: 14px; |
| font-weight: 700; |
| letter-spacing: 1.5px; |
| text-transform: uppercase; |
| margin-bottom: 4px; |
| font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; |
| text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); |
| ">⚡ Breaking News</div> |
| <div style=" |
| color: rgba(255, 255, 255, 0.9); |
| font-size: 11px; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| "> |
| <span style=" |
| background: rgba(255, 255, 255, 0.2); |
| padding: 2px 8px; |
| border-radius: 4px; |
| font-weight: 600; |
| ">{source}</span> |
| <span style="opacity: 0.8;">•</span> |
| <span style="opacity: 0.8;">{time_ago}</span> |
| </div> |
| </div> |
| |
| <!-- Read button --> |
| <a href="{url}" target="_blank" style=" |
| background: white; |
| color: #F23645; |
| padding: 10px 20px; |
| border-radius: 6px; |
| font-size: 13px; |
| font-weight: 700; |
| text-decoration: none; |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| transition: all 0.2s ease; |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); |
| " onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0, 0, 0, 0.3)';" |
| onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 8px rgba(0, 0, 0, 0.2)';"> |
| READ NOW → |
| </a> |
| </div> |
| |
| <!-- News summary --> |
| <div style=" |
| color: white; |
| font-size: 16px; |
| font-weight: 500; |
| line-height: 1.5; |
| margin-left: 48px; |
| font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; |
| text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); |
| animation: slide-in 0.5s ease-out; |
| ">{summary}</div> |
| </div> |
| </div> |
| """ |
|
|
| st.markdown(banner_html, unsafe_allow_html=True) |
|
|