Source code for vistock.ibd

"""
IBD RS (Relative Strength) Rating Module
----------------------------------------

This module provides tools for analyzing and ranking stocks based on their
relative strength compared to a benchmark index, inspired by the Investor's
Business Daily (IBD) methodology.

Key Features:
~~~~~~~~~~~~~
- Relative strength calculation
- Stock and industry ranking generation
- Rating-based filtering of rankings

Usage:
~~~~~~
::

    import ibd
    import vistock.stock_indices as si

    code = 'SOX'
    tickers = si.get_tickers(code)

    stock_df = rankings(tickers,
                        rs_window=rs_window, rating_method=rating_method)

See Also:
~~~~~~~~~
- `RS Rating — Indicator by Fred6724 — tradingview
  <https://www.tradingview.com/script/pziQwiT2/>`_
- `Relative Strength (IBD Style) — Indicator by Skyte — TradingView
  <https://www.tradingview.com/script/SHE1xOMC-Relative-Strength-IBD-Style/>`_

  - `relative-strength/rs_ranking.py at main · skyte/relative-strength
    <https://github.com/skyte/relative-strength/blob/main/rs_ranking.py>`_

- `Exclusive IBD Ratings | Stock News & Stock Market Analysis - IBD
  <https://www.investors.com/ibd-university/
  find-evaluate-stocks/exclusive-ratings/>`_
"""
__version__ = "5.6"
__author__ = "York <york.jong@gmail.com>"
__date__ = "2024/08/05 (initial version) ~ 2024/10/27 (last revision)"

__all__ = [
    'relative_strength',
    'relative_strength_3m',
    'rankings',
    'ma_window_size',
]

import numpy as np
import pandas as pd
import yfinance as yf

import vistock.yf_utils as yfu
from .ranking_utils import *


#------------------------------------------------------------------------------
# IBD Relative Strength (1-Year Version)
#------------------------------------------------------------------------------

