Yahoo Stock Ticker

Shows major stock indices in the menu bar and stock symbols in the dropdown menu by pulling data from the Yahoo Finance API. Similar to finance.yahoo.com the prices are delayed, but no API key is necessary. You can also set price alarms for BUY/SELL limits, which will notify you when the limit is reached.

Image preview of Yahoo Stock Ticker plugin.

yahoo_stock_ticker.10m.py

Edit
Open on GitHub
#!/usr/bin/env LC_ALL=en_US.UTF-8 /usr/local/bin/python3
#
# <xbar.title>Yahoo Stock Ticker</xbar.title>
# <xbar.version>v1.1</xbar.version>
# <xbar.author>Long Do</xbar.author>
# <xbar.author.github>longpdo</xbar.author.github>
# <xbar.desc>Shows major stock indices in the menu bar and stock symbols in the dropdown menu by pulling data from the Yahoo Finance API. Similar to finance.yahoo.com the prices are delayed, but no API key is necessary. You can also set price alarms for BUY/SELL limits, which will notify you when the limit is reached.</xbar.desc>
# <xbar.image>https://github.com/longpdo/bitbar-plugins-custom/raw/master/images/yahoo-stock-ticker.png</xbar.image>
# <xbar.dependencies>python3</xbar.dependencies>
# <xbar.abouturl>https://github.com/longpdo/bitbar-plugins-custom/blob/master/README.md#yahoo-stock-ticker</xbar.abouturl>
#
# by longpdo (https://github.com/longpdo)

from datetime import datetime
import json
import os
import re
import sys
import subprocess

# ---------------------------------------------------------------------------------------------------------------------
# Enter your stock symbols here in the format: ["symbol1", "symbol2", ...]
symbols = ["AAPL", "AMZN", "NFLX", "GOOG", "BIDU", "BABA", "TCEHY"]

# Enter the order how you want to sort the stock list:
# 'name'                     : Sort alphabetically by name from A to Z
# 'market_change_winners'    : Sort by value from top winners to losers
# 'market_change_losers'     : Sort by value from top losers to winners
# 'market_change_volatility' : Sort by absolute value from top to bottom
# '' or other values         : Sort by your custom order from the symbols array above
sort_by = 'market_change_winners'
# ---------------------------------------------------------------------------------------------------------------------

# ---------------------------------------------------------------------------------------------------------------------
# CODE STARTING BELOW HERE, DO NOT EDIT IF YOU ARE A REGULAR USER
# Variables
indices_dict = {
    '^GSPC': 'πŸ‡ΊπŸ‡Έ S&P 500',
    '^DJI': 'πŸ‡ΊπŸ‡Έ DOW 30',
    '^IXIC': 'πŸ‡ΊπŸ‡Έ NASDAQ',
    '^GDAXI': 'πŸ‡©πŸ‡ͺ DAX',
    '^FTSE': 'πŸ‡¬πŸ‡§ FTSE 100',
    '^FCHI': 'πŸ‡«πŸ‡· CAC 40',
    '^STOXX50E': 'πŸ‡ͺπŸ‡Ί EURO STOXX 50',
}
GREEN = '\033[32m'
RED = '\033[31m'
RESET = '\033[0m'
FONT = "| font='Menlo'"
# ---------------------------------------------------------------------------------------------------------------------


# macOS Alerts, Prompts and Notifications -----------------------------------------------------------------------------
# Display a macOS specific alert dialog to get confirmation from user to continue
def alert(alert_title='', alert_text='', alert_buttons=['Cancel', 'OK']):
    try:
        d = locals()
        user_input = subprocess.check_output(['osascript', '-l', 'JavaScript', '-e', '''
            const app = Application.currentApplication()
            app.includeStandardAdditions = true
            const response = app.displayAlert('{alert_title}', {{
                message: '{alert_text}',
                as: 'critical',
                buttons: ['{alert_buttons[0]}', '{alert_buttons[1]}'],
                defaultButton: '{alert_buttons[1]}',
                cancelButton: '{alert_buttons[0]}'
            }})
            response
        '''.format(**d)]).decode('ascii').rstrip()
        return user_input
    except subprocess.CalledProcessError:
        pass


# Display a macOS specific prompt dialog to get text input from the user
def prompt(prompt_text=''):
    try:
        d = locals()
        user_input = subprocess.check_output(['osascript', '-l', 'JavaScript', '-e', '''
            const app = Application.currentApplication()
            app.includeStandardAdditions = true
            const response = app.displayDialog('{prompt_text}', {{
                defaultAnswer: '',
                buttons: ['Cancel', 'OK'],
                defaultButton: 'OK'
            }})
            response.textReturned
        '''.format(**d)]).decode('ascii').rstrip()
        if user_input == '':
            sys.exit()
        return user_input
    except subprocess.CalledProcessError:
        pass


