"""
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;")
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)
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
_log("invoking _on_done on main thread")
QMetaObject.invokeMethod(self, "_on_done", Qt.QueuedConnection, Q_ARG(int, len(df_scored)))
_log("invoke call returned")
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):
import os as _os2
_lp2 = _os2.path.join(_os2.environ.get("TEMP", _os2.path.expanduser("~")), "screener_run.log")
try:
with open(_lp2, "a") as _lf2:
from datetime import datetime as _dt2
_lf2.write(f"[{_dt2.now().strftime('%H:%M:%S')}] _on_done called, total={total}\n")
except Exception:
pass
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
try:
with open(_lp2, "a") as _lf2b:
from datetime import datetime as _dt2b
_lf2b.write(f"[{_dt2b.now().strftime('%H:%M:%S')}] _on_done: df_scored={'None' if df_scored is None else len(df_scored)}, self._df={'None' if self._df is None else 'set'}\n")
except Exception:
pass
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):
import os as _os4, traceback as _tb4
_lp4 = _os4.path.join(_os4.environ.get("TEMP", _os4.path.expanduser("~")), "screener_run.log")
def _log4(msg):
try:
with open(_lp4, "a") as _lf4:
from datetime import datetime as _dt4
_lf4.write(f"[{_dt4.now().strftime('%H:%M:%S')}] AF: {msg}\n")
except Exception:
pass
_log4(f"called, self._df={'None' if self._df is None else len(self._df)}")
if self._df is None:
_log4("returning early — df is None")
return
try:
self._apply_filters_inner()
_log4("inner completed OK")
except Exception as exc:
_log4(f"EXCEPTION: {_tb4.format_exc()}")
try:
QMessageBox.critical(self, "Display Error",
f"Failed to display results:\n\n{_tb4.format_exc()}")
except Exception as e2:
_log4(f"QMessageBox also failed: {e2}")
def _apply_filters_inner(self):
import os as _os3
_lp3 = _os3.path.join(_os3.environ.get("TEMP", _os3.path.expanduser("~")), "screener_run.log")
def _log3(msg):
try:
with open(_lp3, "a") as _lf3:
from datetime import datetime as _dt3
_lf3.write(f"[{_dt3.now().strftime('%H:%M:%S')}] AFI: {msg}\n")
except Exception:
pass
_log3(f"start, df={None if self._df is None else len(self._df)} rows")
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")
_log3(f"calling populate with {len(df)} rows")
self._table.populate(df)
_log3("populate done")
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()
# Force window onto primary screen
primary = app.primaryScreen()
geo = primary.availableGeometry()
win.move(geo.x() + (geo.width() - win.width()) // 2,
geo.y() + (geo.height() - win.height()) // 2)
sys.exit(app.exec())
if __name__ == "__main__":
main()