""" Ultimate Investment Tool GUI — PySide6 rewrite Part 1: imports, constants, formatters, tooltips, field defs, signals, redirector """ import ctypes, html as _html, io, math, multiprocessing, re, sys, threading, webbrowser from datetime import datetime import numpy as np from PySide6.QtCore import * from PySide6.QtWidgets import * from PySide6.QtGui import * _MATPLOTLIB_OK = False _mpl_err = "" try: import matplotlib; matplotlib.use("QtAgg") import matplotlib.ticker as mticker from matplotlib.figure import Figure from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg import matplotlib.dates as mdates _MATPLOTLIB_OK = True except Exception as _e: _mpl_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, _load_sector_stats, get_news_headlines, fetch_insider_trades, fetch_hedge_fund_filings) multiprocessing.freeze_support() 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" _C = { "bg": "#0a0a0a", "bg2": "#111111", "bg3": "#181818", "border": "#222222", "fg": "#ffffff", "fg2": "#888888", "fg3": "#3a3a3a", "positive": "#4ade80", "negative": "#f87171", "warning": "#fbbf24", } _QSS = """ QMainWindow, QWidget {{ background: {bg}; color: {fg}; font-family: 'Segoe UI'; font-size: 13px; }} QSplitter::handle {{ background: {border}; width: 1px; }} QScrollBar:vertical {{ background: {bg}; width: 6px; border: none; }} QScrollBar::handle:vertical {{ background: {border}; border-radius: 3px; min-height: 20px; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} QScrollBar:horizontal {{ background: {bg}; height: 6px; border: none; }} QScrollBar::handle:horizontal {{ background: {border}; border-radius: 3px; }} QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} QTableView, QTableWidget {{ background: {bg}; gridline-color: {border}; border: none; outline: none; selection-background-color: {bg2}; selection-color: {fg}; }} QTableView::item, QTableWidget::item {{ padding: 0 8px; border: none; }} QHeaderView::section {{ background: {bg2}; color: {fg2}; padding: 6px 8px; border: none; border-bottom: 1px solid {border}; font-size: 11px; font-weight: 600; }} QTabWidget::pane {{ border: none; background: {bg}; }} QTabBar::tab {{ background: {bg}; color: {fg2}; padding: 8px 18px; font-size: 12px; border: none; border-bottom: 2px solid transparent; }} QTabBar::tab:selected {{ color: {fg}; border-bottom: 2px solid {fg}; }} QTabBar::tab:hover {{ color: {fg2}; background: {bg3}; }} QLineEdit {{ background: {bg3}; color: {fg}; border: 1px solid {border}; border-radius: 6px; padding: 5px 10px; font-size: 12px; }} QLineEdit:focus {{ border-color: {fg2}; }} QComboBox {{ background: {bg3}; color: {fg}; border: 1px solid {border}; border-radius: 6px; padding: 5px 10px; font-size: 12px; min-width: 80px; }} QComboBox::drop-down {{ border: none; width: 20px; }} QComboBox QAbstractItemView {{ background: {bg2}; color: {fg}; selection-background-color: {bg3}; border: 1px solid {border}; }} QPushButton {{ background: {bg3}; color: {fg2}; border: 1px solid {border}; border-radius: 7px; padding: 6px 14px; font-size: 12px; font-weight: 600; }} QPushButton:hover {{ background: rgba(255,255,255,0.06); color: {fg}; }} QPushButton#run-btn {{ background: {fg}; color: #000000; border-color: {fg}; font-weight: 700; }} QPushButton#run-btn:hover {{ background: #e0e0e0; }} QPushButton:disabled {{ color: {fg3}; background: {bg3}; border-color: {border}; }} QProgressBar {{ background: {bg3}; border: none; border-radius: 0; max-height: 3px; }} QProgressBar::chunk {{ background: {fg}; border-radius: 0; }} QTextEdit {{ background: {bg3}; color: {fg}; border: none; font-family: 'Segoe UI'; font-size: 12px; padding: 4px; }} QToolTip {{ background: #1c1c1c; color: {fg}; border: 1px solid #333333; padding: 8px 12px; font-size: 11px; }} """.format(**_C) # --------------------------------------------------------------------------- # Formatters # --------------------------------------------------------------------------- def _nan(v): return v is None or (isinstance(v, float) and math.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 _score(v): return "—" if _nan(v) else f"{v:.1f}" 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 _fmt_shares(v): return "—" if v is None else f"{int(v):,}" def _fmt_value(v): 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}" # --------------------------------------------------------------------------- # Strategy descriptions # --------------------------------------------------------------------------- STRATEGY_DESCRIPTIONS = { "Balanced (Default)": ( "Best for: General all-purpose screening.\n\n" "Blends all 8 factors evenly — no strong directional bias." ), "High Growth Companies": ( "Best for: High-velocity growth investing.\n\n" "Heavily weights revenue/earnings growth (28%) and price momentum (25%)." ), "Conservative": ( "Best for: Capital preservation and low volatility.\n\n" "Prioritises balance sheet quality (25%) and profitability (18%)." ), "Recent Uptrends": ( "Best for: Short-term technical trend-following.\n\n" "Momentum dominates at 40% — driven by 6-month price return." ), "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%)." ), "Low Risk, High Dividend Yield": ( "Best for: Income-focused portfolios seeking stable cash flow.\n\n" "Profitability (28%) and quality (22%) dominate." ), "High Risk High Reward": ( "Best for: Contrarian bounce plays on oversold names.\n\n" "Reverse momentum (35%) rewards beaten-down stocks." ), "Custom...": ( "Define your own strategy.\n\n" "Set custom weights for each of the 8 scoring factors." ), } # --------------------------------------------------------------------------- # Sector filter map # --------------------------------------------------------------------------- _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()) # --------------------------------------------------------------------------- # Metric tooltips # --------------------------------------------------------------------------- _METRIC_TOOLTIPS: dict = { "Composite Score": ( "The overall investment attractiveness of the stock, scored 0-100.\n\n" "Calculated as a weighted blend of all 8 factor scores." ), "Value": "How cheaply the stock is priced relative to earnings, assets, and cash flow — scored 0-100.", "Growth": "How fast the company is expanding its business — scored 0-100.", "Momentum": "How strong the stock's recent price trend has been — scored 0-100.", "Quality": "How financially sound and efficiently run the company is — scored 0-100.", "Profitability": "How much of its revenue the company actually keeps as profit — scored 0-100.", "Sentiment": "The overall tone of recent news coverage about the company — scored 0-100.", "Analyst": "How bullish professional analysts are on the stock — scored 0-100.", "Risk": "How low-risk the stock appears — scored 0-100, where higher = safer.", "Price": "The most recent closing market price of the stock in USD.", "Market Cap": "The total market value of the company — share price multiplied by shares outstanding.", "P/E Forward": "How much investors are paying per dollar of the company's expected future earnings.", "P/E Trailing": "How much investors are paying per dollar of actual earnings over the past 12 months.", "P/B Ratio": "How much investors are paying relative to the company's net asset value.", "EV / EBITDA": "Enterprise Value divided by operating earnings before non-cash charges.", "52W High": "The highest price the stock reached over the past 52 weeks.", "52W Low": "The lowest price the stock reached over the past 52 weeks.", "Book Value": "Per-share net worth: total assets minus total liabilities divided by shares.", "Revenue Growth": "How much total sales grew compared to the same period a year ago.", "Earnings Growth": "How much net profit grew compared to a year ago.", "EPS Forward": "Analyst consensus estimate for earnings per share over the next 12 months.", "EPS Trailing": "Actual earnings per share reported over the past 12 months.", "1-Month Return": "Stock's total price return over approximately the past month.", "3-Month Return": "Stock's total price return over approximately the past three months.", "6-Month Return": "Stock's total price return over approximately the past six months.", "12-Month Return": "Stock's total price return over approximately the past year.", "ROE": "Return on Equity — profit generated per dollar of shareholders' equity.", "ROA": "Return on Assets — profit generated per dollar of total assets.", "Revenue": "Total revenue earned by the company over the trailing twelve months.", "Net Income": "The company's bottom-line profit over the trailing twelve months.", "Operating Income": "Profit from core business operations before interest and taxes.", "Total Debt": "Total amount the company owes to creditors.", "Total Equity": "Total book value belonging to shareholders.", "Total Cash": "Total cash and short-term liquid investments held by the company.", "Current Ratio": "Current assets divided by current liabilities — measures short-term liquidity.", "Profit Margin": "Percentage of revenue kept as net profit after all expenses.", "Operating Margin": "Percentage of revenue remaining as profit from core operations.", "FCF Yield": "Free cash flow generated relative to market value, as a percentage.", "Dividend Yield": "Annual dividend per share as a percentage of the current stock price.", "Recommendation": "Aggregated buy/sell opinion of all professional analysts currently covering the stock.", "# Analysts": "Number of professional analysts actively covering this stock.", "Price Target": "Average 12-month price target set by all analysts currently covering the stock.", "Upside to Target": "Percentage gain implied by the analyst consensus price target.", "News Sentiment": "Summary score of recent news coverage, from -1 (very negative) to +1 (very positive).", "Short Interest": "Percentage of the stock's freely tradable shares currently sold short.", "Beta": "How much the stock tends to move relative to the broader market.", "30D Volatility": "Day-to-day price fluctuation over the past 30 trading days, annualised.", } # --------------------------------------------------------------------------- # Field definitions # --------------------------------------------------------------------------- _SCORE_FIELDS = [ ("Composite Score", "composite_score"), ("Value", "score_value"), ("Growth", "score_growth"), ("Momentum", "score_momentum"), ("Quality", "score_quality"), ("Profitability", "score_profitability"), ("Sentiment", "score_sentiment"), ("Analyst", "score_analyst"), ("Risk", "score_risk"), ] _VAL_FIELDS = [ ("Price", "price"), ("Market Cap", "market_cap"), ("P/E Forward", "pe_forward"), ("P/E Trailing", "pe_trailing"), ("P/B Ratio", "pb_ratio"), ("Book Value", "book_value"), ("EV / EBITDA", "ev_ebitda"), ("52W High", "52w_high"), ("52W Low", "52w_low"), ] _GROWTH_FIELDS = [ ("Revenue Growth", "revenue_growth"), ("Earnings Growth", "earnings_growth"), ("EPS Forward", "eps_forward"), ("EPS Trailing", "eps_trailing"), ] _MOM_FIELDS = [ ("1-Month Return", "ret_1m"), ("3-Month Return", "ret_3m"), ("6-Month Return", "ret_6m"), ("12-Month Return", "ret_12m"), ] _QUAL_FIELDS = [ ("Revenue", "_revenue_fmt"), ("Net Income", "_net_income_fmt"), ("Operating Income", "_op_income_fmt"), ("ROE", "roe"), ("ROA", "roa"), ("Total Debt", "total_debt"), ("Total Equity", "_equity_fmt"), ("Total Cash", "total_cash"), ("Current Ratio", "current_ratio"), ("Profit Margin", "_profit_margin_fmt"), ("Operating Margin", "_op_margin_fmt"), ("FCF Yield", "fcf_yield"), ("Dividend Yield", "dividend_yield"), ] _ANLST_FIELDS = [ ("Recommendation", "recommendation"), ("# Analysts", "analyst_count"), ("Price Target", "analyst_target"), ("Upside to Target", "analyst_upside"), ("News Sentiment", "news_sentiment"), ("Short Interest", "short_percent"), ("Beta", "beta"), ("30D Volatility", "volatility_30d"), ] # --------------------------------------------------------------------------- # Progress regex # --------------------------------------------------------------------------- _PROGRESS_RE = re.compile(r"(\d+)/(\d+).*?ok=(\d+).*?skip=(\d+).*?ETA=(\d+(?:\.\d+)?)") _PHASE1_RE = re.compile(r"(\d+)/(\d+)\s+scanned\s+valid=(\d+)") # --------------------------------------------------------------------------- # Worker signals # --------------------------------------------------------------------------- class _WorkerSignals(QObject): progress = Signal(int, int, int, int, float) status = Signal(str) done = Signal(int) error = Signal(str) stopped = Signal() # --------------------------------------------------------------------------- # Stdout redirector # --------------------------------------------------------------------------- class _StdoutRedirector(io.TextIOBase): def __init__(self, signals: _WorkerSignals): self._signals = signals 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._signals.progress.emit(int(done), int(total), int(ok), int(skip), float(eta)) else: m2 = _PHASE1_RE.search(s) if m2: done, total, ok = m2.groups() self._signals.progress.emit(int(done), int(total), int(ok), 0, 0.0) else: self._signals.status.emit(s.strip()) return len(s) def flush(self): pass """Part 2: ScoreBar, Sidebar, StockTableModel, StockTable, MetricCell, MetricsGrid""" # --------------------------------------------------------------------------- # ScoreBar # --------------------------------------------------------------------------- class ScoreBar(QWidget): _value_changed = Signal(float) def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(220, 18) self._val = 0.0 self._anim = None def get_value(self): return self._val def set_value(self, v): self._val = float(v) self.update() value = Property(float, get_value, set_value) def paintEvent(self, e): from PySide6.QtGui import QPainter, QColor, QPainterPath p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) w, h = self.width(), self.height() r = h / 2 # Track track = QPainterPath() track.addRoundedRect(0, 0, w, h, r, r) p.fillPath(track, QColor("#222222")) # Fill if self._val > 0: fill_w = max(h, int(w * self._val / 100.0)) fill_w = min(fill_w, w) fill = QPainterPath() fill.addRoundedRect(0, 0, fill_w, h, r, r) p.fillPath(fill, QColor("#ffffff")) p.end() def animate_to(self, v): if self._anim: self._anim.stop() self._anim = QPropertyAnimation(self, b"value", self) self._anim.setDuration(500) self._anim.setStartValue(0.0) self._anim.setEndValue(float(max(0, min(100, v)))) self._anim.setEasingCurve(QEasingCurve.OutCubic) self._anim.start() def reset(self): if self._anim: self._anim.stop() self.set_value(0.0) # --------------------------------------------------------------------------- # Sidebar # --------------------------------------------------------------------------- class Sidebar(QWidget): page_changed = Signal(int) _NAV = [("⊞", "Screener"), ("⌕", "Search"), ("↗", "Tracking"), ("⚙", "Settings")] def __init__(self, parent=None): super().__init__(parent) self.setFixedWidth(72) self._active = 0 self._btns = [] self._build() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) # Logo logo = QLabel("V") logo.setFixedSize(36, 36) logo.setAlignment(Qt.AlignCenter) logo.setStyleSheet( "background:#ffffff; color:#000000; border-radius:8px;" "font-size:18px; font-weight:700;" ) logo_wrap = QWidget() logo_wrap.setFixedHeight(64) lw_lay = QHBoxLayout(logo_wrap) lw_lay.setContentsMargins(0, 0, 0, 0) lw_lay.addStretch() lw_lay.addWidget(logo) lw_lay.addStretch() lay.addWidget(logo_wrap) for i, (icon, label) in enumerate(self._NAV): btn = _SidebarButton(icon, label, i, self) btn.clicked_idx.connect(self._on_nav) self._btns.append(btn) lay.addWidget(btn) lay.addStretch() self.setStyleSheet(f"background:#111111; border-right:1px solid #222222;") self._btns[0].set_active(True) def _on_nav(self, idx): if idx == 3: # Settings — no page return for i, btn in enumerate(self._btns): btn.set_active(i == idx) self._active = idx self.page_changed.emit(idx) class _SidebarButton(QWidget): clicked_idx = Signal(int) def __init__(self, icon, label, idx, parent=None): super().__init__(parent) self._idx = idx self._active = False self.setFixedSize(72, 64) self.setCursor(Qt.PointingHandCursor) self.setAttribute(Qt.WA_StyledBackground, True) self.setStyleSheet("background:#111111;") # Left accent bar — absolutely positioned, shown when active self._bar = QFrame(self) self._bar.setFixedSize(3, 64) self._bar.move(0, 0) self._bar.setStyleSheet("background:#ffffff; border:none;") self._bar.hide() lay = QVBoxLayout(self) lay.setContentsMargins(0, 10, 0, 10) lay.setSpacing(4) lay.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) self._icon_lbl = QLabel(icon) self._icon_lbl.setAlignment(Qt.AlignCenter) self._icon_lbl.setFixedHeight(22) self._icon_lbl.setAttribute(Qt.WA_TransparentForMouseEvents) self._text_lbl = QLabel(label) self._text_lbl.setAlignment(Qt.AlignCenter) self._text_lbl.setFixedHeight(14) self._text_lbl.setAttribute(Qt.WA_TransparentForMouseEvents) lay.addWidget(self._icon_lbl) lay.addWidget(self._text_lbl) self._set_colors(False) def _set_colors(self, active): c = "#ffffff" if active else "#3a3a3a" self._icon_lbl.setStyleSheet( f"background:transparent; font-size:17px; color:{c};") self._text_lbl.setStyleSheet( f"background:transparent; font-size:9px; font-weight:500; color:{c};") def set_active(self, active): self._active = active self._set_colors(active) if active: self._bar.show() self._bar.raise_() else: self._bar.hide() def mousePressEvent(self, e): self.clicked_idx.emit(self._idx) # --------------------------------------------------------------------------- # Stock Table # --------------------------------------------------------------------------- class StockTableModel(QAbstractTableModel): _HEADERS = ["#", "Ticker", "Name", "Score", "Sector"] def __init__(self, parent=None): super().__init__(parent) self._rows = [] def set_data(self, rows): self.beginResetModel() self._rows = rows self.endResetModel() def rowCount(self, parent=QModelIndex()): return len(self._rows) def columnCount(self, parent=QModelIndex()): return 5 def headerData(self, section, orientation, role=Qt.DisplayRole): if orientation == Qt.Horizontal and role == Qt.DisplayRole: return self._HEADERS[section] return None def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None row = self._rows[index.row()] col = index.column() if role == Qt.DisplayRole: if col == 0: return str(row[0]) if col == 1: return str(row[1]) if col == 2: return str(row[2])[:30] if col == 3: return str(row[3]) if col == 4: return str(row[4])[:22] if role == Qt.TextAlignmentRole: if col in (0, 1, 3): return Qt.AlignCenter | Qt.AlignVCenter return Qt.AlignLeft | Qt.AlignVCenter if role == Qt.BackgroundRole: c = "#181818" if index.row() % 2 else "#0a0a0a" return QColor(c) if role == Qt.ForegroundRole: return QColor("#ffffff") return None def get_row_data(self, row_idx): if 0 <= row_idx < len(self._rows): return self._rows[row_idx] return None class StockTable(QTableView): stock_selected = Signal(dict) def __init__(self, parent=None): super().__init__(parent) self._model = StockTableModel() self.setModel(self._model) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.horizontalHeader().setHighlightSections(False) self.verticalHeader().setVisible(False) self.setShowGrid(False) self.setSortingEnabled(False) self.setAlternatingRowColors(False) self.verticalHeader().setDefaultSectionSize(30) hh = self.horizontalHeader() hh.setSectionResizeMode(0, QHeaderView.Fixed) hh.setSectionResizeMode(1, QHeaderView.Fixed) hh.setSectionResizeMode(2, QHeaderView.Stretch) hh.setSectionResizeMode(3, QHeaderView.Fixed) hh.setSectionResizeMode(4, QHeaderView.Fixed) self.setColumnWidth(0, 40) self.setColumnWidth(1, 70) self.setColumnWidth(3, 60) self.setColumnWidth(4, 130) self._df_ref = None self.clicked.connect(self._on_click) def populate(self, df): self._df_ref = df rows = [] for i, (_, row) in enumerate(df.iterrows()): rows.append(( i + 1, row.get("ticker", ""), str(row.get("name", "")), _score(row.get("composite_score")), str(row.get("sector", "")), )) self._model.set_data(rows) def _on_click(self, index): if self._df_ref is None: return row_idx = index.row() row_data = self._model.get_row_data(row_idx) if row_data is None: return ticker = row_data[1] match = self._df_ref[self._df_ref["ticker"] == ticker] if not match.empty: self.stock_selected.emit(match.iloc[0].to_dict()) # --------------------------------------------------------------------------- # MetricCell # --------------------------------------------------------------------------- class MetricCell(QFrame): def __init__(self, label, key, tooltip_text="", parent=None): super().__init__(parent) self._key = key self.setStyleSheet( f"QFrame {{ background:{_C['bg2']}; border-radius:6px; border:1px solid {_C['border']}; }}" ) lay = QVBoxLayout(self) lay.setContentsMargins(12, 10, 12, 10) lay.setSpacing(4) lbl = QLabel(label.upper()) lbl.setStyleSheet(f"color:{_C['fg3']}; font-size:10px; font-weight:600; border:none;") lay.addWidget(lbl) self.value_lbl = QLabel("—") self.value_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:15px; font-weight:700; border:none;") lay.addWidget(self.value_lbl) if tooltip_text: self.setToolTip(tooltip_text) def set_value(self, text, color=None): self.value_lbl.setText(text) c = color if color else _C["fg"] self.value_lbl.setStyleSheet(f"color:{c}; font-size:15px; font-weight:700; border:none;") class MetricsGrid(QWidget): def __init__(self, fields, cols=2, parent=None): super().__init__(parent) self.cells = {} lay = QGridLayout(self) lay.setContentsMargins(8, 8, 8, 8) lay.setSpacing(6) for i, (label, key) in enumerate(fields): tip = _METRIC_TOOLTIPS.get(label, "") cell = MetricCell(label, key, tip) self.cells[key] = cell lay.addWidget(cell, i // cols, i % cols) """Part 3: ScoreTab, CommentaryWidget (with full commentary logic), ChartWidget""" import html as _html import re import threading import webbrowser # --------------------------------------------------------------------------- # ScoreTab # --------------------------------------------------------------------------- class ScoreTab(QWidget): def __init__(self, parent=None): super().__init__(parent) self.bars = {} self.value_labels = {} scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.NoFrame) inner = QWidget() lay = QVBoxLayout(inner) lay.setContentsMargins(12, 12, 12, 12) lay.setSpacing(0) for label, key in _SCORE_FIELDS: row_w = QWidget() row_w.setFixedHeight(40) rl = QHBoxLayout(row_w) rl.setContentsMargins(4, 0, 4, 0) rl.setSpacing(8) tip = _METRIC_TOOLTIPS.get(label, "") lbl = QLabel(label) lbl.setFixedWidth(140) lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;") if tip: lbl.setToolTip(tip) rl.addWidget(lbl) bar = ScoreBar() self.bars[key] = bar rl.addWidget(bar) val_lbl = QLabel("—") val_lbl.setFixedWidth(55) val_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:12px; font-weight:600;") val_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.value_labels[key] = val_lbl rl.addWidget(val_lbl) rl.addStretch() lay.addWidget(row_w) sep = QFrame() sep.setFixedHeight(1) sep.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep) lay.addStretch() scroll.setWidget(inner) outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.addWidget(scroll) def update_scores(self, row): for label, key in _SCORE_FIELDS: v = row.get(key) bar = self.bars[key] val_lbl = self.value_labels[key] if _nan(v): bar.reset() val_lbl.setText("—") else: bar.animate_to(float(v)) val_lbl.setText(f"{float(v):.1f}") # --------------------------------------------------------------------------- # Commentary builder (full logic from original _update_commentary) # --------------------------------------------------------------------------- def _build_commentary_html(row): parts = [] def h(text, color=None): c = color or _C["fg"] parts.append(f'{text}') def hl(text, color=None): h(text, color) parts.append("
") def heading(text): hr = f'
' parts.append(f'{hr}{text}
') ticker = row.get("ticker", "?") name = str(row.get("name", ticker)) sector = str(row.get("sector", "N/A")) score = row.get("composite_score") def _one_para(text, max_chars=480): text = (text or "").strip() if not text: return "" para = text.split("\n")[0].strip() if len(para) <= max_chars: return para snippet = para[:max_chars] last_dot = snippet.rfind(". ") return snippet[:last_dot + 1] if last_dot > max_chars // 3 else snippet.rstrip() + "…" desc = _one_para(str(row.get("business_summary", "") or "")) heading("SUMMARY") if not _nan(score): if score >= 72: tier, tier_c = "Strong Buy candidate", _C["positive"] elif score >= 62: tier, tier_c = "Buy / above-average opportunity", _C["positive"] elif score >= 52: tier, tier_c = "Neutral — Hold / Watch", _C["warning"] elif score >= 42: tier, tier_c = "Underperform — caution advised", _C["negative"] else: tier, tier_c = "Avoid — significant headwinds", _C["negative"] parts.append(f'
{name} ({ticker})
') parts.append(f'Sector: {sector}   Composite Score: {score:.1f} / 100
') parts.append(f'Verdict: {tier}

