|
|
"""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 |
|
|
|
|
|
|
|
|
header_html = 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;">{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>""" |
|
|
|
|
|
|
|
|
st.markdown(header_html, unsafe_allow_html=True) |
|
|
|
|
|
|
|
|
news_cards_html = "" |
|
|
for idx, row in df.head(max_items).iterrows(): |
|
|
news_item = row.to_dict() |
|
|
|
|
|
|
|
|
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']) |
|
|
|
|
|
sentiment_symbol = 'β²' if news_item['sentiment'] == 'positive' else 'βΌ' if news_item['sentiment'] == 'negative' else 'β' |
|
|
|
|
|
|
|
|
news_cards_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';"> |
|
|
<div style="position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: {impact_color};"></div> |
|
|
<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;">{sentiment_symbol} {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> |
|
|
<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> |
|
|
<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> |
|
|
""" |
|
|
|
|
|
|
|
|
import random |
|
|
unique_id = f"news-scroll-{random.randint(10000, 99999)}" |
|
|
|
|
|
|
|
|
scrollable_html = f"""<style> |
|
|
.{unique_id} {{ |
|
|
height: {height}; |
|
|
overflow-y: auto; |
|
|
background: #0D0E13; |
|
|
border: 1px solid #2A2E39; |
|
|
border-top: none; |
|
|
border-radius: 0 0 8px 8px; |
|
|
padding: 16px; |
|
|
}} |
|
|
.{unique_id}::-webkit-scrollbar {{ |
|
|
width: 8px; |
|
|
}} |
|
|
.{unique_id}::-webkit-scrollbar-track {{ |
|
|
background: #1E222D; |
|
|
border-radius: 4px; |
|
|
}} |
|
|
.{unique_id}::-webkit-scrollbar-thumb {{ |
|
|
background: #363A45; |
|
|
border-radius: 4px; |
|
|
}} |
|
|
.{unique_id}::-webkit-scrollbar-thumb:hover {{ |
|
|
background: #434651; |
|
|
}} |
|
|
</style> |
|
|
<div class="{unique_id}"> |
|
|
{news_cards_html} |
|
|
</div> |
|
|
""" |
|
|
|
|
|
st.markdown(scrollable_html, 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 and ML-based impact score.""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not df.empty: |
|
|
latest = df.iloc[0] |
|
|
|
|
|
|
|
|
summary = html_module.escape(latest.get('summary', '').strip()) |
|
|
source = html_module.escape(latest['source']) |
|
|
url = html_module.escape(latest['url']) |
|
|
|
|
|
|
|
|
impact_score = latest.get('breaking_score', 0) |
|
|
score_display = f"{impact_score:.1f}" if impact_score > 0 else "N/A" |
|
|
|
|
|
|
|
|
if impact_score >= 80: |
|
|
score_color = "#FF3B30" |
|
|
score_label = "CRITICAL" |
|
|
elif impact_score >= 60: |
|
|
score_color = "#FF9500" |
|
|
score_label = "HIGH" |
|
|
elif impact_score >= 40: |
|
|
score_color = "#FFCC00" |
|
|
score_label = "MEDIUM" |
|
|
else: |
|
|
score_color = "#34C759" |
|
|
score_label = "LOW" |
|
|
|
|
|
|
|
|
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;"> |
|
|
<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> |
|
|
<div style="position: relative; z-index: 1;"> |
|
|
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;"> |
|
|
<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> |
|
|
<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; flex-wrap: wrap;"> |
|
|
<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> |
|
|
<span style="opacity: 0.8;">β’</span> |
|
|
<span style="background: {score_color}; color: white; padding: 2px 8px; border-radius: 4px; font-weight: 700; font-size: 10px; letter-spacing: 0.5px;">π IMPACT: {score_display}/100 ({score_label})</span> |
|
|
</div> |
|
|
</div> |
|
|
<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> |
|
|
<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) |
|
|
|
|
|
|
|
|
def display_prediction_card(prediction_item: dict): |
|
|
"""Display a single prediction market card with probability visualization.""" |
|
|
|
|
|
|
|
|
title = html_module.escape(prediction_item.get('title', '').strip()) |
|
|
source = html_module.escape(prediction_item['source']) |
|
|
url = html_module.escape(prediction_item['url']) |
|
|
|
|
|
|
|
|
yes_prob = prediction_item.get('yes_probability', 50.0) |
|
|
no_prob = prediction_item.get('no_probability', 50.0) |
|
|
|
|
|
|
|
|
if yes_prob > 60: |
|
|
bar_color = '#089981' |
|
|
sentiment_text = 'YES LIKELY' |
|
|
elif no_prob > 60: |
|
|
bar_color = '#F23645' |
|
|
sentiment_text = 'NO LIKELY' |
|
|
else: |
|
|
bar_color = '#FF9800' |
|
|
sentiment_text = 'BALANCED' |
|
|
|
|
|
|
|
|
end_date = prediction_item.get('end_date') |
|
|
if end_date: |
|
|
if isinstance(end_date, str): |
|
|
end_date_display = end_date |
|
|
else: |
|
|
days_until = (end_date - datetime.now()).days |
|
|
end_date_display = f"Closes in {days_until}d" if days_until > 0 else "Closed" |
|
|
else: |
|
|
end_date_display = "" |
|
|
|
|
|
|
|
|
volume = prediction_item.get('volume', 0) |
|
|
if volume > 1000000: |
|
|
volume_display = f"${volume/1000000:.1f}M volume" |
|
|
elif volume > 1000: |
|
|
volume_display = f"${volume/1000:.1f}K volume" |
|
|
elif volume > 0: |
|
|
volume_display = f"${volume:.0f} volume" |
|
|
else: |
|
|
volume_display = "" |
|
|
|
|
|
|
|
|
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; |
|
|
" onmouseover="this.style.borderColor='#3861FB'; this.style.transform='translateY(-2px)';" |
|
|
onmouseout="this.style.borderColor='#2A2E39'; this.style.transform='translateY(0)';"> |
|
|
|
|
|
<!-- Header --> |
|
|
<div style="margin-bottom: 12px;"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px;"> |
|
|
<span style="color: #3861FB; font-weight: 600; font-size: 13px;">{source}</span> |
|
|
<span style=" |
|
|
background: {bar_color}; |
|
|
color: white; |
|
|
padding: 2px 8px; |
|
|
border-radius: 4px; |
|
|
font-size: 10px; |
|
|
font-weight: 700; |
|
|
">{sentiment_text}</span> |
|
|
</div> |
|
|
<div style="color: #D1D4DC; font-size: 14px; font-weight: 500; line-height: 1.4; margin-bottom: 8px;"> |
|
|
{title} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Probability Visualization --> |
|
|
<div style="margin-bottom: 10px;"> |
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;"> |
|
|
<span style="color: #089981; font-size: 12px; font-weight: 600;">YES {yes_prob:.1f}%</span> |
|
|
<span style="color: #F23645; font-size: 12px; font-weight: 600;">NO {no_prob:.1f}%</span> |
|
|
</div> |
|
|
<!-- Horizontal probability bar --> |
|
|
<div style=" |
|
|
display: flex; |
|
|
height: 8px; |
|
|
border-radius: 4px; |
|
|
overflow: hidden; |
|
|
background: #2A2E39; |
|
|
"> |
|
|
<div style=" |
|
|
width: {yes_prob}%; |
|
|
background: #089981; |
|
|
transition: width 0.3s ease; |
|
|
"></div> |
|
|
<div style=" |
|
|
width: {no_prob}%; |
|
|
background: #F23645; |
|
|
transition: width 0.3s ease; |
|
|
"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Footer info --> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center;"> |
|
|
<div style="color: #787B86; font-size: 11px;"> |
|
|
{end_date_display}{" β’ " + volume_display if volume_display and end_date_display else volume_display} |
|
|
</div> |
|
|
<a href="{url}" target="_blank" style=" |
|
|
color: #3861FB; |
|
|
font-size: 11px; |
|
|
font-weight: 600; |
|
|
text-decoration: none; |
|
|
">View Market β</a> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
st.markdown(card_html, unsafe_allow_html=True) |
|
|
|
|
|
|
|
|
def display_economic_event_card(event_item: dict): |
|
|
"""Display a single economic event card with forecast/actual comparison.""" |
|
|
|
|
|
|
|
|
title = html_module.escape(event_item.get('event_name', event_item.get('title', '')).strip()) |
|
|
country = html_module.escape(event_item.get('country', 'US')) |
|
|
url = html_module.escape(event_item.get('url', '')) |
|
|
|
|
|
|
|
|
forecast = event_item.get('forecast') |
|
|
previous = event_item.get('previous') |
|
|
actual = event_item.get('actual') |
|
|
importance = event_item.get('importance', 'medium') |
|
|
|
|
|
|
|
|
importance_colors = { |
|
|
'high': '#F23645', |
|
|
'medium': '#FF9800', |
|
|
'low': '#787B86' |
|
|
} |
|
|
importance_color = importance_colors.get(importance, '#787B86') |
|
|
|
|
|
|
|
|
time_to_event = event_item.get('time_to_event', '') |
|
|
|
|
|
|
|
|
def format_value(val): |
|
|
if val is None: |
|
|
return '-' |
|
|
if isinstance(val, (int, float)): |
|
|
|
|
|
if abs(val) < 100: |
|
|
return f"{val:.1f}%" |
|
|
else: |
|
|
return f"{val:.1f}" |
|
|
return str(val) |
|
|
|
|
|
forecast_display = format_value(forecast) |
|
|
previous_display = format_value(previous) |
|
|
actual_display = format_value(actual) |
|
|
|
|
|
|
|
|
beat_miss_html = "" |
|
|
if actual is not None and forecast is not None: |
|
|
if actual > forecast: |
|
|
beat_miss_html = '<span style="color: #089981; font-weight: 700;">[BEAT]</span>' |
|
|
elif actual < forecast: |
|
|
beat_miss_html = '<span style="color: #F23645; font-weight: 700;">[MISS]</span>' |
|
|
|
|
|
|
|
|
country_flags = { |
|
|
'US': 'πΊπΈ', |
|
|
'EU': 'πͺπΊ', |
|
|
'UK': 'π¬π§', |
|
|
'JP': 'π―π΅', |
|
|
'CN': 'π¨π³', |
|
|
'CA': 'π¨π¦', |
|
|
'AU': 'π¦πΊ' |
|
|
} |
|
|
flag = country_flags.get(country, 'π') |
|
|
|
|
|
|
|
|
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; |
|
|
" onmouseover="this.style.borderColor='#3861FB'; this.style.transform='translateY(-2px)';" |
|
|
onmouseout="this.style.borderColor='#2A2E39'; this.style.transform='translateY(0)';"> |
|
|
|
|
|
<!-- Header --> |
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;"> |
|
|
<div style="flex: 1;"> |
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;"> |
|
|
<span style="font-size: 20px;">{flag}</span> |
|
|
<span style=" |
|
|
background: {importance_color}; |
|
|
color: white; |
|
|
padding: 2px 8px; |
|
|
border-radius: 4px; |
|
|
font-size: 10px; |
|
|
font-weight: 700; |
|
|
">{importance.upper()}</span> |
|
|
</div> |
|
|
<div style="color: #D1D4DC; font-size: 14px; font-weight: 500; line-height: 1.4;"> |
|
|
{title} |
|
|
</div> |
|
|
</div> |
|
|
{f'<div style="color: #3861FB; font-size: 12px; font-weight: 600; white-space: nowrap; margin-left: 12px;">{time_to_event}</div>' if time_to_event else ''} |
|
|
</div> |
|
|
|
|
|
<!-- Values comparison --> |
|
|
<div style="background: #0D0E13; border-radius: 6px; padding: 10px; margin-bottom: 8px;"> |
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
|
|
<span style="color: #787B86; font-size: 11px;">Forecast:</span> |
|
|
<span style="color: #D1D4DC; font-size: 12px; font-weight: 600;">{forecast_display}</span> |
|
|
</div> |
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;"> |
|
|
<span style="color: #787B86; font-size: 11px;">Previous:</span> |
|
|
<span style="color: #D1D4DC; font-size: 12px; font-weight: 600;">{previous_display}</span> |
|
|
</div> |
|
|
{f'<div style="display: flex; justify-content: space-between;"><span style="color: #787B86; font-size: 11px;">Actual:</span><span style="color: #D1D4DC; font-size: 12px; font-weight: 600;">{actual_display} {beat_miss_html}</span></div>' if actual is not None else ''} |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
st.markdown(card_html, unsafe_allow_html=True) |
|
|
|
|
|
|
|
|
def display_economic_calendar_widget(events_df: pd.DataFrame): |
|
|
"""Display economic calendar widget showing upcoming events.""" |
|
|
|
|
|
if events_df.empty: |
|
|
st.info("π
No upcoming economic events in the next 7 days") |
|
|
return |
|
|
|
|
|
|
|
|
widget_html = """<div style="background: linear-gradient(135deg, #1E222D 0%, #131722 100%); border: 1px solid #2A2E39; border-radius: 12px; padding: 20px; margin-bottom: 20px;"> |
|
|
<div style="margin-bottom: 16px;"> |
|
|
<h3 style="color: #D1D4DC; font-size: 18px; font-weight: 600; margin: 0;">π
Economic Calendar</h3> |
|
|
<p style="color: #787B86; font-size: 13px; margin: 4px 0 0 0;">Upcoming high-impact events</p> |
|
|
</div>""" |
|
|
|
|
|
|
|
|
for idx, event in events_df.head(10).iterrows(): |
|
|
|
|
|
event_name = html_module.escape(event.get('event_name', event.get('title', ''))) |
|
|
country = html_module.escape(event.get('country', 'US')) |
|
|
importance = event.get('importance', 'medium') |
|
|
time_to_event = event.get('time_to_event', '') |
|
|
forecast = event.get('forecast') |
|
|
|
|
|
|
|
|
country_flags = { |
|
|
'US': 'πΊπΈ', |
|
|
'EU': 'πͺπΊ', |
|
|
'UK': 'π¬π§', |
|
|
'JP': 'π―π΅', |
|
|
'CN': 'π¨π³' |
|
|
} |
|
|
flag = country_flags.get(country, 'π') |
|
|
|
|
|
|
|
|
stars = 'β' * ({'high': 3, 'medium': 2, 'low': 1}.get(importance, 1)) |
|
|
|
|
|
|
|
|
forecast_display = f"{forecast:.1f}" if forecast is not None else "N/A" |
|
|
|
|
|
|
|
|
importance_color = '#F23645' if importance == 'high' else '#FF9800' if importance == 'medium' else '#787B86' |
|
|
|
|
|
|
|
|
event_html = f"""<div style="background: #0D0E13; border-left: 3px solid {importance_color}; border-radius: 6px; padding: 12px; margin-bottom: 10px;"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center;"> |
|
|
<div style="flex: 1;"> |
|
|
<div style="color: #D1D4DC; font-size: 13px; font-weight: 500; margin-bottom: 4px;">{flag} {event_name}</div> |
|
|
<div style="color: #787B86; font-size: 11px;">{stars} Forecast: {forecast_display}</div> |
|
|
</div> |
|
|
<div style="color: #3861FB; font-size: 12px; font-weight: 600; white-space: nowrap; margin-left: 12px;">{time_to_event}</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
widget_html += event_html |
|
|
|
|
|
widget_html += "</div>" |
|
|
|
|
|
st.markdown(widget_html, unsafe_allow_html=True) |
|
|
|