From f8b3fda0b5eee86162fb3c11eddda2c8bbfa37a5 Mon Sep 17 00:00:00 2001 From: mgrotke Date: Tue, 28 Jan 2025 13:22:09 -0500 Subject: [PATCH 01/10] People were having trouble with wrongly named folder when downloaded from git --- THE FOLDER NAME SHOULD BE mgconky NOT mgconky-master.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/THE FOLDER NAME SHOULD BE mgconky NOT mgconky-master.txt b/THE FOLDER NAME SHOULD BE mgconky NOT mgconky-master.txt index 6a98f07..98d3021 100644 --- a/THE FOLDER NAME SHOULD BE mgconky NOT mgconky-master.txt +++ b/THE FOLDER NAME SHOULD BE mgconky NOT mgconky-master.txt @@ -4,4 +4,4 @@ This should be the folder: ~/.conky/mgconky/ -Thank you. +Thank you! From 923887536450c49a2d04e0ae4ee853fc0b329e73 Mon Sep 17 00:00:00 2001 From: mgrotke Date: Tue, 28 Jan 2025 17:15:53 -0500 Subject: [PATCH 02/10] AMD graphics card name was not being parsed correctly from lspci --- devices/get_device_info.lua | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/devices/get_device_info.lua b/devices/get_device_info.lua index c0b4e86..17a22fc 100644 --- a/devices/get_device_info.lua +++ b/devices/get_device_info.lua @@ -51,7 +51,7 @@ function conky_get_cpu_info() end end --- ########################################################## GET GU MAKE AND MODEL ########################################################### +-- ########################################################## GET GPU MAKE AND MODEL ########################################################## function conky_shorten_gpu_name(gpu_name) return (gpu_name @@ -73,11 +73,15 @@ function conky_shorten_gpu_name(gpu_name) :gsub("Raspberry Pi Foundation", "Raspberry Pi") :gsub("Broadcom Inc%.", "Broadcom") :gsub("Lite Hash Rate", "LHR") - :gsub("GA%d+%s%[", "") -- Remove chip identifier like "GA106 [" - :gsub("%].*", "") -- Remove everything after "]" - :gsub("%(.*%)", "") -- Remove revision info like "(rev a1)" - :gsub("%s+", " ") -- Normalize spaces - :match("^%s*(.-)%s*$") -- Trim leading/trailing spaces + :gsub("Renoir", "") -- Remove "Renoir" + :gsub("Ryzen %d+/%d+ Mobile Series", "") -- Remove Ryzen details (for APUs) + :gsub("%(Ryzen.-%)", "") -- Remove Ryzen-related info in parentheses + :gsub("GA%d+%s%[", "") -- Remove chip identifier like "GA106 [" + :gsub("%[.*%]%s*", "") -- Remove chip code in brackets like "[1002:1636]" + :gsub("%((rev .-)%)", "") -- Remove revision info like "(rev a1)" + :gsub("%s+", " ") -- Normalize spaces + :gsub("%]%s*$", "") -- Remove trailing square bracket "]" if it exists + :match("^%s*(.-)%s*$") -- Trim leading/trailing spaces ) end From 02b71f33fe2a0fb65d04a95f8d8c704200bae219 Mon Sep 17 00:00:00 2001 From: mgrotke Date: Wed, 29 Jan 2025 00:14:19 -0500 Subject: [PATCH 03/10] Added another filter to name the CPU properly --- devices/get_device_info.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/devices/get_device_info.lua b/devices/get_device_info.lua index 17a22fc..a5f715c 100644 --- a/devices/get_device_info.lua +++ b/devices/get_device_info.lua @@ -13,6 +13,7 @@ function conky_shorten_cpu_name(cpu_name) :gsub("CPU", "") -- Remove "CPU" :gsub("Processor", "") -- Remove "Processor" :gsub("@[%s%w%.]+", "") -- Remove "@ XGHz" + :gsub("with.*", "") -- Remove "with" and anything after the word "with" :gsub("%s%s+", " ") -- Remove extra spaces :gsub("^%s+", "") -- Trim leading spaces :gsub("%s+$", "") -- Trim trailing spaces From a4b49e05e6314828b34b874104e9fd21a023763a Mon Sep 17 00:00:00 2001 From: mgrotke Date: Fri, 31 Jan 2025 00:53:53 -0500 Subject: [PATCH 04/10] Cleaned up the formatting of the active connections --- conf | 5 +-- devices/get_device_info.lua | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/conf b/conf index a5417d8..27bc0fa 100644 --- a/conf +++ b/conf @@ -234,7 +234,8 @@ ${voffset -6}Total: ${color3}${totaldown ${gw_iface}}${color}${goto 140}Total: $ # Connections - netstat shows number of connections from your computer and application/PID making it. Kill spyware! #-------------------- ${voffset 6}${color0}${font Neuropolitical:size=8:bold}CONNECTIONS ${color1}${hr 2}${color}${font Courier:size=9} -${voffset 6}Num. connections / PID / Process -${voffset 2}${color3}${execi 30 netstat -ept | grep ESTAB | awk '{print $9}' | cut -d: -f1 | sort | uniq -c | sort -nr}${color} +#${voffset 6}Num. connections / PID / Process +#${voffset 2}${color3}${execi 30 netstat -ept | grep ESTAB | awk '{print $9}' | cut -d: -f1 | sort | uniq -c | sort -nr}${color} +${voffset 2}${lua_parse conky_get_connections} ]] diff --git a/devices/get_device_info.lua b/devices/get_device_info.lua index a5f715c..a27ee0e 100644 --- a/devices/get_device_info.lua +++ b/devices/get_device_info.lua @@ -361,3 +361,64 @@ print(" CHECK SWAP STATUS = " .. conky_check_swap_status()) print(" GET MEMORY USAGE = " .. conky_get_memory_usage("mem")) print(" GET SWAP USAGE = " .. conky_get_memory_usage("swap")) + +-- ####################################################### GET ACTIVE CONNECTIONS ############################################################## + +function conky_get_key_with_highest_value(t) + local max_key = nil + local max_value = -math.huge -- Initialize with the smallest possible number + + for key, value in pairs(t) do + if type(value) == "number" and value > max_value then + max_value = value + max_key = key + end + end + + return max_key +end + +function conky_get_connections() + local handle = io.popen("ss -eptH | grep ESTAB 2>/dev/null") + if not handle then return "Error: Unable to execute ss command" end + + local process_names = {} + local process_counts = {} + + -- Read each line of output + for line in handle:lines() do + -- Extract process name and PID using pattern matching + local pname, pid = line:match('users:%(%("([^"]+)",pid=(%d+)') + pid = tonumber(pid) -- Ensure pid is a number + if pname and pid then + -- Store process name by PID and count occurrences + process_names[pid] = pname + process_counts[pid] = (process_counts[pid] or 0) + 1 + end + end + handle:close() + + -- Header line + local tab1_offset = "${goto 145}" + local line_prefix = "${voffset 0}${color}${font StyleBats:size=10}h${font Courier:size=9}${voffset -1}${color3}" + local result = string.format("${color}Process%sPID${alignr}Num \n", tab1_offset) + + -- Sort and format output + while true do + local max_key = conky_get_key_with_highest_value(process_counts) + + if max_key == nil then + break -- Stop when the table is empty + end + + result = result .. string.format("%s %s%s%d${alignr}%d \n", line_prefix, string.sub(process_names[max_key], 1, 16), tab1_offset, max_key, process_counts[max_key]) + + -- Remove the highest key from both tables + process_counts[max_key] = nil + process_names[max_key] = nil + end + + return result +end + + From f78d5d47785a59196a479a38be1fa2c097192e2e Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sat, 8 Nov 2025 11:41:25 -0500 Subject: [PATCH 05/10] Fixed df error about swap, fixed user instructions for which dependencies are required and scripts require executable permissions --- conf | 28 ++++++++++++++-------------- devices/get_device_info.lua | 8 ++++++++ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/conf b/conf index 27bc0fa..e516297 100644 --- a/conf +++ b/conf @@ -15,10 +15,11 @@ -- !!!IMPORTANT!!! THE FOLLOWING ARE REQUIREMENTS FOR THIS SCRIPT TO WORK PROPERLY: -- (1) DEPENDENCIES. -- Install the following software, if not already installed: --- "Conky" sudo apt-get install conky-all (or use software manager) --- "Jq" sudo apt-get install jq (or use software manager) --- "Curl" sudo apt-get install curl (or use software manager) --- "Wget" sudo apt-get install wget (or use software manager) +-- "Conky" sudo apt-get install conky-all +-- "Jq" sudo apt-get install jq +-- "Curl" sudo apt-get install curl +-- "Wget" sudo apt-get install wget +-- "python3" sudo apt-get install python3 -- (2) FONTS. -- Install the following custom fonts: -- "Neuropolitical" Place .ttf file in ~/.fonts/ https://www.dafont.com/font-comment.php?file=neuropolitical @@ -28,8 +29,8 @@ -- chmod +x ~/.conky/mgconky/weather/get_weather.sh -- chmod +x ~/.conky/mgconky/weather/parse_weather.sh -- chmod +x ~/.conky/mgconky/weather/parse_forecast.sh --- chmod +x ~/.conky/mgconky/weather/parse_forecast.sh --- chmod +x ~/.conky/mgconky/stocks/get_stocks.py (Requires python3, which is usually pre-installed on your OS) +-- chmod +x ~/.conky/mgconky/stocks/get_stocks_alphavantage.py +-- chmod +x ~/.conky/mgconky/stocks/get_stocks_finnhub.py -- (4) WEATHER. -- Make a free account at https://openweathermap.org/ -- Write down your API key, which is found on the "API keys" tab after you log in. (https://home.openweathermap.org/api_key @@ -91,16 +92,16 @@ conky.config = { color7 = "#FF0000", -- Bad values (red) -- Weather variables - template0 = "YOUR_OPENWEATHERMAP_API_KEY_HERE", -- OpenWeatherMap API key (https://home.openweathermap.org/api_keys) - template1 = "YOUR_OPENWEATHERMAP_CITY_ID_HERE", -- OpenWeatherMap City ID (the number in the URL of your city, for example: https://openweathermap.org/city/5128581) + template0 = "", -- OpenWeatherMap API key (https://home.openweathermap.org/api_keys) + template1 = "4467657", -- OpenWeatherMap City ID (the number in the URL of your city, for example: https://openweathermap.org/city/5128581) template2 = "imperial", -- Temp unit ("default" for Kelvin, "metric" for Celsius, "imperial" for Fahrenheit) template3 = "", -- Locale (e.g., "es_ES.UTF-8") # Leave empty for default -- Stock variables - template4 = "YOUR_FINNHUB_API_KEY_HERE", -- FinnHub API key (https://finnhub.io/) - template5 = "YOUR_ALPHAVANTAGE_API_KEY_HERE", -- Alpha Vantage API key (https://www.alphavantage.co/) - template6 = "goog,amzn,aapl,msft,meta,tsla,avgo,tsm,brk.a,pg,nvda", -- Stock symbols for FinnHub (comma separated, no spaces, i.e. goog,amzn,aapl) - template7 = "nvda", -- Stock symbols for Alpha Vantage (keep to a minimum unless you have a paid API key) + template4 = "", -- FinnHub API key (https://finnhub.io/) + template5 = " 0 then output = output .. '${voffset 0}${color0}${font Neuropolitical:size=8:bold}DRIVE${font Courier:size=9} /dev/' .. drive .. '${color} ${color1}${hr 2}${color}\n' for _, mount in ipairs(data.mountpoints) do + + -- Skip entries that are not real mountpoints like [SWAP] + if mount:sub(1, 1) ~= "/" then + goto continue + end + -- Use df to get the size and used space for the mount local handle = io.popen("df -h --output=target,size,used " .. mount .. " | tail -n 1") local df_result = handle:read("*a") @@ -182,6 +188,8 @@ function conky_get_drives_and_volumes() output = output .. '${voffset 2}' .. target .. ': ${alignr}${color3}' .. used .. 'B${color} of ${color3}' .. size .. 'B${color}\n' output = output .. '${voffset -2}${color5}${fs_bar ' .. target .. '}${color}\n' end + + ::continue:: end output = output .. '\n' -- Add a blank line after the last mount point of the current drive end From 579509fe874f11b2aee1af21921bb350dd46a5c6 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sat, 3 Jan 2026 21:43:01 -0500 Subject: [PATCH 06/10] The AlphaVantage stock script now uses a cache file that is built slowly while waiting 15 seconds per API call, because the free AlphaVantage API keys are rate limited and only the first stock symbol was being returned. --- stocks/get_stocks_alphavantage.py | 92 +++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/stocks/get_stocks_alphavantage.py b/stocks/get_stocks_alphavantage.py index af2fb49..bbb8b22 100755 --- a/stocks/get_stocks_alphavantage.py +++ b/stocks/get_stocks_alphavantage.py @@ -1,9 +1,16 @@ #!/usr/bin/env python3 +import os +import sys +import time import requests from datetime import datetime, timedelta import argparse +CACHE_FILE = os.path.expanduser("~/.cache/mgconky/stocks_alphavantage.txt") +API_DELAY_SECONDS = 15 +USE_CACHE = True + def fetch_intraday_data(api_key, symbol, interval="1min"): """Fetch current and historical intraday price using TIME_SERIES_INTRADAY.""" url = "https://www.alphavantage.co/query" @@ -99,6 +106,7 @@ def fetch_historical_data(api_key, symbol, range_in_days): print(f"Error: Failed to fetch historical data for {symbol} - {e}") return None + def main(): # Parse command-line arguments parser = argparse.ArgumentParser(description="Fetch stock data from Alpha Vantage.") @@ -126,9 +134,15 @@ def main(): line_tab3_offset = "${alignr}" # Could also replace this with goto 120 if you don't like the right alignment # Iterate symbols - for symbol in symbols: + had_success = False + for i, symbol in enumerate(symbols): fetched_data = fetch_intraday_data(args.api_key, symbol) if args.range_in_days < 1 else fetch_historical_data(args.api_key, symbol, args.range_in_days) - if fetched_data: + if ( + isinstance(fetched_data, dict) + and "current_price" in fetched_data + and isinstance(fetched_data["current_price"], (int, float)) + ): + had_success = True current_price = fetched_data["current_price"] compare_price = fetched_data["compare_price"] price_difference = current_price - compare_price @@ -146,11 +160,81 @@ def main(): else: output.append(f"{symbol}: Error fetching data") + # --- Rate limit protection for Alpha Vantage --- + if i < len(symbols) - 1: + time.sleep(API_DELAY_SECONDS) # wait 15 seconds between queries + # Join all parts of the output and print it header_label = f"Intraday" if args.range_in_days < 1 else f"{args.range_in_days} Day" header_line = f"{line_tab1_offset}{color_header}Ticker{line_tab2_offset}Price ($$){line_tab3_offset}{header_label}{color_label}" - return header_line + "\n" + f"{line_tab1_offset}{color_header}${{voffset -5}}${{hr 1}}" + "\n" + "\n".join(output) + final_output = ( + header_line + + "\n" + + f"{line_tab1_offset}{color_header}${{voffset -5}}${{hr 1}}" + + "\n" + + "\n".join(output) + ) + + if had_success: + if USE_CACHE: + # Save results to cache file + os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) + tmp_file = CACHE_FILE + ".tmp" + with open(tmp_file, "w") as f: + f.write(final_output) + os.replace(tmp_file, CACHE_FILE) + else: + # Direct output mode (no cache) + return final_output + else: + # No valid data was returned + # Do NOT overwrite cache + # Do NOT return anything in cache mode + return None + if __name__ == "__main__": - print(main()) + + # The free alpha vantage plan may require you to rate limit. + # Conky will not wait for rate limited output. + # We must instead return the previous results + # (from when this script was last called) + # Set RATE_LIMIT to false if you have the paid plan. + if USE_CACHE: + + # Fork immediately so the process Conky launched can exit right away. + pid = os.fork() + if pid > 0: + # PARENT PROCESS (this is the process Conky launched) + + # Return contents of cache file (from early query to Conky) + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE, "r") as f: + print(f.read(), end="") + sys.stdout.flush() + except Exception: + pass + + # Parent exits immediately ---> Conky renders output + sys.exit(0) + + else: + # CHILD PROCESS (background updater, not waited on by Conky) + + # Detach from the parent session so Conky does not wait on this process + os.setsid() + + # Perform new query + main() + sys.exit(0) + + else: + + # Return the results to Conky + result = main() + if result is not None: + print(result) + + From 6b04d9296a7d46ff18c390cafd3a325f4a2eb4fd Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Wed, 14 Jan 2026 01:28:18 -0500 Subject: [PATCH 07/10] Filtered out temporary mounts like from timeshift, systemd, etc from being considered as mount points to display in the Conky drive list. --- devices/get_device_info.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/devices/get_device_info.lua b/devices/get_device_info.lua index f5c0081..92b021d 100644 --- a/devices/get_device_info.lua +++ b/devices/get_device_info.lua @@ -177,6 +177,17 @@ function conky_get_drives_and_volumes() goto continue end + -- Skip common temporary mounts + if mount:match("^/sys/") then goto continue end + if mount:match("^/proc/") then goto continue end + if mount:match("^/dev/") then goto continue end + + -- Skip mounts under /var/, but allow a mount at /var itself + if mount ~= "/var" and mount:match("^/var/") then goto continue end + + -- Skip runtime mounts except removable media + if mount:match("^/run/") and not mount:match("^/run/media/") then goto continue end + -- Use df to get the size and used space for the mount local handle = io.popen("df -h --output=target,size,used " .. mount .. " | tail -n 1") local df_result = handle:read("*a") @@ -184,6 +195,13 @@ function conky_get_drives_and_volumes() -- Parse df output local target, size, used = df_result:match("(%S+)%s+(%S+)%s+(%S+)") + + -- Ensure mount still exists before emitting fs_bar + local test = io.open(target, "r") + if not test then goto continue end + test:close() + + -- Create output lines for this mountpoint. if target and size and used then output = output .. '${voffset 2}' .. target .. ': ${alignr}${color3}' .. used .. 'B${color} of ${color3}' .. size .. 'B${color}\n' output = output .. '${voffset -2}${color5}${fs_bar ' .. target .. '}${color}\n' From fa2717e1f7dba1aea3eba83b351e5b95ade5d3a3 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Thu, 15 Jan 2026 23:47:22 -0500 Subject: [PATCH 08/10] Split the AlphaVantage stock script into 2 scripts. One to make the API calls to AlphaVantage (this runs infrequently due to rate limit of free API keys) and saves the retrieved JSON files to cache. The other script can run more frequently and scans the cache dir for updated JSON files, and returns the formatted stock data to Conky. --- conf | 4 +- stocks/get_stocks_alphavantage.py | 237 ++++++++------------------ stocks/process_stocks_alphavantage.py | 133 +++++++++++++++ 3 files changed, 209 insertions(+), 165 deletions(-) create mode 100755 stocks/process_stocks_alphavantage.py diff --git a/conf b/conf index e516297..a214e35 100644 --- a/conf +++ b/conf @@ -30,6 +30,7 @@ -- chmod +x ~/.conky/mgconky/weather/parse_weather.sh -- chmod +x ~/.conky/mgconky/weather/parse_forecast.sh -- chmod +x ~/.conky/mgconky/stocks/get_stocks_alphavantage.py +-- chmod +x ~/.conky/mgconky/stocks/process_stocks_alphavantage.py -- chmod +x ~/.conky/mgconky/stocks/get_stocks_finnhub.py -- (4) WEATHER. -- Make a free account at https://openweathermap.org/ @@ -178,7 +179,8 @@ ${voffset -6}${execpi 60 $HOME/.conky/mgconky/stocks/get_stocks_finnhub.py --api ${endif} # ***** Alpha Vantage API ***** ${if_match "${template5}" != "YOUR_ALPHAVANTAGE_API_KEY_HERE"} -${voffset -12}${execpi 43200 $HOME/.conky/mgconky/stocks/get_stocks_alphavantage.py --api_key ${template5} --symbols ${template7} --range_in_days 30 --price_dec_places 0 --percent_dec_places 1} +${execpi 43200 $HOME/.conky/mgconky/stocks/get_stocks_alphavantage.py --api_key ${template5} --symbols ${template7} --range_in_days 30} +${voffset -12}${execpi 60 $HOME/.conky/mgconky/stocks/process_stocks_alphavantage.py --symbols ${template7} --range_in_days 30 --price_dec_places 0 --percent_dec_places 1 --stale_seconds 43200} ${endif} # #-------------------------- diff --git a/stocks/get_stocks_alphavantage.py b/stocks/get_stocks_alphavantage.py index bbb8b22..fe0832b 100755 --- a/stocks/get_stocks_alphavantage.py +++ b/stocks/get_stocks_alphavantage.py @@ -3,16 +3,17 @@ import os import sys import time +import json +import argparse import requests from datetime import datetime, timedelta -import argparse -CACHE_FILE = os.path.expanduser("~/.cache/mgconky/stocks_alphavantage.txt") -API_DELAY_SECONDS = 15 -USE_CACHE = True +CACHE_DIR = os.path.expanduser("~/.cache/mgconky/stocks_alphavantage/") +API_DELAY_SECONDS = 1 +NO_THRASH_SECONDS = 60 * 60 # 1 hour + def fetch_intraday_data(api_key, symbol, interval="1min"): - """Fetch current and historical intraday price using TIME_SERIES_INTRADAY.""" url = "https://www.alphavantage.co/query" params = { "function": "TIME_SERIES_INTRADAY", @@ -26,36 +27,26 @@ def fetch_intraday_data(api_key, symbol, interval="1min"): data = response.json() time_series = data.get(f"Time Series ({interval})") - # Debug: Log the raw response - #print(f"DEBUG: Response data for {symbol}: {data}") - if time_series: - # Sort the timestamps to get the first (open) and latest price sorted_timestamps = sorted(time_series.keys()) - open_time = sorted_timestamps[0] # Earliest timestamp of the day - latest_time = sorted_timestamps[-1] # Most recent timestamp - - # Extract open price and latest price - open_price = float(time_series[open_time]["1. open"]) - latest_price = float(time_series[latest_time]["4. close"]) + open_time = sorted_timestamps[0] + latest_time = sorted_timestamps[-1] return { - "current_price": latest_price, - "compare_price": open_price + "current_price": float(time_series[latest_time]["4. close"]), + "compare_price": float(time_series[open_time]["1. open"]), } else: - print(f"Error: No 'Time Series ({interval})' data found for {symbol}.") - return None + print(f"Error: No 'Time Series ({interval})' data found for {symbol}.", file=sys.stderr) else: - print(f"Error: Failed to fetch intraday data for {symbol} (HTTP {response.status_code})") - return None + print(f"Error: Failed to fetch intraday data for {symbol} (HTTP {response.status_code})", file=sys.stderr) except requests.RequestException as e: - print(f"Error: Failed to fetch intraday data for {symbol} - {e}") - return None + print(f"Error: Failed to fetch intraday data for {symbol} - {e}", file=sys.stderr) + + return None def fetch_historical_data(api_key, symbol, range_in_days): - """Fetch current and historical price using TIME_SERIES_DAILY, allowing fallback to nearby dates.""" url = "https://www.alphavantage.co/query" params = { "function": "TIME_SERIES_DAILY", @@ -68,173 +59,91 @@ def fetch_historical_data(api_key, symbol, range_in_days): data = response.json() time_series = data.get("Time Series (Daily)") - # Debug: Log the raw response - #print(f"DEBUG: Response data for {symbol}: {data}") - if time_series: - # Current price is the latest closing price latest_date = max(time_series.keys()) - latest_data = time_series[latest_date] - current_price = float(latest_data["4. close"]) + current_price = float(time_series[latest_date]["4. close"]) - # Dates to check: exact, one day before, one day after - target_date = (datetime.now() - timedelta(days=range_in_days)).strftime("%Y-%m-%d") - fallback_dates = [ - target_date, - (datetime.now() - timedelta(days=range_in_days + 1)).strftime("%Y-%m-%d"), - (datetime.now() - timedelta(days=range_in_days - 1)).strftime("%Y-%m-%d") - ] - - # Find the first available date in the fallback list + target = datetime.now() - timedelta(days=range_in_days) historical_price = None - for date in fallback_dates: - if date in time_series: - historical_price = float(time_series[date]["4. close"]) + for offset in range(10): + d = (target - timedelta(days=offset)).strftime("%Y-%m-%d") + if d in time_series: + historical_price = float(time_series[d]["4. close"]) break return { "current_price": current_price, - "compare_price": historical_price + "compare_price": historical_price, } else: - print(f"Error: No 'Time Series (Daily)' data found for {symbol}.") - return None + print(f"Error: No 'Time Series (Daily)' data found for {symbol}.", file=sys.stderr) else: - print(f"Error: Failed to fetch historical data for {symbol} (HTTP {response.status_code})") - return None + print(f"Error: Failed to fetch historical data for {symbol} (HTTP {response.status_code})", file=sys.stderr) except requests.RequestException as e: - print(f"Error: Failed to fetch historical data for {symbol} - {e}") - return None + print(f"Error: Failed to fetch historical data for {symbol} - {e}", file=sys.stderr) + + return None def main(): - # Parse command-line arguments - parser = argparse.ArgumentParser(description="Fetch stock data from Alpha Vantage.") - parser.add_argument("--api_key", required=True, help="Your Alpha Vantage API key") - parser.add_argument("--symbols", required=True, help="Comma-separated list of stock symbols") - parser.add_argument("--range_in_days", type=int, default=0, help="Number of days for historical comparison (0 for none)") - parser.add_argument("--price_dec_places", type=int, default=0, help="Decimal places for prices") - parser.add_argument("--percent_dec_places", type=int, default=1, help="Decimal places for percentages") + parser = argparse.ArgumentParser() + parser.add_argument("--api_key", required=True) + parser.add_argument("--symbols", required=True) + parser.add_argument("--range_in_days", type=int, default=0) args = parser.parse_args() - # Split symbols symbols = args.symbols.strip().upper().split(",") + os.makedirs(CACHE_DIR, exist_ok=True) - # Use a list to build the final output string - output = [] - - # Prepare for printing output - color_header = "${color}" - color_label = "${color3}" - color_value = "${color3}" - color_good = "${color6}" - color_bad = "${color7}" - line_tab1_offset = "${goto 25}" - line_tab2_offset = "${goto 90}" - line_tab3_offset = "${alignr}" # Could also replace this with goto 120 if you don't like the right alignment - - # Iterate symbols - had_success = False for i, symbol in enumerate(symbols): - fetched_data = fetch_intraday_data(args.api_key, symbol) if args.range_in_days < 1 else fetch_historical_data(args.api_key, symbol, args.range_in_days) + cache_path = os.path.join(CACHE_DIR, f"{symbol}.json") + + # --- No-thrash protection --- + if os.path.exists(cache_path): + try: + with open(cache_path, "r") as f: + payload = json.load(f) + + if ( + isinstance(payload, dict) + and "timestamp" in payload + and isinstance(payload["timestamp"], (int, float)) + and (time.time() - payload["timestamp"]) < NO_THRASH_SECONDS + ): + # Cache is recent enough ---> skip API call + continue + except Exception: + # Any error ---> fall through and refetch + pass + + fetched_data = ( + fetch_intraday_data(args.api_key, symbol) + if args.range_in_days < 1 + else fetch_historical_data(args.api_key, symbol, args.range_in_days) + ) + if ( isinstance(fetched_data, dict) and "current_price" in fetched_data and isinstance(fetched_data["current_price"], (int, float)) ): - had_success = True - current_price = fetched_data["current_price"] - compare_price = fetched_data["compare_price"] - price_difference = current_price - compare_price - percent_change = (price_difference / current_price) * 100 - color_dynamic = ( - color_good if round(price_difference, args.price_dec_places) > 0 - else color_bad if round(price_difference, args.price_dec_places) < 0 - else color_value - ) - output.append( - f"{line_tab1_offset}{color_label}{symbol}: {line_tab2_offset}{color_value}{round(current_price, args.price_dec_places):.{args.price_dec_places}f} " - f"{line_tab3_offset}{color_dynamic}{round(price_difference, args.price_dec_places):+.{args.price_dec_places}f} " - f"({round(percent_change, args.percent_dec_places):+.{args.percent_dec_places}f}%)" - ) - else: - output.append(f"{symbol}: Error fetching data") + tmp = os.path.join(CACHE_DIR, f"{symbol}.json.tmp") + final = os.path.join(CACHE_DIR, f"{symbol}.json") + + payload = { + "symbol": symbol, + "range_in_days": args.range_in_days, + "timestamp": int(time.time()), + "data": fetched_data, + } + + with open(tmp, "w") as f: + json.dump(payload, f) + os.replace(tmp, final) - # --- Rate limit protection for Alpha Vantage --- if i < len(symbols) - 1: - time.sleep(API_DELAY_SECONDS) # wait 15 seconds between queries - - # Join all parts of the output and print it - header_label = f"Intraday" if args.range_in_days < 1 else f"{args.range_in_days} Day" - header_line = f"{line_tab1_offset}{color_header}Ticker{line_tab2_offset}Price ($$){line_tab3_offset}{header_label}{color_label}" - final_output = ( - header_line - + "\n" - + f"{line_tab1_offset}{color_header}${{voffset -5}}${{hr 1}}" - + "\n" - + "\n".join(output) - ) - - if had_success: - if USE_CACHE: - # Save results to cache file - os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) - tmp_file = CACHE_FILE + ".tmp" - with open(tmp_file, "w") as f: - f.write(final_output) - os.replace(tmp_file, CACHE_FILE) - else: - # Direct output mode (no cache) - return final_output - else: - # No valid data was returned - # Do NOT overwrite cache - # Do NOT return anything in cache mode - return None - + time.sleep(API_DELAY_SECONDS) if __name__ == "__main__": - - # The free alpha vantage plan may require you to rate limit. - # Conky will not wait for rate limited output. - # We must instead return the previous results - # (from when this script was last called) - # Set RATE_LIMIT to false if you have the paid plan. - if USE_CACHE: - - # Fork immediately so the process Conky launched can exit right away. - pid = os.fork() - if pid > 0: - # PARENT PROCESS (this is the process Conky launched) - - # Return contents of cache file (from early query to Conky) - if os.path.exists(CACHE_FILE): - try: - with open(CACHE_FILE, "r") as f: - print(f.read(), end="") - sys.stdout.flush() - except Exception: - pass - - # Parent exits immediately ---> Conky renders output - sys.exit(0) - - else: - # CHILD PROCESS (background updater, not waited on by Conky) - - # Detach from the parent session so Conky does not wait on this process - os.setsid() - - # Perform new query - main() - sys.exit(0) - - else: - - # Return the results to Conky - result = main() - if result is not None: - print(result) - - + main() diff --git a/stocks/process_stocks_alphavantage.py b/stocks/process_stocks_alphavantage.py new file mode 100755 index 0000000..f8516b4 --- /dev/null +++ b/stocks/process_stocks_alphavantage.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +import os +import sys +import time +import json +import argparse +from datetime import datetime, timedelta + +CACHE_DIR = os.path.expanduser("~/.cache/mgconky/stocks_alphavantage/") + +def main(): + # Parse command-line arguments (same shape as original, minus api_key) + parser = argparse.ArgumentParser(description="Process cached Alpha Vantage stock data.") + parser.add_argument("--symbols", required=True, help="Comma-separated list of stock symbols") + parser.add_argument("--range_in_days", type=int, default=0, help="Number of days for historical comparison") + parser.add_argument("--price_dec_places", type=int, default=0, help="Decimal places for prices") + parser.add_argument("--percent_dec_places", type=int, default=1, help="Decimal places for percentages") + parser.add_argument("--stale_seconds", type=int, default=13 * 3600, help="Seconds before cached data is considered stale") + args = parser.parse_args() + + # Split symbols (unchanged) + symbols = args.symbols.strip().upper().split(",") + + # Output builder (unchanged) + output = [] + + # Formatting (unchanged) + color_header = "${color}" + color_label = "${color3}" + color_value = "${color3}" + color_good = "${color6}" + color_bad = "${color7}" + line_tab1_offset = "${goto 25}" + line_tab2_offset = "${goto 90}" + line_tab3_offset = "${alignr}" + + # Iterate symbols (structure preserved) + for symbol in symbols: + cache_path = os.path.join(CACHE_DIR, f"{symbol}.json") + + # Missing cache file → invalid + if not os.path.exists(cache_path): + output.append( + f"{line_tab1_offset}{color_bad}{symbol}: " + f"{line_tab2_offset}{color_value}-- " + f"{line_tab3_offset}{color_value}-- (--%)" + ) + continue + + # Determine staleness from mtime + age_seconds = time.time() - os.stat(cache_path).st_mtime + symbol_color = color_bad if age_seconds > args.stale_seconds else color_label + + # Load cached JSON + try: + with open(cache_path, "r") as f: + payload = json.load(f) + if not isinstance(payload, dict) or "data" not in payload: + raise ValueError("Invalid cache payload") + fetched_data = payload["data"] + + except Exception: + output.append( + f"{line_tab1_offset}{color_bad}{symbol}: " + f"{line_tab2_offset}{color_value}-- " + f"{line_tab3_offset}{color_value}-- (--%)" + ) + continue + + # ------------------------------ + # VALIDATION + # ------------------------------ + if ( + isinstance(fetched_data, dict) + and "current_price" in fetched_data + and isinstance(fetched_data["current_price"], (int, float)) + ): + current_price = fetched_data["current_price"] + compare_price = fetched_data["compare_price"] + + # Defensive: compare_price may still be None + if not isinstance(compare_price, (int, float)): + symbol_color = color_bad + output.append( + f"{line_tab1_offset}{symbol_color}{symbol}: " + f"{line_tab2_offset}{color_value}-- " + f"{line_tab3_offset}{color_value}-- (--%)" + ) + continue + + price_difference = current_price - compare_price + percent_change = (price_difference / current_price) * 100 + + color_dynamic = ( + color_good if round(price_difference, args.price_dec_places) > 0 + else color_bad if round(price_difference, args.price_dec_places) < 0 + else color_value + ) + + output.append( + f"{line_tab1_offset}{symbol_color}{symbol}: " + f"{line_tab2_offset}{color_value}{round(current_price, args.price_dec_places):.{args.price_dec_places}f} " + f"{line_tab3_offset}{color_dynamic}{round(price_difference, args.price_dec_places):+.{args.price_dec_places}f} " + f"({round(percent_change, args.percent_dec_places):+.{args.percent_dec_places}f}%)" + ) + else: + # Invalid cached data → treat as bad/stale + output.append( + f"{line_tab1_offset}{color_bad}{symbol}: " + f"{line_tab2_offset}{color_value}-- " + f"{line_tab3_offset}{color_value}-- (--%)" + ) + + # Header (unchanged naming) + header_label = "Intraday" if args.range_in_days < 1 else f"{args.range_in_days} Day" + header_line = ( + f"{line_tab1_offset}{color_header}Ticker" + f"{line_tab2_offset}Price ($$)" + f"{line_tab3_offset}{header_label}{color_label}" + ) + + print( + header_line + + "\n" + + f"{line_tab1_offset}{color_header}${{voffset -5}}${{hr 1}}" + + "\n" + + "\n".join(output) + ) + +if __name__ == "__main__": + main() + From 08e22632bcefc951c697d4a789008d0a8c92dfd1 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Fri, 16 Jan 2026 00:21:07 -0500 Subject: [PATCH 09/10] Improved comments --- stocks/get_stocks_alphavantage.py | 36 ++++++++++++++++++++++++--- stocks/process_stocks_alphavantage.py | 35 ++++++++++++++++++-------- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/stocks/get_stocks_alphavantage.py b/stocks/get_stocks_alphavantage.py index fe0832b..1e49991 100755 --- a/stocks/get_stocks_alphavantage.py +++ b/stocks/get_stocks_alphavantage.py @@ -8,12 +8,18 @@ import argparse import requests from datetime import datetime, timedelta +# Directory where per-symbol cache JSON files are stored CACHE_DIR = os.path.expanduser("~/.cache/mgconky/stocks_alphavantage/") + +# Delay between API calls to respect AlphaVantage rate limits API_DELAY_SECONDS = 1 -NO_THRASH_SECONDS = 60 * 60 # 1 hour + +# Minimum age before re-querying a symbol to avoid API thrashing +NO_THRASH_SECONDS = 60 * 60 # 1 hour def fetch_intraday_data(api_key, symbol, interval="1min"): + # Query AlphaVantage intraday endpoint for the given symbol url = "https://www.alphavantage.co/query" params = { "function": "TIME_SERIES_INTRADAY", @@ -28,6 +34,7 @@ def fetch_intraday_data(api_key, symbol, interval="1min"): time_series = data.get(f"Time Series ({interval})") if time_series: + # Use earliest bar as open price and latest bar as current price sorted_timestamps = sorted(time_series.keys()) open_time = sorted_timestamps[0] latest_time = sorted_timestamps[-1] @@ -37,16 +44,20 @@ def fetch_intraday_data(api_key, symbol, interval="1min"): "compare_price": float(time_series[open_time]["1. open"]), } else: + # API responded but did not include expected time series data print(f"Error: No 'Time Series ({interval})' data found for {symbol}.", file=sys.stderr) else: + # HTTP error from AlphaVantage print(f"Error: Failed to fetch intraday data for {symbol} (HTTP {response.status_code})", file=sys.stderr) except requests.RequestException as e: + # Network or request failure print(f"Error: Failed to fetch intraday data for {symbol} - {e}", file=sys.stderr) return None def fetch_historical_data(api_key, symbol, range_in_days): + # Query AlphaVantage daily endpoint for historical comparison url = "https://www.alphavantage.co/query" params = { "function": "TIME_SERIES_DAILY", @@ -60,10 +71,14 @@ def fetch_historical_data(api_key, symbol, range_in_days): time_series = data.get("Time Series (Daily)") if time_series: + # Most recent trading day's close is the current price latest_date = max(time_series.keys()) current_price = float(time_series[latest_date]["4. close"]) + # Target calendar date for comparison target = datetime.now() - timedelta(days=range_in_days) + + # Walk backward up to 10 days to find a valid trading day historical_price = None for offset in range(10): d = (target - timedelta(days=offset)).strftime("%Y-%m-%d") @@ -76,29 +91,36 @@ def fetch_historical_data(api_key, symbol, range_in_days): "compare_price": historical_price, } else: + # API responded but did not include expected daily series data print(f"Error: No 'Time Series (Daily)' data found for {symbol}.", file=sys.stderr) else: + # HTTP error from AlphaVantage print(f"Error: Failed to fetch historical data for {symbol} (HTTP {response.status_code})", file=sys.stderr) except requests.RequestException as e: + # Network or request failure print(f"Error: Failed to fetch historical data for {symbol} - {e}", file=sys.stderr) return None def main(): + # Parse command-line arguments parser = argparse.ArgumentParser() parser.add_argument("--api_key", required=True) parser.add_argument("--symbols", required=True) parser.add_argument("--range_in_days", type=int, default=0) args = parser.parse_args() + # Normalize and split ticker symbols symbols = args.symbols.strip().upper().split(",") + + # Ensure cache directory exists os.makedirs(CACHE_DIR, exist_ok=True) for i, symbol in enumerate(symbols): cache_path = os.path.join(CACHE_DIR, f"{symbol}.json") - # --- No-thrash protection --- + # --- No-thrash protection: skip API call if cache is still fresh --- if os.path.exists(cache_path): try: with open(cache_path, "r") as f: @@ -110,18 +132,20 @@ def main(): and isinstance(payload["timestamp"], (int, float)) and (time.time() - payload["timestamp"]) < NO_THRASH_SECONDS ): - # Cache is recent enough ---> skip API call + # Cached data is recent enough -> skip querying AlphaVantage continue except Exception: - # Any error ---> fall through and refetch + # Any read/parse error -> ignore cache and refetch pass + # Fetch either intraday or historical data depending on range fetched_data = ( fetch_intraday_data(args.api_key, symbol) if args.range_in_days < 1 else fetch_historical_data(args.api_key, symbol, args.range_in_days) ) + # Only write cache if fetched data is structurally valid if ( isinstance(fetched_data, dict) and "current_price" in fetched_data @@ -130,6 +154,7 @@ def main(): tmp = os.path.join(CACHE_DIR, f"{symbol}.json.tmp") final = os.path.join(CACHE_DIR, f"{symbol}.json") + # Payload includes metadata for staleness and debugging payload = { "symbol": symbol, "range_in_days": args.range_in_days, @@ -137,13 +162,16 @@ def main(): "data": fetched_data, } + # Atomic write to avoid corrupting existing cache with open(tmp, "w") as f: json.dump(payload, f) os.replace(tmp, final) + # Rate-limit delay between symbols if i < len(symbols) - 1: time.sleep(API_DELAY_SECONDS) + if __name__ == "__main__": main() diff --git a/stocks/process_stocks_alphavantage.py b/stocks/process_stocks_alphavantage.py index f8516b4..547a074 100755 --- a/stocks/process_stocks_alphavantage.py +++ b/stocks/process_stocks_alphavantage.py @@ -7,10 +7,11 @@ import json import argparse from datetime import datetime, timedelta +# Directory containing cached per-symbol JSON files CACHE_DIR = os.path.expanduser("~/.cache/mgconky/stocks_alphavantage/") def main(): - # Parse command-line arguments (same shape as original, minus api_key) + # Parse command-line arguments (reader does not need api_key) parser = argparse.ArgumentParser(description="Process cached Alpha Vantage stock data.") parser.add_argument("--symbols", required=True, help="Comma-separated list of stock symbols") parser.add_argument("--range_in_days", type=int, default=0, help="Number of days for historical comparison") @@ -19,13 +20,13 @@ def main(): parser.add_argument("--stale_seconds", type=int, default=13 * 3600, help="Seconds before cached data is considered stale") args = parser.parse_args() - # Split symbols (unchanged) + # Split ticker symbols by comma symbols = args.symbols.strip().upper().split(",") - # Output builder (unchanged) + # Collect formatted output lines for Conky output = [] - # Formatting (unchanged) + # Conky color and layout formatting color_header = "${color}" color_label = "${color3}" color_value = "${color3}" @@ -35,11 +36,11 @@ def main(): line_tab2_offset = "${goto 90}" line_tab3_offset = "${alignr}" - # Iterate symbols (structure preserved) + # Process each symbol for symbol in symbols: cache_path = os.path.join(CACHE_DIR, f"{symbol}.json") - # Missing cache file → invalid + # Missing cache file -- display formatted placeholder if not os.path.exists(cache_path): output.append( f"{line_tab1_offset}{color_bad}{symbol}: " @@ -48,19 +49,25 @@ def main(): ) continue - # Determine staleness from mtime + # Determine cache age using file modification time age_seconds = time.time() - os.stat(cache_path).st_mtime + + # Symbol turns red (color_bad) if cached data is stale symbol_color = color_bad if age_seconds > args.stale_seconds else color_label - # Load cached JSON + # Load cached JSON payload try: with open(cache_path, "r") as f: payload = json.load(f) + + # Validate payload structure before use if not isinstance(payload, dict) or "data" not in payload: raise ValueError("Invalid cache payload") + fetched_data = payload["data"] except Exception: + # Corrupt or unreadable cache file -- display formatted placeholder output.append( f"{line_tab1_offset}{color_bad}{symbol}: " f"{line_tab2_offset}{color_value}-- " @@ -79,7 +86,7 @@ def main(): current_price = fetched_data["current_price"] compare_price = fetched_data["compare_price"] - # Defensive: compare_price may still be None + # Comparison price may be missing if no trading day was found if not isinstance(compare_price, (int, float)): symbol_color = color_bad output.append( @@ -89,15 +96,18 @@ def main(): ) continue + # Compute absolute and percentage change price_difference = current_price - compare_price percent_change = (price_difference / current_price) * 100 + # Color change based on price movement direction color_dynamic = ( color_good if round(price_difference, args.price_dec_places) > 0 else color_bad if round(price_difference, args.price_dec_places) < 0 else color_value ) + # The actual formatted line with valid stock data output.append( f"{line_tab1_offset}{symbol_color}{symbol}: " f"{line_tab2_offset}{color_value}{round(current_price, args.price_dec_places):.{args.price_dec_places}f} " @@ -105,21 +115,24 @@ def main(): f"({round(percent_change, args.percent_dec_places):+.{args.percent_dec_places}f}%)" ) else: - # Invalid cached data → treat as bad/stale + # Cached data missing required fields -- display formatted placeholder output.append( f"{line_tab1_offset}{color_bad}{symbol}: " f"{line_tab2_offset}{color_value}-- " f"{line_tab3_offset}{color_value}-- (--%)" ) - # Header (unchanged naming) + # Header label depends on intraday vs historical mode header_label = "Intraday" if args.range_in_days < 1 else f"{args.range_in_days} Day" + + # Formatted header line header_line = ( f"{line_tab1_offset}{color_header}Ticker" f"{line_tab2_offset}Price ($$)" f"{line_tab3_offset}{header_label}{color_label}" ) + # Print final output for Conky to render print( header_line + "\n" From 031c5ff8dd8015ab0eea020508d99eb88c706fad Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Tue, 20 Jan 2026 19:40:21 -0500 Subject: [PATCH 10/10] Improved error messages with AlphaVantage stock API calls. --- stocks/get_stocks_alphavantage.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/stocks/get_stocks_alphavantage.py b/stocks/get_stocks_alphavantage.py index 1e49991..cb255e8 100755 --- a/stocks/get_stocks_alphavantage.py +++ b/stocks/get_stocks_alphavantage.py @@ -18,6 +18,12 @@ API_DELAY_SECONDS = 1 NO_THRASH_SECONDS = 60 * 60 # 1 hour +def log_error(message): + """Print timestamped error message to stderr and flush immediately.""" + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] {message}", file=sys.stderr, flush=True) + + def fetch_intraday_data(api_key, symbol, interval="1min"): # Query AlphaVantage intraday endpoint for the given symbol url = "https://www.alphavantage.co/query" @@ -45,13 +51,13 @@ def fetch_intraday_data(api_key, symbol, interval="1min"): } else: # API responded but did not include expected time series data - print(f"Error: No 'Time Series ({interval})' data found for {symbol}.", file=sys.stderr) + log_error(f"No 'Time Series ({interval})' data found for {symbol}.") else: # HTTP error from AlphaVantage - print(f"Error: Failed to fetch intraday data for {symbol} (HTTP {response.status_code})", file=sys.stderr) + log_error(f"Failed to fetch intraday data for {symbol} (HTTP {response.status_code})") except requests.RequestException as e: # Network or request failure - print(f"Error: Failed to fetch intraday data for {symbol} - {e}", file=sys.stderr) + log_error(f"Failed to fetch intraday data for {symbol} - {e}") return None @@ -92,13 +98,14 @@ def fetch_historical_data(api_key, symbol, range_in_days): } else: # API responded but did not include expected daily series data - print(f"Error: No 'Time Series (Daily)' data found for {symbol}.", file=sys.stderr) + log_error(f"No 'Time Series (Daily)' data found for {symbol}.") else: # HTTP error from AlphaVantage - print(f"Error: Failed to fetch historical data for {symbol} (HTTP {response.status_code})", file=sys.stderr) + log_error(f"Failed to fetch historical data for {symbol} (HTTP {response.status_code})") + except requests.RequestException as e: # Network or request failure - print(f"Error: Failed to fetch historical data for {symbol} - {e}", file=sys.stderr) + log_error(f"Failed to fetch historical data for {symbol} - {e}") return None