# Display a macOS specific prompt dialog prompting user for a choice from a list
def prompt_selection(prompt_text='', choices=''):
    try:
        d = locals()
        user_selection = subprocess.check_output(['osascript', '-l', 'JavaScript', '-e', '''
            const app = Application.currentApplication()
            app.includeStandardAdditions = true
            var choices = {choices}
            const response = app.chooseFromList(choices, {{
                withPrompt: '{prompt_text}',
                defaultItems: [choices[0]]
            }})
            response
        '''.format(**d)]).decode('ascii').rstrip()
        if user_selection == 'false':
            sys.exit()
        return user_selection
    except subprocess.CalledProcessError:
        pass


# Display a macOS specific notification
def notify(text, title, subtitle, sound='Glass'):
    cmd = 'osascript -e \'display notification "{}" with title "{}" subtitle "{}" sound name "{}"\''
    os.system(cmd.format(text, title, subtitle, sound))
# ---------------------------------------------------------------------------------------------------------------------


# Methods to read, write, remove data from the hidden .db file --------------------------------------------------------
def read_data_file(data_file):
    with open(data_file, 'r') as f:
        content = f.readlines()
    f.close()
    content = [x.strip() for x in content]
    return content


def write_data_file(data_file, imit_type, symbol, price):
    with open(data_file, 'a') as f:
        f.write(limit_type + ' ' + symbol + ' ' + price + '\n')
    f.close()


def remove_line_from_data_file(data_file, line_to_be_removed):
    with open(data_file, 'r') as f:
        content = f.readlines()
    with open(data_file, 'w') as f:
        for line in content:
            if line.strip('\n') != line_to_be_removed:
                f.write(line)
    f.close()
# ---------------------------------------------------------------------------------------------------------------------


# Curl the yahoo api for data
def get_stock_data(symbol):
    # Building the curl command as a string
    library = 'curl --silent '
    api = 'https://query2.finance.yahoo.com/v10/finance/quoteSummary/'
    modules = ['assetProfile', 'summaryProfile', 'summaryDetail', 'esgScores', 'price', 'incomeStatementHistory',
               'incomeStatementHistoryQuarterly', 'balanceSheetHistory', 'balanceSheetHistoryQuarterly',
               'cashflowStatementHistory', 'cashflowStatementHistoryQuarterly', 'defaultKeyStatistics', 'financialData',
               'calendarEvents', 'secFilings', 'recommendationTrend', 'upgradeDowngradeHistory', 'institutionOwnership',
               'fundOwnership', 'majorDirectHolders', 'majorHoldersBreakdown', 'insiderTransactions', 'insiderHolders',
               'netSharePurchaseActivity', 'earnings', 'earningsHistory', 'earningsTrend', 'industryTrend', 'indexTrend',
               'sectorTrend']
    modules_string = '?modules=' + ','.join(modules)
    cmd = library + "'" + api + symbol + modules_string + "'"

    # Popen to run the curl command and retrieve the output
    output = os.popen(cmd).read()
    # Jsonify the output from the curl command
    json_output = json.loads(output)

    # Check if a valid symbol was used
    try:
        stock_data = json_output['quoteSummary']['result'][0]
    except IndexError:
        alert('Error', 'Invalid stock symbol: ' + symbol)
        sys.exit()

    return stock_data


# Check a given stock symbol against the price limit list
def check_price_limits(symbol_to_be_checked, current_price, price_limit_list, data_file):
    for limit_entry in price_limit_list:
        if symbol_to_be_checked in limit_entry:
            # Get the limit price, limits are saved in the format: TYPE SYMBOL PRICE
            limit_price = float(limit_entry.split()[2])
            notification_text = 'Current price is: ' + str(current_price)
            notification_title = 'Price Alarm'

            # Notify user if current price is lower than the BUY limit, then remove the limit from list
            if 'BUY' in limit_entry and current_price < limit_price:
                notification_subtitle = 'BUY Limit: ' + str(limit_price)
                notify(notification_text, notification_title,
                       notification_subtitle)
                remove_line_from_data_file(data_file, limit_entry)

            # Notify user if current price is higher than the SELL limit, then remove the limit from list
            if 'SELL' in limit_entry and current_price > limit_price:
                notification_subtitle = 'SELL Limit: ' + str(limit_price)
                notify(notification_text, notification_title,
                       notification_subtitle)
                remove_line_from_data_file(data_file, limit_entry)


