File size: 14,304 Bytes
f680f62
1f5a1dc
f680f62
e11ad18
f680f62
 
 
3f99637
f680f62
 
 
 
 
 
 
 
 
1f5a1dc
 
e11ad18
 
f680f62
0975e8a
f680f62
 
 
 
 
 
 
1f5a1dc
 
e11ad18
 
 
 
f680f62
 
 
 
 
 
 
 
 
 
 
 
 
2ac2093
5df6537
1f5a1dc
e11ad18
 
5df6537
0975e8a
2ac2093
5df6537
 
f680f62
 
 
 
 
 
 
 
 
1f5a1dc
f680f62
e11ad18
 
 
 
2ac2093
 
 
0975e8a
e11ad18
2ac2093
e11ad18
2ac2093
e11ad18
 
 
0975e8a
 
 
 
 
d730ea7
 
 
 
0975e8a
f9577f0
d730ea7
f680f62
 
e11ad18
 
f680f62
 
 
 
 
 
 
 
 
 
 
 
 
e11ad18
 
 
f680f62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e11ad18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
067b5d0
e11ad18
 
f680f62
 
 
e11ad18
0975e8a
 
f680f62
 
 
 
 
 
 
 
 
 
 
 
 
 
0975e8a
 
f680f62
 
 
 
 
b851202
f680f62
 
 
 
 
 
 
 
 
0975e8a
6d0b28e
 
 
 
0975e8a
 
 
f680f62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e11ad18
3f99637
f680f62
 
 
 
 
 
 
e11ad18
 
 
 
 
 
 
 
 
3f99637
 
 
 
 
 
 
 
 
 
 
e11ad18
 
 
 
 
f680f62
 
 
b851202
 
f680f62
 
b851202
e11ad18
 
 
 
f680f62
0975e8a
f680f62
e11ad18
f680f62
d730ea7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f680f62
 
e11ad18
1f5a1dc
 
 
e11ad18
1f5a1dc
067b5d0
1f5a1dc
e11ad18
f680f62
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
import logging
import os

import dotenv
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.axes import Axes
import numpy as np
import pandas as pd
from ta.volume import volume_weighted_average_price
from ta.momentum import RSIIndicator, StochasticOscillator
from ta.trend import MACD 


