""" 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("