Add EDGAR bulk fetch, Supabase analyst cache, nightly GitHub Actions workflow

- Replace per-ticker yf.Ticker.info with EDGAR XBRL frames API (~50 bulk calls)
- Add nightly cache_builder.py: fetches analyst/news/sector data for all US stocks
- Add .github/workflows/cache_refresh.yml: runs cache_builder Mon-Fri at 2am EST
- Fix sector/industry: cache_builder stores sector from yfinance; screener reads it
- Fix PyInstaller: add collect_all('tzdata'), set upx=False to prevent archive corruption
- Fix EDGAR row format: use dict keys (cik/val) instead of array indices
- Fix yfinance MultiIndex: squeeze single-ticker DataFrame to Series

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nolan Kovacs 2026-03-20 13:54:23 -04:00
parent 4afd71ba86
commit 9c7588c396
12 changed files with 7807 additions and 261 deletions

35
.github/workflows/cache_refresh.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Refresh Analyst Cache
on:
schedule:
- cron: '0 7 * * 1-5' # 02:00 AM EST (07:00 UTC), MonFri only
workflow_dispatch: # allow manual trigger from the GitHub Actions UI
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 90 # analyst phase ~50 min + news phase ~6 min
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install --upgrade pip
pip install \
yfinance>=0.2.36 \
pandas>=2.0.0 \
requests>=2.28.0 \
vaderSentiment>=3.3.2
- name: Run cache builder
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
run: python cache_builder.py

BIN
.gitignore vendored

Binary file not shown.

View File

