File size: 8,569 Bytes
00e5508
 
 
 
 
 
 
 
 
f6522a9
 
00e5508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cde486e
 
 
 
 
 
 
 
 
 
 
 
00e5508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cde486e
 
 
 
 
 
 
 
 
 
 
00e5508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cde486e
 
 
 
 
 
 
 
 
 
 
00e5508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cde486e
00e5508
cde486e
00e5508
 
 
cde486e
00e5508
 
 
 
 
 
 
 
 
 
 
 
 
 
cde486e
00e5508
 
 
 
cde486e
 
00e5508
 
 
 
 
 
 
 
 
 
 
cde486e
 
00e5508
 
 
 
 
 
 
 
 
 
 
cde486e
 
00e5508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
import streamlit as st
import requests
import pandas as pd
import plotly.express as px
import os

# Global API key and default ETF symbols
API_KEY = os.getenv("FMP_API_KEY")

#ETF_ASSET_SYMBOL = "SPY"  # Used for asset composition
#ETF_SYMBOL = "QDVE.DE"     # For sector and country composition, you may adjust this as needed.

def format_number(n):
    """
    Format a number with K, M, or B suffix.
    """
    abs_n = abs(n)
    if abs_n < 1e3:
        return str(round(n))
    elif abs_n < 1e6:
        return f"{round(n/1e3)}K"
    elif abs_n < 1e9:
        return f"{round(n/1e6)}M"
    else:
        return f"{round(n/1e9)}B"

#############################
# ETF Asset Composition Functions
#############################
@st.cache_data(show_spinner=False)
def get_etf_asset_composition(etf_symbol: str) -> pd.DataFrame:
    url = f"https://financialmodelingprep.com/api/v3/etf-holder/{etf_symbol}?apikey={API_KEY}"
    try:
        response = requests.get(url)
        response.raise_for_status()
        data = response.json()
        if not data:
            return pd.DataFrame()
        df = pd.DataFrame(data)
        df['marketValue'] = pd.to_numeric(df['marketValue'], errors='coerce')
        df_sorted = df.sort_values(by="marketValue", ascending=False)
        return df_sorted
    except:
        # Generic failover, no mention of the source
        return pd.DataFrame()

def plot_etf_asset_composition(df: pd.DataFrame, etf_symbol: str):
    if df.empty:
        return None
    date_as_of = df.iloc[0]['updated'] if ('updated' in df.columns and not df.empty) else "N/A"
    tickers = df['asset'].tolist()
    market_values = df['marketValue'].tolist()
    labels = []
    for _, row in df.iterrows():
        mv_formatted = format_number(row['marketValue'])
        shares_formatted = format_number(row['sharesNumber'])
        weight_formatted = f"{round(row['weightPercentage'], 2)}%"
        labels.append(
            f"{row['asset']}<br>Market Value: {mv_formatted}<br>"
            f"Shares: {shares_formatted}<br>Weight: {weight_formatted}"
        )
    title = f"ETF Holdings by Market Value for {etf_symbol} (as of {date_as_of})"
    fig = px.bar(
        x=tickers,
        y=market_values,
        text=labels,
        title=title,
        labels={"x": "Asset", "y": "Market Value (USD)"}
    )
    fig.update_traces(textposition='outside')
    fig.update_layout(uniformtext_minsize=8, uniformtext_mode='hide')
    return fig

#############################
# Sector Composition Functions
#############################
@st.cache_data(show_spinner=False)
def get_etf_sector_composition(etf_symbol: str) -> pd.DataFrame:
    url = f"https://financialmodelingprep.com/api/v3/etf-sector-weightings/{etf_symbol}?apikey={API_KEY}"
    try:
        response = requests.get(url)
        response.raise_for_status()
        data = response.json()
        if not data:
            return pd.DataFrame()
        df = pd.DataFrame(data)
        df['weightPercentage'] = df['weightPercentage'].str.rstrip('%').astype(float)
        df_sorted = df.sort_values(by="weightPercentage", ascending=False)
        return df_sorted
    except:
        return pd.DataFrame()

def plot_etf_sector_composition(df: pd.DataFrame, etf_symbol: str):
    if df.empty:
        return None
    sectors = df['sector'].tolist()
    weights = df['weightPercentage'].tolist()
    labels = [f"{sector}<br>Weight: {weight:.2f}%" for sector, weight in zip(sectors, weights)]
    title = f"ETF Sector Weighting for {etf_symbol}"
    fig = px.bar(
        x=sectors,
        y=weights,
        text=labels,
        title=title,
        labels={"x": "Sector", "y": "Weight Percentage"}
    )
    fig.update_traces(textposition='outside')
    fig.update_layout(uniformtext_minsize=8, uniformtext_mode='hide')
    return fig

