├── .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
--------------------------------------------------------------------------------