') else: parts.append(f'
{name} ({ticker}) — {sector}
') hl("Insufficient data for full evaluation.", _C["warning"]) parts.append("
") if desc: parts.append(f'{_html.escape(desc)}

') heading("INVESTMENT THESIS") pe = row.get("pe_forward") or row.get("pe_trailing") rev_g = row.get("revenue_growth") earn_g = row.get("earnings_growth") roe = row.get("roe") pm = row.get("profit_margin") upside = row.get("analyst_upside") rec = str(row.get("recommendation", "")) ns = row.get("news_sentiment") r3 = row.get("ret_3m") r6 = row.get("ret_6m") sv = row.get("score_value", 50) sg = row.get("score_growth", 50) sm = row.get("score_momentum", 50) sq = row.get("score_quality", 50) sp = row.get("score_profitability", 50) ss = row.get("score_sentiment", 50) sa = row.get("score_analyst", 50) sr = row.get("score_risk", 50) strengths = [] if not _nan(sv) and sv >= 62: pe_str = f" (P/E: {pe:.1f}x)" if not _nan(pe) and pe > 0 else "" strengths.append(f"Valuation: Trading at an attractive multiple relative to peers{pe_str}.") if not _nan(sg) and sg >= 62: g_parts = [] if not _nan(rev_g) and rev_g > 0.05: g_parts.append(f"revenue +{rev_g*100:.1f}%") if not _nan(earn_g) and earn_g > 0.05: g_parts.append(f"earnings +{earn_g*100:.1f}%") g_str = " (" + ", ".join(g_parts) + ")" if g_parts else "" strengths.append(f"Growth: Strong fundamental growth trajectory{g_str}.") if not _nan(sm) and sm >= 62: m_parts = [] if not _nan(r3): m_parts.append(f"3M: {r3*100:+.1f}%") if not _nan(r6): m_parts.append(f"6M: {r6*100:+.1f}%") m_str = " (" + ", ".join(m_parts) + ")" if m_parts else "" strengths.append(f"Momentum: Positive price trend relative to universe{m_str}.") if not _nan(sq) and sq >= 62: r_str = f" ROE: {roe*100:.1f}%." if not _nan(roe) else "" strengths.append(f"Quality: Strong balance sheet and capital efficiency.{r_str}") if not _nan(sp) and sp >= 62: p_str = f" Net margin: {pm*100:.1f}%." if not _nan(pm) else "" strengths.append(f"Profitability: High-quality earnings with strong margins.{p_str}") if not _nan(ss) and ss >= 62: s_str = f" (score: {ns:+.3f})" if not _nan(ns) else "" strengths.append(f"Sentiment: Recent news coverage is broadly positive{s_str}.") if not _nan(sa) and sa >= 62: a_parts = [] if rec and rec not in ("nan", "N/A"): a_parts.append(rec.replace("_", " ").title()) if not _nan(upside) and upside > 0: a_parts.append(f"{upside*100:.1f}% upside to consensus target") a_str = " — " + ", ".join(a_parts) if a_parts else "" strengths.append(f"Analyst Consensus: Bullish institutional view{a_str}.") if not _nan(sr) and sr >= 62: strengths.append("Risk Profile: Low short interest and stable volatility — favourable risk/reward.") parts.append("
") if strengths: for txt in strengths: parts.append(f' ✔ {_html.escape(txt)}
') else: parts.append(f' No strong positive catalysts identified at current levels.
') parts.append("
") heading("RISK FACTORS") short_pct = row.get("short_percent") beta = row.get("beta") vol = row.get("volatility_30d") debt_eq = row.get("debt_to_equity") risks = [] if not _nan(sv) and sv <= 38: pe_str = f" (P/E: {pe:.1f}x)" if not _nan(pe) and pe > 0 else "" risks.append(f"Valuation Risk: Elevated multiples relative to peers{pe_str}. Leaves little margin for error.") if not _nan(sg) and sg <= 38: risks.append("Growth Risk: Weak or decelerating growth may disappoint investors.") if not _nan(sm) and sm <= 38: m_parts = [] if not _nan(r3): m_parts.append(f"3M: {r3*100:+.1f}%") if not _nan(r6): m_parts.append(f"6M: {r6*100:+.1f}%") m_str = " (" + ", ".join(m_parts) + ")" if m_parts else "" risks.append(f"Momentum Risk: Negative price trend{m_str}. Downtrend may continue.") if not _nan(sq) and sq <= 38: risks.append("Balance Sheet Risk: High leverage or weak liquidity could amplify downside.") if not _nan(ss) and ss <= 38: s_str = f" (score: {ns:+.3f})" if not _nan(ns) else "" risks.append(f"Sentiment Risk: Negative news flow may weigh on near-term price action{s_str}.") if not _nan(sa) and sa <= 38: r_str = rec.replace("_", " ").title() if rec and rec not in ("nan", "N/A") else "" u_str = f" | {upside*100:.1f}% downside to target" if not _nan(upside) and upside < 0 else "" risks.append(f"Analyst Risk: Bearish institutional view. {r_str}{u_str}") if not _nan(short_pct) and short_pct > 0.08: lvl = "Very high" if short_pct > 0.20 else "Elevated" risks.append(f"Short Interest: {lvl} at {short_pct*100:.1f}% of float — signals institutional bearish conviction.") if not _nan(beta): if beta > 1.8: risks.append(f"Volatility Risk: High beta ({beta:.2f}x) significantly amplifies both market upside and downside moves.") elif beta < 0: risks.append(f"Unusual Beta: Negative beta ({beta:.2f}) — moves inversely to the market.") if not _nan(vol) and vol > 0.40: risks.append(f"Volatility Risk: 30-day realised volatility of {vol*100:.1f}% (annualised) is significantly above average.") if not _nan(debt_eq) and debt_eq > 200: risks.append(f"Leverage Risk: Debt/equity ratio of {debt_eq:.0f}% indicates heavy debt load.") parts.append("
") if risks: for txt in risks: parts.append(f' ✘ {_html.escape(txt)}
') else: parts.append(f' No major risk flags identified at this time.
') parts.append("
") heading("ADDITIONAL CONTEXT") div = row.get("dividend_yield") curr = row.get("current_ratio") fcf = row.get("fcf_yield") price = row.get("price") hi52 = row.get("52w_high") lo52 = row.get("52w_low") parts.append("
") if not _nan(div) and div > 0: parts.append(f' • Dividend yield of {div*100:.2f}% provides income support.
') if not _nan(curr) and curr >= 2.0: parts.append(f' • Current ratio of {curr:.1f}x indicates strong short-term liquidity.
') elif not _nan(curr) and curr < 1.0: parts.append(f' • Current ratio of {curr:.1f}x raises near-term liquidity concerns.
') if not _nan(fcf) and fcf > 0.03: parts.append(f' • FCF yield of {fcf*100:.1f}% suggests meaningful free cash flow generation.
') if not _nan(price) and not _nan(hi52) and not _nan(lo52) and hi52 > lo52: pct_from_high = (price - hi52) / hi52 pct_from_low = (price - lo52) / lo52 parts.append(f' • Current price is {abs(pct_from_high)*100:.1f}% below 52-week high and {pct_from_low*100:.1f}% above 52-week low.
') if not _nan(beta) and 0.7 <= beta <= 1.3: parts.append(f' • Beta of {beta:.2f} closely tracks the broader market — suitable for balanced portfolios.
') parts.append("
") # News headlines headlines = row.get("news_headlines") or [] if headlines: heading("NEWS HIGHLIGHTS") parts.append("
") def _classify(title): t = title.lower() if any(w in t for w in ["earn","profit","revenue","eps","beat","miss","quarter","guidance","forecast","sales","income","margin"]): return "earnings" if any(w in t for w in ["acqui","merger","deal","buyout","takeover","bid","acquire"]): return "ma" if any(w in t for w in ["partner","contract","agreement","launch","expand","open","win"]): return "growth" if any(w in t for w in ["upgrade","downgrade","target","initiat","rating","analyst","overweight","underweight"]): return "analyst" if any(w in t for w in ["lawsuit","investig","sec","fine","penalty","fraud","violation","probe","regulat","recall"]): return "legal" if any(w in t for w in ["layoff","restructur","closure","shut down","dismissal","job cut"]): return "restructuring" if any(w in t for w in ["ceo","cfo","coo","chief","executive","appoint","resign","depart","hire","leadership"]): return "leadership" if any(w in t for w in ["dividend","buyback","repurchas","split","special pay"]): return "shareholder" if any(w in t for w in ["patent","innovat","product","drug","trial","fda","approv","launch","technolog"," ai "]): return "product" return "general" _IMPL = { ("earnings",True): "Strong financials can support multiple expansion and analyst upgrades.", ("earnings",False): "Weak results or missed guidance may trigger near-term selling pressure.", ("ma",True): "M&A activity can unlock value — watch for deal certainty and synergy capture.", ("ma",False): "Deal uncertainty or high premiums may weigh on near-term returns.", ("growth",True): "Expansion signals may support long-term revenue growth and market share gains.", ("growth",False): "Setbacks in growth plans could delay targets and disappoint investors.", ("analyst",True): "Analyst upgrades and raised price targets often attract institutional buying.", ("analyst",False): "Downgrades or reduced targets may reduce institutional demand.", ("legal",True): "Positive legal resolution removes an investor overhang.", ("legal",False): "Regulatory or legal headwinds can create lasting uncertainty and valuation pressure.", ("restructuring",True): "Cost reduction efforts can improve margins and free cash flow over time.", ("restructuring",False): "Restructuring may signal operational challenges or weakening demand.", ("leadership",True): "New leadership can bring strategic clarity and renewed investor confidence.", ("leadership",False): "Executive departures may signal internal issues or strategic uncertainty.", ("shareholder",True): "Capital returns signal management confidence and can attract income investors.", ("shareholder",False): "Dividend cuts or suspension may indicate cash flow stress.", ("product",True): "New products or approvals can open significant revenue opportunities.", ("product",False): "Product setbacks or failures may materially impact future revenue projections.", ("general",True): "Positive market narrative may support near-term investor sentiment.", ("general",False): "Negative coverage can weigh on investor sentiment and short-term price action.", } def _trim_summary(text, title="", max_sentences=2): text = _html.unescape(text or "") text = re.sub(r'<[^>]+>', ' ', text) text = re.sub(r'\s+', ' ', text).strip() if not text: return "" if title: norm_title = re.sub(r'\W+', ' ', title.lower()).strip() norm_text = re.sub(r'\W+', ' ', text.lower()).strip() if norm_text.startswith(norm_title[:50]) or norm_title[:50] in norm_text[:len(norm_title)+20]: return "" pts = re.split(r'(?<=[.!?])\s+', text) trimmed = " ".join(pts[:max_sentences]).strip() if trimmed and trimmed[-1] not in ".!?": trimmed += "." return trimmed for idx, item in enumerate(headlines): if len(item) == 5: title, h_score, age_days, url, summary = item else: title, h_score, age_days = item url, summary = "", "" category = _classify(title) if h_score >= 0.05: h_color, h_label = _C["positive"], "Positive" implication = _IMPL.get((category, True), _IMPL[("general", True)]) elif h_score <= -0.05: h_color, h_label = _C["negative"], "Negative" implication = _IMPL.get((category, False), _IMPL[("general", False)]) else: h_color, h_label = _C["warning"], "Neutral" implication = "Neutral coverage — monitor for shifts in tone as the story develops." age_hours = age_days * 24 age_mins = age_days * 1440 if age_mins < 1: age_str = "Just now" elif age_hours < 1: age_str = f"{int(age_mins)}m ago" elif age_hours < 24: age_str = f"{int(age_hours)}h ago" elif age_days < 2: age_str = "Yesterday" else: age_str = f"{int(age_days)}d ago" short_summary = _trim_summary(summary, title=title, max_sentences=2) recap = f"{short_summary} {implication}" if short_summary else implication parts.append(f' [{h_label}] ') if url: parts.append(f'{_html.escape(title)}
') else: parts.append(f'{_html.escape(title)}
') parts.append(f' {age_str} · {_html.escape(recap)}