# Print the indices information in the menu bar
def print_index(index, name):
    market_state = index['price']['marketState']
    change = index['price']['regularMarketChangePercent']['raw']

    # Setting color and emojis depending on the market state and the market change
    if market_state != 'REGULAR':
        # Set change with a moon emoji for closed markets
        colored_change = 'πŸŒ›' + \
            '(' + index['price']['regularMarketChangePercent']['fmt'] + ') '
    if market_state == 'REGULAR':
        # Set color for positive and negative values
        color = ''
        if change > 0:
            color = GREEN + 'β–²'
        if change < 0:
            color = RED + 'β–Ό'
        # Format change to decimal with a precision of two and reset ansi color at the end
        colored_change = color + \
            '(' + index['price']['regularMarketChangePercent']['fmt'] + ') ' + RESET

    # Print the index info only to the menu bar
    print(name, colored_change, '| dropdown=false', sep=' ')


# Print the stock info in the dropdown menu with additional info in the submenu
def print_stock(s):
    market_state = s['price']['marketState']
    change = s['price']['regularMarketChangePercent']['raw']

    # Setting color and emojis depending on the market state and the market change
    if market_state != 'REGULAR':
        market = 'CLOSED'
        # Set change with a moon emoji for closed markets
        colored_change = 'πŸŒ›' + \
            '(' + s['price']['regularMarketChangePercent']['fmt'] + ') '
    if market_state == 'REGULAR':
        # Set color for positive and negative values
        color = ''
        market = 'OPEN'
        if change > 0:
            color = GREEN + 'β–²'
        if change < 0:
            color = RED + 'β–Ό'
        # Format change to decimal with a precision of two and reset ansi color at the end
        change_in_percent = '(' + \
            s['price']['regularMarketChangePercent']['fmt'] + ')'
        colored_change = color + change_in_percent + RESET

    # Remove appending stock exchange symbol for foreign exchanges, e.g. Apple stock symbol in Frankfurt: APC.F -> APC
    symbol = s['price']['symbol'].split('.')[0]
    # Convert epoch to human readable time HH:MM:SS
    time = datetime.fromtimestamp(
        s['price']['regularMarketTime']).strftime('%X')

    regular_market_day_high = s['summaryDetail']['regularMarketDayHigh']['raw']
    regular_market_day_low = s['summaryDetail']['regularMarketDayLow']['raw']
    regular_market_day_range = regular_market_day_high - regular_market_day_low

    fifty_two_week_high = s['summaryDetail']['fiftyTwoWeekHigh']['raw']
    fifty_two_week_low = s['summaryDetail']['fiftyTwoWeekLow']['raw']
    fifty_two_week_range = fifty_two_week_high - fifty_two_week_low

    # Print the stock info seen in the dropdown menu
    stock_info = '{:<5} {:>10} {:<10}' + FONT
    print(stock_info.format(
        symbol, s['price']['regularMarketPrice']['fmt'], colored_change))
    # Print additional stock info in the submenu
    stock_submenu = '{:<17} {:<17}' + FONT
    print('--' + s['price']['shortName'] + FONT)
    print('--' + s['price']['longName'] +
          ' - Currency in ' + s['price']['currency'] + FONT)
    print('--' + time + ' - Market is ' + market + FONT)
    print('-----')
    print(stock_submenu.format('--Previous Close:',
          s['price']['regularMarketPreviousClose']['fmt']))
    print(stock_submenu.format(
        '--Open:', s['price']['regularMarketOpen']['fmt']))
    print(stock_submenu.format('--Bid:', s['summaryDetail']['bid']['fmt']))
    print(stock_submenu.format('--Ask:', s['summaryDetail']['ask']['fmt']))
    print(stock_submenu.format("--Day's Range:",
          '{:.2f}'.format(regular_market_day_range)))
    print(stock_submenu.format('--52 Week Range:',
          '{:.2f}'.format(fifty_two_week_range)))


