Add debugging in gcal renderer.
[kiosk.git] / stock_renderer.py
1 #!/usr/bin/env python3
2
3 import logging
4 import os
5 from typing import Any, Dict, List, Optional, Tuple
6
7 import yfinance as yf  # type: ignore
8 import plotly.graph_objects as go
9
10 import file_writer
11 import kiosk_constants
12 import renderer
13
14
15 logger = logging.getLogger(__file__)
16
17
18 class stock_quote_renderer(renderer.abstaining_renderer):
19     """Render the stock prices page."""
20
21     def __init__(
22             self,
23             name_to_timeout_dict: Dict[str, int],
24             symbols: List[str],
25             display_subs: Dict[str, str] = None,
26     ) -> None:
27         super().__init__(name_to_timeout_dict)
28         self.symbols = symbols
29         self.display_subs = display_subs
30         self.info_cache: Dict[yf.ticker.Ticker, Dict] = {}
31
32     def cache_info(self, ticker: yf.ticker.Ticker) -> Dict:
33         if ticker in self.info_cache:
34             return self.info_cache[ticker]
35         i = ticker.get_info()
36         self.info_cache[ticker] = i
37         return i
38
39     def get_ticker_name(self, ticker: yf.Ticker) -> str:
40         """Get friendly name of a ticker."""
41         info = self.cache_info(ticker)
42         if "shortName" in info:
43             return info["shortName"]
44         return ticker
45
46     @staticmethod
47     def get_item_from_dict(keys: List[str], dictionary: Dict[str, Any]) -> Optional[Any]:
48         result = None
49         for key in keys:
50             result = dictionary.get(key, None)
51             if result:
52                 return result
53         return None
54
55     def get_price(self, ticker: yf.Ticker) -> Optional[float]:
56         """Get most recent price of a ticker."""
57
58         # First try fast_info
59         price = stock_quote_renderer.get_item_from_dict(
60             ['last_price',
61              'open',
62              'previous_close'],
63             ticker.fast_info)
64         if price:
65             return price
66
67         # Next try info
68         price = stock_quote_renderer.get_item_from_dict(
69             ['bid',
70              'ask',
71              'lastMarket'],
72             self.cache_info(ticker))
73         if price:
74             return price
75
76         # Finally, fall back on history
77         hist = ticker.history(period="1d").to_dict()['Close']
78         latest = None
79         latest_price = None
80         for k, v in hist.items():
81             if latest is None or k > latest:
82                 price = hist[k]
83                 if price is not None:
84                     latest = k
85                     latest_price = price
86         print(f"Price: fell back on latest close {latest_price} at {latest}")
87         return latest_price
88
89     @staticmethod
90     def make_chart(symbol: str, ticker: yf.Ticker, period: str) -> str:
91         base_filename = f'stock_chart_{symbol}.png'
92         output_filename = os.path.join(kiosk_constants.pages_dir, base_filename)
93         transparent = go.Layout(
94             paper_bgcolor='rgba(0,0,0,0)',
95             plot_bgcolor='rgba(0,0,0,0)',
96             xaxis_rangeslider_visible=False,
97         )
98         hist = ticker.history(period=period, interval="1wk")
99         chart = go.Figure(
100             data=go.Candlestick(
101                 open=hist['Open'],
102                 high=hist['High'],
103                 low=hist['Low'],
104                 close=hist['Close'],
105             ),
106             layout=transparent,
107         )
108         chart.update_xaxes(visible=False, showticklabels=False)
109         chart.update_yaxes(side="right")
110         chart.write_image(output_filename, format="png", width=600, height=350)
111         print(f"Write {output_filename}...")
112         return base_filename
113
114     def get_last_close(
115             self,
116             ticker: yf.Ticker
117     ) -> float:
118         last_close = stock_quote_renderer.get_item_from_dict(
119             ['previous_close',
120              'open'],
121             ticker.fast_info)
122         if last_close:
123             return last_close
124
125         last_close = stock_quote_renderer.get_item_from_dict(
126             ['preMarketPrice'],
127             self.cache_info(ticker))
128         if last_close:
129             return last_close
130         return self.get_price(ticker)
131
132     def get_change_and_delta(
133             self,
134             ticker: yf.Ticker,
135             price: float
136     ) -> Tuple[float, float]:
137         """Given the current price, look up opening price and compute delta."""
138         last_price = self.get_last_close(ticker)
139         delta = price - last_price
140         return (delta / last_price * 100.0, delta)
141
142     def periodic_render(self, key: str) -> bool:
143         """Write an up-to-date stock page."""
144         with file_writer.file_writer("stock_3_86400.html") as f:
145             f.write("<H1>Stock Quotes</H1><HR>")
146             f.write("<TABLE WIDTH=99%>")
147             symbols_finished = 0
148             for symbol in self.symbols:
149                 ticker = yf.Ticker(symbol)
150                 if ticker is None:
151                     logger.debug(f"Unknown symbol {symbol} -- ignored.")
152                     continue
153                 name = self.get_ticker_name(ticker)
154                 if name is None:
155                     logger.debug(f'Bad name for {symbol} -- skipped.')
156                     continue
157                 price = self.get_price(ticker)
158                 if price is None:
159                     logger.debug(f"No price information for {symbol} -- skipped.")
160                     continue
161                 (percent_change, delta) = self.get_change_and_delta(
162                     ticker, price
163                 )
164                 chart_filename = stock_quote_renderer.make_chart(symbol, ticker, "1y")
165                 print(f"delta: {delta}, change: {percent_change}")
166                 if percent_change < 0:
167                     cell_color = "#b00000"
168                 elif percent_change > 0:
169                     cell_color = "#009000"
170                 else:
171                     cell_color = "#a0a0a0"
172                 if symbols_finished % 4 == 0:
173                     if symbols_finished > 0:
174                         f.write("</TR>")
175                         f.write("<TR>")
176                 symbols_finished += 1
177                 if self.display_subs is not None and symbol in self.display_subs:
178                     symbol = self.display_subs[symbol]
179                 f.write(
180                     f"""
181 <TD WIDTH=20% HEIGHT=150 BGCOLOR="{cell_color}">
182   <!-- Container -->
183   <DIV style="position:relative;
184               height:175px;">
185     <!-- Symbol {symbol} -->
186     <DIV style="position:absolute;
187                 bottom:50;
188                 right:-20;
189                 -webkit-transform:rotate(-90deg);
190                 font-size:28pt;
191                 font-family: helvetica, arial, sans-serif;
192                 font-weight:900;
193                 -webkit-text-stroke: 2px black;
194                 color: #ddd">
195       {symbol}
196     </DIV>
197     <!-- Current price, Change today and percent change today, name -->
198     <DIV style="position:absolute;
199                 left:10;
200                 top:20;
201                 font-size:24pt;
202                 font-family:helvetica, arial, sans-serif;
203                 font-weight:900;
204                 width:80%">
205             ${price:.2f}<BR>
206             <I>({percent_change:.1f}%)</I><BR>
207             <B>${delta:.2f}</B>
208     </DIV>
209     <IMG SRC="{chart_filename}" WIDTH=100%>
210   </DIV>
211 </TD>"""
212                 )
213             f.write("</TR></TABLE>")
214         return True
215
216 # Test
217 x = stock_quote_renderer({}, ["MSFT", "GOOG", "BTC-USD", "ABHYX", "GC=F", "VNQ"], { "BTC-USD": "BTC", "GC=F": "GOLD" })
218 x.periodic_render(None)