import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import requests
import numpy as np
from datetime import datetime, date, timedelta
import os
st.set_page_config(layout="wide")
API_KEY = os.getenv("FMP_API_KEY")
# -----------------------------
# Data Fetching
# -----------------------------
@st.cache_data
def fetch_analyst_ratings(ticker):
"""
Fetches analyst consensus ratings for a given ticker.
"""
url = f'https://financialmodelingprep.com/api/v4/upgrades-downgrades-consensus?symbol={ticker}&apikey={API_KEY}'
try:
r = requests.get(url)
r.raise_for_status()
data = r.json()
return data[0] if data else None
except Exception:
return None
@st.cache_data
def fetch_detailed_ratings(ticker):
"""
Fetches detailed analyst ratings for a given ticker.
"""
url = f'https://financialmodelingprep.com/api/v4/upgrades-downgrades?symbol={ticker}&apikey={API_KEY}'
try:
r = requests.get(url)
r.raise_for_status()
data = r.json()
df = pd.DataFrame(data) if data else pd.DataFrame()
# Reorder columns if target columns exist
if not df.empty and any(col in df.columns for col in ['newsBaseURL', 'newsPublisher', 'newsURL', 'newsTitle']):
target_cols = ['newsBaseURL', 'newsPublisher', 'newsURL', 'newsTitle']
other_cols = [c for c in df.columns if c not in target_cols]
new_order = other_cols + target_cols
df = df[new_order]
return df
except Exception:
return pd.DataFrame()
@st.cache_data
def fetch_upgrades_downgrades_rss_feed(api_key, num_pages=5):
"""
Fetches up to 'num_pages' pages of upgrades/downgrades data
from the FMP RSS feed API.
"""
all_data = []
for page in range(num_pages):
url = f"https://financialmodelingprep.com/api/v4/upgrades-downgrades-rss-feed?page={page}&apikey={api_key}"
response = requests.get(url)
if response.status_code == 200:
data = response.json()
all_data.extend(data)
else:
print(f"Error fetching page {page}: {response.status_code}")
df = pd.DataFrame(all_data)
if not df.empty and any(col in df.columns for col in ['newsBaseURL', 'newsPublisher', 'newsURL', 'newsTitle']):
target_cols = ['newsBaseURL', 'newsPublisher', 'newsURL', 'newsTitle']
other_cols = [c for c in df.columns if c not in target_cols]
new_order = other_cols + target_cols
df = df[new_order]
return df
# -----------------------------
# Plotting
# -----------------------------
def plot_analyst_ratings(data):
"""
Plots a horizontal bar chart of analyst consensus ratings.
"""
df = pd.DataFrame({
"rating": ["Strong Buy", "Buy", "Hold", "Sell", "Strong Sell"],
"count": [
data["strongBuy"], data["buy"], data["hold"], data["sell"], data["strongSell"]
]
})
total_ratings = df["count"].sum()
df["percentage"] = (df["count"] / total_ratings * 100).round(0).astype(int)
df["text"] = df["count"].astype(str) + " (" + df["percentage"].astype(str) + "%)"
color_map = {
"Strong Buy": "#145A32",
"Buy": "#27ae60",
"Hold": "#f1c40f",
"Sell": "#e74c3c",
"Strong Sell": "#641E16"
}
custom_colors = [color_map[r] for r in df["rating"]]
fig = px.bar(
df,
x='count',
y='rating',
orientation='h',
color='rating',
color_discrete_sequence=custom_colors,
text='text'
)
fig.update_traces(
textposition='outside',
textfont=dict(size=16, color="white"),
textangle=0
)
fig.update_layout(
template='plotly_dark',
paper_bgcolor='#0e1117',
plot_bgcolor='#0e1117',
showlegend=False,
xaxis=dict(showgrid=False, visible=False),
yaxis=dict(title='', showgrid=False, tickfont=dict(size=18, color="white")),
margin=dict(l=120, r=10, t=80, b=30),
height=350,
title=dict(
text=(
f"Analyst Consensus: {data['consensus']}
"
f"Aggregate opinion from {total_ratings} analysts"
),
x=0.015,
xanchor='left',
y=0.9,
yanchor='top',
font=dict(size=25, color="white")
),
width=None
)
return fig
def plot_sentiment_over_time(df):
"""
Plots net sentiment changes over time.
"""
df['publishedDate'] = pd.to_datetime(df['publishedDate'], errors='coerce')
grade_map = {
'Strong Buy': 2, 'Buy': 2, 'Overweight': 2, 'Outperform': 2,
'Hold': 0, 'Neutral': 0,
'Underperform': -2, 'Reduce': -2, 'Sell': -2
}
df['newGrade_score'] = df['newGrade'].map(grade_map)
df['previousGrade_score'] = df['previousGrade'].map(grade_map)
df['sentiment_change'] = df['newGrade_score'] - df['previousGrade_score']
df['year_month'] = df['publishedDate'].dt.to_period('M')
monthly_sentiment = df.groupby('year_month')['sentiment_change'].sum().reset_index()
monthly_sentiment['year_month'] = monthly_sentiment['year_month'].astype(str)
fig = go.Figure()
fig.add_trace(go.Scatter(
x=monthly_sentiment['year_month'],
y=monthly_sentiment['sentiment_change'],
mode='lines+markers',
name='Net Sentiment',
line=dict(color='blue', width=6),
marker=dict(color='blue', size=8),
hovertemplate='Month: %{x}
Sentiment: %{y}'
))
fig.update_layout(
template='plotly_dark',
paper_bgcolor='#0e1117',
plot_bgcolor='#0e1117',
#title="Analyst Sentiment Over Time",
xaxis=dict(
title=dict(text='Month', font=dict(color='green', size=20)),
tickangle=45,
tickfont=dict(color='white'),
showgrid=True,
gridcolor='white'
),
yaxis=dict(
title=dict(text='Sum of Sentiment Changes', font=dict(color='green', size=20)),
tickfont=dict(color='white'),
showgrid=True,
gridcolor='white'
),
font=dict(color='white'),
margin=dict(l=40, r=40, t=80, b=80),
width=None
)
return fig
def plot_actions_over_time(df):
"""
Plots a stacked bar chart of analyst actions over time.
"""
df['publishedDate'] = pd.to_datetime(df['publishedDate'])
df['year_month'] = df['publishedDate'].dt.to_period('M')
monthly_counts = df.groupby(['year_month','action'])['symbol'].count().reset_index(name='count')
monthly_counts['year_month'] = monthly_counts['year_month'].astype(str)
pivot_df = monthly_counts.pivot(
index='year_month',
columns='action',
values='count'
).fillna(0)
action_colors = {
'upgrade': 'green',
'downgrade': 'red',
'hold': 'gray',
'initiate': 'orange'
}
fig = go.Figure()
for action in sorted(pivot_df.columns):
fig.add_trace(go.Bar(
x=pivot_df.index,
y=pivot_df[action],
name=action.capitalize(),
marker_color=action_colors.get(action, 'blue'),
hovertemplate=(
"%{x}
Action: " + action.capitalize() + "
Count: %{y}"
)
))
fig.update_layout(
barmode='stack',
template='plotly_dark',
paper_bgcolor='#0e1117',
plot_bgcolor='#0e1117',
#title="Upgrades vs. Downgrades vs. Holds Over Time",
xaxis=dict(
title=dict(text='Month', font=dict(color='green', size=20)),
tickangle=45,
tickfont=dict(color='white'),
showgrid=True,
gridcolor='white'
),
yaxis=dict(
title=dict(text='Count of Actions', font=dict(color='green', size=20)),
tickfont=dict(color='white'),
showgrid=True,
gridcolor='white'
),
legend=dict(font=dict(color='white')),
margin=dict(l=40, r=40, t=80, b=80),
width=None
)
return fig
def plot_animated_transition_heatmap(df):
"""
Plots an animated heatmap of grade transitions over time.
"""
df['publishedDate'] = pd.to_datetime(df['publishedDate'], errors='coerce')
df['year_month'] = df['publishedDate'].dt.to_period('M')
all_months = sorted(df['year_month'].dropna().unique())
all_prev_grades = sorted(df['previousGrade'].dropna().unique())
all_new_grades = sorted(df['newGrade'].dropna().unique())
frames = []
initial_data = None
for idx, month in enumerate(all_months):
monthly_df = df[df['year_month'] == month]
transition_counts = monthly_df.groupby(
['previousGrade','newGrade']
).size().reset_index(name='count')
z_data = np.zeros((len(all_prev_grades), len(all_new_grades)), dtype=int)
for _, row in transition_counts.iterrows():
r_idx = all_prev_grades.index(row['previousGrade'])
c_idx = all_new_grades.index(row['newGrade'])
z_data[r_idx, c_idx] = row['count']
z_data_list = z_data.tolist()
frames.append(
go.Frame(
data=[go.Heatmap(
z=z_data_list,
coloraxis="coloraxis",
hovertemplate="Prev: %{y}
New: %{x}
Count: %{z}",
text=z_data_list,
texttemplate="%{text}",
textfont=dict(color="white"),
showscale=True
)],
name=str(month)
)
)
if idx == 0:
initial_data = z_data_list
if initial_data is None:
raise ValueError("No valid data found to plot.")
fig = go.Figure(
data=[go.Heatmap(
z=initial_data,
coloraxis="coloraxis",
hovertemplate="Prev: %{y}
New: %{x}
Count: %{z}",
text=initial_data,
texttemplate="%{text}",
textfont=dict(color="white"),
showscale=True
)],
layout=go.Layout(
# title="Animated Transition Heatmap (Monthly)",
xaxis=dict(
title=dict(text="New Grade", font=dict(color="green", size=20), standoff=75),
tickvals=list(range(len(all_new_grades))),
ticktext=all_new_grades,
tickfont=dict(color='white')
),
yaxis=dict(
title=dict(text="Previous Grade", font=dict(color="green", size=20)),
tickvals=list(range(len(all_prev_grades))),
ticktext=all_prev_grades,
tickfont=dict(color='white'),
autorange='reversed'
),
template="plotly_dark",
paper_bgcolor='#0e1117',
plot_bgcolor='#0e1117'
),
frames=frames
)
fig.update_layout(
coloraxis=dict(colorscale="Blues"),
updatemenus=[
dict(
type="buttons",
showactive=False,
x=0, # Position from left
y=1.2, # Position from top
xanchor="left", # Anchor the button's left side to x=0
yanchor="top", # Anchor the button's top to y=1
buttons=[
dict(
label="Play Animation",
method="animate",
args=[
None,
{"frame": {"duration": 1000, "redraw": True},
"transition": {"duration": 500}}
]
)
]
)
],
sliders=[
dict(
steps=[
dict(
method="animate",
args=[
[frame.name],
{
"mode": "immediate",
"frame": {"duration": 500, "redraw": True},
"transition": {"duration": 300}
}
],
label=frame.name
)
for frame in frames
],
x=0,
y=-0.1,
xanchor="left",
yanchor="top",
pad=dict(b=50, t=20),
currentvalue=dict(prefix="Month: ", font=dict(color='white')),
len=0.9
)
],
width=None
)
return fig
def plot_extreme_sentiment_symbols(
df,
start_date=None,
end_date=None,
top_n=10,
title= ""
):
"""
Identifies most bullish and bearish symbols based on sentiment changes.
"""
df['publishedDate'] = pd.to_datetime(df['publishedDate'], errors='coerce')
df['publishedDate'] = df['publishedDate'].dt.tz_localize(None)
if start_date is not None:
df = df[df['publishedDate'] >= pd.to_datetime(start_date)]
if end_date is not None:
df = df[df['publishedDate'] <= pd.to_datetime(end_date)]
grade_map = {
'Strong Buy': 2, 'Buy': 2, 'Overweight': 2, 'Outperform': 2,
'Hold': 0, 'Neutral': 0,
'Underperform': -2, 'Reduce': -2, 'Sell': -2
}
df['newGrade_score'] = df['newGrade'].map(grade_map)
df['previousGrade_score'] = df['previousGrade'].map(grade_map)
df['sentiment_change'] = df['newGrade_score'] - df['previousGrade_score']
symbol_sentiment = df.groupby('symbol')['sentiment_change'].sum().reset_index()
symbol_sentiment.sort_values('sentiment_change', ascending=False, inplace=True)
top_bullish = symbol_sentiment.head(top_n)
top_bearish = symbol_sentiment.tail(top_n)
top_bullish['category'] = 'Bullish'
top_bearish['category'] = 'Bearish'
combined = pd.concat([top_bullish, top_bearish], ignore_index=True)
fig = px.bar(
combined,
x='sentiment_change',
y='symbol',
color='category',
orientation='h',
title=title,
text='sentiment_change',
color_discrete_map={'Bullish': 'green', 'Bearish': 'red'}
)
n_bars = len(combined)
fig.update_layout(
height=50 + (40 * n_bars),
template='plotly_dark',
paper_bgcolor='#0e1117',
plot_bgcolor='#0e1117',
xaxis=dict(
title=dict(text='Net Sentiment (Sum of Upgrades/Downgrades)', font=dict(color='green', size=20)),
tickfont=dict(color='white')
),
yaxis=dict(
title=dict(text='Symbol', font=dict(color='green', size=20)),
dtick=1,
tickfont=dict(color='white')
),
width=None
)
fig.update_traces(textposition='auto')
return fig
# -----------------------------
# Sidebar and Pages
# -----------------------------
with st.sidebar:
st.header("Parameters")
with st.expander("Select Page", expanded=True):
page = st.radio("Analyses", ["Actions by Ticker", "Actions Live Feed"], index=0)
if page == "Actions by Ticker":
with st.expander("Ticker Input", expanded=True):
ticker = st.text_input(
"Enter Ticker Symbol",
value="AAPL",
help="Enter a valid stock ticker symbol (e.g., AAPL)."
)
run_button = st.button("Run Analysis")
else:
with st.expander("Date Range", expanded=True):
# In the sidebar for live feed:
start_date = st.date_input(
"Start Date",
value=date.today() - timedelta(days=45),
help="Select the start date."
)
end_date = st.date_input(
"End Date",
value=date.today(),
help="Select the end date."
)
with st.expander("Display Options", expanded=True):
top_n = st.slider(
"Number of Symbols",
min_value=5,
max_value=20,
value=10,
help="How many bullish and bearish symbols to display."
)
run_button = st.button("Run Analysis")
st.title("Stock Downgrades and Upgrades")
st.write("This tool provides real-time updates on analyst ratings, shows sentiment trends over time, and displays recent analyst actions.")
if 'results' not in st.session_state:
st.session_state.results = {}
# -----------------------------
# Page 1: Actions by Ticker
# -----------------------------
if page == "Actions by Ticker":
if run_button:
if not ticker.strip():
st.error("Please enter a valid ticker symbol.")
else:
with st.spinner("Fetching data..."):
consensus_data = fetch_analyst_ratings(ticker.upper())
detailed_data = fetch_detailed_ratings(ticker.upper())
st.session_state.results["consensus"] = consensus_data
st.session_state.results["detailed"] = detailed_data
if "consensus" in st.session_state.results and "detailed" in st.session_state.results:
consensus_data = st.session_state.results["consensus"]
detailed_data = st.session_state.results["detailed"]
st.subheader(f"Analyst Consensus for {ticker.upper()}")
st.write("This chart displays the overall analyst consensus rating for the stock. It shows the distribution of ratings such as Strong Buy, Buy, Hold, Sell, and Strong Sell.")
if consensus_data:
st.plotly_chart(plot_analyst_ratings(consensus_data), use_container_width=True)
else:
st.warning("No consensus data available.")
st.subheader(f"Sentiment Trend Over Time for {ticker.upper()}")
st.write("This line chart tracks changes in analyst sentiment over time. Net sentiment is computed by subtracting the previous rating score from the new rating score using a mapping (Strong Buy, Buy, Overweight, Outperform = 2; Hold, Neutral = 0; Underperform, Reduce, Sell = -2), which indicates the direction of rating adjustments each month.")
if not detailed_data.empty:
st.plotly_chart(plot_sentiment_over_time(detailed_data), use_container_width=True)
else:
st.warning("No detailed data available for sentiment analysis.")
st.subheader(f"Analyst Actions Over Time for {ticker.upper()}")
st.write("This stacked bar chart shows monthly counts of analyst actions. It shows the number of upgrades, downgrades, and holds over time.")
if not detailed_data.empty:
st.plotly_chart(plot_actions_over_time(detailed_data), use_container_width=True)
else:
st.warning("No detailed data available for action analysis.")
st.subheader(f"Rating Transition Heatmap for {ticker.upper()}")
st.write("This animated heatmap visualizes transitions between previous and new analyst ratings each month. It shows how frequently ratings change from one category to another. Use the slider or play button to observe changes for specific dates.")
if not detailed_data.empty:
try:
st.plotly_chart(plot_animated_transition_heatmap(detailed_data), use_container_width=True)
except ValueError:
st.warning("Insufficient data for transition heatmap.")
else:
st.warning("No detailed data available for transitions.")
st.subheader(f"Detailed Analyst Action Data for {ticker.upper()}")
st.write("The table below provides the detailed information on each analyst action and the associated news data.")
if not detailed_data.empty:
with st.expander("View Detailed Results", expanded=False):
st.dataframe(detailed_data)
else:
st.warning("No detailed data available.")
# -----------------------------
# Page 2: Actions Live Feed
# -----------------------------
else:
if run_button:
if start_date > end_date:
st.error("Start date must be before end date.")
else:
with st.spinner("Fetching live feed data..."):
rss_feed_df = fetch_upgrades_downgrades_rss_feed(API_KEY, num_pages=5)
st.session_state.results["live_data"] = rss_feed_df
if "live_data" in st.session_state.results:
live_data = st.session_state.results["live_data"]
if not live_data.empty:
st.subheader("Top Bullish and Bearish Stocks")
st.write("This bar chart displays stocks with the highest net sentiment. Net sentiment is computed by subtracting previous rating scores from new rating scores using a defined mapping (e.g., Strong Buy, Buy, Overweight, Outperform = +2; Hold, Neutral = 0; Underperform, Reduce, Sell = -2). This helps identify stocks viewed most positively and negatively by analysts.")
st.plotly_chart(
plot_extreme_sentiment_symbols(
live_data,
start_date=start_date,
end_date=end_date,
top_n=top_n
),
use_container_width=True
)
st.subheader("Detailed Live Feed Data")
st.write("This table provides a detailed view of the latest analyst upgrades, downgrades, and related news.")
with st.expander("View Detailed Results", expanded=False):
st.dataframe(live_data)
else:
st.warning("No live data returned from the feed.")
else:
st.info("Please run the analysis to fetch live data.")
# Hide default Streamlit style
st.markdown(
"""
""",
unsafe_allow_html=True
)