Stock-Tool/dist/screener_gui.py

2933 lines
125 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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'<span style="color:{c};">{text}</span>')
def hl(text, color=None):
h(text, color)
parts.append("<br>")
def heading(text):
hr = f'<hr style="border:1px solid {_C["border"]}; margin:6px 0;">'
parts.append(f'{hr}<b><span style="color:{_C["fg"]};">{text}</span></b><br>')
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'<br><b><span style="color:{_C["fg"]};">{name} ({ticker})</span></b><br>')
parts.append(f'<span style="color:{_C["fg2"]};">Sector: {sector}&nbsp;&nbsp;&nbsp;Composite Score: {score:.1f} / 100</span><br>')
parts.append(f'Verdict: <b><span style="color:{tier_c};">{tier}</span></b><br><br>')
else:
parts.append(f'<br><b><span style="color:{_C["fg"]};">{name} ({ticker}) — {sector}</span></b><br>')
hl("Insufficient data for full evaluation.", _C["warning"])
parts.append("<br>")
if desc:
parts.append(f'<span style="color:{_C["fg2"]}; font-size:11px;">{_html.escape(desc)}</span><br><br>')
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("<br>")
if strengths:
for txt in strengths:
parts.append(f'<span style="color:{_C["positive"]};"> &#10004; {_html.escape(txt)}</span><br>')
else:
parts.append(f'<span style="color:{_C["warning"]};"> No strong positive catalysts identified at current levels.</span><br>')
parts.append("<br>")
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("<br>")
if risks:
for txt in risks:
parts.append(f'<span style="color:{_C["negative"]};"> &#10008; {_html.escape(txt)}</span><br>')
else:
parts.append(f'<span style="color:{_C["positive"]};"> No major risk flags identified at this time.</span><br>')
parts.append("<br>")
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("<br>")
if not _nan(div) and div > 0:
parts.append(f'<span style="color:{_C["positive"]};"> &bull; Dividend yield of {div*100:.2f}% provides income support.</span><br>')
if not _nan(curr) and curr >= 2.0:
parts.append(f'<span style="color:{_C["positive"]};"> &bull; Current ratio of {curr:.1f}x indicates strong short-term liquidity.</span><br>')
elif not _nan(curr) and curr < 1.0:
parts.append(f'<span style="color:{_C["negative"]};"> &bull; Current ratio of {curr:.1f}x raises near-term liquidity concerns.</span><br>')
if not _nan(fcf) and fcf > 0.03:
parts.append(f'<span style="color:{_C["positive"]};"> &bull; FCF yield of {fcf*100:.1f}% suggests meaningful free cash flow generation.</span><br>')
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'<span style="color:{_C["fg"]};"> &bull; Current price is {abs(pct_from_high)*100:.1f}% below 52-week high and {pct_from_low*100:.1f}% above 52-week low.</span><br>')
if not _nan(beta) and 0.7 <= beta <= 1.3:
parts.append(f'<span style="color:{_C["fg"]};"> &bull; Beta of {beta:.2f} closely tracks the broader market — suitable for balanced portfolios.</span><br>')
parts.append("<br>")
# News headlines
headlines = row.get("news_headlines") or []
if headlines:
heading("NEWS HIGHLIGHTS")
parts.append("<br>")
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'<span style="color:{h_color}; font-weight:600;"> [{h_label}] </span>')
if url:
parts.append(f'<a href="{url}" style="color:{_C["fg"]};">{_html.escape(title)}</a><br>')
else:
parts.append(f'<span style="color:{_C["fg"]};">{_html.escape(title)}</span><br>')
parts.append(f'<span style="color:{_C["fg2"]}; font-size:11px;"> {age_str} &middot; {_html.escape(recap)}</span><br><br>')
parts.append("<br>")
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("<br>")
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 = "1236 months (fundamental thesis)", "Strong valuation discount and balance sheet quality support a longer holding period."
elif hi_mom: timeframe, tf_reason = "312 months (momentum-driven)", "Price trend is strong — re-evaluate if momentum fades."
else: timeframe, tf_reason = "618 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 = "1224 months (value unlock)", "Undervalued on fundamentals — patience required for market to re-rate."
elif hi_mom and not hi_val: timeframe, tf_reason = "16 months (trend play)", "Momentum-driven opportunity. Set a stop-loss and monitor price action."
elif hi_growth: timeframe, tf_reason = "618 months (growth compounding)", "Strong growth trajectory. Suitable while earnings acceleration continues."
else: timeframe, tf_reason = "612 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 13 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: <b><span style="color:{rec_c};">{rec_label}</span></b><br>')
parts.append(f'<span style="color:{_C["fg"]};"> Timeframe: {timeframe}</span><br><br>')
parts.append(f'<span style="color:{_C["warning"]};"> Reasoning: {_html.escape(tf_reason)}</span><br><br>')
parts.append(f'<span style="color:{_C["fg"]};"> Key metrics driving this view:</span><br>')
if not _nan(sv):
extra = f" — P/E {pe:.1f}x" if not _nan(pe) and pe > 0 else ""
parts.append(f'<span style="color:{_C["fg"]};"> &bull; Value score {sv:.0f}/100{extra}</span><br>')
if not _nan(sg):
extra = f" — Rev growth {rev_g*100:+.1f}%" if not _nan(rev_g) else ""
parts.append(f'<span style="color:{_C["fg"]};"> &bull; Growth score {sg:.0f}/100{extra}</span><br>')
if not _nan(sm):
extra = f" — 6M return {r6*100:+.1f}%" if not _nan(r6) else ""
parts.append(f'<span style="color:{_C["fg"]};"> &bull; Momentum score {sm:.0f}/100{extra}</span><br>')
if not _nan(sa):
extra = f"{upside*100:.1f}% upside to target" if not _nan(upside) and upside > 0 else ""
parts.append(f'<span style="color:{_C["fg"]};"> &bull; Analyst score {sa:.0f}/100{extra}</span><br>')
else:
parts.append(f'<span style="color:{_C["warning"]};"> Insufficient data to generate a recommendation.</span><br>')
parts.append("<br>")
parts.append(f'<hr style="border:1px solid {_C["border"]}; margin:6px 0;">')
parts.append(
f'<span style="color:{_C["fg2"]}; font-size:10px;">'
"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."
"</span>"
)
return '<html><body style="font-family:Consolas,monospace; font-size:12px; color:#ffffff; margin:10px;">' + "".join(parts) + "</body></html>"
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
self._progress.setValue(100)
self._status_lbl.setText(f" Done — {total} stocks scored.")
self._apply_filters()
self._lastrun_lbl.setText("Last run: " + datetime.now().strftime("%H:%M:%S"))
self._run_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
# Share with app
if hasattr(self._app, '_df'):
self._app._df = df_scored
self._app._df_raw = df_raw
self._app._sector_stats = sector_stats
@Slot(str)
def _on_error(self, text):
self._status_lbl.setText(" Error: " + text)
self._run_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
self._progress.hide()
QMessageBox.critical(self, "Screen Error", f"The screening run failed:\n\n{text}")
@Slot()
def _on_stopped(self):
self._status_lbl.setText(" Stopped.")
self._run_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
self._progress.hide()
def _apply_filters(self):
if self._df is None: return
try:
self._apply_filters_inner()
except Exception as exc:
import traceback
QMessageBox.critical(self, "Display Error",
f"Failed to display results:\n\n{traceback.format_exc()}")
def _apply_filters_inner(self):
df = self._df
rule = _SECTOR_FILTER_MAP.get(self._sector_cb.currentText())
if rule:
col, val = rule
if col in df.columns: df = df[df[col] == val]
state = self._filters_dlg.get_state() if self._filters_dlg else {}
search_q = state.get("search_q", "").strip()
if search_q:
q = search_q.lower()
mask = (df["ticker"].str.lower().str.contains(q, na=False, regex=False) |
df["name"].str.lower().str.contains(q, na=False, regex=False))
df = df[mask]
def _f(s):
try: return float(s.replace("$","").replace(",","").strip())
except: return None
for col2 in ("price","market_cap","composite_score","ret_1m","ret_3m","ret_6m","ret_12m","analyst_upside"):
if col2 in df.columns:
df = df.copy()
df[col2] = _pd.to_numeric(df[col2], errors="coerce")
pmin = _f(state.get("price_min",""))
pmax = _f(state.get("price_max",""))
if pmin is not None and "price" in df.columns: df = df[df["price"].notna() & (df["price"] >= pmin)]
if pmax is not None and "price" in df.columns: df = df[df["price"].notna() & (df["price"] <= pmax)]
mktcap = state.get("mktcap","Any")
if mktcap != "Any" and "market_cap" in df.columns:
if mktcap.startswith("Mega"): df = df[df["market_cap"].fillna(0) >= 100e9]
elif mktcap.startswith("Large"): df = df[(df["market_cap"].fillna(0) >= 10e9) & (df["market_cap"].fillna(0) < 100e9)]
elif mktcap.startswith("Mid"): df = df[(df["market_cap"].fillna(0) >= 2e9) & (df["market_cap"].fillna(0) < 10e9)]
elif mktcap.startswith("Small"): df = df[df["market_cap"].fillna(0) < 2e9]
smin = _f(state.get("score_min",""))
if smin is not None and "composite_score" in df.columns:
df = df[df["composite_score"].fillna(0) >= smin]
ret_period = state.get("ret_period","Any")
ret_min = _f(state.get("ret_min",""))
_rcol = {"1M":"ret_1m","3M":"ret_3m","6M":"ret_6m","12M":"ret_12m"}
if ret_period != "Any" and ret_min is not None:
col2 = _rcol.get(ret_period)
if col2 and col2 in df.columns:
df = df[df[col2].fillna(-999) >= ret_min / 100.0]
umin = _f(state.get("upside_min",""))
if umin is not None and "analyst_upside" in df.columns:
df = df[df["analyst_upside"].fillna(-999) >= umin / 100.0]
sort_by = state.get("sort_by","Score ↓")
if sort_by == "Price ↓" and "price" in df.columns:
df = df.sort_values("price", ascending=False, na_position="last")
elif sort_by == "Price ↑" and "price" in df.columns:
df = df.sort_values("price", ascending=True, na_position="last")
elif sort_by == "Upside ↓" and "analyst_upside" in df.columns:
df = df.sort_values("analyst_upside", ascending=False, na_position="last")
self._table.populate(df)
n = len(df)
self._status_lbl.setText(f" Showing {n} stocks. Click a ticker to see details.")
# Refresh selected ticker if still present
if self._selected_ticker and self._df is not None:
match = self._df[self._df["ticker"] == self._selected_ticker]
if not match.empty:
self._detail.update(match.iloc[0])
def _open_filters(self):
if self._filters_dlg is None:
self._filters_dlg = FiltersDialog(self)
self._filters_dlg.filters_changed.connect(self._apply_filters)
self._filters_dlg.show()
self._filters_dlg.raise_()
def _on_strategy_change(self, strategy):
if strategy == "Custom...":
self._open_custom_strategy_dialog()
return
if self._df_raw is not None and not self._rescoring:
self._rescoring = True
self._rescore_id += 1
current_id = self._rescore_id
weights = STRATEGY_PRESETS.get(strategy)
self._status_lbl.setText(f" Rescoring with strategy: {strategy}")
def _rescore():
try:
result = score_stocks(self._df_raw, weights=weights, sector_stats=self._sector_stats)
except Exception:
self._rescoring = False
return
def _done():
self._rescoring = False
if self._rescore_id != current_id: return
self._df = result
if hasattr(self._app, '_df'): self._app._df = result
self._apply_filters()
self._status_lbl.setText(f" Strategy: {strategy} — rescored {len(result)} stocks.")
QMetaObject.invokeMethod(self, "_apply_filters", Qt.QueuedConnection)
threading.Thread(target=_rescore, daemon=True).start()
def _open_custom_strategy_dialog(self):
dlg = QDialog(self)
dlg.setWindowTitle("Custom Strategy Weights")
dlg.setModal(True)
lay = QVBoxLayout(dlg)
lbl = QLabel("Set factor weights (must sum to 100%)")
lbl.setStyleSheet(f"color:{_C['fg']}; font-weight:700;")
lay.addWidget(lbl)
FACTORS = [
("value","Value"),("growth","Growth"),("momentum","Momentum"),
("quality","Quality"),("profitability","Profitability"),
("sentiment","Sentiment"),("analyst","Analyst"),("risk","Risk"),
]
strategy = self._strategy_cb.currentText()
base = self._custom_weights or STRATEGY_PRESETS.get(strategy, WEIGHTS)
entries = {}
grid = QGridLayout()
for i, (key, lbl_txt) in enumerate(FACTORS):
grid.addWidget(QLabel(lbl_txt), i, 0)
e = QLineEdit(f"{base[key]*100:.1f}")
e.setFixedWidth(80)
entries[key] = e
grid.addWidget(e, i, 1)
grid.addWidget(QLabel("%"), i, 2)
lay.addLayout(grid)
sum_lbl = QLabel("Sum: 100.0%")
sum_lbl.setStyleSheet(f"color:{_C['positive']};")
lay.addWidget(sum_lbl)
def _update_sum():
total = 0.0
for e in entries.values():
try: total += float(e.text())
except: pass
c = _C["positive"] if abs(total - 100) < 0.5 else _C["negative"]
sum_lbl.setText(f"Sum: {total:.1f}%")
sum_lbl.setStyleSheet(f"color:{c};")
for e in entries.values(): e.textChanged.connect(_update_sum)
btn_row = QWidget(); brl = QHBoxLayout(btn_row)
apply_btn = QPushButton("Apply"); cancel_btn = QPushButton("Cancel")
brl.addWidget(apply_btn); brl.addWidget(cancel_btn)
lay.addWidget(btn_row)
def _apply():
w = {}
for key, e in entries.items():
try: w[key] = float(e.text()) / 100.0
except: return
total = sum(w.values())
if total <= 0: return
w = {k: v / total for k, v in w.items()}
self._custom_weights = w
dlg.accept()
if self._df_raw is not None:
self._df = score_stocks(self._df_raw, weights=w, sector_stats=self._sector_stats)
if hasattr(self._app, '_df'): self._app._df = self._df
self._apply_filters()
def _cancel():
if not self._custom_weights:
self._strategy_cb.blockSignals(True)
self._strategy_cb.setCurrentText("Balanced (Default)")
self._strategy_cb.blockSignals(False)
dlg.reject()
apply_btn.clicked.connect(_apply)
cancel_btn.clicked.connect(_cancel)
dlg.exec()
# ---------------------------------------------------------------------------
# SearchPage
# ---------------------------------------------------------------------------
class SearchPage(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._df = None
self._df_raw = None
self._sector_stats = {}
self._get_weights = lambda: None
self._last_row = None
self._results_shown = False
self._build()
def set_data(self, df, df_raw, sector_stats, get_weights):
self._df = df
self._df_raw = df_raw
self._sector_stats = sector_stats
self._get_weights = get_weights or (lambda: None)
def _build(self):
self._main_lay = QStackedLayout(self)
# Hero page
hero_w = QWidget()
hl = QVBoxLayout(hero_w)
hl.addStretch()
center = QWidget()
cl = QVBoxLayout(center)
cl.setSpacing(12)
cl.setAlignment(Qt.AlignCenter)
title = QLabel(_APP_NAME.upper())
title.setStyleSheet(f"color:{_C['fg']}; font-size:24px; font-weight:700;")
title.setAlignment(Qt.AlignCenter)
cl.addWidget(title)
self._search_edit = QLineEdit()
self._search_edit.setPlaceholderText("Ticker or company name…")
self._search_edit.setFixedWidth(400)
self._search_edit.returnPressed.connect(self._do_search)
cl.addWidget(self._search_edit, alignment=Qt.AlignCenter)
search_btn = QPushButton("Search Stock")
search_btn.setObjectName("run-btn")
search_btn.setFixedWidth(160)
search_btn.clicked.connect(self._do_search)
cl.addWidget(search_btn, alignment=Qt.AlignCenter)
self._search_status = QLabel("")
self._search_status.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;")
self._search_status.setAlignment(Qt.AlignCenter)
cl.addWidget(self._search_status)
hl.addWidget(center)
hl.addStretch()
self._main_lay.addWidget(hero_w)
# Results page
results_w = QWidget()
rl = QVBoxLayout(results_w)
rl.setContentsMargins(0, 0, 0, 0)
rl.setSpacing(0)
hdr = QWidget()
hdr_lay = QHBoxLayout(hdr)
hdr_lay.setContentsMargins(12, 6, 12, 6)
self._status2 = QLabel("")
self._status2.setStyleSheet(f"color:{_C['fg2']}; font-size:12px;")
hdr_lay.addWidget(self._status2)
hdr_lay.addStretch()
back_btn = QPushButton("← Back")
back_btn.clicked.connect(self._go_back)
hdr_lay.addWidget(back_btn)
rl.addWidget(hdr)
self._detail = DetailPanel()
rl.addWidget(self._detail, 1)
self._main_lay.addWidget(results_w)
def _go_back(self):
self._main_lay.setCurrentIndex(0)
self._results_shown = False
self._search_edit.clear()
self._search_status.setText("")
def _do_search(self):
query = self._search_edit.text().strip().lstrip("$")
if not query: return
self._search_status.setText("Searching…")
threading.Thread(target=self._search_worker, args=(query,), daemon=True).start()
def _search_worker(self, query):
row = self._find_in_df(query)
if row is not None:
import pandas as pd
if isinstance(row, pd.Series): row = row.to_dict()
QMetaObject.invokeMethod(self, "_show_result", Qt.QueuedConnection,
Q_ARG(object, row), Q_ARG(str, f"Found in screener data: {row['ticker']}"))
return
QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection, Q_ARG(str, "Fetching data…"))
fetch_result = [None]
def _do():
fetch_result[0] = self._fetch_live(query)
t = threading.Thread(target=_do, daemon=True)
t.start(); t.join(timeout=20)
if t.is_alive():
QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection,
Q_ARG(str, "Search timed out. Check your connection and try again."))
return
live_row = fetch_result[0]
if live_row is None:
QMetaObject.invokeMethod(self._search_status, "setText", Qt.QueuedConnection,
Q_ARG(str, f"No results for '{query}'. Try the exact ticker symbol."))
return
# Score it
try:
import pandas as pd
_w = self._get_weights()
scored = score_stocks(pd.DataFrame([live_row]), weights=_w, sector_stats=self._sector_stats)
if not scored.empty:
s = scored.iloc[0]
for col in ("composite_score","score_value","score_growth","score_momentum",
"score_quality","score_profitability","score_sentiment","score_analyst","score_risk"):
if col in s: live_row[col] = s[col]
except Exception: pass
QMetaObject.invokeMethod(self, "_show_result", Qt.QueuedConnection,
Q_ARG(object, live_row), Q_ARG(str, f"Live data: {live_row['ticker']}"))
def _find_in_df(self, query):
if self._df is None or self._df.empty: return None
q = query.upper().strip().lstrip("$")
mask = self._df["ticker"].str.upper() == q
if mask.any(): return self._df[mask].iloc[0]
if "name" in self._df.columns:
mask2 = self._df["name"].str.upper().str.contains(q, regex=False, na=False)
if mask2.any(): return self._df[mask2].iloc[0]
return None
def _fetch_live(self, query):
if not _YF_OK: return None
import pandas as pd
ticker = query.upper().strip().lstrip("$")
try:
stock = yf.Ticker(ticker)
info = stock.info
if not info or not isinstance(info, dict) or len(info) < 3:
try:
results = yf.Search(query, max_results=1)
quotes = getattr(results, "quotes", [])
if quotes:
ticker = quotes[0].get("symbol", ticker)
stock = yf.Ticker(ticker)
info = stock.info
except: pass
if not info or not isinstance(info, dict) or len(info) < 3: return None
def _g(k, default=None):
v = info.get(k, default)
return v if v not in (None, "N/A", "", "None") else default
hist = stock.history(period="1y", auto_adjust=True)
close = hist["Close"].dropna() if not hist.empty else pd.Series(dtype=float)
price = close.iloc[-1] if not close.empty else _g("regularMarketPrice")
ret_1m = (close.iloc[-1]/close.iloc[-22]-1) if len(close) >= 22 else None
ret_3m = (close.iloc[-1]/close.iloc[-66]-1) if len(close) >= 66 else None
ret_6m = (close.iloc[-1]/close.iloc[-130]-1) if len(close) >= 130 else None
ret_12m= (close.iloc[-1]/close.iloc[0]-1) if len(close) >= 2 else None
_company_name = _g("longName") or _g("shortName") or ""
try:
_news_agg, _news_headlines = get_news_headlines(stock, ticker=ticker, company_name=_company_name)
except:
_news_agg, _news_headlines = None, []
return {
"ticker": ticker,
"name": _g("longName") or _g("shortName") or ticker,
"sector": _g("sector","N/A"), "industry": _g("industry","N/A"),
"price": price, "market_cap": _g("marketCap"),
"pe_forward": _g("forwardPE"), "pe_trailing": _g("trailingPE"),
"pb_ratio": _g("priceToBook"), "book_value": _g("bookValue"),
"ev_ebitda": _g("enterpriseToEbitda"),
"52w_high": _g("fiftyTwoWeekHigh"), "52w_low": _g("fiftyTwoWeekLow"),
"revenue_growth": _g("revenueGrowth"), "earnings_growth": _g("earningsGrowth"),
"eps_forward": _g("forwardEps"), "eps_trailing": _g("trailingEps"),
"ret_1m": ret_1m, "ret_3m": ret_3m, "ret_6m": ret_6m, "ret_12m": ret_12m,
"roe": _g("returnOnEquity"), "roa": _g("returnOnAssets"),
"total_debt": _g("totalDebt"), "debt_to_equity": _g("debtToEquity"),
"total_cash": _g("totalCash"), "current_ratio": _g("currentRatio"),
"revenue": _g("totalRevenue"), "profit_margin": _g("profitMargins"),
"operating_margin": _g("operatingMargins"),
"fcf_yield": (float(_g("freeCashflow"))/float(_g("marketCap"))
if _g("freeCashflow") and _g("marketCap") and float(_g("marketCap")) > 0 else None),
"dividend_yield": _g("dividendYield"),
"recommendation": _g("recommendationKey"),
"analyst_count": _g("numberOfAnalystOpinions"),
"analyst_target": _g("targetMeanPrice"),
"analyst_upside": ((_g("targetMeanPrice") or 0) / price - 1
if price and price > 0 and _g("targetMeanPrice") else None),
"analyst_norm": ((5.0 - float(info["recommendationMean"])) / 4.0
if info.get("recommendationMean") is not None
and 1 <= float(info["recommendationMean"]) <= 5 else None),
"news_sentiment": _news_agg, "news_headlines": _news_headlines,
"short_percent": _g("shortPercentOfFloat"), "beta": _g("beta"),
"volatility_30d": (float(close.iloc[-30:].pct_change().dropna().std() * (252**0.5))
if len(close) >= 35 else None),
"composite_score": None, "score_value": None, "score_growth": None,
"score_momentum": None, "score_quality": None, "score_profitability": None,
"score_sentiment": None, "score_analyst": None, "score_risk": None,
"business_summary": _g("longBusinessSummary","") or "",
}
except: return None
@Slot(object, str)
def _show_result(self, row, status_msg):
self._last_row = row
self._search_status.setText(status_msg)
self._status2.setText(status_msg)
self._detail.update(row)
self._main_lay.setCurrentIndex(1)
self._results_shown = True
# ---------------------------------------------------------------------------
# InsiderTable
# ---------------------------------------------------------------------------
class InsiderTable(QWidget):
_COLS = [
("Date",70,"center"),("Ticker",65,"center"),("Company",175,"left"),
("Insider",155,"left"),("Title",125,"left"),("Type",90,"center"),
("Shares",90,"right"),("Price",70,"right"),("Value",90,"right"),("After",95,"right"),
]
_PERIODS = [("Today",1),("3D",3),("1W",7),("2W",14)]
_TYPES = [("All","all"),("Buys","buy"),("Sales","sale")]
def __init__(self, parent=None):
super().__init__(parent)
self._all_trades = []
self._iid_to_url = {}
self._loading = False
self._has_loaded = False
self._days_back = 3
self._type_filter = "all"
self._build()
def _build(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
# Controls
ctrl = QWidget(); ctrl.setFixedHeight(44)
cl = QHBoxLayout(ctrl); cl.setContentsMargins(12,6,12,6); cl.setSpacing(8)
title = QLabel("Insider Transactions")
title.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700;")
cl.addWidget(title)
self._period_btns = {}
for lbl, days in self._PERIODS:
btn = QPushButton(lbl)
btn.setCheckable(True); btn.setChecked(days == self._days_back)
btn.clicked.connect(lambda checked, d=days: self._set_period(d))
cl.addWidget(btn)
self._period_btns[days] = btn
cl.addWidget(self._vsep())
self._type_btns = {}
for lbl, val in self._TYPES:
btn = QPushButton(lbl)
btn.setCheckable(True); btn.setChecked(val == "all")
btn.clicked.connect(lambda checked, v=val: self._set_type(v))
cl.addWidget(btn)
self._type_btns[val] = btn
cl.addWidget(self._vsep())
self._refresh_btn = QPushButton("↻ Refresh")
self._refresh_btn.setObjectName("run-btn")
self._refresh_btn.clicked.connect(self._refresh)
cl.addWidget(self._refresh_btn)
self._status_lbl = QLabel("Click ↻ Refresh to load insider trading data.")
self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;")
cl.addWidget(self._status_lbl)
cl.addStretch()
lay.addWidget(ctrl)
sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};")
lay.addWidget(sep)
self._table = QTableWidget(0, len(self._COLS))
self._table.setHorizontalHeaderLabels([c[0] for c in self._COLS])
self._table.verticalHeader().setVisible(False)
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self._table.setShowGrid(False)
self._table.setAlternatingRowColors(False)
self._table.horizontalHeader().setStretchLastSection(False)
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
for i, (_, w, _a) in enumerate(self._COLS):
self._table.setColumnWidth(i, w)
self._table.clicked.connect(self._on_row_click)
lay.addWidget(self._table, 1)
def _vsep(self):
f = QFrame(); f.setFixedSize(1, 20); f.setStyleSheet(f"background:{_C['border']};"); return f
def _set_period(self, days):
self._days_back = days
for d, btn in self._period_btns.items(): btn.setChecked(d == days)
self._refresh()
def _set_type(self, val):
self._type_filter = val
for v, btn in self._type_btns.items(): btn.setChecked(v == val)
self._apply_filter()
def on_show(self):
if not self._has_loaded: self._refresh()
def _refresh(self):
if self._loading: return
self._loading = True
self._refresh_btn.setEnabled(False)
self._status_lbl.setText("Fetching from SEC EDGAR...")
days = self._days_back
def _progress(done, total):
QMetaObject.invokeMethod(self._status_lbl, "setText", Qt.QueuedConnection,
Q_ARG(str, f"Loading... ({done}/{total} filings)"))
def _worker():
try: trades = fetch_insider_trades(days_back=days, progress_cb=_progress)
except: trades = []
QMetaObject.invokeMethod(self, "_on_fetched", Qt.QueuedConnection, Q_ARG(object, trades))
threading.Thread(target=_worker, daemon=True).start()
@Slot(object)
def _on_fetched(self, trades):
self._loading = False; self._has_loaded = True
self._all_trades = trades
self._refresh_btn.setEnabled(True)
self._apply_filter()
n = len(trades)
self._status_lbl.setText(f"{n} transaction{'s' if n!=1 else ''} loaded." if n else "No transactions found.")
def _apply_filter(self):
trades = self._all_trades or []
if self._type_filter == "buy": trades = [t for t in trades if t.get("transaction_code") == "P"]
elif self._type_filter == "sale": trades = [t for t in trades if t.get("transaction_code") == "S"]
self._populate(trades)
def _populate(self, trades):
self._table.setRowCount(0)
self._iid_to_url.clear()
for i, tx in enumerate(trades):
tc = tx.get("transaction_code","")
date_str = tx.get("tx_date") or tx.get("filed_date") or ""
try:
y, m, d = date_str[:10].split("-")
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
except: pass
color = {"P": _C["positive"], "S": _C["negative"], "A": _C["warning"]}.get(tc, _C["fg2"])
vals = [
date_str, tx.get("ticker",""), (tx.get("company") or "")[:26],
(tx.get("insider") or "")[:22], (tx.get("title") or "")[:18],
tx.get("transaction_type",""), _fmt_shares(tx.get("shares")),
_price(tx.get("price")) if tx.get("price") else "",
_fmt_value(tx.get("value")), _fmt_shares(tx.get("owned_after")),
]
self._table.insertRow(i)
for j, v in enumerate(vals):
item = QTableWidgetItem(str(v))
item.setForeground(QColor(color))
bg = QColor("#181818") if i % 2 else QColor("#0a0a0a")
item.setBackground(bg)
self._table.setItem(i, j, item)
self._iid_to_url[i] = tx.get("url","")
self._table.setRowHeight(0, 28)
for r in range(self._table.rowCount()):
self._table.setRowHeight(r, 28)
def _on_row_click(self, index):
url = self._iid_to_url.get(index.row(),"")
if url: webbrowser.open(url)
# ---------------------------------------------------------------------------
# HedgeFundTable
# ---------------------------------------------------------------------------
class HedgeFundTable(QWidget):
_COLS = [
("Filed",82,"center"),("Fund",195,"left"),("Company",185,"left"),
("Shares",90,"right"),("Value",90,"right"),("Class",60,"center"),("Type",45,"center"),
]
_PERIODS = [("30D",30),("60D",60),("90D",90),("180D",180)]
_MIN_VALS = [("All",0),("$1M+",1_000_000),("$10M+",10_000_000),("$100M+",100_000_000)]
def __init__(self, parent=None):
super().__init__(parent)
self._all_holdings = []
self._iid_to_url = {}
self._loading = False
self._has_loaded = False
self._days_back = 90
self._min_val = 1_000_000
self._build()
def _build(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
ctrl = QWidget(); ctrl.setFixedHeight(44)
cl = QHBoxLayout(ctrl); cl.setContentsMargins(12,6,12,6); cl.setSpacing(8)
title = QLabel("Hedge Fund Holdings (13F-HR)")
title.setStyleSheet(f"color:{_C['fg']}; font-size:13px; font-weight:700;")
cl.addWidget(title)
self._period_btns = {}
for lbl, days in self._PERIODS:
btn = QPushButton(lbl); btn.setCheckable(True); btn.setChecked(days == self._days_back)
btn.clicked.connect(lambda checked, d=days: self._set_period(d))
cl.addWidget(btn); self._period_btns[days] = btn
cl.addWidget(self._vsep())
self._minval_btns = {}
for lbl, val in self._MIN_VALS:
btn = QPushButton(lbl); btn.setCheckable(True); btn.setChecked(val == self._min_val)
btn.clicked.connect(lambda checked, v=val: self._set_min_val(v))
cl.addWidget(btn); self._minval_btns[val] = btn
cl.addWidget(self._vsep())
self._refresh_btn = QPushButton("↻ Refresh")
self._refresh_btn.setObjectName("run-btn")
self._refresh_btn.clicked.connect(self._refresh)
cl.addWidget(self._refresh_btn)
self._status_lbl = QLabel("Click ↻ Refresh to load hedge fund holdings.")
self._status_lbl.setStyleSheet(f"color:{_C['fg2']}; font-size:11px;")
cl.addWidget(self._status_lbl)
cl.addStretch()
lay.addWidget(ctrl)
sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};")
lay.addWidget(sep)
self._table = QTableWidget(0, len(self._COLS))
self._table.setHorizontalHeaderLabels([c[0] for c in self._COLS])
self._table.verticalHeader().setVisible(False)
self._table.setSelectionBehavior(QAbstractItemView.SelectRows)
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self._table.setShowGrid(False)
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
for i, (_, w, _a) in enumerate(self._COLS):
self._table.setColumnWidth(i, w)
self._table.clicked.connect(self._on_row_click)
lay.addWidget(self._table, 1)
def _vsep(self):
f = QFrame(); f.setFixedSize(1, 20); f.setStyleSheet(f"background:{_C['border']};"); return f
def _set_period(self, days):
self._days_back = days
for d, btn in self._period_btns.items(): btn.setChecked(d == days)
self._refresh()
def _set_min_val(self, val):
self._min_val = val
for v, btn in self._minval_btns.items(): btn.setChecked(v == val)
self._apply_filter()
def on_show(self):
if not self._has_loaded: self._refresh()
def _refresh(self):
if self._loading: return
self._loading = True; self._refresh_btn.setEnabled(False)
self._status_lbl.setText("Fetching from SEC EDGAR...")
days = self._days_back
def _progress(done, total):
QMetaObject.invokeMethod(self._status_lbl, "setText", Qt.QueuedConnection,
Q_ARG(str, f"Loading... ({done}/{total} funds)"))
def _worker():
try: holdings = fetch_hedge_fund_filings(days_back=days, progress_cb=_progress)
except: holdings = []
QMetaObject.invokeMethod(self, "_on_fetched", Qt.QueuedConnection, Q_ARG(object, holdings))
threading.Thread(target=_worker, daemon=True).start()
@Slot(object)
def _on_fetched(self, holdings):
self._loading = False; self._has_loaded = True
self._all_holdings = holdings; self._refresh_btn.setEnabled(True)
self._apply_filter()
n = len(holdings)
self._status_lbl.setText(f"{n} holding{'s' if n!=1 else ''} loaded." if n else "No holdings found.")
def _apply_filter(self):
rows = [h for h in self._all_holdings if (h.get("value") or 0) >= self._min_val] if self._min_val > 0 else list(self._all_holdings)
self._populate(rows)
def _populate(self, holdings):
self._table.setRowCount(0)
self._iid_to_url.clear()
for i, h in enumerate(holdings):
date_str = h.get("filed_date","")
try:
y, m, d = date_str[:10].split("-")
date_str = f"{_cal.month_abbr[int(m)]} {int(d)}"
except: pass
opt = (h.get("put_call") or "").strip()
color = _C["warning"] if opt else _C["fg"]
vals = [
date_str, (h.get("fund_name") or "")[:28], (h.get("company") or "")[:26],
_fmt_shares(h.get("shares")), _fmt_value(h.get("value")),
(h.get("class_") or "")[:8], opt[:4] if opt else "",
]
self._table.insertRow(i)
for j, v in enumerate(vals):
item = QTableWidgetItem(str(v))
item.setForeground(QColor(color))
item.setBackground(QColor("#181818") if i % 2 else QColor("#0a0a0a"))
self._table.setItem(i, j, item)
self._iid_to_url[i] = h.get("url","")
for r in range(self._table.rowCount()):
self._table.setRowHeight(r, 28)
def _on_row_click(self, index):
url = self._iid_to_url.get(index.row(),"")
if url: webbrowser.open(url)
# ---------------------------------------------------------------------------
# TrackingPage
# ---------------------------------------------------------------------------
class TrackingPage(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._active_sub = "insider"
self._build()
def _build(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
# Sub-nav
subnav = QWidget(); subnav.setFixedHeight(44)
sl = QHBoxLayout(subnav); sl.setContentsMargins(12,8,12,8); sl.setSpacing(8)
self._insider_btn = QPushButton("Insider Trades")
self._insider_btn.setCheckable(True); self._insider_btn.setChecked(True)
self._insider_btn.clicked.connect(lambda: self._switch("insider"))
self._hf_btn = QPushButton("Hedge Funds")
self._hf_btn.setCheckable(True)
self._hf_btn.clicked.connect(lambda: self._switch("hf"))
sl.addWidget(self._insider_btn); sl.addWidget(self._hf_btn); sl.addStretch()
lay.addWidget(subnav)
sep = QFrame(); sep.setFixedHeight(1); sep.setStyleSheet(f"background:{_C['border']};")
lay.addWidget(sep)
self._stack = QStackedWidget()
self._insider = InsiderTable()
self._hf = HedgeFundTable()
self._stack.addWidget(self._insider)
self._stack.addWidget(self._hf)
lay.addWidget(self._stack, 1)
def _switch(self, which):
self._active_sub = which
self._insider_btn.setChecked(which == "insider")
self._hf_btn.setChecked(which == "hf")
self._stack.setCurrentIndex(0 if which == "insider" else 1)
self.on_show()
def on_show(self):
if self._active_sub == "hf": self._hf.on_show()
else: self._insider.on_show()
# ---------------------------------------------------------------------------
# ScreenerApp
# ---------------------------------------------------------------------------
class ScreenerApp(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"{_APP_NAME} v{_APP_VERSION}{_channel_label}")
self.setMinimumSize(900, 560)
self._df = None
self._df_raw = None
self._sector_stats = {}
self._build_ui()
self.showMaximized()
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._sidebar = Sidebar()
self._sidebar.page_changed.connect(self._switch_page)
layout.addWidget(self._sidebar)
self._stack = QStackedWidget()
layout.addWidget(self._stack, 1)
self._screener_page = ScreenerPage(self)
self._search_page = SearchPage(self)
self._tracking_page = TrackingPage(self)
self._stack.addWidget(self._screener_page)
self._stack.addWidget(self._search_page)
self._stack.addWidget(self._tracking_page)
def _switch_page(self, idx):
self._stack.setCurrentIndex(idx)
if idx == 1:
self._search_page.set_data(
self._df, self._df_raw, self._sector_stats,
self._screener_page.get_current_weights
)
elif idx == 2:
self._tracking_page.on_show()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
multiprocessing.freeze_support()
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
pass
app = QApplication(sys.argv)
app.setStyleSheet(_QSS)
win = ScreenerApp()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()