Spaces:
Running
Running
| import streamlit as st | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| import plotly.express as px | |
| import requests | |
| import yfinance as yf | |
| from datetime import datetime, date | |
| import os | |
| st.set_page_config(layout="wide") | |
| FMP_API_KEY = os.getenv("FMP_API_KEY") | |
| # ------------------------------------------------------------------- | |
| # Initialize session state defaults | |
| # ------------------------------------------------------------------- | |
| if "valid_ticker" not in st.session_state: | |
| st.session_state["valid_ticker"] = None | |
| if "ticker" not in st.session_state: | |
| st.session_state["ticker"] = None | |
| if "hist" not in st.session_state: | |
| st.session_state["hist"] = None | |
| if "consensus" not in st.session_state: | |
| st.session_state["consensus"] = None | |
| if "df_targets" not in st.session_state: | |
| st.session_state["df_targets"] = None | |
| if "df_rss" not in st.session_state: | |
| st.session_state["df_rss"] = None | |
| # ------------------------------------------------------------------- | |
| # Column reordering helper: move specified columns to the end | |
| # ------------------------------------------------------------------- | |
| def move_columns_to_end(df, cols_to_move): | |
| existing = [col for col in cols_to_move if col in df.columns] | |
| fixed_order = [col for col in df.columns if col not in existing] + existing | |
| return df[fixed_order] | |
| # ------------------------------------------------------------------- | |
| # Cache functions | |
| # ------------------------------------------------------------------- | |
| def fetch_yfinance_data(symbol, period="5y"): | |
| try: | |
| ticker_obj = yf.Ticker(symbol) | |
| hist = ticker_obj.history(period=period) | |
| if hist.empty: | |
| raise ValueError("No historical data found.") | |
| return hist | |
| except: | |
| st.error("Unable to fetch historical price data.") | |
| return None | |
| def fetch_fmp_consensus(symbol): | |
| try: | |
| url = f"https://financialmodelingprep.com/api/v4/price-target-consensus?symbol={symbol}&apikey={FMP_API_KEY}" | |
| response = requests.get(url) | |
| data = response.json() | |
| if data and len(data) > 0: | |
| return data[0] | |
| else: | |
| raise ValueError("No consensus data returned.") | |
| except: | |
| st.error("Unable to fetch consensus data.") | |
| return None | |
| def fetch_price_target_data(symbol): | |
| try: | |
| url = f"https://financialmodelingprep.com/api/v4/price-target?symbol={symbol}&apikey={FMP_API_KEY}" | |
| response = requests.get(url) | |
| data = response.json() | |
| if data: | |
| df = pd.DataFrame(data) | |
| df['publishedDate'] = pd.to_datetime(df['publishedDate']) | |
| return df | |
| else: | |
| raise ValueError("No price target data returned.") | |
| except: | |
| st.error("Unable to fetch price target data.") | |
| return None | |
| def fetch_price_target_rss_feed(num_pages=5): | |
| try: | |
| all_data = [] | |
| for page in range(num_pages): | |
| url = f"https://financialmodelingprep.com/api/v4/price-target-rss-feed?page={page}&apikey={FMP_API_KEY}" | |
| response = requests.get(url) | |
| if response.status_code == 200: | |
| data = response.json() | |
| all_data.extend(data) | |
| if all_data: | |
| df = pd.DataFrame(all_data) | |
| df['publishedDate'] = pd.to_datetime(df['publishedDate']) | |
| return df | |
| else: | |
| raise ValueError("No live feed data returned.") | |
| except: | |
| st.error("Unable to fetch live feed data.") | |
| return None | |
| def is_valid_ticker(tkr): | |
| try: | |
| _ = yf.Ticker(tkr).info | |
| return True | |
| except: | |
| return False | |
| # ------------------------------------------------------------------- | |
| # Sidebar | |
| # ------------------------------------------------------------------- | |
| st.sidebar.title("Analysis Parameters") | |
| with st.sidebar.expander("Page Selection", expanded=True): | |
| page = st.radio( | |
| "Select a page", | |
| ["Price Targets by Ticker", "Price Target Live Feed"], | |
| help="Choose a view for detailed stock data or a live feed of recent targets." | |
| ) | |
| if page == "Price Targets by Ticker": | |
| with st.sidebar.expander("Analysis Inputs", expanded=True): | |
| ticker = st.text_input( | |
| "Ticker Symbol", | |
| value="AAPL", | |
| help="Enter a valid stock ticker symbol (e.g. AAPL)." | |
| ) | |
| run_analysis = st.sidebar.button("Run Analysis") | |
| else: | |
| run_analysis = st.sidebar.button("Run Analysis", help="Fetch the latest live feed data.") | |
| # ------------------------------------------------------------------- | |
| # Logic to store data in session state if Run Analysis is clicked | |
| # ------------------------------------------------------------------- | |
| if page == "Price Targets by Ticker": | |
| if run_analysis: | |
| if not is_valid_ticker(ticker): | |
| st.session_state["valid_ticker"] = False | |
| else: | |
| st.session_state["valid_ticker"] = True | |
| st.session_state["ticker"] = ticker | |
| st.session_state["hist"] = fetch_yfinance_data(ticker) | |
| st.session_state["consensus"] = fetch_fmp_consensus(ticker) | |
| st.session_state["df_targets"] = fetch_price_target_data(ticker) | |
| elif page == "Price Target Live Feed": | |
| if run_analysis: | |
| st.session_state["df_rss"] = fetch_price_target_rss_feed(num_pages=5) | |
| # ------------------------------------------------------------------- | |
| # Main Page Content | |
| # ------------------------------------------------------------------- | |
| if page == "Price Targets by Ticker": | |
| st.title("Analyst Price Targets") | |
| if st.session_state["valid_ticker"] is None: | |
| st.markdown("Enter a stock symbol and click **Run Analysis** to load the data.") | |
| elif st.session_state["valid_ticker"] is False: | |
| st.error("Invalid symbol. Please try again.") | |
| else: | |
| ticker = st.session_state["ticker"] | |
| hist = st.session_state["hist"] | |
| consensus = st.session_state["consensus"] | |
| df_targets = st.session_state["df_targets"] | |
| # Fixed bubble size multiplier | |
| bubble_multiplier = 1.2 | |
| # ----------------------------------------- | |
| # 12 Month Analyst Forecast Consensus | |
| # ----------------------------------------- | |
| if hist is not None and consensus is not None: | |
| st.markdown("### Analyst Forecast (12-Month)") | |
| st.write("This chart shows the stock's closing price history. " | |
| "It also shows projected targets for the next year, " | |
| "including high, low, median, and overall consensus.") | |
| def plot_price_data_with_targets(history_df, cons, symbol, forecast_months=12): | |
| last_date = history_df.index[-1] | |
| future_date = last_date + pd.DateOffset(months=forecast_months) | |
| last_close = history_df['Close'][-1] | |
| extended_future_date = future_date + pd.DateOffset(days=90) | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=history_df.index, | |
| y=history_df['Close'], | |
| mode='lines', | |
| name='Close Price', | |
| line=dict(color='royalblue', width=2), | |
| hovertemplate='Date: %{x}<br>Price: %{y:.2f}<extra></extra>' | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=[last_date], | |
| y=[last_close], | |
| mode='markers', | |
| marker=dict(color='white', size=12, symbol='circle'), | |
| name="Current Price", | |
| hovertemplate='Date: %{x}<br>Price: %{y:.2f}<extra></extra>' | |
| )) | |
| annotations = [ | |
| dict( | |
| x=last_date, | |
| y=last_close, | |
| text=f"{round(last_close)}", | |
| font=dict(size=16, color='white'), | |
| showarrow=False, | |
| yshift=30 | |
| ) | |
| ] | |
| targets = [ | |
| ("Target High", cons["targetHigh"], "green"), | |
| ("Target Low", cons["targetLow"], "red"), | |
| ("Target Consensus", cons["targetConsensus"], "orange"), | |
| ("Target Median", cons["targetMedian"], "purple") | |
| ] | |
| for name, val, color in targets: | |
| val_rounded = round(val) | |
| fig.add_trace(go.Scatter( | |
| x=[last_date, future_date], | |
| y=[last_close, val_rounded], | |
| mode='lines', | |
| line=dict(dash='dash', color=color, width=2), | |
| name=name, | |
| hovertemplate=f"{name}: {val_rounded}<extra></extra>" | |
| )) | |
| annotations.append( | |
| dict( | |
| x=future_date, | |
| y=val_rounded, | |
| text=f"<b>{val_rounded}</b>", | |
| showarrow=True, | |
| arrowhead=2, | |
| ax=20, | |
| ay=0, | |
| font=dict(color=color, size=20) | |
| ) | |
| ) | |
| fig.add_shape( | |
| type="line", | |
| x0=last_date, | |
| x1=last_date, | |
| y0=history_df['Close'].min(), | |
| y1=history_df['Close'].max(), | |
| line=dict(color="gray", dash="dot") | |
| ) | |
| fig.update_layout( | |
| template='plotly_dark', | |
| paper_bgcolor='#0e1117', | |
| plot_bgcolor='#0e1117', | |
| font=dict(color='white'), | |
| title=dict(text=f"{symbol} Price History & 12-Month Targets", font=dict(color='white')), | |
| legend=dict( | |
| x=0.01, y=0.99, | |
| bordercolor="white", | |
| borderwidth=1, | |
| font=dict(color='white') | |
| ), | |
| xaxis=dict( | |
| range=[history_df.index[0], extended_future_date], | |
| showgrid=True, | |
| gridcolor='gray', | |
| title=dict(text="Date", font=dict(color='white')), | |
| tickfont=dict(color='white') | |
| ), | |
| yaxis=dict( | |
| showgrid=True, | |
| gridcolor='gray', | |
| title=dict(text="Price", font=dict(color='white')), | |
| tickfont=dict(color='white') | |
| ), | |
| annotations=annotations, | |
| margin=dict(l=40, r=40, t=60, b=40) | |
| ) | |
| return fig | |
| fig_consensus = plot_price_data_with_targets(hist, consensus, ticker) | |
| st.plotly_chart(fig_consensus, use_container_width=True) | |
| # ----------------------------------------- | |
| # Price Target Evolution (Bubble Chart) | |
| # ----------------------------------------- | |
| st.markdown("### Analyst Price Target Changes Over Time") | |
| st.write("This chart shows how price targets have shifted. " | |
| "Bubble sizes represent the percentage change from the posted price.") | |
| if df_targets is not None: | |
| def plot_price_target_evolution(df): | |
| df['publishedDate'] = pd.to_datetime(df['publishedDate'], errors='coerce').dt.tz_localize(None) | |
| df['targetChange'] = df['priceTarget'] - df['priceWhenPosted'] | |
| df['direction'] = df['targetChange'].apply( | |
| lambda x: "Raised" if x > 0 else ("Lowered" if x < 0 else "No Change") | |
| ) | |
| df['percentChange'] = (df['targetChange'] / df['priceWhenPosted']) * 100 | |
| color_map = {"Raised": "green", "Lowered": "red", "No Change": "gray"} | |
| colors = df['direction'].map(color_map) | |
| bubble_sizes = abs(df['percentChange']) * bubble_multiplier | |
| df['date'] = df['publishedDate'].dt.date | |
| daily_median = df.groupby('date')['priceTarget'].median() | |
| daily_median.index = pd.to_datetime(daily_median.index) | |
| fig = go.Figure() | |
| # Price When Posted line+markers | |
| fig.add_trace(go.Scatter( | |
| x=df['publishedDate'], | |
| y=df['priceWhenPosted'], | |
| mode='lines+markers', | |
| name='Price When Posted', | |
| line=dict(color='royalblue', width=2, dash='dot'), | |
| marker=dict(size=8), | |
| hovertemplate='Date: %{x}<br>Price When Posted: %{y:.2f}<extra></extra>' | |
| )) | |
| # Bubble markers for Price Target | |
| fig.add_trace(go.Scatter( | |
| x=df['publishedDate'], | |
| y=df['priceTarget'], | |
| mode='markers', | |
| name='Price Target', | |
| marker=dict( | |
| size=bubble_sizes, | |
| color=colors, | |
| opacity=0.7, | |
| line=dict(width=1, color='black') | |
| ), | |
| hovertemplate=( | |
| "<b>%{customdata[0]}</b><br>" | |
| "Published: %{x}<br>" | |
| "Price Target: %{y:.2f}<br>" | |
| "Price When Posted: %{customdata[1]:.2f}<br>" | |
| "Target Change: %{customdata[2]:.2f}<br>" | |
| "Percent Change: %{customdata[3]:.2f}%<br>" | |
| "Bubble Scale: 2.0" | |
| "<extra></extra>" | |
| ), | |
| customdata=df[['newsTitle', 'priceWhenPosted', 'targetChange', 'percentChange']].values | |
| )) | |
| # Median line | |
| if not daily_median.empty: | |
| fig.add_trace(go.Scatter( | |
| x=daily_median.index, | |
| y=daily_median.values, | |
| mode='lines', | |
| name='Median Price Target', | |
| line=dict(color='white', dash='dash', width=3, shape='hv'), | |
| hovertemplate='Date: %{x}<br>Median Price Target: %{y:.2f}<extra></extra>' | |
| )) | |
| # Annotation for latest price | |
| if not df.empty: | |
| current_date = df['publishedDate'].max() | |
| current_price = df.loc[df['publishedDate'] == current_date, 'priceWhenPosted'].iloc[-1] | |
| fig.add_annotation( | |
| x=current_date, | |
| y=current_price, | |
| text=f"<b>{round(current_price)}</b>", | |
| showarrow=False, | |
| font=dict(size=16, color='white'), | |
| yshift=30 | |
| ) | |
| fig.update_layout( | |
| template='plotly_dark', | |
| paper_bgcolor='#0e1117', | |
| plot_bgcolor='#0e1117', | |
| font=dict(color='white'), | |
| title=dict(text=f"{ticker}: Posted Price, Price Targets & Daily Median", font=dict(color='white')), | |
| legend=dict( | |
| x=0.01, y=0.99, | |
| bordercolor="white", | |
| borderwidth=1, | |
| font=dict(color='white') | |
| ), | |
| xaxis=dict( | |
| showgrid=True, | |
| gridcolor='gray', | |
| title=dict(text="Published Date", font=dict(color='white')), | |
| tickfont=dict(color='white') | |
| ), | |
| yaxis=dict( | |
| showgrid=True, | |
| gridcolor='gray', | |
| title=dict(text="Price (USD)", font=dict(color='white')), | |
| tickfont=dict(color='white') | |
| ), | |
| margin=dict(l=40, r=40, t=60, b=40) | |
| ) | |
| return fig | |
| fig_evolution = plot_price_target_evolution(df_targets) | |
| st.plotly_chart(fig_evolution, use_container_width=True) | |
| st.markdown("### Detailed Historical Price Targets") | |
| st.write("This table lists recent price targets, news headlines, and links.") | |
| df_targets["MovementChart"] = df_targets.apply( | |
| lambda row: [row["priceWhenPosted"], row["priceTarget"]], | |
| axis=1 | |
| ) | |
| df_targets = move_columns_to_end( | |
| df_targets, | |
| ["newsTitle","newsURL","newsPublisher","newsBaseURL","url"] | |
| ) | |
| with st.expander("Detailed Data", expanded=False): | |
| st.dataframe( | |
| df_targets, | |
| column_config={ | |
| "MovementChart": st.column_config.LineChartColumn( | |
| "From Posted to Target", | |
| help="Line from priceWhenPosted to priceTarget", | |
| ) | |
| }, | |
| height=300 | |
| ) | |
| elif page == "Price Target Live Feed": | |
| st.title("Live Analyst Targets") | |
| if st.session_state["df_rss"] is None: | |
| st.markdown("Click **Run Analysis** to fetch the latest feed.") | |
| else: | |
| df_rss = st.session_state["df_rss"] | |
| if not df_rss.empty: | |
| st.markdown("### Latest Analyst Announcements") | |
| st.write("This chart shows a daily view of median percentage changes in targets for various symbols.") | |
| def plot_rss_feed(df): | |
| df['date'] = df['publishedDate'].dt.date | |
| df['targetChange'] = df['priceTarget'] - df['priceWhenPosted'] | |
| df['percentChange'] = (df['targetChange'] / df['priceWhenPosted']) * 100 | |
| grouped = df.groupby(['date', 'symbol']).agg({ | |
| 'percentChange': 'median', | |
| 'priceTarget': 'median', | |
| 'priceWhenPosted': 'median' | |
| }).reset_index() | |
| if grouped.empty: | |
| return None | |
| grouped['date'] = pd.to_datetime(grouped['date']) | |
| fig = px.scatter( | |
| grouped, | |
| x='date', | |
| y='symbol', | |
| size=grouped['percentChange'].abs(), | |
| color='percentChange', | |
| color_continuous_scale='RdYlGn', | |
| title='Daily Median Analyst % Change by Symbol', | |
| labels={'date': 'Date', 'symbol': 'Ticker', 'percentChange': '% Change'} | |
| ) | |
| unique_symbols = grouped['symbol'].nunique() | |
| fig.update_layout( | |
| template='plotly_dark', | |
| paper_bgcolor='#0e1117', | |
| plot_bgcolor='#0e1117', | |
| font=dict(color='white'), | |
| title=dict(text='Daily Median Analyst % Change by Symbol', font=dict(color='white')), | |
| xaxis=dict( | |
| showgrid=True, | |
| gridcolor='gray', | |
| title=dict(text="Date", font=dict(color='white')), | |
| tickfont=dict(color='white') | |
| ), | |
| yaxis=dict( | |
| showgrid=True, | |
| gridcolor='gray', | |
| title=dict(text="Ticker", font=dict(color='white')), | |
| tickfont=dict(color='white') | |
| ), | |
| height=(unique_symbols * 10), | |
| margin=dict(l=40, r=40, t=60, b=40) | |
| ) | |
| fig.update_traces( | |
| customdata=grouped[['symbol', 'percentChange', 'priceTarget', 'priceWhenPosted']].values, | |
| hovertemplate=( | |
| "<b>%{customdata[0]}</b><br>" | |
| "Date: %{x}<br>" | |
| "Median % Change: %{customdata[1]:.2f}%<br>" | |
| "Median Target: %{customdata[2]:.2f}<br>" | |
| "Median Posted: %{customdata[3]:.2f}<extra></extra>" | |
| ) | |
| ) | |
| return fig | |
| feed_fig = plot_rss_feed(df_rss) | |
| if feed_fig: | |
| st.plotly_chart(feed_fig, use_container_width=True) | |
| else: | |
| st.info("No grouped data to plot.") | |
| st.markdown("### Detailed Live Feed Data") | |
| st.write("This table lists recent announcements with their posted price and target.") | |
| df_rss["MovementChart"] = df_rss.apply( | |
| lambda row: [row["priceWhenPosted"], row["priceTarget"]], | |
| axis=1 | |
| ) | |
| df_rss = move_columns_to_end( | |
| df_rss, | |
| ["newsTitle","newsURL","newsPublisher","newsBaseURL","url"] | |
| ) | |
| with st.expander("Detailed Data", expanded=False): | |
| st.dataframe( | |
| df_rss, | |
| column_config={ | |
| "MovementChart": st.column_config.LineChartColumn( | |
| "From Posted to Target", | |
| help="Line from priceWhenPosted to priceTarget", | |
| ) | |
| }, | |
| height=300 | |
| ) | |
| else: | |
| st.info("No live feed data available.") | |
| # Hide default Streamlit style | |
| st.markdown( | |
| """ | |
| <style> | |
| #MainMenu {visibility: hidden;} | |
| footer {visibility: hidden;} | |
| </style> | |
| """, | |
| unsafe_allow_html=True | |
| ) |