') parts.append("
") heading("RECOMMENDATION") n_strengths = len(strengths) n_risks = len(risks) hi_mom = not _nan(sm) and sm >= 65 hi_val = not _nan(sv) and sv >= 65 hi_qual = not _nan(sq) and sq >= 65 hi_growth = not _nan(sg) and sg >= 65 hi_short = not _nan(row.get("short_percent")) and (row.get("short_percent") or 0) > 0.15 hi_vol = not _nan(vol) and (vol or 0) > 0.50 parts.append("
") if not _nan(score): if score >= 72 and n_strengths >= 3 and n_risks == 0: rec_label, rec_c = "STRONG BUY", _C["positive"] if hi_val and hi_qual: timeframe, tf_reason = "12–36 months (fundamental thesis)", "Strong valuation discount and balance sheet quality support a longer holding period." elif hi_mom: timeframe, tf_reason = "3–12 months (momentum-driven)", "Price trend is strong — re-evaluate if momentum fades." else: timeframe, tf_reason = "6–18 months", "Multiple factors aligned. Monitor quarterly earnings for confirmation." elif score >= 62 and n_strengths >= 2: rec_label, rec_c = "BUY", _C["positive"] if hi_val and not hi_mom: timeframe, tf_reason = "12–24 months (value unlock)", "Undervalued on fundamentals — patience required for market to re-rate." elif hi_mom and not hi_val: timeframe, tf_reason = "1–6 months (trend play)", "Momentum-driven opportunity. Set a stop-loss and monitor price action." elif hi_growth: timeframe, tf_reason = "6–18 months (growth compounding)", "Strong growth trajectory. Suitable while earnings acceleration continues." else: timeframe, tf_reason = "6–12 months", "Favorable setup. Review if fundamentals or sentiment deteriorate." elif score >= 52: rec_label, rec_c = "HOLD / WATCH", _C["warning"] timeframe, tf_reason = "Reassess in 1–3 months", "Mixed signals. No compelling entry at current levels — monitor for improvement." elif score >= 42: rec_label, rec_c = "UNDERPERFORM", _C["negative"] timeframe, tf_reason = "Avoid new positions", "Below-average score with notable risk factors. Wait for a clearer signal." else: rec_label, rec_c = "AVOID", _C["negative"] timeframe, tf_reason = "Do not initiate", "Significant headwinds across multiple factors. Risk outweighs reward." if hi_short: rec_label = f"{rec_label} — CAUTION (High Short Interest)" tf_reason += " High short interest warns of institutional bearish conviction." if hi_vol: tf_reason += " Elevated volatility increases risk; size positions accordingly." parts.append(f' Verdict: {rec_label}
') parts.append(f' Timeframe: {timeframe}

') parts.append(f' Reasoning: {_html.escape(tf_reason)}