#############################
# Country Composition Functions
#############################
@st.cache_data(show_spinner=False)
def get_etf_country_composition(etf_symbol: str) -> pd.DataFrame:
    url = f"https://financialmodelingprep.com/api/v3/etf-country-weightings/{etf_symbol}?apikey={API_KEY}"
    try:
        response = requests.get(url)
        response.raise_for_status()
        data = response.json()
        if not data:
            return pd.DataFrame()
        df = pd.DataFrame(data)
        df['weightPercentage'] = df['weightPercentage'].str.rstrip('%').astype(float)
        df_sorted = df.sort_values(by="weightPercentage", ascending=False)
        return df_sorted
    except:
        return pd.DataFrame()

def plot_etf_country_composition(df: pd.DataFrame, etf_symbol: str):
    if df.empty:
        return None
    countries = df['country'].tolist()
    weights = df['weightPercentage'].tolist()
    labels = [f"{country}<br>Weight: {weight:.2f}%" for country, weight in zip(countries, weights)]
    title = f"ETF Country Weighting for {etf_symbol}"
    fig = px.bar(
        x=countries,
        y=weights,
        text=labels,
        title=title,
        labels={"x": "Country", "y": "Weight Percentage"}
    )
    fig.update_traces(textposition='outside')
    fig.update_layout(uniformtext_minsize=8, uniformtext_mode='hide')
    return fig

#############################
# MAIN APP
#############################
def main():
    st.set_page_config(page_title="ETF Asset Composition Dashboard", layout="wide")
    st.title("ETF Asset Composition Dashboard")
    st.write(
        "Analyze the composition of an ETF. "
        "Use the side menu to enter the ETF ticker symbol and then click the 'Run ETF Composition' button. "
        "The analysis is divided into three sections: Asset Composition, Sector Composition, and Country Composition. "
        "Each section includes a chart and a data table. Hover over the charts for more details."
    )
    
    # Initialize run button state
    if "run_etf" not in st.session_state:
        st.session_state.run_etf = False

    # Sidebar: Settings inside an expander
    with st.sidebar.expander("Settings", expanded=True):
        etf_ticker = st.text_input(
            "ETF Ticker",
            value="SPY",
            help="Enter the ticker symbol of the ETF (e.g., SPY, QQQ)."
        )
        if st.button("Run ETF Composition"):
            st.session_state.run_etf = True
            st.session_state.etf_ticker = etf_ticker

    # Main content
    if st.session_state.get("run_etf", False):
        # Section 1: ETF Asset Composition
        st.header("1. ETF Asset Composition")
        st.write(
            "Shows the top holdings by market value. "
            "The bar chart includes each asset's market value, shares held, and weight in the ETF."
        )
        asset_df = get_etf_asset_composition(st.session_state.etf_ticker)
        asset_fig = plot_etf_asset_composition(asset_df, st.session_state.etf_ticker)
        if asset_fig is not None:
            st.plotly_chart(asset_fig, use_container_width=True)
        st.subheader("Data")
        st.dataframe(asset_df, use_container_width=True)
        
        # Section 2: Sector Composition
        st.header("2. Sector Composition")
        st.write(
            "Displays the ETF's sector weighting. "
            "The bar chart visualizes each sector's percentage in the portfolio."
        )
        sector_df = get_etf_sector_composition(st.session_state.etf_ticker)
        sector_fig = plot_etf_sector_composition(sector_df, st.session_state.etf_ticker)
        if sector_fig is not None:
            st.plotly_chart(sector_fig, use_container_width=True)
        st.subheader("Data")
        st.dataframe(sector_df, use_container_width=True)
        
        # Section 3: Country Composition
        st.header("3. Country Composition")
        st.write(
            "Shows the geographic distribution of the ETF's holdings by country. "
            "The bar chart displays the weight percentage by country."
        )
        country_df = get_etf_country_composition(st.session_state.etf_ticker)
        country_fig = plot_etf_country_composition(country_df, st.session_state.etf_ticker)
        if country_fig is not None:
            st.plotly_chart(country_fig, use_container_width=True)
        st.subheader("Data")
        st.dataframe(country_df, use_container_width=True)
    else:
        st.info("Please enter the ETF ticker in the sidebar and click 'Run ETF Composition'.")

if __name__ == "__main__":
    main()

hide_streamlit_style = """
<style>
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
</style>
"""
st.markdown(hide_streamlit_style, unsafe_allow_html=True)