# Print the price limits in the dropdown menu
def print_price_limits(price_limit_list):
    PARAMETERS = FONT + " refresh=true terminal='false' bash='" + __file__ + "'"

    print('---')
    print('Price Limits' + FONT)
    # Print available price limits in the submenu
    for limit_entry in price_limit_list:
        # Split the limit entry, limits are saved in the format: TYPE SYMBOL PRICE
        limit_type = limit_entry.split()[0]
        symbol = limit_entry.split()[1]
        limit_price = limit_entry.split()[2]
        price_limit_submenu = '{:<6} {:<4} {:<10}'
        # Print the price limit data into the submenu
        # onClick will rerun this script with parameters 'remove' and the {limit_entry} to remove clicked the limit
        print(price_limit_submenu.format('--' + limit_type, symbol, limit_price +
              PARAMETERS + " param1='remove' param2='" + limit_entry + "'"))
    print('-----')
    print('--To remove a limit, click on it.' + FONT)
    # Print the clickable fields to set new limits or clear all price limits
    # onClick will rerun this script with parameters 'set' to set a new limit
    print('Set new Price Limit...' + PARAMETERS + " param1='set'")
    # onClick will rerun this script with parameters 'clear' to clear the hidden .db file
    print('Clear all Price Limits...' + PARAMETERS + " param1='clear'")


if __name__ == '__main__':
    data_file = os.path.join(os.path.dirname(os.path.realpath(
        __file__)), '.' + os.path.basename(__file__) + '.db')

    # Normal execution by BitBar without any parameters
    if len(sys.argv) == 1:
        stocks = []

        # Check if hidden .db file exists
        try:
            price_limit_list = read_data_file(data_file)
        except FileNotFoundError:
            price_limit_list = []

        # Print the menu bar information
        for symbol, name in indices_dict.items():
            # For each symbol: curl the data, then print it
            index = get_stock_data(symbol)
            print_index(index, name)

        # For each symbol: curl the data, check against the .db file for limits
        for symbol in symbols:
            stock = get_stock_data(symbol)
            stocks.append(stock)
            check_price_limits(
                symbol, stock['price']['regularMarketPrice'], price_limit_list, data_file)

        # Set order of stocks
        if sort_by == 'name':
            stocks = sorted(stocks, key=lambda k: k['shortName'])
        if sort_by == 'market_change_winners':
            stocks = sorted(
                stocks, key=lambda k: k['price']['regularMarketChangePercent']['raw'], reverse=True)
        if sort_by == 'market_change_losers':
            stocks = sorted(
                stocks, key=lambda k: k['regularMarketChangePercent'])
        if sort_by == 'market_change_volatility':
            stocks = sorted(stocks, key=lambda k: abs(
                k['regularMarketChangePercent']), reverse=True)

        # Print the stock information inside the dropdown menu
        print('---')
        for stock in stocks:
            print_stock(stock)

        # Print the price limit section inside the dropdown
        print_price_limits(price_limit_list)

    # Script execution with parameter 'set' to set new price limits
    if len(sys.argv) == 2 and sys.argv[1] == 'set':
        # Run this until user does not want to continue
        while True:
            # Get the user selection of whether he wants to set 'BUY' or 'SELL'
            limit_type_prompt = 'Select the type of your limit: BUY (SELL) limits are triggered, when the price is lower (higher) than the limit.'
            limit_type_choices = '["BUY", "SELL"]'
            limit_type = prompt_selection(
                limit_type_prompt, limit_type_choices)

            # Get the user selection of all tracked symbols
            symbol = prompt_selection('Select stock symbol:', symbols)

            # Get the user input for a price limit, info message includes the current market price
            price = prompt('Current price of ' + symbol + ' is ' + str(get_stock_data(
                symbol)['regularMarketPrice']) + '. Enter a value for your price limit.')
            # Check if the user input are decimals with a precision of two
            if not re.match(r'^\d+(\.\d{1,2})?$', price):
                # Alert the user on invalid value and stop the script
                alert('Error', 'You entered an invalid value: ' + price +
                      ' - valid values are decimals with a precision of 2, e.g 25.70!')
                sys.exit()

            # Write the limit to the hidden .db file
            write_data_file(data_file, limit_type, symbol, price)

            # Ask user if he wants to add another limit
            add_another_limit = alert(
                'Question', 'Do you want to add another price limit?', ['No', 'Yes'])
            # If the user clicked the 'No' button, stop the script
            if add_another_limit is None:
                sys.exit()

    # Script execution with parameter 'clear' to clear the .db file
    if len(sys.argv) == 2 and sys.argv[1] == 'clear':
        # Ask for user confirmation
        warning = alert(
            'Warning', 'This will clear your price limits! Do you want to continue?')
        if warning is None:
            sys.exit()

        # Clear the file
        open(data_file, 'w').close()

    # Script execution with the parameters 'remove' and the line to be removed
    if len(sys.argv) == 3 and sys.argv[1] == 'remove':
        limit_to_be_removed = sys.argv[2]
        remove_line_from_data_file(data_file, limit_to_be_removed)