[docs] def relative_strength(closes, closes_ref, interval='1d'): """ Calculate the relative strength of a stock compared to a reference index. Relative Strength (RS) is a metric used to evaluate the performance of a stock relative to a benchmark index. A higher RS rating indicates that the stock has outperformed the index, while a lower RS rating suggests underperformance. This function calculates the RS rating by comparing the quarter-weighted growth of the stock's closing prices to the quarter-weighted growth of the reference index's closing prices over the past year. The formula is as follows: :: growth = (current - previous) / previous gf = current/previous = growth + 1 relative_rate = gf_stock / gf_index relative_strength = relative_rate * 100 Here gf means "growth factor", i.e., "price ratio" The quarter-weighted growth is calculated using the `weighted_growth` function. Parameters ---------- closes: pd.Series Closing prices of the stock. closes_ref: pd.Series Closing prices of the reference index. interval: str, optional The frequency of the data points. Must be one of '1d' for daily data, '1wk' for weekly data, or '1mo' for monthly data. Defaults to '1d'. Returns ------- pd.Series Relative strength values for the stock. Example ------- >>> stock_closes = pd.Series([100, 102, 105, 103, 107]) >>> index_closes = pd.Series([1000, 1010, 1015, 1005, 1020]) >>> rs = relative_strength(stock_closes, index_closes) """ growth_stock = weighted_growth(closes, interval) growth_ref = weighted_growth(closes_ref, interval) rs = (1 + growth_stock) / (1 + growth_ref) * 100 return round(rs, 2)
def weighted_growth(closes, interval): """ Calculate the performance of the last year, with the most recent quarter weighted double. This function calculates growths (returns) for each of the last four quarters and applies a weighting scheme that emphasizes recent performance. The most recent quarter is given a weight of 40%, while each of the three preceding quarters are given a weight of 20%. Here is the formula for calculating the return: RS Return = 40% * P3 + 20% * P6 + 20% * P9 + 20% * P12 With P3 = Performance over the last quarter (3 months) P6 = Performance over the last two quarters (6 months) P9 = Performance over the last three quarters (9 months) P12 = Performance over the last four quarters (12 months) Parameters ---------- closes: pd.Series Closing prices of the stock/index. interval: str, optional The frequency of the data points. Must be one of '1d' for daily data, '1wk' for weekly data, or '1mo' for monthly data. Returns ------- pd.Series: Performance values of the stock/index. Example ------- >>> closes = pd.Series([100, 102, 105, 103, 107, 110, 112]) >>> weighted_perf = weighted_growth(closes) """ # Calculate performances over the last quarters p1 = quarters_growth(closes, 1, interval) # over the last quarter p2 = quarters_growth(closes, 2, interval) # over the last two quarters p3 = quarters_growth(closes, 3, interval) # over the last three quarters p4 = quarters_growth(closes, 4, interval) # over the last four quarters return (2 * p1 + p2 + p3 + p4) / 5 def quarters_growth(closes, n, interval): """ Calculate the growth (percentage change) over the last n quarters. This function uses 63 trading days (252 / 4) as an approximation for one quarter. This is based on the common assumption of 252 trading days in a year. Parameters ---------- closes: pd.Series Closing prices of the stock or index. n: int Number of quarters to look back. interval: str, optional The frequency of the data points. Must be one of '1d' for daily data, '1wk' for weekly data, or '1mo' for monthly data. Returns ------- pd.Series The return (percentage change) over the last n quarters. Example ------- >>> closes = pd.Series([100, 102, 105, 103, 107, 110, 112]) >>> quarterly_growth = quarters_growth(closes, 1) """ quarter = { '1d': 252//4, # 252 trading days in a year '1wk': 52//4, # 52 weeks in a year '1mo': 12//4, # 12 months in a year }[interval] periods = min(len(closes) - 1, quarter * n) grwoth = closes.ffill().pct_change(periods=periods, fill_method=None) return grwoth.fillna(0) #------------------------------------------------------------------------------ # IBD Relative Strength (3-Month Version) #------------------------------------------------------------------------------
[docs] def relative_strength_3m(closes, closes_ref, interval='1d'): """ Calculate the 3-Month Relative Strength of a stock compared to a reference index, based on price performance (growths). The 3-Month Relative Strength Rating (RS Rating) measures the stock's price performance against a benchmark index over a recent three-month period. This rating is designed to help investors quickly gauge the strength of a stock's performance relative to the market. Parameters ---------- closes: pd.Series Closing prices of the stock. closes_ref: pd.Series Closing prices of the reference index. interval: str, optional The frequency of the data points. Must be one of '1d' for daily data, '1wk' for weekly data, or '1mo' for monthly data. Defaults to '1d'. Returns ------- pd.Series 3-Month relative strength values for the stock, rounded to two decimal places. The values represent the stock's performance relative to the benchmark index, with 100 indicating parity. """ # Determine the number of trading days for the specified interval span = { '1d': 252 // 4, # a 3-month period based on 252 trading days in a year '1wk': 52 // 4, # 13 weeks (3 months) for weekly data '1mo': 12 // 4, # 3 months for monthly data }[interval] return relative_strength_with_span(closes, closes_ref, span)
def relative_strength_with_span(closes, closes_ref, span): """ Calculate the relative strength of a stock compared to a reference index based on price performance (growths), over a specified period. Parameters ---------- closes: pd.Series Closing prices of the stock. closes_ref: pd.Series Closing prices of the reference index. span: int The span (number of periods) to calculate the exponential moving average (EMA) for the growth factors. Returns ------- pd.Series Relative strength values for the stock, rounded to two decimal places. The values represent the stock's performance relative to the benchmark index, with 100 indicating parity. """ # Calculate daily growths (returns) for the stock and reference index growth_stock = closes.pct_change(fill_method=None).fillna(0) growth_ref = closes_ref.pct_change(fill_method=None).fillna(0) # Calculate daily growth factors gf_stock = growth_stock + 1 gf_ref = growth_ref + 1 # Calculate the Exponential Moving Average (EMA) of the growth factors ema_gf_stock = gf_stock.ewm(span=span, adjust=False).mean() ema_gf_ref = gf_ref.ewm(span=span, adjust=False).mean() # Calculate the cumulative growth factors cum_gf_stock = ema_gf_stock.rolling(window=span, min_periods=1).apply(np.prod, raw=True) cum_gf_ref = ema_gf_ref.rolling(window=span, min_periods=1).apply(np.prod, raw=True) # Calculate the relative strength (RS) rs = cum_gf_stock / cum_gf_ref * 100 return rs.round(2) # Return the RS values rounded to two decimal places #------------------------------------------------------------------------------ # IBD RS Rankings #------------------------------------------------------------------------------
[docs] def rankings(tickers, ticker_ref='^GSPC', period='2y', interval='1d', rs_window='12mo', rating_method='rank'): """ Analyze stocks and generate a ranking table for individual stocks and industries based on Relative Strength (RS). This function calculates Relative Strength (RS) for the given stocks compared to a reference index, then ranks both individual stocks and industries according to their RS values. It provides historical RS data and rating rankings. Parameters ---------- tickers : list of str A list of stock tickers to analyze. ticker_ref : str, optional The ticker symbol for the reference index. Defaults to '^GSPC' (S&P 500). period : str, optional The period for which to fetch historical data. Defaults to '2y' (two years). interval : str, optional The frequency of the data points. Must be one of '1d' for daily data, '1wk' for weekly data, or '1mo' for monthly data. Defaults to '1d'. rs_window : str, optional The time window ('3mo' or '12mo') for calculating Relative Strength (RS). Defaults to '12mo'. rating_method : str, optional The method to calculate ratings. Either 'rank' (based on relative ranking) or 'qcut' (based on quantiles). Defaults to 'rank'. Returns ------- pd.DataFrame A DataFrame containing stock rankings and RS ratings. """ # Set moving average windows based on the interval try: ma_wins = { '1d': [50, 200], '1wk': [10, 40]}[interval] vma_win = { '1d': 50, '1wk': 10}[interval] except KeyError: raise ValueError("Invalid interval. " "Must be '1d', or '1wk'.") stock_df = build_stock_rs_df(tickers=tickers, ticker_ref=ticker_ref, period=period, interval=interval, rs_window=rs_window) stock_df = stock_df.sort_values(by='RS', ascending=False) rs_columns = ['RS', '3mo:1mo max', '6mo:3mo max', '9mo:6mo max'] rating_columns = ['Rating (RS)', 'Rating (3M:1M)', 'Rating (6M:3M)', 'Rating (9M:6M)'] ranking_df = append_ratings(stock_df, rs_columns, rating_columns, method=rating_method) ranking_df = move_columns_to_end( ranking_df, [ 'Price', '52W pos', *[f'MA{w}' for w in ma_wins], f'Volume / VMA{vma_win}', ], ) return ranking_df
def build_stock_rs_df(tickers, ticker_ref='^GSPC', period='2y', interval= '1d', rs_window='12mo'): """ Fetch historical stock data and calculate Relative Strength (RS) for the given stock tickers compared to a reference index. This function returns a DataFrame that includes the RS values and historical max RS values over different periods for each stock. Parameters ---------- tickers : list of str A list of stock tickers to analyze. ticker_ref : str, optional The ticker symbol for the reference index. Defaults to '^GSPC' (S&P 500). period : str, optional The period for which to fetch historical data. Defaults to '2y' (two years). interval : str, optional The frequency of the data points. Must be one of '1d' (daily), '1wk' (weekly), or '1mo' (monthly). Defaults to '1d'. rs_window : str, optional The time window for calculating Relative Strength. Either '3mo' for short-term or '12mo' for long-term RS. Defaults to '12mo'. Returns ------- pd.DataFrame A DataFrame containing stock rankings with the following columns: 'Ticker', 'Price', 'RS' (current), 'RS (1wk:max)', 'RS (1mo:max)', 'RS (3mo:max)', 'RS (6mo:max)', 'RS (9mo:max)'. """ # Select the appropriate relative strength function based on the rs_window rs_func = { '3mo': relative_strength_3m, '12mo': relative_strength, }[rs_window] # Set moving average windows based on the interval try: ma_wins = { '1d': [50, 200], '1wk': [10, 40]}[interval] vma_win = { '1d': 50, '1wk': 10}[interval] except KeyError: raise ValueError("Invalid interval. " "Must be '1d', or '1wk'.") # simple moving average function sma = lambda x, win: x.rolling(window=win, min_periods=1).mean() # Batch download stock price data df_all = yf.download([ticker_ref] + tickers, period=period, interval=interval) df_ref = df_all.xs(ticker_ref, level='Ticker', axis=1) rs_data = [] price_ma = {} for ticker in tickers: df = df_all.xs(ticker, level='Ticker', axis=1) # Caluclate Moving Average for win in ma_wins: price_ma[f'{win}'] = sma(df['Close'], win).round(2) vol_div_vma = (df['Volume'] / sma(df['Volume'], vma_win)).round(2) # Calculate Relative Strengths rs = rs_func(df['Close'], df_ref['Close'], interval) end_date = rs.index[-1] # Calculate max values for the specified time periods one_week_ago = end_date - pd.DateOffset(weeks=1) one_month_ago = end_date - pd.DateOffset(months=1) three_months_ago = end_date - pd.DateOffset(months=3) six_months_ago = end_date - pd.DateOffset(months=6) nine_months_ago = end_date - pd.DateOffset(months=9) # Calculate position in 52W range high_52w = df['Close'].rolling(window=252, min_periods=1).max().iloc[-1] low_52w = df['Close'].rolling(window=252, min_periods=1).min().iloc[-1] current_price = df['Close'].asof(end_date) range_position = (current_price - low_52w) / (high_52w - low_52w) rs_data.append({ 'Ticker': ticker, 'RS': rs.asof(end_date), '1wk:end max': rs.loc[one_week_ago:end_date].max(), '1mo:1wk max': rs.loc[one_month_ago:one_week_ago].max(), '3mo:1mo max': rs.loc[three_months_ago:one_month_ago].max(), '6mo:3mo max': rs.loc[six_months_ago:three_months_ago].max(), '9mo:6mo max': rs.loc[nine_months_ago:six_months_ago].max(), 'Price': df['Close'].asof(end_date).round(2), '52W pos': range_position.round(2), **{f'MA{w}': price_ma[f'{w}'].iloc[-1] for w in ma_wins}, f'Volume / VMA{vma_win}': vol_div_vma.iloc[-1], }) # Create DataFrame from RS data stock_df = pd.DataFrame(rs_data) return stock_df #------------------------------------------------------------------------------ # Unit Test #------------------------------------------------------------------------------ def main(rating_method='qcut', rs_window='12mo', out_dir='out'): import os from datetime import datetime import vistock.stock_indices as si from vistock.ibd_fin import financial_metric_ranking code = 'SPX' code = 'SOX' tickers = si.get_tickers(code) df_rs = rankings(tickers, rs_window=rs_window, rating_method=rating_method) df_fin = financial_metric_ranking(tickers) if df_rs.empty or df_fin.empty: print("Not enough data to generate rankings.") return columns_to_keep = ['Ticker', 'Sector', 'Industry'] df_stock = pd.merge(df_rs, df_fin[columns_to_keep], on='Ticker', how='left') print('\nStock DataFrame:') print(df_stock.head(10)) rs_columns = ['RS', '1mo:1wk max', '3mo:1mo max', '6mo:3mo max', '9mo:6mo max'] columns = ['Sector', 'Ticker'] + rs_columns df_industry = groupby_industry(df_stock, columns, key='RS') df_industry = df_industry.sort_values(by='RS', ascending=False) df_industry = df_industry.reset_index(drop=True) rating_columns = ['Rating (RS)', 'Rating (1M:1W)', 'Rating (3M:1M)', 'Rating (6M:3M)', 'Rating (9M:6M)'] df_industry = append_ratings(df_industry, rs_columns, rating_columns) print('\nIndustry DataFrame:') print(df_industry.head(10)) # Save to CSV print("\n\n***") today = datetime.now().strftime('%Y%m%d') os.makedirs(out_dir, exist_ok=True) for table, kind in zip([df_stock, df_industry], ['stocks', 'industries']): filename = f'rs_{kind}_{rs_window}_{rating_method}_{today}.csv' table.to_csv(os.path.join(out_dir, filename), index=False) print(f'Your "{filename}" is in the "{out_dir}" folder.') print("***\n") if __name__ == "__main__": import time start_time = time.time() main(rating_method='qcut', rs_window='3mo') print(f"Execution time: {time.time() - start_time:.4f} seconds")