') parts.append(f' Key metrics driving this view:
') if not _nan(sv): extra = f" — P/E {pe:.1f}x" if not _nan(pe) and pe > 0 else "" parts.append(f' • Value score {sv:.0f}/100{extra}
') if not _nan(sg): extra = f" — Rev growth {rev_g*100:+.1f}%" if not _nan(rev_g) else "" parts.append(f' • Growth score {sg:.0f}/100{extra}
') if not _nan(sm): extra = f" — 6M return {r6*100:+.1f}%" if not _nan(r6) else "" parts.append(f' • Momentum score {sm:.0f}/100{extra}
') if not _nan(sa): extra = f" — {upside*100:.1f}% upside to target" if not _nan(upside) and upside > 0 else "" parts.append(f' • Analyst score {sa:.0f}/100{extra}
') else: parts.append(f' Insufficient data to generate a recommendation.
') parts.append("
") parts.append(f'
') parts.append( f'' "This commentary is generated algorithmically from public market data. " "It is for informational purposes only and does not constitute financial advice. " "Always conduct your own due diligence before investing." "" ) return '' + "".join(parts) + "" class CommentaryWidget(QTextBrowser): def __init__(self, parent=None): super().__init__(parent) self.setReadOnly(True) self.setOpenLinks(False) self.anchorClicked.connect(lambda url: webbrowser.open(url.toString())) self._row = None def update_commentary(self, row): self._row = row headlines = row.get("news_headlines") or [] if not headlines and not row.get("_news_fetched"): self.setHtml(_build_commentary_html(row)) ticker = row.get("ticker", "") name = row.get("name", "") if ticker: def _fetch(_r=row, _t=ticker, _n=name): try: import yfinance as _yf _, _hl = get_news_headlines(_yf.Ticker(_t), ticker=_t, company_name=_n) _r["news_headlines"] = _hl except Exception: _r["news_headlines"] = None _r["_news_fetched"] = True QMetaObject.invokeMethod(self, "_refresh_commentary", Qt.QueuedConnection) threading.Thread(target=_fetch, daemon=True).start() else: self.setHtml(_build_commentary_html(row)) @Slot() def _refresh_commentary(self): if self._row: self.setHtml(_build_commentary_html(self._row)) # --------------------------------------------------------------------------- # ChartWidget # --------------------------------------------------------------------------- class ChartWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self._ticker = None self._period = "6mo" self._canvas = None self._build() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) # Period selector ctrl = QWidget() ctrl.setFixedHeight(34) ctrl_lay = QHBoxLayout(ctrl) ctrl_lay.setContentsMargins(8, 4, 8, 0) ctrl_lay.setSpacing(4) self._period_btns = {} for val, lbl in [("1mo","1M"),("3mo","3M"),("6mo","6M"),("1y","1Y"),("2y","2Y"),("5y","5Y")]: btn = QPushButton(lbl) btn.setFixedHeight(24) btn.setCheckable(True) btn.setChecked(val == "6mo") btn.setStyleSheet( f"QPushButton {{ background:{_C['bg3']}; color:{_C['fg2']}; border:1px solid {_C['border']}; border-radius:4px; padding:2px 10px; font-size:11px; }}" f"QPushButton:checked {{ background:{_C['fg']}; color:#000000; border-color:{_C['fg']}; }}" ) btn.clicked.connect(lambda checked, v=val: self._select_period(v)) ctrl_lay.addWidget(btn) self._period_btns[val] = btn ctrl_lay.addStretch() lay.addWidget(ctrl) sep = QFrame(); sep.setFixedHeight(1) sep.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep) self._placeholder = QLabel("Select a stock to view its price chart.") self._placeholder.setAlignment(Qt.AlignCenter) self._placeholder.setStyleSheet(f"color:{_C['fg2']}; font-size:13px;") lay.addWidget(self._placeholder, 1) self._chart_container = QWidget() chart_lay = QVBoxLayout(self._chart_container) chart_lay.setContentsMargins(0, 0, 0, 0) self._chart_inner_lay = chart_lay self._chart_container.hide() lay.addWidget(self._chart_container, 1) def _select_period(self, val): self._period = val for v, btn in self._period_btns.items(): btn.setChecked(v == val) if self._ticker: self.load_chart(self._ticker, val) def load_chart(self, ticker, period=None): self._ticker = ticker if period: self._period = period self._placeholder.setText(f"Loading chart for {ticker}…") self._placeholder.show() if self._canvas: self._chart_container.hide() threading.Thread(target=self._fetch_thread, args=(ticker, self._period), daemon=True).start() def _fetch_thread(self, ticker, period): if not _MATPLOTLIB_OK: QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection, Q_ARG(str, f"Chart unavailable: {_mpl_err}")) return if not _YF_OK: QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection, Q_ARG(str, "Chart unavailable: yfinance not installed.")) return try: hist = yf.Ticker(ticker).history(period=period, auto_adjust=True) if hist is None or hist.empty: QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection, Q_ARG(str, f"No price history found for {ticker}.")) return QMetaObject.invokeMethod(self, "_render_chart", Qt.QueuedConnection, Q_ARG(object, hist), Q_ARG(str, ticker)) except Exception as e: QMetaObject.invokeMethod(self._placeholder, "setText", Qt.QueuedConnection, Q_ARG(str, f"Chart error: {e}")) @Slot(object, str) def _render_chart(self, hist, ticker): if not _MATPLOTLIB_OK: return import pandas as pd # Clear old canvas while self._chart_inner_lay.count(): item = self._chart_inner_lay.takeAt(0) if item.widget(): item.widget().deleteLater() self._canvas = None close = hist["Close"] volume = hist["Volume"] if "Volume" in hist.columns else None ma20 = close.rolling(20).mean() if len(close) >= 20 else None ma50 = close.rolling(50).mean() if len(close) >= 50 else None dates = pd.to_datetime(close.index).tz_localize(None) if close.index.tz else pd.to_datetime(close.index) prices = close.values dpi = max(72, min(300, self.logicalDpiX())) fig = Figure(figsize=(5, 3.8), dpi=dpi, facecolor=_C["bg"]) if volume is not None: ax_price = fig.add_axes([0.09, 0.30, 0.87, 0.62], facecolor=_C["bg"]) ax_volume = fig.add_axes([0.09, 0.06, 0.87, 0.20], facecolor=_C["bg"], sharex=ax_price) else: ax_price = fig.add_axes([0.09, 0.08, 0.87, 0.86], facecolor=_C["bg"]) ax_volume = None ax_price.plot(dates, prices, color="#4ade80", linewidth=1.6, label="Close") if ma20 is not None: ax_price.plot(dates, ma20.values, color="#fbbf24", linewidth=1.0, linestyle="--", label="20-Day MA") if ma50 is not None: ax_price.plot(dates, ma50.values, color="#888888", linewidth=1.0, linestyle="--", label="50-Day MA") ax_price.set_title(f"{ticker} — Price History", color="#4ade80", fontsize=10, pad=6) ax_price.tick_params(colors=_C["fg2"], labelsize=8) for spine in ax_price.spines.values(): spine.set_edgecolor(_C["border"]) ax_price.grid(color="#111111", linewidth=0.5) ax_price.legend(facecolor=_C["bg"], edgecolor=_C["border"], labelcolor=_C["fg"], fontsize=8, loc="upper left") ax_price.tick_params(axis="x", labelbottom=(ax_volume is None)) vol_ma20_arr = None if ax_volume is not None and volume is not None: vol_dates = pd.to_datetime(volume.index).tz_localize(None) if volume.index.tz else pd.to_datetime(volume.index) bar_colors = [_C["positive"] if i == 0 or prices[i] >= prices[i-1] else _C["negative"] for i in range(len(prices))] ax_volume.bar(vol_dates, volume.values, color=bar_colors, width=0.8, align="center") ax_volume.tick_params(colors=_C["fg2"], labelsize=7) for spine in ax_volume.spines.values(): spine.set_edgecolor(_C["border"]) ax_volume.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x/1e6:.0f}M" if x >= 1e6 else f"{x/1e3:.0f}K")) ax_volume.grid(color=_C["border"], linewidth=0.4) _vol_arr = volume.values.astype(float) _kernel = np.ones(20) / 20 _conv = np.convolve(_vol_arr, _kernel, mode="full")[:len(_vol_arr)] _conv[:19] = np.nan vol_ma20_arr = _conv ax_bottom = ax_volume if ax_volume is not None else ax_price try: ax_bottom.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y")) ax_bottom.xaxis.set_major_locator(mdates.AutoDateLocator()) except Exception: pass for lbl in ax_bottom.get_xticklabels(): lbl.set_rotation(30); lbl.set_ha("right"); lbl.set_color(_C["fg2"]) # Crosshair vline_p = ax_price.axvline(x=dates[0], color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False) hline_p = ax_price.axhline(y=prices[0], color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False) dot_p = ax_price.plot([], [], "o", color="#4ade80", markersize=5, zorder=5)[0] info_box = ax_price.text(0.99, 0.97, "", transform=ax_price.transAxes, ha="right", va="top", fontsize=8, color="#4ade80", bbox=dict(boxstyle="round,pad=0.4", facecolor=_C["bg"], edgecolor="#4ade80", alpha=0.96), visible=False, zorder=10) vline_v = ax_volume.axvline(x=dates[0], color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False) if ax_volume else None hline_v = ax_volume.axhline(y=0, color=_C["fg2"], linewidth=0.8, linestyle="--", visible=False) if ax_volume else None info_box_v = ax_volume.text(0.99, 0.97, "", transform=ax_volume.transAxes, ha="right", va="top", fontsize=7.5, color="#4ade80", bbox=dict(boxstyle="round,pad=0.35", facecolor=_C["bg"], edgecolor="#4ade80", alpha=0.96), visible=False, zorder=10) if ax_volume else None date_nums = np.array([d.timestamp() for d in dates]) def _on_move(event): if event.inaxes not in ([ax_price] + ([ax_volume] if ax_volume else [])): vline_p.set_visible(False); hline_p.set_visible(False); dot_p.set_data([], []) info_box.set_visible(False) if vline_v: vline_v.set_visible(False) if hline_v: hline_v.set_visible(False) if info_box_v: info_box_v.set_visible(False) try: canvas.draw_idle() except: pass return try: x_dt = mdates.num2date(event.xdata).replace(tzinfo=None) ts = x_dt.timestamp() idx = int(np.argmin(np.abs(date_nums - ts))) snap_date = dates[idx]; snap_price = prices[idx] snap_x = mdates.date2num(snap_date) vline_p.set_xdata([snap_x, snap_x]); vline_p.set_visible(True) hline_p.set_ydata([snap_price, snap_price]); hline_p.set_visible(True) dot_p.set_data([snap_x], [snap_price]) pct = (snap_price - prices[idx-1]) / prices[idx-1] * 100 if idx > 0 and prices[idx-1] != 0 else None chg_str = f"\nChange: {'+' if pct and pct >= 0 else ''}{pct:.2f}%" if pct is not None else "" vol_str = "" if volume is not None and idx < len(volume.values): v = volume.values[idx] vol_str = f"\nVol: {v/1e6:.2f}M" if v >= 1e6 else f"\nVol: {int(v):,}" info_box.set_text(f"{snap_date.strftime('%b %d, %Y')}\nPrice: ${snap_price:,.2f}{chg_str}{vol_str}") info_box.set_visible(True) if vline_v: vline_v.set_xdata([snap_x, snap_x]); vline_v.set_visible(True) if info_box_v and volume is not None and idx < len(volume.values): v = volume.values[idx] vol_fmt = f"{v/1e6:.2f}M" if v >= 1e6 else f"{int(v):,}" direction = "" if pct is not None: direction = f"\n{'↑ Buying' if pct >= 0 else '↓ Selling'} pressure {pct:+.2f}%" avg_str = "" if vol_ma20_arr is not None and idx < len(vol_ma20_arr): avg = vol_ma20_arr[idx] if not np.isnan(avg) and avg > 0: avg_fmt = f"{avg/1e6:.2f}M" if avg >= 1e6 else f"{int(avg):,}" avg_str = f"\nvs 20D Avg: {v/avg:.2f}× ({avg_fmt})" info_box_v.set_text(f"{snap_date.strftime('%b %d, %Y')}\nVolume: {vol_fmt}{direction}{avg_str}") info_box_v.set_visible(True) if hline_v: hline_v.set_ydata([v, v]); hline_v.set_visible(True) canvas.draw_idle() except: pass def _on_leave(event): vline_p.set_visible(False); hline_p.set_visible(False); dot_p.set_data([], []) info_box.set_visible(False) if vline_v: vline_v.set_visible(False) if hline_v: hline_v.set_visible(False) if info_box_v: info_box_v.set_visible(False) try: canvas.draw_idle() except: pass canvas = FigureCanvasQTAgg(fig) canvas.draw() fig.canvas.mpl_connect("motion_notify_event", _on_move) fig.canvas.mpl_connect("axes_leave_event", _on_leave) fig.canvas.mpl_connect("figure_leave_event", _on_leave) self._chart_inner_lay.addWidget(canvas) self._canvas = canvas self._placeholder.hide() self._chart_container.show() # --------------------------------------------------------------------------- # Row data preparation helper # --------------------------------------------------------------------------- def _prepare_row_dict(row): d = dict(row) total_debt = d.get("total_debt") de_ratio = d.get("debt_to_equity") revenue = d.get("revenue") pm = d.get("profit_margin") om = d.get("operating_margin") ns = d.get("news_sentiment") if not _nan(total_debt) and not _nan(de_ratio) and de_ratio != 0: d["_equity_fmt"] = _mcap(total_debt / (de_ratio / 100.0)) else: d["_equity_fmt"] = "—" d["_revenue_fmt"] = _mcap(revenue) if not _nan(revenue) else "—" d["_net_income_fmt"] = _mcap(revenue * pm) if not _nan(revenue) and not _nan(pm) else "—" d["_op_income_fmt"] = _mcap(revenue * om) if not _nan(revenue) and not _nan(om) else "—" if not _nan(pm): _pm_pct = f"{pm * 100:.1f}%" d["_profit_margin_fmt"] = f"{_pm_pct} ({_mcap(revenue * pm)})" if not _nan(revenue) else _pm_pct else: d["_profit_margin_fmt"] = "—" if not _nan(om): _om_pct = f"{om * 100:.1f}%" d["_op_margin_fmt"] = f"{_om_pct} ({_mcap(revenue * om)})" if not _nan(revenue) else _om_pct else: d["_op_margin_fmt"] = "—" if _nan(ns): d["_sentiment_label"] = "— (no data)" elif ns >= 0.05: d["_sentiment_label"] = f"Positive ({ns:+.3f})" elif ns <= -0.05: d["_sentiment_label"] = f"Negative ({ns:+.3f})" else: d["_sentiment_label"] = f"Neutral ({ns:+.3f})" return d def _fmt_for_key(key, d): def _rec_fmt(v): return "—" if not v or str(v) in ("nan","N/A","None") else str(v).replace("_"," ").title() def _cnt_fmt(v): return "—" if _nan(v) else str(int(v)) ns = d.get("news_sentiment") _FMTS = { "composite_score": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_value": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_growth": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_momentum": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_quality": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_profitability":lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_sentiment": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_analyst": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "score_risk": lambda v: (_score(v) + " / 100") if not _nan(v) else "—", "price": _price, "market_cap": _mcap, "pe_forward": _flt, "pe_trailing": _flt, "pb_ratio": _flt, "book_value": _mcap, "ev_ebitda": _flt, "52w_high": _price, "52w_low": _price, "revenue_growth": _pct, "earnings_growth": _pct, "eps_forward": _flt, "eps_trailing": _flt, "ret_1m": _pct, "ret_3m": _pct, "ret_6m": _pct, "ret_12m": _pct, "roe": _pct, "roa": _pct, "total_debt": _mcap, "total_cash": _mcap, "_equity_fmt": lambda v: v or "—", "_revenue_fmt": lambda v: v or "—", "_net_income_fmt": lambda v: v or "—", "_op_income_fmt": lambda v: v or "—", "_profit_margin_fmt": lambda v: v or "—", "_op_margin_fmt": lambda v: v or "—", "current_ratio": _flt, "profit_margin": _pct, "operating_margin": _pct, "fcf_yield": _pct, "dividend_yield": _pct, "recommendation": _rec_fmt, "analyst_count": _cnt_fmt, "analyst_target": _price, "analyst_upside": _pct, "news_sentiment": lambda v: d.get("_sentiment_label", "—"), "short_percent": _pct, "beta": _flt, "volatility_30d": _pct, } fmt = _FMTS.get(key, lambda v: _flt(v)) val = d.get(key) return fmt(val) def _color_for_key(key, d): val = d.get(key) ns = d.get("news_sentiment") pm = d.get("profit_margin") om = d.get("operating_margin") if key in _GREEN_KEYS and not _nan(val): return _C["positive"] if val >= 0 else _C["negative"] if key == "news_sentiment" and not _nan(ns): return _C["positive"] if ns >= 0.05 else (_C["negative"] if ns <= -0.05 else _C["warning"]) if key in ("short_percent", "volatility_30d") and not _nan(val): return _C["negative"] if val > 0.10 else _C["positive"] if key == "total_debt" and not _nan(val): return _C["negative"] if val > 0 else _C["fg"] if key == "total_cash" and not _nan(val): return _C["positive"] if val > 0 else _C["fg"] if key == "_net_income_fmt" and not _nan(pm): return _C["positive"] if pm >= 0 else _C["negative"] if key == "_op_income_fmt" and not _nan(om): return _C["positive"] if om >= 0 else _C["negative"] if key in ("_profit_margin_fmt", "profit_margin") and not _nan(pm): return _C["positive"] if pm >= 0 else _C["negative"] if key in ("_op_margin_fmt", "operating_margin") and not _nan(om): return _C["positive"] if om >= 0 else _C["negative"] return _C["fg"] # --------------------------------------------------------------------------- # DetailPanel # --------------------------------------------------------------------------- class _MetricRow(QWidget): def __init__(self, label, key, parent=None): super().__init__(parent) self._key = key lay = QHBoxLayout(self) lay.setContentsMargins(16, 6, 16, 6) lay.setSpacing(8) tip = _METRIC_TOOLTIPS.get(label, "") lbl = QLabel(label) lbl.setFixedWidth(160) lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;") if tip: lbl.setToolTip(tip) lay.addWidget(lbl) self.val_lbl = QLabel("—") self.val_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:12px; font-family:'Consolas';") lay.addWidget(self.val_lbl) lay.addStretch() def set_value(self, text, color=None): self.val_lbl.setText(text) c = color or _C["fg"] self.val_lbl.setStyleSheet(f"color:{c}; font-size:12px; font-family:'Consolas';") def _make_grid_tab(fields, cols=2): """Return (QWidget tab, dict of key->MetricCell) using 2-col metric card grid.""" tab = QWidget() scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.NoFrame) inner = QWidget() lay = QGridLayout(inner) lay.setContentsMargins(10, 10, 10, 10) lay.setSpacing(6) cells = {} for i, (label, key) in enumerate(fields): tip = _METRIC_TOOLTIPS.get(label, "") cell = MetricCell(label, key, tip) cells[key] = cell lay.addWidget(cell, i // cols, i % cols) # stretch the last row so cards stay top-aligned lay.setRowStretch((len(fields) - 1) // cols + 1, 1) scroll.setWidget(inner) outer = QVBoxLayout(tab) outer.setContentsMargins(0, 0, 0, 0) outer.addWidget(scroll) return tab, cells class DetailPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) self._current_ticker = None self._all_rows = {} # key -> _MetricRow across all tabs self._score_tab = None self._commentary = None self._chart = None self._build() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) # Header self._header = QLabel(" \u2190 Select a stock from the list") self._header.setStyleSheet(f"color:{_C['fg2']}; font-size:14px; font-weight:600; padding:12px 16px;") lay.addWidget(self._header) sep = QFrame(); sep.setFixedHeight(1) sep.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep) # No-data warning self._no_data_bar = QLabel(" \u26a0 No fundamental data found for this ticker — scores based on price & momentum only.") self._no_data_bar.setStyleSheet(f"background:#1a1200; color:{_C['warning']}; font-size:11px; padding:6px 16px;") self._no_data_bar.hide() lay.addWidget(self._no_data_bar) # Tabs self._tabs = QTabWidget() self._tabs.setVisible(False) lay.addWidget(self._tabs, 1) # Scores tab self._score_tab = ScoreTab() self._tabs.addTab(self._score_tab, "Scores") # Valuation t, cells = _make_grid_tab(_VAL_FIELDS) self._all_rows.update(cells) self._tabs.addTab(t, "Valuation") # Growth t, cells = _make_grid_tab(_GROWTH_FIELDS) self._all_rows.update(cells) self._tabs.addTab(t, "Growth") # Momentum t, cells = _make_grid_tab(_MOM_FIELDS) self._all_rows.update(cells) self._tabs.addTab(t, "Momentum") # Quality & Profit t, cells = _make_grid_tab(_QUAL_FIELDS) self._all_rows.update(cells) self._tabs.addTab(t, "Quality & Profit") # Analyst tab (metric grid + commentary) analyst_tab = QWidget() al = QVBoxLayout(analyst_tab) al.setContentsMargins(0, 0, 0, 0) al.setSpacing(0) # Metrics as a 2-col grid anlst_grid_w = QWidget() anlst_grid_lay = QGridLayout(anlst_grid_w) anlst_grid_lay.setContentsMargins(10, 10, 10, 6) anlst_grid_lay.setSpacing(6) for i, (label, key) in enumerate(_ANLST_FIELDS): tip = _METRIC_TOOLTIPS.get(label, "") cell = MetricCell(label, key, tip) self._all_rows[key] = cell anlst_grid_lay.addWidget(cell, i // 2, i % 2) al.addWidget(anlst_grid_w) hdiv = QFrame(); hdiv.setFixedHeight(1) hdiv.setStyleSheet(f"background:{_C['border']};") al.addWidget(hdiv) # Commentary header chdr = QWidget() chdr_lay = QHBoxLayout(chdr) chdr_lay.setContentsMargins(8, 4, 8, 4) lc = QLabel("Analyst Commentary") lc.setStyleSheet(f"color:{_C['fg']}; font-size:12px; font-weight:700;") chdr_lay.addWidget(lc) ls = QLabel("auto-generated from market data") ls.setStyleSheet(f"color:{_C['fg2']}; font-size:10px;") chdr_lay.addWidget(ls) chdr_lay.addStretch() al.addWidget(chdr) self._commentary = CommentaryWidget() al.addWidget(self._commentary, 1) self._tabs.addTab(analyst_tab, "Analyst") # Chart tab self._chart = ChartWidget() self._tabs.addTab(self._chart, "Chart") def update(self, row): if isinstance(row, _pd.Series): row = row.to_dict() ticker = row.get("ticker", "?") name = str(row.get("name", ticker)) sector = str(row.get("sector", "N/A")) self._header.setText(f" {ticker} — {name} \u00b7 {sector}") self._header.setStyleSheet(f"color:{_C['fg']}; font-size:14px; font-weight:600; padding:12px 16px;") self._tabs.setVisible(True) if str(row.get("_data_source", "")) == "none": self._no_data_bar.show() else: self._no_data_bar.hide() d = _derive_row_extras(row) # Update metric rows for key, row_w in self._all_rows.items(): text = _fmt_for_key(key, d) color = _color_for_key(key, d) row_w.set_value(text, color) # Score tab self._score_tab.update_scores(d) # Commentary self._commentary.update_commentary(d) # Chart new_ticker = row.get("ticker") if new_ticker and new_ticker != self._current_ticker: self._current_ticker = new_ticker self._chart.load_chart(new_ticker) # --------------------------------------------------------------------------- # FiltersDialog # --------------------------------------------------------------------------- class FiltersDialog(QDialog): filters_changed = Signal() def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Filters") self.setWindowFlags(Qt.Dialog | Qt.WindowCloseButtonHint) self.setModal(False) self.setMinimumWidth(320) # State self.price_min = "" self.price_max = "" self.sort_by = "Score ↓" self.mktcap = "Any" self.score_min = "" self.ret_period = "Any" self.ret_min = "" self.upside_min = "" self.search_q = "" lay = QVBoxLayout(self) lay.setSpacing(10) def _label(text): l = QLabel(text) l.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;") return l def _entry(): e = QLineEdit() e.setFixedWidth(100) return e def _combo(items): c = QComboBox() for it in items: c.addItem(it) return c title = QLabel("Post-Screen Filters") title.setStyleSheet(f"color:{_C['fg']}; font-size:14px; font-weight:700;") lay.addWidget(title) g = QGridLayout() g.setColumnStretch(1, 1) r = 0 g.addWidget(_label("Price $"), r, 0) pf = QWidget(); pfl = QHBoxLayout(pf); pfl.setContentsMargins(0,0,0,0) self._price_min = _entry(); self._price_max = _entry() pfl.addWidget(self._price_min); pfl.addWidget(QLabel("–")); pfl.addWidget(self._price_max) g.addWidget(pf, r, 1); r += 1 g.addWidget(_label("Sort by"), r, 0) self._sort_cb = _combo(["Score ↓", "Price ↓", "Price ↑", "Upside ↓"]) g.addWidget(self._sort_cb, r, 1); r += 1 g.addWidget(_label("Market Cap"), r, 0) self._mktcap_cb = _combo(["Any","Mega (>$100B)","Large ($10B–$100B)","Mid ($2B–$10B)","Small (<$2B)"]) g.addWidget(self._mktcap_cb, r, 1); r += 1 g.addWidget(_label("Min Score"), r, 0) self._score_min = _entry() g.addWidget(self._score_min, r, 1); r += 1 g.addWidget(_label("Return"), r, 0) rf = QWidget(); rfl = QHBoxLayout(rf); rfl.setContentsMargins(0,0,0,0) self._ret_period_cb = _combo(["Any","1M","3M","6M","12M"]) self._ret_min = _entry() rfl.addWidget(self._ret_period_cb); rfl.addWidget(QLabel("≥")); rfl.addWidget(self._ret_min); rfl.addWidget(QLabel("%")) g.addWidget(rf, r, 1); r += 1 g.addWidget(_label("Min Upside"), r, 0) uf = QWidget(); ufl = QHBoxLayout(uf); ufl.setContentsMargins(0,0,0,0) self._upside_min = _entry() ufl.addWidget(self._upside_min); ufl.addWidget(QLabel("% to target")) g.addWidget(uf, r, 1); r += 1 g.addWidget(_label("Search"), r, 0) self._search_edit = QLineEdit() g.addWidget(self._search_edit, r, 1); r += 1 lay.addLayout(g) close_btn = QPushButton("Close") close_btn.clicked.connect(self.hide) lay.addWidget(close_btn) # Connections for w in [self._price_min, self._price_max, self._score_min, self._ret_min, self._upside_min, self._search_edit]: w.editingFinished.connect(self._emit) w.returnPressed.connect(self._emit) self._sort_cb.currentTextChanged.connect(self._emit) self._mktcap_cb.currentTextChanged.connect(self._emit) self._ret_period_cb.currentTextChanged.connect(self._emit) self._search_edit.textChanged.connect(self._emit) def _emit(self): self.price_min = self._price_min.text().strip() self.price_max = self._price_max.text().strip() self.sort_by = self._sort_cb.currentText() self.mktcap = self._mktcap_cb.currentText() self.score_min = self._score_min.text().strip() self.ret_period = self._ret_period_cb.currentText() self.ret_min = self._ret_min.text().strip() self.upside_min = self._upside_min.text().strip() self.search_q = self._search_edit.text().strip() self.filters_changed.emit() def get_state(self): return { "price_min": self.price_min, "price_max": self.price_max, "sort_by": self.sort_by, "mktcap": self.mktcap, "score_min": self.score_min, "ret_period": self.ret_period, "ret_min": self.ret_min, "upside_min": self.upside_min, "search_q": self.search_q, } """Part 5: ScreenerPage, SearchPage, TrackingPage (Insider + HedgeFund), ScreenerApp, main""" import calendar as _cal import threading import webbrowser import pandas as _pd # --------------------------------------------------------------------------- # ScreenerPage # --------------------------------------------------------------------------- class ScreenerPage(QWidget): def __init__(self, app_ref, parent=None): super().__init__(parent) self._app = app_ref self._df = None self._df_raw = None self._sector_stats = {} self._pending_df_raw = None self._pending_df_scored = None self._pending_sector_stats = {} self._stop_event = threading.Event() self._run_id = 0 self._rescoring = False self._rescore_id = 0 self._custom_weights = None self._signals = None self._filters_dlg = None self._selected_ticker = None self._build() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) # Toolbar toolbar = QWidget() toolbar.setFixedHeight(52) tl = QHBoxLayout(toolbar) tl.setContentsMargins(12, 8, 12, 8) tl.setSpacing(6) self._run_btn = QPushButton("▶ Run") self._run_btn.setObjectName("run-btn") self._run_btn.clicked.connect(self._on_run) tl.addWidget(self._run_btn) self._stop_btn = QPushButton("■ Stop") self._stop_btn.setEnabled(False) self._stop_btn.clicked.connect(self._on_stop) tl.addWidget(self._stop_btn) tl.addWidget(self._vsep()) tl.addWidget(QLabel("Index")) self._index_cb = QComboBox() for x in INDEX_CHOICES: self._index_cb.addItem(x) tl.addWidget(self._index_cb) tl.addWidget(self._vsep()) tl.addWidget(QLabel("Sector")) self._sector_cb = QComboBox() for x in SECTOR_CHOICES: self._sector_cb.addItem(x) self._sector_cb.currentTextChanged.connect(self._apply_filters) tl.addWidget(self._sector_cb) tl.addWidget(self._vsep()) tl.addWidget(QLabel("Strategy")) self._strategy_cb = QComboBox() for x in list(STRATEGY_PRESETS.keys()) + ["Custom..."]: self._strategy_cb.addItem(x) self._strategy_cb.currentTextChanged.connect(self._on_strategy_change) tl.addWidget(self._strategy_cb) tl.addWidget(self._vsep()) filters_btn = QPushButton("⚙ Filters") filters_btn.clicked.connect(self._open_filters) tl.addWidget(filters_btn) tl.addStretch() self._lastrun_lbl = QLabel("") self._lastrun_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;") tl.addWidget(self._lastrun_lbl) lay.addWidget(toolbar) sep = QFrame(); sep.setFixedHeight(1) sep.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep) # Progress bar self._progress = QProgressBar() self._progress.setFixedHeight(3) self._progress.setRange(0, 100) self._progress.setValue(0) self._progress.setTextVisible(False) self._progress.hide() lay.addWidget(self._progress) # Main splitter splitter = QSplitter(Qt.Horizontal) splitter.setHandleWidth(1) # Left: table left = QWidget() ll = QVBoxLayout(left) ll.setContentsMargins(0, 0, 0, 0) ll.setSpacing(0) results_lbl = QLabel("Results") results_lbl.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700; padding:10px 16px;") ll.addWidget(results_lbl) sep2 = QFrame(); sep2.setFixedHeight(1) sep2.setStyleSheet(f"background:{_C['border']};") ll.addWidget(sep2) self._table = StockTable() self._table.stock_selected.connect(self._on_stock_selected) ll.addWidget(self._table, 1) splitter.addWidget(left) # Right: detail self._detail = DetailPanel() splitter.addWidget(self._detail) splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 2) splitter.setSizes([300, 600]) lay.addWidget(splitter, 1) # Status bar sep3 = QFrame(); sep3.setFixedHeight(1) sep3.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep3) self._status_lbl = QLabel(" Ready — press Run to start a scan") self._status_lbl.setFixedHeight(26) self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px; padding:0 12px;") lay.addWidget(self._status_lbl) def _vsep(self): f = QFrame() f.setFixedSize(1, 20) f.setStyleSheet(f"background:{_C['border']};") return f def get_current_weights(self): strategy = self._strategy_cb.currentText() if strategy == "Custom..." and self._custom_weights: return self._custom_weights return STRATEGY_PRESETS.get(strategy) def _on_stock_selected(self, row_dict): self._selected_ticker = row_dict.get("ticker") self._detail.update(row_dict) def _on_run(self): self._run_id += 1 self._stop_event.clear() self._run_btn.setEnabled(False) self._stop_btn.setEnabled(True) self._progress.setValue(0) self._progress.show() self._status_lbl.setText(" Starting…") strategy = self._strategy_cb.currentText() run_weights = self._custom_weights if strategy == "Custom..." and self._custom_weights else STRATEGY_PRESETS.get(strategy) sector_filter = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText()) screen_filters = {"sector_filter": sector_filter, "mktcap_range": None} signals = _WorkerSignals() self._signals = signals signals.progress.connect(self._on_progress) signals.status.connect(self._on_status) signals.done.connect(self._on_done) signals.error.connect(self._on_error) signals.stopped.connect(self._on_stopped) settings = { "index": self._index_cb.currentText(), "weights": run_weights, "workers": 10, "screen_filters": screen_filters, } threading.Thread(target=self._worker, args=(settings, self._run_id, signals), daemon=True).start() def _on_stop(self): self._stop_event.set() self._run_id += 1 self._run_btn.setEnabled(True) self._stop_btn.setEnabled(False) self._progress.setValue(0) self._progress.hide() self._status_lbl.setText(" Stopped.") def _worker(self, settings, run_id, signals): import os as _os, traceback as _tb _log_path = _os.path.join(_os.environ.get("TEMP", _os.path.expanduser("~")), "screener_run.log") def _log(msg): try: with open(_log_path, "a", encoding="utf-8") as _f: from datetime import datetime as _dt _f.write(f"[{_dt.now().strftime('%H:%M:%S')}] {msg}\n") except Exception: pass _log("=== Run started ===") redirector = _StdoutRedirector(signals) old_stdout = sys.stdout sys.stdout = redirector try: signals.status.emit("Fetching ticker lists…") _log("collect_tickers start") tickers = collect_tickers(index=settings["index"]) _log(f"collect_tickers done: {len(tickers)} tickers") if not tickers: signals.error.emit("Could not retrieve any ticker lists.") return if self._stop_event.is_set(): signals.stopped.emit(); return sf = settings.get("screen_filters", {}) signals.status.emit(f"Screening {len(tickers)} stocks…") _log("fetch_all start") df_raw = fetch_all(tickers, max_workers=settings["workers"], screen_filters=sf) _log(f"fetch_all done: {len(df_raw)} rows, empty={df_raw.empty}") if self._stop_event.is_set(): signals.stopped.emit(); return if df_raw.empty: signals.error.emit("No data returned from yfinance.") return signals.status.emit("Loading sector stats…") sector_stats = _load_sector_stats() _log(f"sector_stats: {len(sector_stats)} sectors") signals.status.emit(f"Computing scores for {len(df_raw)} stocks…") df_scored = score_stocks(df_raw, weights=settings.get("weights"), sector_stats=sector_stats) _log(f"score_stocks done: {len(df_scored)} rows") # Store results in instance vars — avoids passing large objects through signal self._pending_df_raw = df_raw self._pending_df_scored = df_scored self._pending_sector_stats = sector_stats signals.done.emit(len(df_scored)) _log("done signal emitted") except Exception as exc: _log(f"EXCEPTION: {_tb.format_exc()}") signals.error.emit(str(exc)) finally: sys.stdout = old_stdout _log("=== Run finished ===") @Slot(int, int, int, int, float) def _on_progress(self, done, total, ok, skip, eta): if total: self._progress.setValue(int(done / total * 100)) self._status_lbl.setText(f" {done}/{total} ok={ok} skip={skip} ETA {eta:.0f}s") @Slot(str) def _on_status(self, text): self._status_lbl.setText(" " + text) @Slot(int) def _on_done(self, total): df_raw = getattr(self, "_pending_df_raw", None) df_scored = getattr(self, "_pending_df_scored", None) sector_stats = getattr(self, "_pending_sector_stats", {}) if df_scored is None: self._status_lbl.setText(" Error: result data missing.") self._run_btn.setEnabled(True) self._stop_btn.setEnabled(False) return self._df_raw = df_raw self._df = df_scored self._sector_stats = sector_stats self._progress.setValue(100) self._status_lbl.setText(f" Done — {total} stocks scored.") self._apply_filters() self._lastrun_lbl.setText("Last run: " + datetime.now().strftime("%H:%M:%S")) self._run_btn.setEnabled(True) self._stop_btn.setEnabled(False) # Share with app if hasattr(self._app, '_df'): self._app._df = df_scored self._app._df_raw = df_raw self._app._sector_stats = sector_stats @Slot(str) def _on_error(self, text): self._status_lbl.setText(" Error: " + text) self._run_btn.setEnabled(True) self._stop_btn.setEnabled(False) self._progress.hide() QMessageBox.critical(self, "Screen Error", f"The screening run failed:\n\n{text}") @Slot() def _on_stopped(self): self._status_lbl.setText(" Stopped.") self._run_btn.setEnabled(True) self._stop_btn.setEnabled(False) self._progress.hide() def _apply_filters(self): if self._df is None: return try: self._apply_filters_inner() except Exception as exc: import traceback QMessageBox.critical(self, "Display Error", f"Failed to display results:\n\n{traceback.format_exc()}") def _apply_filters_inner(self): df = self._df rule = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText()) if rule: col, val = rule if col in df.columns: df = df[df[col] == val] state = self._filters_dlg.get_state() if self._filters_dlg else {} search_q = state.get("search_q", "").strip() if search_q: q = search_q.lower() mask = (df["ticker"].str.lower().str.contains(q, na=False, regex=False) | df["name"].str.lower().str.contains(q, na=False, regex=False)) df = df[mask] def _f(s): try: return float(s.replace("$","").replace(",","").strip()) except: return None for col2 in ("price","market_cap","composite_score","ret_1m","ret_3m","ret_6m","ret_12m","analyst_upside"): if col2 in df.columns: df = df.copy() df[col2] = _pd.to_numeric(df[col2], errors="coerce") pmin = _f(state.get("price_min","")) pmax = _f(state.get("price_max","")) if pmin is not None and "price" in df.columns: df = df[df["price"].notna() & (df["price"] >= pmin)] if pmax is not None and "price" in df.columns: df = df[df["price"].notna() & (df["price"] <= pmax)] mktcap = state.get("mktcap","Any") if mktcap != "Any" and "market_cap" in df.columns: if mktcap.startswith("Mega"): df = df[df["market_cap"].fillna(0) >= 100e9] elif mktcap.startswith("Large"): df = df[(df["market_cap"].fillna(0) >= 10e9) & (df["market_cap"].fillna(0) < 100e9)] elif mktcap.startswith("Mid"): df = df[(df["market_cap"].fillna(0) >= 2e9) & (df["market_cap"].fillna(0) < 10e9)] elif mktcap.startswith("Small"): df = df[df["market_cap"].fillna(0) < 2e9] smin = _f(state.get("score_min","")) if smin is not None and "composite_score" in df.columns: df = df[df["composite_score"].fillna(0) >= smin] ret_period = state.get("ret_period","Any") ret_min = _f(state.get("ret_min","")) _rcol = {"1M":"ret_1m","3M":"ret_3m","6M":"ret_6m","12M":"ret_12m"} if ret_period != "Any" and ret_min is not None: col2 = _rcol.get(ret_period) if col2 and col2 in df.columns: df = df[df[col2].fillna(-999) >= ret_min / 100.0] umin = _f(state.get("upside_min","")) if umin is not None and "analyst_upside" in df.columns: df = df[df["analyst_upside"].fillna(-999) >= umin / 100.0] sort_by = state.get("sort_by","Score ↓") if sort_by == "Price ↓" and "price" in df.columns: df = df.sort_values("price", ascending=False, na_position="last") elif sort_by == "Price ↑" and "price" in df.columns: df = df.sort_values("price", ascending=True, na_position="last") elif sort_by == "Upside ↓" and "analyst_upside" in df.columns: df = df.sort_values("analyst_upside", ascending=False, na_position="last") self._table.populate(df) n = len(df) self._status_lbl.setText(f" Showing {n} stocks. Click a ticker to see details.") # Refresh selected ticker if still present if self._selected_ticker and self._df is not None: match = self._df[self._df["ticker"] == self._selected_ticker] if not match.empty: self._detail.update(match.iloc[0]) def _open_filters(self): if self._filters_dlg is None: self._filters_dlg = FiltersDialog(self) self._filters_dlg.filters_changed.connect(self._apply_filters) self._filters_dlg.show() self._filters_dlg.raise_() def _on_strategy_change(self, strategy): if strategy == "Custom...": self._open_custom_strategy_dialog() return if self._df_raw is not None and not self._rescoring: self._rescoring = True self._rescore_id += 1 current_id = self._rescore_id weights = STRATEGY_PRESETS.get(strategy) self._status_lbl.setText(f" Rescoring with strategy: {strategy}…") def _rescore(): try: result = score_stocks(self._df_raw, weights=weights, sector_stats=self._sector_stats) except Exception: self._rescoring = False return def _done(): self._rescoring = False if self._rescore_id != current_id: return self._df = result if hasattr(self._app, '_df'): self._app._df = result self._apply_filters() self._status_lbl.setText(f" Strategy: {strategy} — rescored {len(result)} stocks.") QMetaObject.invokeMethod(self, "_apply_filters", Qt.QueuedConnection) threading.Thread(target=_rescore, daemon=True).start() def _open_custom_strategy_dialog(self): dlg = QDialog(self) dlg.setWindowTitle("Custom Strategy Weights") dlg.setModal(True) lay = QVBoxLayout(dlg) lbl = QLabel("Set factor weights (must sum to 100%)") lbl.setStyleSheet(f"color:{_C['fg']}; font-weight:700;") lay.addWidget(lbl) FACTORS = [ ("value","Value"),("growth","Growth"),("momentum","Momentum"), ("quality","Quality"),("profitability","Profitability"), ("sentiment","Sentiment"),("analyst","Analyst"),("risk","Risk"), ] strategy = self._strategy_cb.currentText() base = self._custom_weights or STRATEGY_PRESETS.get(strategy, WEIGHTS) entries = {} grid = QGridLayout() for i, (key, lbl_txt) in enumerate(FACTORS): grid.addWidget(QLabel(lbl_txt), i, 0) e = QLineEdit(f"{base[key]*100:.1f}") e.setFixedWidth(80) entries[key] = e grid.addWidget(e, i, 1) grid.addWidget(QLabel("%"), i, 2) lay.addLayout(grid) sum_lbl = QLabel("Sum: 100.0%") sum_lbl.setStyleSheet(f"color:{_C['positive']};") lay.addWidget(sum_lbl) def _update_sum(): total = 0.0 for e in entries.values(): try: total += float(e.text()) except: pass c = _C["positive"] if abs(total - 100) < 0.5 else _C["negative"] sum_lbl.setText(f"Sum: {total:.1f}%") sum_lbl.setStyleSheet(f"color:{c};") for e in entries.values(): e.textChanged.connect(_update_sum) btn_row = QWidget(); brl = QHBoxLayout(btn_row) apply_btn = QPushButton("Apply"); cancel_btn = QPushButton("Cancel") brl.addWidget(apply_btn); brl.addWidget(cancel_btn) lay.addWidget(btn_row) def _apply(): w = {} for key, e in entries.items(): try: w[key] = float(e.text()) / 100.0 except: return total = sum(w.values()) if total <= 0: return w = {k: v / total for k, v in w.items()} self._custom_weights = w dlg.accept() if self._df_raw is not None: self._df = score_stocks(self._df_raw, weights=w, sector_stats=self._sector_stats) if hasattr(self._app, '_df'): self._app._df = self._df self._apply_filters() def _cancel(): if not self._custom_weights: self._strategy_cb.blockSignals(True) self._strategy_cb.setCurrentText("Balanced (Default)") self._strategy_cb.blockSignals(False) dlg.reject() apply_btn.clicked.connect(_apply) cancel_btn.clicked.connect(_cancel) dlg.exec() # --------------------------------------------------------------------------- # SearchPage # --------------------------------------------------------------------------- class SearchPage(QWidget): def __init__(self, parent=None): super().__init__(parent) self._df = None self._df_raw = None self._sector_stats = {} self._get_weights = lambda: None self._last_row = None self._results_shown = False self._build() def set_data(self, df, df_raw, sector_stats, get_weights): self._df = df self._df_raw = df_raw self._sector_stats = sector_stats self._get_weights = get_weights or (lambda: None) def _build(self): self._main_lay = QStackedLayout(self) # Hero page hero_w = QWidget() hl = QVBoxLayout(hero_w) hl.addStretch() center = QWidget() cl = QVBoxLayout(center) cl.setSpacing(12) cl.setAlignment(Qt.AlignCenter) title = QLabel(_APP_NAME.upper()) title.setStyleSheet(f"color:{_C['fg']}; font-size:24px; font-weight:700;") title.setAlignment(Qt.AlignCenter) cl.addWidget(title) self._search_edit = QLineEdit() self._search_edit.setPlaceholderText("Ticker or company name…") self._search_edit.setFixedWidth(400) self._search_edit.returnPressed.connect(self._do_search) cl.addWidget(self._search_edit, alignment=Qt.AlignCenter) search_btn = QPushButton("Search Stock") search_btn.setObjectName("run-btn") search_btn.setFixedWidth(160) search_btn.clicked.connect(self._do_search) cl.addWidget(search_btn, alignment=Qt.AlignCenter) self._search_status = QLabel("") self._search_status.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;") self._search_status.setAlignment(Qt.AlignCenter) cl.addWidget(self._search_status) hl.addWidget(center) hl.addStretch() self._main_lay.addWidget(hero_w) # Results page results_w = QWidget() rl = QVBoxLayout(results_w) rl.setContentsMargins(0, 0, 0, 0) rl.setSpacing(0) hdr = QWidget() hdr_lay = QHBoxLayout(hdr) hdr_lay.setContentsMargins(12, 6, 12, 6) self._status2 = QLabel("") self._status2.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;") hdr_lay.addWidget(self._status2) hdr_lay.addStretch() back_btn = QPushButton("← Back") back_btn.clicked.connect(self._go_back) hdr_lay.addWidget(back_btn) rl.addWidget(hdr) self._detail = DetailPanel() rl.addWidget(self._detail, 1) self._main_lay.addWidget(results_w) def _go_back(self): self._main_lay.setCurrentIndex(0) self._results_shown = False self._search_edit.clear() self._search_status.setText("") def _do_search(self): query = self._search_edit.text().strip().lstrip("$") if not query: return self._search_status.setText("Searching…") threading.Thread(target=self._search_worker, args=(query,), daemon=True).start() def _search_worker(self, query): row = self._find_in_df(query) if row is not None: import pandas as pd if isinstance(row, pd.Series): row = row.to_dict() QMetaObject.invokeMethod(self, "_show_result", Qt.QueuedConnection, Q_ARG(object, row), Q_ARG(str, f"Found in screener data: {row['ticker']}")) return QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection, Q_ARG(str, "Fetching data…")) fetch_result = [None] def _do(): fetch_result[0] = self._fetch_live(query) t = threading.Thread(target=_do, daemon=True) t.start(); t.join(timeout=20) if t.is_alive(): QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection, Q_ARG(str, "Search timed out. Check your connection and try again.")) return live_row = fetch_result[0] if live_row is None: QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection, Q_ARG(str, f"No results for '{query}'. Try the exact ticker symbol.")) return # Score it try: import pandas as pd _w = self._get_weights() scored = score_stocks(pd.DataFrame([live_row]), weights=_w, sector_stats=self._sector_stats) if not scored.empty: s = scored.iloc[0] for col in ("composite_score","score_value","score_growth","score_momentum", "score_quality","score_profitability","score_sentiment","score_analyst","score_risk"): if col in s: live_row[col] = s[col] except Exception: pass QMetaObject.invokeMethod(self, "_show_result", Qt.QueuedConnection, Q_ARG(object, live_row), Q_ARG(str, f"Live data: {live_row['ticker']}")) def _find_in_df(self, query): if self._df is None or self._df.empty: return None q = query.upper().strip().lstrip("$") mask = self._df["ticker"].str.upper() == q if mask.any(): return self._df[mask].iloc[0] if "name" in self._df.columns: mask2 = self._df["name"].str.upper().str.contains(q, regex=False, na=False) if mask2.any(): return self._df[mask2].iloc[0] return None def _fetch_live(self, query): if not _YF_OK: return None import pandas as pd ticker = query.upper().strip().lstrip("$") try: stock = yf.Ticker(ticker) info = stock.info if not info or not isinstance(info, dict) or len(info) < 3: try: results = yf.Search(query, max_results=1) quotes = getattr(results, "quotes", []) if quotes: ticker = quotes[0].get("symbol", ticker) stock = yf.Ticker(ticker) info = stock.info except: pass if not info or not isinstance(info, dict) or len(info) < 3: return None def _g(k, default=None): v = info.get(k, default) return v if v not in (None, "N/A", "", "None") else default hist = stock.history(period="1y", auto_adjust=True) close = hist["Close"].dropna() if not hist.empty else pd.Series(dtype=float) price = close.iloc[-1] if not close.empty else _g("regularMarketPrice") ret_1m = (close.iloc[-1]/close.iloc[-22]-1) if len(close) >= 22 else None ret_3m = (close.iloc[-1]/close.iloc[-66]-1) if len(close) >= 66 else None ret_6m = (close.iloc[-1]/close.iloc[-130]-1) if len(close) >= 130 else None ret_12m= (close.iloc[-1]/close.iloc[0]-1) if len(close) >= 2 else None _company_name = _g("longName") or _g("shortName") or "" try: _news_agg, _news_headlines = get_news_headlines(stock, ticker=ticker, company_name=_company_name) except: _news_agg, _news_headlines = None, [] return { "ticker": ticker, "name": _g("longName") or _g("shortName") or ticker, "sector": _g("sector","N/A"), "industry": _g("industry","N/A"), "price": price, "market_cap": _g("marketCap"), "pe_forward": _g("forwardPE"), "pe_trailing": _g("trailingPE"), "pb_ratio": _g("priceToBook"), "book_value": _g("bookValue"), "ev_ebitda": _g("enterpriseToEbitda"), "52w_high": _g("fiftyTwoWeekHigh"), "52w_low": _g("fiftyTwoWeekLow"), "revenue_growth": _g("revenueGrowth"), "earnings_growth": _g("earningsGrowth"), "eps_forward": _g("forwardEps"), "eps_trailing": _g("trailingEps"), "ret_1m": ret_1m, "ret_3m": ret_3m, "ret_6m": ret_6m, "ret_12m": ret_12m, "roe": _g("returnOnEquity"), "roa": _g("returnOnAssets"), "total_debt": _g("totalDebt"), "debt_to_equity": _g("debtToEquity"), "total_cash": _g("totalCash"), "current_ratio": _g("currentRatio"), "revenue": _g("totalRevenue"), "profit_margin": _g("profitMargins"), "operating_margin": _g("operatingMargins"), "fcf_yield": (float(_g("freeCashflow"))/float(_g("marketCap")) if _g("freeCashflow") and _g("marketCap") and float(_g("marketCap")) > 0 else None), "dividend_yield": _g("dividendYield"), "recommendation": _g("recommendationKey"), "analyst_count": _g("numberOfAnalystOpinions"), "analyst_target": _g("targetMeanPrice"), "analyst_upside": ((_g("targetMeanPrice") or 0) / price - 1 if price and price > 0 and _g("targetMeanPrice") else None), "analyst_norm": ((5.0 - float(info["recommendationMean"])) / 4.0 if info.get("recommendationMean") is not None and 1 <= float(info["recommendationMean"]) <= 5 else None), "news_sentiment": _news_agg, "news_headlines": _news_headlines, "short_percent": _g("shortPercentOfFloat"), "beta": _g("beta"), "volatility_30d": (float(close.iloc[-30:].pct_change().dropna().std() * (252**0.5)) if len(close) >= 35 else None), "composite_score": None, "score_value": None, "score_growth": None, "score_momentum": None, "score_quality": None, "score_profitability": None, "score_sentiment": None, "score_analyst": None, "score_risk": None, "business_summary": _g("longBusinessSummary","") or "", } except: return None @Slot(object, str) def _show_result(self, row, status_msg): self._last_row = row self._search_status.setText(status_msg) self._status2.setText(status_msg) self._detail.update(row) self._main_lay.setCurrentIndex(1) self._results_shown = True # --------------------------------------------------------------------------- # InsiderTable # --------------------------------------------------------------------------- class InsiderTable(QWidget): _COLS = [ ("Date",70,"center"),("Ticker",65,"center"),("Company",175,"left"), ("Insider",155,"left"),("Title",125,"left"),("Type",90,"center"), ("Shares",90,"right"),("Price",70,"right"),("Value",90,"right"),("After",95,"right"), ] _PERIODS = [("Today",1),("3D",3),("1W",7),("2W",14)] _TYPES = [("All","all"),("Buys","buy"),("Sales","sale")] def __init__(self, parent=None): super().__init__(parent) self._all_trades = [] self._iid_to_url = {} self._loading = False self._has_loaded = False self._days_back = 3 self._type_filter = "all" self._build() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) # Controls ctrl = QWidget(); ctrl.setFixedHeight(44) cl = QHBoxLayout(ctrl); cl.setContentsMargins(12,6,12,6); cl.setSpacing(8) title = QLabel("Insider Transactions") title.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700;") cl.addWidget(title) self._period_btns = {} for lbl, days in self._PERIODS: btn = QPushButton(lbl) btn.setCheckable(True); btn.setChecked(days == self._days_back) btn.clicked.connect(lambda checked, d=days: self._set_period(d)) cl.addWidget(btn) self._period_btns[days] = btn cl.addWidget(self._vsep()) self._type_btns = {} for lbl, val in self._TYPES: btn = QPushButton(lbl) btn.setCheckable(True); btn.setChecked(val == "all") btn.clicked.connect(lambda checked, v=val: self._set_type(v)) cl.addWidget(btn) self._type_btns[val] = btn cl.addWidget(self._vsep()) self._refresh_btn = QPushButton("↻ Refresh") self._refresh_btn.setObjectName("run-btn") self._refresh_btn.clicked.connect(self._refresh) cl.addWidget(self._refresh_btn) self._status_lbl = QLabel("Click ↻ Refresh to load insider trading data.") self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;") cl.addWidget(self._status_lbl) cl.addStretch() lay.addWidget(ctrl) sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep) self._table = QTableWidget(0, len(self._COLS)) self._table.setHorizontalHeaderLabels([c[0] for c in self._COLS]) self._table.verticalHeader().setVisible(False) self._table.setSelectionBehavior(QAbstractItemView.SelectRows) self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) self._table.setShowGrid(False) self._table.setAlternatingRowColors(False) self._table.horizontalHeader().setStretchLastSection(False) self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) for i, (_, w, _a) in enumerate(self._COLS): self._table.setColumnWidth(i, w) self._table.clicked.connect(self._on_row_click) lay.addWidget(self._table, 1) def _vsep(self): f = QFrame(); f.setFixedSize(1, 20); f.setStyleSheet(f"background:{_C['border']};"); return f def _set_period(self, days): self._days_back = days for d, btn in self._period_btns.items(): btn.setChecked(d == days) self._refresh() def _set_type(self, val): self._type_filter = val for v, btn in self._type_btns.items(): btn.setChecked(v == val) self._apply_filter() def on_show(self): if not self._has_loaded: self._refresh() def _refresh(self): if self._loading: return self._loading = True self._refresh_btn.setEnabled(False) self._status_lbl.setText("Fetching from SEC EDGAR...") days = self._days_back def _progress(done, total): QMetaObject.invokeMethod(self._status_lbl, "setText", Qt.QueuedConnection, Q_ARG(str, f"Loading... ({done}/{total} filings)")) def _worker(): try: trades = fetch_insider_trades(days_back=days, progress_cb=_progress) except: trades = [] QMetaObject.invokeMethod(self, "_on_fetched", Qt.QueuedConnection, Q_ARG(object, trades)) threading.Thread(target=_worker, daemon=True).start() @Slot(object) def _on_fetched(self, trades): self._loading = False; self._has_loaded = True self._all_trades = trades self._refresh_btn.setEnabled(True) self._apply_filter() n = len(trades) self._status_lbl.setText(f"{n} transaction{'s' if n!=1 else ''} loaded." if n else "No transactions found.") def _apply_filter(self): trades = self._all_trades or [] if self._type_filter == "buy": trades = [t for t in trades if t.get("transaction_code") == "P"] elif self._type_filter == "sale": trades = [t for t in trades if t.get("transaction_code") == "S"] self._populate(trades) def _populate(self, trades): self._table.setRowCount(0) self._iid_to_url.clear() for i, tx in enumerate(trades): tc = tx.get("transaction_code","") date_str = tx.get("tx_date") or tx.get("filed_date") or "" try: y, m, d = date_str[:10].split("-") date_str = f"{_cal.month_abbr[int(m)]} {int(d)}" except: pass color = {"P": _C["positive"], "S": _C["negative"], "A": _C["warning"]}.get(tc, _C["fg2"]) vals = [ date_str, tx.get("ticker","—"), (tx.get("company") or "")[:26], (tx.get("insider") or "")[:22], (tx.get("title") or "")[:18], tx.get("transaction_type","—"), _fmt_shares(tx.get("shares")), _price(tx.get("price")) if tx.get("price") else "—", _fmt_value(tx.get("value")), _fmt_shares(tx.get("owned_after")), ] self._table.insertRow(i) for j, v in enumerate(vals): item = QTableWidgetItem(str(v)) item.setForeground(QColor(color)) bg = QColor("#181818") if i % 2 else QColor("#0a0a0a") item.setBackground(bg) self._table.setItem(i, j, item) self._iid_to_url[i] = tx.get("url","") self._table.setRowHeight(0, 28) for r in range(self._table.rowCount()): self._table.setRowHeight(r, 28) def _on_row_click(self, index): url = self._iid_to_url.get(index.row(),"") if url: webbrowser.open(url) # --------------------------------------------------------------------------- # HedgeFundTable # --------------------------------------------------------------------------- class HedgeFundTable(QWidget): _COLS = [ ("Filed",82,"center"),("Fund",195,"left"),("Company",185,"left"), ("Shares",90,"right"),("Value",90,"right"),("Class",60,"center"),("Type",45,"center"), ] _PERIODS = [("30D",30),("60D",60),("90D",90),("180D",180)] _MIN_VALS = [("All",0),("$1M+",1_000_000),("$10M+",10_000_000),("$100M+",100_000_000)] def __init__(self, parent=None): super().__init__(parent) self._all_holdings = [] self._iid_to_url = {} self._loading = False self._has_loaded = False self._days_back = 90 self._min_val = 1_000_000 self._build() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) ctrl = QWidget(); ctrl.setFixedHeight(44) cl = QHBoxLayout(ctrl); cl.setContentsMargins(12,6,12,6); cl.setSpacing(8) title = QLabel("Hedge Fund Holdings (13F-HR)") title.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700;") cl.addWidget(title) self._period_btns = {} for lbl, days in self._PERIODS: btn = QPushButton(lbl); btn.setCheckable(True); btn.setChecked(days == self._days_back) btn.clicked.connect(lambda checked, d=days: self._set_period(d)) cl.addWidget(btn); self._period_btns[days] = btn cl.addWidget(self._vsep()) self._minval_btns = {} for lbl, val in self._MIN_VALS: btn = QPushButton(lbl); btn.setCheckable(True); btn.setChecked(val == self._min_val) btn.clicked.connect(lambda checked, v=val: self._set_min_val(v)) cl.addWidget(btn); self._minval_btns[val] = btn cl.addWidget(self._vsep()) self._refresh_btn = QPushButton("↻ Refresh") self._refresh_btn.setObjectName("run-btn") self._refresh_btn.clicked.connect(self._refresh) cl.addWidget(self._refresh_btn) self._status_lbl = QLabel("Click ↻ Refresh to load hedge fund holdings.") self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;") cl.addWidget(self._status_lbl) cl.addStretch() lay.addWidget(ctrl) sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep) self._table = QTableWidget(0, len(self._COLS)) self._table.setHorizontalHeaderLabels([c[0] for c in self._COLS]) self._table.verticalHeader().setVisible(False) self._table.setSelectionBehavior(QAbstractItemView.SelectRows) self._table.setEditTriggers(QAbstractItemView.NoEditTriggers) self._table.setShowGrid(False) self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) for i, (_, w, _a) in enumerate(self._COLS): self._table.setColumnWidth(i, w) self._table.clicked.connect(self._on_row_click) lay.addWidget(self._table, 1) def _vsep(self): f = QFrame(); f.setFixedSize(1, 20); f.setStyleSheet(f"background:{_C['border']};"); return f def _set_period(self, days): self._days_back = days for d, btn in self._period_btns.items(): btn.setChecked(d == days) self._refresh() def _set_min_val(self, val): self._min_val = val for v, btn in self._minval_btns.items(): btn.setChecked(v == val) self._apply_filter() def on_show(self): if not self._has_loaded: self._refresh() def _refresh(self): if self._loading: return self._loading = True; self._refresh_btn.setEnabled(False) self._status_lbl.setText("Fetching from SEC EDGAR...") days = self._days_back def _progress(done, total): QMetaObject.invokeMethod(self._status_lbl, "setText", Qt.QueuedConnection, Q_ARG(str, f"Loading... ({done}/{total} funds)")) def _worker(): try: holdings = fetch_hedge_fund_filings(days_back=days, progress_cb=_progress) except: holdings = [] QMetaObject.invokeMethod(self, "_on_fetched", Qt.QueuedConnection, Q_ARG(object, holdings)) threading.Thread(target=_worker, daemon=True).start() @Slot(object) def _on_fetched(self, holdings): self._loading = False; self._has_loaded = True self._all_holdings = holdings; self._refresh_btn.setEnabled(True) self._apply_filter() n = len(holdings) self._status_lbl.setText(f"{n} holding{'s' if n!=1 else ''} loaded." if n else "No holdings found.") def _apply_filter(self): rows = [h for h in self._all_holdings if (h.get("value") or 0) >= self._min_val] if self._min_val > 0 else list(self._all_holdings) self._populate(rows) def _populate(self, holdings): self._table.setRowCount(0) self._iid_to_url.clear() for i, h in enumerate(holdings): date_str = h.get("filed_date","") try: y, m, d = date_str[:10].split("-") date_str = f"{_cal.month_abbr[int(m)]} {int(d)}" except: pass opt = (h.get("put_call") or "").strip() color = _C["warning"] if opt else _C["fg"] vals = [ date_str, (h.get("fund_name") or "")[:28], (h.get("company") or "")[:26], _fmt_shares(h.get("shares")), _fmt_value(h.get("value")), (h.get("class_") or "")[:8], opt[:4] if opt else "—", ] self._table.insertRow(i) for j, v in enumerate(vals): item = QTableWidgetItem(str(v)) item.setForeground(QColor(color)) item.setBackground(QColor("#181818") if i % 2 else QColor("#0a0a0a")) self._table.setItem(i, j, item) self._iid_to_url[i] = h.get("url","") for r in range(self._table.rowCount()): self._table.setRowHeight(r, 28) def _on_row_click(self, index): url = self._iid_to_url.get(index.row(),"") if url: webbrowser.open(url) # --------------------------------------------------------------------------- # TrackingPage # --------------------------------------------------------------------------- class TrackingPage(QWidget): def __init__(self, parent=None): super().__init__(parent) self._active_sub = "insider" self._build() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) # Sub-nav subnav = QWidget(); subnav.setFixedHeight(44) sl = QHBoxLayout(subnav); sl.setContentsMargins(12,8,12,8); sl.setSpacing(8) self._insider_btn = QPushButton("Insider Trades") self._insider_btn.setCheckable(True); self._insider_btn.setChecked(True) self._insider_btn.clicked.connect(lambda: self._switch("insider")) self._hf_btn = QPushButton("Hedge Funds") self._hf_btn.setCheckable(True) self._hf_btn.clicked.connect(lambda: self._switch("hf")) sl.addWidget(self._insider_btn); sl.addWidget(self._hf_btn); sl.addStretch() lay.addWidget(subnav) sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};") lay.addWidget(sep) self._stack = QStackedWidget() self._insider = InsiderTable() self._hf = HedgeFundTable() self._stack.addWidget(self._insider) self._stack.addWidget(self._hf) lay.addWidget(self._stack, 1) def _switch(self, which): self._active_sub = which self._insider_btn.setChecked(which == "insider") self._hf_btn.setChecked(which == "hf") self._stack.setCurrentIndex(0 if which == "insider" else 1) self.on_show() def on_show(self): if self._active_sub == "hf": self._hf.on_show() else: self._insider.on_show() # --------------------------------------------------------------------------- # ScreenerApp # --------------------------------------------------------------------------- class ScreenerApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(f"{_APP_NAME} v{_APP_VERSION} — {_channel_label}") self.setMinimumSize(900, 560) self._df = None self._df_raw = None self._sector_stats = {} self._build_ui() self.showMaximized() def _build_ui(self): central = QWidget() self.setCentralWidget(central) layout = QHBoxLayout(central) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self._sidebar = Sidebar() self._sidebar.page_changed.connect(self._switch_page) layout.addWidget(self._sidebar) self._stack = QStackedWidget() layout.addWidget(self._stack, 1) self._screener_page = ScreenerPage(self) self._search_page = SearchPage(self) self._tracking_page = TrackingPage(self) self._stack.addWidget(self._screener_page) self._stack.addWidget(self._search_page) self._stack.addWidget(self._tracking_page) def _switch_page(self, idx): self._stack.setCurrentIndex(idx) if idx == 1: self._search_page.set_data( self._df, self._df_raw, self._sector_stats, self._screener_page.get_current_weights ) elif idx == 2: self._tracking_page.on_show() # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main(): multiprocessing.freeze_support() try: ctypes.windll.shcore.SetProcessDpiAwareness(2) except Exception: pass app = QApplication(sys.argv) app.setStyleSheet(_QSS) win = ScreenerApp() win.show() sys.exit(app.exec()) if __name__ == "__main__": main()