diff --git a/.github/workflows/cache_refresh.yml b/.github/workflows/cache_refresh.yml new file mode 100644 index 0000000..a969454 --- /dev/null +++ b/.github/workflows/cache_refresh.yml @@ -0,0 +1,35 @@ +name: Refresh Analyst Cache + +on: + schedule: + - cron: '0 7 * * 1-5' # 02:00 AM EST (07:00 UTC), Mon–Fri 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 diff --git a/.gitignore b/.gitignore index 331f6cb..f591f23 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/StockScreener.spec b/StockScreener.spec index aae3669..786650e 100644 --- a/StockScreener.spec +++ b/StockScreener.spec @@ -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 ) diff --git a/activation_dialog.py b/activation_dialog.py index 7146baf..76fbd20 100644 --- a/activation_dialog.py +++ b/activation_dialog.py @@ -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("", lambda _e: self._command() if self._enabled else None) + self.bind("", lambda _e: self.itemconfig(self._rect, fill="#00c060") if self._enabled else None) + self.bind("", 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("", 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) diff --git a/build.bat b/build.bat index 9fd6e1e..c11cfe5 100644 --- a/build.bat +++ b/build.bat @@ -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 diff --git a/cache_builder.py b/cache_builder.py new file mode 100644 index 0000000..9f51ddb --- /dev/null +++ b/cache_builder.py @@ -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() diff --git a/dist/screener_gui.py b/dist/screener_gui.py new file mode 100644 index 0000000..02c1b48 --- /dev/null +++ b/dist/screener_gui.py @@ -0,0 +1,5011 @@ +""" +Ultimate Investment Tool GUI +============================ +Graphical interface for the multi-factor stock screener. + +Run: python screener_gui.py +Build exe: build.bat +""" + +import ctypes +import html as _html +import io +import multiprocessing +import queue +import re +import sys +import threading +import tkinter as tk +import webbrowser +from tkinter import ttk + +import numpy as np + +_MATPLOTLIB_OK = False +_MATPLOTLIB_ERR = "" +try: + import matplotlib + matplotlib.use("TkAgg") + import matplotlib.ticker as mticker + from matplotlib.figure import Figure + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + import matplotlib.dates as mdates + _MATPLOTLIB_OK = True +except Exception as _e: + _MATPLOTLIB_ERR = str(_e) + +_YF_OK = False +try: + import yfinance as yf + _YF_OK = True +except Exception: + pass + +from stock_screener import (INDEX_CHOICES, STRATEGY_PRESETS, WEIGHTS, + collect_tickers, fetch_all, score_stocks, + get_news_headlines, fetch_insider_trades) + +# Short descriptions shown as tooltips when hovering strategy dropdown items +STRATEGY_DESCRIPTIONS = { + "Balanced (Default)": ( + "Best for: General all-purpose screening.\n\n" + "Blends all 8 factors evenly — no strong directional bias. " + "Good starting point for building a diversified watch list." + ), + "High Growth Companies": ( + "Best for: High-velocity growth investing.\n\n" + "Heavily weights revenue/earnings growth (28%) and price momentum (25%). " + "Targets fast-growing tech, biotech, and SaaS. " + "Higher risk — low weight on value and safety metrics." + ), + "Conservative": ( + "Best for: Capital preservation and low volatility.\n\n" + "Prioritises balance sheet quality (25%) and profitability (18%). " + "Targets blue-chip companies with low debt and stable earnings." + ), + "Recent Uptrends": ( + "Best for: Short-term technical trend-following.\n\n" + "Momentum dominates at 40% — driven by 6-month price return. " + "News sentiment adds 18%. Valuation nearly irrelevant. " + "Best used in bull markets with high portfolio turnover." + ), + "Buy and Hold Long Term": ( + "Best for: Value-focused buy-and-hold investors.\n\n" + "Screens for deeply undervalued stocks by P/E, P/B, and EV/EBITDA (30%). " + "Risk factor ignored — long holding period absorbs volatility. " + "Classic fundamental investing approach." + ), + "Low Risk, High Dividend Yield": ( + "Best for: Income-focused portfolios seeking stable cash flow.\n\n" + "Profitability (28%) and quality (22%) dominate. Targets mature companies " + "converting revenue to free cash flow reliably. Suitable for retirees." + ), + "High Risk High Reward": ( + "Best for: Contrarian bounce plays on oversold names.\n\n" + "Reverse momentum (35%) rewards beaten-down stocks. Analyst upside (30%) " + "filters for institutional conviction. Quality floor (15%) avoids value traps." + ), + "Custom...": ( + "Define your own strategy.\n\n" + "Set custom weights for each of the 8 scoring factors. " + "Click to open the configuration dialog." + ), +} + +multiprocessing.freeze_support() + +# Read update channel from license cache (set during auth in launcher.py) +try: + import license_check as _lc + _CHANNEL = _lc.get_channel() +except Exception: + _CHANNEL = "stable" + +try: + from updater import APP_VERSION as _APP_VERSION +except Exception: + _APP_VERSION = "?" + +_APP_NAME = "Ultimate Investment Tool" +_channel_label = "Beta" if _CHANNEL == "beta" else "Stable" +_WINDOW_TITLE = f"{_APP_NAME} v{_APP_VERSION} — {_channel_label}" + +# Tell Windows the process handles its own DPI scaling (crisp rendering). +try: + ctypes.windll.shcore.SetProcessDpiAwareness(2) # per-monitor DPI aware +except Exception: + try: + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except Exception: + try: + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass + +# --------------------------------------------------------------------------- +# DPI scaling — computed HERE at module load via the Windows GDI API, +# BEFORE any Tk window is created. +# +# Why not winfo_fpixels()? That call requires a visible window that has +# been assigned to a monitor. When called immediately after Tk() in +# __init__, the window is still hidden/off-screen, so Windows reports the +# primary monitor DPI — which may differ from the monitor the maximised +# window actually lands on. GetDeviceCaps on the screen DC is always +# consistent and works before any window exists. +# +# _S : scale factor (actual PPI / 96). 1.0 at 100%, 1.25 at 125%, etc. +# _s(n): scale any hard-coded pixel value for the current display DPI. +# --------------------------------------------------------------------------- +_S: float = 1.0 +try: + _hdc = ctypes.windll.user32.GetDC(0) # 0 = screen DC + _dpi = ctypes.windll.gdi32.GetDeviceCaps(_hdc, 88) # LOGPIXELSX = 88 + ctypes.windll.user32.ReleaseDC(0, _hdc) + if _dpi > 0: + _S = _dpi / 96.0 +except Exception: + _S = 1.0 + +def _s(n: float) -> int: + """Return n scaled to the current display DPI (minimum 1 pixel).""" + return max(1, int(n * _S)) + +# --------------------------------------------------------------------------- +# Sector filter (display name → (dataframe column, exact value to match)) +# None means "no filter" (show all sectors). +# Defense is a sub-industry inside Industrials, so it filters on "industry". +# --------------------------------------------------------------------------- + +_SECTOR_FILTER_MAP = { + "All Sectors": None, + "Technology": ("sector", "Technology"), + "Healthcare": ("sector", "Healthcare"), + "Financials": ("sector", "Financial Services"), + "Consumer Disc.": ("sector", "Consumer Cyclical"), + "Consumer Staples": ("sector", "Consumer Defensive"), + "Industrials": ("sector", "Industrials"), + "Defense": ("industry", "Aerospace & Defense"), + "Energy": ("sector", "Energy"), + "Materials": ("sector", "Basic Materials"), + "Real Estate": ("sector", "Real Estate"), + "Communication": ("sector", "Communication Services"), + "Utilities": ("sector", "Utilities"), +} + +SECTOR_CHOICES = list(_SECTOR_FILTER_MAP.keys()) + + +# --------------------------------------------------------------------------- +# Theme +# --------------------------------------------------------------------------- + +T = { + "bg": "#000000", # pure black — main background + "bg2": "#000000", # same black — seamless panels + "bg3": "#0d0d0d", # near-black — barely raised surfaces + "border": "#0d0d0d", # near-invisible — seamless separators + "fg": "#ffffff", # white — primary text + "fg2": "#555555", # gray — secondary / dim text + "accent": "#00e676", # bright green — primary accent + "green": "#00e676", # green — positive indicators + "red": "#ff4545", # red — negative indicators + "yellow": "#ffd740", # amber — neutral indicators + "sel": "#00261a", # dark green — selection highlight + "row_odd": "#040404", # near-black row stripe + "row_even": "#000000", # pure black row stripe + "font_ui": ("Segoe UI", 11), + "font_mono": ("Consolas", 11), + "font_head": ("Segoe UI", 12, "bold"), + "font_sec": ("Segoe UI", 10, "bold"), + "font_small": ("Segoe UI", 10), +} + + +# --------------------------------------------------------------------------- +# Stdout redirector +# --------------------------------------------------------------------------- + +_PROGRESS_RE = re.compile( + r"(\d+)/(\d+).*?ok=(\d+).*?skip=(\d+).*?ETA=(\d+(?:\.\d+)?)" +) +# Matches new fetch_all Phase-1 output: " 123/456 scanned valid=89 (27%)" +_PHASE1_RE = re.compile(r"(\d+)/(\d+)\s+scanned\s+valid=(\d+)") + + +class _StdoutRedirector(io.TextIOBase): + def __init__(self, q: queue.Queue, run_id: int = 0): + self._q = q + self._run_id = run_id + + def write(self, s: str) -> int: + if not s or not s.strip(): + return len(s) if s else 0 + m = _PROGRESS_RE.search(s) + if m: + done, total, ok, skip, eta = m.groups() + self._q.put({"type": "progress", "run_id": self._run_id, + "done": int(done), "total": int(total), + "ok": int(ok), "skip": int(skip), "eta": float(eta)}) + else: + # Match Phase-1 price-history progress: "N/N scanned valid=V (X%)" + m2 = _PHASE1_RE.search(s) + if m2: + done, total, ok = m2.groups() + self._q.put({"type": "progress", "run_id": self._run_id, + "done": int(done), "total": int(total), + "ok": int(ok), "skip": 0, "eta": 0.0}) + else: + self._q.put({"type": "status", "run_id": self._run_id, + "text": s.strip()}) + return len(s) + + def flush(self): + pass + + +# --------------------------------------------------------------------------- +# Formatters +# --------------------------------------------------------------------------- + +def _nan(v): + return v is None or (isinstance(v, float) and np.isnan(v)) + +def _pct(v): + return "—" if _nan(v) else f"{v * 100:.1f}%" + +def _flt(v, d=2): + return "—" if _nan(v) else f"{v:.{d}f}" + +def _price(v): + return "—" if _nan(v) else f"${v:,.2f}" + +def _mcap(v): + if _nan(v): return "—" + if v >= 1e12: return f"${v / 1e12:.2f}T" + if v >= 1e9: return f"${v / 1e9:.2f}B" + if v >= 1e6: return f"${v / 1e6:.2f}M" + return f"${v:.0f}" + +def _score(v): + return "—" if _nan(v) else f"{v:.1f}" + +def _fmt_shares(v): + """Format a share count with commas; returns '—' for None.""" + if v is None: return "—" + return f"{int(v):,}" + +def _fmt_value(v): + """Format a dollar value compactly: $1.2M, $340K, etc.""" + if v is None: return "—" + if v >= 1e9: return f"${v / 1e9:.1f}B" + if v >= 1e6: return f"${v / 1e6:.1f}M" + if v >= 1e3: return f"${v / 1e3:.0f}K" + return f"${v:.0f}" + + +# --------------------------------------------------------------------------- +# Tooltip +# --------------------------------------------------------------------------- + +class _Tooltip: + """ + Attaches a dark-themed popup to a tk.Label. + + Strategy: + - → always show immediately (reliable trigger). + - → hide if the cursor has drifted outside the text bounding + box (padding area), so the popup disappears the moment the + cursor leaves the actual characters. + - → hide when the cursor leaves the widget entirely. + + font, padx, pady, anchor are passed explicitly at construction time so + no fragile runtime widget inspection is needed. + """ + _PAD = 8 + + def __init__(self, widget: tk.Widget, text: str, + font=None, padx: int = 0, pady: int = 0, + anchor: str = "center"): + self._w = widget + self._text = text.strip() + self._tip: tk.Toplevel | None = None + self._padx = padx + self._pady = pady + self._anchor = anchor + self._font_spec = font # kept as a tuple for tk.call font commands + widget.bind("", self._show, add="+") + widget.bind("", self._on_motion, add="+") + widget.bind("", self._hide, add="+") + widget.bind("