├── .dockerignore ├── .gitignore ├── README.md ├── build ├── Dockerfile ├── requirements.txt └── src │ ├── app.py │ ├── envs.py │ ├── fmp_api.py │ ├── global_states.py │ ├── loader.py │ ├── logo.png │ ├── portfolio_analysis.py │ ├── shared.py │ └── ticker_analysis.py ├── docker-compose.yml ├── down.sh ├── init.sh ├── run.sh └── run_docker.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | **.env -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.env 2 | **__pycache__ 3 | **stocks.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Financial Dashboard for Dividend Stocks 2 | 3 | This is a Python web app built with [Solara](https://github.com/widgetti/solara) where you can analyze dividend stocks. 4 | 5 | * [Article](https://medium.com/@dreamferus/creating-financial-dashboards-in-python-with-solara-70c82f39391d?sk=2552f997f26ae15528b4491c7c1bb6ba) 6 | * [Video, Stock Analysis](https://www.youtube.com/watch?v=0DzHakZImvU) 7 | * [Video, Portfolio Analysis](https://www.youtube.com/watch?v=BxmUgVwCQyc) 8 | 9 | ## Setup 10 | 11 | First clone the repository: 12 | 13 | ```bash 14 | git clone git@github.com:FerusAndBeyond/python-dividend-dashboard.git 15 | ``` 16 | 17 | and then cd into it 18 | 19 | ```bash 20 | cd python-dividend-dashboard 21 | ``` 22 | 23 | #### Environment variables 24 | 25 | Run `init.sh`: 26 | 27 | ```bash 28 | sh init.sh 29 | ``` 30 | 31 | This will create a `.env` file for configuration variables such as the needed API keys. The following APIs are used: 32 | 33 | ###### Financial Modeling Prep 34 | 35 | Financial Modeling Prep (FMP) is a financial data API containing various data for fundamental analysis. You can sign up for free to obtain 250 API requests per day. Alternatively, for a premium version with more requests and additional features, you can sign up for a paid version. You can support me by using my affiliate link while also getting 15% off [here](https://utm.guru/uggRv). 36 | 37 | More info about FMP [here](https://site.financialmodelingprep.com/developer/docs). 38 | 39 | ###### OpenAI API 40 | 41 | OpenAI, the company that created ChatGPT, has an API that you can use to analyze and generate text using AI. More info [here](https://platform.openai.com/docs/overview). 42 | 43 | --- 44 | 45 | Add the two API keys to the `.env` file for variables `FMP_API_KEY` and `OPENAI_API_KEY`. 46 | 47 | #### Run 48 | 49 | There are two options, run in docker or outside of docker. To run in docker, you need to first have docker installed. Then, simply call: 50 | 51 | ###### Docker 52 | 53 | ```bash 54 | sh run_docker.sh 55 | ``` 56 | 57 | Then open http://localhost:5000/. 58 | 59 | To shut it down, use `sh down.sh`. 60 | 61 | ###### Outside of Docker 62 | 63 | To run outside of Docker, first install the dependencies: 64 | 65 | ```bash 66 | pip install -r build/requirements.txt 67 | ``` 68 | 69 | Then run the app: 70 | 71 | ```bash 72 | sh run.sh 73 | ``` 74 | 75 | and open http://localhost:8765/. -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | WORKDIR /app 3 | COPY requirements.txt . 4 | RUN pip install -r requirements.txt 5 | COPY src . 6 | CMD ["solara", "run", "app.py", "--host=0.0.0.0", "--production"] -------------------------------------------------------------------------------- /build/requirements.txt: -------------------------------------------------------------------------------- 1 | solara==1.28.0 2 | openai==1.2.3 3 | pandas==2.1.0 4 | requests==2.31.0 5 | plotly==5.18.0 6 | pydantic==2.1.1 7 | pydantic_core==2.4.0 8 | pydantic-settings==2.0.2 -------------------------------------------------------------------------------- /build/src/app.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import os 3 | import random 4 | from threading import Thread 5 | import time 6 | from typing import Optional, List 7 | import requests 8 | from requests.exceptions import HTTPError 9 | import plotly.express as px 10 | import plotly.graph_objects as go 11 | from solara.alias import rv 12 | from datetime import datetime 13 | from openai import OpenAI, APIConnectionError 14 | import solara as sl 15 | from urllib.parse import urlencode 16 | from loader import Loader 17 | from envs import envs 18 | from ticker_analysis import TickerAnalysis 19 | from portfolio_analysis import PortfolioAnalysis 20 | from global_states import global_loader 21 | 22 | TITLE = "Dividend Stock Analyzer" 23 | 24 | @sl.component 25 | def Page(): 26 | # css styling 27 | sl.Style(""" 28 | html, body { 29 | overflow-x: hidden; 30 | width: 100%; 31 | } 32 | .logo { 33 | width: 150px; 34 | margin-top: 10px; 35 | border-radius: 100%; 36 | box-shadow: 0 0 3px rgba(0,0,0,0.5); 37 | } 38 | 39 | .logo-animation { 40 | animation: logo-animation 1s infinite; 41 | } 42 | 43 | @keyframes logo-animation { 44 | 0% { 45 | box-shadow: 0 0 3px rgba(0,0,0,0.5); 46 | } 47 | 50% { 48 | box-shadow: 0 0 10px rgb(0 133 255); 49 | } 50 | 100% { 51 | box-shadow: 0 0 3px rgba(0,0,0,0.5); 52 | } 53 | } 54 | 55 | /* remove solara watermark */ 56 | .v-application--wrap > :nth-child(2) > :nth-child(2) { 57 | visibility: hidden; 58 | } 59 | """) 60 | 61 | # title, shown in the browser tab 62 | sl.Title(TITLE) 63 | 64 | menu, set_menu = sl.use_state("Ticker Analysis") 65 | 66 | # image and title 67 | with sl.Column(align="center"): 68 | # classes is CSS-classes, 69 | # here an extra animation is added during loading 70 | sl.Image("./logo.png", classes=["logo"] + ([] if global_loader.value is None else ["logo-animation"])) 71 | sl.HTML("h1", TITLE) 72 | 73 | # all menu options 74 | menu_options = [("Ticker Analysis", TickerAnalysis), ("Portfolio Analysis", PortfolioAnalysis)] 75 | # one column for each option 76 | with sl.Columns([1]*len(menu_options), style="width: 500px; margin: 0 auto;"): 77 | for option, _ in menu_options: 78 | with sl.Column(align="center"): 79 | # button to choose the menu 80 | sl.Button(option, color="primary" if option == menu else "default", on_click=lambda option=option: set_menu(option)) 81 | 82 | with sl.Column(style="margin: 10px; padding: 10px;"): 83 | for option, component in menu_options: 84 | # display the chosen menu only 85 | if menu == option: 86 | if component is not None: 87 | component() -------------------------------------------------------------------------------- /build/src/envs.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings, SettingsConfigDict 2 | import os 3 | 4 | class Envs(BaseSettings): 5 | fmp_api_key: str 6 | openai_api_key: str 7 | openai_model: str = "gpt-3.5-turbo" 8 | 9 | kwargs = {} if os.getenv("IN_DOCKER") == "true" else dict(_env_file="../../.env") 10 | envs = Envs(**kwargs) -------------------------------------------------------------------------------- /build/src/fmp_api.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import pandas as pd 3 | from urllib.parse import urlencode 4 | import requests 5 | from threading import Thread 6 | 7 | class FMPAPI: 8 | def __init__(self, api_key: str, n_requests_in_parallel: Optional[int] = None): 9 | self._api_key = api_key 10 | self.data = {} 11 | self.n_requests_in_parallel = n_requests_in_parallel 12 | self._threads = [] 13 | 14 | def get_sync(self, *args, **kwargs): 15 | self._get(*args, **kwargs) 16 | return self.data[args[0]] 17 | 18 | def get(self, *args, **kwargs): 19 | if self.n_requests_in_parallel is not None and len(self._threads) >= self.n_requests_in_parallel: 20 | self.collect(1) 21 | th = Thread(target=self._get, args=args, kwargs=kwargs) 22 | self._threads.append(th) 23 | th.start() 24 | 25 | def collect(self, n: Optional[int] = None): 26 | if n is None: 27 | n = len(self._threads) 28 | for th in self._threads[:n]: 29 | th.join() 30 | self._threads = self._threads[n:] 31 | 32 | def _get(self, name: str, endpoint: str, to_dataframe: bool = True, first: bool = False, use_key: Optional[str]=None, **query_params: str | float | int): 33 | if query_params is None: 34 | query_params = {} 35 | else: 36 | # handle reserved keywords in Python, e.g. _from -> from 37 | query_params = { 38 | (k if not k.startswith("_") else k[1:]): v 39 | for k, v in query_params.items() 40 | } 41 | query_params |= { "apikey": self._api_key } 42 | qs = urlencode(query_params) 43 | if endpoint.startswith("/"): 44 | endpoint = endpoint[1:] 45 | response = requests.get(f"https://financialmodelingprep.com/api/v3/{endpoint}?{qs}") 46 | response.raise_for_status() 47 | 48 | data = response.json() 49 | 50 | if first: 51 | data = data[0] 52 | 53 | if use_key is not None: 54 | data = data[use_key] 55 | 56 | if to_dataframe: 57 | data = pd.DataFrame(data) 58 | if "date" in data.columns: 59 | data = data.set_index("date") 60 | data.index = pd.to_datetime(data.index) 61 | data = data.sort_index() 62 | self.data[name] = data 63 | -------------------------------------------------------------------------------- /build/src/global_states.py: -------------------------------------------------------------------------------- 1 | import solara as sl 2 | 3 | global_loader = sl.Reactive(None) -------------------------------------------------------------------------------- /build/src/loader.py: -------------------------------------------------------------------------------- 1 | import solara as sl 2 | from typing import Optional 3 | from solara.alias import rv 4 | 5 | @sl.component 6 | def Loader(text: Optional[str]=None): 7 | sl.Style(""" 8 | .loader { 9 | font-size: 3em; 10 | animation: spinner 0.75s infinite; 11 | color: green !important; 12 | } 13 | 14 | @keyframes spinner { 15 | 0% { 16 | transform: rotate(0deg); 17 | } 18 | 100% { 19 | transform: rotate(359deg); 20 | } 21 | } 22 | 23 | .loader-text { 24 | font-size: 1.5em; 25 | font-weight: 500; 26 | margin-top: 1em; 27 | } 28 | """) 29 | 30 | with sl.Column(style="text-align: center;"): 31 | rv.Icon(children=['mdi-currency-usd'], class_="loader", style_="font-size: 3em;") 32 | if text is not None: 33 | sl.HTML("div", unsafe_innerHTML=text, class_="loader-text") -------------------------------------------------------------------------------- /build/src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FerusAndBeyond/python-dividend-dashboard/a3c204662b56bb7ecf00843eaae51453c8979bf9/build/src/logo.png -------------------------------------------------------------------------------- /build/src/portfolio_analysis.py: -------------------------------------------------------------------------------- 1 | import solara as sl 2 | import time 3 | import random 4 | from copy import deepcopy 5 | import pandas as pd 6 | import json 7 | from json import JSONEncoder 8 | import pandas as pd 9 | import os 10 | from datetime import datetime, timedelta 11 | import solara as sl 12 | from loader import Loader 13 | from envs import envs 14 | from fmp_api import FMPAPI 15 | from shared import TickerInput, ticker_exists, Center 16 | from global_states import global_loader 17 | pd.options.plotting.backend = "plotly" 18 | 19 | # save chosen stocks and dividends to json-files 20 | STOCKS_PATH = "./stocks.json" 21 | 22 | TIME_PERIOD_MAPPING = { # from text to pd.Grouper 23 | "Month": dict(pandas="M", text="monthly", tickformat="%Y-%b"), 24 | "Week": dict(pandas="W", text="weekly", tickformat="%Y-%m-%d"), 25 | "Day": dict(pandas="D", text="daily", tickformat="%Y-%m-%d"), 26 | } 27 | 28 | class CustomJSONEncoder(JSONEncoder): 29 | def default(self, o): 30 | if isinstance(o, pd.Timestamp): 31 | return o.isoformat() 32 | return super().default(o) 33 | 34 | def extract_stock_dividends(stocks): 35 | divs = [] 36 | for stock in stocks: 37 | for div in stock["dividends"]: 38 | # old dividends with no exact date, better ignore 39 | if pd.isnull(pd.to_datetime(div["paymentDate"])): 40 | continue 41 | divs.append({ 42 | **{k: v for k, v in stock.items() if k != "dividends"}, 43 | **stock, 44 | "date": pd.to_datetime(div["paymentDate"]), 45 | "dividend": stock["shares"] * div["dividend"] 46 | }) 47 | return sorted(divs, key=lambda x: x["date"]) 48 | 49 | CURRENCY_TO_SIGN = { 50 | "USD": "$", 51 | "SEK": "kr", 52 | "EURO": "€", 53 | # add if you use other... 54 | } 55 | 56 | stocks = sl.Reactive(None) 57 | 58 | @sl.component 59 | def PorfolioStatistics(): 60 | if stocks.value is None or len(stocks.value) == 0: 61 | return 62 | 63 | divs = extract_stock_dividends(stocks.value) 64 | if len(divs) == 0: 65 | return 66 | 67 | # calculate dividend yield (based on past 12 months) 68 | divs = pd.DataFrame(divs).set_index("date").loc[lambda x: (x.index > datetime.utcnow() - timedelta(days=365)) & (x.index < datetime.utcnow())] 69 | stock_weight = divs.groupby("ticker").apply(lambda x: x["shares"].iloc[0] * x["price"].iloc[0]).pipe(lambda x: x / x.sum()) 70 | yield_per_stock = (divs 71 | .groupby("ticker") 72 | .apply(lambda x: 100*x["dividend"].sum() / (x["shares"].iloc[0]*x["price"].iloc[0])) 73 | .sort_values(ascending=False) 74 | ) # to percent 75 | yield_per_stock.name = "Yield (%)" 76 | total_value = pd.DataFrame(stocks.value).groupby("currency").apply(lambda x: (x["shares"] * x["price"]).sum()).to_dict() 77 | yield_porfolio = yield_per_stock.values.dot(stock_weight) 78 | 79 | with sl.Card(title="Portfolio info"): 80 | for currency, value in total_value.items(): 81 | sl.Text(f"Total value ({currency}): {value:.2f}", style="font-size: 1em") 82 | sl.HTML("br") 83 | sl.Text(f"Portfolio dividend yield: {yield_porfolio:.2}%", style="font-size: 1em") 84 | # chart with yields 85 | fig = yield_per_stock.plot.bar() 86 | fig.update_xaxes(title_text="Company") 87 | fig.update_layout(title="Dividend yield per stock", title_x=0.5) 88 | fig.update_yaxes(title_text="Dividend yield (%)") 89 | sl.FigurePlotly(fig) 90 | 91 | 92 | 93 | @sl.component 94 | def NextDividend(): 95 | if stocks.value is None or len(stocks.value) == 0: 96 | return 97 | 98 | divs = [div for div in extract_stock_dividends(stocks.value) if div["date"] > datetime.utcnow()] 99 | 100 | if len(divs) == 0: 101 | return 102 | 103 | 104 | next_div = divs[0] 105 | currency_sign = CURRENCY_TO_SIGN[next_div["currency"]] 106 | dividend = next_div["dividend"] 107 | ticker = next_div["ticker"] 108 | 109 | # round up 110 | next_div_in_days = (next_div["date"] - datetime.utcnow()).days+1 111 | 112 | with sl.Column(style="align-items: center;"): 113 | sl.Text(f"Next dividend", style="font-size: 1.5em") 114 | sl.Text(f"{currency_sign}{dividend}", style="color: green; font-size: 2em;") 115 | with sl.Row(): 116 | sl.Text("from") 117 | sl.Text("$" + ticker, style="font-size: 1.5; color: green;") 118 | sl.Text("in") 119 | with sl.Row(style="align-items: center;"): 120 | sl.Text(str(next_div_in_days), style="font-size: 6em") 121 | sl.Text("days", style="font-size: 1.5em") 122 | 123 | @sl.component 124 | def DividendCalendarGraph(): 125 | if len(stocks.value) == 0: 126 | return sl.Text("No stocks added yet.") 127 | 128 | time_period, set_time_period = sl.use_state("Day") 129 | 130 | divs = extract_stock_dividends(stocks.value) 131 | grouped = (pd.DataFrame(divs) 132 | .set_index("date") 133 | .loc[lambda x: x.index > datetime.utcnow()] 134 | .pipe(lambda x: x if time_period == "Day" else ( 135 | x.groupby(["currency", pd.Grouper(level=0, freq=TIME_PERIOD_MAPPING[time_period]["pandas"])]) 136 | [["dividend"]] 137 | .sum() 138 | .reset_index(level=0) 139 | )) 140 | ) 141 | 142 | # one plot for each currency 143 | with sl.Card(title="Dividend Calendar"): 144 | with Center(30): 145 | sl.Select("Aggregation time period", values=list(TIME_PERIOD_MAPPING.keys()), value=time_period, on_value=set_time_period) 146 | for currency in grouped["currency"].unique(): 147 | only_currency = grouped[grouped["currency"] == currency] 148 | date_range = pd.date_range(start=only_currency.index.min(), end=only_currency.index.max(), freq=TIME_PERIOD_MAPPING[time_period]["pandas"]) 149 | plot_data = (only_currency 150 | # add company name for daily view 151 | [["dividend", "companyName"] if time_period == "Day" else ["dividend"]] 152 | .pipe(lambda x: 153 | ( 154 | (x.reindex(date_range) 155 | .fillna(0) ) 156 | if time_period != "Day" 157 | else 158 | # join names and divs if on the same day 159 | (pd.concat([x, pd.DataFrame(index=date_range).assign(dividend=0, companyName="")], axis=0) 160 | .groupby(level=0) 161 | .agg( 162 | dividend=("dividend", lambda x: x.sum()), 163 | companyName=("companyName", lambda x: ", ".join([x for x in x.tolist() if x != ""])) 164 | ) 165 | .sort_index()) 166 | ) 167 | .pipe(lambda x: x.set_index(x.index.strftime(TIME_PERIOD_MAPPING[time_period]["tickformat"]))) 168 | ) 169 | ) 170 | fig = plot_data.plot( 171 | kind="bar", 172 | y="dividend", 173 | title=f"Scheduled dividends ({TIME_PERIOD_MAPPING[time_period]['text']}, {currency})", 174 | ) 175 | if time_period == "Day": 176 | fig.update_traces(hovertemplate='Dividend: %{y}, Company name: %{customdata}', customdata=plot_data["companyName"]) 177 | # set layout 178 | fig.update_xaxes(title_text=time_period, type="category", tickangle=45) 179 | fig.update_layout(title_x=0.5) 180 | fig.update_yaxes(title_text=f"Dividends ({CURRENCY_TO_SIGN[currency]})") 181 | 182 | sl.FigurePlotly(fig) 183 | 184 | def save_json(data, path): 185 | if data.value is None or len(data.value) == 0: 186 | return 187 | with open(path + "_tmp", "w") as f: 188 | json.dump(data.value, f, default=str) 189 | # atomic write, as thread can be cancelled 190 | os.replace(path + "_tmp", path) 191 | 192 | @sl.component 193 | def StockList(): 194 | hide_stocks, set_hide_stocks = sl.use_state(False) 195 | input_ticker, set_input_ticker = sl.use_state(None) 196 | internal_edit, set_internal_edit = sl.use_state(False) 197 | input_shares, set_input_shares = sl.use_state(1) 198 | error, set_error = sl.use_state(False) 199 | 200 | def update_stock_list(stock): 201 | if stocks.value is None: 202 | return 203 | idx = [i for i in range(len(stocks.value)) if stocks.value[i]["ticker"] == stock["ticker"]] 204 | if len(idx) == 0: # i.e. new stock 205 | api = FMPAPI(api_key=envs.fmp_api_key) 206 | exists = ticker_exists(stock["ticker"], api) 207 | if not exists: 208 | set_error("Ticker could not be found.") 209 | return 210 | add_stock_data([stock]) 211 | set_error(False) 212 | else: 213 | _stocks = deepcopy(stocks.value) 214 | _stocks[idx[0]]["shares"] = input_shares 215 | stocks.value = _stocks 216 | set_input_ticker(None) 217 | set_input_shares(1) 218 | 219 | def edit_existing_stock(stock): 220 | set_internal_edit(True) 221 | update_edit_stock(stock) 222 | 223 | def add_new_input(): 224 | set_input_ticker("") 225 | set_internal_edit(False) 226 | 227 | def update_edit_stock(stock): 228 | set_input_ticker(stock["ticker"]) 229 | set_input_shares(stock["shares"]) 230 | 231 | if not hide_stocks: 232 | with Center(90): 233 | with sl.Card(title="Stocks", style="align-items: center"): 234 | sl.Button("Hide stocks", style="margin-bottom: 0.5em", icon_name="mdi-eye-off", color="secondary", on_click=lambda: set_hide_stocks(True)) 235 | sl.Button("Add example data", on_click=lambda: ( 236 | add_stock_data([ 237 | {"ticker": "KO", "shares": 5 }, 238 | {"ticker": "JNJ", "shares": 50 }, 239 | {"ticker": "AAPL", "shares": 10}, 240 | {"ticker": "MSFT", "shares": 5}, 241 | {"ticker": "DIS", "shares": 5}, 242 | {"ticker": "T", "shares": 5}, 243 | {"ticker": "VZ", "shares": 5}, 244 | {"ticker": "PFE", "shares": 5}, 245 | {"ticker": "CSCO", "shares": 20 } 246 | ], clear=True)), 247 | style="margin-bottom: 1em; display: block;" 248 | ) 249 | input_error = not internal_edit and any([input_ticker == stock["ticker"] for stock in stocks.value]) 250 | for i, stock in enumerate(stocks.value + ([{"ticker": input_ticker, "shares": input_shares}] if not internal_edit and input_ticker is not None else [])): 251 | # handle if the new ticker is the same as existing 252 | chosen_stock = input_ticker == stock["ticker"] and (internal_edit or i == len(stocks.value)) 253 | with sl.Columns([ 254 | 0.5, 255 | (3 if chosen_stock and not internal_edit else 1), 256 | 3, 257 | 1 258 | ], style=f"align-items: {'baseline' if chosen_stock else 'center'};"): 259 | sl.IconButton(icon_name="mdi-minus", style="font-size: 0.3em", on_click=lambda stock=stock: ( 260 | stocks.set([s for s in stocks.value if s["ticker"] != stock["ticker"]]) 261 | if not (chosen_stock and not internal_edit) else 262 | set_input_ticker(None) 263 | )) 264 | if not internal_edit and chosen_stock: 265 | TickerInput(value=input_ticker, on_value=set_input_ticker, error=error or input_error) 266 | else: 267 | sl.Text("$" + stock["ticker"], style="color: green;") 268 | with sl.Row(): 269 | if chosen_stock: 270 | sl.InputInt(label="Shares", value=input_shares, on_value=set_input_shares) 271 | else: 272 | sl.Text(str(stock["shares"]), style="color: black") 273 | sl.Text("Shares", style="color: gray") 274 | if chosen_stock: 275 | if not input_error: 276 | sl.IconButton(icon_name="mdi-check", on_click=lambda stock=stock: update_stock_list(stock)) 277 | else: 278 | # placeholder 279 | sl.Div() 280 | else: 281 | sl.IconButton(icon_name="mdi-pencil", on_click=lambda stock=stock: edit_existing_stock(stock)) 282 | sl.IconButton(icon_name="mdi-plus", on_click=lambda: add_new_input()) 283 | else: 284 | sl.Button( 285 | "Show stock list", 286 | style="width: 200px", 287 | color="secondary", 288 | on_click=lambda: set_hide_stocks(False), 289 | icon_name="mdi-eye" 290 | ) 291 | 292 | def add_stock_data(new_stocks, clear=False): 293 | ticker_to_stock = { stock["ticker"]: stock for stock in new_stocks } 294 | 295 | api = FMPAPI(api_key=envs.fmp_api_key, n_requests_in_parallel=4) 296 | for stock in new_stocks: 297 | t = stock["ticker"] 298 | api.get(t, f"/historical-price-full/stock_dividend/{t}", to_dataframe=False) 299 | api.get(f"{t}_profile", f"/profile/{t}", to_dataframe=False, first=True) 300 | api.collect() 301 | 302 | profile_info = {} 303 | for k, v in api.data.items(): 304 | splits = k.rsplit("_", maxsplit=1) 305 | if splits[-1] == "profile": 306 | profile_info[splits[0]] = v 307 | continue 308 | new_stocks_data = [ 309 | { 310 | **ticker_to_stock[k], 311 | "expires": datetime.utcnow() + timedelta(days=1), 312 | **profile_info.get(k, {}), 313 | "dividends": v["historical"] 314 | } 315 | for k, v in api.data.items() if not k.endswith("_profile") 316 | ] 317 | stocks.set(([] if stocks.value is None or clear else stocks.value) + new_stocks_data) 318 | 319 | @sl.component 320 | def PortfolioAnalysis(): 321 | # save to json on change 322 | def save_stocks(): 323 | save_json(stocks, STOCKS_PATH) 324 | 325 | # data is loaded from json at the start 326 | def get_saved_data(): 327 | if stocks.value is not None: 328 | return 329 | 330 | # set the global loader (the icon-backlight) 331 | global_loader.set(True) 332 | 333 | # try open file, if not exists, set empty list 334 | try: 335 | with open(STOCKS_PATH, "r") as f: 336 | _stocks = json.load(f) 337 | except (json.JSONDecodeError, FileNotFoundError): 338 | stocks.set([]) 339 | global_loader.set(None) 340 | return 341 | 342 | for s in _stocks: 343 | s["expires"] = pd.to_datetime(s["expires"]) 344 | 345 | # if data has expired (is older than 1 day then fetch anew) 346 | has_info = [s for s in _stocks if s["expires"] > datetime.utcnow() and "dividends" in s] 347 | has_not_info = [s for s in _stocks if s["expires"] < datetime.utcnow() or "dividends" not in s] 348 | # set stocks with info directly 349 | stocks.set(has_info) 350 | # fetch missing info 351 | add_stock_data(has_not_info) 352 | 353 | global_loader.set(None) 354 | 355 | 356 | # dependencies = [] means it is only called once 357 | sl.use_thread(get_saved_data, dependencies=[]) 358 | # called every time the stocks.value is changed 359 | sl.use_thread(save_stocks, dependencies=[stocks.value]) 360 | 361 | if stocks.value is None: 362 | Loader() 363 | return 364 | 365 | # display the list of stocks and 366 | # enable editing and adding new stocks 367 | StockList() 368 | 369 | # as a component to not rerender needlessly 370 | with sl.Columns([2, 5], style="align-items: center;"): 371 | # display when the next dividend will be payed 372 | NextDividend() 373 | # display some portfolio info 374 | PorfolioStatistics() 375 | 376 | # display the calendar for future dividends 377 | DividendCalendarGraph() 378 | -------------------------------------------------------------------------------- /build/src/shared.py: -------------------------------------------------------------------------------- 1 | import solara as sl 2 | 3 | @sl.component 4 | def Center(percent=80, children=None): 5 | offset = (100-percent)/2 6 | with sl.Columns([offset, percent, offset]): 7 | sl.Div() 8 | sl.Column(children=children) 9 | sl.Div() 10 | 11 | def ticker_exists(ticker, api): 12 | try: 13 | api.get_sync("profile", f"/profile/{ticker}", to_dataframe=False, first=True) 14 | return True 15 | except IndexError: 16 | return False 17 | 18 | def TickerInput(value, on_value, error=False, style=None): 19 | with sl.Row(style="align-items: center;"): 20 | sl.Text("$", style="color: gray") 21 | sl.InputText( 22 | label="Ticker", 23 | value=value, 24 | error=error, 25 | continuous_update=True, 26 | on_value=on_value, 27 | style=style 28 | ) -------------------------------------------------------------------------------- /build/src/ticker_analysis.py: -------------------------------------------------------------------------------- 1 | import solara as sl 2 | import pandas as pd 3 | import os 4 | import random 5 | from threading import Thread 6 | import time 7 | from typing import Optional, List 8 | import requests 9 | from requests.exceptions import HTTPError 10 | import plotly.express as px 11 | import plotly.graph_objects as go 12 | from solara.alias import rv 13 | from datetime import datetime 14 | from openai import OpenAI, APIConnectionError 15 | import solara as sl 16 | from pydantic_settings import BaseSettings, SettingsConfigDict 17 | from loader import Loader 18 | from envs import envs 19 | from fmp_api import FMPAPI 20 | from shared import TickerInput, ticker_exists 21 | from global_states import global_loader 22 | 23 | DATA_PROMPT = """\ 24 | {question} 25 | 26 | Company: {company} 27 | Ticker: {ticker} 28 | 29 | Earnings: 30 | 31 | {earnings} 32 | 33 | DIVIDENDS: 34 | 35 | {dividends} 36 | 37 | P/E: 38 | 39 | {pe} 40 | 41 | DEBT/EQUITY: 42 | 43 | {dte} 44 | 45 | CASH/SHARE: 46 | 47 | {cps} 48 | 49 | FREE CASH FLOW/SHARE: 50 | 51 | {fcf} 52 | 53 | PAYOUT RATIO: 54 | 55 | {payout} 56 | """ 57 | 58 | TIME_DIFFS = { 59 | "1 week": pd.DateOffset(weeks=1), 60 | "1 month": pd.DateOffset(months=1), 61 | "3 months": pd.DateOffset(months=3), 62 | "1 year": pd.DateOffset(years=1), 63 | "3 years": pd.DateOffset(years=3), 64 | "5 years": pd.DateOffset(years=5) 65 | } 66 | 67 | def get_price_data_fig(srs, moving_average, time_window, time_window_key, currency): 68 | # create moving average 69 | ma = srs.rolling(window=moving_average).mean().dropna() 70 | # only in time window 71 | start = (pd.to_datetime("today").floor("D") - time_window) 72 | srs = srs.loc[start:] 73 | ma = ma.loc[start:] 74 | # create figures for normal and moving average 75 | fig1 = px.line(y=srs, x=srs.index) 76 | fig1.update_traces(line_color="blue", name="Price", showlegend=True) 77 | fig2 = px.line(y=ma, x=ma.index) 78 | fig2.update_traces(line_color="orange", name=f"Moving average price ({moving_average})", showlegend=True) 79 | # combine and add layout 80 | fig = go.Figure(data = fig1.data + fig2.data) 81 | fig.update_layout( 82 | title=f"Adjusted closing price last {time_window_key}", 83 | xaxis_title="Date", 84 | yaxis_title=currency, 85 | title_x = 0.5, 86 | # align labels top-left, side-by-side 87 | legend=dict(y=1.1, x=0, orientation="h"), 88 | showlegend=True, 89 | height=500 90 | ) 91 | return fig 92 | 93 | 94 | def plot_data(data, key, title, yaxis_title, show_mean=False, mean_text="", type="line"): 95 | # getattr(px, type) if type = 'line' is px.line 96 | fig = getattr(px, type)(y=data[key], x=data[key].index) 97 | # add a historical mean if specified 98 | if show_mean: 99 | fig.add_hline(data[key].mean(), line_dash="dot", annotation_text=mean_text) 100 | # set title and axis-titles 101 | fig.update_layout( 102 | title=title, 103 | xaxis_title="Date", 104 | yaxis_title=yaxis_title, 105 | title_x = 0.5, 106 | showlegend=False 107 | ) 108 | return fig 109 | 110 | @sl.component 111 | def AppComponent(title: Optional[str] = None, children: List[sl.Element] = None, **kwargs): 112 | if title is not None: 113 | # add it before the other content 114 | children = [ 115 | sl.HTML("h1", title, style="text-align: center; color: black;"), 116 | sl.HTML("br") 117 | ] + ([] if children is None else children) 118 | sl.Card(children=children, **kwargs) 119 | 120 | @sl.component 121 | def ShowMore(text: str, n: int = 400): 122 | show_more, set_show_more = sl.use_state(False) 123 | 124 | if show_more or len(text) < n: 125 | sl.Markdown(text) 126 | if len(text) > n: 127 | sl.Button("Show less", on_click=lambda: set_show_more(False)) 128 | else: 129 | sl.Markdown(text[:n] + "...") 130 | sl.Button("Show more", style="width: 150px;", on_click=lambda: set_show_more(True)) 131 | 132 | @sl.component 133 | def AIAnalysis(ticker: str | None, data: dict): 134 | ai_analysis, set_ai_analysis = sl.use_state(None) 135 | asked_last, set_asked_last = sl.use_state(None) 136 | question, set_question = sl.use_state("") 137 | load, set_load = sl.use_state(False) 138 | 139 | def fetch_ai_analysis(): 140 | if not load: 141 | return 142 | 143 | if asked_last is not None: 144 | _ticker, _question = asked_last 145 | # same, don't fetch 146 | if _ticker == ticker and _question == question: 147 | return 148 | set_asked_last((ticker, question)) 149 | 150 | client = OpenAI(api_key=envs.openai_api_key) 151 | 152 | # reset 153 | set_ai_analysis("") 154 | 155 | stream = client.chat.completions.create( 156 | model=envs.openai_model, 157 | messages=[ 158 | { 159 | "role": "system", 160 | "content": """ 161 | You are a Stock Market Analyst, a financial expert deeply versed 162 | in the intricacies of the stock market. Your keen ability to dissect complex 163 | market trends, interpret financial data, and understand the implications of 164 | historical data on stock performance makes your insight invaluable. 165 | With a talent for predicting market movements and understanding investment strategies, 166 | you balance an analytical approach with an intuitive understanding of market psychology. 167 | Your guidance is sought after for making informed, strategic decisions in the dynamic 168 | world of stock trading. 169 | """ 170 | }, 171 | { 172 | "role": "user", 173 | "content": DATA_PROMPT.format( 174 | ticker=ticker, 175 | question=( 176 | "Give me your insights into the following stock:" 177 | if question is None or question == "" 178 | else question 179 | ), 180 | company=data["info"]["companyName"], 181 | earnings=data["earnings_per_share"].to_string(), 182 | dividends=data["dividends"].to_string(), 183 | pe=data["historical_PE"].to_string(), 184 | dte=data["debt_to_equity"].to_string(), 185 | cps=data["cash_per_share"].to_string(), 186 | fcf=data["free_cash_flow_per_share"].to_string(), 187 | payout=data["payout_ratio"].to_string() 188 | ) 189 | } 190 | ], 191 | stream=True, 192 | temperature=0.5 193 | ) 194 | combined = "" 195 | for chunk in stream: 196 | added = chunk.choices[0].delta.content 197 | if added is not None: 198 | combined += added 199 | set_ai_analysis(combined) 200 | 201 | set_load(False) 202 | 203 | # fetch in a separate thread to not block UI 204 | fetch_thread = sl.use_thread(fetch_ai_analysis, dependencies=[load]) 205 | 206 | def stop_fetching(): 207 | fetch_thread.cancel() 208 | set_load(False) 209 | 210 | if data is None: 211 | return 212 | 213 | with AppComponent("AI Analysis"): 214 | rv.Textarea( 215 | v_model=question, 216 | on_v_model=set_question, 217 | outlined=True, 218 | hide_details=True, 219 | label="Question (optional)", 220 | rows=3, 221 | auto_grow=True 222 | ) 223 | sl.HTML("br") 224 | if not load and (asked_last is None or asked_last != (ticker, question)): 225 | sl.Button("Analyze", icon_name="mdi-auto-fix", color="primary", on_click=lambda: set_load(True)) 226 | else: 227 | sl.Button("Stop", icon_name="mdi-cancel", color="secondary", on_click=lambda: stop_fetching()) 228 | sl.HTML("br") 229 | sl.HTML("br") 230 | 231 | if ai_analysis is not None: 232 | sl.HTML("h3", "Output:") 233 | ShowMore(ai_analysis) 234 | 235 | @sl.component 236 | def PercentageChange(data: Optional[dict]): 237 | if data is None: 238 | return 239 | 240 | # no title 241 | with AppComponent(): 242 | # Add changes for different periods 243 | close = data["stock_closings"] 244 | latest_price = close.iloc[-1]# data["info"]["price"] 245 | # should all be displayed on the same row 246 | today = pd.to_datetime("today").floor("D") 247 | 248 | with sl.Columns([1]*len(TIME_DIFFS)): 249 | for name, difference in TIME_DIFFS.items(): 250 | # go back to the date ago 251 | date = (today - difference) 252 | # if there is no data back then, then use the earliest 253 | if date < close.index[0]: 254 | date = close.index[0] 255 | # if no match, get the date closest to it back in time, e.g. weekend to friday 256 | idx = close.index.get_indexer([date],method='ffill')[0] 257 | previous_price = close.iloc[idx] 258 | # calculate change in percent 259 | change = 100*(latest_price - previous_price) / previous_price 260 | # show red if negative, green if positive 261 | color = "red" if change < 0 else "green" 262 | 263 | with sl.Row(): 264 | sl.Text(name) 265 | sl.Text(f"{round(change, 2)}%", style=dict(color=color)) 266 | 267 | def Overview(info: Optional[dict]=None): 268 | # basic information 269 | if info is None: 270 | return 271 | with AppComponent("Overview"): 272 | for text, key in [ 273 | ("Current price", "price"), 274 | ("Country", "country"), 275 | ("Exchange", "exchange"), 276 | ("Sector", "sector"), 277 | ("Industry", "industry"), 278 | ("Full time employees", "fullTimeEmployees") 279 | ]: 280 | sl.Markdown(f"- {text}: **{info[key]}**") 281 | 282 | @sl.component 283 | def Description(info: dict): 284 | if info is None: 285 | return 286 | 287 | with AppComponent("Description"): 288 | ShowMore(info["description"]) 289 | 290 | @sl.component 291 | def Price(data: dict): 292 | 293 | moving_average, set_moving_average = sl.use_state(30) 294 | time_window_key, set_time_window_key = sl.use_state("5 years") 295 | # select the value from the key, i.e. the pd.DateOffset 296 | time_window = TIME_DIFFS[time_window_key] 297 | 298 | if data is None: 299 | return 300 | 301 | info = data["info"] 302 | 303 | with AppComponent("Price"): 304 | # here I set different widths to each column, 305 | # meaning the first is 1 width and the second 3, 306 | # i.e. 1/(1+3) = 25% and 3 / (1+4) = 75% 307 | with sl.Columns([1, 3], style="align-items: center;"): 308 | 309 | # second column, graph and graph settings 310 | with sl.Column(): 311 | # show the graph 312 | fig = get_price_data_fig(data["stock_closings"], moving_average, time_window, time_window_key, info["currency"]) 313 | sl.FigurePlotly(fig) 314 | 315 | # options that will dictate the graph: 316 | 317 | # radio buttons for what time window to display the stock price 318 | with sl.Column(align="center"): 319 | sl.HTML("h2", "Time window") 320 | sl.ToggleButtonsSingle( 321 | value=time_window_key, 322 | values=list(TIME_DIFFS.keys()), 323 | on_value=set_time_window_key 324 | ) 325 | # set moving average 326 | sl.SliderInt(label=f"Moving average: {moving_average}", min=2, max=500, value=moving_average, on_value=set_moving_average) 327 | 328 | @sl.component 329 | def Charts(data: dict): 330 | if data is None: 331 | return 332 | 333 | with AppComponent("Charts"): 334 | currency = data["info"]["currency"] 335 | 336 | # define all plots 337 | div_fig = plot_data( 338 | data, 339 | key="dividends", 340 | title="Adjusted dividends", 341 | yaxis_title=currency, 342 | type="bar" 343 | ) 344 | pe_fig = plot_data( 345 | data, 346 | key="historical_PE", 347 | title="Historical Price-to-Earnings (P/E) Ratio", 348 | yaxis_title="P/E", 349 | show_mean=True, 350 | mean_text="Average Historical P/E", 351 | ) 352 | yield_fig = plot_data( 353 | data, 354 | key="dividend_yield", 355 | title="Dividend Yield", 356 | yaxis_title="Percent %", 357 | show_mean=True, 358 | mean_text="Average Historical Dividend Yield", 359 | type="bar" 360 | ) 361 | payout_fig = plot_data( 362 | data, 363 | key="payout_ratio", 364 | title="Payout Ratio", 365 | yaxis_title="Payout Ratio", 366 | type="bar", 367 | show_mean=True, 368 | mean_text="Average Historical Payout Ratio" 369 | ) 370 | cps_fig = plot_data( 371 | data, 372 | key="cash_per_share", 373 | title="Cash/Share", 374 | yaxis_title=currency, 375 | type="bar" 376 | ) 377 | fcf_fig = plot_data( 378 | data, 379 | key="free_cash_flow_per_share", 380 | title="Free Cash Flow/Share", 381 | yaxis_title=currency, 382 | type="bar" 383 | ) 384 | eps_fig = plot_data( 385 | data, 386 | key="earnings_per_share", 387 | title="Earnings/Share", 388 | yaxis_title=currency, 389 | type="bar" 390 | ) 391 | dte_fig = plot_data( 392 | data, 393 | key="debt_to_equity", 394 | title="Debt-to-equity", 395 | yaxis_title="Debt/Equity" 396 | ) 397 | 398 | # align plots side by side 399 | combos = [(div_fig, pe_fig), (eps_fig, yield_fig), (payout_fig, cps_fig), (fcf_fig, dte_fig)] 400 | for (fig1, fig2) in combos: 401 | with sl.Columns([1,1]): 402 | if fig1 is not None: 403 | sl.FigurePlotly(fig1) 404 | if fig2 is not None: 405 | sl.FigurePlotly(fig2) 406 | 407 | @sl.component 408 | def FunAnimation(): 409 | sl.Style(""" 410 | .loading-text { 411 | animation: loading-text-animation 3s infinite; 412 | } 413 | 414 | @keyframes loading-text-animation { 415 | 0% { 416 | color: #3f00b3; 417 | } 418 | 50% { 419 | color: #13bdfc; 420 | } 421 | 100% { 422 | color: #3f00b3; 423 | } 424 | } 425 | """) 426 | 427 | loader, set_loader = sl.use_state("") 428 | texts = [ 429 | "Consulting with our financial wizards...", 430 | "Negotiating with the stock market gremlins...", 431 | "Brewing a fresh pot of financial data..." 432 | ] 433 | def animate(): 434 | while True: 435 | set_loader(random.choice(texts)) 436 | time.sleep(3) 437 | sl.use_thread(animate, dependencies=[]) 438 | 439 | sl.HTML("h3", loader, class_="loading-text") 440 | 441 | @sl.component 442 | def TickerAnalysis(): 443 | # define states 444 | 445 | # two ticker states, one is updated on input, 446 | # the other after the button is clicked to load the data 447 | ticker, set_ticker = sl.use_state(None) 448 | input_ticker, set_input_ticker = sl.use_state("") 449 | error, set_error = sl.use_state(False) 450 | # data fetched from FMP API 451 | data, set_data = sl.use_state(None) 452 | 453 | # method to fetch all data from FMP API 454 | def fetch_data(): 455 | if ticker is None or ticker == "": 456 | return 457 | 458 | # the cache holds (data, expiration_time) 459 | if ticker in sl.cache.storage and sl.cache.storage[ticker][1] > datetime.utcnow(): 460 | print(f"Fetching ${ticker} data from cache") 461 | set_data(sl.cache.storage[ticker][0]) 462 | return 463 | 464 | print(f"Fetching ${ticker} data from API") 465 | 466 | # run multiple requests in parallel and collect the results here 467 | 468 | api = FMPAPI(api_key=envs.fmp_api_key, n_requests_in_parallel=3) 469 | # assert it exists 470 | try: 471 | exists = ticker_exists(ticker, api) 472 | if exists: 473 | set_error(False) 474 | else: 475 | set_error("Ticker not found") 476 | set_data(None) 477 | set_ticker(None) 478 | return 479 | except HTTPError as e: 480 | if e.response.status_code == 401: 481 | set_error("Invalid API key") 482 | set_data(None) 483 | set_ticker(None) 484 | return 485 | # unknown error 486 | raise e 487 | 488 | api.get("key_metrics_annually", f"/key-metrics/{ticker}", period="annual") 489 | # by default 5 years, daily 490 | api.get("stock_data", f"/historical-price-full/{ticker}", use_key="historical") 491 | api.get("financial_ratios_annually", f"/ratios/{ticker}", period="annual") 492 | api.get("income_statement_annually", f"/income-statement/{ticker}", period="annual") 493 | api.get("dividends", f"/historical-price-full/stock_dividend/{ticker}", use_key="historical") 494 | 495 | api.collect() 496 | 497 | profile = api.data["profile"] 498 | key_metrics_annually = api.data["key_metrics_annually"] 499 | stock_data = api.data["stock_data"] 500 | financial_ratios_annually = api.data["financial_ratios_annually"] 501 | income_statement_annually = api.data["income_statement_annually"] 502 | dividends = api.data["dividends"] 503 | try: 504 | divs = dividends["adjDividend"].resample("1Y").sum().sort_index() 505 | except KeyError: 506 | dividends=None 507 | divs = pd.Series(0, name="Dividends") 508 | data = { 509 | "stock_closings": stock_data["adjClose"], 510 | "historical_PE": key_metrics_annually["peRatio"], 511 | "payout_ratio": financial_ratios_annually["payoutRatio"], 512 | "dividend_yield": 100*financial_ratios_annually["dividendYield"], 513 | "cash_per_share": key_metrics_annually["cashPerShare"], 514 | "debt_to_equity": key_metrics_annually["debtToEquity"], 515 | "free_cash_flow_per_share": key_metrics_annually["freeCashFlowPerShare"], 516 | "dividends": divs, 517 | "earnings_per_share": income_statement_annually["eps"], 518 | "info": profile, 519 | "all": dict( 520 | key_metrics_annually=key_metrics_annually, 521 | financial_ratios_annually=financial_ratios_annually, 522 | income_statement_annually=income_statement_annually, 523 | dividends=dividends 524 | ) 525 | } 526 | 527 | # update cache and set expiration time to 1 hour from now 528 | sl.cache.storage[ticker] = (data, datetime.utcnow() + pd.DateOffset(hours=1)) 529 | # set the `data` state 530 | set_data(data) 531 | global_loader.set(None) 532 | 533 | # fetch in a thread to not block the UI 534 | sl.use_thread(fetch_data, dependencies=[ticker]) 535 | 536 | def update_ticker(): 537 | set_data(None) 538 | global_loader.set(True) 539 | set_ticker(input_ticker) 540 | 541 | # when ticker is changed but not data => loading 542 | is_loading = data is None and ticker is not None and ticker != "" 543 | 544 | 545 | # sl.Columns takes a list of widths, [1, 1, 1] means 3 columns with equal width 546 | with sl.Columns([1, 1, 1]): 547 | # the first element will have the first width, 548 | # the second the second width, etc. 549 | 550 | # sl.HTML("div") is used as a placeholder 551 | sl.HTML("div") 552 | with sl.Column(align="center"): 553 | if not is_loading: 554 | # $ + input + button 555 | with sl.Row(style="align-items: center;"): 556 | TickerInput(value=input_ticker, on_value=set_input_ticker, error=error) 557 | sl.IconButton( 558 | color="primary", 559 | icon_name='mdi-chevron-right', 560 | on_click=update_ticker, 561 | disabled=input_ticker == "" or input_ticker == ticker 562 | ) 563 | sl.HTML("div") 564 | 565 | if ticker is None or ticker == "": 566 | return 567 | 568 | if is_loading: 569 | Loader() 570 | 571 | info = None if data is None else data["info"] 572 | # Title 573 | if info is not None: 574 | sl.HTML("h1", f"{info['companyName']} ({info['symbol']})") 575 | 576 | # Percentage change across time periods 577 | PercentageChange(data) 578 | 579 | # Overview + price side by side 580 | with sl.Columns([1, 4], style="align-items: center;"): 581 | Overview(info) 582 | Price(data) 583 | 584 | # Description + AI-analysis side by side 585 | with sl.Columns([2, 3]): 586 | Description(info) 587 | AIAnalysis(ticker, data) 588 | 589 | # Charts for various data 590 | Charts(data) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | dividend_app: 4 | build: 5 | context: build 6 | dockerfile: Dockerfile 7 | ports: 8 | - "5000:5000" 9 | environment: 10 | PORT: 5000 11 | FMP_API_KEY: ${FMP_API_KEY} 12 | OPENAI_API_KEY: ${OPENAI_API_KEY} 13 | OPENAI_MODEL: ${OPENAI_MODEL} 14 | IN_DOCKER: true 15 | PYTHONUNBUFFERED: 1 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /down.sh: -------------------------------------------------------------------------------- 1 | docker-compose -f docker-compose.yml down -------------------------------------------------------------------------------- /init.sh: -------------------------------------------------------------------------------- 1 | env_content="# note if \$OPENAI_API_KEY is set, it will override below value 2 | OPENAI_API_KEY=... 3 | FMP_API_KEY=... 4 | OPENAI_MODEL=gpt-3.5-turbo" 5 | 6 | if [ -f ".env" ]; then 7 | echo ".env already exists" 8 | else 9 | ( 10 | echo "creating .env" && 11 | touch .env && 12 | echo "$env_content" >> .env 13 | ) 14 | fi -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | cd build/src && solara run app.py -------------------------------------------------------------------------------- /run_docker.sh: -------------------------------------------------------------------------------- 1 | docker-compose -f docker-compose.yml up --build --------------------------------------------------------------------------------