class TechnicalAnalysis():
    def __init__(
            self,
            ticker: str,
            df_hist: pd.DataFrame,
            df_past=None,
            df_fcst=None,
            plot_ta:bool=True,
            savefig:bool=False,
            debug=False):
        # input arguments
        """
        Initialize TechnicalAnalysis object.
        Args:
        ticker : str
            stock ticker to analyze
        df_hist: pd.DataFrame
            historical price data for ticker
        df_past: pd.DataFrame, optional, default: None
            Closeing price of the ticker for the past few days
        df_fcst: pd.DataFrame, optional, default: None
            Forecasted closing price and relative returns nextf few days
        plot_ta : bool, optional, default: True
            whether to generate plots of technical analysis metrics. Plot will be created under plots/{ticker}.png
        debug : bool, optional, default: False
            whether run in debug mode, so that logging should be produced at debug level
        """
        # set up logging
        if debug:
            self.logger_level = logging.DEBUG
        else:
            self.logger_level = logging.INFO
        self.logger = logging.getLogger(__name__)
        logging.basicConfig(level=self.logger_level)  # filename='TechnicalAnalysis.log', 

        # input arguments
        self.ticker = ticker
        self.df_hist = df_hist
        self.df_past = df_past
        self.df_fcst = df_fcst
        self.plot_ta = plot_ta
        self.savefig = savefig
        # done initializing
        self.logger.info(f'Initialized TechnicalAnalysis object for ticker: {ticker}')

    def run(
            self
            ) -> None:
        """
        Main entry point for the TechnicalAnalysis object.
        This method:
        - computes the technical analysis metrics
        - plots the price and TA metrics.
        """
        df = self.df_hist
        # add the features based on technical analysis
        if df.shape[0] > 0:
            df = self.tech_analysis(df)
            # Merge with forecast data
            df_merged = self.merge_hist_with_forecast(df, self.df_past, self.df_fcst)
            # plot the results
            if self.plot_ta:
                os.makedirs('plots', exist_ok=True)
                fig = self.plot_stock_metrics(
                    df_merged,
                    datasets={
                            'Volume': ['Volume'], 
                            'Indices': ['RSI', 'StochOsc'],
                            'Trend': ['MACD', 'MACDsig', 'MACDdif'],
                            'Prices': ['Close', 'VWAP']  # 'High','Low',
                            },
                    savefig=self.savefig
                    )
            else:
                fig = None
        else:
            if self.plot_ta:
                fig = self.get_fetcherror_fig(message='failed fetching data')
            else:
                fig = None

        return df, fig
    
    
    def tech_analysis(
            self,
            df: pd.DataFrame
            ) -> pd.DataFrame:
        """
        Calculates technical analysis indicators for the fetched stock price data.
        This method takes the fetched stock price data and calculates several
        technical analysis indicators. The following indicators are calculated:
        - Additional Price Indicators:
          - Volume-Weighted Average Price (VWAP)
        - Momentum Indicators:
          - Relative Strength Index (RSI)
            - Stochastic Oscillator
        - Trend Indicators: 
            - Moving Average Convergence Divergence (MACD)
        The calculated indicators are added to the DataFrame as new columns.
        Args:
        df: pd.DataFrame
            The DataFrame containing the fetched stock price data.
        Returns:
        pd.DataFrame
            The DataFrame with the calculated technical analysis indicators.
        """

        # Price Indicators
        # Volume-Weighted Average Price (VWAP)
        # https://chartschool.stockcharts.com/table-of-contents/technical-indicators-and-overlays/technical-overlays/volume-weighted-average-price-vwap
        df['VWAP'] = volume_weighted_average_price(
            high=df['High'],
            low=df['Low'],
            close=df['Close'],
            volume=df['Volume'],
        )

        # Indices
        # RSI:
        # https://www.investopedia.com/terms/r/rsi.asp
        df['RSI'] = RSIIndicator(
            df['Close'],
            window=14).rsi()
        # Stochastic Oscillator: 
        # https://chartschool.stockcharts.com/table-of-contents/technical-indicators-and-overlays/technical-indicators/stochastic-oscillator-fast-slow-and-full
        df['StochOsc'] = StochasticOscillator(
            df['High'],
            df['Low'],
            df['Close'],
            window=14).stoch()

        # Trend signals
        # Moving Average Convergence Divergence (MACD):
        # https://chartschool.stockcharts.com/table-of-contents/technical-indicators-and-overlays/technical-indicators/macd-moving-average-convergence-divergence-oscillator
        macd = MACD(
            df['Close'],
            window_slow=26,
            window_fast=12,
            window_sign=9)
        df['MACD'] = macd.macd()
        df['MACDsig'] = macd.macd_signal()
        df['MACDdif'] = macd.macd_diff()

        return df

    def merge_hist_with_forecast(self, df_hist: pd.DataFrame, df_past: pd.DataFrame | None, df_fcst: pd.DataFrame | None) -> pd.DataFrame:
        # make sure we are merging the right thing
        """
        Merge historical data with forecast data. If forecast data is available, merge it with historical data based on date.
        If forecast data is not available, return the historical data as is.
        Args:
        df_hist: pd.DataFrame
            Historical data
        df_past: pd.DataFrame
            Recent data used for comparison
        df_fcst: pd.DataFrame or None
            Forecast data
        Returns:
        df_merged: pd.DataFrame
            Merged data
        """
        if df_fcst is not None:
            # Make sure that the previous hist close price is matching to that of the past close price
            assert df_hist.Close.iloc[-2] == df_past.Close.iloc[-2]
            df_hist.reset_index(inplace=True)
            # in case there are overlapping dates, make sure to remove them 
            df_fcst = df_fcst.loc[~df_fcst["Date"].isin(df_hist["Date"]), ["Date", "Close"]]
            date_diff = df_fcst.Date.iloc[0] - df_hist.Date.iloc[-1]
            if date_diff > pd.Timedelta('3 days'):
                self.logger.warning(f'Date diff between the first forecast and the last hist is {date_diff}')
            df_merged = pd.concat([df_hist, df_fcst], ignore_index=True)
            df_merged.set_index("Date", inplace=True)
        else:
            df_merged = df_hist
        return df_merged

    def plot_stock_metrics(
            self,
            df,
            datasets,
            savefig=False
            ) -> None:
        """
        Plots the given stock metrics datasets as subplots.
        This method takes in a DataFrame and a dictionary of datasets, where
        each key is a dataset name and the value is a list of column names.
        The method creates a figure with subplots for each dataset and plots
        the corresponding columns of the DataFrame.
        The figure is then saved to a file in the 'plots' directory in png format
        with the ticker symbol as the filename.
        Args:
        df (pd.DataFrame)
            The DataFrame to plot
        datasets (dict)
            A dictionary of datasets, where each key is a dataset name and
            the value is a list of column names to be plotted
        savefig (bool)
            Whether to save the figure to a file
        """
        numax = len(datasets)
        fig, axes = plt.subplots(
            nrows=numax,
            ncols=1,
            figsize=(6, 3*numax))
        for i, ax in enumerate(axes.flat):
            dataset = list(datasets.keys())[i]
            colstoplot = datasets[dataset]
            self.plot_stock_metrics_ax(
                ax,
                dataset,
                df,
                colstoplot)
        plt.tight_layout()
        if savefig:
            rootdir = os.path.dirname(os.path.dirname(__file__))
            fname = os.path.join(rootdir, 'plots', f'{self.ticker}.png')
            plt.savefig(fname)
            self.logger.info(f'Saved figure into: {fname}')
            plt.close()
            fig = None
        return fig

    def plot_stock_metrics_ax(
            self,
            ax:Axes,
            dataset:str,
            df:pd.DataFrame,
            colstoplot:list) -> None:
        """
        Plots specified stock metrics on the provided Axes object.
        This function takes in an Axes object and plots the specified columns from
        the DataFrame `df` on it. It formats the x-axis with major ticks set to 
        every Monday and minor ticks set to every day. The plot includes a title, 
        x and y labels, and optionally a legend if more than one column is plotted.
        Additional shaded regions are added for certain datasets.
        Args:
            ax (matplotlib.axes.Axes): The flattened axes to plot on.
            dataset (str): The name of the dataset, used for the title and y-label.
            df (pd.DataFrame): The DataFrame containing the data to plot.
            colstoplot (list): A list of column names to plot from the DataFrame.
        Note:
            - If `dataset` is 'Index' or 'Indices', the y-axis is limited to [0, 100]
            and a shaded region between y=30 and y=70 is added.
            - If `dataset` is 'Price' or 'Prices', a shaded region between 'Low' and 
            'High' columns is added.
        """

        self.logger.info(f'plotting {colstoplot} in {dataset}')
        colorcycle = ['black', 'blue', 'green', 'orange']
        for i, col in enumerate(colstoplot):
            ax.plot(
                df.index,
                df[col],
                color=colorcycle[i],
                label=col,
                linewidth=2)
        
        if dataset in ['Index', 'Indices']:
            ax.set_ylim([0, 100])
            # Add a transparent shaded region between y=30 and y=70
            ax.fill_between(df.index, 30, 70, color='gray', alpha=0.3, label='30-70 Range')
        elif dataset in ['Price', 'Prices']:
            # Add a transparent shaded region daily lows and highs
            ax.fill_between(df.index, df['Low'], df['High'], color='gray', alpha=0.3, label='Price Range')
            # extract the Price rows for which 'High's are NaN
            nanind = np.where(df.High.isna())
            df_fcst = df['Close'].iloc[nanind]
            if df_fcst.shape[0] > 0:
                # append the last day before the forecasts
                nonnanind = np.where(df.High.notna())
                df_now = df['Close'].iloc[[nonnanind[0][-1]]]
                df_now_fcst = pd.concat([df_now, df_fcst])
                # connect the last available day and forecasts with a red line
                ax.plot(df_now_fcst.index, df_now_fcst, color='red')
                # plot only the forecasts with markers
                ax.plot(df_fcst.index, df_fcst, color='red', marker='*', label='Forecast')
        # else:
        #     # plot a transparent line across the full df.index just to make sure that x-axis limits are identical for all panels
        #     ax.plot(df.index, df[col], color='gray', alpha=0.0)
        #     # ax.fill_between(df.index, 30, 70, color='gray', alpha=0.0)
        ax.set_xlim([df.index.min()-pd.Timedelta(days=5), df.index.max()+pd.Timedelta(days=5)])
        # Format major ticks with year
        # Set major ticks (every Monday with labels)
        ax.xaxis.set_major_locator(mdates.WeekdayLocator(byweekday=mdates.MO))
        # ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d'))
        ax.xaxis.set_major_formatter(mdates.DateFormatter('%m.%d'))
        # Set minor ticks (every day, but without labels)
        ax.xaxis.set_minor_locator(mdates.DayLocator())
        plt.setp(ax.get_xticklabels(), rotation=90, ha='center')
    
        ax.set_ylabel(dataset)

        ax.grid(True, linestyle='--', alpha=0.7)
        ax.set_title(dataset)
        # ax.set_xlabel('Date')
        if len(colstoplot) > 1:
            ax.legend(loc='upper left')

    def get_fetcherror_fig(
            self,
            message
            ) -> plt.Figure:
        """
        Fetches images/plot_error.png, annotates it and returns it as a matplotlib.pyplot.Figure object
        Args:
        message (str): message to be annotated on the displayed image
        Returns:
        plt.Figure: figure object containing the annotated image
        """
        
        fig, ax = plt.subplots(
            figsize=(5, 5)
        )

        # Load and display the image
        parentdir = os.path.dirname(os.path.dirname(__file__)) 
        fname = os.path.join(parentdir, 'images', 'plot_error.png')
        img = plt.imread(fname)
        ax.imshow(img)

        # Remove axes ticks and labels
        # ax.set_xticks([])
        # ax.set_yticks([])
        ax.axis('off')  # removes both ticks and axes lines completely

        ax.text(0.5, 0.05, message, fontsize=20, ha='center', va='center', transform=ax.transAxes)
        return fig

if __name__ == '__main__':
    ticker = 'AAPL'
    # testing
    from src.fetch_forecast import FetchForecast
    from src.fetch_data import FetchData
    dotenv.load_dotenv(dotenv.find_dotenv())
    df_hist = FetchData(ticker, fetchperiodinweeks=12).run()
    df_past, df_fcst = FetchForecast(ticker, df_hist).run()
    df, fig = TechnicalAnalysis(ticker, df_hist=df_hist, df_past=df_past, df_fcst=df_fcst, plot_ta=True, savefig=True, debug=False).run()
    # print(f'columns: {df.columns}')