@ -3,25 +3,24 @@ from PyInstaller.utils.hooks import collect_all
datas = []
binaries = []
hiddenimports = [
'pandas', 'numpy', 'yfinance', 'requests',
'lxml', 'lxml.etree', 'html5lib', 'bs4',
'appdirs', 'platformdirs',
'tkinter', 'tkinter.ttk', 'tkinter.messagebox',
'screener_gui', 'stock_screener',
'matplotlib', 'matplotlib.backends.backend_tkagg',
'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker',
'winreg',
'hwid', 'license_check', 'activation_dialog', 'updater',
]
hiddenimports = ['pandas', 'numpy', 'yfinance', 'requests', 'lxml', 'lxml.etree', 'html5lib', 'bs4', 'appdirs', 'platformdirs', 'tkinter', 'tkinter.ttk', 'tkinter.messagebox', 'screener_gui', 'stock_screener', 'matplotlib', 'matplotlib.backends.backend_tkagg', 'matplotlib.figure', 'matplotlib.dates', 'matplotlib.ticker', 'winreg', 'ssl', '_ssl', 'certifi', 'charset_normalizer', 'hwid', 'license_check', 'activation_dialog', 'updater', 'vaderSentiment', 'vaderSentiment.vaderSentiment']
tmp_ret = collect_all('pandas')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('numpy')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('yfinance')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('matplotlib')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('pandas')
tmp_ret = collect_all('requests')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('certifi')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('charset_normalizer')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('vaderSentiment')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('tzdata')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
@ -38,10 +37,7 @@ a = Analysis(
noarchive=False,
optimize=0,
)
# Bytecode encryption key (requires tinyaes + PyInstaller 5.x)
# If your PyInstaller version does not support this, remove the key= line.
pyz = PYZ(a.pure, cipher=None) # swap cipher=None for cipher=block_cipher if using --key
pyz = PYZ(a.pure)
exe = EXE(
pyz,
@ -53,7 +49,7 @@ exe = EXE(
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
@ -62,4 +58,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
uac_admin=True, # embed UAC manifest: always run as Administrator
)

View File

@ -7,24 +7,84 @@ saves it to disk so it won't be asked again.
"""
import tkinter as tk
from tkinter import ttk
import license_check
from hwid import get_hwid
# ---- Colors matching the screener's dark theme ----
_BG = "#1e1e2e"
_PANEL = "#2a2a3e"
_ACCENT = "#4f8ef7"
_TEXT = "#e0e0f0"
_SUBTEXT = "#888899"
_ERROR = "#ff6b6b"
_SUCCESS = "#50fa7b"
_BORDER = "#3a3a5e"
_ENTRY_BG = "#12121f"
# ---- Colors — exact match to the main app theme ----
_BG = "#000000"
_ACCENT = "#00e676"
_TEXT = "#ffffff"
_SUBTEXT = "#555555"
_ERROR = "#ff4545"
_SUCCESS = "#00e676"
_BORDER = "#0d0d0d"
def run_activation() -> bool:
# ---- Shared pill-shape helper ------------------------------------------------
def _pill_pts(w, h, r=10):
return [r,0, w-r,0, w,0, w,r, w,h-r, w,h, w-r,h, r,h, 0,h, 0,h-r, 0,r, 0,0]
# ---- Pill entry (borderless green canvas entry) ------------------------------
def _make_pill_entry(parent, textvariable, width_px=380, height_px=40,
font=("Consolas", 11)):
"""Return a (frame, entry) tuple — a pill-shaped green entry."""
r = height_px // 2
frame = tk.Frame(parent, bg=_BG, bd=0, highlightthickness=0)
cv = tk.Canvas(frame, width=width_px, height=height_px,
highlightthickness=0, bd=0, bg=_BG)
cv.pack()
pts = _pill_pts(width_px, height_px, r)
cv.create_polygon(pts, smooth=True, fill=_ACCENT, outline=_ACCENT, width=0)
inner = tk.Frame(cv, bg=_ACCENT, bd=0, highlightthickness=0)
cv.create_window(width_px // 2, height_px // 2, window=inner,
width=width_px - 10, height=height_px - 8)
entry = tk.Entry(inner, textvariable=textvariable,
font=font, bg=_ACCENT, fg="#000000",
insertbackground="#000000", relief="flat",
bd=0, highlightthickness=0)
entry.pack(fill="x", expand=True, padx=8, ipady=2)
return frame, entry
# ---- Pill button (borderless canvas button) ----------------------------------
class _PillBtn(tk.Canvas):
def __init__(self, parent, text, command,
width=120, height=36, radius=10,
bg=_ACCENT, fg="#000000"):
super().__init__(parent, width=width, height=height,
highlightthickness=0, bd=0, bg=parent.cget("bg"))
self._bg = bg
self._fg = fg
self._command = command
self._enabled = True
pts = _pill_pts(width, height, radius)
self._rect = self.create_polygon(pts, smooth=True,
fill=bg, outline=bg, width=0)
self._lbl = self.create_text(width // 2, height // 2,
text=text, fill=fg,
font=("Segoe UI", 10, "bold"))
self.config(cursor="hand2")
self.bind("<Button-1>", lambda _e: self._command() if self._enabled else None)
self.bind("<Enter>", lambda _e: self.itemconfig(self._rect, fill="#00c060") if self._enabled else None)
self.bind("<Leave>", lambda _e: self.itemconfig(self._rect, fill=self._bg))
def set_enabled(self, enabled: bool):
self._enabled = enabled
self.itemconfig(self._rect, fill=self._bg if enabled else _BORDER)
self.itemconfig(self._lbl, fill=self._fg if enabled else _SUBTEXT)
self.config(cursor="hand2" if enabled else "")
def set_text(self, text: str):
self.itemconfig(self._lbl, text=text)
# ---- Main dialog ------------------------------------------------------------
def run_activation(hwid: str) -> bool:
"""
Open the activation dialog and block until the user either activates
successfully or closes the window.
@ -34,7 +94,7 @@ def run_activation() -> bool:
activated = [False]
root = tk.Tk()
root.title("Ultimate Investment Tool — Activation")
root.title("Ultimate Investment Tool — License Activation")
root.configure(bg=_BG)
root.resizable(False, False)
@ -45,108 +105,78 @@ def run_activation() -> bool:
y = (root.winfo_screenheight() - h) // 2
root.geometry(f"{w}x{h}+{x}+{y}")
# Intercept close button — same as clicking Exit
root.protocol("WM_DELETE_WINDOW", lambda: _on_exit(root, activated))
# ---- Header ----
header = tk.Frame(root, bg=_ACCENT, height=4)
header.pack(fill="x")
# ---- Header accent bar ----
tk.Frame(root, bg=_ACCENT, height=4).pack(fill="x")
tk.Label(
root, text="Ultimate Investment Tool",
font=("Segoe UI", 16, "bold"),
bg=_BG, fg=_TEXT
).pack(pady=(28, 4))
tk.Label(root, text="Ultimate Investment Tool",
font=("Segoe UI", 16, "bold"),
bg=_BG, fg=_TEXT).pack(pady=(28, 4))
tk.Label(
root, text="Enter your license key to activate this software.",
font=("Segoe UI", 9),
bg=_BG, fg=_SUBTEXT
).pack()
# ---- Key entry ----
entry_frame = tk.Frame(root, bg=_PANEL, bd=0, highlightthickness=1,
highlightbackground=_BORDER)
entry_frame.pack(padx=40, pady=24, fill="x")
tk.Label(root, text="Enter your license key to activate this software.",
font=("Segoe UI", 9),
bg=_BG, fg=_SUBTEXT).pack()
# ---- Pill key entry ----
key_var = tk.StringVar()
key_entry = tk.Entry(
entry_frame, textvariable=key_var,
font=("Consolas", 11), bg=_ENTRY_BG, fg=_TEXT,
insertbackground=_TEXT, relief="flat",
bd=10, width=36
)
key_entry.pack(fill="x")
ef, key_entry = _make_pill_entry(root, key_var, width_px=400, height_px=42)
ef.pack(pady=22)
key_entry.focus()
# ---- Status label ----
status_var = tk.StringVar(value="")
status_label = tk.Label(
root, textvariable=status_var,
font=("Segoe UI", 9),
bg=_BG, fg=_ERROR, wraplength=400
)
status_label = tk.Label(root, textvariable=status_var,
font=("Segoe UI", 9),
bg=_BG, fg=_ERROR, wraplength=420)
status_label.pack(pady=(0, 8))
# ---- Buttons ----
# ---- Pill buttons ----
btn_frame = tk.Frame(root, bg=_BG)
btn_frame.pack(pady=4)
activate_btn = tk.Button(
btn_frame, text="Activate",
font=("Segoe UI", 10, "bold"),
bg=_ACCENT, fg="white",
activebackground="#3a7ae0", activeforeground="white",
relief="flat", cursor="hand2", padx=24, pady=8,
command=lambda: _on_activate(root, key_var, status_var, status_label,
activate_btn, activated)
)
activate_btn.grid(row=0, column=0, padx=8)
activate_btn = _PillBtn(btn_frame, "Activate",
command=lambda: _on_activate(
root, key_var, status_var, status_label,
activate_btn, activated, hwid),
width=130, height=38, bg=_ACCENT, fg="#000000")
activate_btn.grid(row=0, column=0, padx=10)
exit_btn = tk.Button(
btn_frame, text="Exit",
font=("Segoe UI", 10),
bg=_PANEL, fg=_SUBTEXT,
activebackground=_BORDER, activeforeground=_TEXT,
relief="flat", cursor="hand2", padx=24, pady=8,
command=lambda: _on_exit(root, activated)
)
exit_btn.grid(row=0, column=1, padx=8)
exit_btn = _PillBtn(btn_frame, "Exit",
command=lambda: _on_exit(root, activated),
width=110, height=38, bg=_BORDER, fg=_SUBTEXT)
exit_btn.grid(row=0, column=1, padx=10)
# Allow Enter key to trigger activation
root.bind("<Return>", lambda e: _on_activate(
root, key_var, status_var, status_label, activate_btn, activated
))
root, key_var, status_var, status_label, activate_btn, activated, hwid))
# ---- Footer ----
tk.Label(
root, text="Need a license? Contact us to subscribe.",
font=("Segoe UI", 8),
bg=_BG, fg=_SUBTEXT
).pack(side="bottom", pady=14)
tk.Label(root, text="Need a license? Contact us to subscribe.",
font=("Segoe UI", 8),
bg=_BG, fg=_SUBTEXT).pack(side="bottom", pady=14)
root.mainloop()
return activated[0]
def _on_activate(root, key_var, status_var, status_label, activate_btn, activated):
def _on_activate(root, key_var, status_var, status_label,
activate_btn, activated, hwid):
key = key_var.get().strip()
if not key:
status_var.set("Please enter a license key.")
return
# Show checking state
activate_btn.config(state="disabled", text="Checking…")
activate_btn.set_enabled(False)
activate_btn.set_text("Checking…")
status_var.set("Contacting license server…")
status_label.config(fg=_SUBTEXT)
root.update()
try:
hwid = get_hwid()
valid, reason = license_check.verify_license(key, hwid)
except Exception as exc:
activate_btn.config(state="normal", text="Activate")
activate_btn.set_enabled(True)
activate_btn.set_text("Activate")
status_var.set(f"Error: {exc}")
status_label.config(fg=_ERROR)
return
@ -158,7 +188,8 @@ def _on_activate(root, key_var, status_var, status_label, activate_btn, activate
activated[0] = True
root.after(900, root.destroy)
else:
activate_btn.config(state="normal", text="Activate")
activate_btn.set_enabled(True)
activate_btn.set_text("Activate")
status_var.set(reason)
status_label.config(fg=_ERROR)

View File

@ -6,7 +6,7 @@ echo ======================================
echo.
echo Installing dependencies...
python -m pip install pyinstaller tinyaes matplotlib --quiet
python -m pip install pyinstaller matplotlib --quiet
if errorlevel 1 (
echo ERROR: pip failed. Make sure Python is installed and on PATH.
pause
@ -21,48 +21,12 @@ copy /Y dist\stock_screener.py stock_screener.py >nul
echo Building executable...
echo.
python -m PyInstaller ^
--onefile ^
--windowed ^
--key "UIT-K3y-2025-Ultr4Inv3st" ^
--name "StockScreener" ^
--hidden-import pandas ^
--hidden-import numpy ^
--hidden-import yfinance ^
--hidden-import requests ^
--hidden-import lxml ^
--hidden-import lxml.etree ^
--hidden-import html5lib ^
--hidden-import bs4 ^
--hidden-import appdirs ^
--hidden-import platformdirs ^
--hidden-import tkinter ^
--hidden-import tkinter.ttk ^
--hidden-import tkinter.messagebox ^
--hidden-import screener_gui ^
--hidden-import stock_screener ^
--hidden-import matplotlib ^
--hidden-import matplotlib.backends.backend_tkagg ^
--hidden-import matplotlib.figure ^
--hidden-import matplotlib.dates ^
--hidden-import matplotlib.ticker ^
--hidden-import winreg ^
--hidden-import hwid ^
--hidden-import license_check ^
--hidden-import activation_dialog ^
--hidden-import updater ^
--collect-all yfinance ^
--collect-all matplotlib ^
launcher.py
python -m PyInstaller StockScreener.spec
if errorlevel 1 (
echo.
echo BUILD FAILED. See errors above.
echo.
echo NOTE: If the error mentions '--key' or 'tinyaes', your PyInstaller version
echo may not support bytecode encryption. Remove the '--key' line above
echo and rebuild. Encryption requires PyInstaller 5.x + tinyaes.
echo.
echo Cleaning up temporary files...
if exist screener_gui.py del screener_gui.py
if exist stock_screener.py del stock_screener.py

320
cache_builder.py Normal file
View File

@ -0,0 +1,320 @@
"""
Analyst & News Cache Builder
============================
Runs nightly via GitHub Actions. Fetches analyst consensus data and
news sentiment for every US-listed stock and upserts results into the
Supabase analyst_cache table.
Phase 1 Analyst data via yf.Ticker.info (~50 min for 6,500 tickers)
Phase 2 News sentiment via Google News RSS (~6 min, 20 workers)
Each phase writes to Supabase incrementally every UPSERT_EVERY records
so a partial run is never fully lost.
"""
import html
import os
import random
import re
import time
import urllib.parse
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
import requests
import yfinance as yf
try:
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
_VADER = SentimentIntensityAnalyzer()
except ImportError:
_VADER = None
# ---------------------------------------------------------------------------
# Supabase credentials — injected as GitHub Actions secrets
# ---------------------------------------------------------------------------
_SUPABASE_URL = os.environ["SUPABASE_URL"]
_SUPABASE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
_SUPABASE_HEADERS = {
"apikey": _SUPABASE_KEY,
"Authorization": f"Bearer {_SUPABASE_KEY}",
"Content-Type": "application/json",
"Prefer": "resolution=merge-duplicates",
}
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_TICKER_RE = re.compile(r'^[A-Z]{1,5}(-[A-Z]{1,2})?$')
_HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
INFO_BATCH = 20
INFO_PAUSE = 4.0
MAX_WORKERS = 5
NEWS_WORKERS = 20
UPSERT_EVERY = 500
# ---------------------------------------------------------------------------
# Ticker fetching (NASDAQ Trader files — same source as the screener)
# ---------------------------------------------------------------------------
def _clean_ticker(symbol: str) -> str | None:
t = symbol.strip().upper().replace(".", "-")
return t if _TICKER_RE.match(t) else None
def fetch_tickers() -> list[str]:
"""Return all US common-stock tickers from NASDAQ Trader listing files."""
tickers = []
base = "https://www.nasdaqtrader.com/dynamic/SymDir/"
# NASDAQ-listed stocks
try:
resp = requests.get(base + "nasdaqlisted.txt", headers=_HTTP_HEADERS, timeout=20)
resp.raise_for_status()
for line in resp.text.splitlines()[1:]:
if line.startswith("File Creation"):
break
parts = line.split("|")
if len(parts) < 7:
continue
if parts[3].strip() == "Y" or parts[6].strip() == "Y": # test/ETF
continue
t = _clean_ticker(parts[0].strip())
if t:
tickers.append(t)
print(f" NASDAQ listed : {sum(1 for _ in tickers)} tickers")
except Exception as e:
print(f" [WARN] nasdaqlisted.txt: {e}")
# NYSE / AMEX listed stocks
try:
resp = requests.get(base + "otherlisted.txt", headers=_HTTP_HEADERS, timeout=20)
resp.raise_for_status()
count = 0
for line in resp.text.splitlines()[1:]:
if line.startswith("File Creation"):
break
parts = line.split("|")
if len(parts) < 7:
continue
if parts[2].strip() not in {"N", "A"}: # NYSE=N, AMEX=A only
continue
if parts[4].strip() == "Y" or parts[6].strip() == "Y": # ETF/test
continue
t = _clean_ticker(parts[0].strip())
if t:
tickers.append(t)
count += 1
print(f" NYSE/AMEX : {count} tickers")
except Exception as e:
print(f" [WARN] otherlisted.txt: {e}")
combined = list(dict.fromkeys(tickers))
print(f" Total : {len(combined)} unique tickers\n")
return combined
# ---------------------------------------------------------------------------
# Phase 1 — Analyst data
# ---------------------------------------------------------------------------
def _fetch_analyst(ticker: str) -> dict | None:
"""Fetch analyst consensus fields for one ticker via yf.Ticker.info."""
time.sleep(random.uniform(0.0, 1.0))
try:
info = yf.Ticker(ticker).info
if not info or not isinstance(info, dict) or len(info) < 3:
return None
try:
rec_mean = float(info["recommendationMean"]) if info.get("recommendationMean") is not None else None
except (TypeError, ValueError):
rec_mean = None
analyst_norm = (5.0 - rec_mean) / 4.0 if rec_mean is not None and 1 <= rec_mean <= 5 else None
try:
target = float(info["targetMeanPrice"]) if info.get("targetMeanPrice") is not None else None
except (TypeError, ValueError):
target = None
price = info.get("currentPrice") or info.get("regularMarketPrice")
try:
price = float(price) if price is not None else None
except (TypeError, ValueError):
price = None
analyst_upside = (target / price - 1.0) if target and price and price > 0 else None
return {
"ticker": ticker,
"pe_forward": info.get("forwardPE"),
"eps_forward": info.get("forwardEps"),
"analyst_norm": analyst_norm,
"analyst_upside": analyst_upside,
"analyst_count": info.get("numberOfAnalystOpinions"),
"analyst_target": target,
"recommendation": info.get("recommendationKey") or "N/A",
"sector": info.get("sector") or None,
"industry": info.get("industry") or None,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
except Exception:
return None
def run_analyst_phase(tickers: list[str]) -> None:
print(f"Phase 1: Analyst data for {len(tickers)} tickers ...")
total = len(tickers)
done = 0
pending: list[dict] = []
start = time.time()
for batch_start in range(0, total, INFO_BATCH):
batch = tickers[batch_start: batch_start + INFO_BATCH]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:
futures = {ex.submit(_fetch_analyst, t): t for t in batch}
for fut in as_completed(futures):
done += 1
result = fut.result()
if result:
pending.append(result)
elapsed = time.time() - start
eta = (elapsed / done) * (total - done) if done else 0
print(
f"\r {done}/{total} ({done / total * 100:.0f}%)"
f" cached={len(pending)} ETA={eta:.0f}s ",
end="", flush=True,
)
if len(pending) >= UPSERT_EVERY:
print()
_upsert(pending)
pending.clear()
if batch_start + INFO_BATCH < total:
time.sleep(INFO_PAUSE)
if pending:
print()
_upsert(pending)
print(f"\n Phase 1 complete in {time.time() - start:.0f}s\n")
# ---------------------------------------------------------------------------
# Phase 2 — News sentiment (Google News RSS + VADER)
# ---------------------------------------------------------------------------
def _fetch_news_sentiment(ticker: str) -> dict | None:
"""Fetch Google News RSS headlines and compute VADER compound score."""
if _VADER is None:
return None
try:
query = urllib.parse.quote(f"{ticker} stock")
rss_url = (
f"https://news.google.com/rss/search"
f"?q={query}&hl=en-US&gl=US&ceid=US:en"
)
resp = requests.get(rss_url, headers=_HTTP_HEADERS, timeout=8)
resp.raise_for_status()
root = ET.fromstring(resp.content)
scores = []
for item in root.findall(".//item")[:15]:
title_el = item.find("title")
if title_el is not None and title_el.text:
title = html.unescape(
re.sub(r"<[^>]+>", " ", title_el.text)
).strip()
scores.append(_VADER.polarity_scores(title)["compound"])
sentiment = sum(scores) / len(scores) if scores else None
return {
"ticker": ticker,
"news_sentiment": sentiment,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
except Exception:
return None
def run_news_phase(tickers: list[str]) -> None:
if _VADER is None:
print("Phase 2: Skipped — vaderSentiment not installed\n")
return
print(f"Phase 2: News sentiment for {len(tickers)} tickers ...")
total = len(tickers)
done = 0
pending: list[dict] = []
start = time.time()
with ThreadPoolExecutor(max_workers=NEWS_WORKERS) as ex:
futures = {ex.submit(_fetch_news_sentiment, t): t for t in tickers}
for fut in as_completed(futures):
done += 1
result = fut.result()
if result:
pending.append(result)
if done % 200 == 0:
elapsed = time.time() - start
eta = (elapsed / done) * (total - done) if done else 0
print(
f"\r {done}/{total} ({done / total * 100:.0f}%)"
f" ETA={eta:.0f}s ",
end="", flush=True,
)
if len(pending) >= UPSERT_EVERY:
_upsert(pending)
pending.clear()
if pending:
_upsert(pending)
print(f"\n Phase 2 complete in {time.time() - start:.0f}s\n")
# ---------------------------------------------------------------------------
# Supabase upsert
# ---------------------------------------------------------------------------
def _upsert(rows: list[dict]) -> None:
"""Upsert a batch of rows into analyst_cache (merge on primary key)."""
if not rows:
return
try:
resp = requests.post(
f"{_SUPABASE_URL}/rest/v1/analyst_cache",
headers=_SUPABASE_HEADERS,
json=rows,
timeout=30,
)
resp.raise_for_status()
print(f" Upserted {len(rows)} rows to Supabase")
except Exception as e:
print(f" [WARN] Upsert failed: {e}")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
print("=" * 55)
print(" ANALYST + NEWS CACHE BUILDER")
print("=" * 55 + "\n")
tickers = fetch_tickers()
if not tickers:
print("ERROR: No tickers fetched — aborting.")
raise SystemExit(1)
run_analyst_phase(tickers)
run_news_phase(tickers)
print("Cache build complete.")
if __name__ == "__main__":
main()

5011
dist/screener_gui.py vendored Normal file

File diff suppressed because it is too large Load Diff

2086
dist/stock_screener.py vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,11 +8,13 @@ Generates a stable hardware fingerprint for this machine using:
Returns a SHA-256 hex digest. Never changes unless hardware changes.
"""
import functools
import hashlib
import subprocess
import winreg
@functools.lru_cache(maxsize=None)
def get_hwid() -> str:
"""Return a stable SHA-256 HWID string for the current machine."""
components = []

View File

@ -6,57 +6,26 @@ Startup sequence:
2. Load stored license key show activation dialog if none found
3. Verify license against Supabase (with 30-min offline grace period)
4. Silently check for and apply updates
5. Load and run screener_gui.py
Source file hygiene
-------------------
On every launch, the launcher scans the parent folder of the exe for stray
copies of screener_gui.py or stock_screener.py and removes them (pulling in
any newer version first so no edits are lost).
5. Launch the bundled screener_gui
"""
import importlib.util
import os
import shutil
import sys
import tkinter as tk
from tkinter import messagebox
# Fix SSL certificate path for PyInstaller frozen builds.
# Without this, requests raises "Could not find a suitable TLS CA certificate bundle".
if getattr(sys, "frozen", False):
import certifi
os.environ["REQUESTS_CA_BUNDLE"] = certifi.where()
os.environ["SSL_CERT_FILE"] = certifi.where()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _exe_dir() -> str:
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
_SOURCE_FILES = ("screener_gui.py", "stock_screener.py")
def _sync_source_files(exe_dir: str) -> None:
parent_dir = os.path.dirname(exe_dir)
if parent_dir == exe_dir:
return
for fname in _SOURCE_FILES:
stray = os.path.join(parent_dir, fname)
if not os.path.exists(stray):
continue
live = os.path.join(exe_dir, fname)
if not os.path.exists(live) or os.path.getmtime(stray) > os.path.getmtime(live):
try:
shutil.copy2(stray, live)
except OSError:
pass
try:
os.remove(stray)
except OSError:
pass
def _fatal(title: str, message: str) -> None:
"""Show an error dialog and exit."""
root = tk.Tk()
@ -71,9 +40,6 @@ def _fatal(title: str, message: str) -> None:
# ---------------------------------------------------------------------------
def main():
exe_dir = _exe_dir()
_sync_source_files(exe_dir)
# ------------------------------------------------------------------
# Step 1: HWID
# ------------------------------------------------------------------
@ -82,7 +48,7 @@ def main():
hwid = get_hwid()
except Exception as exc:
_fatal("Ultimate Investment Tool", f"Could not generate hardware ID:\n\n{exc}")
return # unreachable, but satisfies linters
return
# ------------------------------------------------------------------
# Step 2: Load or activate license
@ -92,12 +58,10 @@ def main():
license_key = license_check.load_license_key(hwid)
if license_key is None:
# First launch — show activation dialog
import activation_dialog
activated = activation_dialog.run_activation()
activated = activation_dialog.run_activation(hwid)
if not activated:
sys.exit(0) # user closed the window
# Reload after successful save
sys.exit(0)
license_key = license_check.load_license_key(hwid)
if license_key is None:
_fatal("Ultimate Investment Tool", "Activation failed. Please restart and try again.")
@ -109,7 +73,6 @@ def main():
valid, reason = license_check.verify_license(license_key, hwid)
if not valid:
# License invalid — clear stored key so they can re-enter on next launch
license_check.delete_license_key()
_fatal(
"Ultimate Investment Tool — License Error",
@ -123,33 +86,20 @@ def main():
try:
import updater
updater.check_and_apply_update()
# If an update was applied, updater restarts the process — we never reach here.
except Exception:
pass # never block the app over a failed update check
pass
# ------------------------------------------------------------------
# Step 5: Load screener_gui.py
# Step 5: Launch bundled app
# ------------------------------------------------------------------
script = os.path.join(exe_dir, "screener_gui.py")
if os.path.exists(script):
if exe_dir not in sys.path:
sys.path.insert(0, exe_dir)
spec = importlib.util.spec_from_file_location("screener_gui_live", script)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod.main()
return
# Fallback: frozen version bundled inside the exe
try:
import screener_gui
screener_gui.main()
except Exception as exc:
except Exception:
import traceback
_fatal(
"Ultimate Investment Tool",
f"Could not start.\n\n{exc}\n\n"
"Make sure screener_gui.py is in the same folder as this exe."
"Ultimate Investment Tool — Startup Error",
f"The application failed to start:\n\n{traceback.format_exc()}"
)

View File

@ -4,7 +4,7 @@ License Check
Validates the stored license key against Supabase on every launch.
Falls back to a local cache if the server is unreachable (30-minute grace period).
Storage location: %APPDATA%\StockScreener\
Storage location: %APPDATA%\\StockScreener\\
license.dat XOR-encrypted license key (machine-bound)
auth_cache.dat last successful auth result + timestamp
"""
@ -101,13 +101,21 @@ def delete_license_key() -> None:
# Auth cache
# ---------------------------------------------------------------------------
def _save_cache(valid: bool, reason: str) -> None:
def _save_cache(valid: bool, reason: str, channel: str = "stable") -> None:
_ensure_dir()
payload = {"valid": valid, "reason": reason, "timestamp": time.time()}
payload = {"valid": valid, "reason": reason, "channel": channel, "timestamp": time.time()}
with open(_CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f)
def get_channel() -> str:
"""Return the update channel ('stable' or 'beta') from the last auth cache."""
cache = _load_cache()
if cache:
return cache.get("channel", "stable")
return "stable"
def _load_cache() -> dict | None:
if not os.path.exists(_CACHE_FILE):
return None
@ -142,20 +150,28 @@ def verify_license(license_key: str, hwid: str) -> tuple[bool, str]:
timeout=10,
)
resp.raise_for_status()
data = resp.json()
valid = bool(data.get("valid", False))
reason = data.get("reason", "Unknown error")
_save_cache(valid, reason)
data = resp.json()
valid = bool(data.get("valid", False))
reason = data.get("reason", "Unknown error")
channel = data.get("channel", "stable")
_save_cache(valid, reason, channel)
return valid, reason
except requests.RequestException:
except requests.exceptions.SSLError as e:
return False, f"SSL error contacting license server: {e}"
except requests.exceptions.ConnectionError as e:
cache = _load_cache()
if cache and cache.get("valid"):
age = time.time() - cache.get("timestamp", 0)
if age < _GRACE_PERIOD_SECONDS:
return True, "offline_grace"
return (
False,
"Cannot reach the license server. "
"Please check your internet connection and try again."
)
return False, f"Cannot connect to license server. Check your internet connection.\n\n({e})"
except requests.exceptions.HTTPError as e:
return False, f"License server error: {e}"
except requests.RequestException as e:
cache = _load_cache()
if cache and cache.get("valid"):
age = time.time() - cache.get("timestamp", 0)
if age < _GRACE_PERIOD_SECONDS:
return True, "offline_grace"
return False, f"License check failed: {e}"

View File

@ -1,27 +1,38 @@
"""
Auto-Updater
============
Checks Supabase for a newer version of screener_gui.py and stock_screener.py.
If found, silently downloads and replaces the files in the exe directory,
then restarts the application.
On every launch (after a successful license check), checks Supabase for a
newer version. If found:
- Downloads new screener_gui.py and stock_screener.py silently
- If a new exe is also available, self-replaces via a helper batch script
and restarts. The user sees nothing the app just comes back up updated.
Called after a successful license check on every launch.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import shutil
import requests
# ---------------------------------------------------------------------------
# Current app version — bump this in lockstep with new Supabase version rows
# Current app version — kept in sync by push_update.py before each build.
# ---------------------------------------------------------------------------
APP_VERSION = "1.0.0"
APP_VERSION = "1.3.1"
# ---------------------------------------------------------------------------
# Supabase config (same anon key as license_check.py)
# GitHub token — fine-grained PAT with Contents:read on the release repo.
# Required to download assets from a private GitHub repository.
# Generate at: GitHub → Settings → Developer settings → Fine-grained tokens
# ---------------------------------------------------------------------------
_GITHUB_TOKEN = "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"
_GITHUB_REPO = "nolankovacs/ultimate-investment-tool"
# ---------------------------------------------------------------------------
# Supabase config
# ---------------------------------------------------------------------------
_SUPABASE_URL = "https://yeispcpmepjelfbhfkwr.supabase.co"
_SUPABASE_ANON = (
@ -36,9 +47,6 @@ _HEADERS = {
"Content-Type": "application/json",
}
# Files that can be updated without rebuilding the exe
_UPDATABLE_FILES = ("screener_gui.py", "stock_screener.py")
def _exe_dir() -> str:
if getattr(sys, "frozen", False):
@ -47,7 +55,6 @@ def _exe_dir() -> str:
def _parse_version(v: str) -> tuple:
"""Convert '1.2.3' → (1, 2, 3) for comparison."""
try:
return tuple(int(x) for x in v.strip().split("."))
except Exception:
@ -55,11 +62,18 @@ def _parse_version(v: str) -> tuple:
def _download_file(url: str, dest_path: str) -> None:
"""Download url → dest_path, using a temp file so the write is atomic."""
"""Download url → dest_path atomically via a temp file."""
dl_headers = {}
if _GITHUB_TOKEN and "github.com" in url:
dl_headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
# GitHub API asset endpoint requires this header to stream the binary
if "api.github.com" in url and "/releases/assets/" in url:
dl_headers["Accept"] = "application/octet-stream"
tmp_dir = tempfile.mkdtemp()
tmp_path = os.path.join(tmp_dir, os.path.basename(dest_path))
try:
with requests.get(url, stream=True, timeout=30) as r:
with requests.get(url, stream=True, timeout=60, headers=dl_headers) as r:
r.raise_for_status()
with open(tmp_path, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
@ -69,53 +83,173 @@ def _download_file(url: str, dest_path: str) -> None:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _restart() -> None:
"""Restart the current process cleanly."""
if getattr(sys, "frozen", False):
os.execv(sys.executable, [sys.executable] + sys.argv[1:])
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
def _self_replace_exe(exe_url: str) -> None:
"""
Download the new exe to %TEMP%, then launch a helper batch script that
waits for this process to exit, moves the file into place, and restarts
the app. Nothing is left in the application directory. Never returns.
"""
if not getattr(sys, "frozen", False):
return # only meaningful in a frozen exe
current_exe = os.path.abspath(sys.executable)
tmp_dir = tempfile.gettempdir()
pending_exe = os.path.join(tmp_dir, "StockScreener_pending.exe")
# Download to %TEMP% — nothing lands next to the running exe
try:
_download_file(exe_url, pending_exe)
except Exception as exc:
_log_update_error(f"_download_file failed for {exe_url}: {exc}")
return
# Batch script in %TEMP% — deletes itself and the pending file when done
helper = os.path.join(tmp_dir, "uit_update.bat")
with open(helper, "w") as f:
f.write(
f"@echo off\n"
f":retry\n"
f"timeout /t 1 /nobreak >nul\n"
f"move /Y \"{pending_exe}\" \"{current_exe}\"\n"
f"if errorlevel 1 goto retry\n"
f"start \"\" \"{current_exe}\"\n"
f"del \"%~f0\"\n"
)
# Launch the helper detached, then exit so the running exe is unlocked
subprocess.Popen(
["cmd", "/c", helper],
creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
close_fds=True,
)
sys.exit(0)
def get_release_notes() -> tuple:
"""
Returns (version, release_notes) for the current channel from Supabase.
Falls back gracefully on any error.
"""
try:
import license_check
channel = license_check.get_channel()
resp = requests.post(
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
headers=_HEADERS,
json={"p_channel": channel},
timeout=8,
)
resp.raise_for_status()
data = resp.json()
version = data.get("version", APP_VERSION)
notes = data.get("release_notes") or "No release notes available."
return version, notes
except Exception:
return APP_VERSION, "Unable to fetch release notes."
def _log_update_error(msg: str) -> None:
"""Upload update events/errors to Supabase update_logs table."""
# Remote upload only — no local file created
try:
hwid = "unknown"
channel = "unknown"
try:
from hwid import get_hwid
hwid = get_hwid()
except Exception:
pass
try:
import license_check
channel = license_check.get_channel()
except Exception:
pass
requests.post(
f"{_SUPABASE_URL}/rest/v1/update_logs",
headers={
**_HEADERS,
"Content-Type": "application/json",
"Prefer": "return=minimal",
},
json={
"hwid": hwid,
"app_version": APP_VERSION,
"channel": channel,
"message": msg,
},
timeout=5,
)
except Exception:
pass # never let logging break the app
def _get_github_exe_url(version: str, channel: str) -> str | None:
"""
Query the GitHub Releases API to find the StockScreener.exe asset URL
for the given version and channel. Used as a fallback when Supabase
doesn't return exe_url.
"""
try:
tag = f"v{version}-{channel}" if channel != "stable" else f"v{version}"
resp = requests.get(
f"https://api.github.com/repos/{_GITHUB_REPO}/releases/tags/{tag}",
headers={
"Authorization": f"Bearer {_GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
},
timeout=10,
)
resp.raise_for_status()
for asset in resp.json().get("assets", []):
if asset["name"] == "StockScreener.exe":
return asset["url"] # API asset URL — requires octet-stream header
_log_update_error(f"GitHub release {tag} found but no StockScreener.exe asset")
return None
except Exception as exc:
_log_update_error(f"_get_github_exe_url({version}, {channel}) failed: {exc}")
return None
def check_and_apply_update() -> None:
"""
Silently check for updates and apply them if available.
Errors are swallowed a failed update check never blocks the app.
Silently check for updates and apply them. Errors are swallowed so a
failed update check never blocks the app from launching.
"""
try:
import license_check
channel = license_check.get_channel()
# Use the RPC — it runs with elevated DB permissions (SECURITY DEFINER)
# so the anon key can read the table through it even with RLS enabled.
resp = requests.post(
f"{_SUPABASE_URL}/rest/v1/rpc/get_latest_version",
headers=_HEADERS,
json={},
json={"p_channel": channel},
timeout=8,
)
resp.raise_for_status()
data = resp.json()
latest = data.get("version", APP_VERSION)
gui_url = data.get("gui_py_url")
scr_url = data.get("screener_py_url")
latest = data.get("version", APP_VERSION)
if _parse_version(latest) <= _parse_version(APP_VERSION):
return # already up to date
exe_dir = _exe_dir()
updated = False
# Always resolve the download URL via the GitHub API — direct
# github.com/releases/download URLs return 404 for private repos
# when accessed with a token outside of a browser session.
exe_url = _get_github_exe_url(latest, channel)
if not exe_url:
_log_update_error(
f"Update available ({APP_VERSION}{latest}) but could not "
f"locate the asset on GitHub for channel={channel}"
)
return
if gui_url:
dest = os.path.join(exe_dir, "screener_gui.py")
_download_file(gui_url, dest)
updated = True
# Self-replace exe if a new one is available (exits this process)
_self_replace_exe(exe_url)
if scr_url:
dest = os.path.join(exe_dir, "stock_screener.py")
_download_file(scr_url, dest)
updated = True
if updated:
# Restart so the fresh .py files are loaded
_restart()
except Exception:
# Never crash the app over a failed update check
pass
except Exception as exc:
_log_update_error(f"check_and_apply_update failed: {exc}")
# never crash the app over a failed update check